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:

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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:

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:

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 to mop-attestation/v1 once a second implementation ships. Implementations MUST accept the sendwyrd-attestation/v1 literal 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>

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 generic mop:// scheme; until then, implementations MUST accept sendwyrd:// 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):

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-endian n.

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 the published_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_000 as the AAD's expires_at_ms for any wyrd where the original ttl_seconds was 0. A naïve implementation that stores the historical expires_at from the publish receipt and uses that value will succeed for finite-TTL wyrds and silently fail-decrypt for permanent ones, because the historical receipt's expires_at equals PERMANENT_EXPIRES_AT_MS only 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:

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:

  1. Body parses as JSON; all required fields present.
  2. handle matches ^[A-Za-z0-9_-]{16}$.
  3. envelope, k_origin_pub, publish_signature decode as base64url; sizes are within bounds.
  4. ttl_seconds ∈ [0, 31_536_000].
  5. publish_timestamp_ms is within ±REPLAY_WINDOW_MS of relay clock.
  6. BIP-340 Schnorr signature verifies against publish_message (§6.4) and the X-only of k_origin_pub.
  7. handle is not already taken (DB unique constraint); on conflict, return 409 handle_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:

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:

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}

9.2 Capability-URL conformance {#mop-001-9-2}

9.3 Crypto conformance {#mop-001-9-3}

9.4 Permanence conformance {#mop-001-9-4}

9.5 Relay-behavior conformance {#mop-001-9-5}

10. Security Considerations {#mop-001-10}

10.1 Fragment hygiene {#mop-001-10-1}

The K_read fragment MUST NOT appear in any of:

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:

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:

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.