Transport & durable inbox
OC Chat messages travel as gift-wrapped Nostr events that relays cannot read. This page covers the wire format, the v2 wrap encryption that closes the social-graph leak, the durable store-and-forward inbox, and the NIP-42 relay-AUTH hardening.
Gift-wrap transport
The canonical chat envelope travels as the inner blob of a Nostr kind-1059
gift-wrap (NIP-59), signed by an ephemeral, discarded Schnorr key, with
created_at rounded to the minute and the recipient inbox pubkey in the p
tag. The recipient's chat inbox pubkey is HKDF(device_sk) — so publishing a
device record IS creating an inbox; there is no second ceremony.
The v2 encrypted wrap (NORMATIVE, ct = "oc-chat/v2")
The wrap content MUST be encrypted to the recipient inbox key, not merely
encoded. The chat envelope's own plaintext fields (from.address,
recipients[*].address/device_id/device_pk, full-precision created_at)
plus the sender's stable device pubkey would otherwise hand every relay — and
the durable-inbox operator — the full who-talks-to-whom graph. The
construction:
shared = x( ECDH(eph_sk, lift_x(inbox_pk)) ) # x-coordinate only — negation-safe for BIP-340 x-only keys
key = HKDF-SHA256(ikm=shared, salt="oc-chat/v2", info=eph_pk_hex || inbox_pk_hex, L=32)
content = base64url( nonce(12) || AES-256-GCM(key, nonce, payload_json) )
eph_sk is the same ephemeral key that signs the outer event, so
event.pubkey is the ECDH counterpart the recipient uses; binding key to the
(eph_pk, inbox_pk) pair stops cross-wrap key reuse. A conforming publisher
MUST emit oc-chat/v2; a recipient SHOULD also accept the legacy plain-base64
oc-chat/v1 for messages already in flight, and MUST treat a v2 wrap that fails
the AEAD as not addressed to it.
What a relay sees after the v2 wrap:
| Visible to the relay | Closed by the v2 wrap |
|---|---|
| an ephemeral pubkey | the sender's identity (fresh throwaway key every message) |
the inbox p tag | the recipient's Bitcoin address + device set |
| a minute timestamp | the full-precision send time |
| ciphertext | the body, the conversation, every envelope field |
The residual disclosure is the inbox p tag and the timing — narrowed further
by relay AUTH and, on the durable inbox, eliminated by
opaque queue routing.
Durable inbox
The gift-wrap rendezvous is best-effort: a relay MAY garbage-collect an event before an offline recipient connects, so an offline recipient + a swept relay event = a lost message. A conforming deployment therefore SHOULD provide a store-and-forward inbox — an operator-run queue retaining the opaque gift-wrap inner blob until the recipient drains it.
This is the free-tier floor, not a paid feature. A paid tier extends only the retention horizon, multi-device fan-out, and history depth. Durability of basic delivery is never the paywall.
The operator MUST be unable to (a) read any message — it holds ciphertext only, never a key — or (b) link two conversations of one recipient, or link a queue to a Bitcoin identity. Routing every conversation to the single published inbox pubkey would let an operator that retains enumerate a recipient's whole correspondence. Durable routing therefore uses an opaque per-conversation queue id derived from a recipient secret.
Queue derivation
queue_seed = HKDF-SHA256(ikm = device_sk, salt = "", info = "oc-lock-chat/inbox-queue-seed/v1", L = 32)
queue_id(c) = base64url( HMAC-SHA256(key = queue_seed, msg = utf8(conversation_id)) ) // 43 chars, unpadded
queue_seedis a per-device secret — never published, never leaving the device unencrypted.queue_id(c)is unguessable withoutqueue_seed, and two conversations on the same device produce unrelated ids — so the operator sees N independent queues, not one recipient (test vectorvc06).- A device recomputes
queue_idfor any conversation fromqueue_seed+conversation_id, holding no stored queue↔conversation map.
Bootstrap (first contact)
A sender reaching a recipient for the first time cannot yet compute
queue_id(c) (it derives from a secret the sender lacks). The first inbound
message of a new conversation goes to the recipient's bootstrap queue,
derivable by anyone holding the recipient's device record:
bootstrap_id = base64url( SHA-256( utf8("oc-lock-chat/inbox-bootstrap/v1:" || inbox_pubkey_hex) ) )
The bootstrap queue carries the same recipient-linkability the relay p tag
does and MUST be disclosed as such; it is used only until a per-conversation
queue is established.
Handshake — migrating off bootstrap
Each party advertises its own receiving queue_id inside the encrypted
payload (the
recv_queue field). A
party that has learned its peer's recv_queue MUST deposit subsequent messages
there and SHOULD stop using the bootstrap queue. Because recv_queue travels
inside the AEAD-sealed payload, the operator never learns the bootstrap ↔
per-conversation mapping.
Operator obligations (NORMATIVE)
A store-and-forward inbox operator MUST:
- Store the gift-wrap inner blob byte-for-byte — preserving the envelope
id,sig, ciphertext GCM tag, and unknown fields. A re-wrap fan-out is a detachedrecipients[]merge and MUST NOT recompute the signedid. - Route solely on the opaque
queue_id/bootstrap_id— never require, store, or index a Bitcoin address, device pubkey, or plaintext thread metadata. - Hold no key material of any party.
- Treat the queue as availability, not authority: a draining recipient
re-verifies authenticity from the artifact alone (BIP-322 device-record
binding +
chat_envelope_id), exactly as for a relay-delivered message.
The operator endpoint is a named trust anchor for availability and MUST be surfaced plainly. The Ed25519 substitution test passes by inheritance: the queue is an ordinary mailbox asserting no Bitcoin claim of its own — the authority of every message it holds derives from the BIP-322-rooted device record.
Relay AUTH (NIP-42)
The transport leaves one disclosed residue: the inbox pubkey in the kind-1059
p tag, plus the bare fact of a connection, are visible to any observer of an
open relay. NIP-42 lets a relay demand an
authenticated connection before it serves or accepts events, so an
unauthenticated observer never sees the p tag. Pairing an auth-required
relay with the v2 wrap is the institutional privacy posture.
Handshake. On an auth-required relay the server sends
["AUTH", <challenge>]; the client replies
["AUTH", <signed kind-22242 event>] carrying ["relay", <url>] +
["challenge", <challenge>] tags, a recent created_at, empty content, and a
Schnorr signature. A client MUST complete this before publishing/subscribing on
such a relay, and MUST re-attempt a request the relay rejected with
auth-required: after completing AUTH (E_RELAY_AUTH_REQUIRED).
Which key signs. The kind-22242 event is not the gift-wrap and MUST NOT
be signed by an identity key or tied to any one message's ephemeral wrap key.
The default is a fresh, per-connection ephemeral key discarded with the
socket — the relay learns one random pubkey linking to no identity and no
message: pure access-gating. A relay that allow-lists specific members MAY
require a designated stable credential key, named per relay (auth_key).
Ed25519 verdict: relay AUTH is not Bitcoin-load-bearing — it works with any Schnorr keypair and carries no Bitcoin claim (the default key is deliberately random). It is a metadata-privacy hardening that rides on the identity already proven by the device record. An
auth-requiredrelay still sees the metadata of clients it admits — so it is a named trust anchor (its URL surfaced in relay settings), and a client MUST NOT present AUTH as making a relay trustless. The shared family relay relay.ochk.io is open by default; AUTH is an opt-in posture for self-hosted / institutional relays.
Next
- Threading & attachments — what rides inside the encrypted payload.
- Channels — where source-intake pairs AUTH with public posts.
- Security posture — S6, S9, S10 (the transport's named residues).