Posta — wire protocol proposal 1

Posta is a wire protocol for cryptographically authenticated messaging between participants identified by HTTPS URLs. A participant publishes Ed25519 public keys at their URL and receives signed envelopes via HTTPS POST. The protocol provides per-message origin authentication; it does not provide payload encryption, multi-recipient envelopes, or delivery acknowledgement. Trust bootstraps from HTTPS; no additional PKI, directory service, or central infrastructure is defined.

1. Status

This document is proposal 1 of the Posta wire protocol. It specifies wire format version 1, the only currently defined format. Document revisions increment the proposal number (proposal 2, 3, …); the wire format version increments only on changes that are not backward-compatible (§10).

The Go code under pkg/posta 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 wire format version 1.

2. Overview

A participant in Posta 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.

4.1 Canonical URL

The comparisons in §5.2.1 (actor-doc url) and §7.2 (envelope recipient) are performed against the canonical form of each URL. Canonicalization is a deterministic, total algorithm: every input string produces either a canonical URL string or one of the rejection categories listed below. The wire-level fields that carry participant URLs — actor-doc url (§5.2.1), envelope sender and recipient (§6.2.1) — MUST already be in canonical form when transmitted; a non-canonical value will fail downstream verification (a non-canonical recipient cannot match the receiver's canonical URL → §7.2 wrong-recipient; a non-canonical sender cannot be resolved to an actor doc → §7.3 bad-signature; a non-canonical actor-doc url triggers §5.2.1 rejection, which cascades through §7.3 to bad-signature).

A canonical Posta URL has the structure https://<host>[:port]<path> and satisfies all of the following:

  1. Scheme. The input scheme MUST be https (case-insensitive on input; the canonical form is lowercase https). Any other scheme → reject non-https-scheme.

  2. Userinfo. The input MUST NOT contain a userinfo component (user@host, user:pass@host). Reject userinfo-present.

  3. Host.

    • The host MUST be a DNS name. IP-address literals (IPv4 dotted-quad, IPv6 bracketed) MUST be rejected as ip-literal-host. (Future wire format versions MAY relax this and specify IP-literal canonicalization.)
    • Host labels MUST be representable as A-labels per IDNA 2008 / [UTS #46], processing option nontransitional. U-labels in the input MUST be converted to A-labels before comparison and storage; mixed-form inputs are converted segment-by-segment.
    • After A-label conversion the host MUST be compared and stored as lowercase ASCII.
    • Empty host or syntactically invalid label → reject malformed-host.
  4. Port. The default port :443 MUST be omitted from the canonical form. Any other port is preserved as :<decimal> with no leading zeros. Non-numeric, zero, or out-of-range ports (not 1–65535) → reject malformed-port.

  5. Path.

    • . and .. segments MUST be resolved per RFC 3986 §5.2.4. The canonical form contains no dot-segments.
    • Any trailing / MUST be removed; a path consisting solely of / becomes empty. https://example.com/inbox/, https://example.com/inbox, and https://example.com/inbox/./ all canonicalize to https://example.com/inbox.
    • Percent-encoded sequences MUST be syntactically valid (%XX with two hex digits). Invalid sequences → reject malformed-path.
    • Hex digits in percent-encodings MUST be uppercase. %2f is normalized to %2F.
    • Unreserved characters (RFC 3986 §2.3: A-Z, a-z, 0-9, -, ., _, ~) MUST NOT appear percent-encoded; if present, they MUST be decoded. %7E is normalized to ~.
    • Path comparison is case-sensitive (RFC 3986 §6.2.2.1). https://example.com/Alice and https://example.com/alice are distinct identities. Operators of multi-tenant hosts SHOULD avoid case-only-distinct paths in user-assigned URLs.
  6. Query. The input MUST NOT contain a query component. An unencoded ? → reject query-present. (A percent-encoded %3F in the path is a literal ? character and is not a query delimiter.)

  7. Fragment. The input MUST NOT contain a fragment. An unencoded # → reject fragment-present. (%23 in the path is a literal #.)

Conformance vectors for this algorithm live under testdata/vectors/url-canonical/ (§15). The vectors are normative: an implementation that reproduces every input → canonical mapping and every input → reject category in those files is canonicalization-conformant.

4.2 Display form

For human-readable rendering — TUI chrome, log lines, share strings, business-card-style identifiers — Posta defines a display form derived from the canonical URL by:

Canonical Display form U-label rendering (optional)
https://alice.example alice.example
https://posta.no/u/arne posta.no/u/arne
https://posta.no:8443/u/arne posta.no:8443/u/arne
https://xn--caf-dma.example xn--caf-dma.example café.example

The display form is bijective with the canonical form: any display string can be converted back by prepending https://, and any canonical URL has exactly one display string. The U-label rendering column is a separate, optional presentation layer described below — it is not the display form, and it MUST NOT appear in any wire field.

Clients MAY render U-labels (e.g., café.example for xn--caf-dma.example) in display contexts, provided storage and identity comparison remain on the A-label canonical form and the U-label rendering follows IDNA 2008 / UTS #46 conversion in the same direction. Mixed-script hosts SHOULD be flagged or punycode-displayed per the application's IDN spoofing policy; this is a UI concern, not a protocol one.

CLIs and other user-input surfaces SHOULD accept the display form and prepend https:// before canonicalization. The display form MUST NOT appear in sender, recipient, or actor-doc url — those fields carry the full canonical URL.

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

The response is application/posta+json. The Accept request header is ignored.

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 set Cache-Control and ETag headers on actor docs to enable upstream caching (CDNs, proxies, browsers). A cached actor doc is stale when the time since its local fetch exceeds the timestamp window (§7.5); verifiers MUST NOT use a stale doc, and SHOULD use HTTP conditional requests (If-None-Match / If-Modified-Since) to refresh efficiently. Verifiers MUST also refetch when a received message references a keyId not present in the cached doc (see §7.3).

The timestamp window bounds both replay protection (§7.5) and key-management propagation (§9): one constant, one freshness guarantee.

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/posta+json
Posta-Signature: <base64-ed25519-signature-over-raw-body-bytes>

<raw envelope bytes>

The Content-Type header MUST be application/posta+json. Senders SHOULD send no parameters; receivers MUST consider only the bare media type and MUST tolerate (and ignore) any parameters such as charset=utf-8.

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 Posta-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 (§8). Subsequent steps are skipped.

7.1 Parse and shape

  1. Verify the request Content-Type (the bare media type, after stripping any parameters) equals application/posta+json. Mismatch → 415 unsupported-media-type. This check runs first so malformed transports are rejected before the body is read.
  2. Read the request body, keeping the raw bytes. If the body exceeds the §11 cap, reject as 413 payload-too-large. Receivers SHOULD refuse to buffer further than the cap so an oversized POST cannot consume unbounded memory.
  3. Parse as JSON. Failure → 400 malformed-envelope.
  4. Verify all required fields (§6.2.1) are present and well-typed. Failure → 400 malformed-envelope.
  5. Verify v == 1. Failure → 400 unsupported-version.

7.2 Recipient match

Canonicalize the envelope's recipient field and the receiver's own URL per §4.1. The two canonical strings MUST be byte-for-byte equal. A mismatch — or a recipient value rejected by §4.1 — fails this check → 421 wrong-recipient.

7.3 Key resolution

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

The resolved key MUST be Ed25519: if the key entry carries an algorithm field (§5.2.1) it MUST equal "ed25519"; if absent, "ed25519" is implied.

7.4 Signature verification

Verify that the value of Posta-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 is fixed at 5 minutes; receivers that deviate are non-conformant.

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 each (sender, id) pair at least until 5 minutes past the envelope's timestamp — past that instant, no replay of the same bytes can pass §7.5. With ±5 min skew permitted in both directions, this is equivalent to retaining for twice the timestamp window from the moment of acceptance in the worst case. Receivers SHOULD keep (sender, id) pairs 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 responding 204 No Content with empty body. A receiver MUST NOT return 2xx and then fail to persist.

The 2xx response is the delivery confirmation. v1 has no signed-receipt mechanism; applications that need cryptographic non-repudiation MAY implement an ack-message convention as a follow-up POST in the reverse direction.

8. 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.
413 payload-too-large Request body exceeds the §11 cap.
415 unsupported-media-type Request Content-Type is not application/posta+json (after stripping parameters).
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.

9. Key management

A participant's actor doc lists the keys currently authoritative for the URL. Key management is editing that list:

A participant MAY keep multiple keys listed indefinitely — e.g., one per device in a multi-device fleet; signatures from any listed key are equally valid, and the protocol does not distinguish "current" from "legacy." There is no rotation announcement, no negotiation, no retain window, and no global cache invalidation. The actor doc is the source of truth, and the timestamp window bounds how stale a verifier's view of it can be.

10. Versioning

The envelope's v field is the wire format version. This document (proposal 1) specifies wire format version 1, the only currently defined format. Future revisions of this document — proposal 2, 3, … — may either refine wire format 1 in backward-compatible ways (clarifications, additional optional fields, new payload kinds) or introduce a new wire format version. Future wire formats MUST:

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

11. Limits

12. Security considerations

13. Payload conventions

The envelope's payload field (§6.2.1) is any valid JSON value, opaque to the protocol. This section defines an OPTIONAL convention for self-describing payload objects, and lists the well-known kinds defined by this specification.

The convention is opt-in: a payload that does not follow it is still a valid envelope. The convention exists so that two implementations written by different people can interoperate at the application layer without out-of-band coordination.

13.1 The kind discriminator

When a payload is a JSON object, it SHOULD carry a kind field — a string that identifies the payload's schema:

{
  "kind": "posta.text/v1",
  "body": "hello"
}

Receivers MAY route, render, or store payloads based on kind. Receivers MUST treat unknown kind values as opaque: the payload is still a valid envelope and MUST be persisted with the same durability as any other message (§7.7); the receiver simply MAY decline to render it.

13.1.1 Namespacing

The posta.* namespace is reserved for kinds defined by this specification. Three well-known kinds are defined in this proposal: posta.text/v1 (§13.2), posta.link/v1 (§13.3), and posta.room.broadcast/v1 (§14).

Community-defined kinds SHOULD use a namespace the publisher controls — a reverse-DNS prefix (com.example.app.event/v1), a project name (myapp.reaction/v1), or any other unambiguous identifier. There is no central registry; collisions are an application concern.

13.1.2 Versioning

Kinds SHOULD carry a version suffix (e.g., /v1, /v2). A new version is appropriate when an incompatible change is needed; backward-compatible additions (new optional fields) SHOULD reuse the existing version. Receivers that recognize an older version of a kind but not a newer one MUST treat the unknown version as opaque per §13.1.

13.1.3 Surfacing unknown kinds

User-facing clients SHOULD indicate to the user that a message with an unrecognized kind has arrived, rather than omitting it from view entirely. A minimal placeholder (for example, "message of kind X: no renderer available") satisfies this; the goal is that the user knows something is waiting and can read it in a different client, or after a future update.

13.2 posta.text/v1 — plain text messages

The well-known kind for human-to-human plain text messages:

{
  "kind": "posta.text/v1",
  "body": "hello, world"
}

13.2.1 Required fields

body is plain UTF-8 text with no inline formatting markup (no Markdown, no HTML). A renderer that displays body as plain text — preserving line breaks, escaping HTML if rendered in a web context — is conformant.

There is no length limit beyond the envelope's overall body limit (§11). Receivers MAY truncate for display.

Additional optional fields MAY be added in future revisions of this kind, under the same posta.text/v1 identifier, provided they remain backward-compatible per §13.1.2.

13.3 posta.link/v1 — references to external content

The well-known kind for messages whose primary content is a reference to fetchable content. posta.link/v1 is the canonical answer to the §11 body cap: files, long-form text, and any other content too large for the inline JSON envelope travels as a URL.

{
  "kind": "posta.link/v1",
  "url": "https://files.example/abc123.jpg",
  "mediaType": "image/jpeg",
  "name": "photo.jpg",
  "size": 524288
}

13.3.1 Required fields

13.3.2 Optional fields

Receivers MUST tolerate unknown fields. Adding a new required field requires a new kind value per §13.1.2.

13.3.3 Fetch policy

Auto-fetch reveals to the host of url that the recipient saw the message — a side-channel the sender can exploit deliberately. Receivers SHOULD NOT auto-fetch for unknown senders. A privacy-respecting default is to fetch only on explicit user gesture, or to auto-fetch only when mediaType matches a narrow allow-list (e.g., image/* for chat clients) and the URL host is on a user-maintained trust list.

A verified envelope proves the sender wrote this URL; it does not prove the content at the URL is what the sender intended. The host can serve different bytes to different fetchers and change them over time. Senders that need stronger content-integrity guarantees can include a hash alongside the URL — this is application convention; posta.link/v1 does not standardize a hash field in this proposal.

14. Room broadcast wrapper

A room is a Posta participant whose application behavior is to re-broadcast received messages to a set of subscriber URLs. A room is a normal URL: it has an actor doc, a keypair, and an inbox; nothing in §§4–10 distinguishes a room from a person on the wire. This section specifies the wrapper payload convention that lets a recipient verify the origin of a message carried through a room — i.e., confirm that the room is delivering an unmodified message authored by some other URL. The wrapper is the well-known payload kind posta.room.broadcast/v1 under the convention defined in §13.

Allow-list management, invitation and leave UX, system-message vocabulary, backfill, and silent-acceptance of non-member POSTs are all local policy choices, not protocol. This section specifies only the wire shape required for cross-implementation interop on the verifiable-origin property.

14.1 The wrapper payload

When a room broadcasts a message originally authored by some other URL S, it constructs a normal envelope (per §6) with the room's URL as sender and the recipient member's URL as recipient, and the following object as payload:

{
  "kind": "posta.room.broadcast/v1",
  "envelopeBytes": "<base64 of S's exact original POST body bytes>",
  "signature": "<base64 of S's Posta-Signature value>"
}

14.1.1 Required fields

Rooms MAY include additional fields in the wrapper payload object. Receivers MUST tolerate unknown fields. New required fields, if ever introduced, MUST be carried under a new kind value.

14.2 Verifying a wrapped message

A receiver of a wrapped broadcast performs two verifications:

  1. Outer envelope. Verify the room's envelope per §7 in full. This proves the room delivered the message and commits the room to the wrapped content as carried.
  2. Inner envelope. After §7 succeeds, the receiving application MAY additionally verify the original sender's signature:
    • Base64-decode payload.envelopeBytes to obtain the original POST body bytes.
    • Parse those bytes as an envelope (§6.2). Use the inner envelope's sender and keyId to resolve the original sender's public key, following the §7.3 procedure with the inner sender as the URL to fetch.
    • Base64-decode payload.signature.
    • Verify the signature is valid Ed25519 over the decoded envelopeBytes against the resolved public key (per §7.4).

The inner envelope is not a Posta receive — it is application evidence. Receivers MUST NOT apply §7.2 (recipient match) to the inner envelope: its recipient is the room URL, not the receiver's own URL. Receivers SHOULD NOT apply §7.5 (timestamp skew) to the inner envelope either; rooms may legitimately deliver outside the inner timestamp's skew window, and the outer envelope's timestamp is what bounds replay.

14.3 Why bytes, not objects

envelopeBytes carries the exact bytes the original sender signed. Re-serializing the inner envelope as a JSON object inside the wrapper would silently break inner-signature verification: Posta signatures are over raw body bytes (§6.3), and JSON re-serialization may change whitespace, key ordering, escape choices, or number formatting — none of which encoding/json (or any other JSON library) is required to preserve. A wrapper that carries a parsed-and-re-serialized envelope CANNOT be verified by a downstream recipient.

This is the same reason §6.3 forbids re-serialization for primary envelopes. The rule extends recursively: anywhere a Posta envelope is forwarded for later verification, it MUST travel as raw bytes.

14.4 What the wrapper guarantees

A correctly verified wrapped broadcast (both outer and inner verifications passing) proves:

It does NOT prove:

Receivers that need stronger delivery guarantees MAY implement application-level acknowledgement messages (cf. §7.7).

14.5 Threading across the wrapper

When a member composes a reply to a wrapped broadcast, the reply's inReplyTo (§6.2.2) MUST reference the inner envelope's id, not the outer wrapper's id. The outer envelope's id is generated fresh per outbound POST — the room emits one wrapper per member, each with a distinct id — so wrapper ids are per-recipient delivery artifacts and differ across members; only the inner author id is stable across the room. Clients that surface wrapper ids in user-visible affordances (permalinks, deep links) MUST use the inner id.

15. 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).