The MUSTs, indexed
Every normative sentence of the current text, one row each, so that an implementation, a review or a test can name the rule it holds.
100 sentences of the current text carry MUST, MUST NOT or REQUIRED in the obligatory sense of RFC 2119 — 122 keywords in all. They are listed here in document order, under the section each sits in, with the id the record of § 12 lists it under: the section number and a count within the section. The sentence links to its section of the text.
2 Identity, certificates and mTLS
| Id | The sentence |
|---|---|
2.#1 | When both proofs are present their leaf keys MUST match, else envelope_invalid. |
2.#2 | A host MUST keep a superseded leaf's private key until that leaf's notAfter, so an envelope sealed to it by a contact that has not yet heard still opens; an envelope sealed to a key the host once held and holds no longer is answered certificate_renewed with the current chain (§14.4). |
2.1 Deriving the root from a passkey
| Id | The sentence |
|---|---|
2.1#1 | A wallet that can use a WebAuthn credential MUST derive the root's private key from one rather than generate and store it; a wallet that cannot — a command-line tool — generates the key and keeps it as §9 says. |
2.1#2 | A wallet MUST use exactly these values. |
2.1#3 | A wallet MUST NOT present this as a guarantee: whether a given provider carries the PRF secret across its own sync is that provider's property and not the protocol's, and a wallet that has not verified it SHOULD say so rather than imply otherwise. |
2.1#4 | A wallet that knows which credential it needs names that credential in its request to the authenticator; whether or not it does, it MUST prove the derived root before signing (§2.2). |
2.1#5 | A wallet that derives its root MUST still be able to export it (§9). |
2.2 Proving a root before using it
| Id | The sentence |
|---|---|
2.2#1 | Before issuing any certificate, a wallet MUST establish that the root it is about to sign with is the root the identity already has. |
2.2#2 | A wallet MUST refuse to sign unless all four hold: |
2.2#3 | The challenge MUST be domain-separated from certificate bytes — the ASCII HDTP root proof v1 followed by a newline and at least 32 random bytes — so that proving possession can never be made to sign a certificate. |
2.2#4 | A wallet MUST validate a chain it has assembled (§14.2) against the expected root and endpoint before returning it. |
2.2#5 | A root certificate is issued once. A wallet MUST NOT rebuild a root certificate for an identity that already has one. |
3 Contact cards (vCard)
| Id | The sentence |
|---|---|
3.#1 | An implementation MUST NOT write any other X-HDTP-* property, and MUST ignore any it reads — an endpoint, a key, a gateway: the address and the key are the leaf's, and there is no gateway. |
3.#2 | A receiving implementation MUST NOT treat FN as identifying, and SHOULD NOT present it as a contact's whole identity: where two pinned contacts render alike, show the fingerprint alongside. |
3.#3 | FN is untrusted display input: a receiver MUST strip control and bidirectional-format characters from it before rendering it, SHOULD cap its length, and SHOULD fold confusable scripts when deciding whether two names collide. |
3.#4 | A receiver MUST reject a card without X-HDTP-CERT, one whose certificate does not parse as §14.1 describes — no issuer key identifier or one that is not the 32 bytes a key identifier is, no endpoint or several, a validity longer than 398 days — and a card whose X-HDTP-VERSION names a major version it does not implement, each with bad_request. |
3.#5 | A receiver MUST also refuse, at intake and again before every dial, an endpoint whose host resolves to a loopback, link-local or private address — the resolve-and-vet guard §6.2 applies to media URLs — unless the owner has configured that network on purpose, and a guest's endpoint that names the receiver's own address, which no honest card carries. |
3.#6 | A writer MUST NOT put a control character into a card — in FN, in X-HDTP-SEAL, or in a property it adds: a card is lines, a line break writes a property of the writer's choosing, and a reader takes the first of a name, so a name of x, a line break and X-HDTP-SEAL:none turns a card that requires sealing into one that does not. |
4 Invites
| Id | The sentence |
|---|---|
4.#1 | The same URL serves two audiences by content negotiation: a browser gets the human landing page; a client sending Accept: application/hdtp-invite+json or Accept: application/json, or appending ?format=json, gets {"card","card_sig","chain"} — the signed card and the issuer's chain (§2), whose leaf MUST byte-equal the card's X-HDTP-CERT and which the redeemer MUST validate (§14.2) before use, so it can seal its very first call. |
5.2 Manual flow (vCard shared over existing channels)
| Id | The sentence |
|---|---|
5.2#1 | "pending"}` any stranger gets, while nothing is recorded and the owner is never bothered: blocked MUST be indistinguishable from never-met (§12). |
5.3 A contact at a new address
| Id | The sentence |
|---|---|
5.3#1 | A receiver therefore MUST keep, for 30 days after a remove_contact, the removed root and the leaf that removed it; a chain from that root with a newer leaf inside that window is handled as a new address under ask, whatever the setting says, since a host that removed a contact and a host that returns cannot both have been the person's wish. |
6.1 Tiers
| Id | The sentence |
|---|---|
6.1#1 | A caller at the pending tier MAY list its tools: its tools/list MUST answer at the pending tier, naming contact_accepted and contact_rejected, and every other call from it MUST answer pending_approval until the owner decides. |
6.2 Core tools
| Id | The sentence |
|---|---|
6.2#1 | A msg_id MUST be a non-empty string — idempotency keyed on nothing protects nothing. |
6.2#2 | The key is the caller's root and the msg_id: a host MUST answer a msg_id the same contact has used before with the original result, and MUST NOT execute the call again. |
6.2#3 | {"status": "ok"} — a card refresh, or {"status": "pending"} from a new address under ask (§5.3). The caller's chain is the authority: the card's certificate MUST equal the chain's leaf, and a card that names another root or carries a certificate that is not that leaf MUST be refused bad_request, the card-intake code of §3 |
6.2#4 | get_status answers from that fixed four-value vocabulary; an implementation whose upstream presence source knows richer states MUST map any state not listed to busy. |
7 Messaging and threads
| Id | The sentence |
|---|---|
7.#1 | A thread_id belongs to the contact that first used it: a host MUST refuse with bad_request a send_message from any other contact carrying that thread_id — without this rule, thread placement is an impersonation vector, one contact writing into the middle of another's conversation. |
9 Hosting
| Id | The sentence |
|---|---|
9.#1 | A host MUST generate a fresh key for every renewal, so that a leaf key compromised without anyone noticing dies with its leaf; a suspected compromise is the same act done at once, and the new leaf outranks the stolen one with every contact it reaches (§14.3). |
9.#2 | A leaf's key does not outlive its leaf: a host MUST stop using the key of a leaf that has expired and MUST destroy it, keeping the key id so that an envelope still sealed to it is answered certificate_renewed (§14.4) — past its date every verifier refuses the leaf (§14.2 rule 4), so the key can do nothing legitimate, and a renewal has never needed it. |
9.#3 | A host that makes an archive MUST NOT put key material of any kind in it, and a host that imports one MUST refuse any key material in it and MUST refuse, rather than ignore, anything else it does not recognise. |
9.#4 | An address an identity has vacated MUST NOT be assigned to another identity until the last leaf issued for it has expired, so a contact that missed the move never reaches a stranger where it expects a friend. |
9.#5 | It issues one live leaf per identity at a time — a second endpoint is a move, not a second home, because contacts keep one pin and the newest leaf wins — and MUST NOT issue a second while one is live except as its replacement. |
9.#6 | A wallet MUST NOT write a leaf, a ledger entry or a contact into the file, and writes it once, when the root is made, and again only when the root is re-bound or a hardware key takes it. |
9.1 Signing requests
| Id | The sentence |
|---|---|
9.1#1 | A wallet MUST refuse a request that is not a top-level navigation, as the browser's fetch metadata reports it (Sec-Fetch-Mode: navigate, Sec-Fetch-Dest: document), so that a script on another page cannot probe it. |
9.1#2 | A wallet MUST refuse a request whose Origin is absent, null, or different from the origin of redirect: the host that asks is the host that collects. |
9.1#3 | A wallet MUST refuse a request with a field the table above does not list, a field that is not a string, or a field that breaks the table, and a request that has expired or expires more than ten minutes ahead. |
9.1#4 | A wallet MUST refuse a request whose CSR fails the checks of §9 — its own signature, and a key that is not a root — or names an endpoint that is not in the normal form of §14.1 or fails the address guard of §3. |
9.1#5 | A wallet MUST prove the root against expect_root (§2.2) before it signs. |
9.1#6 | It MUST show the person the asking origin, the recipient as the host's own claim, the endpoint, the validity and whether the host is new. |
9.1#7 | The wallet MUST NOT keep anything of the request once it has answered, and MUST NOT write its body to a log. |
9.1#8 | A host MUST accept an answer only once, only with the state it minted for a pending request, and only a chain whose leaf carries that request's key and validates at its endpoint (§14.2). |
9.2 The export
| Id | The sentence |
|---|---|
9.2#1 | An exporter MUST NOT leave out a media file it holds for a message it exports: a file it cannot include is a reason to refuse the export, never to omit the file. |
9.2#2 | Spreadsheet formulas. A writer MUST write a CSV cell that begins with =, +, -, @, ', a tab or a carriage return with one ' before it, and a reader strips one leading '. base64url DER cannot begin that way: it starts with M, from its first byte 0x30. |
9.2#3 | Unencrypted. Every surface that writes an export MUST tell the person, before the file is written, that it is not encrypted, that anyone who gets it can read what it holds — their contact list and all their conversations and files for a full export, their contact list for a book — and that it holds no keys, so it cannot be used to speak as them. |
9.2#4 | A host that delivers an export over a network MUST NOT keep it at rest: it builds the file when the signed-in person asks and streams it to them. |
9.2#5 | Validation. An importer MUST check the whole file before it writes anything, and MUST refuse the whole file if any check below fails: |
9.2#6 | An importer MUST refuse any entry whose name is not exactly manifest.json, contacts.csv, threads.csv, messages.jsonl, media/, or media/ followed by 64 lowercase hex digits — so no .., no absolute path, no backslash and no other file — and it MUST read the zip's central directory as the only index |
9.2#7 | An importer MUST refuse a file in which one name appears twice |
9.2#8 | An importer MUST refuse a file that lacks a member; a file MAY omit threads.csv, messages.jsonl and media/ only when its manifest counts them zero, which is what a book does |
9.2#9 | An importer MUST refuse an encrypted entry, a symbolic link (a Unix mode in the external attributes), and any directory but media/ |
9.2#10 | An importer MUST count sizes by the bytes it actually decompresses, never by the sizes a header states, and MUST refuse a manifest over 64 KiB, a contacts.csv over 4 MiB or 5000 rows, a threads.csv over 16 MiB, a line of messages.jsonl over 64 KiB, a media file over 5 MiB, and anything over a ceiling of the host's own (below) |
9.2#11 | An importer MUST refuse a text member that manifest.files does not list or whose sha256 differs from it, a listed member the file lacks, a files entry that names anything but a text member, a media member whose name is not the lowercase hex sha256 of its bytes, and counts that differ from what the file holds — counts.media included, which is the number of media members |
9.2#12 | An importer MUST refuse a file whose owner is not the root of the identity importing it, and a contact row whose root is owner |
9.2#13 | An importer MUST refuse a header that is not exactly the one shown, a row or a message that breaks what its column or member holds above, a message with a member not listed or one missing, and a message with more than one attachment, a message that carries an attachment and a body that is not empty, and a time that is not an RFC 3339 instant in UTC ending in Z, or whose fraction follows anything but a . |
9.2#14 | An importer MUST refuse a thread whose contact, a message whose thread, contact or non-null reply_to, or an attachment whose file names nothing in the file, and a media file that nothing names |
9.2#15 | An importer MUST refuse any cell, any string member of the manifest or of a message, and any media file that decodes as a private key — PKCS #8 or SEC1, in DER or PEM — and MUST parse a certificate only as a certificate of §14.1's profile |
9.2#16 | It MUST show the person the contacts, and write nothing until the person agrees. |
9.2#17 | An imported leaf MUST NOT replace a pin the host validated itself, and a row's leaf is pinned only when [leaf, root_cert] validates at the row's endpoint (§14.2). |
9.2#18 | A host MUST NOT send a message it imported, whatever its status: retries belonged to the host that exported it. |
9.2#19 | The import MUST end with a request for a new leaf for the importing endpoint, which the host mints itself with expect_root equal to owner — move for an identity new to the host, renew for one it already serves — and which the person completes in their wallet (§9.1). |
9.2#20 | Once that leaf is installed, the host MUST call update_contact at every imported contact that is not blocked and whose leaf it holds (step 2), since a contact whose leaf it does not hold cannot be sealed to, and MUST report every other contact that is not blocked as unreached, without retrying it; that contact stays pinned by its root. |
9.2#21 | A contact that refuses the call — update_contact is a contact-tier tool, and that contact does not hold the identity as one — MUST then be sent request_contact, which that contact decides under its own policy. |
9.2#22 | A writer MUST write reply_to as null when the message it names is not in the file. |
9.2#23 | A writer MUST drop from their_permissions every name that is not a permission of §8 and every name repeated, since the column is informative. |
9.2#24 | A writer MUST truncate display_name to 200 characters, since it is the contact's own claim. |
9.2#25 | A writer MUST leave out a message whose body, or whose media file, the key-material check above would refuse, with the attachment it carried, and MUST list each message it leaves out, by its id and the reason, in the report it gives the person; it never leaves one out silently. |
9.2#26 | Ceilings. A host MAY set import ceilings of its own, on the whole file and on counts — contacts, threads, lines of messages.jsonl, the characters of an id — and MUST name the ceiling in each refusal it makes for one. |
9.2#27 | A host MUST NOT refuse to write an export because the file would exceed an import ceiling of its own, of any kind, the whole-file ceiling included; it MAY warn the person that the file exceeds them, naming each. |
9.2#28 | The owner's own strings. A writer MUST refuse to write a manifest whose owner_name or tool the key-material check above would refuse, naming the member: those are the owner's and the host's own, not a contact's, so there is nothing to leave out. |
13.1 Format
| Id | The sentence |
|---|---|
13.1#1 | base64url HPKE encapsulated key, of exactly the suite's Npk (RFC 9180, Section 7.1): 65 bytes for HDTP-SEAL-P256, an uncompressed P-256 point, and 32 for HDTP-SEAL-X25519. A receiver MUST refuse any other length (envelope_invalid) — sig covers the three members concatenated with nothing between them, so the suite's own length is what fixes the boundary; without it a byte moved from the end of enc to the front of ct leaves the signed bytes identical |
13.1#2 | Each of the four members is base64url (RFC 4648, Section 5) without padding, in its one canonical spelling, and a receiver MUST refuse (envelope_invalid) a member written any other way: with a character outside that alphabet — padding, whitespace and the standard alphabet's + and / among them — or with a last character whose unused bits are not zero. |
13.1#3 | The suite follows the recipient's key and nothing else: a receiver MUST refuse an envelope whose suite is not the one its key takes (envelope_invalid), so no choice is left on the wire for a sender to make badly. |
13.1#4 | The HPKE info parameter is the ASCII string HDTP-SEAL-v1, and an envelope sealed under any other info string MUST NOT open. |
13.1#5 | The HPKE ephemeral MUST be fresh for every envelope — a reused one repeats the key and the nonce, and two ciphertexts under them leak the XOR of their plaintexts — and both sides MUST refuse an all-zero DH output, which a low-order X25519 point produces (RFC 9180, Section 7.1.4). |
13.1#6 | msg_id is REQUIRED and MUST be non-empty — replay protection keyed on an empty string protects nothing. |
13.1#7 | A protected header carrying a member not listed for its v, or one whose type is not the one listed — v, ts and exp are JSON integers, suite, kid, msg_id and cty JSON strings — MUST be rejected (envelope_invalid): the header is the AAD, and two implementations that disagree about what was signed cannot interoperate. |
13.2 The sealed_call tool
| Id | The sentence |
|---|---|
13.2#1 | The plaintext of a request envelope is one bare JSON object (no JSON-RPC framing) of method, params, and exactly one of chain and leaf; the method MUST be tools/call or tools/list. |
13.2#2 | A sender MUST carry chain on first contact and in its first envelope to each contact after a renewal, and MAY carry it at any time. |
13.2#3 | Results. The result of a sealed request MUST be sealed back to the caller, in the same format: |
13.2#4 | chain MUST validate (§14.2), sig MUST verify under its leaf key, and its leaf MUST byte-equal the card argument's X-HDTP-CERT. |
13.2#5 | Error results follow the sealing rule too: once a request envelope has been successfully opened, an error result MUST be sealed back like any other result — a plaintext error is only for an envelope that could not be opened at all, where there is no proven key to seal toward. |
13.3 Opening
| Id | The sentence |
|---|---|
13.3#1 | Receivers MUST validate in this order, rejecting at the first failure: |
13.3#2 | Idempotency records for seen msg_ids MUST be retained until min(exp, ts + 300 s) — the end of the window in which the envelope could be presented again and accepted. |
13.3#3 | A blocked sender's envelopes MUST be processed exactly as an unknown sender's — the guest card-binding rules of §13.2 apply and a sealed tools/list is rejected envelope_invalid — so sealing never becomes an oracle distinguishing blocked from unknown (§12); a guest envelope whose inner call carries no card argument is likewise rejected envelope_invalid. |
13.4 Negotiation
| Id | The sentence |
|---|---|
13.4#1 | none — the recipient does not accept envelopes (sealed_call absent; senders MUST NOT seal); optional — both accepted; senders MAY seal; required — unsealed substantive calls are refused (plain tools/list still answers with whatever the transport identity earns), and senders MUST seal. |
13.5 Stated trade-offs
| Id | The sentence |
|---|---|
13.5#1 | That key signs exactly four structures — a TLS handshake, a certificate signing request, a card, an envelope — each distinguishable by its first bytes, and an implementation MUST NOT sign anything else with it. |
14.1 Profile
| Id | The sentence |
|---|---|
14.1#1 | A certificate's signatureAlgorithm MUST be its issuer key's own algorithm; a verifier takes the algorithm from the key, never from the certificate, so a mismatch is simply a certificate the key did not sign. |
14.1#2 | The algorithm identifier inside the tbsCertificate and the outer signatureAlgorithm MUST be byte-equal and carry no parameters, as RFC 5280, Section 4.1.1.2, requires — a certificate that reads one way to a verifier of this profile and another to a TLS stack is what this profile excludes. |
14.1#3 | So an ECDSA signature on a certificate MUST be the twin with s ≤ n/2, the *low-S* form: an issuer normalises what it signs, including a signature a hardware token made, and a verifier refuses the other twin as outside the profile, at card intake as much as in a chain. |
14.1#4 | A chain is the leaf followed by the root and nothing else; a verifier MUST refuse any other length. |
14.2 Chain validation
| Id | The sentence |
|---|---|
14.2#1 | A verifier handed a chain MUST apply these in order and MUST refuse at the first failure — envelope_invalid in an envelope, a refused handshake for a client certificate, bad_request for a card: |
14.2#2 | When the verifier already holds a fingerprint for the identity in question — from a pin, or from the issuer key identifier of a card's certificate — the two MUST be equal. |
14.2#3 | The verifier's clock is not past the root's notAfter, and is within the leaf's notBefore and notAfter; the leaf's notAfter − notBefore is at most 398 days; and the leaf's notAfter MUST NOT be after the root's. |
14.2#4 | When the verifier knows which address is in question — the URL it dialed, the endpoint it pinned, the endpoint in the card — the URI MUST equal it byte for byte — both are the normal form of §14.1, so nothing is normalised at comparison time. |
14.2#5 | A dNSName beside the URI MUST equal its host, and the address guard of §3 — no loopback, link-local or private host; never the verifier's own endpoint from a guest — applies before any dial. |
14.2#6 | A verifier therefore MUST NOT refuse a chain on the root's notBefore — including a root whose notBefore is later than the leaf's, which is the ordinary case for a new identity, since §14.1 backdates a first leaf up to an hour for clock skew while the root was made minutes ago. |
14.3 The newest leaf wins
| Id | The sentence |
|---|---|
14.3#1 | Equal notBefore and equal bytes is the pinned leaf; a verifier MUST refuse a leaf with an equal notBefore and different bytes. |
14.3#2 | A host MUST NOT treat an unanswered or failed refresh as a reason to refuse a contact or to un-pin one: an endpoint that is down, slow, or behind a network the host cannot reach at that moment is not a compromised endpoint, and a rule that turned unreachability into revocation would let any carrier disconnect two people by dropping one request. |
14.4 certificate_renewed
| Id | The sentence |
|---|---|
14.4#1 | An endpoint MUST keep the key identifiers of every leaf it has held for an identity it still serves — fingerprints, never keys past their notAfter. |
14.4#2 | A caller MUST follow certificate_renewed at most once per call, and only when the chain it carries is newer than or equal to its pin (§14.3): an older chain, a chain to another root, or a chain naming another address is discarded and the call fails as it would have. |