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:
- Identity. The URL is the participant's identifier. There is no separate username, key fingerprint, or directory entry.
- Inbox. Sending a message means HTTPS POST to the URL.
- Key publication. HTTPS GET on the URL returns an actor doc listing the URL's published public keys.
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:
- Use the
httpsscheme. - Resolve via DNS to a host that is publicly reachable on the internet (or, for testing, on a network shared with all intended participants).
- Present a TLS certificate valid for the URL's host as anchored in the verifier's local trust store (typically the Web PKI).
- Be stable: a participant changing their URL is, for the purposes of this protocol, a new identity.
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
url— the participant's canonical URL. Verifiers MUST reject the actor doc ifurldoes not exactly match the URL they fetched from (after normalization: lowercase scheme/host, default port collapsed). This defends against actor-doc misconfiguration on multi-tenant hosts.keys— a non-empty array of key objects. Each key object MUST contain:id— a stable string up to 64 characters, unique across all keys ever published by this URL.algorithm— currently MUST be the literal string"ed25519".publicKey— base64-encoded raw 32-byte Ed25519 public key.
5.2.2 Optional fields (display-only)
name— a display string (max 280 chars). Verifiers MUST NOT usenamefor identification, dedup, sorting-as-identity, or any trust decision. Identity is the URL.about— free-form bio (max 280 chars).avatar— a URL to an image. Clients MAY display.
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
v— integer, MUST be1for this version.sender— the sender's own URL.recipient— the URL the message is being POSTed to.timestamp— RFC 3339 timestamp, sender's wall clock at send time.id— a string up to 256 characters, unique per(sender, id). Senders SHOULD use ULID or UUIDv7 for natural ordering.keyId— theidof the sender's key being used to sign this message. MUST match an entry in the sender's actor doc at the time of sending.payload— any valid JSON value. Opaque to the protocol; application-defined.
6.2.2 Optional fields
inReplyTo— theidof a prior message this is a reply to. Receivers MUST NOT enforce that the referenced message exists.
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
- Read the request body in full, keeping the raw bytes.
- Parse as JSON. Failure →
400 malformed-envelope. - Verify all required fields (§6.2.1) are present and well-typed. Failure →
400 malformed-envelope. - 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.
- If absent: fetch
GET <sender>. On HTTP failure or invalid actor doc →401 bad-signature. - If the
keyIdfrom the envelope is not present in the cached actor doc: fetch once. If still absent in the freshly-fetched doc →401 unknown-key.
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.
- If the request did not include
Msg-Receipt: required: respond204 No Contentwith empty body. - If the request included
Msg-Receipt: required: respond200 OKwithContent-Type: application/msg+jsonand a signed receipt envelope as the body (§8). A receiver MAY decline to issue receipts; in that case it returns204 No Contentregardless. The sender MUST tolerate the absence of a receipt.
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:
- Has its own
id,timestamp,keyId, and signature, all generated by the original recipient using the receipt issuer's keys. - Has
senderset to the original recipient's URL andrecipientset to the original sender's URL.
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:
- Generating a new keypair with a new
id. - Publishing the new key in their actor doc (the
keysarray now contains both). - Signing all new outbound messages with the new key (using its
keyId). - After a retain window (suggested: 30 days), removing the old key from the actor doc.
Receivers handle rotation transparently:
- A message arriving with a
keyIdnot in the cached actor doc triggers exactly one re-fetch (§7.3). - During the retain window, both old and new keys are present in the actor doc, so messages signed with either are accepted.
- After the retain window, messages signed with the old key are rejected as
unknown-key.
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:
- Be served at the same URL (the URL has no version prefix).
- Be detectable by the
vfield of the envelope. - Either be a strict superset of v1 (with v1 receivers tolerating unknown fields per §6.2.2) or use a new
vinteger (causing v1-only receivers to reject withunsupported-version).
The actor doc has no version field; it is forward-compatible by tolerating unknown fields.
12. Limits
- Maximum POST body size: implementations SHOULD accept at least 1 MB. Receivers MAY enforce a larger or smaller limit; a body exceeding the receiver's limit MAY be rejected with
413 Payload Too Large(noerrorcode defined). - Maximum actor doc size: implementations SHOULD accept at least 64 KB.
idlength: maximum 256 bytes.keyIdlength: maximum 64 bytes.
13. Security considerations
- Identity is the URL. Display fields (
name,about,avatar) are advisory. Implementations that surface them in UI MUST also surface the URL alongside, and MUST key all storage and trust decisions on the URL. - HTTPS is the trust anchor. The protocol's origin authentication relies entirely on HTTPS to authenticate the sender's hostname during actor-doc fetch. A compromise of the sender's TLS (stolen cert, hijacked DNS, malicious CA) is a compromise of msg identity.
- Replay protection requires durable id-storage. Receivers that prune old messages must retain
(sender, id)pairs at least as long as the timestamp window; ideally substantially longer. - Cross-recipient replay is prevented by including
recipientin the signed envelope. Implementations MUST NOT skip §7.2. - Rate limiting is the receiver's responsibility. The protocol does not define abuse-mitigation primitives. Default reference behavior is per-sender rate limiting; stricter modes (
acl=contacts) are local policy. - Private keys never leave the daemon process. No protocol message includes private key material.
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:
- Produces the exact
Msg-Signatureheader value given the (envelope bytes, private key) inputs of every "produce" vector, AND - Returns the expected status code and
errorvalue for every "verify" vector,
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).