msg — wire protocol v1

1. Status

This document specifies version 1 of the msg protocol. The Go code under pkg/msg is a reference implementation; this document is the source of truth. Test vectors under testdata/vectors/ form the conformance contract: an implementation that produces and accepts the byte sequences in those files, and applies the rejection rules listed here, conforms to v1.

2. Overview

A participant in msg is a single URL. The URL serves three roles simultaneously:

Trust bootstraps from HTTPS: an implementation that trusts HTTPS to a hostname can trust whatever public keys that hostname serves at that URL. There are no relays, no central directory, and no PKI beyond the Web PKI used by HTTPS.

3. Conventions

The keywords MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are used as defined in RFC 2119. All times are encoded as RFC 3339 timestamps with a numeric offset or Z. All binary values are encoded as standard base64 (RFC 4648 §4, with padding) — not URL-safe base64. All JSON is UTF-8.

4. URLs

A URL identifying a participant MUST:

The URL's path MAY be anything the host chooses. https://alice.example/inbox, https://alice.example/, and https://example.org/users/alice are all valid.

5. Discovery: GET on the URL

A participant URL MUST respond to HTTPS GET with their actor doc: a JSON object describing the participant.

5.1 Request

GET <url> HTTP/1.1
Accept: application/msg+json

Implementations MAY also send Accept: application/json; servers SHOULD respond with Content-Type: application/msg+json regardless.

5.2 Response

200 OK with a JSON body of the following shape:

{
  "url": "https://alice.example/inbox",
  "name": "Alice",
  "about": "free-form bio",
  "avatar": "https://alice.example/me.jpg",
  "keys": [
    {
      "id": "2026-05-a",
      "algorithm": "ed25519",
      "publicKey": "<base64>"
    }
  ]
}

5.2.1 Required fields

5.2.2 Optional fields (display-only)

Unknown top-level fields MUST be tolerated by verifiers and MAY be preserved by clients.

5.3 Caching

Servers SHOULD return Cache-Control: max-age=86400 (24h) on actor docs and SHOULD set an ETag. Verifiers SHOULD honor cache directives but MUST always refetch when a received message references a keyId not present in the cached doc (see §7.3).

6. Sending: POST a message

To send a message to URL R, the sender constructs an envelope and POSTs it to R.

6.1 Request

POST <recipient-url> HTTP/1.1
Content-Type: application/msg+json
Msg-Signature: <base64-ed25519-signature-over-raw-body-bytes>
Msg-Receipt: required          (optional; signals the sender wants a signed receipt)

<raw envelope bytes>

6.2 Envelope

The body is a JSON object:

{
  "v": 1,
  "sender": "https://alice.example/inbox",
  "recipient": "https://bob.example/inbox",
  "timestamp": "2026-05-06T14:30:00Z",
  "id": "01HZ7K3F8QR4MQM7QF2X3WX0V1",
  "keyId": "2026-05-a",
  "inReplyTo": "01HZ6XYZ...",
  "payload": <any valid JSON value>
}

6.2.1 Required fields

6.2.2 Optional fields

Unknown fields MUST be tolerated by receivers and MUST be preserved if the message is ever forwarded or exported.

6.3 Signature

The value of the Msg-Signature header is the base64 encoding of the Ed25519 signature, computed using the sender's private key whose public half is published with keyId matching the envelope's keyId, over the raw body bytes of the HTTP request (the exact bytes the receiver will read from the request body).

There is no canonicalization. Implementations MUST NOT re-serialize the envelope before signing or verifying. Senders SHOULD use compact JSON (no extraneous whitespace) for predictability and for inclusion in test vectors.

7. Receiving: verification procedure

Upon receiving a POST, the receiver MUST perform the following checks in order. Failure at any step results in the corresponding HTTP status and error code (§9). Subsequent steps are skipped.

7.1 Parse and shape

  1. Read the request body in full, keeping the raw bytes.
  2. Parse as JSON. Failure → 400 malformed-envelope.
  3. Verify all required fields (§6.2.1) are present and well-typed. Failure → 400 malformed-envelope.
  4. Verify v == 1. Failure → 400 unsupported-version.

7.2 Recipient match

Verify the envelope's recipient field exactly matches the receiver's own canonical URL (after the same normalization as §5.2.1). Mismatch → 421 wrong-recipient.

7.3 Key resolution

Look up the sender's actor doc in the local cache.

The resolved key MUST have algorithm: "ed25519".

7.4 Signature verification

Verify that the value of Msg-Signature is a valid Ed25519 signature, over the raw body bytes received in §7.1, against the public key resolved in §7.3. Failure → 401 bad-signature.

7.5 Timestamp window

Compute |now - timestamp|. If greater than 5 minutes (300 seconds), reject as 401 stale-timestamp. The window MAY be configurable; implementations SHOULD NOT widen beyond 10 minutes without a clearly stated reason.

7.6 Replay (deduplication)

Reject if a message with the same (sender, id) pair has been previously accepted by this receiver. Reject as 409 duplicate-id.

The receiver's message store MAY serve as the seen-set; explicit retention policy MUST keep (sender, id) pairs at least as long as the timestamp window, and SHOULD keep them substantially longer (default: forever).

7.7 Persist and ack

If all checks above pass, the receiver MUST durably store the message (envelope, raw signed bytes, signature) before returning a 2xx response. A receiver MUST NOT return 2xx and then fail to persist.

8. Receipts

A receipt is a regular envelope from the original recipient to the original sender, with payload of the form:

{ "ackOf": "<original-message-id>" }

The receipt envelope:

The receipt is returned as the response body of the original POST. The original sender MAY verify it using the same procedure as §7 (treating the receipt as an inbound message), keying off the original recipient's published actor doc, with one exception: §7.2 (recipient match) verifies against the original sender's URL, since the receipt is addressed back to them.

A receipt confirms only that the original recipient verified, accepted, and persisted the original message. It does not imply application-level processing.

9. Errors

Receiver error responses MUST use the HTTP status code listed below and MUST include a JSON body:

{ "error": "<stable-code>" }

A receiver MAY include an additional human-readable "message" field for diagnostics; senders MUST NOT depend on its content.

Status Code Meaning
400 malformed-envelope JSON parse failure, missing/ill-typed required field.
400 unsupported-version v not recognized.
401 bad-signature Signature failed Ed25519 verification.
401 stale-timestamp Outside the timestamp skew window.
401 unknown-key keyId absent from sender's actor doc after one re-fetch.
409 duplicate-id (sender, id) already accepted.
421 wrong-recipient recipient does not match the receiver's URL.
5xx internal Receiver-side problem; sender SHOULD retry with backoff.

Error code strings are stable. Future versions may extend the list; renaming is a breaking change.

10. Key rotation

A participant rotates a key by:

  1. Generating a new keypair with a new id.
  2. Publishing the new key in their actor doc (the keys array now contains both).
  3. Signing all new outbound messages with the new key (using its keyId).
  4. After a retain window (suggested: 30 days), removing the old key from the actor doc.

Receivers handle rotation transparently:

There is no rotation announcement, negotiation, or global cache invalidation.

11. Versioning

The v field versions the envelope schema. v1 is this document. Future versions MUST:

The actor doc has no version field; it is forward-compatible by tolerating unknown fields.

12. Limits

13. Security considerations

14. Test vectors

Conformance is defined by the test vectors under testdata/vectors/. Each vector is a JSON file describing inputs and expected outcome. An implementation that:

is conformant with v1.

The vector files are normative. This document and the vectors must agree; in case of discrepancy, the vectors describe behavior and this document MUST be corrected to match (after which both are updated together).