Skip to content

Latest commit

 

History

History
318 lines (235 loc) · 18 KB

File metadata and controls

318 lines (235 loc) · 18 KB

wire protocol — v0.1 (extended through v0.13.5)

This document defines the wire format that two wire implementations must agree on to interoperate. The Rust implementation in this repository is the reference; this document is normative.

Audit status: This document was originally written for wire v0.1. The core message-signing rules (§§ 2, 3) and the agent-card crypto layer (§ 1) remain accurate through v0.13.5. Sections later in the doc note where the v0.13 reality has moved past the original v0.1 scoping (federation, handle directories, group rooms, RFC-001 identity layer). For the formal A2A interoperability surface see a2a-extension/wire-identity-v1.md; for the operator/org identity layer see RFC-001.

Conventions

  • All multi-byte integers are big-endian.
  • All hashes are SHA-256.
  • All signatures are Ed25519 (RFC 8032).
  • All base64 is RFC 4648 standard alphabet, no line wrapping, with padding.
  • All hex is lowercase.
  • Times are RFC 3339 / ISO 8601 with Z suffix (UTC).
  • "MUST" / "SHOULD" / "MAY" follow RFC 2119 semantics.

1. Identity — DID + agent-card

Every wire endpoint has a stable identity backed by an Ed25519 keypair. The DID is did:wire:<handle> where <handle> is [A-Za-z0-9_-]+.

The signed agent-card binds the DID to one or more public keys plus capabilities and policies. v0.1 schema (schema_version: "v3.1"):

{
  "schema_version": "v3.1",
  "did": "did:wire:paul",
  "name": "Paul",
  "capabilities": ["wire/v3.1"],
  "verify_keys": {
    "ed25519:paul:b2e5aae7": {
      "key": "<base64-encoded 32-byte Ed25519 public key>",
      "alg": "ed25519",
      "active": true
    }
  },
  "policies": {"max_message_body_kb": 64},
  "signature": "<base64-encoded Ed25519 signature>"
}

v3.2 / RFC-001 additions (schema_version: "v3.2", default capability wire/v3.2):

v3.2 cards MAY additionally carry an operator-identity claim and any number of organisation-membership claims. These are optional; a v3.2 card without them is wire-compatible with v3.1 readers (the verify_agent_card routine does not inspect schema_version and verify_keys-only cards continue to verify).

{
  "schema_version": "v3.2",
  "did": "did:wire:paul-b2e5aae7",                              // v0.5.7+: pubkey-suffixed
  "op_did": "did:wire:op:alice-<32hex>",                        // RFC-001 §1
  "op_pubkey": "<base64 Ed25519 pubkey: operator>",             // inline — enables offline verify
  "op_cert": "<base64 Ed25519 sig: operator over session DID>", // verify with op_pubkey above
  "org_memberships": [{
    "org_did": "did:wire:org:acme-<32hex>",
    "org_pubkey": "<base64 Ed25519 pubkey: org>",               // inline — enables offline verify
    "member_cert": "<base64 Ed25519 sig: org over op_did>"      // verify with org_pubkey above
  }],
  ...
}

Cert primitives live in src/identity.rs (sign_did_cert / verify_op_cert / verify_member_cert). Both verifiers take the inline pubkey directly — the design is fully-offline self-certifying: no resolver lookup on the pairing hot path. The 16-byte (32-hex) fingerprint for operator and organisation DIDs is computed by agent_card::long_fingerprint. See the did:wire method spec for the full DID shape catalogue.

v0.13 DID format note: since v0.5.7 the per-session DID is pubkey-suffixed (did:wire:<handle>-<8hex>) to prevent handle collisions across distinct keypairs. v0.1 cards using the bare did:wire:<handle> form remain verifiable for backward compatibility but new claims always carry the suffix.

Key id format: <handle>:<fingerprint> where fingerprint = first 8 hex chars of SHA-256(public_key_bytes). Cards on disk prefix this with ed25519: to allow algorithm migration in v0.2+.

Signing rule: signature is Ed25519 over card_canonical(card) — see §3 below — with signature field absent. To verify, strip signature, recompute the canonical bytes, run Ed25519_Verify(verify_keys[*].key, canonical_bytes, signature).

Cards with empty or malformed verify_keys MUST be rejected.

Reserved: dh_pubkey (v0.15, RFC-006). An optional top-level card field carrying a base64 X25519 public key, distinct from the Ed25519 verify_keys (signing). It is reserved but unset in v0.15: declared now so per-pair message encryption (NIP-44 v2, RFC-006 §2.4) and the Nostr-DM path (RFC-007) can derive a shared secret additively in v0.2 without a card-schema break. v0.15 readers MUST tolerate its absence and ignore it when present. Identity stays anchored to the Ed25519 key; dh_pubkey is a key-agreement endpoint, never an identity anchor.

2. Events — kinds, structure, signing

Events are signed JSON objects. v0.1 mandatory fields:

{
  "timestamp": "2026-05-10T03:46:01Z",
  "from": "did:wire:paul",
  "to": "did:wire:willard",
  "type": "decision",
  "kind": 1,
  "body": "ship the v0.1 demo",
  "event_id": "<64-char hex SHA-256>",
  "public_key_id": "paul:b2e5aae7",
  "signature": "<base64 Ed25519 signature>"
}

2.1 Kind ranges (Nostr-compatible)

Range Class Semantics
1000–9999 regular persistent — relays SHOULD store indefinitely
10000–19999 replaceable latest-by-(from, kind) wins
20000–29999 ephemeral best-effort — relays MAY drop after delivery
30000–39999 addressable replaceable, with optional d tag for namespacing

Special-cased out-of-range kinds (Nostr / heartbeat compatibility):

kind name class
1 decision regular
100 heartbeat ephemeral

v0.1 ships these named kinds:

kind name description
1 decision Nostr-compat short message
100 heartbeat liveness ping
1000 decision wire-native decision
1001 claim assertion or proposal
1002 ack acknowledgement
1100 agent_card self-card or peer-card update
1101 trust_add_key add a verify key
1102 trust_revoke_key revoke a verify key
1200 wire_open bilateral wire establishment
1201 wire_close bilateral wire teardown

Kinds reserved for v0.2+: 1900 (file_share), 1901 (file_revoke), 10500 (registry_revocation). v0.1 implementations MUST NOT send or accept these. See ANTI_FEATURES.md.

2.2 event_id and signing

event_id = hex(SHA-256(canonical(event, strict=true))) — see §3 for canonical, where strict=true excludes event_id itself.

Signing: signature = base64(Ed25519_Sign(private_key, hex_decode(event_id))). The signature commits to the 32-byte raw event_id digest, which transitively commits to the canonical body. This is the Nostr NIP-01 sign-over-id pattern; it lets relays cite events by id without re-canonicalizing the body.

2.3 Verification rules

To verify a received event:

  1. Recompute event_id' from the body. Reject if event_id' != event_id.
  2. Resolve public_key_id to a public key in the trust state. Reject if unknown or marked active: false.
  3. Ed25519_Verify(public_key, hex_decode(event_id), base64_decode(signature)). Reject on failure.
  4. Reject if to is set and does not match the recipient's own DID.

The from field MAY be the bare handle (paul) or fully-qualified DID (did:wire:paul). Verifiers MUST accept both forms.

2.4 Reserved: enc body container (v0.15, RFC-006)

Status: reserved, not yet emitted. Declared in v0.15 so per-event encryption can land additively in v0.2 without a second wire-format break. v0.15 writers MUST NOT emit it; v0.15 readers MUST tolerate its absence (the default) and ignore it when present (treat the body as opaque), per the field-additive rule. Full design: RFC-006 D1.

An encrypted event carries an optional enc discriminator alongside a ciphertext body:

{
  "kind": 1000,
  "enc":  "wire-x25519.v1",           // absent ⇒ plaintext body (the v0.15 default)
  "body": { "ct": "<base64 ciphertext>" }
}
  • Discriminator is wire-x25519.v1, NOT nip44.v2. D1 reuses NIP-44 v2's symmetric envelope (HKDF → ChaCha20 + HMAC-SHA256 + length-hiding padding) but derives the conversation key from an X25519 ECDH (wire identities are Ed25519 → X25519), with a wire-specific HKDF salt (wire-x25519-v1). It is therefore deliberately not Nostr-wire-compatible NIP-44 — the rename prevents a Nostr reader from mis-decrypting a wire body. (True Nostr-DM interop is an RFC-007/D3 concern over a separate secp256k1 transport key.)
  • The event_id / signing mechanics of §2.2 are unchanged: the signature commits to whatever body contains (plaintext object or {ct}), so signing and verification are encryption-agnostic. A reader that does not understand enc still verifies the signature and event_id; it simply cannot read the ciphertext.
  • Because this is purely additive, an enc-bearing event stays schema_version major v3 — no major bump (src/signing.rs::EVENT_SCHEMA_VERSION / schema_major). Encryption rides inside the existing body; it does not break the event format.
  • Companion card reservation: dh_pubkey (§1) — the X25519 key used to derive the per-pair shared secret.

3. Canonical form

Canonical JSON serialization is the input to all hashing and signing. Rules:

  1. Object keys serialize in lexicographic byte order.
  2. No whitespace anywhere; separators are "," and ":".
  3. UTF-8 throughout. Non-ASCII characters are NOT \u-escaped.
  4. Top-level fields signature and public_key_id are always stripped before serialization.
  5. Top-level field event_id is stripped iff strict=true.

Strict mode is used when computing event_id (the field cannot reference itself). Non-strict mode is used everywhere else (verification, transport).

4. Trust state

Each agent maintains a local trust state, persisted to ~/.config/wire/trust.json:

{
  "version": 1,
  "agents": {
    "paul": {
      "tier": "ATTESTED",
      "did": "did:wire:paul",
      "public_keys": [
        {
          "key_id": "paul:b2e5aae7",
          "key": "<base64 pub>",
          "added_at": "<rfc3339>",
          "active": true
        }
      ]
    },
    "willard": {
      "tier": "VERIFIED",
      "did": "did:wire:willard",
      "card": { ... full signed card ... },
      "pinned_at": "<rfc3339>",
      "verified_at": "<rfc3339>"
    }
  }
}

4.1 Tiers

Tier Promotion path Acceptance
UNTRUSTED initial pin events ignored
ORG_VERIFIED member_cert verifies against an accepted org (RFC-001 §5, v3.2+) events accepted with org-policy gating only; does NOT satisfy >= VERIFIED checks
VERIFIED SAS confirm or wire pin of signed card events accepted
ATTESTED self-attestation only self events accepted
TRUSTED reserved for v0.2+ reserved

Promotion is one-way. UNTRUSTED → ORG_VERIFIED → VERIFIED → ATTESTED. promote_to_verified accepts either UNTRUSTED or ORG_VERIFIED as source per RFC-001 §5 ("a SAS-paired peer that happens to share our org is recorded at VERIFIED, not downgraded"). Reverting requires removing the agent record entirely. The strict ORG_VERIFIED < VERIFIED invariant is property-tested in tests/trust_ceiling_prop.rs.

5. Pairing — SPAKE2 + SAS + AEAD

5.1 Code phrase

Format: NN-XXXXXX where NN is two random decimal digits and XXXXXX is six random base32 characters from the RFC 4648 alphabet (A-Z2-7, no 0/1 ambiguity). Total entropy ~36.6 bits.

Operators read this aloud over a side channel they trust.

5.2 SPAKE2 handshake

Both sides instantiate Spake2<Ed25519Group>::start_symmetric with:

  • password = code_phrase.as_bytes()
  • identity = pair_id_from_relay

pair_id_from_relay is a 16-byte random hex string the relay assigns when the host opens a pair-slot keyed by code_hash = SHA-256("wire/v1 code-phrase" || code_phrase). Including pair_id in the SPAKE2 identity prevents crosstalk between concurrent pairings on the same relay.

Both sides exchange SPAKE2 messages via the relay's /v1/pair endpoints (see §6.4) and call finish() on the peer's message to derive a 32-byte shared secret.

5.3 Short Authentication String (SAS)

Both sides compute identically:

sas = SHA-256("wire/v1 sas" || spake_key || sorted_pubkeys)
sas_digits = (last_4_bytes_be(sas) % 1_000_000) zero-padded to 6 digits

Format displayed: XXX-XXX (split for readability). Operators verify by reading aloud.

If digits do not match, MITM is suspected. Operators MUST refuse confirmation; the implementation MUST NOT proceed with the bootstrap exchange.

5.4 AEAD bootstrap

After SAS confirmation, both sides:

  1. Derive a ChaCha20-Poly1305 key: aead_key = HKDF-SHA256(salt=code_hash, ikm=spake_key, info="wire/v1 bootstrap-aead", L=32).
  2. Build a bootstrap payload: {"card": <signed-card>, "relay_url": <url>, "slot_id": <id>, "slot_token": <token>}.
  3. Seal: random 12-byte nonce + ChaCha20-Poly1305 over canonical JSON of the payload. Wire format: nonce || ciphertext+tag.
  4. POST to /v1/pair/<pair_id>/bootstrap with role=host|guest.
  5. Poll /v1/pair/<pair_id>?as_role=host|guest for the peer's sealed payload.
  6. Open with aead_key. AEAD failure → abort pairing; do not retry.
  7. verify_agent_card on peer's card. Bad signature → abort.
  8. Pin peer's card at tier VERIFIED. Save peer's relay coordinates.

6. Relay HTTP endpoints

The reference relay binds to an arbitrary host:port. All endpoints emit JSON responses; errors include {"error": "<msg>"}.

6.1 GET /healthz

Returns 200 OK with body ok\n. Liveness check; no auth.

6.2 POST /v1/slot/allocate

Allocates an event-slot. Request body: {"handle": "<optional>"}. Response: {"slot_id": "<32-hex>", "slot_token": "<64-hex>"}. The token is a bearer for both reads and writes.

6.3 Event slots

  • POST /v1/events/<slot_id> body {"event": <signed-event>}, Authorization: Bearer <slot_token> — stores or dedupes by event_id. Response: {"event_id": "<id>", "status": "stored"|"duplicate"}. Body cap: 256 KiB. Returns 401/403 on bad/missing token, 404 on unknown slot, 413 on body cap exceeded.
  • GET /v1/events/<slot_id>?since=<event_id>&limit=<n>, bearer auth — returns [<event>, ...] from after since (exclusive). Default limit 100, max 1000.

6.4 Pair slots

Pair slots are ephemeral, in-memory only. A relay restart aborts in-progress pairings; clients MUST handle this by retrying.

  • POST /v1/pair body {"code_hash": "<hex>", "msg": "<base64-spake-msg>", "role": "host"|"guest"}. Response: {"pair_id": "<32-hex>"}. Host registers first; guest finds the existing slot via code_hash. Same role registering twice for one slot returns 409 Conflict.
  • GET /v1/pair/<pair_id>?as_role=host|guest. Returns the OTHER side's data: {"peer_msg": "<b64>"|null, "peer_bootstrap": "<b64>"|null}. Returns 404 on unknown pair_id.
  • POST /v1/pair/<pair_id>/bootstrap body {"role": "host"|"guest", "sealed": "<base64>"}. Stores the sealed bootstrap payload from our side.

The relay performs no Ed25519 verification, no PAKE arithmetic, no decryption. It is a dumb pipe. Trust is established and verified entirely client-side.

7. Persistence

The reference implementation stores events to <state_dir>/slots/<slot_id>.jsonl (append-only, one event per line) and tokens to <state_dir>/tokens.json. Pair slots are NOT persisted.

A relay MUST reload event slots and tokens on startup to provide restart-recovery. Pair slots MUST start empty.

8. Forward compatibility

  • Unknown top-level fields in events MUST be preserved verbatim through canonicalization. Implementations MUST NOT drop fields they don't recognize.
  • New kind ids in unused range slots are forward-compatible: an old verifier sees an unknown kind and routes by class (regular/replaceable/ephemeral/addressable). Application logic decides whether to accept or skip.
  • New top-level fields in agent-cards are similarly preserved.
  • Schema bumps (e.g. schema_version: "v4.0") require a migration path documented at the time of bump.

9. What this protocol does NOT specify

  • Discovery (pairings are out-of-band; no DHT, no registry in v0.1)superseded. v0.5+ ships a handle directory served via GET /.well-known/agent-card.json?handle=<nick>; see a2a-extension/wire-identity-v1.md.
  • Group rooms (mesh-of-bilateral only — see ANTI_FEATURES.md)superseded. v0.13.3+ ships shared-slot group rooms via wire group create / invite / join / send / tail (see src/group.rs).
  • Message encryption above the wire layer (events are signed-plaintext today; encryption pending NIP-44 v2 or DIDComm authcrypt in a future revision).
  • Spam control (relay accepts any signed event under the body cap; rate limiting is operator-side).
  • File transfer above 256 KiB (deferred; not currently implemented).
  • Federation between relays (each pair shares one relay; cross-relay roaming is v0.3+)superseded. v0.5+ resolves <handle>@<relay> via .well-known lookup against the relay's domain; peers behind different relays pair through the federation directly.

10. Revision notes

Wire version Schema Notable protocol changes
v0.1 v3.1 Initial spec — bilateral SAS, single-relay, mesh-of-pairs.
v0.5.7 v3.1 Pubkey-suffixed DIDs (did:wire:<handle>-<8hex>) to prevent handle collisions.
v0.5 v3.1 Federated handle directory + .well-known/agent-card.json; A2A v1.0 AgentCard emission with wire as an A2A extension.
v0.6 v3.1 Mesh / local-sister sessions + intra-machine pair-all.
v0.13.3 v3.1 Group rooms via shared relay slot.
v0.13.5 v3.1 Last cut release (Cargo 0.13.5).
v0.14 (main, unreleased) v3.2 RFC-001 — operator + organisation identity claims on the card (inline op_pubkey / org_pubkey + certs), Tier::OrgVerified, identity::* cert primitives, fully-offline self-certifying verification. Backward-compatible with v3.1 readers.