MOP-001: Capability-URL Message Envelope
1. Abstract {#mop-001-1}
MOP (Message Object Protocol) is a relay-agnostic wire format for short, end-to-end-encrypted messages addressed by capability URL: a host-blind reference whose authorization material lives in the URL fragment and therefore never traverses the host. MOP-001 specifies the foundational envelope — the byte layout of a published wyrd, the cryptographic constructions that bind ciphertext to addressing metadata, and the minimum HTTP contract a conformant relay MUST honor.
The protocol is deliberately minimal. It carries one short body (≤ 300 codepoints of countable prose), under one symmetric key, addressed by one 96-bit handle, signed by one ephemeral secp256k1 keypair. No identity, no presence, no thread graph at this layer. Identity, threading, and social topology — if needed — are layered above by reference implementations such as SendWyrd. Relays are dumb mailboxes by design: they store ciphertext keyed by handle, enforce TTL, and serve fetches. They cannot read message bodies, link authors across messages, or learn recipient identities from publish or fetch traffic.
The wire format follows from a single design constraint: the read key is a URL fragment. RFC 3986 §3.5 mandates that fragments are processed client-side and never sent to the host. From that constraint, host-blindness is mechanical — not a policy promise, not a "we don't log" affidavit, but a property of where the bytes go on the network. Everything else in MOP-001 is downstream of preserving that property under TTL, replay, signing, and reply semantics.
This document is the wire spec. Section 4 is the bytes. Section 6 is the crypto. Section 8 is the relay contract. Sections 9–11 are the testable conformance surface. A clean-room Go implementation can be built from this document alone.
2. Terminology {#mop-001-2}
The key words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, NOT RECOMMENDED, MAY, and OPTIONAL in this document are to be interpreted as described in BCP 14 (RFC 2119, RFC 8174) when, and only when, they appear in all capitals.
Wyrd : One published message. The atomic unit MOP addresses, encrypts, signs, and serves.
Handle : A 12-byte (96-bit) identifier, base64url-encoded to 16 ASCII characters without padding. The wyrd's primary key on the relay. Client-generated; collisions are rejected at the database uniqueness constraint.
K_read
: Per-wyrd 32-byte symmetric key for AES-256-GCM body encryption. Travels in the URL fragment.
K_origin
: Per-wyrd secp256k1 keypair authorizing publish, delete (burn), reply-fetch, and presence-check operations. The private half (K_origin_priv) stays on the author's device; the public half (K_origin_pub) is exposed by the relay alongside the envelope.
Envelope : Byte-framed AES-256-GCM ciphertext binding a body to handle, expiry, and replies-enabled flag. Wire-form per §4.2.
Capability URL
: A URL of the form https://<relay>/w/<handle>#<K_read_b64u>. Possession of the URL grants exactly the right to fetch and decrypt the wyrd. The fragment is the bearer credential; the host serves ciphertext to anyone who asks for the handle.
Relay : An HTTP server that accepts publishes, serves fetches by handle, and enforces the lifecycle rules in §8. Relays are read-blind: they can correlate handles to published-at timestamps and IPs, but cannot read bodies.
TTL
: Time-to-live in seconds. A wyrd's ciphertext is served for ttl_seconds from publish, after which the relay returns a tombstone.
Permanent wyrd
: A wyrd published with ttl_seconds = 0. The sentinel binds to a fixed expiry timestamp PERMANENT_EXPIRES_AT_MS (year 9999) inside the AAD; see §7.2.
Tombstone
: The 410 Gone response returned for an expired or burned wyrd during the retention window (30 days post-gone_at).
3. Threat Model {#mop-001-3}
MOP-001 defends against the following adversaries:
The relay itself. The relay sees ciphertexts, handles, K_origin pubkeys, publish timestamps, and the source IPs of publish/fetch traffic. It MUST NOT be able to read message bodies, link an author's wyrds across publishes (each wyrd uses a fresh K_origin), or forge publishes/burns on behalf of a key it does not hold.
Network observers. TLS protects transport. An on-path observer who can break TLS to a specific relay reduces to the relay-as-adversary case for that fetch only.
Anyone who learns a capability URL. This is not an adversary; this is the design. Possession of the URL is the read grant. The threat model is that the URL is treated as a bearer secret: shared by the author over a chosen channel, with the understanding that anyone the URL reaches can read.
An adversary who learns the URL but not the fragment. Such an adversary (e.g., an indexer that scrapes the host pathname) MUST NOT be able to decrypt the body. This is mechanically true: the host never receives the fragment.
Replay. A captured publish, delete, reply-fetch, or presence-check signature MUST NOT be reusable outside a 60-second window centered on its embedded timestamp.
Tampering. Modifying the envelope, handle, expiry, or replies-enabled flag MUST cause decrypt to fail. The AAD construction in §4.3 binds these fields to the ciphertext.
Authorship forgery. A wyrd published with K_origin keypair K can only be burned by the holder of K_origin_priv; replies on that wyrd can only be decrypted by the holder of K_origin_priv.
MOP-001 does not defend against:
- Traffic analysis at the relay (the relay observes who published when, and who fetched what).
- Compromise of the author's device or seed phrase (the entire HD wallet can be reconstructed).
- A capability URL leaked into a public log (the URL grants read; that is its purpose).
- Long-term cryptographic breaks of secp256k1, AES-256, or SHA-256.
- Quantum adversaries (this is a v1 footgun acknowledged here; MOP-002+ may introduce post-quantum primitives).
The protocol is host-blind, not anonymous. Relays SHOULD operate without account systems and SHOULD NOT log fetch IPs beyond what is needed for rate limiting; conformant relays publish a per-host warrant canary at /canary per §10.
4. Wire Format {#mop-001-4}
All binary values on the wire are base64url-encoded per RFC 4648 §5 with no padding (the = characters MUST be omitted). Multi-byte integers in cryptographic constructions are big-endian unless otherwise specified.
4.1 Constants {#mop-001-4-1}
| Name | Value | Purpose |
|---|---|---|
PROTOCOL_VERSION |
1 |
Decimal protocol version, distinct from ENVELOPE_VERSION. |
ENVELOPE_VERSION |
0x01 |
First byte of every envelope and AAD. |
HANDLE_BYTES |
12 |
Raw handle byte length (96 bits). |
HANDLE_CHARS |
16 |
Base64url(no-pad) length of an encoded handle. |
K_READ_BYTES |
32 |
AES-256-GCM key length. |
K_READ_CHARS |
43 |
Base64url(no-pad) length of an encoded K_read. |
K_ORIGIN_PUB_BYTES |
33 |
SEC1 compressed secp256k1 pubkey. |
ENVELOPE_IV_BYTES |
12 |
AES-GCM nonce length. |
ENVELOPE_TAG_BYTES |
16 |
AES-GCM authentication tag length. |
BODY_CODEPOINT_CAP |
300 |
Maximum countable codepoints in a body. |
ENVELOPE_BYTE_CEILING |
1500 |
Maximum envelope size accepted by relays. |
REPLY_CODEPOINT_CAP |
300 |
Maximum codepoints in a reply. |
REPLY_BLOB_BYTE_CEILING |
2500 |
Maximum reply blob size. |
TTL_SECONDS_MIN |
0 |
0 is the permanent sentinel; otherwise minimum is 1. |
TTL_SECONDS_MAX |
31_536_000 |
One year (60 × 60 × 24 × 365). |
TTL_SECONDS_DEFAULT |
7_776_000 |
90 days. |
PERMANENT_EXPIRES_AT_MS |
253_370_764_800_000 |
Year 9999 sentinel; see §7.2. |
REPLAY_WINDOW_MS |
60_000 |
Signed-message timestamp tolerance. |
TOMBSTONE_RETENTION_DAYS |
30 |
How long 410 Gone is served before 404. |
4.2 Envelope byte layout {#mop-001-4-2}
The envelope is the concatenation:
ver(1) || iv(12) || ciphertext(N) || tag(16)
where:
verisENVELOPE_VERSION(0x01). A relay receiving any other value MUST reject the publish with HTTP 400 /malformed_request. A client receiving any other value on fetch MUST refuse to decrypt.ivis a uniformly random 96-bit nonce drawn from a CSPRNG.ciphertextis the AES-256-GCM ciphertext of the UTF-8 encoded body, under the construction in §6.3, with the AAD in §4.3.tagis the 128-bit GCM authentication tag, appended to the ciphertext (matches the Web Crypto API output convention).
The envelope total length MUST satisfy 1 + 12 + 16 ≤ len ≤ ENVELOPE_BYTE_CEILING. A publish MUST be rejected with HTTP 413 / payload_too_large if the envelope exceeds ENVELOPE_BYTE_CEILING bytes.
4.3 AAD construction {#mop-001-4-3}
The Additional Authenticated Data passed to AES-256-GCM is the concatenation:
ver(1) || handle(12) || expires_at_ms_be(8) || replies_enabled(1)
— total 22 bytes. Field semantics:
| Field | Bytes | Type | Encoding |
|---|---|---|---|
ver |
1 | uint8 | Equals ENVELOPE_VERSION (0x01). |
handle |
12 | raw bytes | The handle's raw bytes (NOT the base64url string). |
expires_at_ms |
8 | uint64 | Big-endian Unix epoch milliseconds. For ttl_seconds > 0, equals publish_timestamp_ms + ttl_seconds × 1000. For ttl_seconds = 0, equals PERMANENT_EXPIRES_AT_MS. See §7.2. |
replies_enabled |
1 | uint8 | 0x01 if true, 0x00 if false. |
Tampering with any AAD byte MUST cause AES-GCM tag verification to fail. This binds ciphertext to handle, expiry, and replies-policy: a relay cannot serve a wyrd's ciphertext under a different handle, extend its expiry, or flip its replies flag without invalidating the tag.
4.4 Publish request body {#mop-001-4-4}
POST /api/v1/wyrds body, application/json:
{
"handle": "<16-char-b64u>",
"envelope": "<b64u envelope bytes>",
"k_origin_pub": "<b64u 33-byte SEC1 compressed>",
"ttl_seconds": 7776000,
"replies_enabled": false,
"publish_signature": "<b64u 64-byte schnorr sig>",
"publish_timestamp_ms": 1717000000000
}
Field reference:
| Field | Type | Encoding | Constraints |
|---|---|---|---|
handle |
string | b64u | MUST match ^[A-Za-z0-9_-]{16}$. |
envelope |
string | b64u | Decoded length ≤ ENVELOPE_BYTE_CEILING. |
k_origin_pub |
string | b64u | Decoded length MUST equal 33 (SEC1 compressed). |
ttl_seconds |
number | integer | 0 ≤ ttl_seconds ≤ 31_536_000. |
replies_enabled |
boolean | — | Strict boolean; not "true"/"false" strings. |
publish_signature |
string | b64u | Decoded length 64 (BIP-340 Schnorr). |
publish_timestamp_ms |
number | integer | Unix epoch ms; MUST be within ±REPLAY_WINDOW_MS of relay clock. |
The relay MUST verify the Schnorr signature against the publish_message hash defined in §6.4 and the X-only pubkey derived from k_origin_pub (drop the leading parity byte). On failure: HTTP 422 / signature_invalid.
4.5 Fetch envelope response {#mop-001-4-5}
GET /api/v1/wyrds/{handle} 200 response, application/json:
{
"handle": "<16-char-b64u>",
"envelope": "<b64u envelope bytes>",
"k_origin_pub": "<b64u 33-byte SEC1 compressed>",
"published_at": 1717000000000,
"expires_at": 1724776000000,
"replies_enabled": false
}
published_at and expires_at are Unix epoch milliseconds. For permanent wyrds, expires_at MUST equal PERMANENT_EXPIRES_AT_MS (253_370_764_800_000). The client MUST use this exact value when reconstructing the AAD for decryption — see §7.2.
4.6 Tombstone response {#mop-001-4-6}
When a wyrd is gone (expired or burned), GET /api/v1/wyrds/{handle} returns HTTP 410 with body:
{
"status": "gone",
"reason": "expired",
"gone_at": "2026-04-28T12:00:00.000Z"
}
reason is one of "expired", "burned", "key_mismatch". gone_at is ISO-8601 UTC. Tombstones are served for TOMBSTONE_RETENTION_DAYS (30 days) after gone_at; thereafter the relay MUST return 404.
4.7 Delete (burn) request {#mop-001-4-7}
DELETE /api/v1/wyrds/{handle} body:
{
"delete_signature": "<b64u 64-byte schnorr sig>",
"delete_timestamp_ms": 1717000000000
}
Signature is over the delete_message hash in §6.4. On success the relay MUST clear the envelope ciphertext (set to zero-length), set gone_at = now, gone_reason = "burned", and cascade-delete any replies. Response 200:
{ "handle": "<16-char-b64u>", "gone_at": 1717000000000, "gone_reason": "burned" }
A burn on an already-gone wyrd MUST return 410 with the existing tombstone (idempotent).
4.8 Reply submission and fetch {#mop-001-4-8}
POST /api/v1/wyrds/{handle}/replies body:
{ "reply_blob": "<b64u reply blob>", "submit_timestamp_ms": 1717000000000 }
The reply blob is the ECIES envelope per §6.5. Its size MUST NOT exceed REPLY_BLOB_BYTE_CEILING (2500 bytes). Replies submitted to a wyrd whose replies_enabled = false MUST be rejected with HTTP 403 / replies_disabled.
GET /api/v1/wyrds/{handle}/replies returns the array of stored reply blobs with their received_at timestamps. Fetch is authorized by a K_origin Schnorr signature over fetch_replies_message (§6.4); only the wyrd's author can pull replies.
4.9 Authorship attestation body convention {#mop-001-4-9}
A wyrd whose body is exactly three lines:
sendwyrd-attestation/v1
target=<16-char-handle-b64u>
sig=<86-char-sig-b64u>
is an authorship attestation. The sig is a BIP-340 Schnorr signature by the target wyrd's K_origin_priv over authorship_attestation_message (§6.4). Renderers MAY surface a verification banner; relays MUST NOT inspect bodies. The body convention is defined in this layer because the message format is public; it does not bind any relay behavior.
Note on the
sendwyrd-prefix. This prefix is a reference-implementation artifact. A future MOP revision SHOULD generalize tomop-attestation/v1once a second implementation ships. Implementations MUST accept thesendwyrd-attestation/v1literal for v1 conformance.
5. Capability URL Scheme {#mop-001-5}
5.1 Canonical form {#mop-001-5-1}
The canonical capability URL is fragment-form:
https://<relay-host>/w/<handle>#<K_read_b64u>
<handle>is exactly 16 base64url characters.<K_read_b64u>is exactly 43 base64url characters (32 bytes, no padding).- The fragment MUST be the read key, nothing else.
A conformant client MUST emit this form. The fragment is processed client-side per RFC 3986 §3.5 and is never sent to the relay on fetch; this is the structural property that makes the relay host-blind.
5.2 Legacy path form {#mop-001-5-2}
A path-form URL https://<relay-host>/w/<handle>/k/<K_read_b64u> MAY be recognized at parse time for backward compatibility with v0 implementations. Composers MUST NOT emit this form. Web clients SHOULD client-side redirect path-form URLs to fragment-form before any decode attempt, so the read key never reaches the host.
5.3 Native scheme {#mop-001-5-3}
The URI scheme sendwyrd://w/<handle>#<K_read_b64u> (or with path-form k-segment) MAY be used in body text for transitive references between wyrds. It is not a transport scheme; clients normalize it to the canonical relay host before fetching.
The
sendwyrd://scheme prefix is a reference-implementation artifact. MOP-002+ SHOULD register a genericmop://scheme; until then, implementations MUST acceptsendwyrd://for v1 interop.
5.4 Sharing on Nostr {#mop-001-5-4}
A MOP capability URL is a self-describing artifact and shares cleanly across any transport, including Nostr. To share on Nostr, paste the URL into kind:1 content (or into the inner sealed event of a NIP-17 gift-wrap for direct messages). Aware Nostr clients MAY recognize the URL pattern and render it as an encrypted-artifact card via standard OpenGraph metadata served by the relay; no special tag schema is required.
Implementations bridging MOP to Nostr MUST observe one privacy rule, derived from fragment-hygiene (§10.1):
- The read key (URL fragment) MUST NOT be placed inside any indexable Nostr tag. Tags are stored cleartext on relays and are scrapeable; fragments are bearer secrets. If implementing a tag-based reference convention, the canonical URL MUST appear with the fragment stripped.
An earlier draft of this spec referenced NIP-C6 (Capability-URL References,
nostr-protocol/nips#2327) as a normative bridge. That NIP was withdrawn 2026-04-28 in favor of the simpler "URL self-identifies + standard OpenGraph rendering" model — the URL alone is the contract, no per-event tag schema is needed for capability artifacts to render or distinguish reference from share (fragment present = share, fragment absent = reference).
6. Cryptographic Primitives {#mop-001-6}
6.1 Curves and ciphers {#mop-001-6-1}
| Use | Primitive | Notes |
|---|---|---|
| Asymmetric | secp256k1 | NIST not used; matches Bitcoin/Nostr. |
| Signing | BIP-340 Schnorr | 64-byte sig, 32-byte X-only pubkey. |
| Key agreement | secp256k1 ECDH | X-coordinate of shared point (32 bytes). |
| AEAD | AES-256-GCM | 96-bit IV, 128-bit tag. |
| KDF | HKDF-SHA256 | Per RFC 5869. |
| Hash | SHA-256 | For canonical message construction. |
| Mnemonic | BIP-39 (English) | 12 words default, 24 words OPTIONAL. |
| HD derivation | BIP-32 | secp256k1 master key + hardened derivation. |
The reference implementation uses @noble/curves, @noble/hashes, @noble/ciphers, @scure/bip32, @scure/bip39. Any equivalent library implementing the same standards is conformant.
6.2 HD derivation {#mop-001-6-2}
An author's BIP-39 mnemonic produces a 64-byte BIP-39 seed (PBKDF2 per BIP-39). Per-wyrd keys are derived from this seed indexed by an unsigned 31-bit integer n, the HD index.
K_origin keypair at index n — BIP-32 hardened derivation path m/300'/n':
purpose = 300 (decimal, BIP-43 flat)
path = m / 300' / n'
Both levels are hardened. The 32-byte private key is the leaf node's private key; the 33-byte SEC1 compressed public key is secp256k1.getPublicKey(priv, compressed=true); the 32-byte BIP-340 X-only pubkey is the trailing 32 bytes of the compressed form.
K_read symmetric key at index n — HKDF-SHA256:
ikm = bip39_seed (64 bytes)
salt = "" (empty, NOT a literal "salt" string)
info = "sendwyrd:k_read" || n_be_4bytes
out = 32 bytes
Domain separation: the secp256k1 path m/300'/n' and the HKDF info "sendwyrd:k_read" || n_be_4bytes live in disjoint key-derivation namespaces, so K_origin and K_read at the same index never collide.
The HKDF info string
"sendwyrd:k_read"is a reference-implementation artifact frozen for v1 conformance. A future MOP-NNN will normalize this and other mixed domain prefixes (see §6.5) to"mop:v1:..."with a versioned migration. v1 implementations MUST use the literal byte string"sendwyrd:k_read"followed by 4-byte big-endiann.
The HD index n MUST be consumed (incremented) on every compose attempt regardless of publish success, to prevent IV/key reuse across retries.
6.3 Body encryption (AES-256-GCM) {#mop-001-6-3}
Encrypt:
plaintext_bytes = utf8_encode(body)
iv = csprng(12)
aad = build_aad(handle, expires_at_ms, replies_enabled) // §4.3
(ct, tag) = AES-256-GCM-Encrypt(K_read, iv, plaintext_bytes, aad)
envelope = 0x01 || iv || ct || tag
Decrypt:
require envelope[0] == 0x01
iv = envelope[1..13]
ct_tag = envelope[13..]
aad = build_aad(handle_bytes, expires_at_ms, replies_enabled)
plaintext = AES-256-GCM-Decrypt(K_read, iv, ct_tag, aad)
Decryption MUST fail closed: any tag mismatch, length error, or version mismatch surfaces as decrypt failure with no further attempt. UTF-8 decode of the plaintext MUST be strict (fatal: true).
6.4 Canonical signed messages {#mop-001-6-4}
All signing is BIP-340 Schnorr over a 32-byte SHA-256 digest. Inputs are concatenated big-endian byte strings with no length prefixes.
be8(x) denotes the 8-byte big-endian unsigned 64-bit encoding of x.
Publish (signed by author at compose time):
publish_message := SHA-256(
"mop:v1:publish" // 14 ASCII bytes
|| handle // 12 raw bytes
|| envelope // full envelope bytes
|| be8(ttl_seconds) // 8 bytes
|| (replies_enabled ? 0x01 : 0x00) // 1 byte
|| be8(publish_timestamp_ms) // 8 bytes
)
Delete (burn):
delete_message := SHA-256(
"mop:v1:delete" // 13 ASCII bytes
|| handle // 12 raw bytes
|| be8(delete_timestamp_ms) // 8 bytes
)
Fetch replies:
fetch_replies_message := SHA-256(
"mop:v1:fetch_replies" // 20 ASCII bytes
|| handle // 12 raw bytes
|| be8(fetch_timestamp_ms) // 8 bytes
)
Presence check (HD recovery sweep):
presence_check_message := SHA-256(
"mop:v1:presence_check" // 21 ASCII bytes
|| k_origin_pub // 33 bytes SEC1 compressed
|| be8(presence_timestamp_ms) // 8 bytes
)
Authorship attestation (binds only to target handle, not to attestation's own handle):
authorship_attestation_message := SHA-256(
"mop:v1:authorship_attestation" // 29 ASCII bytes
|| target_handle // 12 raw bytes
)
The
mop:v1:prefix is the canonical MOP-001 domain tag. Reference implementation uses these identical strings.
6.5 Reply ECIES {#mop-001-6-5}
Replies are encrypted to the wyrd author's ephemeral K_origin_pub. Construction:
Encrypt (replier holds the wyrd's K_origin_pub):
1. e_priv = csprng(32) // ephemeral secp256k1 private key
2. e_pub = secp256k1.getPublicKey(e_priv, compressed=true) // 33 bytes
3. shared = X-coordinate of ECDH(e_priv, K_origin_pub) // 32 bytes
4. aes_key = HKDF-SHA256(
ikm=shared, salt="",
info="mop:v1:reply:aes_key:" || handle || e_pub,
len=32)
5. iv = HKDF-SHA256(
ikm=shared, salt="",
info="mop:v1:reply:iv:" || handle || e_pub,
len=12)
6. aad = 0x01 || handle(12) || e_pub(33) // 46 bytes
7. (ct, tag) = AES-256-GCM-Encrypt(aes_key, iv, plaintext_bytes, aad)
8. blob = 0x01 || e_pub(33) || ct || tag
Decrypt (author holds K_origin_priv):
Mirror with shared = X-coordinate of ECDH(K_origin_priv, e_pub). Tag mismatch fails closed.
The HKDF info strings
"mop:v1:reply:aes_key:"and"mop:v1:reply:iv:"are normative bytes. Reference implementation currently uses these identical literals; the trailing:is part of the info string.
The HKDF info includes both handle and e_pub so reply blobs are bound to the specific wyrd and the specific ephemeral key — replay of a reply ciphertext under a different e_pub or handle MUST fail authentication.
Note on domain-prefix consistency. MOP-001 v1 freezes the as-shipped mixed prefixes — signed messages and reply-ECIES use "mop:v1:...", while HD HKDF (§6.2) uses "sendwyrd:k_read" and seed-store derivation uses "sendwyrd:v1:seedstore". These are KDF inputs whose outputs are already in production; rotating them is a wire-format break. A future MOP-NNN will normalize all domain prefixes to "mop:v1:..." with a versioned migration path; v1 conformance MUST use the literal byte strings as documented in this spec.
7. TTL and Permanence Semantics {#mop-001-7}
7.1 Standard TTL {#mop-001-7-1}
For ttl_seconds in [1, 31_536_000], the relay computes expires_at_ms = publish_timestamp_ms + ttl_seconds × 1000. The client MUST compute the same value and bind it into the AAD per §4.3.
The relay stores the ciphertext until expires_at_ms; on the first fetch after expiry, the relay MUST flip the row's state to gone with gone_reason = "expired" and gone_at = now, then return a 410 tombstone for TOMBSTONE_RETENTION_DAYS thereafter. Lazy expiration is acceptable; eager sweeps are also acceptable. The relay MUST NOT serve the envelope after expires_at_ms.
ttl_seconds = TTL_SECONDS_DEFAULT (90 days) is the RECOMMENDED default for clients that do not surface a TTL choice to the user.
7.2 Permanent wyrds and the AAD footgun {#mop-001-7-2}
A ttl_seconds = 0 value is the permanent sentinel. A wyrd published with ttl_seconds = 0 is intended to never expire (modulo author burn). Both client and relay MUST bind the AAD's expires_at_ms field to the fixed sentinel timestamp:
PERMANENT_EXPIRES_AT_MS = 253_370_764_800_000 // Year 9999-01-01T00:00:00Z, in ms
The relay stores expires_at = PERMANENT_EXPIRES_AT_MS in its database for permanent rows. On fetch, the relay MUST return this exact value in the expires_at field of the response.
NORMATIVE FOOTGUN — implementer take note.
The AAD of a permanent wyrd is bound to
PERMANENT_EXPIRES_AT_MS, not to any historical "expires_at" value the client may have stored locally at compose time, not to a future-recomputed value, and not to thepublished_at + (some default TTL)heuristic.Any client-side feature that reconstructs the AAD to decrypt its own historical permanent wyrds — for example, an "open my own past wyrds" recovery flow — MUST use the literal value
253_370_764_800_000as the AAD'sexpires_at_msfor any wyrd where the originalttl_secondswas0. A naïve implementation that stores the historicalexpires_atfrom the publish receipt and uses that value will succeed for finite-TTL wyrds and silently fail-decrypt for permanent ones, because the historical receipt'sexpires_atequalsPERMANENT_EXPIRES_AT_MSonly if the client wrote that exact constant — implementations that recompute or round will diverge.Conformance test vectors in
/mop-conformance/vectors/MUST include at least one permanent-wyrd round-trip exercising this binding.
A relay MAY refuse permanent publishes for resource reasons (e.g., free tier limit). If it does, it MUST return HTTP 422 / permanence_disabled rather than silently clamping ttl_seconds = 0 to a finite TTL — silent clamping would invalidate the AAD binding and render the wyrd undecryptable.
7.3 Burn semantics {#mop-001-7-3}
A burn (DELETE per §4.7) clears the envelope ciphertext to zero length and sets gone_reason = "burned". The 30-day tombstone is then served. Burn is the only way to take a permanent wyrd offline before year 9999.
A burn signature replays once within REPLAY_WINDOW_MS. The relay SHOULD NOT cache burn signatures; idempotence on already-gone rows is provided by the row-state check, not by signature memoization.
8. Relay Behavior {#mop-001-8}
8.1 Storage {#mop-001-8-1}
A conformant relay MUST persist, per wyrd:
handle(PK, unique)k_origin_pub(33 bytes raw)envelope(variable, ≤ENVELOPE_BYTE_CEILING)published_at(timestamp)expires_at(timestamp)replies_enabled(boolean)gone_at(nullable timestamp)gone_reason(nullable enum:expired | burned | key_mismatch)
The relay MUST NOT persist message bodies in any decrypted form. The relay MUST NOT log fetch IPs in any retained form beyond the rate-limiter's working set.
8.2 Publish (POST /api/v1/wyrds) {#mop-001-8-2}
The relay MUST validate, in order:
- Body parses as JSON; all required fields present.
handlematches^[A-Za-z0-9_-]{16}$.envelope,k_origin_pub,publish_signaturedecode as base64url; sizes are within bounds.ttl_seconds ∈ [0, 31_536_000].publish_timestamp_msis within±REPLAY_WINDOW_MSof relay clock.- BIP-340 Schnorr signature verifies against
publish_message(§6.4) and the X-only ofk_origin_pub. handleis not already taken (DB unique constraint); on conflict, return 409handle_collision_retry.
On success: insert the row, return 201 with { handle, published_at, expires_at }.
The relay MUST NOT inspect or interpret the envelope ciphertext. The relay MUST NOT publish a wyrd whose AAD-implied expires_at differs from the relay's computed expires_at (relay computes from publish_timestamp_ms + ttl_seconds × 1000, or PERMANENT_EXPIRES_AT_MS if ttl_seconds = 0); since the AAD is opaque to the relay, this is enforced indirectly by the client's signature, which covers ttl_seconds, replies_enabled, and publish_timestamp_ms. A client that lies about any of these breaks its own decryption.
8.3 Fetch (GET /api/v1/wyrds/{handle}) {#mop-001-8-3}
The relay MUST:
- Return 404 if no row matches.
- If
gone_atis set andnow - gone_at ≤ TOMBSTONE_RETENTION_DAYS, return 410 with the tombstone (§4.6). - If
gone_atis set andnow - gone_at > TOMBSTONE_RETENTION_DAYS, return 404. - If
expires_at ≤ now, setgone_at = now,gone_reason = "expired", persist, return 410. - Otherwise return 200 with the envelope payload (§4.5).
Fetches are unauthenticated: anyone with the handle gets ciphertext. Authorization is the K_read fragment, which the client never sends to the relay.
8.4 Burn (DELETE /api/v1/wyrds/{handle}) {#mop-001-8-4}
Per §4.7. The relay MUST verify the Schnorr signature against the row's k_origin_pub. Burn an already-gone wyrd MUST return 410 idempotently.
8.5 Replies {#mop-001-8-5}
The relay MUST:
- Reject reply submissions to wyrds with
replies_enabled = false(HTTP 403 /replies_disabled). - Reject reply submissions to gone wyrds (HTTP 410).
- Cap reply blobs at
REPLY_BLOB_BYTE_CEILING(HTTP 413). - On burn, cascade-delete all replies for that handle.
- Authorize reply fetches with a Schnorr signature over
fetch_replies_message.
8.6 Replay defense {#mop-001-8-6}
Every signed operation (publish, delete, fetch-replies, presence-check) MUST include a *_timestamp_ms field within ±REPLAY_WINDOW_MS (60 s) of the relay's clock. The relay MAY additionally maintain a short-window signature cache to drop literal replays, but the timestamp window suffices for v1 conformance.
8.7 Rate limiting {#mop-001-8-7}
The relay SHOULD apply per-IP rate limits to write operations (publish, burn, reply submission) and to fetch operations. Specific quotas are out of scope for MOP-001; reference implementation values are not normative.
9. Conformance {#mop-001-9}
A conformant MOP-001 implementation — relay or client — MUST satisfy every assertion below. Each anchor ID is a stable reference for the conformance test suite.
9.1 Wire-format conformance {#mop-001-9-1}
- A composed envelope MUST round-trip through encrypt → decrypt with the AAD construction in §4.3.
- Tampering with any byte of
handle,expires_at_ms, orreplies_enabledin the AAD MUST cause decrypt failure. - An envelope whose
verbyte is not0x01MUST be rejected on both publish and fetch.
9.2 Capability-URL conformance {#mop-001-9-2}
- Composers MUST emit fragment-form URLs. Path-form URLs MUST NOT be emitted.
- Parsers MUST accept fragment-form and SHOULD accept path-form for backward compatibility.
- Web clients MUST rewrite path-form URLs to fragment-form before any fetch.
9.3 Crypto conformance {#mop-001-9-3}
- HD derivation MUST follow
m/300'/n'with both levels hardened;nMUST be in[0, 2^31 - 1]. - K_read MUST equal
HKDF-SHA256(seed, "", "sendwyrd:k_read" || be4(n), 32). - All Schnorr signatures MUST be BIP-340 over a 32-byte SHA-256 digest with the canonical message constructions in §6.4.
- ECIES reply blobs MUST follow the construction in §6.5 exactly, including HKDF info strings.
9.4 Permanence conformance {#mop-001-9-4}
- A
ttl_seconds = 0publish MUST cause both client and relay to usePERMANENT_EXPIRES_AT_MS(253_370_764_800_000) in AAD construction and storage. - A relay MUST NOT silently clamp
ttl_seconds = 0to a finite TTL. - A client that decrypts its own historical permanent wyrd MUST use
PERMANENT_EXPIRES_AT_MSliterally for AAD reconstruction, regardless of any locally cachedexpires_atvalue.
9.5 Relay-behavior conformance {#mop-001-9-5}
- Publish MUST verify the Schnorr signature before persisting the row.
- Fetch MUST return 410 for expired or burned wyrds within the 30-day tombstone window.
- Burn MUST clear the envelope ciphertext and cascade-delete replies.
- Replay window MUST be enforced at
±60_000ms on every signed operation.
10. Security Considerations {#mop-001-10}
10.1 Fragment hygiene {#mop-001-10-1}
The K_read fragment MUST NOT appear in any of:
- HTTP
Refererheaders - Server access logs
- Web analytics payloads
- Indexable transport-layer tags (e.g., Nostr event tags, per §5.4)
- Browser history exports if mitigatable
Web clients SHOULD set Referrer-Policy: no-referrer on fetch requests and SHOULD rewrite the URL bar to drop the fragment after first decode (so a casual screenshot doesn't leak the read key).
10.2 Per-host warrant canary {#mop-001-10-2}
A conformant relay operator SHOULD publish a warrant canary at /canary (HTTP 200, text/plain or application/json) declaring (a) the date of last refresh, (b) any compelled-disclosure events, (c) the operator's commitment to refresh by a stated cadence. Removal or staleness of /canary is the signaling channel; the canary MUST NOT make affirmative legal claims it cannot keep.
The canary is operational, not protocol-bound: a missing /canary does not break wire conformance, but it removes a signal users rely on. Reference implementations (SendWyrd) SHIP the canary endpoint; downstream operators SHOULD do the same.
10.3 Fresh ephemeral keys per wyrd {#mop-001-10-3}
K_origin is fresh per wyrd. An author who reuses a K_origin across publishes (e.g., by reusing the HD index n) collapses the per-wyrd unlinkability the protocol provides. Implementations MUST advance n on every compose attempt regardless of success/failure.
10.4 Relay correlation surface {#mop-001-10-4}
The relay observes:
- Publish: source IP,
k_origin_pub,publish_timestamp_ms,ttl_seconds,replies_enabled, envelope size. - Fetch: source IP, target handle, fetch timestamp.
A relay can build a per-IP graph of "this IP published these handles with these K_origin pubkeys at these times." The protocol does not defend against this. Operators concerned with traffic analysis SHOULD route through Tor or a privacy-preserving CDN; Cloudflare-style edge logging is incompatible with strong anonymity claims.
10.5 Permanent-wyrd retention {#mop-001-10-5}
A permanent wyrd is forever from the protocol's standpoint. From the relay's standpoint, it is "until the operator decides otherwise." Authors who want durability beyond a single relay MUST replicate (publish to multiple relays). MOP-001 does not specify replication; that is layered above.
10.6 BIP-39 passphrase optionality {#mop-001-10-6}
The reference implementation supports BIP-39 passphrase encryption of the seed at rest (see seedStore.ts). This is a renderer-layer concern, not a protocol concern. MOP-001 does not mandate seed-storage format; an implementation MAY persist the raw seed, an encrypted seed record, or no local seed at all (always-recover-from-mnemonic).
11. Extension Registry {#mop-001-11}
11.1 Reserved namespace {#mop-001-11-1}
The following identifier prefixes are reserved by MOP-001 for forward-compatible extension:
mop:v1:— canonical signed-message domain tags. Reserved.mop:v2:,mop:v3:, … — reserved for successor protocol versions.x-mop-— implementation-specific extensions. SHOULD be used for non-standard fields in JSON request/response bodies.sendwyrd:,sendwyrd-— legacy reference-implementation prefix; reserved for backward compatibility but DEPRECATED for new use.
11.2 Extending the envelope {#mop-001-11-2}
A future version that adds AAD fields MUST bump ENVELOPE_VERSION to a new byte value. Mixed-version relays and clients MUST refuse cross-version envelopes (no negotiation; version is a hard match). MOP-001 reserves 0x01 exclusively.
11.3 New body conventions {#mop-001-11-3}
Body conventions like §4.9's authorship attestation are out-of-band: the relay does not parse them. New body conventions are registered by precedent (first-shipped) and SHOULD use a mop-<convention>/v<N> header line on the body's first line, e.g. mop-attestation/v1.
End of MOP-001.