HDTP 1.0 specification
The normative text, in full and on one page: how agents identify each other, how contacts are exchanged and approved, what a contact may call, how a person moves between hosts without losing anyone, and how calls stay sealed where an edge terminates TLS. Every section has an address; the link beside a heading copies it.
Introduction
HDTP was formerly named PACT.
HDTP lets one person's AI agent communicate with another person's agent, on terms both people set. It is built from mutual TLS, X.509 certificates and MCP tools, with one addition: a sealed envelope (§13) that carries identity and confidentiality across a link whose TLS is terminated before it reaches the recipient. An identity belongs to the person, not to whoever hosts it, so the key that controls an identity is separate from the key that serves it: the person acts as a certificate authority. The root certificate in their wallet is the identity; the host they choose holds a leaf certificate the root issued, naming the address it serves and the date its authority ends. HDTP has no DIDs, no SAS, no prekeys, no directory, no log, no sequence numbers and no relay.
- The identity is the person's; the host serves it. An identity is the fingerprint of a self-signed root certificate whose private key lives in the person's wallet and signs nothing but certificates. The host — their own machine, or a provider — holds a leaf the root issued for one address, valid for at most 398 days, one year by default, and that leaf's key is the one that speaks: it is the TLS certificate, it signs every call, contacts seal to it. Contacts pin the root, learn the current leaf from every exchange, and never have to be told when it is renewed. Moving is a new leaf for a new address, and a contact request from there (§5.3, §9).
- Your agent is a publicly exposed MCP server. Sending a message is calling the other party's
send_messagetool. Everything a contact may do — messages, media, status, availability, calendar booking — is an MCP tool that is visible and callable only per your permission settings for that contact. - Contacts are vCards in your phone book. A contact card is a standard vCard with three
X-HDTP-*properties, one of them the leaf certificate. Share it over WhatsApp, email, AirDrop, or as a QR — the channels people already use. Adding a contact is always a manual, human approval. - Invites are short URLs. All settings (expiry, max uses, auto-accept, permission preset) live on the sender's server, so a link is revocable at the protocol level by deleting it. A QR of the link invites a room full of people.
- Threads like a messenger. Conversations carry a
thread_idand optionaltopic, shared by both sides. Agents talk to agents; a human can type into the same thread manually. Each agent is reachable because it is hosted, not because a server in the middle holds its mail.
Non-goals. §11 states, for each security property, what HDTP relies on and what risk remains.
- No forward secrecy at the envelope layer (§13): a later key compromise decrypts recorded sealed traffic, bounded by a leaf's lifetime.
- No hiding of metadata: edges always see the recipient's key, timing and sizes (the sender rides inside the ciphertext, §13.1), and an unsealed call is readable by whatever carries it.
- No anonymity or traffic-analysis resistance.
- No directory: a bare fingerprint resolves to nothing, and every relationship starts from a card or an invite.
- No store-and-forward: a person who must be reachable while their own machine is off is hosted (§9), and there is no relay role.
- No recovery and no rotation of a lost or compromised root: the person's backups are the only copy.
- No post-quantum cryptography; §13.5 records the path to it.
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 [RFC2119] [RFC8174] when, and only when, they appear in all capitals, as shown here.
1 Architecture
Mermaid source
flowchart TB
subgraph SA["Person A"]
HA["Human A<br/>(phone app + contact book + wallet)"]
AA["Agent A<br/>(LLM + policies)"]
MA["MCP server A<br/>https://a.example/mcp"]
TA["A's private tools<br/>calendar, mail (MCP)"]
HA --- AA
AA --- MA
AA --- TA
end
subgraph SB["Person B"]
HB["Human B<br/>(phone app + contact book + wallet)"]
AB["Agent B<br/>(LLM + policies)"]
MB["MCP server B<br/>https://b.example/mcp"]
TB["B's private tools<br/>calendar, mail (MCP)"]
HB --- AB
AB --- MB
AB --- TB
end
AA -- "mTLS · calls B's tools<br/>send_message, book_slot…" --> MB
AB -- "mTLS · calls A's tools" --> MABoth sides are symmetric: every participant runs (or is hosted with) an agent and exposes an MCP server over HTTPS. "A messages B" = A's agent makes one mTLS-authenticated MCP tool call to B's server. B's server identifies the caller by the certificate chain it proves — as the client certificate, or inside a sealed envelope's signature (§2, §13) — validates that chain to a root pinned in B's contact list, and shows/allows exactly the tools B's permission settings grant that contact. Humans sit above their agents: they approve contacts, set permissions, hold the wallet that issues their host its certificate, and can type messages that travel the same rails.
2 Identity, certificates and mTLS
An identity is a root certificate: self-signed X.509, its private key held by the person in a wallet and used for one thing, issuing certificates. The root's fingerprint is the identity's name everywhere — in pins, in envelopes, on screen — and is computed from the root's key:
fingerprint = "sha256:" + base64url( SHA-256( SubjectPublicKeyInfo ) )
A host — the person's own machine, or a provider — serves the identity under a leaf certificate the root issued. The leaf carries the host's own key, the one address the identity answers at, and the dates between which the host's authority runs. The leaf's key does three jobs: it is the TLS certificate, it signs every envelope, and contacts seal to it (§13). Contacts pin the root above it and learn the leaf beneath. §14 gives both certificates' profile and the validation rules; this section is what they mean.
| Certificate | Key held by | Algorithm | Names | Lives |
|---|---|---|---|---|
| root | the person, in a wallet | Ed25519; P-256 permitted | the identity, by fingerprint | as long as the identity: until the end date the person sets, or for good; never rotated |
| leaf | the host, one per identity | Ed25519 or ECDSA P-256 | the endpoint, as its subject alternative name | as long as the person chooses, up to 398 days, one year by default; renewed by the wallet with a fresh key |
A chain is exactly two certificates, leaf then root. It travels everywhere identity must: as the TLS client certificate chain, inside a sealed envelope until the receiver holds the leaf and by fingerprint after that (§13.2), in the redeem_invite and get_card results, and on the invite landing (§4). A card carries the leaf alone (§3); the root arrives with the first exchange, and nothing about it needs to be trusted in advance, because it is accepted only if it hashes to the fingerprint the leaf names as its issuer.
Verification, in full in §14.2: the root is self-signed, hashes to the fingerprint the verifier holds or is about to pin, and has not passed its end date, if it has one; the leaf is signed by that root, within its validity now, no longer than 398 days, ending no later than the root, and names exactly one endpoint; that endpoint equals the address in question, byte for byte. Then the leaf's key is the identity's voice at that address — until a newer leaf says otherwise, which it does the instant it is seen (§14.3).
Client side (who is calling): a caller proves possession of its leaf key in either of two ways — by presenting the chain as its TLS client certificate, or by the detached signature on a sealed envelope (§13), which survives pipes that strip client certificates. The receiver validates the chain, takes the root's fingerprint as the caller's identity, and resolves it through its pins (§6.1). A pin records the root, the endpoint and the leaf last accepted: the caller's leaf is accepted only if it names the pinned endpoint and is no older than the pinned leaf. An older leaf proves nothing (§14.3); a different endpoint is a request to change it (§5.3). When both proofs are present their leaf keys MUST match, else envelope_invalid. A chain whose root resolves to no pin gets the guest tier only (§6.1).
Server side (who am I calling): the endpoint is the pinned leaf's subject alternative name — a card carries no separate address, and a wallet signs no leaf without one. The endpoint's TLS server certificate is validated as either (a) normal WebPKI for the URL's hostname — the default, works with Let's Encrypt and behind terminating edges — or (b) the contact's own chain, which a host on its own machine MAY present as its server certificate and which the caller validates to the pinned root. Either way the name in the certificate equals the host dialed, and the authorization anchor is the root pinned at add-contact time, which no leaf moves.
Renewal. A leaf is renewed by the wallet issuing a new one for the same endpoint — with a fresh key — before the old expires; a host SHOULD tell the person that a renewal is due from thirty days before the leaf's notAfter. Nothing is announced: a host carries its chain in its first envelope to each contact after a renewal, and a contact that has not seen it asks for it (§13.2), so a contact learns the new leaf on the next exchange in either direction, and because the endpoint is unchanged it needs no one's approval to accept it. 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). A contact that sealed to an expired leaf learns the current one the same way — which is what keeps a card printed a year ago usable, as long as the address on it still stands. An expired leaf is refused everywhere, not demoted to guest, until the wallet renews it; a host's reminders are part of serving the identity.
Moving is a new leaf for a new endpoint, a contact request from there, and the old host forgetting: §5.3 and §9.
Losing keys. A lost or compromised leaf key is a renewal with a new key. A lost root is the end of the identity: re-share a new card from a new identity. A root past the end date its person set is the same end. A compromised root is the same, because whoever holds it can issue leaves, and no rotation ceremony could tell the two holders apart. There is deliberately no recovery and no rotation; the person's own backups of the wallet are the only copy, and the wallet says so once, when the root is made. Where the root is derived from a credential (§2.1) the rule is unchanged, but what counts as a copy is wider: a passkey its provider synchronises is a copy of the identity, and an export (§9) is another.
2.1 Deriving the root from a passkey
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. A wallet that derives holds no root at rest: the key is reconstructed on each use from a secret the authenticator returns, and exists only as long as one signing takes.
The derivation is specified so that any conforming wallet reproduces the same identity from the same credential. Without that, a person's identity would depend on which wallet they happened to use, which is the opposite of what a root in the person's own hands is for. A wallet MUST use exactly these values.
salt = SHA-256("hdtp/vault/1") # 32 bytes, the PRF input
prf = the WebAuthn prf extension's first output for that salt # 32 bytes, from the authenticator
seed = HKDF-SHA256(ikm = prf, salt = "", info, L = 32)
key = the Ed25519 private key whose 32-byte seed is `seed`
info |
Derives |
|---|---|
"hdtp/root/1" |
the root's private key |
"hdtp/store-key/1" |
the key sealing the wallet's own record — its ledger, its contact book and, once the root has been re-bound (§9), the root itself |
"hdtp/store-id/1" |
the address that record is kept at, wherever it is kept |
info strings are US-ASCII without a terminator. HKDF is RFC 5869 with an empty salt, so the extract step is HMAC-SHA256(key = 0x00 × 32, prf). The root's algorithm is Ed25519: P-256 remains permitted for a generated root, but a derived root is Ed25519 so that one credential yields one identity and not two. Appendix B carries vectors for all three info strings over one PRF output.
The PRF salt is a fixed constant rather than a per-credential one, because a wallet arriving cold on a new device must derive before it can fetch anything, and a per-credential salt would have to be fetched first. A fixed salt still yields a per-credential secret, since the PRF is keyed by the credential. The salt's wording carries no meaning: it is a fixed byte string.
Three properties follow:
- A derived root is indistinguishable on the wire. It is a self-signed X.509 root like any other, validated by §14.2 like any other. No verifier learns how it was made, and none behaves differently because of it.
- The identity travels with the credential. Where the authenticator's provider synchronises the credential across a person's devices, the identity follows, with nothing to copy and nothing to lose. 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.
- Which credential answered matters. Deriving from the wrong credential does not fail — it produces a valid root belonging to a different identity. 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).
A wallet that derives its root MUST still be able to export it (§9). A derived root is exportable key material like any other, and the export is what lets a person use a tool that cannot speak WebAuthn, or leave the provider whose credential it is.
2.2 Proving a root before using it
Before issuing any certificate, a wallet MUST establish that the root it is about to sign with is the root the identity already has. For a derived root this is not a formality: the wrong credential yields a well-formed root, ready to sign, belonging to somebody else.
A wallet MUST refuse to sign unless all four hold:
- the root key's fingerprint equals the fingerprint the identity is known by;
- the root certificate it will return parses, and its
SubjectPublicKeyInfoequals the root key's; - the root key signs a challenge that verifies under that certificate's public key. The challenge MUST be domain-separated from certificate bytes — the ASCII
HDTP root proof v1followed by a newline and at least 32 random bytes — so that proving possession can never be made to sign a certificate. - the root certificate has not passed its
notAfter(§14.2 rule 4): a root past the end date its person set signs nothing more, and the wallet refuses before any signature is made — before a passkey or a card is asked.
A leaf a wallet issues under a root with an end date ends no later than the root (§14.2 rule 4): where the validity the person chose would run past it, the wallet ends the leaf with the root and tells the person.
A wallet MUST validate a chain it has assembled (§14.2) against the expected root and endpoint before returning it. A chain that fails validation is a wallet defect, and returning it makes the defect the host's to discover.
A root certificate is issued once. A wallet MUST NOT rebuild a root certificate for an identity that already has one. A rebuilt root has the same fingerprint, a fresh serial and a later notBefore; leaves issued earlier still validate under it unless it ends before they do, because chain validation reads the root's notAfter and never its notBefore (§14.2). Where the wallet keeps no copy of its own, the root certificate is supplied with the signing request and returned unchanged beside the new leaf.
3 Contact cards (vCard)
An HDTP contact card is a standard vCard 4.0 (RFC 6350) with three extension properties, so it saves into phone contact books, syncs like every other contact, and travels over WhatsApp/email/AirDrop/QR unchanged:
BEGIN:VCARD
VERSION:4.0
FN:Alina Rao
TEL:+91 98x xx xx xxx
EMAIL:alina@example.com
X-HDTP-VERSION:1
X-HDTP-CERT:MIIBkTCCAUOgAwIBAgIUX7…(the leaf certificate, base64url DER, folded per RFC 6350)…
X-HDTP-SEAL:required
END:VCARD
| Property | Required | Meaning |
|---|---|---|
X-HDTP-VERSION |
yes | Protocol major version: 1, and nothing else. A card naming another major is refused bad_request |
X-HDTP-CERT |
yes | The identity's current leaf certificate, base64url DER (§14.1). It carries the endpoint, the leaf key, the issuing root's fingerprint and the validity dates — everything a card must say about identity and reachability, and the signature that binds them, in one |
X-HDTP-SEAL |
no | Inbound sealing policy: none|optional|required (§13). Absent = none |
A leaf is 400–500 bytes of DER, so a card stays under a kilobyte: a QR a phone reads from a screen, and for print the invite URL (§4) is the lighter carrier. A root is never in a card: the leaf names it by fingerprint (its issuer key identifier, §14.1), and the root itself arrives with the first exchange. 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.
What a card anchors is the root fingerprint and the endpoint — both read from the leaf, and both outliving it. A card whose leaf has expired is still a valid bootstrap for that root at that address: the first exchange brings the current leaf (§2, §14.4). Pinning a card means recording those two things; trust in them equals trust in the channel that carried the card, and the first chain that validates to that root at that endpoint is the proof of possession. A sender MAY seal its first call to the leaf key of a card whose leaf has expired — as a bootstrap only, pinning nothing until a chain validates — and expects either a result carrying the current chain or certificate_renewed (§14.4).
A signed card. Wherever a card is served — by get_card, by redeem_invite, and on the invite landing (§4) — it comes with card_sig and the identity's chain (§2). card_sig is the current leaf key's signature over the UTF-8 bytes of the card text exactly as sent, line breaks included: pure Ed25519 (RFC 8032), 64 bytes, for an Ed25519 leaf, or ECDSA with SHA-256 in ASN.1 DER for a P-256 leaf, written as base64url without padding (RFC 4648, Section 5). A receiver validates the chain (§14.2), checks that the card's X-HDTP-CERT is the chain's leaf, and verifies card_sig under that leaf's key over the card text it received. Appendix B carries a signed card.
FN is the sender's own claim, and carries no authority. The identity is the
root's fingerprint; the name beside it is whatever the card's author typed, and so
is the commonName inside the certificate. Two contacts may therefore carry the
same FN — usually because two people really are called the same thing,
occasionally because one of them chose it. 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.
Implementations SHOULD also let the owner assign their own local name for a
contact, which is the only name no peer can influence.
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. None of this is wire-visible — a card is accepted or rejected on its certificate, never on its name.
Intake is strict exactly where identity or reachability is at stake. 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. There is no root to pin, no address to reach, or no version in common; accepting such a card only defers the failure to a worse moment. An expired leaf is not a reason to reject: the root and the endpoint are what the card is for. Unknown X-HDTP-* properties are preserved and ignored, which is how minor versions stay compatible.
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. An IPv6 literal that embeds an IPv4 address — IPv4-mapped, IPv4-compatible, NAT64 (64:ff9b::/96) or 6to4 (2002::/16) — is judged by the address inside it, which is the one a translator dials: [64:ff9b::7f00:1] is loopback on any NAT64 network, and for a literal there is no name to resolve, so this is the whole guard. NAT64's local-use prefix (64:ff9b:1::/48) and site-local addresses are never public.
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.
The card a phone shares natively as "contact QR" is therefore already an HDTP identity. An agent watches the phone book (or an import action): any contact carrying X-HDTP-* fields is offerable as "connect our agents?" — which triggers the manual flow of §5.2. Ordinary contacts apps preserve unknown X- properties, so vCard needs no new sharing channel.
4 Invites
An invite is a short URL whose entire state lives server-side with the issuer:
https://agent.alina.example/i/3f9c2a7b51e04d8c9a6f0b2e7d1c4a85
(The token is a path segment on the issuer's host — /i/<token> — because the landing below is served by the issuer, and a URL fragment never reaches a server. A QR of this URL is the shareable form.)
Issuer-side settings per invite — because state is server-side, all of this is enforceable and changeable after the link is shared:
| Setting | Default | Notes |
|---|---|---|
expires_at |
14 days | Redeems after this fail |
max_uses |
1 | Set high for a QR shown to a room; each redeem becomes its own contact |
auto_accept |
false | true = redeeming immediately creates the contact (conference-badge mode); false = each redeem lands as a pending request for manual approval |
preset |
"basic" | Permission preset granted on accept (§8) |
label |
— | "Pune conference 2026" — shows on incoming requests |
| revoked | — | Deleting the token invalidates the link at the protocol level; nothing cryptographic to chase |
The invite URL itself contains no personal data and no key — only the bearer token. The URL resolves (over TLS, to the endpoint the issuer personally handed over as QR/link) to a landing page serving the issuer's signed card: the vCard plus a signature over it by the issuer's leaf key. 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. That answer's Content-Type is application/hdtp-invite+json. An unknown, revoked, expired, or used-up token answers with one indistinguishable not-found on both views. The redeemer therefore holds the issuer's card before redeeming — which is also what lets a guest seal redeem_invite toward a required issuer (§13) — and redemption re-returns the same signed card in-band, so the redeemer pins a root whose chain reached it over the URL the issuer personally handed out.
5 Adding contacts
Contacts are always mutual and always human-approved (an invite's auto_accept is the issuer pre-approving at share time). Contact state on each side:
Mermaid source
stateDiagram-v2
[*] --> none
none --> pending_out : I redeemed an invite /<br/>sent a request
none --> pending_in : someone requested me
pending_in --> active : I approve
pending_in --> blocked : I reject
pending_in --> none : request expires
pending_out --> active : they approve<br/>(contact_accepted call)
pending_out --> blocked : rejected<br/>(contact_rejected call)
pending_out --> none : expired
active --> blocked : I block
blocked --> active : I unblock
active --> none : remove_contact<br/>(either side)A request waiting in pending_in or pending_out expires after a lifetime the host sets — for example, 30 days — and its row returns to none.
5.1 Invite flow (QR / link)
Mermaid source
sequenceDiagram
autonumber
actor A as Alina (human)
participant AS as Alina's MCP server
actor B as Bharat (human)
participant BA as Bharat's agent
A->>A: create invite (expiry, uses, preset)
A-->>B: QR / link via any channel
B->>BA: scan QR
BA->>AS: redeem_invite(token, card_B) [mTLS: B's chain]
Note over AS: chain valid (§14.2)? leaf = card_B's X-HDTP-CERT?<br/>token valid? not expired / revoked / uses left?
alt auto_accept invite
AS-->>BA: accepted + signed card_A + chain_A + granted permissions
Note over BA: validate chain_A · pin A's root and endpoint · save vCard to phone book
else manual approval
AS-->>BA: pending + signed card_A + chain_A
AS->>A: notify: contact request (Bharat, via "Pune conference" invite)
A->>AS: approve
AS->>BA: contact_accepted(card_A, permissions) [mTLS: A's chain]
Note over BA: caller's root = pinned root · endpoint = pinned endpoint
end
Note over AS,BA: both sides active · both phone books updatedNo further exchange is needed: B proved possession of B's leaf key in step 4 — by presenting the chain as the client certificate, or by the signature on a sealed redeem_invite (§13); either way the server validates the chain and checks its leaf is the one in the submitted card — and A's chain reached B signed, over the endpoint A personally handed out in the QR. Mutual mTLS (or sealed calls) from here on.
5.2 Manual flow (vCard shared over existing channels)
Mermaid source
sequenceDiagram
autonumber
actor A as Alina (human)
actor B as Bharat (human)
participant BA as Bharat's agent
participant AS as Alina's MCP server
A-->>B: vCard via WhatsApp / email / AirDrop / contact QR
B->>B: saved to phone contact book
BA->>B: "This contact has an HDTP agent - connect?"
B->>BA: yes
BA->>AS: request_contact(card_B, note) [mTLS: B's chain]
AS-->>BA: pending
AS->>A: notify: request from Bharat (unsolicited - always manual)
A->>AS: approve + choose permission preset
AS->>BA: contact_accepted(card_A, permissions) [mTLS: A's chain]
Note over BA: caller's root = the issuer of the certificate<br/>in the vCard B already holds · endpoint = the card'sThe vCard B received out-of-band is the trust anchor: the contact_accepted caller is accepted only with a chain that validates to the root the card's certificate names, at the endpoint it names. Trust in the card equals trust in the channel that carried it — which is the same trust people already place in a shared phone number.
What "pin" means. In both flows the thing pinned is the root fingerprint the card's leaf names as its issuer, together with the endpoint the leaf names, and the leaf itself as the latest one seen. The chain a caller proves — as a client certificate or inside an envelope — is validated to that root and checked against that endpoint, and "the same key" in the notes above means the leaf key of a chain that passes, not merely the key the card shows.
Rejection: declining a request is a demotion, not a deletion. The requester's row moves to blocked, so a rejected stranger cannot simply knock again — their next request_contact receives the same {"status": "pending"} any stranger gets, while nothing is recorded and the owner is never bothered: blocked MUST be indistinguishable from never-met (§12). The rejecting side MAY tell the peer by calling the pending-tier contact_rejected tool (§6.2), the mirror of contact_accepted; the default is silence. A requester that receives contact_rejected moves its own pending_out row to blocked — its record that the approach was declined and is not to be repeated.
Removal / blocking: remove_contact notifies the peer and deletes the pin on both sides (effective locally regardless — enforcement is "your root is no longer in my list"). Blocking is local-only: the contact silently drops to guest tier; no notification is sent.
An address that belongs to someone. A stranger whose leaf names an endpoint the receiver has pinned for another root, or had pinned for another root within the last 30 days, is never auto-accepted — an invite's auto_accept does not apply — and is shown to the owner beside the name of the contact who holds or held that address. The honest case exists: a person who lost their root starts a new identity at the same address, and their contacts must see that it is a new identity. The dishonest one is a former provider re-using an address it was asked to vacate, wearing the departed person's name.
5.3 A contact at a new address
When a person moves to another host, the new host holds a fresh leaf naming its own endpoint and a copy of the person's contact book (§9), and nothing of the old host's. It reaches each contact from the new address by calling update_contact with the new card, over a client certificate or a sealed envelope carrying the new chain. The receiver validates the chain to the root it has pinned — so this is provably the same person — and finds an endpoint different from the one it pinned and a leaf newer than the one it holds. What happens next is the owner's setting, accept_new_hosts:
auto, the default: the pin's endpoint and leaf are replaced, the call answersok, and the next message flows to the new address; the change is recorded in the owner's audit and shown to the owner as an event — "Alina now writes from a new address" — because a stolen root re-homes contacts in exactly this way, and a person who is told can ask. The default isautobecause the root's signature on the new leaf is the person's own authorisation of the new host, and asking their contact to confirm what they already signed adds a human step to a question the cryptography has settled.ask: the call answers{"status": "pending"}; the request appears beside contact requests, naming the contact, the old address and the new one; until the owner decides, every other call from the new address answerspending_approval, and messages to the contact keep going to the old address, where they can fail. Approving re-pins asautowould have; rejecting leaves the pin as it was, and the new address is a stranger the owner MAY block.
After a removal. A host that is being left, and still holds a valid leaf, could call remove_contact at every contact before the new host arrives, and the person would find their contacts gone. 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. A person who removes a contact and returns meets the same question.
The rule applies to a pinned root in any state but blocked — a peer may move between my request and their contact_accepted: under auto the endpoint is re-pinned and the call proceeds in the tier its state earns; under ask it waits as above.
Either way the old host's leaf — still within its validity, and still in the old host's hands unless it has done what §9 requires — is now older than the one pinned, so a call from the old address proves nothing (§14.3). A contact the new host could not reach — asleep for the whole validity of the old leaf, or absent from the contact book — needs the card again over a human channel: the person re-shares it, or publishes the QR where people find them, and the next exchange carries the current leaf.
6 The agent MCP server and its tools
Every participant exposes one MCP server over HTTPS, under the mTLS rules of §2. HDTP does not fix an MCP revision: a server MAY implement any MCP revision whose clients can list and call its tools (tools/list and tools/call). HDTP is the layer around those two methods that decides who the caller is and what it may call.
Authorization is the proven chain, resolved to a pinned root (§2). The TLS client certificate chain presented on the connection that carries a request is that request's credential; a sealed call carries its own, the chain or leaf inside the envelope (§13). MCP lets an implementation use its own authentication, and HDTP uses no OAuth on this surface. The identity selects a tier and a permission profile, and tools/list returns only what that caller may use. Consumer MCP clients such as hosted chat apps cannot present client certificates; HDTP's callers are agents, which can.
An HDTP server does not send notifications/tools/list_changed, and does not declare listChanged: a caller finds what it may call by calling tools/list when it needs to.
6.1 Tiers
Mermaid source
flowchart TD
C["Incoming call<br/>chain: leaf + root<br/>(client cert or envelope sig, §2)"] --> V{"chain valid?<br/>(§14.2)"}
V -- no --> R["refused<br/>envelope_invalid · handshake"]
V -- yes --> F{"root pinned?"}
F -- no --> G["GUEST tier<br/>redeem_invite · request_contact"]
F -- "blocked or pending_in, or the leaf<br/>is older than the pinned one (§14.3)" --> G
F -- "other endpoint,<br/>any state but blocked" --> N["NEW ADDRESS (§5.3)<br/>auto: re-pin, continue · ask: pending"]
F -- "pending_out,<br/>pinned endpoint" --> P["PENDING tier<br/>contact_accepted · contact_rejected"]
F -- "active,<br/>pinned endpoint" --> A["CONTACT tier<br/>tools filtered by this contact's<br/>permission profile (§8)"]A leaf newer than the pinned one, at the pinned endpoint, replaces it on the way through: that is a renewal, learned (§2). 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. A guest whose endpoint belongs to a pinned contact, or did within 30 days, reaches the owner with that contact's name beside it and is never auto-accepted (§5).
6.2 Core tools
Results. Every tool answers with an MCP CallToolResult holding one text content item, whose text is a JSON object: the tool's result. A result that succeeds carries no isError, or isError: false. A refusal is a result with isError: true whose text is a JSON object holding code, one of the codes of §12, and, where §12 says so, retry_after or data. A call to a tool the caller cannot see, or to one that does not exist, is answered as a refusal: blocked_or_unknown at the guest tier, permission_denied at the pending and contact tiers. Appendix A shows each shape on the wire.
{
"jsonrpc": "2.0",
"id": 7,
"result": {
"content": [
{
"type": "text",
"text": "{\"thread_id\":\"3b1f0c9a6e2d4f5b8a7c1e0d9f2b4a6c\",\"status\":\"delivered\"}"
}
]
}
}
{
"jsonrpc": "2.0",
"id": 8,
"result": {
"isError": true,
"content": [
{
"type": "text",
"text": "{\"code\":\"permission_denied\"}"
}
]
}
}
Idempotency. msg_id-bearing calls are idempotent: the same msg_id re-sent is acknowledged, not re-executed. A msg_id MUST be a non-empty string — idempotency keyed on nothing protects nothing. 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.
Arguments. Every bound below is in bytes of UTF-8. A string past its bound is refused too_large; an argument of the wrong type, a required one that is absent, or a value not among those listed is refused bad_request. The bounds given are the defaults get_card's limits advertises (§12); a host MAY bound the other strings, and does not advertise those bounds.
Guest tier
| Tool | Arguments | Result |
|---|---|---|
redeem_invite |
token (string), card (string: the redeemer's vCard text, §3) |
status ("accepted" or "pending"); card, card_sig and chain: the issuer's signed card and chain (§3, §2); permissions (array of permission names, §8), only when accepted |
request_contact |
card (string), note (string, optional, at most 1024 bytes) |
{"status": "pending"} |
sealed_call |
protected, enc, ct, sig (strings: the four members of an envelope, §13.1) |
an envelope of the same four members, sealed back to the caller (§13.2) |
redeem_invite answers an unknown, expired, revoked or used-up token with invite_invalid. A caller that sends request_contact while its earlier request still waits is answered pending_approval. sealed_call is listed at every tier whenever the card's X-HDTP-SEAL is not none (§13.4).
Pending tier (the caller is someone I asked to be my contact)
| Tool | Arguments | Result |
|---|---|---|
contact_accepted |
card (string, optional: the accepter's vCard), permissions (array of permission names, optional: what the accepter grants me) |
{"status": "ok"} |
contact_rejected |
reason (string, optional, at most 1024 bytes) |
{"status": "ok"} |
Contact tier (each tool present only if permitted for this caller, §8)
| Tool | Permission | Arguments | Result |
|---|---|---|---|
send_message |
message.text |
msg_id (string); text (string, at most 16384 bytes); thread_id, topic, reply_to (strings, optional); sender ("agent" or "human", optional) |
thread_id; status: "delivered" |
send_media |
message.media |
msg_id (string); data (string: standard base64 with padding, RFC 4648, Section 4, of at most 5242880 bytes once decoded) or url (string); filename, mime, thread_id (strings, optional); sender ("agent" or "human", optional) |
thread_id; status: "delivered" |
get_status |
status.view |
none | status: "available", "busy", "dnd" or "offline" |
update_contact |
(always) | card (string: the caller's new card) |
{"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 |
remove_contact |
(always) | none | {"status": "ok"} |
get_card |
(always) | none | card, card_sig and chain: the identity's current signed card and chain (§3, §2) — always the chain, which is how a caller that cannot verify a result gets it (§13.2); limits (§12) |
sender labels a message as written by the identity's agent or typed by its human (§7); on both tools it defaults to agent when absent — the safe direction; a host never invents a human claim. 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. url media is recorded, never fetched on receipt: fetching is an explicit owner action, made with resolve-and-vet address guards (private ranges refused), not a side effect a sender can trigger.
Integrations. Anything else a person wants to expose to contacts — a document dropbox, a task intake, a payment request — is another MCP tool on the same server, behind the same permissions (§8). HDTP defines no other extension mechanism.
Example: calendar tools. Availability and booking show how a capability is added as tools: three tools behind the calendar.* permissions of §8. They are not core tools, and a host without a calendar answers them unavailable.
| Tool | Permission | Arguments | Result |
|---|---|---|---|
check_availability |
calendar.availability |
window (object: from, to, tz), duration_min (integer) |
slots: at most 5 objects of start, end, tz — candidate slots filtered by the owner's policy, never raw free/busy |
book_slot |
calendar.book |
msg_id (string), slot (object: start, end, tz), subject (string), thread_id (string, optional) |
booking_id; ics: the booking as iCalendar text (RFC 5545) |
cancel_booking |
calendar.book |
booking_id (string), reason (string, optional) |
{"status": "ok"} |
7 Messaging and threads
A conversation is a thread_id plus an optional human-readable topic, stored by both sides. A thread_id is a unique string chosen by whoever sends first; a send_message that carries none is placed in a new thread whose thread_id the receiving host assigns and returns. Messages are stored under their thread_id and grouped by it when read. A thread's topic is the one given when the thread was created. Either agent — or either human, typing manually — continues a thread by calling the peer's send_message with that thread_id. sender: human|agent is honest labeling shown in the peer's UI; an agent replying autonomously identifies as the assistant, never as its owner.
Mermaid source
sequenceDiagram
autonumber
actor HA as Human A
participant AA as Agent A
participant MB as B's MCP server
participant AB as Agent B
actor HB as Human B
HA->>AA: "ask Bharat's agent to find 45 min next week"
AA->>MB: send_message(thread T1, topic "Coffee catch-up",<br/>text: proposal, sender: agent)
MB->>AB: deliver into thread T1
AB->>MB: policy check - allowed to negotiate?
AB->>AA: send_message on A's server (thread T1):<br/>"Tue 10:00 or Thu 09:30?" [reverse direction, same thread]
AA->>MB: book via check_availability + book_slot
MB-->>AA: booking_id + ics
AA->>HA: "Booked: Tue 10:00 - added to your calendar"
AB->>HB: digest: "Booked coffee with Alina, Tue 10:00"
HB->>AB: (optionally types into T1 manually - sender: human)Negotiation is conversation between agents inside a thread; there is no negotiation state machine on the wire. Where structure helps, it is a tool: the calendar example of §6.2 answers availability with at most 5 policy-filtered candidate slots, never raw free/busy, and a booking with the iCalendar text both sides file through their own calendar tools. 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. Multi-party coordination (several people's agents negotiating) is therefore parallel per-contact threads sharing a topic string: there is no group cryptography, and each contact's thread is its own. When the peer is unreachable, the sending host retries with backoff until a deadline it sets locally — for example, 24 hours — and then reports the failure to its owner. There is no store-and-forward role: a peer that must be reachable while its own machine is off is hosted (§9).
8 Permissions
Per-contact switchboard, controlled by the owner, enforced at the owner's server on every call — changes apply instantly, no wire protocol needed (flip a switch → the tool disappears from that caller's tools/list and calls return permission_denied).
| Permission | Gates | In "basic" preset |
|---|---|---|
message.text |
send_message |
✔ |
message.media |
send_media |
✖ |
status.view |
get_status (status visibility) |
✖ |
calendar.availability |
check_availability (the calendar example, §6.2) |
✖ |
calendar.book |
book_slot, cancel_booking (the calendar example, §6.2) |
✖ |
integration.<name> |
any additional exposed tool | ✖ |
integration.<name> is one switch per integration, not per tool: it gates every tool that integration exposes, and those tools appear in a contact's tools/list under the name <slug>_<tool> — the integration's slug and the tool's own name joined by _, lowercased, with every run of characters other than a–z and 0–9 replaced by one _ and none left at either end — while the permission is integration.<slug>. Integration grants sit outside preset bundles in both directions — no bundle names them, so applying a preset never grants one and never revokes one; they are always an explicit per-contact decision.
Presets are owner-editable bundles assigned at approval time and adjustable per contact afterwards; four are defined as defaults:
| Preset | Grants |
|---|---|
basic |
message.text |
work |
message.text · calendar.availability · calendar.book |
friend |
message.text · message.media · status.view · calendar.availability · calendar.book |
family |
the same bundle as friend — the distinction is the owner's to draw, not the protocol's |
A preset is a label for a bundle, not a lock: hand-toggle one switch and the grant is bespoke. Beyond visibility, the owner's agent applies its own policy on top (auto-reply vs. surface-to-human, auto-book windows, quiet hours) — that is local behavior, not protocol.
Mermaid source
flowchart LR
subgraph B["B's server - per-contact profiles"]
P1["Alina: text+media+availability+book"]
P2["Vendor X: text only"]
P3["Unknown callers: guest tier"]
end
A1["Alina's agent"] -->|"tools/list shows 9 tools"| B
A2["Vendor X agent"] -->|"tools/list shows 5 tools"| B
A3["Stranger"] -->|"tools/list shows 3 guest tools"| BThe counts are for a host whose card asks for sealing: each list holds sealed_call (§6.2). Alina's profile grants five tools, and every contact also sees update_contact, remove_contact and get_card; Vendor X's grants send_message; a stranger sees redeem_invite and request_contact.
9 Hosting
Direct calls need the recipient's server reachable. The hours it is not are answered by the thing a person-held root makes safe: being hosted. A host runs the identity's server all the time, under a leaf the person issued, and can be replaced without the person losing anything. There is no relay role, and no store-and-forward gateway that would see every sender, recipient and timestamp for its trouble: a host delivers directly and, when the peer is unreachable until expires, reports failure (§7). It never writes a gateway property on a card, and never honours one a card names (§3). This section is what a host holds, what it must do when the person leaves, and how the wallet on the other side behaves.
What a host holds. The leaf certificate for the identity at its endpoint and that leaf's private key; a superseded leaf's key until its notAfter (§2); the identity's data — contacts, threads, media, invites, settings, audit log. Never the root. A host obtains a leaf by sending the wallet a certificate signing request (PKCS #10, RFC 2986) carrying the host's key and the endpoint it will serve; the wallet shows the person the endpoint and the validity, and signs or does not. A provider's sign-up page for a person who has no wallet runs the same ceremony in the browser: the root is made or derived there (§2.1), the first leaf is issued there, a copy is downloaded before anything else happens, and no server sees a root. The ceremony runs in a document the provider's page cannot read — served from an origin that is not the provider's, pinned by integrity hash, and top-level rather than framed, because WebAuthn in a cross-origin frame depends on the embedder delegating permission, which not every browser allows. It hands the page back only the leaf; a page that could read the root would be the provider seeing it.
Renewal is §2: a CSR again, for the same endpoint, before the old leaf expires. 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). 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.
Moving is the person issuing a leaf to the new host, the data carried across as an archive, and the new host reaching every contact by §5.3 before the person tells the old host to leave, so that no contact meets a gap. The archive is the export of §9.2: the person's contacts and their conversations, with the media in them, and nothing that is the host's own — no settings, no credentials, no invites, no record of the host's leaves. 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. A host imports the archive by §9.2: the person sees every contact before one is written, an imported leaf never replaces a pin the host validated itself, and the import ends with a new leaf from the person's wallet.
Mermaid source
sequenceDiagram
autonumber
actor P as Person (wallet)
participant O as Old host
participant N as New host
participant C as A contact
N->>P: CSR (new host key, new endpoint)
P->>N: leaf, signed by the root
O-->>N: archive (contacts and conversations, never keys)
P->>O: leave
O->>O: delete the leaf key and every record
N->>C: update_contact(new card) [chain: new leaf + root]
C->>C: chain valid · root pinned · endpoint differs · leaf newer
alt accept_new_hosts = auto
C-->>N: ok (re-pinned)
else ask
C-->>N: pending
C->>C: owner approves
end
C->>N: send_message [sealed to the new leaf]What a host must do when the person leaves. Destroy the leaf's private key and delete every record of the identity — data, keys, the fingerprints of former leaves — at once, keep nothing beyond what law compels, and answer calls at the old address exactly as it answers calls for an address it never served. The protocol's backstop against a host that does not is the leaf's own expiry, and the fact that a newer leaf outranks it with every contact it reaches (§14.3). Outside the protocol, the person's backstop is the regime the provider is audited under: a provider that hosts other people's identities is their data processor, and its audits and legal obligations are what make deletion checkable. 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.
The wallet holds the root and nothing a host holds. It signs certificates only from an explicit user action, in a window of its own that no page can draw over, and before signing it shows the endpoint the leaf will name, the origin of the page that asked (a difference between the two is shown, not hidden — a provider's portal and the addresses it serves are often different hosts), whether that endpoint's host is one it has issued to before, and the validity. It verifies the CSR's own signature, so the key it certifies is one the host proved it holds, and refuses a CSR whose key is a root. Issuing a leaf to a new endpoint requires a deliberate act by the person again — the passphrase, the hardware key, or a fresh user-verified assertion in a wallet that has no passphrase — even in an unlocked session; a renewal for the same endpoint needs the click alone. 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.
At rest the root is under a key derived from a passphrase with a memory-hard function, optionally wrapped by a hardware key — and better, the root is not at rest in the wallet at all: held in a hardware key, or derived from a passkey on each use (§2.1). A root generated in a hardware key has no copy anywhere, which is the one defence against a root fought over by two holders (§14.3); a derived root has exactly one, the file below, which only the person holds. Unlocked, or reconstructed, it lives for the signing and nowhere a page can reach; a wallet that must hold it in software SHOULD hold it in a handle it cannot itself read back.
The wallet keeps its own copy of the person's contact book, so the book outlives any host and any identity, and a ledger of the leaves it has issued — each entry the endpoint and the dates, never the leaf itself, which is the host's to serve and grants nothing. The ledger and the contact book live in the wallet's record: under the store key of §2.1 for a wallet with a credential, and beside the file under the recovery key for one without. The book leaves and enters the wallet as a §9.2 export holding contacts only — a book; inside the record it keeps the wallet's own form.
The file is the backup of the root and nothing else — the root's private key, its certificate and, for a derived root, the PRF secret of §2.1 — sealed under a recovery key held by nobody but the person — generated by a wallet that has a credential, shown once and offered as a file; in a wallet without one, a command-line tool, a passphrase the person chooses. 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.
Losing the credential is not losing the root: the file and the recovery key open the record through the PRF secret, and the wallet then re-binds — it makes a new credential, seals the root into a new record under the store key that credential derives, carries the ledger and the contacts, deletes the old record and writes a fresh file — and from then on the new credential opens the root as the lost one derived it. A wallet MAY hold a root at rest in that way and in no other, on the person's act; a re-bound root is indistinguishable on the wire (§2.1). Losing every copy — the credential, and the file or its recovery key — ends the identity; the wallet says so once, when the root is made.
9.1 Signing requests
A host asks a web wallet for a leaf with a signing request: an HTML form submitted by top-level navigation, POST, application/x-www-form-urlencoded, to the wallet's signing address. Nothing of it is carried in the URL. Its body has these fields:
| Field | What it holds |
|---|---|
csr |
the certificate signing request, base64url PKCS #10, at most 4096 bytes: the host's key and the endpoint the leaf will name |
purpose |
renew or move |
expect_root |
the fingerprint of the identity's root, which the wallet proves by §2.2 |
root_cert |
optional: base64url DER of the root certificate, at most 4096 bytes, when the host holds it; it hashes to expect_root |
redirect |
the absolute URL the answer returns to: https, or http to a loopback host (localhost, 127.0.0.0/8, ::1); no userinfo, no fragment, at most 2048 bytes |
state |
32 random bytes, base64url without padding — exactly 43 characters — minted by the host for this request; the wallet echoes it and reads nothing in it |
recipient |
at most 200 characters: what the host calls itself, which is the host's own claim |
valid_days |
the validity the host suggests, in days: a decimal integer from 1 to 398, with no leading zero |
expires |
an RFC 3339 instant in UTC — ending in Z, with a fraction of a second, where there is one, after a . and never a , — at most ten minutes after the request is made |
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. 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. 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. 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.
A wallet MUST prove the root against expect_root (§2.2) before it signs. 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. The person chooses the validity; valid_days is a suggestion.
It answers by navigating the top-level browsing context to redirect with the fragment chain=<leaf>.<root>&state=<state>, the two certificates base64url DER, or error=<code>&state=<state> — cancelled when the person declines — and to no other destination; a request it refuses gets no answer at its redirect. The wallet MUST NOT keep anything of the request once it has answered, and MUST NOT write its body to a log. Only the chain and the state travel, and the chain is public: it certifies a key only the host holds (§9's proof of possession), so a page that collected it could not use it.
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). A captured request can be replayed until expires, and a replay still needs the person's act and yields a leaf only for the asking host's own key. A fragment reaches no server log and no Referer; a host SHOULD read it in the page, clear it from the address bar, and submit it to itself under the person's own session. A page that sets Referrer-Policy: no-referrer makes its browser send Origin: null on the form, which every wallet refuses, so the page that submits a signing request has to relax that policy.
9.2 The export
An export is the file that carries a person's contacts, conversations and files from one host to another, and the archive of §9 is an export. It is one zip file, and it is not encrypted, so that any host can import it. It holds no key of any kind — neither the host's nor the person's — and is not the file of §9 that backs up a root: a wallet's export of its root (§2.1) is that file, and never this one. The name of the file is not significant; an importer reads nothing from it.
manifest.json format version, owner, time, counts, the sha256 of each text member
contacts.csv one row per contact
threads.csv one row per thread; several threads per contact
messages.jsonl one JSON object per line: bodies, replies, attachments
media/
media/<sha256> one file per attachment, named by the sha256 of its bytes
The wallet's contact book travels in the same format: a book is an export holding manifest.json and contacts.csv only, whose manifest counts zero threads, messages and media.
manifest.json is one JSON object with exactly these members:
{
"hdtp_export": 1,
"owner": "sha256:…",
"owner_name": "Alina",
"exported_at": "2026-09-27T10:00:00Z",
"tool": "…",
"counts": {
"contacts": 12,
"threads": 30,
"messages": 812,
"media": 9
},
"files": {
"contacts.csv": "<sha256 hex>",
"threads.csv": "<sha256 hex>",
"messages.jsonl": "<sha256 hex>"
}
}
hdtp_export is 1, the only version there is. owner is the fingerprint of the exporting identity's root, and the only place the file says whose it is; owner_name is that identity's display name and tool the writer's name and version, both informative. exported_at is the time of the export. Every time in an export — exported_at, added, created_at, last_at and a message's time — is an RFC 3339 instant in UTC: it ends in Z, and a fraction of a second, where there is one, follows a . and never a ,. counts counts the rows, lines and media files, and counts.media is the number of media/ members. files maps each text member the file holds — contacts.csv, threads.csv, messages.jsonl — to the lowercase hex sha256 of its bytes, and lists nothing else: a media member is bound by its own name, which is the sha256 of its bytes, and by counts.media, so the number of files an export carries is not bounded by the size of its manifest.
contacts.csv, like threads.csv, is UTF-8 CSV as RFC 4180 describes it, and its first row is exactly this header:
root,endpoint,name,display_name,status,was_active,permissions,their_permissions,leaf,root_cert,added
| Column | What it holds |
|---|---|
root |
the contact's fingerprint; unique in the file, and never owner |
endpoint |
the contact's endpoint, in the normal form of §14.1, passing the address guard of §3 |
name |
the owner's own name for the contact, at most 200 characters |
display_name |
the contact's name for themselves, at most 200 characters |
status |
active, blocked or pending_out; a request received and not yet decided stays with the host that received it |
was_active |
true or false: whether this was ever a contact, which decides what an unblock restores |
permissions |
what the owner grants the contact: the names of §8, separated by spaces |
their_permissions |
what the contact last said it grants, the same way; informative only |
leaf, root_cert |
optional, base64url DER; root_cert hashes to root |
added |
RFC 3339 |
threads.csv has exactly the header id,contact,topic,created_at,last_at: id is unique in the file, contact is a root from contacts.csv, topic is the thread's topic (§7), and the times are RFC 3339.
messages.jsonl holds one JSON object per line, with exactly these members:
{
"id": "…",
"thread": "<thread id>",
"contact": "sha256:…",
"msg_id": "…",
"direction": "in|out",
"sender": "agent|human",
"time": "RFC 3339",
"body": "text, at most 16 KiB, any lines",
"reply_to": "<msg_id>|null",
"status": "delivered|queued|failed|read",
"attachments": [
{
"file": "<sha256>",
"filename": "a.pdf",
"mime": "application/pdf",
"size": 12345
}
]
}
id is unique in the file; msg_id is the message's own idempotency id (§6.2); body is the text alone, and the file a message carried is its attachments, naming a media/ member by file. attachments holds at most one element, because a message carries at most one file (send_media, §6.2), and a message that carries one has an empty body, because send_media carries no caption; a media message that carried a link rather than bytes travels with attachments: [] and the link as its body. 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. direction is in or out, sender is agent or human (§7), time is RFC 3339, status is one of the four shown, and reply_to is a msg_id in the file or null. An outgoing message that was never delivered travels with status: queued.
media/<sha256> is the bytes of one file, at most 5 MiB (§12's inline limit), named by the lowercase hex sha256 of those bytes.
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.
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. The words need not be these, for a full export:
This file is not encrypted. Anyone who gets it can read your contact list and all your conversations and files. It holds no keys, so it cannot be used to speak as you. Keep it where you keep private documents, and delete it once it has been imported.
and for a book:
This file is not encrypted. Anyone who gets it can read your contact list. It holds no keys, so it cannot be used to speak as you. Keep it where you keep private documents, and delete it once it has been imported.
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.
Validation. An importer MUST check the whole file before it writes anything, and MUST refuse the whole file if any check below fails:
| Check | Rule |
|---|---|
| Names | 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 |
| Duplicates | An importer MUST refuse a file in which one name appears twice |
| Members | 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 |
| Entry kinds | An importer MUST refuse an encrypted entry, a symbolic link (a Unix mode in the external attributes), and any directory but media/ |
| Sizes | 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) |
| Hashes | 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 |
| Owner | 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 |
| Rows | 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 . |
| References | 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 |
| Key material | 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 |
A refusal names the member, and the row or line and the column where there is one; a refusal for a ceiling of the host's own names that ceiling (below).
Import. A host imports a file that passed validation in this order:
- It MUST show the person the contacts, and write nothing until the person agrees.
- It merges the rows with the pins it already holds. An imported leaf MUST NOT replace a pin the host validated itself, and a row's
leafis pinned only when[leaf, root_cert]validates at the row'sendpoint(§14.2). - It writes the contacts, which are recognised at once in the status their rows give, then the threads, the messages and the files. A host MUST NOT send a message it imported, whatever its
status: retries belonged to the host that exported it. - The import MUST end with a request for a new leaf for the importing endpoint, which the host mints itself with
expect_rootequal toowner—movefor an identity new to the host,renewfor one it already serves — and which the person completes in their wallet (§9.1). - Once that leaf is installed, the host MUST call
update_contactat 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. A contact that pins the identity takes the new address by §5.3. A contact that refuses the call —update_contactis a contact-tier tool, and that contact does not hold the identity as one — MUST then be sentrequest_contact, which that contact decides under its own policy.
What a contact controls. Some of what an export carries is the contact's own — the name they give themselves, the permissions they say they grant, the messages they sent — and none of it stops the owner taking their export. A writer MUST write reply_to as null when the message it names is not in the file. 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. A writer MUST truncate display_name to 200 characters, since it is the contact's own claim. 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. The importer's checks are unchanged, so a file that breaks any of these was not written by a conforming writer, and is refused as hostile.
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. 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. A person can always leave with their data (§9, Moving). A writer MAY refuse a file its container cannot represent — a zip over 4 GiB without zip64 — naming why.
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.
10 Deployment
Mermaid source
flowchart LR
subgraph SH["Self-hosted at home"]
N1["Agent + MCP server<br/>on home machine<br/>leaf issued by the owner's wallet"]
end
subgraph TU["Reachability options"]
T1["Port forward / static IP<br/>full mTLS end-to-end<br/>the chain is the server certificate"]
T2["TCP/TLS passthrough tunnel<br/>full mTLS end-to-end"]
T3["Edge-terminating tunnel or proxy<br/>identity + confidentiality ride<br/>the sealed envelope (§13)"]
end
subgraph PF["Provider-hosted"]
H1["Provider runs MCP servers,<br/>contact stores, invite pages<br/>under leaves its customers issued"]
end
N1 --> T1
N1 --> T2
N1 --> T3Self-hosting: anything that passes TLS through to the machine unterminated — a forwarded port, or a tunnel that routes TLS by its server name without decrypting it — preserves end-to-end mTLS, and the caller's client certificate reaches the server. A tunnel or proxy that terminates TLS at its edge strips client certificates; behind such an edge, caller identity and confidentiality ride the sealed envelope instead (X-HDTP-SEAL: required, §13), and the edge sees ciphertext plus metadata only. A custom domain + Let's Encrypt on the tunnel/host gives contacts a clean endpoint; a host on its own domain MAY instead present its chain as the server certificate (§2). A machine that is not always on is not an HDTP host; a person whose machine is not always on is hosted by a provider (§9).
Provider mode: the operator hosts each customer's MCP server (per-tenant paths or hostnames) and renders invite links/QRs. It holds one leaf per identity, issued by the customer's own root for the address the operator serves it at, and never the root: a customer who leaves issues a leaf to the next host, that host reaches every contact from its own address (§5.3), and the operator deletes what it held (§9). Every change of address — a custom domain, a rename, a move between the operator's environments — is a leaf the person signs and a new address at every contact (§5.3); an operator gates such changes behind that ceremony rather than performing them alone. A person arriving with nothing makes their first identity in the browser on the operator's sign-up page: the root is generated there, the first leaf issued there, the root's backup file (§9) downloaded before anything else happens. The same front-door machinery scales down to one person: an ingress — an HDTP host on a VPS routing per-subdomain, either passing TLS through untouched or terminating public TLS and re-originating over mutually pinned mTLS to the home machine — is the self-hosted form of provider mode, and a provider is that ingress run for many tenants.
11 Security considerations
What HDTP relies on for each property, and the risk that remains:
| Property | What HDTP relies on | What remains |
|---|---|---|
| Who am I talking to | A root pinned from a vCard/invite exchanged human-to-human; every call proves the leaf key of a chain that validates to it and names the address in use: X.509 chain validation with the person as the authority, and one rule about which leaf is newest (§14) | Trust in a card equals trust in the channel that carried it (§3) |
| Consent | Manual approval on both sides, always; invites = pre-approval by the issuer; a contact's new address is accepted on the strength of their own root's signature, or on the owner's say-so (§5.3) | Under accept_new_hosts: auto, a stolen root moves contacts to a new address without asking them; the owner is shown the event (§5.3) |
| Wire privacy | TLS 1.3 between the two endpoints; sealed envelopes past terminating edges (§13) | No forward secrecy at the envelope layer, and carriers see metadata (§13.5) |
| Harvest now, decrypt later | Nothing: HDTP has no post-quantum cryptography. The path is set (§13.5): sealing first, as a hybrid key the leaf carries and one suite; the card as a pointer and the chain sent once, so the change touches neither card nor wire | Sealed traffic recorded today can be decrypted by an adversary that later has a quantum computer |
| Impersonation of a link | Invite redemption anchored to the issuer-distributed URL; card signature by the issuer's leaf key; the card's certificate names its issuer | Whoever controls the sharing channel can swap the card or the URL: the same trust as sharing a phone number |
| Impersonation by name | Nothing at the protocol layer: FN is the sender's claim (§3). Attribution is cryptographic — a chain validates to the pinned root or it is refused — so a contact can never send as another. What it can do is call itself what another calls itself; an owner's own name for a contact is a local answer, not a wire one |
On first contact, before the owner has named anyone, the only name on screen is the one the peer chose |
| Revocation | Delete contact/invite server-side — instant, local, nothing cryptographic outstanding. A host's authority ends at its leaf's notAfter, or the moment a newer leaf reaches a contact — no CRL, no OCSP |
A contact that has not seen a newer leaf accepts the older one until its notAfter (§14.3) |
| Renewal | A new leaf from the wallet, learned on the next exchange; the root is never rotated, because a compromised root's holder could rotate it too, and rotation would not tell the person from the thief | A lost or compromised root is a new identity (§2) |
| Replay/dup | Idempotent msg_id per call; TLS between the endpoints; for a sealed call, the envelope's msg_id record and its 300-second window (§13.3) |
— |
| Spam | The guest tier has two tools, and sealed_call when sealing is on; invites carry expiry/uses; per-contact and per-identity call budgets (§12) |
A flood still costs the receiver the work of refusing it (§14.5) |
| Prompt injection | Every inbound string (text, note, topic, filenames) is untrusted data — length-capped, never concatenated into the agent's instructions, rendered to humans as quoted content |
— |
| Custodial hosting | The host holds the leaf key and can act as you while the leaf is valid — as every hosted service can — but never the root: its authority is written on a certificate you signed, for an address you saw, until a date you chose, and is outranked by the next leaf you sign (§9); HDTP keeps no log, and the certificate is the record | While its leaf is valid, a host can act as the person at its address (§14.5) |
12 Errors, limits, conformance
Errors. Each code below is the code of a refusal: a tool result with isError: true whose text is a JSON object (§6.2). A refusal carries code and, for the two codes that say so, one more member.
| Code | Meaning |
|---|---|
unknown_contact |
The call needs a contact or a request the receiver does not hold for the caller — for example, contact_accepted from a caller the receiver sent no request to |
pending_approval |
The caller is at the pending tier and the call is not one of its tools (§6.1); a request_contact repeated while the first still waits; a contact at a new address under ask (§5.3) |
permission_denied |
At the pending or contact tier, a tool the caller may not use, or one that does not exist (§6.2, §8) |
invite_invalid |
An invite token that is unknown, expired, revoked or used up (§4) |
blocked_or_unknown |
At the guest tier, a tool the caller may not use, or one that does not exist: one answer for every case, so that a guest cannot tell them apart |
too_large |
A string or a decoded data past its bound (§6.2) |
rate_limited |
A call budget is spent (below). It carries retry_after: whole seconds, at least 1, until the budget holds a call again |
unavailable |
The host cannot serve the call now: a tool it is temporarily withholding, a capability it does not have, or a bound on the contacts or requests an identity holds (below) |
bad_request |
An argument that is absent, of the wrong type or not among the values allowed (§6.2), or a card refused at intake (§3) |
seal_required |
An identified caller's unsealed substantive call to a recipient whose card says X-HDTP-SEAL: required (§13.3) |
identity_required |
No usable identity proof where one is needed (§13.3) |
envelope_invalid |
An envelope that is malformed, misdirected, mis-signed, expired or fingerprint-mismatched, or whose chain fails §14.2 |
seal_not_accepted |
A sealed call to a recipient whose card says X-HDTP-SEAL: none: the sender was told not to seal (§13.4) |
certificate_renewed |
An envelope sealed to a leaf key this endpoint once held and holds no longer. It carries data, {"chain": [leaf, root]}: the current chain, which the caller validates against its pin before it seals again (§14.4) |
chain_required |
An envelope that named its sender's leaf by fingerprint and could not be verified against a leaf the receiver holds; it carries nothing more, and the sender sends again with its chain (§13.2) |
Limits are defaults — operator-tunable, and discoverable: the numbers below are what an untuned host enforces; an operator MAY raise or lower them, and the values in force are advertised as the limits object of the get_card result. Every byte count is of UTF-8, or of decoded bytes for media.
limits member |
What it bounds | Default |
|---|---|---|
text_bytes |
a send_message text |
16384 |
note_bytes |
a request_contact note, and a contact_rejected reason |
1024 |
media_inline_bytes |
a send_media data, decoded; larger media go by url |
5242880 |
availability_slots |
the slots one check_availability answer holds (§6.2) |
5 |
invite_ttl_days |
the longest lifetime, in days, an invite may be given (§4) | 90 |
contact_calls_per_second |
the per-contact budget's rate (below) | 1 |
contact_burst |
the per-contact budget's burst | 10 |
identity_calls_per_second |
the per-identity budget's rate | the number of contacts the identity may hold, times contact_calls_per_second, or less where the host cannot sustain that |
guest_calls_per_hour |
the guest budget, per address and root | 10 |
guest_source_calls_per_hour |
the budget of a source address alone | 60 |
A certificate is at most 4 KiB, and a chain is exactly two certificates (§14.2). A host MAY bound the contacts an identity holds — its active contacts and the requests it has sent — and the requests waiting for its owner; a redeem_invite or request_contact past either bound is answered unavailable, and neither bound is advertised.
Call budgets are sized by the contacts an identity may hold, so that the people it knows are not throttled for talking to it at once. Every budget is a token bucket — a sustained rate and a burst: a bucket holds at most its burst in calls, refills at its rate, and a call it has no whole call for is answered rate_limited with retry_after the seconds, rounded up and at least 1, until it holds one again. The default budgets:
- Per contact: 1 call/second with a burst of 10, keyed by the identity called and the contact's pinned root.
- Per identity, every contact together: the number of contacts the identity may hold times the per-contact rate, with one second of that as its burst, so that each of its contacts can call at its own rate at the same moment and none is refused — unless the host cannot sustain that rate, in which case it enforces and advertises the rate it can.
- Guest tier: 10/hour per IP+key, the key being the root fingerprint of the chain presented, with a burst of 10.
- Source address: a small-form envelope answered
chain_requiredcounts against the budget of its source address alone, 60/hour with a burst of 60, since an unverified sender is a guest until proven and many callers share an address behind a NAT or a hosting provider's egress; a source over budget is answeredrate_limitedbefore anything is opened.
A caller at pending tier spends the guest budget, and when one guest dimension is missing (no client address behind an edge, no key on a bare probe), the remaining dimension still budgets alone; neither absence buys an unmetered path. Every call that reaches dispatch spends — tools/list, and a tool the caller may not see or that does not exist, as much as any other — and a replayed envelope answered from its record (§13.3) spends nothing. The per-identity budget is spent only by contacts; a guest's call spends its guest budget and nothing a contact needs.
Conformance checklist. An implementation is an HDTP agent server if it:
- exposes an MCP server over HTTPS accepting TLS client certificates, or, behind an edge that terminates TLS, requiring sealed calls (§10, §13.4);
- identifies callers by fingerprint against a contact list — the root of a validated chain — with guest, pending and contact tiers;
- implements the guest and pending tools and
send_message,update_contact,remove_contactandget_card; - filters
tools/listper caller; - enforces manual approval for unsolicited requests;
- supports invite issuance with expiry, uses and revocation;
- emits and imports vCards with the
X-HDTP-*properties; - treats inbound strings as untrusted;
- honors idempotent
msg_id.
An implementation advertising X-HDTP-SEAL: optional|required additionally implements §13: sealed_call at every tier, the open order, and sealed results for sealed requests.
Because identity is a certificate chain, an implementation also:
- validates every chain by §14.2 and passes the shared vectors;
- carries its chain in its first envelope to each contact and in the first after each renewal, names its leaf by fingerprint otherwise, and answers
chain_requireduniformly to any small-form envelope it cannot verify (§13.2); - keeps one pin per root — endpoint and latest leaf — treats an older leaf as no proof (§14.3), and learns a newer leaf at the pinned endpoint from any exchange;
- runs the new-address flow of §5.3 under
accept_new_hosts; - holds a superseded leaf's key until its
notAfterand answers a former key withcertificate_renewed(§14.4); - deletes everything it held for an identity that has left (§9);
- refuses any envelope whose
vis not1, and any card whoseX-HDTP-VERSIONis not1, asenvelope_invalidandbad_requestrespectively.
A wallet is an HDTP wallet if it holds a root and nothing a host holds, signs a certificate only from an explicit user action, and shows the endpoint before signing while letting the person set the validity — any span up to the 398-day ceiling of §14.1, which is the receiver's rule: a wallet that offers a longer validity issues leaves every contact refuses (§14.2).
The record. Each sentence of this document that states an absolute requirement or prohibition, in the obligatory keywords of BCP 14, has an identifier: its section's number and its place among those sentences of that section, as in 14.3#1. PROOFS.md in the hdtp-identity repository lists every one beside the test, intrusion scenario or named external artefact that holds it.
13 Sealed envelopes
Optional at the protocol level, negotiated per §3's X-HDTP-SEAL; an implementation that never seals remains conforming toward none recipients.
Plain mTLS ends where TLS ends. A terminating tunnel edge reads whatever crosses it and sees no client certificate — so behind such a pipe, both confidentiality and caller identity need a carrier that survives termination. The sealed envelope is that carrier: HPKE encryption to the recipient's leaf key plus a detached signature by the sender's leaf key. One key does all three jobs — TLS, signature, sealing — and it belongs to a leaf under a root (§2); the sender's chain rides inside the envelope until the receiver holds the leaf, and is named by fingerprint after that — which is how a new or renewed leaf travels without every "hi" carrying a kilobyte of certificates (§13.2).
13.1 Format
An envelope is a JSON object of four members:
| Member | Content |
|---|---|
protected |
base64url of the canonical-JSON header bytes (the HPKE AAD): v (=1), suite, kid (the fingerprint of the recipient leaf key this is sealed to — it names the recipient), msg_id, ts, exp (integer Unix seconds; exp − ts ≤ 30 days), cty (application/hdtp-call+json for requests, application/hdtp-result+json for results). There is no from and no to: the sender is the chain inside the ciphertext, the recipient is the key. |
enc |
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 |
ct |
base64url ciphertext of the plaintext payload |
sig |
base64url detached signature by the sender's leaf key over protected ‖ enc ‖ ct (the raw byte concatenation of the three decoded members) |
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. sig covers the decoded bytes, so every spelling a reader forgives is a second envelope that verifies, and two readers that forgive different things disagree about which envelopes exist: a reader that skips a character it does not recognise accepts an envelope a strict reader refuses.
kid is what lets a superseded key be refused before anything is opened, and refused usefully — with certificate_renewed and the current chain (§14.4). The header names one key and nothing else; the sender's certificates travel inside the ciphertext (§13.2), so a carrier sees which key a message is for, when, and how large — never who sent it, a name, or an address. The recipient's key id is stable for a leaf's life, and that linkage is the metadata that remains (§13.5).
Canonical JSON is the JSON Canonicalization Scheme of RFC 8785: UTF-8, keys sorted by code point, no insignificant whitespace, no HTML escaping, numbers in their shortest form. Suites (HPKE is RFC 9180, Base mode):
| Suite id | KEM | KDF | AEAD | For recipients whose leaf key is |
|---|---|---|---|---|
HDTP-SEAL-P256 |
DHKEM(P-256, HKDF-SHA256) | HKDF-SHA256 | AES-128-GCM | P-256 |
HDTP-SEAL-X25519 |
DHKEM(X25519, HKDF-SHA256) | HKDF-SHA256 | ChaCha20-Poly1305 | Ed25519, birationally converted |
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. The signature uses the sender's own algorithm regardless of the recipient's suite — which is what lets any two identities interoperate; HPKE Base mode is used rather than Auth mode because an Ed25519 identity and a P-256 identity cannot share an authentication DH. Pinned encodings: ECDSA P-256/SHA-256 signatures are ASN.1 DER; Ed25519 signatures are pure Ed25519 per RFC 8032. The HPKE info parameter is the ASCII string HDTP-SEAL-v1, and an envelope sealed under any other info string MUST NOT open. Ed25519 keys convert to X25519 per the standard maps: the public key by the birational map of RFC 7748, Section 4.1, the private scalar from the SHA-512-derived, clamped scalar of RFC 8032, Section 5.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).
msg_id is REQUIRED and MUST be non-empty — replay protection keyed on an empty string protects nothing. 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. A ts of "1757000000" is not the same bytes as one of 1757000000, and a language that coerces the one to the other has accepted a header a stricter peer refuses.
13.2 The sealed_call tool
Sealing is carried MCP-natively by one wrapper tool, sealed_call, present at every tier. Its tool arguments are the four envelope members of §13.1 at top level — {"protected": …, "enc": …, "ct": …, "sig": …} — and its result is an envelope of the same shape.
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. MCP request metadata (_meta) has no place in it: a plaintext with any other member is refused (§13.3), and a receiver reads only name and arguments from params. chain is the sender's leaf and root, base64url DER, leaf first: a proof from the root, the key that verifies sig, and the one way a leaf the receiver holds is updated — when it is present the receiver validates it in full (§14.2) and the pin follows §14.3, a newer leaf replacing the pinned one, an older one proving nothing, a different endpoint being §5.3. 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.
leaf is the fingerprint of the sender's leaf key (sha256: and 43 base64url characters), and says: verify me under the leaf you already hold. A receiver that holds that leaf for an active or pending contact, still within its validity, verifies sig under it and proceeds at the pinned tier and endpoint, with nothing to update; a receiver that cannot verify sig against a leaf it holds — the fingerprint is unknown, it names a blocked contact, the held leaf has expired, or the signature fails — answers chain_required, in plaintext and with no data, and the sender resends with chain. The answer is the same in every case so that it tells a stranger nothing about who the receiver knows, and a blocked contact meets it exactly as a stranger does. An envelope that carried chain is never answered chain_required: a chain that fails is envelope_invalid.
The inner call is dispatched exactly as if it had arrived directly from the proven identity — same tiers, same permission switchboard (§8).
Results. The result of a sealed request MUST be sealed back to the caller, in the same format: kid names the caller's leaf key, msg_id is the request's, for correlation, cty is application/hdtp-result+json, and the plaintext carries the responder's own chain or leaf beside the result, by the rule a request follows — the chain when the caller has not seen this leaf, the fingerprint after. A result plaintext is one bare JSON object of result, the inner result, or error, an error object of §12, and exactly one of chain and leaf. Result envelopes are never dispatched: the receiving caller decodes, opens, validates the chain or finds the named leaf among its pins, verifies the signature and correlates, and the request-side steps of §13.3 (idempotency, tiering) do not apply to them. A caller that cannot verify a result asks with get_card, which always answers with the chain. A plaintext request gets a plaintext result.
A guest's sealed redeem_invite/request_contact is bound three ways inside the opened payload: 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. A sealed tools/list from an unknown sender has no card to bind and is rejected envelope_invalid (guests use plain tools/list, which always answers).
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. certificate_renewed (§14.4) is always of that second kind: it answers an envelope sealed to a key the recipient no longer holds, which was never opened, so it travels in plaintext and carries nothing a caller trusts before validating the chain.
cty is what binds direction: application/hdtp-call+json envelopes are dispatched, application/hdtp-result+json envelopes are only ever correlated, and an envelope whose cty does not match its position is rejected envelope_invalid.
13.3 Opening
Receivers MUST validate in this order, rejecting at the first failure:
- Decode
protected. - Check that
vandsuiteare supported. - Resolve
kidto a leaf key this endpoint holds for the identity served at the path the envelope arrived at — the current one, or a superseded one not yet past itsnotAfter. Otherwise answercertificate_renewedwith the current chain whenkidnames a key this endpoint once held for that identity, andenvelope_invalidwhen it never did or holds it for another identity (§14.4). - Check that
suiteis the one the leaf's key takes (§13.1). - HPKE-open.
- Require the plaintext to carry exactly
method,paramsand one ofchainorleaf. - With
leaf, find the leaf it names among the pins of active and pending contacts and verifysigunder its key, answeringchain_requiredto any failure, and proceed at that pin's tier and endpoint. Withchain, validate it (§14.2), verifysigunder its leaf key, and resolve the tier (§6.1): when the chain's root is pinned, a leaf older than the pinned one is a guest, a different endpoint is §5.3, and a newer leaf at the pinned endpoint replaces it; when it is not pinned, apply the guest binding of §13.2. - Enforce time:
now < exp, and|now − ts| ≤ 300 s, since every envelope is delivered directly. - Enforce
msg_ididempotency: a replayed envelope is acknowledged with its original result, never re-executed. - Dispatch.
A failure at a step that names no other answer is envelope_invalid, and so is any header whose v is not 1.
A substantive call is any unsealed tools/call other than sealed_call itself; from an identified caller to a required recipient it fails seal_required, and a call carrying no usable identity proof where one is needed fails identity_required first (§12).
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. Nothing later than ts + 300 s passes the skew check, so a record held past that point protects nothing, and exp − ts can be thirty days: bounding retention by exp alone would let a sender choose how long every receiver must remember it. Envelope msg_ids and the inner call's msg_ids are separate namespaces: a replayed envelope and a re-sent inner msg_id are recognised independently. One store can hold both by prefixing envelope keys, for example with env:.
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
X-HDTP-SEAL on the card (§3): 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. A host MAY additionally require transport client certificates (a client_cert posture knob) and refuse a certificate-less sealed_call with identity_required. That is an owner's hardening choice about their own front door, not a protocol contradiction: the envelope still proves who is calling; the certificate requirement decides who may knock at all. Such a host cannot be reached through a terminating edge.
13.5 Stated trade-offs
No forward secrecy — HPKE Base mode to a long-lived key means a later compromise of a leaf key decrypts ciphertext recorded while it was current; mitigations are the 300-second window and the leaf's lifetime — a leaf key lives at most 398 days, and a renewal with a fresh key retires it — which bound the exposure, not fix it. Post-quantum: not provided. The path is set so that the change is small. Sealing goes first, since recorded ciphertext is exposed today while a forger would need the computer today: a hybrid KEM key (X25519 with ML-KEM-768, the X-Wing combiner) carried in the leaf as an extension, and one suite in place of these two. Signatures go later, FN-DSA-512 preferred once standardised. Two structural changes keep both off the card and the wire: the card as a pointer — root fingerprint and endpoint, the chain fetched from the endpoint — and the chain sent once, a leaf fingerprint inside the ciphertext thereafter and chain_required when a receiver lacks it. Metadata is not nothing: a carrier sees kid, timing and sizes, and can tie every message to one recipient key for that leaf's life; it does not see who sent it. One leaf key does TLS, signatures and sealing; kid names it. 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. The v: 1 and certificate vectors live in Appendix B; an implementation that opens and verifies all of them is envelope-interoperable.
14 Certificates
Two X.509 certificates, one rule about which leaf is newest, and one answer for a caller holding an old key. Everything a verifier needs is in the chain it is handed; nothing is fetched, and there is no directory.
14.1 Profile
Both certificates are X.509 v3 (RFC 5280). Keys are Ed25519 (RFC 8410) or ECDSA P-256, and a P-256 key is written as its uncompressed point (RFC 5480, Section 2.2, also allows the compressed one, which is not an HDTP key), so one key has one SubjectPublicKeyInfo and one fingerprint (§2); signatures are Ed25519 or ECDSA with SHA-256, in the encodings §13.1 pins.
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. 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.
An ECDSA signature (r, s) has a twin, (r, n − s), that verifies under the same key over the same bytes and that anybody can compute with no key at all; on a certificate it is a second byte string for one leaf — same key, same fingerprint, same endpoint, same notBefore — which §14.3 reads as a conflict. 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. The rule is the certificate's alone: an envelope's, a request's or a card's signature is verified and never pinned or compared as bytes, so its twin harms nobody.
A key identifier is the 32-byte SHA-256 of the SubjectPublicKeyInfo — the bytes a fingerprint (§2) encodes — used for subjectKeyIdentifier and authorityKeyIdentifier alike, so the leaf's issuer key identifier is the root's fingerprint.
| Root | Leaf | |
|---|---|---|
| issued by | itself | the root |
subject |
one commonName, untrusted (§3) — the wallet fills it with the person's chosen name |
one commonName, untrusted |
serialNumber |
random, at least 64 bits | random, at least 64 bits |
| validity | notBefore at creation; notAfter 99991231235959Z, RFC 5280's "no well-defined expiration", unless the person sets an end date for the identity, which may be any instant not before notBefore and which a wallet refuses when it has already passed — a root is never rotated, with an end date or without one |
notBefore the later of one hour before issuance and one second after the previous leaf's notBefore — the wallet knows every leaf it issued, so a verifier whose clock runs a little behind still accepts, and no leaf is superseded by its own predecessor; notAfter at most 398 days after notBefore, RECOMMENDED one year, and no later than the root's notAfter |
basicConstraints |
critical; cA true; pathLenConstraint 0 |
critical; cA false |
keyUsage |
critical; keyCertSign only |
critical; digitalSignature, plus keyAgreement for a P-256 key |
extendedKeyUsage |
— | serverAuth, clientAuth |
subjectAltName |
— | exactly one uniformResourceIdentifier: the endpoint, an https URL in RFC 3986 normal form — lowercase scheme and host, no default port, dot segments removed, percent-encoding uppercase and minimal, a non-empty path, no userinfo, query, fragment or trailing slash — the one string the host advertises and callers dial. MAY add the dNSName of that URL's host, for TLS stacks that match names; a dNSName that differs from the URI's host is a refusal |
subjectKeyIdentifier |
its key identifier | its key identifier |
authorityKeyIdentifier |
— | the root's key identifier, and nothing else |
A chain is the leaf followed by the root and nothing else; a verifier MUST refuse any other length.
The profile is exact. A certificate is not an HDTP certificate if it carries any of these:
- an extension not listed here, critical or not, or a duplicated extension;
- a name of another shape;
- a signature algorithm other than its issuer key's own, or an ECDSA signature in the high-S form;
- a validity field that is not a date that exists (
260230120000Zis refused, not read as 2 March); - an extension whose value is not the type RFC 5280 gives it (a
keyUsagethat is not a BIT STRING, asubjectAltNamethat is not a SEQUENCE), or does not fill its OCTET STRING; - a
basicConstraintsthat is anything but DER's own three spellings of it: empty,TRUE, orTRUEand a path length read in full; - a non-minimal DER length, or a byte after its end.
An exact profile closes the whole class of things one parser sees and another does not, rather than one instance at a time. There is no CRL, no OCSP and no policy: revocation is the next leaf (§14.3), and expiry is expiry.
14.2 Chain validation
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:
- The chain has exactly two certificates, and each matches §14.1 exactly — the first as a leaf, the second as a root: the fields, the algorithms, the extensions and their criticality, nothing more, in strict DER with nothing after the end. A single self-signed certificate is not a chain, and is refused: there is no root above it to pin.
- The second is a root: self-signed, its signature verifying under its own key,
cAtrue,keyCertSignset. Its key identifier is computed from its key as §14.1 defines, never read from the certificate, and its fingerprint is the identity. 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. - The first is a leaf: its signature verifies under the root's key, its
authorityKeyIdentifierequals the root's computed key identifier,cAfalse,digitalSignatureset. - The verifier's clock is not past the root's
notAfter, and is within the leaf'snotBeforeandnotAfter; the leaf'snotAfter − notBeforeis at most 398 days; and the leaf'snotAfterMUST NOT be after the root's. - The leaf's
subjectAltNameholds exactly one URI, anhttpsURL: the endpoint. 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. A mismatch is a refusal, never a warning. AdNSNamebeside 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. - The leaf's key is then the proven key: the key
sigis verified under (§13), the key a client certificate presents, and the key to seal to.
A verifier never trusts a host, a card, or a provider. It trusts the fingerprint it pinned and the rules above. The rules govern a chain the verifier validates; the key a sender seals its first call to is read from a card, needs no validation, and may belong to an expired leaf (§3).
Rule 4 reads the root's notAfter and never its notBefore. The notAfter is the identity's end: a root without an end date carries 99991231235959Z and never reaches it, and past an end date its person set, every chain under the root is refused. A leaf cannot outlive its root, and that bound is what ends a pinned leaf with its root in the small form of §13.2, which is judged against the pinned leaf alone; a pending contact's leaf taken from a card was never validated against a root (§3: a card carries none), so there the bound rests on the wallet that issued the leaf. An end date is the person's own sunset for the identity, not a defence: whoever holds the root key can sign another root certificate for the same key with a later notAfter, and it carries the same fingerprint. The root's notBefore carries no trust, because what identifies the person is the fingerprint of the key and not a date the same key wrote. 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. This is a deliberate departure from RFC 5280 path validation, which requires every certificate in a path to be valid at the time of use; it is written down because an implementation that reaches for a general X.509 path validator would refuse chains a conforming HDTP implementation accepts, and a parser differential is a parser differential whichever direction it runs in (§14.1).
The 398-day ceiling is the verifier's rule, not the issuer's setting. How long a leaf lasts, beneath that ceiling, is the person's own decision and a wallet asks them for it (§9.1); one year is a default, not the answer. The ceiling is different in kind, and an implementation that offered it as a preference would be offering nothing: rule 4 is applied by the receiver, so a longer certificate is not a longer-lived identity but one every conforming contact refuses, and the person would learn that from their contacts rather than from their wallet. It is also the one thing in this protocol that withdraws a host's authority without anybody's cooperation — there is no revocation list and no responder to ask (§11), so §9's backstop against a host that will not destroy a key it was asked to destroy is that key's certificate running out, and a newer leaf only outranks the old one with contacts it actually reaches (§14.3), never shortening the certificate's own life. A ceiling a host's own tenant could raise would remove the guarantee from the person it protects.
14.3 The newest leaf wins
For each pinned identity a verifier keeps the endpoint and the latest leaf it accepted. A leaf whose notBefore is earlier than the pinned leaf's is superseded: a caller presenting it resolves to no pin and gets the guest tier (§6.1), a result carrying it is envelope_invalid, and nothing is sealed to it. A leaf with a later notBefore at the pinned endpoint replaces the pinned one as it passes — a renewal; at another endpoint it is a new address (§5.3). Equal notBefore and equal bytes is the pinned leaf; a verifier MUST refuse a leaf with an equal notBefore and different bytes.
The rule is absolute. A newer leaf from the root takes priority the instant it is seen, whatever the validity of the older one: at that contact the older leaf is finished, however it is used afterwards, and nothing it does is the identity's. There is no grace period, because any delay would be time a compromised leaf keeps speaking. Only the root can produce a leaf with a later notBefore, so no host can outrank the person, and a host that has been replaced can outrank nobody who has seen its replacement. What the rule cannot do is reach a contact that has seen nothing new: that contact goes on accepting the old leaf until its notAfter — the reason a leaf's validity is short, and the reason §9 requires a host that has been left to delete the key.
A newer leaf arrives on use, and needs no poll. The residual above is bounded by the leaf's own life rather than by a freshness sweep, and deliberately so: nothing here asks a verifier to go looking. The protocol already delivers a renewal at the moment it matters, which is when two parties actually exchange — a host carries its chain in its first envelope to each contact after a renewal (§13.2), an envelope sealed to a key the host no longer holds is answered certificate_renewed with the current chain (§14.4), and get_card carries the chain at the contact tier (§6.2). A contact that talks to an identity learns its current leaf by talking to it. A contact that never talks to it has nothing to learn, because it is not calling anyone.
What remains: an attacker holding a leaf key stolen from a host can call a contact that has not heard of the renewal, and that contact's pinned leaf is the stolen one, so it is accepted — until that leaf's notAfter. This is §14.5's second row and it is not new. What bounds it is the leaf's lifetime, which is the person's own choice (§2, §14.1): someone who wants a tighter window over a host they trust less signs a shorter leaf, and the ceiling of §14.2 is the longest that window can ever be. A revocation list would close the case at the cost this protocol declines to pay — a third party that learns who talks to whom, and a responder every conversation depends on reaching.
Refresh. An owner can ask their host to refresh one contact. The host calls that contact's get_card (§6.2), validates the chain it answers to the pinned root at the pinned endpoint (§14.2), checks that the card's certificate is the chain's leaf, verifies card_sig (§3), and then takes the leaf by the rule above. A host refreshes only the contact its owner names, when the owner asks. 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. The pin stands, and the leaf's notAfter remains the only deadline that refuses on its own. notBefore orders the leaves: the root signs it, and the wallet keeps it increasing across the leaves it issues (§14.1).
14.4 certificate_renewed
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. An envelope whose kid names one of them and no key it still holds is answered, in plaintext, with certificate_renewed and data {"chain": [leaf, root]}: the identity's current chain at this endpoint. The caller validates it (§14.2) against the root it holds and the address it dialed, updates its pin, and re-seals. A kid the endpoint never held is envelope_invalid with nothing attached — which is also what an identity that has left gets at its old address, because a host that has been left keeps nothing (§9). The answer proves nothing by itself; only the chain's validation does, and a forged one fails rule 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. A kid this endpoint holds for a different identity is envelope_invalid, never opened and never answered with a chain, so that on a provider serving many identities from one origin no identity's key answers at another's path.
14.5 Compromise cases
What an attacker can hold, what stops each, and what each still costs. Every row names rules that live elsewhere in this document; the table is the checklist, not a new mechanism.
| The attacker holds | What stops it | What remains |
|---|---|---|
| The root, from a stolen and opened wallet record or backup file | Nothing cryptographic, by decision: whoever holds the root is the person. What makes the theft hard is the passphrase and memory-hard derivation, the hardware wrap, the wallet's own window, and the deliberate act asked again for a new endpoint (§9). What lets contacts notice a move the person never made is ask and the event shown under auto (§5.3). When two parties hold the root, no rule can settle it — the newest leaf wins for whoever issued last (§14.3), so a root held by two parties is abandoned — and what prevents it is a root no stored copy holds: one generated in a hardware key, which has no copy, or one derived from a passkey (§2.1), whose only stored copy is the backup file the person keeps (§9) |
a new identity, re-shared over human channels; the wallet's contact book is the list to call |
| A leaf key, from a host | The key speaks only from its one address and can neither issue nor move; a renewal with a fresh key outranks it with every contact it reaches (§14.3); a leaf expires within the span the person chose, at most 398 days, which is the bound on the whole case and the reason to choose it deliberately (§2) | contacts that exchange nothing until then; ciphertext recorded to that key (§13.5) |
| The current host itself, rogue | As every hosted service: bounded to one address and one date by the leaf, unable to change either, and answerable to the wallet's contact book and the export the person can take anywhere | what it does while it serves — reads, sends, refuses to renew — shows only in its audit log and in silence |
| A former host's leaf, still valid after a move | Newest leaf wins with every contact reached; the new host calls update_contact at every contact before the old host is told; the 30 days a receiver keeps a removed root defeat a host that calls remove_contact at every contact as it leaves (§5.3); the address is not reassigned until the leaf expires; the duty to delete, audited (§9) |
contacts the new host never reached, until the leaf expires |
| The sign-up page, keeping the root it made | The ceremony runs where the page cannot read, open-source and integrity-pinned (§9); a page that derives its root (§2.1) never has one to keep and holds the key only in a handle it cannot read back, for one signing; a person who will not trust it brings a wallet | the same trust as any web wallet, for the seconds it signs |
| A CSR for an address of the attacker's choosing | The wallet shows the endpoint, the asking origin, and whether the host is new; verifies proof of possession; demands the passphrase for a new endpoint (§9) | a person who signs what they did not read |
| The wire, as a carrier or edge | Sealing; the sender inside the ciphertext (§13.1) | messages tied to one recipient leaf for its life; no forward secrecy; nothing post-quantum yet (§13.5) |
| A certificate that reads one way to one parser and another to the next | The exact profile and strict DER of §14.1: nothing unlisted, nothing duplicated, nothing trailing, the declared algorithm the issuer key's own | nothing |
| A card altered in transit so that its leaf is the same certificate in other bytes — an ECDSA signature swapped for its twin — pinning a leaf the real host can never match, where reading the fingerprint aloud (§3) finds nothing wrong | §14.1 admits one twin only, the low-S one, and refuses the other at card intake; Ed25519 signatures have no twin under strict verification | nothing: the altered card is refused, which its sender notices |
A forged or replayed certificate_renewed |
Validation to the caller's own pin, the newest-leaf rule, the dialed address, one follow per call (§14.4) | nothing |
| A card naming a hostile, internal, or borrowed address | The address guard at intake and before every dial (§3, §14.2); a guest's endpoint never equals the receiver's own; a guest at an address that belongs or lately belonged to a pinned contact is never auto-accepted and is shown beside that contact's name (§5) | a person who approves a stranger whose card wears a friend's name at a fresh address |
Free roots, flooding request_contact, or guessed fingerprints in the small form |
Two guest tools, rate-limited by address and root (§12); human approval; invites with expiry and uses. A guessed fingerprint still needs that leaf's key to sign, gets chain_required whatever it guessed, moves no state, and spends the source's guest budget |
a flood still costs the receiver the work of refusing it |
| A planted row, in an export from a former host | No key enters, and a file carrying anything unexpected is refused whole (§9.2); the person sees every contact before one is written; an imported leaf never replaces a pin the host validated itself; the import ends with a new leaf from the person's wallet (§9.2) | a row the person did not notice in the review, recognised as a contact until they remove it |
| An unlocked wallet, driven by a page | Signing only from the wallet's own window after a click; a deliberate act again for a new endpoint (§9); the leaf key signs nothing but its four structures (§13.5), and a root proves possession over bytes that can never be a certificate (§2.2) | a renewal for the same endpoint, which changes nothing a contact sees |
Appendix A worked examples
The examples are JSON-RPC messages as they cross the wire, each sent over HTTPS with the caller's chain as its TLS client certificate (§2), except the sealed call at the end. Certificates, signatures and envelope members are shortened with …; Appendix B holds them whole, and Alina's card is its signed_card. Where the MCP revision in use requires them, a result also carries resultType and _meta; they are left out here.
Listing tools, as a guest. The caller's root is pinned nowhere, so the guest tier answers (§6.1). The card of this host asks for sealing, so sealed_call is listed.
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list"
}
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"tools": [
{
"name": "redeem_invite",
"inputSchema": {
"type": "object"
}
},
{
"name": "request_contact",
"inputSchema": {
"type": "object"
}
},
{
"name": "sealed_call",
"inputSchema": {
"type": "object",
"required": [
"protected",
"enc",
"ct",
"sig"
],
"properties": {
"protected": {
"type": "string"
},
"enc": {
"type": "string"
},
"ct": {
"type": "string"
},
"sig": {
"type": "string"
}
},
"additionalProperties": false
}
}
]
}
}
Redeeming an invite. Bharat's agent redeems Alina's invite (§4, §5.1). The invite does not auto-accept, so the answer is pending, with Alina's signed card and chain.
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "redeem_invite",
"arguments": {
"token": "3f9c2a7b51e04d8c9a6f0b2e7d1c4a85",
"card": "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Bharat Mehta\r\nX-HDTP-VERSION:1\r\nX-HDTP-CERT:MIIB…\r\nX-HDTP-SEAL:required\r\nEND:VCARD\r\n"
}
}
}
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"content": [
{
"type": "text",
"text": "{\"status\":\"pending\",\"card\":\"BEGIN:VCARD\\r\\nVERSION:4.0\\r\\nFN:Alina Rao\\r\\nX-HDTP-VERSION:1\\r\\nX-HDTP-CERT:MIIBuTCCAW…\\r\\nX-HDTP-SEAL:required\\r\\nEND:VCARD\\r\\n\",\"card_sig\":\"hoqEtTzpoQOTFZ6x…\",\"chain\":[\"MIIBuTCCAW…\",\"MIIBMTCB5K…\"]}"
}
]
}
}
Before it pins anything, Bharat's agent validates the chain (§14.2) — the root's fingerprint equals the issuer key identifier of the card's certificate, the leaf is the card's X-HDTP-CERT, and the leaf names the endpoint it will call from now on — and verifies card_sig (§3).
A message. Alina's agent writes to Bharat's server; Bharat has granted message.text (§8).
{
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": {
"name": "send_message",
"arguments": {
"msg_id": "b2f6b7f0-3f0a-4d55-9f6e-2a1c9d4e8a11",
"thread_id": "0f6e2c1a-7d4b-4f0e-9a51-3c2b8d9e4f10",
"topic": "Coffee catch-up",
"text": "Alina's assistant here. Alina would like 45 minutes with Bharat next week, mornings, Koregaon Park. What works?",
"sender": "agent"
}
}
}
{
"jsonrpc": "2.0",
"id": 7,
"result": {
"content": [
{
"type": "text",
"text": "{\"thread_id\":\"0f6e2c1a-7d4b-4f0e-9a51-3c2b8d9e4f10\",\"status\":\"delivered\"}"
}
]
}
}
A refusal. Bharat has not granted message.media, so send_media is not in Alina's tools/list, and a call to it is refused (§6.2).
{
"jsonrpc": "2.0",
"id": 8,
"result": {
"isError": true,
"content": [
{
"type": "text",
"text": "{\"code\":\"permission_denied\"}"
}
]
}
}
A spent budget. The per-contact budget holds no call; one refills in 3 seconds (§12).
{
"jsonrpc": "2.0",
"id": 9,
"result": {
"isError": true,
"content": [
{
"type": "text",
"text": "{\"code\":\"rate_limited\",\"retry_after\":3}"
}
]
}
}
A sealed call. Appendix B's alina-to-bharat: the same kind of send_message, sealed to Bharat's leaf key because a terminating edge sits in front of his server (§13). The arguments of sealed_call are the envelope's four members:
{
"jsonrpc": "2.0",
"id": 10,
"method": "tools/call",
"params": {
"name": "sealed_call",
"arguments": {
"protected": "eyJjdHkiOiJhcHBsaWNhdGlv…",
"enc": "BDtCX1LmUSqSnb_0…",
"ct": "kCRX1NHxkygGve7H…",
"sig": "FALrKT-0zNjr4vwA…"
}
}
}
protected decodes to the header, and ct opens to the plaintext call, which carries Alina's chain because Bharat has not seen her leaf (§13.2):
{
"cty": "application/hdtp-call+json",
"exp": 1789301400,
"kid": "sha256:zCJ2MAykJsOSW0BAAOFRDSisojCaqSSnI_A7-XQtOXE",
"msg_id": "vec-v1-alina-to-bharat",
"suite": "HDTP-SEAL-P256",
"ts": 1789300800,
"v": 1
}
{
"method": "tools/call",
"params": {
"name": "send_message",
"arguments": {
"msg_id": "vec-1",
"text": "hello from the HDTP test vectors"
}
},
"chain": [
"MIIBuTCCAW…",
"MIIBMTCB5K…"
]
}
The answer is a tool result whose text is an envelope of the same four members, sealed back to Alina's leaf key; it opens to {"result": …, "leaf": "sha256:…"}, the inner tool result beside the fingerprint of Bharat's leaf, which Alina already holds.
A renewed key. An envelope sealed to a leaf key the host has since replaced is answered in plaintext with the current chain (§14.4); the caller validates it against its pin and seals again.
{
"jsonrpc": "2.0",
"id": 11,
"result": {
"isError": true,
"content": [
{
"type": "text",
"text": "{\"code\":\"certificate_renewed\",\"data\":{\"chain\":[\"MIIBuTCCAW…\",\"MIIBMTCB5K…\"]}}"
}
]
}
}
The calendar example. With calendar.availability and calendar.book granted (§6.2, §8), availability and a booking — the arguments, and then the text of each result:
{
"name": "check_availability",
"arguments": {
"window": {
"from": "2026-08-24T00:00:00+05:30",
"to": "2026-08-29T23:59:59+05:30",
"tz": "Asia/Kolkata"
},
"duration_min": 45
}
}
{
"slots": [
{
"start": "2026-08-25T10:00:00+05:30",
"end": "2026-08-25T10:45:00+05:30",
"tz": "Asia/Kolkata"
}
]
}
{
"name": "book_slot",
"arguments": {
"msg_id": "5d0c7e21-9b4a-4f3e-8c1d-2a6b7e9f0c13",
"slot": {
"start": "2026-08-25T10:00:00+05:30",
"end": "2026-08-25T10:45:00+05:30",
"tz": "Asia/Kolkata"
},
"subject": "Coffee catch-up",
"thread_id": "0f6e2c1a-7d4b-4f0e-9a51-3c2b8d9e4f10"
}
}
{
"booking_id": "bk_91h2",
"ics": "BEGIN:VCALENDAR\r\n…\r\nEND:VCALENDAR\r\n"
}
Appendix B test vectors
The vectors. Generated by vectors/gen.mjs in this repository — every key derives from a label, so the certificates and the Ed25519 signatures reproduce byte for byte, and ECDSA signatures are one valid signature — and proven against this document by vectors/check.mjs. Eleven certificates to the §14.1 profile (three roots, one of them with an end date its person chose; two valid leaves, an expired leaf, a 404-day leaf and a successor leaf under a fresh key; and three leaves under the root with an end date, one ending before it, one ending the second it does and one outliving it) and four that exist to be refused, each marked refused (the twin of an ECDSA signature, a validity date that is not a date, an issuer key identifier that is not 32 bytes, a root whose end date is before its start); twenty chain cases, each refusal naming the §14.2 rule it fails, and the ones about a root's end date naming the reason too; four §14.3 comparisons; three certificate_renewed answers, two of them discarded; and three v: 1 envelopes with a header of v, suite, kid, msg_id, ts, exp and cty: two, one each way, in the full form with the sender's chain inside the plaintext, and one in the small form of §13.2 that names the sender's leaf by fingerprint. To pass the envelopes: open ct with the recipient leaf key (leaf_keys_pkcs8_hex) and the §13 parameters (AAD = decoded protected, info = HDTP-SEAL-v1), compare against plaintext_hex, then either validate the chain in the plaintext and verify sig under its leaf key, or, for the small form, verify sig under the key of the leaf the fingerprint names — leaf_a, which a receiver holding Alina as a contact already has — over the decoded protected‖enc‖ct. signed_card is the card leaf_a's host serves and its card_sig (§3): verify the signature under leaf_a's key over the card text's UTF-8 bytes. The root private keys are not in the vectors, because a verifier never needs one; the leaf keys are, so the envelopes open. The derivation block (§2.1) is the one place a root's seed appears, and it belongs to a throwaway identity that exists in no certificate here: a derivation vector without its seed could only say wrong, never which of the two steps was wrong, and the two steps are exactly what an implementation gets wrong. It gives, for one PRF output, the seed each info string produces and — for hdtp/root/1 — the Ed25519 key and the identity that seed is. The three seeds must come out unrelated; a port that dropped info from the expand step would reproduce nothing else in this block.
{
"generated_by": "vectors/gen.mjs (every key derives from a label; what Ed25519 signs reproduces byte for byte; an ECDSA signature is one valid signature and is NEW EACH RUN, so root_b, leaf_b, leaf_b_twin and the P-256 envelope differ in their signature bytes from one generation to the next, and are to be verified, never compared)",
"now": "2026-09-13T12:00:00Z",
"certificates": {
"root_a": {
"der_hex": "308201313081e4a0030201020209009902956916dc1741300506032b657030143112301006035504030c09416c696e612052616f3020170d3236303930313030303030305a180f39393939313233313233353935395a30143112301006035504030c09416c696e612052616f302a300506032b657003210072797f971e6d7db62a222d6fc5dc4ecf0a37da238c3dba4edc378a1c43cacfb0a351304f30120603551d130101ff040830060101ff020100300e0603551d0f0101ff04040302020430290603551d0e0422042091fb3d906eb14ac59a36dbd666c5670169c68ad860fc780a37875b7d63860b50300506032b65700341007cf5cf2ee42ee1f9b9352037b4481e031273d3395cd67949cd5e6905aed782f3571f4cf04690de150d64bd02407da3c86681ad2a8a4f8041db977a28dabdb708",
"note": "Ed25519 root, self-signed, CN \"Alina Rao\", notAfter 9999-12-31"
},
"root_b": {
"der_hex": "308201783082011ea003020102020900e51123c1ee85d6e0300a06082a8648ce3d04030230173115301306035504030c0c426861726174204d656874613020170d3236303930313030303030305a180f39393939313233313233353935395a30173115301306035504030c0c426861726174204d656874613059301306072a8648ce3d020106082a8648ce3d030107034200042182cffdf3abefadb51050e603f6da251cb03575cd9b2f7baafae23519a92fa6efd75d4108e0295da93862096a0cc55709e8ca1e00fb5a398fe7f2f41f369699a351304f30120603551d130101ff040830060101ff020100300e0603551d0f0101ff04040302020430290603551d0e04220420c4d1dc13f1531d0508e128392934cc7e2f574827c9d960f1f5e5d441d2e5d0f3300a06082a8648ce3d0403020348003045022100ef6f40755526e552094f3b3f78541c533ecbae37929eb19deb925b9d976caf3b02204bf48e6b14112e1cd35a2f5f863431b722a0c7ae703970e1a3c4198a86b26adb",
"note": "P-256 root, self-signed, CN \"Bharat Mehta\""
},
"root_c": {
"der_hex": "308201353081e8a003020102020900abe1f0c28d4f0d8f300506032b657030173115301306035504030c0c4368616e6472612049796572301e170d3236303930313030303030305a170d3238303130313030303030305a30173115301306035504030c0c4368616e6472612049796572302a300506032b657003210093fa835c0d98975e4dfd1253682bfa8fcfa5c88b9c27622981b7715d01912075a351304f30120603551d130101ff040830060101ff020100300e0603551d0f0101ff04040302020430290603551d0e0422042010c7462ef86a91d6525d7e7c0df46a299f5b0b9df8b73f517ad2133f06a295c9300506032b6570034100dcaa7dadb3d4ff3e160482527d65a0a6653b6a527a3b697f2421ec862628d4c10de127e794c0966771bd5b885c839959e36bbeca6c54ddc1951fdc7e64c33a0d",
"note": "Ed25519 root, self-signed, CN \"Chandra Iyer\", with the end date its person chose: notAfter 2028-01-01"
},
"leaf_a": {
"der_hex": "308201b93082016ba003020102020900ea540e3161472cf5300506032b657030143112301006035504030c09416c696e612052616f301e170d3236303930313030303030305a170d3237303930313030303030305a30143112301006035504030c09416c696e612052616f302a300506032b65700321001ce0f36e1d5893400ff6e56a8cd265e3794642e86639e4ff88e73b0c56164454a381d93081d6300c0603551d130101ff04023000300e0603551d0f0101ff040403020780301d0603551d250416301406082b0601050507030106082b06010505070302303f0603551d1104383036861f68747470733a2f2f6167656e742e616c696e612e6578616d706c652f6d637082136167656e742e616c696e612e6578616d706c6530290603551d0e042204200975ead0c601974554d791f5af4c5e8075e2270da564996c609168cadd201663302b0603551d2304243022802091fb3d906eb14ac59a36dbd666c5670169c68ad860fc780a37875b7d63860b50300506032b65700341001ee7f12004e2eec7364cfb46c500d606f800c7890cbe1923c741e7218bea12327cd901a8cee470be46fb681802ddbc2ab7a973bbd25aee6a2d02e726e28d800e",
"note": "Ed25519 leaf under root_a for https://agent.alina.example/mcp, 2026-09-01 to 2027-09-01, with a dNSName beside the URI"
},
"leaf_b": {
"der_hex": "308201e830820190a003020102020862cf0a32fd882130300a06082a8648ce3d04030230173115301306035504030c0c426861726174204d65687461301e170d3236303930313030303030305a170d3237303930313030303030305a30173115301306035504030c0c426861726174204d656874613059301306072a8648ce3d020106082a8648ce3d0301070342000425ae89c5b1dd827dbf4898149b8aa8e757abf2dc340aadea8dd8962f23c4c9b03533b78eb5547b8b8d023e00a768bcc6243985160b980f6d7ad036e4e437b539a381c53081c2300c0603551d130101ff04023000300e0603551d0f0101ff040403020388301d0603551d250416301406082b0601050507030106082b06010505070302302b0603551d1104243022862068747470733a2f2f6167656e742e6268617261742e6578616d706c652f6d637030290603551d0e04220420cc2276300ca426c3925b404000e1510d28aca2309aa924a723f03bf9742d3971302b0603551d23042430228020c4d1dc13f1531d0508e128392934cc7e2f574827c9d960f1f5e5d441d2e5d0f3300a06082a8648ce3d0403020346003043021f471852b24c95bdfa9b038a85c89c74c04d3e5f74f7ebab2adeeae4f7330cae0220346bb62de6aeb188a5cb7e653b637e4638115cedc1b80f26825c7298d8ddb229",
"note": "P-256 leaf under root_b for https://agent.bharat.example/mcp, 2026-09-01 to 2027-09-01, keyUsage digitalSignature+keyAgreement"
},
"leaf_a_expired": {
"der_hex": "308201a330820155a00302010202080c2ba902f1b494b2300506032b657030143112301006035504030c09416c696e612052616f301e170d3235303630313030303030305a170d3236303630313030303030305a30143112301006035504030c09416c696e612052616f302a300506032b65700321001ce0f36e1d5893400ff6e56a8cd265e3794642e86639e4ff88e73b0c56164454a381c43081c1300c0603551d130101ff04023000300e0603551d0f0101ff040403020780301d0603551d250416301406082b0601050507030106082b06010505070302302a0603551d1104233021861f68747470733a2f2f6167656e742e616c696e612e6578616d706c652f6d637030290603551d0e042204200975ead0c601974554d791f5af4c5e8075e2270da564996c609168cadd201663302b0603551d2304243022802091fb3d906eb14ac59a36dbd666c5670169c68ad860fc780a37875b7d63860b50300506032b6570034100bd3e08c9907a2eaff999ae36ba73832e1f8203d12d2b6bffaf4673badc6c8f7c0ca40ea2103c1c18ea1d3a9ccda10078cba409ecd9f66b9240473945427f690a",
"note": "leaf_a's key and endpoint, 2025-06-01 to 2026-06-01: expired at NOW"
},
"leaf_a_long": {
"der_hex": "308201a430820156a003020102020900bca80aa75de7cf1e300506032b657030143112301006035504030c09416c696e612052616f301e170d3236303930313030303030305a170d3237313031303030303030305a30143112301006035504030c09416c696e612052616f302a300506032b65700321001ce0f36e1d5893400ff6e56a8cd265e3794642e86639e4ff88e73b0c56164454a381c43081c1300c0603551d130101ff04023000300e0603551d0f0101ff040403020780301d0603551d250416301406082b0601050507030106082b06010505070302302a0603551d1104233021861f68747470733a2f2f6167656e742e616c696e612e6578616d706c652f6d637030290603551d0e042204200975ead0c601974554d791f5af4c5e8075e2270da564996c609168cadd201663302b0603551d2304243022802091fb3d906eb14ac59a36dbd666c5670169c68ad860fc780a37875b7d63860b50300506032b6570034100b917a5bd634c0d8043cc0db0f7f4e0af19864b61f16c1a9cc4b3631b3231ae5ffa9d6d65c932db48758c7f8205991309e59f7328febcdb887b0dd4b7fa66460a",
"note": "leaf_a's key and endpoint, 2026-09-01 to 2027-10-10: 404 days"
},
"leaf_a_next": {
"der_hex": "308201a330820155a0030201020208184ebda4c22a39ae300506032b657030143112301006035504030c09416c696e612052616f301e170d3237303830323030303030305a170d3238303830313030303030305a30143112301006035504030c09416c696e612052616f302a300506032b6570032100dfa2709de5df9d8ba0777dca0c057929965e2e4c1dad64b2b5575da5317e6841a381c43081c1300c0603551d130101ff04023000300e0603551d0f0101ff040403020780301d0603551d250416301406082b0601050507030106082b06010505070302302a0603551d1104233021861f68747470733a2f2f6167656e742e616c696e612e6578616d706c652f6d637030290603551d0e0422042065408c3946e4d3f67b94d604ce6db33905fb8c1a338f19c07fd5291617e33afd302b0603551d2304243022802091fb3d906eb14ac59a36dbd666c5670169c68ad860fc780a37875b7d63860b50300506032b6570034100ae9628d44a62bc0206cbba247107af806cfab8a8dd8a30123cc4e607cc3185960e0debda938790397288b5e7c8301e318774b43910caa94f9f57ec35316dbf04",
"note": "a fresh key for the same endpoint, 2027-08-02 to 2028-08-01: the renewal that supersedes leaf_a"
},
"leaf_c": {
"der_hex": "308201ac3082015ea0030201020209009ea0bddafb957295300506032b657030173115301306035504030c0c4368616e6472612049796572301e170d3236303930313030303030305a170d3237303930313030303030305a30173115301306035504030c0c4368616e6472612049796572302a300506032b6570032100e78afc044e110567e3d9e1c165ef300cf4300e5c1a19ce72b15b9b7b8f5e3323a381c63081c3300c0603551d130101ff04023000300e0603551d0f0101ff040403020780301d0603551d250416301406082b0601050507030106082b06010505070302302c0603551d1104253023862168747470733a2f2f6167656e742e6368616e6472612e6578616d706c652f6d637030290603551d0e04220420caee43ea8b13027c58cb63d328fc5e209e5921f347ca7508c682cb01e4fffc89302b0603551d2304243022802010c7462ef86a91d6525d7e7c0df46a299f5b0b9df8b73f517ad2133f06a295c9300506032b65700341004a28ede30cc8d530f64a2482436438cc89b75c3a04f32aed906e7b6d65bb7072fe390126d83b86ed42228b1f27b6c55dbe40f7c8a596609b7cd38bd1ff7f5000",
"note": "Ed25519 leaf under root_c for https://agent.chandra.example/mcp, 2026-09-01 to 2027-09-01, ending before its root"
},
"leaf_c_last": {
"der_hex": "308201ab3082015da003020102020871c183e395a439d6300506032b657030173115301306035504030c0c4368616e6472612049796572301e170d3237303130313030303030305a170d3238303130313030303030305a30173115301306035504030c0c4368616e6472612049796572302a300506032b6570032100e78afc044e110567e3d9e1c165ef300cf4300e5c1a19ce72b15b9b7b8f5e3323a381c63081c3300c0603551d130101ff04023000300e0603551d0f0101ff040403020780301d0603551d250416301406082b0601050507030106082b06010505070302302c0603551d1104253023862168747470733a2f2f6167656e742e6368616e6472612e6578616d706c652f6d637030290603551d0e04220420caee43ea8b13027c58cb63d328fc5e209e5921f347ca7508c682cb01e4fffc89302b0603551d2304243022802010c7462ef86a91d6525d7e7c0df46a299f5b0b9df8b73f517ad2133f06a295c9300506032b6570034100e1cad48a457f1b9d3c9039bea01074392c0e0919f7dc62199f90dc3ffc5d9b27e467add011e9d09398d0c1a8ac23cbef4a58dd2ed1be41cb8f638a3d9664c902",
"note": "leaf_c's key and endpoint, 2027-01-01 to 2028-01-01: ends the second its root does"
},
"leaf_c_outlives": {
"der_hex": "308201ab3082015da003020102020876d225b87262cb2f300506032b657030173115301306035504030c0c4368616e6472612049796572301e170d3237303630313030303030305a170d3238303630313030303030305a30173115301306035504030c0c4368616e6472612049796572302a300506032b6570032100e78afc044e110567e3d9e1c165ef300cf4300e5c1a19ce72b15b9b7b8f5e3323a381c63081c3300c0603551d130101ff04023000300e0603551d0f0101ff040403020780301d0603551d250416301406082b0601050507030106082b06010505070302302c0603551d1104253023862168747470733a2f2f6167656e742e6368616e6472612e6578616d706c652f6d637030290603551d0e04220420caee43ea8b13027c58cb63d328fc5e209e5921f347ca7508c682cb01e4fffc89302b0603551d2304243022802010c7462ef86a91d6525d7e7c0df46a299f5b0b9df8b73f517ad2133f06a295c9300506032b65700341005ddb39a45715511670e4877518be660615f0f18aefc26d42383b67a6b97c5d6d53ada91f662e00654e2fc447f3a1e1f7390845cf719037e76c7f66886aaf8d0f",
"note": "leaf_c's key and endpoint, 2027-06-01 to 2028-06-01: ends after its root, which rule 4 refuses"
},
"leaf_b_twin": {
"der_hex": "308201ea30820190a003020102020862cf0a32fd882130300a06082a8648ce3d04030230173115301306035504030c0c426861726174204d65687461301e170d3236303930313030303030305a170d3237303930313030303030305a30173115301306035504030c0c426861726174204d656874613059301306072a8648ce3d020106082a8648ce3d0301070342000425ae89c5b1dd827dbf4898149b8aa8e757abf2dc340aadea8dd8962f23c4c9b03533b78eb5547b8b8d023e00a768bcc6243985160b980f6d7ad036e4e437b539a381c53081c2300c0603551d130101ff04023000300e0603551d0f0101ff040403020388301d0603551d250416301406082b0601050507030106082b06010505070302302b0603551d1104243022862068747470733a2f2f6167656e742e6268617261742e6578616d706c652f6d637030290603551d0e04220420cc2276300ca426c3925b404000e1510d28aca2309aa924a723f03bf9742d3971302b0603551d23042430228020c4d1dc13f1531d0508e128392934cc7e2f574827c9d960f1f5e5d441d2e5d0f3300a06082a8648ce3d0403020348003045022023e6161f7d09b9f949d1f27669bfed4ef4f26dce2e16184b1a1c54044a1f99cd022100936983af98bb6a88265761d7736bb061586db282a5e59ca4a32cbb1a3d7a8863",
"note": "leaf_b's TBS under the OTHER twin of an ECDSA signature, (r, n − s): it verifies under root_b and is refused by the profile (§14.1: low-S)",
"refused": true
},
"leaf_a_feb30": {
"der_hex": "308201a430820156a003020102020900de7fe857145b30bb300506032b657030143112301006035504030c09416c696e612052616f301e170d3236303233303132303030305a170d3237303330313030303030305a30143112301006035504030c09416c696e612052616f302a300506032b65700321001ce0f36e1d5893400ff6e56a8cd265e3794642e86639e4ff88e73b0c56164454a381c43081c1300c0603551d130101ff04023000300e0603551d0f0101ff040403020780301d0603551d250416301406082b0601050507030106082b06010505070302302a0603551d1104233021861f68747470733a2f2f6167656e742e616c696e612e6578616d706c652f6d637030290603551d0e042204200975ead0c601974554d791f5af4c5e8075e2270da564996c609168cadd201663302b0603551d2304243022802091fb3d906eb14ac59a36dbd666c5670169c68ad860fc780a37875b7d63860b50300506032b6570034100dcf5f0121338a9e7381e7e982395d4f000f22e1079b5c10028e835f41a69ba4702b57e40888e8217bbcb017ee8d56c64e74e7492ce6b69ae1999610ef78d0e06",
"note": "leaf_a's key and endpoint with a notBefore of 260230120000Z, 30 February: refused, not read as 2 March (§14.1)",
"refused": true
},
"leaf_a_aki3": {
"der_hex": "3082018730820139a003020102020900b134d8a0c4474f83300506032b657030143112301006035504030c09416c696e612052616f301e170d3236303930313030303030305a170d3237303930313030303030305a30143112301006035504030c09416c696e612052616f302a300506032b65700321001ce0f36e1d5893400ff6e56a8cd265e3794642e86639e4ff88e73b0c56164454a381a73081a4300c0603551d130101ff04023000300e0603551d0f0101ff040403020780301d0603551d250416301406082b0601050507030106082b06010505070302302a0603551d1104233021861f68747470733a2f2f6167656e742e616c696e612e6578616d706c652f6d637030290603551d0e042204200975ead0c601974554d791f5af4c5e8075e2270da564996c609168cadd201663300e0603551d23040730058003010203300506032b657003410063f5e965d31f3a86d1a39ae11742e98a4cf5ba4e7ab8969b03f9d5381508db5c018cd32645807ec350aa8714df589f482b8254d9b12046cb36de8fd5fed9b40c",
"note": "leaf_a's key and endpoint with an authorityKeyIdentifier of three bytes, 01 02 03: refused, because a key identifier is 32 bytes (§14.1), at card intake as much as in a chain",
"refused": true
},
"root_c_backwards": {
"der_hex": "308201353081e8a003020102020900cc7ee76dfd7cd361300506032b657030173115301306035504030c0c4368616e6472612049796572301e170d3236303930313030303030305a170d3236303833313233353935395a30173115301306035504030c0c4368616e6472612049796572302a300506032b657003210093fa835c0d98975e4dfd1253682bfa8fcfa5c88b9c27622981b7715d01912075a351304f30120603551d130101ff040830060101ff020100300e0603551d0f0101ff04040302020430290603551d0e0422042010c7462ef86a91d6525d7e7c0df46a299f5b0b9df8b73f517ad2133f06a295c9300506032b65700341000301b3157eaab5697452ac1d6ac0630dba6d251b3780c3764a0628bc8cda75bd9ad22fa3aed58a04e4ca77a079018bed2d19b0dc268c367d7b78e54266b40b02",
"note": "root_c's key and name with notAfter 2026-08-31T23:59:59Z, before its notBefore: refused by the profile (§14.1)",
"refused": true
}
},
"leaf_keys_pkcs8_hex": {
"leaf_a": "302e020100300506032b657004220420e3243080ed02732529eefba42d0f7e576c79b06dba351525611abce2771d9cf1",
"leaf_a_next": "302e020100300506032b657004220420b8cdbdc2e637be798937f714c93aa5ecb6263a7e943e0dd7ba60ef0e7b73e162",
"leaf_b": "3041020100301306072a8648ce3d020106082a8648ce3d03010704273025020101042058f95740ead99d8bdb345794fd57d4c3274dd8ac64ac1225184f03283aad3d28",
"leaf_c": "302e020100300506032b65700422042006b29015329cc41a26202229779d3d547eece30293cd7dfebb8a053ea42053e8"
},
"chain_cases": [
{
"name": "alina valid",
"chain": [
"leaf_a",
"root_a"
],
"expected_root": "sha256:kfs9kG6xSsWaNtvWZsVnAWnGithg_HgKN4dbfWOGC1A",
"expected_endpoint": "https://agent.alina.example/mcp",
"now": "2026-09-13T12:00:00Z",
"expect": "accept"
},
{
"name": "bharat valid",
"chain": [
"leaf_b",
"root_b"
],
"expected_root": "sha256:xNHcE_FTHQUI4Sg5KTTMfi9XSCfJ2WDx9eXUQdLl0PM",
"expected_endpoint": "https://agent.bharat.example/mcp",
"now": "2026-09-13T12:00:00Z",
"expect": "accept"
},
{
"name": "first contact, no expectation",
"chain": [
"leaf_a",
"root_a"
],
"now": "2026-09-13T12:00:00Z",
"expect": "accept"
},
{
"name": "chain of three",
"chain": [
"leaf_a",
"root_a",
"root_a"
],
"now": "2026-09-13T12:00:00Z",
"expect": "refuse",
"rule": 1
},
{
"name": "single certificate is not a chain",
"chain": [
"root_a"
],
"now": "2026-09-13T12:00:00Z",
"expect": "refuse",
"rule": 1
},
{
"name": "leaf presented as root",
"chain": [
"leaf_a",
"leaf_a"
],
"now": "2026-09-13T12:00:00Z",
"expect": "refuse",
"rule": 1
},
{
"name": "root is not the one pinned",
"chain": [
"leaf_a",
"root_a"
],
"expected_root": "sha256:xNHcE_FTHQUI4Sg5KTTMfi9XSCfJ2WDx9eXUQdLl0PM",
"now": "2026-09-13T12:00:00Z",
"expect": "refuse",
"rule": 2
},
{
"name": "leaf under the wrong root",
"chain": [
"leaf_a",
"root_b"
],
"now": "2026-09-13T12:00:00Z",
"expect": "refuse",
"rule": 3
},
{
"name": "expired leaf",
"chain": [
"leaf_a_expired",
"root_a"
],
"now": "2026-09-13T12:00:00Z",
"expect": "refuse",
"rule": 4
},
{
"name": "leaf not yet valid",
"chain": [
"leaf_a_next",
"root_a"
],
"now": "2026-09-13T12:00:00Z",
"expect": "refuse",
"rule": 4
},
{
"name": "leaf longer than 398 days",
"chain": [
"leaf_a_long",
"root_a"
],
"now": "2026-09-13T12:00:00Z",
"expect": "refuse",
"rule": 4
},
{
"name": "endpoint mismatch",
"chain": [
"leaf_a",
"root_a"
],
"expected_endpoint": "https://agent.alina.example/mcp/",
"now": "2026-09-13T12:00:00Z",
"expect": "refuse",
"rule": 5
},
{
"name": "an ECDSA signature swapped for its twin",
"chain": [
"leaf_b_twin",
"root_b"
],
"now": "2026-09-13T12:00:00Z",
"expect": "refuse",
"rule": 1
},
{
"name": "a validity field that is not a date",
"chain": [
"leaf_a_feb30",
"root_a"
],
"now": "2026-09-13T12:00:00Z",
"expect": "refuse",
"rule": 1
},
{
"name": "an issuer key identifier that is not 32 bytes",
"chain": [
"leaf_a_aki3",
"root_a"
],
"now": "2026-09-13T12:00:00Z",
"expect": "refuse",
"rule": 1
},
{
"name": "a root with an end date not yet reached",
"chain": [
"leaf_c",
"root_c"
],
"expected_root": "sha256:EMdGLvhqkdZSXX58DfRqKZ9bC534tz9RetITPwailck",
"expected_endpoint": "https://agent.chandra.example/mcp",
"now": "2026-09-13T12:00:00Z",
"expect": "accept"
},
{
"name": "a root at the last second of its end date",
"chain": [
"leaf_c_last",
"root_c"
],
"now": "2028-01-01T00:00:00Z",
"expect": "accept"
},
{
"name": "a root past its end date",
"chain": [
"leaf_c_last",
"root_c"
],
"now": "2028-01-01T00:00:01Z",
"expect": "refuse",
"rule": 4,
"reason": "root has expired"
},
{
"name": "a leaf that outlives its root",
"chain": [
"leaf_c_outlives",
"root_c"
],
"now": "2027-09-01T00:00:00Z",
"expect": "refuse",
"rule": 4,
"reason": "leaf outlives the root"
},
{
"name": "a root whose end date is before its start",
"chain": [
"leaf_c",
"root_c_backwards"
],
"now": "2026-09-13T12:00:00Z",
"expect": "refuse",
"rule": 1,
"reason": "root notAfter is before its notBefore"
}
],
"newest_leaf_cases": [
{
"pinned": "leaf_a",
"presented": "leaf_a",
"expect": "same"
},
{
"pinned": "leaf_a",
"presented": "leaf_a_next",
"expect": "newer"
},
{
"pinned": "leaf_a_next",
"presented": "leaf_a",
"expect": "superseded"
},
{
"pinned": "leaf_a",
"presented": "leaf_a_long",
"expect": "conflict"
}
],
"certificate_renewed_cases": [
{
"name": "renewal followed",
"pinned_leaf": "leaf_a",
"dialed": "https://agent.alina.example/mcp",
"now": "2027-08-15T12:00:00Z",
"answer": {
"code": "certificate_renewed",
"data": {
"chain": [
"MIIBozCCAVWgAwIBAgIIGE69pMIqOa4wBQYDK2VwMBQxEjAQBgNVBAMMCUFsaW5hIFJhbzAeFw0yNzA4MDIwMDAwMDBaFw0yODA4MDEwMDAwMDBaMBQxEjAQBgNVBAMMCUFsaW5hIFJhbzAqMAUGAytlcAMhAN-icJ3l352LoHd9ygwFeSmWXi5MHa1ksrVXXaUxfmhBo4HEMIHBMAwGA1UdEwEB_wQCMAAwDgYDVR0PAQH_BAQDAgeAMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAqBgNVHREEIzAhhh9odHRwczovL2FnZW50LmFsaW5hLmV4YW1wbGUvbWNwMCkGA1UdDgQiBCBlQIw5RuTT9nuU1gTObbM5BfuMGjOPGcB_1SkWF-M6_TArBgNVHSMEJDAigCCR-z2QbrFKxZo229ZmxWcBacaK2GD8eAo3h1t9Y4YLUDAFBgMrZXADQQCulijUSmK8AgbLuiRxB6-AbPq4qN2KMBI8xOYHzDGFlg4N69qTh5A5coi158gwHjGHdLQ5EMqpT59X7DUxbb8E",
"MIIBMTCB5KADAgECAgkAmQKVaRbcF0EwBQYDK2VwMBQxEjAQBgNVBAMMCUFsaW5hIFJhbzAgFw0yNjA5MDEwMDAwMDBaGA85OTk5MTIzMTIzNTk1OVowFDESMBAGA1UEAwwJQWxpbmEgUmFvMCowBQYDK2VwAyEAcnl_lx5tfbYqIi1vxdxOzwo32iOMPbpO3DeKHEPKz7CjUTBPMBIGA1UdEwEB_wQIMAYBAf8CAQAwDgYDVR0PAQH_BAQDAgIEMCkGA1UdDgQiBCCR-z2QbrFKxZo229ZmxWcBacaK2GD8eAo3h1t9Y4YLUDAFBgMrZXADQQB89c8u5C7h-bk1IDe0SB4DEnPTOVzWeUnNXmkFrteC81cfTPBGkN4VDWS9AkB9o8hmga0qik-AQduXeijavbcI"
]
}
},
"expect": "follow"
},
{
"name": "older chain discarded",
"pinned_leaf": "leaf_a_next",
"dialed": "https://agent.alina.example/mcp",
"now": "2027-08-15T12:00:00Z",
"answer": {
"code": "certificate_renewed",
"data": {
"chain": [
"MIIBuTCCAWugAwIBAgIJAOpUDjFhRyz1MAUGAytlcDAUMRIwEAYDVQQDDAlBbGluYSBSYW8wHhcNMjYwOTAxMDAwMDAwWhcNMjcwOTAxMDAwMDAwWjAUMRIwEAYDVQQDDAlBbGluYSBSYW8wKjAFBgMrZXADIQAc4PNuHViTQA_25WqM0mXjeUZC6GY55P-I5zsMVhZEVKOB2TCB1jAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB_wQEAwIHgDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwPwYDVR0RBDgwNoYfaHR0cHM6Ly9hZ2VudC5hbGluYS5leGFtcGxlL21jcIITYWdlbnQuYWxpbmEuZXhhbXBsZTApBgNVHQ4EIgQgCXXq0MYBl0VU15H1r0xegHXiJw2lZJlsYJFoyt0gFmMwKwYDVR0jBCQwIoAgkfs9kG6xSsWaNtvWZsVnAWnGithg_HgKN4dbfWOGC1AwBQYDK2VwA0EAHufxIATi7sc2TPtGxQDWBvgAx4kMvhkjx0HnIYvqEjJ82QGozuRwvkb7aBgC3bwqt6lzu9Ja7motAucm4o2ADg",
"MIIBMTCB5KADAgECAgkAmQKVaRbcF0EwBQYDK2VwMBQxEjAQBgNVBAMMCUFsaW5hIFJhbzAgFw0yNjA5MDEwMDAwMDBaGA85OTk5MTIzMTIzNTk1OVowFDESMBAGA1UEAwwJQWxpbmEgUmFvMCowBQYDK2VwAyEAcnl_lx5tfbYqIi1vxdxOzwo32iOMPbpO3DeKHEPKz7CjUTBPMBIGA1UdEwEB_wQIMAYBAf8CAQAwDgYDVR0PAQH_BAQDAgIEMCkGA1UdDgQiBCCR-z2QbrFKxZo229ZmxWcBacaK2GD8eAo3h1t9Y4YLUDAFBgMrZXADQQB89c8u5C7h-bk1IDe0SB4DEnPTOVzWeUnNXmkFrteC81cfTPBGkN4VDWS9AkB9o8hmga0qik-AQduXeijavbcI"
]
}
},
"expect": "discard"
},
{
"name": "another root discarded",
"pinned_leaf": "leaf_a",
"dialed": "https://agent.alina.example/mcp",
"now": "2026-09-13T12:00:00Z",
"answer": {
"code": "certificate_renewed",
"data": {
"chain": [
"MIIB6DCCAZCgAwIBAgIIYs8KMv2IITAwCgYIKoZIzj0EAwIwFzEVMBMGA1UEAwwMQmhhcmF0IE1laHRhMB4XDTI2MDkwMTAwMDAwMFoXDTI3MDkwMTAwMDAwMFowFzEVMBMGA1UEAwwMQmhhcmF0IE1laHRhMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEJa6JxbHdgn2_SJgUm4qo51er8tw0Cq3qjdiWLyPEybA1M7eOtVR7i40CPgCnaLzGJDmFFguYD2160Dbk5De1OaOBxTCBwjAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB_wQEAwIDiDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwKwYDVR0RBCQwIoYgaHR0cHM6Ly9hZ2VudC5iaGFyYXQuZXhhbXBsZS9tY3AwKQYDVR0OBCIEIMwidjAMpCbDkltAQADhUQ0orKIwmqkkpyPwO_l0LTlxMCsGA1UdIwQkMCKAIMTR3BPxUx0FCOEoOSk0zH4vV0gnydlg8fXl1EHS5dDzMAoGCCqGSM49BAMCA0YAMEMCH0cYUrJMlb36mwOKhcicdMBNPl909-urKt7q5PczDK4CIDRrti3mrrGIpct-ZTtjfkY4EVztwbgPJoJccpjY3bIp",
"MIIBeDCCAR6gAwIBAgIJAOURI8HuhdbgMAoGCCqGSM49BAMCMBcxFTATBgNVBAMMDEJoYXJhdCBNZWh0YTAgFw0yNjA5MDEwMDAwMDBaGA85OTk5MTIzMTIzNTk1OVowFzEVMBMGA1UEAwwMQmhhcmF0IE1laHRhMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEIYLP_fOr7621EFDmA_baJRywNXXNmy97qvriNRmpL6bv111BCOApXak4YglqDMVXCejKHgD7WjmP5_L0HzaWmaNRME8wEgYDVR0TAQH_BAgwBgEB_wIBADAOBgNVHQ8BAf8EBAMCAgQwKQYDVR0OBCIEIMTR3BPxUx0FCOEoOSk0zH4vV0gnydlg8fXl1EHS5dDzMAoGCCqGSM49BAMCA0gAMEUCIQDvb0B1VSblUglPOz94VBxTPsuuN5KesZ3rkludl2yvOwIgS_SOaxQRLhzTWi9fhjQxtyKgx65wOXDho8QZioayats"
]
}
},
"expect": "discard"
}
],
"envelopes": [
{
"name": "alina-to-bharat",
"form": "chain",
"suite": "HDTP-SEAL-P256",
"sender_chain": [
"leaf_a",
"root_a"
],
"recipient_chain": [
"leaf_b",
"root_b"
],
"plaintext_hex": "7b226d6574686f64223a22746f6f6c732f63616c6c222c22706172616d73223a7b226e616d65223a2273656e645f6d657373616765222c22617267756d656e7473223a7b226d73675f6964223a227665632d31222c2274657874223a2268656c6c6f2066726f6d207468652048445450207465737420766563746f7273227d7d2c22636861696e223a5b224d4949427554434341577567417749424167494a414f7055446a466852797a314d4155474179746c634441554d524977454159445651514444416c4262476c7559534253595738774868634e4d6a59774f5441784d4441774d4441775768634e4d6a63774f5441784d4441774d444177576a41554d524977454159445651514444416c4262476c7559534253595738774b6a414642674d725a5841444951416334504e754856695451415f323557714d306d586a65555a433647593535502d49357a734d56685a45564b4f4232544342316a414d42674e5648524d4241663845416a41414d41344741315564447745425f775145417749486744416442674e5648535545466a4155426767724267454642516344415159494b77594242515548417749775077594456523052424467774e6f59666148523063484d364c7939685a3256756443356862476c755953356c654746746347786c4c32316a634949545957646c626e517559577870626d45755a586868625842735a54417042674e56485134454967516743585871304d59426c3056553135483172307865674858694a77326c5a4a6c73594a466f79743067466d4d774b7759445652306a42435177496f41676b6673396b473678537357614e7476575a73566e41576e47697468675f48674b4e34646266574f4743314177425159444b3256774130454148756678494154693773633254507447785144574276674178346b4d76686b6a7830486e49597671456a4a383251476f7a755277766b6237614267433362777174366c7a75394a61376d6f744175636d346f32414467222c224d4949424d544342354b41444167454341676b416d514b566152626346304577425159444b3256774d425178456a415142674e5642414d4d435546736157356849464a68627a4167467730794e6a41354d4445774d4441774d444261474138354f546b354d54497a4d54497a4e546b314f566f77464445534d424147413155454177774a51577870626d4567556d46764d436f77425159444b32567741794541636e6c5f6c78357466625971496931767864784f7a776f3332694f4d5062704f3344654b4845504b7a37436a555442504d42494741315564457745425f7751494d415942416638434151417744675944565230504151485f42415144416749454d436b474131556444675169424343522d7a32516272464b785a6f3232395a6d785763426163614b3247443865416f33683174395934594c5544414642674d725a5841445151423839633875354337682d626b314944653053423444456e50544f567a5765556e4e586d6b467274654338316366545042476b4e345644575339416b42396f38686d67613071696b2d415164755865696a6176626349225d7d",
"protected": "eyJjdHkiOiJhcHBsaWNhdGlvbi9oZHRwLWNhbGwranNvbiIsImV4cCI6MTc4OTMwMTQwMCwia2lkIjoic2hhMjU2OnpDSjJNQXlrSnNPU1cwQkFBT0ZSRFNpc29qQ2FxU1NuSV9BNy1YUXRPWEUiLCJtc2dfaWQiOiJ2ZWMtdjEtYWxpbmEtdG8tYmhhcmF0Iiwic3VpdGUiOiJIRFRQLVNFQUwtUDI1NiIsInRzIjoxNzg5MzAwODAwLCJ2IjoxfQ",
"enc": "BDtCX1LmUSqSnb_0czHEYeELskyQ6mf5B1n8Uw5LU5J9W5u8OoBwdS7uyb_lPIu6ZFbVLWMVQc4uoCyxi2iB55Y",
"ct": "kCRX1NHxkygGve7HK-_BYDxWTdGAvUY4ZSMVj50aMUzZv9pY4Y2IB740f9PLrgS7M1p4dkGPJKDcF2hypMWB6M-V2c7GsynyYLTWbPBAikixfbSB5POnSQ9-L_bP5Mmb8DAH61OCR4V7fmLuxMF9b3Bb780o4M6Raa_eYlM_Qv5WYbCdUZ1KMFPU7tcuX8SEfOknPQn61C09o3SY9-vC76ArJQ02X1zjeUjDacCrGlxnH9y8B0qmXuuQnjhDFhOMNfdqBOQDG_YlVIk5E5yzw6Y9ezxk0JKhSkYO1Qpjq5ymKFd6Dc5CD9ZS6aL8d_w3lxNITJp0IDLXwl2hNte4ZHTce4GxyS_0WnZvecjo6hrXBzTYuz2-1hXqzvZhrCHA7_csq_Dh-mx3iqkUX0edNLJqNDv4Roudun-MQ61zYDDIWhvy1dqKdaP9FPyAq5Ugrta8_frfXplkVfsSBErBy-Nw8EBnLUv1ZYAORBEuRpToo2A3gGXpE7uh22Z6VIQiqMwPa800BGMyqx2JSbdfC_oBQpJLxAYBbCWpgW0ATw9YDzDDe24zj_8kewt8X1SmjY0MNmLNb5JI0U4scAjPUa0LmRGusJDIEAx8wDAnagev2kSPJYxs8pT0yug_UXoFJmBVqHZcz10si5d_upaeOU-ae5vBN1s6QdkcoixYAJa8gYDlonbbWqJj4_h06-3KsEKHUiN2cb7omuoSBLiIIkLToI__kgyIxJL79oUS8plYKjQLgiKtMGFpyt9s1-FFDb8tWR-Sa-suEbH8C-YEaqsy4P8OLXv_OauWptEFjenVJ6oTyxBe4GylmkdkVfY7BoS4rU2-jVr_DjjU_oIw5sEIWt8o3GykhStBcYfTwJMDWgA-3mYjnEsaeswnw3b9m4qz0dgOhoLAba65MyYGdy_NQWH7lgmYVO1jM_DVzlV0pQCw8aUa2Q497d3_EC6U9a85jkWCEKaVo-_O44L-Eh2OLEDpCS8vfBpHd80Gp_7j210QMg27ANjPmybjSbafm-ruE8QQzycKnYevfnVdQrmmJSvDbhUNwTf_GK2b0u33VKKCz8TvrSG1Z1e0UfBib09uPyBAVU2pLkxW56gDkUQFUmGsMEobJ4of0IbzDdloUh2rMSV3WwV-pWj-bHAA3jXlEs4swVBbgGz6j_5eV1n9nJhaiTzdYmd0xw-cvB0X2HBSSYn9z6tUa7sd1Sxl0rOCBPNcIcgnxNFbVnjr7DVG6KuKl_kZl9nh0xXCCcPS5YwAaL9OXCAYGbtHAIhDhi8-DZzY4pYdbNL0bGmg3CQqtcFISGauQ54CxsTRtfsTtvTcYjLWf_nYNnr4ipOxnkV-FD2UY5sJ0cbVTtIlmyX0FvZLOnjCk-9W01n7IcXb9wBFkbeTlWN5tliN7c2CPh5GXjIQCWPI51poAWMPeJ05PpteeortPX-6DutmT1wp-gS2qGk6swkCLdL4NQl0nnyiMM3MRbJt6j6yBjqrG9QyGX1tdvc0ajcW_maDsxP56J_vdPqIjr06aJQ43DN-IMYhW4C_Tq_t58cqYUM7",
"sig": "FALrKT-0zNjr4vwAsiq-jSh4gzkrghC8b6q495eT6dUoMB_lA2hR499R8f4ApgavJwTog7BnNJ2O_BMpkFLZAw"
},
{
"name": "bharat-to-alina",
"form": "chain",
"suite": "HDTP-SEAL-X25519",
"sender_chain": [
"leaf_b",
"root_b"
],
"recipient_chain": [
"leaf_a",
"root_a"
],
"plaintext_hex": "7b226d6574686f64223a22746f6f6c732f63616c6c222c22706172616d73223a7b226e616d65223a2273656e645f6d657373616765222c22617267756d656e7473223a7b226d73675f6964223a227665632d31222c2274657874223a2268656c6c6f2066726f6d207468652048445450207465737420766563746f7273227d7d2c22636861696e223a5b224d49494236444343415a436741774942416749495973384b4d76324949544177436759494b6f5a497a6a304541774977467a45564d424d47413155454177774d516d6868636d46304945316c614852684d423458445449324d446b774d5441774d4441774d466f58445449334d446b774d5441774d4441774d466f77467a45564d424d47413155454177774d516d6868636d46304945316c614852684d466b77457759484b6f5a497a6a3043415159494b6f5a497a6a304441516344516741454a61364a78624864676e325f534a67556d34716f3531657238747730437133716a6469574c795045796241314d37654f74565237693430435067436e614c7a474a446d4646677559443231363044626b354465314f614f4278544342776a414d42674e5648524d4241663845416a41414d41344741315564447745425f775145417749446944416442674e5648535545466a4155426767724267454642516344415159494b77594242515548417749774b7759445652305242435177496f59676148523063484d364c7939685a3256756443356961474679595851755a586868625842735a533974593341774b5159445652304f42434945494d7769646a414d704362446b6c7441514144685551306f724b49776d716b6b707950774f5f6c304c546c784d437347413155644977516b4d434b41494d54523342507855783046434f456f4f536b307a4834765630676e79646c673866586c314548533564447a4d416f4743437147534d343942414d43413059414d454d434830635955724a4d6c6233366d774f4b68636963644d424e506c3930392d75724b7437713550637a444b3443494452727469336d727247497063742d5a54746a666b593445567a74776267504a6f4a6363706a5933624970222c224d4949426544434341523667417749424167494a414f555249384875686462674d416f4743437147534d343942414d434d4263784654415442674e5642414d4d44454a6f59584a686443424e5a57683059544167467730794e6a41354d4445774d4441774d444261474138354f546b354d54497a4d54497a4e546b314f566f77467a45564d424d47413155454177774d516d6868636d46304945316c614852684d466b77457759484b6f5a497a6a3043415159494b6f5a497a6a3044415163445167414549594c505f664f72373632314546446d415f62614a5279774e58584e6d793937717672694e526d704c36627631313142434f417058616b3459676c71444d565843656a4b48674437576a6d50355f4c30487a61576d614e524d45387745675944565230544151485f42416777426745425f7749424144414f42674e56485138424166384542414d43416751774b5159445652304f42434945494d54523342507855783046434f456f4f536b307a4834765630676e79646c673866586c314548533564447a4d416f4743437147534d343942414d43413067414d45554349514476623042315653626c55676c504f7a393456427854507375754e354b65735a33726b6c75646c3279764f774967535f534f617851524c687a5457693966686a517874794b67783635774f5844686f38515a696f6179617473225d7d",
"protected": "eyJjdHkiOiJhcHBsaWNhdGlvbi9oZHRwLWNhbGwranNvbiIsImV4cCI6MTc4OTMwMTQwMCwia2lkIjoic2hhMjU2OkNYWHEwTVlCbDBWVTE1SDFyMHhlZ0hYaUp3MmxaSmxzWUpGb3l0MGdGbU0iLCJtc2dfaWQiOiJ2ZWMtdjEtYmhhcmF0LXRvLWFsaW5hIiwic3VpdGUiOiJIRFRQLVNFQUwtWDI1NTE5IiwidHMiOjE3ODkzMDA4MDAsInYiOjF9",
"enc": "MVw3MjzMuEfaKu4ljNQhqMibwGg-JhN9EZRI132Dyng",
"ct": "dWsgrpMX2RWbzriWASLry8fm9E9hcG9xPAI9khDCknz9WcjcA3OPI6Py4YEuocuWSHIbDySz_uJh_exQde8FyS1oazSguRoeH3AAzfcWSTIl5AZU0y1CoSG3yObLeCFqQpfd5nY1SEeLgr3K3EOkIEEygUL0hYsYO8dhV8JWeNP05f2-rY5t21E_w4cp0zXNH-a-sW7WVI4vTPVxWTcYuBZX7eZ1jhtz6qwZzXOdRFhirglOe_A9glv3ccp3NuXIXBi3r2VMqDyPqAs42PAKoBbScN8C2n76y64yVYAdwOHqY5OWhY5tVDHVYGXIxpRlwYKTOFmuO-rmCnO3_8vjme-YebM0oTzi1Bm6KprZQTzLQvs23bdJnJ6qWB1dh7fAErOac3XpflogUlYfjJv8dEDpbFQpi953Ex5VeKBEoynP9zrXNYvyr3jXAdlgUSniAk50P8fX2kgfLqztdtF-frLNPxYC57uyLdj4aX2rL5h_LGNOOzHtTIX0mnP_ylMicQr20JwGKkVlVEnEZ-LUFfbepYQPbO3WKYjxvttbAmjPdMrpZCksYoUcivJFpmaXotIJwzMqRthTJmm9QtLKoAJESfE9tKmMyV8cg0HwDbZav7ikUFscUMmYXCBKv3bWpWTmK58gGSYxVSoCdbN53JLynZlrvGirfhEJWl7fwZj9ovsN-rOPpwLaPHw4YKX_apXPvl9mEuyb0uyIW9_7P-5LXtz_tqQ22-8I-Hpoq_AM3TLdIg6IbqBFyGocI4zHJ_wFKN24pl-2G8RsFDZt6QPV9W_S0c0DXfYQCiuw9Y6McImJJf4_7NycWby3PyzlrgIFZIc3_ytGw5si7wVY6LaeSxOMJbdf00tgC0YyJEU_NYwE_rO874waJ9Ki-zr5QfzK9-9NTf81GPz4_Bd49a7Xgn8ZjZ0lFWtXTZeudd31dC2Eua1SBNlsZ0H_C7PsTCNugkKvWAZg7J-LsaKNGVRu53ZlcjppUm2OOp0Z43Ck5jxCOa5ufKipNkPadOwGyKkNL5EVRTcSo5BaOz7x2T_yXuh3y94HErLP2IVjr62vO5WPgfwNGPrOhVqbs6n5CMCv4KE9ajnZteBaCaHYDEbbab3C73a28gvuWbarUHoxDl3Wciv7wFPclRzQ_GqqJJn7weBYUSegqi8Hc0upznt5KPJIo2JUrKZQHRbAcfOKDKv8AG4eHa9VhZUfDa0eWw1tU4YOI3zzV31Qy0_Qk-hqJatu7MWgenCxWL-BjkZ0I3apDgbMVvac-Vq73kX3IIIXSNokJCr5tth1u_j0CPtq9Xx_VOAtUd_CfNC1mtdY5E966v5D_Oj7vZZ0zCJw31YfCpqLNSXZ9s4Bwp6NM1siQ3zWGAkMz-Ob0PdgoAPxk8tSlnA4xsW67PfPr-_hCOt_KCMjZugBNckkX1EQVj_1XaCftvd2FyZKQongKqmwsjJEoaw8MUx6YcvHm8cLEdnFh0CSmyRAUpPsJ5zKZW3YZOv2NbSlM-n-L4MG6YjJRGkz68IMr9PZbJvyrDSJjg4nvQQiB8kPwVEEDnPxsZ-6I5JIuCpSCRK7YkCxJILHbdS6AaA7GqX_Ygsh4xOZyuSANYCI9eyaugWxxt5JdkU96669PWD9Fc7RhKMz1pVlWrAU2lQSR4BAaZ4CNd8FvHAg_0iEDFWKv5FrWiTWVGpWH0wOHlQfcxyeH6cym1LE1ETxE7U74isOWXw4zYqzkzdOhJ5Xt3Thd9AwmNvN5xanzuWorbSys7RNQA",
"sig": "MEQCIAkON1eCRnY7aZ-QX8SPOkoljVwPGGVxx1Cg_boylUb7AiAWR-n9tQJNR_4HcXAjXfkgdM-LDD5Txv9EicOs_9Qv3w"
},
{
"name": "alina-to-bharat-by-reference",
"form": "leaf",
"suite": "HDTP-SEAL-P256",
"sender_chain": [
"leaf_a",
"root_a"
],
"recipient_chain": [
"leaf_b",
"root_b"
],
"plaintext_hex": "7b226d6574686f64223a22746f6f6c732f63616c6c222c22706172616d73223a7b226e616d65223a2273656e645f6d657373616765222c22617267756d656e7473223a7b226d73675f6964223a227665632d31222c2274657874223a2268656c6c6f2066726f6d207468652048445450207465737420766563746f7273227d7d2c226c656166223a227368613235363a43585871304d59426c3056553135483172307865674858694a77326c5a4a6c73594a466f79743067466d4d227d",
"protected": "eyJjdHkiOiJhcHBsaWNhdGlvbi9oZHRwLWNhbGwranNvbiIsImV4cCI6MTc4OTMwMTQwMCwia2lkIjoic2hhMjU2OnpDSjJNQXlrSnNPU1cwQkFBT0ZSRFNpc29qQ2FxU1NuSV9BNy1YUXRPWEUiLCJtc2dfaWQiOiJ2ZWMtdjEtYWxpbmEtdG8tYmhhcmF0LXJlZiIsInN1aXRlIjoiSERUUC1TRUFMLVAyNTYiLCJ0cyI6MTc4OTMwMDgwMCwidiI6MX0",
"enc": "BM0lqrSTC4qyxAL87KoXr5eo3uRJwBiNtiUZtL790YzJsVBeaS7lhSedGyNsPBYWt5DVV1qcT6YDaV8b2E7C2oo",
"ct": "iq0O-sqrJdSp_ys5IYY-qv3dJprm0nfugrDPCU5W4wSxl78Av1aCXT0blwJjk9GZOBdLVa0ULi2qKFos6c6M0m7nLEhgJdfV5tFujW3NiQyDyYg2YfK_D4bM-iZTD9U_cG8scQRLOSMqREaWmRk_Qw8P1fplrmhfux3GQ5Tl9VOBoTQqZNmGORPy-rnDB6Rp9aox6Irhrh8ZqP4Y8z00dZ8-v1uSeot-bupNNLT7p0Gd9FGnW9Kk8HTMKefGGUkmc5wTlt5t0rqy6xqwxw",
"sig": "o2uZXByY56E1WPrl-gFetWUj2hhfb_zMgrRgVdyqEBMjRd0rlJPrXN7HDxHHznQg_xr-RnFZKQq7hx_lQYM0DA"
}
],
"derivation": [
{
"label": "root",
"prf": "9He89-EmS-vXLacNUr056xb8vfOisUPULoWKtxOPUaM",
"salt": "hArwKi9mjuCpCqvaa0fl_xMKCi-SS5B43gVC_6wbU4M",
"info": "hdtp/root/1",
"seed": "K8uwdL17lL4d-C6p4K0jjI4gzu_MRrpql9OuMs7TWV0",
"alg": "ed25519",
"spki": "MCowBQYDK2VwAyEAFkQ4dYeAKQ7d7VaBCdHDkUj-lNiP5B5ni3z3_Tlc6A8",
"fingerprint": "sha256:sTsPU8cHx-jNcHuwgjPLe9oOku1XLvqu9t6jDwGUtsw",
"note": "the identity this passkey is: Ed25519 from the derived seed. No certificate: a root's serial and notBefore are the wallet's, not the derivation's, so the key is what reproduces and the certificate is not"
},
{
"label": "store-key",
"prf": "9He89-EmS-vXLacNUr056xb8vfOisUPULoWKtxOPUaM",
"salt": "hArwKi9mjuCpCqvaa0fl_xMKCi-SS5B43gVC_6wbU4M",
"info": "hdtp/store-key/1",
"seed": "hc9IVLsZfOxTgxEUlaDpcUHSkrNaf6ynXm036NwuQ3E",
"note": "HKDF-SHA256 over the same prf; a 32-byte secret, not a key"
},
{
"label": "store-id",
"prf": "9He89-EmS-vXLacNUr056xb8vfOisUPULoWKtxOPUaM",
"salt": "hArwKi9mjuCpCqvaa0fl_xMKCi-SS5B43gVC_6wbU4M",
"info": "hdtp/store-id/1",
"seed": "ii0qTaCcVQoglH-nZqmdhPIUqczEVnD54FSDsm9hLv4",
"note": "HKDF-SHA256 over the same prf; a 32-byte secret, not a key"
}
],
"signed_card": {
"leaf": "leaf_a",
"card": "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Alina Rao\r\nX-HDTP-VERSION:1\r\nX-HDTP-CERT:MIIBuTCCAWugAwIBAgIJAOpUDjFhRyz1MAUGAytlcDAUMRIwEAYDVQQDDAlBbGl\r\n uYSBSYW8wHhcNMjYwOTAxMDAwMDAwWhcNMjcwOTAxMDAwMDAwWjAUMRIwEAYDVQQDDAlBbGluY\r\n SBSYW8wKjAFBgMrZXADIQAc4PNuHViTQA_25WqM0mXjeUZC6GY55P-I5zsMVhZEVKOB2TCB1jA\r\n MBgNVHRMBAf8EAjAAMA4GA1UdDwEB_wQEAwIHgDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBB\r\n QUHAwIwPwYDVR0RBDgwNoYfaHR0cHM6Ly9hZ2VudC5hbGluYS5leGFtcGxlL21jcIITYWdlbnQ\r\n uYWxpbmEuZXhhbXBsZTApBgNVHQ4EIgQgCXXq0MYBl0VU15H1r0xegHXiJw2lZJlsYJFoyt0gF\r\n mMwKwYDVR0jBCQwIoAgkfs9kG6xSsWaNtvWZsVnAWnGithg_HgKN4dbfWOGC1AwBQYDK2VwA0E\r\n AHufxIATi7sc2TPtGxQDWBvgAx4kMvhkjx0HnIYvqEjJ82QGozuRwvkb7aBgC3bwqt6lzu9Ja7\r\n motAucm4o2ADg\r\nX-HDTP-SEAL:required\r\nEND:VCARD\r\n",
"card_sig": "hoqEtTzpoQOTFZ6xGlAO_ecu4d_6L1AHgH_E1Nm5oyhJ43IUG4dznyWjNFJNA8eCZUk3ogcF2qqljEk0GD2QAA",
"note": "the card leaf_a's host serves with `get_card`, `redeem_invite` and the invite landing; card_sig is pure Ed25519 by leaf_a's key over the card text's UTF-8 bytes, CRLF line ends included, written as base64url without padding"
}
}
End of HDTP.
© 2026 Sumit Agrawal. The text is licensed under CC BY 4.0; attribute it as: HDTP — Human Delegated Trust Protocol, by Sumit Agrawal, version 1.0.0 (2026-10-03), https://hdtp.io/spec/, licensed under CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/).
Revision 1.0.0.