Skip to content

Latest commit

 

History

History
1579 lines (1342 loc) · 96.1 KB

File metadata and controls

1579 lines (1342 loc) · 96.1 KB

Cipherpost Protocol Specification

Status: DRAFT — current through v2 derived-key addressing (PROTOCOL_VERSION = 2, crate 2.0.0-alpha.1), which supersedes the v1.1 Real v1 wire format (shipped 2026-04-26). Also covers the experimental, off-by-default large-payload feature (see §Pitfall #22).

This document describes the protocol as shipped through v1.0 Walking Skeleton (Phases 1–4) and v1.1 Real v1 (Phases 5–9) — all four Material variants, --pin / --burn encryption modes, non-interactive automation — and the v2 derived-key addressing redesign, in which each share and each receipt is published under its own key derive(parent_pub, share_ref) rather than under a shared _cipherpost / _cprcpt-<ref> label on the parent key (§3.8). Wire-format decisions documented here are stable — changes require a protocol version bump. v2 is a clean break from v1.1: the PROTOCOL_VERSION 1→2 bump invalidates every previously issued share and receipt, and the "v1.0 byte-identity" lock-in is retired for v2 (records now encode protocol_version: 2, changing their signed bytes). The #[serde(skip_serializing_if = "is_false")] elision for pin_required / burn_after_read is preserved so those flags stay byte-absent when false — but v2 records are no longer byte-identical to v1.0. Editorial polish continues.

Protocol version: cipherpost/v2 (PROTOCOL_VERSION = 2) License: MIT (see LICENSE)

Table of Contents

  1. Introduction
  2. Terminology
  3. Wire Format
  4. Share URI
  5. Flows
  6. Exit Codes
  7. Passphrase Contract
  8. Appendix: Test Vectors
  9. Lineage

1. Introduction

Cipherpost is a self-sovereign, serverless, accountless protocol for handing off cryptographic material between parties. It is built on Mainline DHT via PKARR for rendezvous, age (X25519 derived from Ed25519) for payload encryption, and Ed25519/PKARR keypairs as identity — so there is no operator, no account, and no subpoena target. The core value: hand off a key to someone, end-to-end encrypted, with a signed receipt, without standing up or depending on any server.

Cipherpost is not a KMS, a vault, or a general file-transfer tool. It is purpose-built for the handoff of cryptographic material (private keys, certificates, credentials, API tokens, passphrases) between parties who already know each other's identity — that is, who can exchange Ed25519 public keys out-of-band. Nothing in the protocol establishes trust in a counterparty's identity; that trust is a pre-condition.

Every share carries a sender-attested purpose string — a human-readable label such as "prod deploy key rotation 2026-04-21". The purpose is signed by the sender so it cannot be modified in transit without breaking the inner signature. However, cipherpost does NOT verify the truth value of the purpose. A malicious sender can claim any purpose. This is sender-attested, not independently verified. Recipients MUST verify the sender's identity and corroborate the purpose out-of-band before relying on the material. This constraint is stated again in §3.1 and in THREAT-MODEL.md §4 (Sender-Purpose Adversary).

Payload plaintext is capped at 64 KB (PAYL-03, D-PS-01). The PKARR SignedPacket that carries the encrypted payload must additionally fit within ~1000 bytes (BEP44 wire budget). These are two distinct enforcement layers with distinct error codes; see §3.1 for details.

The protocol uses a dual-signature model: an outer PKARR SignedPacket signature (handled by pkarr::ClientBlocking) ensures the packet was published by the holder of the sender's Ed25519 private key, and an inner Ed25519 signature over the JCS-canonical form of the signed struct ensures the payload fields have not been altered inside a valid PKARR packet. Both signatures are verified before any decryption occurs (D-RECV-01).

After a recipient successfully accepts a share, they publish a signed Receipt under their own PKARR key. The receipt is publicly verifiable by the sender using only public information and provides attestation that the recipient accepted the specific share at a specific time.

2. Terminology

  • age — the payload encryption format (X25519 + ChaCha20-Poly1305). Cipherpost uses the age crate exclusively; no direct chacha20poly1305 calls (CRYPTO-05).
  • Argon2id — passphrase-based KDF for identity-file encryption; params stored in the identity file PHC-format header (CRYPTO-02).
  • BEP44 — BitTorrent Enhancement Proposal 44; defines the ~1000-byte SignedPacket size budget that pkarr inherits.
  • Ed25519 — signature algorithm used for identity, outer PKARR packet signature, and inner Envelope/Receipt signatures.
  • HKDF — HMAC-based Key Derivation Function (SHA-256). All cipherpost HKDF call-sites use a domain-separated info string prefixed cipherpost/v1/ (D-08, CRYPTO-03).
  • JCS — JSON Canonicalization Scheme, RFC 8785. Used for every signable struct before Ed25519 signing (D-CRYPTO-04). Implementation: serde_canonical_json crate.
  • Mainline DHT — the BitTorrent Distributed Hash Table used as rendezvous.
  • PKARR — Public-Key Addressable Resource Records; a scheme for storing DNS-shaped records (TXT, etc.) signed by an Ed25519 key and resolved via Mainline DHT. Cipherpost uses pkarr (>= 5.0.0); see Cargo.toml for the exact pin in effect.
  • sender-attested purpose — the human-readable purpose string is signed by the sender but is NOT independently verified by any third party (D-WIRE-05, PITFALL #12).
  • derived key (blinded key) — a per-record key A' = derive(parent_pub, share_ref) computed by single-hop stealth blinding (§3.8). v2 addresses each share under derive(sender_pub, share_ref) and each receipt under derive(recipient_pub, share_ref), giving every record its own PKARR packet. The tweak is derived from public inputs, so the counterparty derives the same key with no discovery index.
  • Share — one published OuterRecord carrying an age-encrypted payload (v2: published under the sender's derived key derive(sender_pub, share_ref)).
  • share_ref — 128-bit share identifier, 32-char lowercase hex (D-06, PAYL-05). In v2 it is also the per-record derivation input for §3.8 addressing.
  • Receipt — signed attestation published by the recipient after successful acceptance (D-RS-01..07). v2: published under the recipient's derived key derive(recipient_pub, share_ref).
  • z-base-32 — the encoding used by PKARR for public keys. An Ed25519/PKARR public key encodes as exactly 52 z-base-32 characters.

3. Wire Format

All cipherpost signable structs are canonicalized via RFC 8785 (JCS) before being signed with Ed25519. Floats are forbidden in signable structs (CRYPTO-04). Struct fields are serialized in alphabetical order by name (JCS invariant). Every Ed25519 signature is produced over the JCS bytes of the -Signable projection of the wire struct (the wire struct minus its own signature field).

Source-of-truth code: src/record.rs, src/receipt.rs, src/payload.rs, src/crypto.rs::jcs_serialize. Canonical rules: See RFC 8785 (rfc-editor.org/rfc/rfc8785). Canonical JSON implementation: serde_canonical_json (>= 1.0.0, RFC 8785 JCS); see Cargo.toml for the exact pin in effect. Hash algorithm: SHA-256 via the sha2 crate; see Cargo.toml for the exact pin in effect. Hash outputs are rendered as lowercase hex. Signature algorithm: Ed25519 via ed25519-dalek. The exact version pin is a build constraint, not a protocol guarantee — it is locked in Cargo.toml to match pkarr's transitive ed25519-dalek dependency; see CLAUDE.md §Load-bearing lock-ins for the rationale. Payload encryption: age (>= 0.10); see Cargo.toml for the exact pin in effect. age is the only reachable path to chacha20poly1305; no direct calls are permitted. Base64 codec: base64::engine::general_purpose::STANDARD (with padding) — applied uniformly for signatures and for OuterRecord.blob and Material::GenericSecret.bytes (D-WIRE-04). URL_SAFE_NO_PAD is banned at the wire layer.

PKARR wire budget: A representative OuterRecord blob (base64-encoded age ciphertext) must fit within 550 bytes (measured at v1.0 cut; see tests/signed_packet_budget.rs). Within the ~1000-byte BEP44 DNS-packet envelope this leaves room for the JSON structure and the recipient z-base-32. A blob exceeding this ceiling surfaces Error::WireBudgetExceeded at publish time (§3.3, §3.4, §5.1 step 7).

Bootstrap nodes (v1.1): v1.1 uses the pkarr default Mainline bootstrap node set (router.bittorrent.com:6881, dht.transmissionbt.com:6881, dht.libtorrent.org:25401, relay.pkarr.org:6881); no user-tunable bootstrap configuration is exposed in this milestone. Future milestones may revisit if private- testnet support is requested (see CLAUDE.md §Load-bearing lock-ins).

Receipt publication — no CAS in v2 (supersedes the v1.1 publish_receipt CAS contract): Under v2 derived-key addressing (§3.8) each receipt is published under its own key derive(recipient_pub, share_ref), which is written by a single writer and holds a single record. There is therefore no resolve-merge-republish and no compare-and-swap on the v2 receipt path — the concurrency that the v1.1 CAS contract guarded (multiple records merged under one parent packet) cannot arise. (Historical: v1.1 published receipts under the recipient's parent key at _cprcpt-<share_ref> and required implementations to single-retry-then-fail on pkarr::errors::ConcurrencyError inside the Transport method, collapsing final conflicts into Error::Transport with no public Error::CasConflict variant per Pitfall #16. The v1.1 parent-key publish_receipt / resolve_all_cprcpt trait methods and their merge/CAS machinery were removed at v2.)

3.1 Envelope

The plaintext payload. Serialized as JCS, then age-encrypted to produce OuterRecord.blob. Encrypted with age to the recipient's X25519 key (derived from their Ed25519 pubkey); for --self sends, encrypted to the sender's own X25519 key.

Plaintext size cap: 64 KB. Payloads exceeding this are rejected pre-encrypt (PAYL-03, D-PS-01).

Wire budget: The PKARR SignedPacket carrying this payload's OuterRecord must fit within ~1000 bytes (BEP44 budget). Two-layer enforcement per D-PS-01: plaintext > 64 KB aborts before crypto; SignedPacket > wire budget aborts at publish time with Error::WireBudgetExceeded.

Field Type Wire encoding Description Source decision
created_at i64 JSON integer Unix seconds; MUST equal OuterRecord.created_at (single timestamp) D-WIRE-02, PAYL-01
material Material tagged enum — see §3.2 Typed cryptographic payload D-WIRE-03, PAYL-02
protocol_version u16 JSON integer Always 2 in cipherpost/v2 (was 1 in v1.x) D-07
purpose String UTF-8, control chars stripped Sender-attested description; NOT independently verified D-WIRE-05, PAYL-04

Purpose normalization (D-WIRE-05): Before JCS serialization, the purpose string has ASCII C0 controls (0x00..0x1F), DEL (0x7F), and C1 controls (0x80..0x9F) removed. Stripping happens once at send time so sender and recipient compute identical JCS bytes.

Security note (PITFALL #12 / D-WIRE-05): purpose is signed by the sender, so it cannot be modified in flight without breaking the inner signature. However, cipherpost does NOT verify the truth value of the purpose. A malicious sender can claim any purpose. Recipients MUST verify the sender's identity and corroborate the purpose out-of-band before relying on the material. See THREAT-MODEL.md §4 Sender-Purpose Adversary.

3.2 Material

Tagged enum; Rust-level serde directives: #[serde(tag = "type", rename_all = "snake_case")] (D-WIRE-03). Variant names on the wire: generic_secret, x509_cert, pgp_key, ssh_key.

cipherpost/v1.0 shipped: generic_secret only. cipherpost/v1.1 (Phase 6) adds: x509_cert { bytes }. cipherpost/v1.1 (Phase 7) adds: pgp_key { bytes } and ssh_key { bytes }.

generic_secret wire form:

{"type": "generic_secret", "bytes": "<base64-STANDARD-padded>"}
Field Type Wire encoding Description Source decision
type String literal "generic_secret" Variant discriminator D-WIRE-03
bytes String base64-STANDARD, padded Arbitrary byte payload D-WIRE-04

x509_cert wire form (cipherpost/v1.1):

{"type": "x509_cert", "bytes": "<base64-STANDARD-padded>"}
Field Type Wire encoding Description Source decision
type String literal "x509_cert" Variant discriminator D-WIRE-03
bytes String base64-STANDARD, padded — canonical DER per RFC 5280 strict profile X.509 certificate bytes D-P6-01, X509-01

The bytes field carries canonical DER (RFC 5280 strict profile, definite-length encoding). CLI input MAY be DER or PEM; PEM is normalized to DER at ingest before JCS hashing and Envelope construction so share_ref remains deterministic across re-sends of semantically identical certificates (X509-01). Indefinite-length BER is rejected at ingest (exit 1) with a generic user-facing message — NOT exit 3 (which is reserved for signature failures per X509-08).

Parser: x509-parser 0.16 with default-features = false and the verify feature explicitly OFF. Enabling verify would pull ring, which is rejected by the supply-chain policy (.planning/research/SUMMARY.md §Phase 6). A CI test (tests/x509_dep_tree_guard.rs) runs cargo tree and fails the build if ring or aws-lc ever appears in the dep graph — catches feature-flag regressions before they ship.

DN rendering convention (OQ-3 resolved): Subject / Issuer rendering in the acceptance-banner subblock (§5.2) uses x509-parser's Display impl, which produces OpenSSL-forward ordering (C=US, O=..., CN=leaf, matching openssl x509 -noout -subject) — NOT strict RFC 4514 backward ordering. This matches security engineers' mental model.

Oracle hygiene (X509-08): Every parse / normalization / variant-mismatch failure path returns Error::InvalidMaterial { variant, reason } with a short curated reason literal (e.g., "malformed DER", "trailing bytes after certificate", "PEM body decode failed", "PEM label is not CERTIFICATE", "accessor called on wrong variant"). The reason is NEVER an x509-parser / nom:: / asn1-rs / der-parser internal string — the enum does not use #[source] or #[from] to prevent Display-chain leakage via err.source(). A test (tests/x509_error_oracle.rs) enumerates every constructed reason across 4 variants and asserts Display contains none of {X509Error, parse error at, nom::, Incomplete, Needed, PEMError, asn1-rs, der-parser, x509_parser::}.

Wire-budget note (cipherpost/v1.1 Phase 6 deferral): Realistic X.509 certificates exceed the 1000-byte BEP44 SignedPacket ceiling. Cipherpost surfaces this as a clean Error::WireBudgetExceeded { encoded, budget: 1000, plaintext } at send time. See §Pitfall #22 (consolidated below) for the cross-variant what-works-today matrix and the two-tier-storage architectural fix (shipped experimentally as the v2-alpha large-payload feature).

pgp_key wire form (cipherpost/v1.1, Phase 7):

{"type": "pgp_key", "bytes": "<base64-STANDARD-padded>"}
Field Type Wire encoding Description Source decision
type String literal "pgp_key" Variant discriminator D-WIRE-03
bytes String base64-STANDARD, padded — binary OpenPGP packet stream per RFC 4880 §4.2 / RFC 9580 §4.2 OpenPGP key bytes (public TPK or secret TSK) D-P7-01..09, PGP-01

The bytes field carries the binary OpenPGP packet stream verbatim from the sender's input — no canonical re-encode. The RFC-defined packet stream IS canonical (RFC 4880 §4.2); re-encoding through the pgp crate could alter insignificant bits and drift share_ref across sender toolchains. CLI input MUST be binary; ASCII armor (-----BEGIN PGP PUBLIC/PRIVATE KEY BLOCK-----) is REJECTED at ingest with the exact reason "ASCII-armored input rejected — supply binary packet stream" (D-P7-05 / PGP-01). Multi-primary keyrings (>1 top-level PublicKey/SecretKey packet) are REJECTED at ingest with the count substituted (D-P7-06 / PGP-03). Trailing bytes after the last valid packet are REJECTED (WR-01 mirror; the pgp crate's PacketParser silently advances cursor past 0xFF stream-end magic, so the trailing-bytes oracle sums per-packet serialized lengths via pgp::ser::Serialize::to_writer rather than relying on cursor position).

Parser: pgp 0.19.0 exact-pin with default-features = false (disables bzip2 and asm features). Pulls rsa 0.9 transitively for RFC-4880 RSA support (advisory RUSTSEC-2023-0071 accepted — see §Supply-Chain Deferrals). Pulls ed25519-dalek 2.x transitively (coexists with cipherpost's =3.0.0-pre.5 pin — see §Supply-Chain Deferrals). Same dep-tree guard CI test asserts no ring / aws-lc / openssl-sys leak.

Oracle hygiene (PGP-08): Every parse failure returns Error::InvalidMaterial { variant: "pgp_key", reason } with a short curated reason literal — never an rpgp internal type or message. Audit set: "ASCII-armored input rejected — supply binary packet stream", "PgpKey must contain exactly one primary key; keyrings are not supported in v1.1 (found N primary keys)" (N substituted), "malformed PGP packet stream", "trailing bytes after PGP packet stream", "accessor called on wrong variant". A test (tests/pgp_error_oracle.rs) enumerates each × 4 variants and asserts Display contains none of {pgp::errors, PgpError, pgp::packet, packet::Error, pgp::Error, rpgp} plus the Phase 6 X.509 forbidden-token set.

ssh_key wire form (cipherpost/v1.1, Phase 7 Plan 05+):

{"type": "ssh_key", "bytes": "<base64-STANDARD-padded>"}
Field Type Wire encoding Description Source decision
type String literal "ssh_key" Variant discriminator D-WIRE-03
bytes String base64-STANDARD, padded — canonical OpenSSH v1 PEM bytes (UTF-8) OpenSSH v1 private-key blob D-P7-10..16, SSH-01..10

The bytes field carries the canonical OpenSSH v1 PEM blob (UTF-8) produced by re-encoding the user's input through ssh-key's PrivateKey::to_openssh(LineEnding::LF) at ingest time (D-P7-11). Because OpenSSH v1 framing has historically tolerated several superficial encoding variations (CRLF vs LF, different line widths, whitespace trailers from text-editor saves), cipherpost re-encodes to a single canonical byte stream so share_ref is deterministic across re-sends of semantically identical keys.

CLI input MUST be OpenSSH v1 (-----BEGIN OPENSSH PRIVATE KEY-----). Other formats are REJECTED at ingest with the distinct Error::SshKeyFormatNotSupported variant (D-P7-12 — separate from Error::InvalidMaterial because the user-facing message embeds a copy-pasteable ssh-keygen -p -o -f <path> conversion hint that is variant-specific). Specifically rejected formats:

  • Legacy PEM: -----BEGIN RSA/DSA/EC PRIVATE KEY-----
  • RFC 4716 SSH2: ---- BEGIN SSH2 ENCRYPTED PRIVATE KEY ----
  • OpenSSH-FIDO: -----BEGIN OPENSSH-FIDO PRIVATE KEY-----
  • Arbitrary garbage / empty input

The Display of Error::SshKeyFormatNotSupported intentionally omits BOTH the rejected format name (avoiding an info-disclosure oracle: "your input looked like RSA-PEM") AND any ssh-key crate internal types — it is a single static literal pointing the user at ssh-keygen -p -o. Maps to exit 1.

Trailing bytes after the -----END OPENSSH PRIVATE KEY----- marker are also REJECTED (T-07-39 / WR-01 mirror) with Error::InvalidMaterial { variant: "ssh_key", reason: "trailing bytes after OpenSSH v1 blob" } — guards against attacker-appended trailers drifting share_ref. Whitespace-only trailers from text-editor saves are tolerated (sliced off before parse).

Parser: ssh-key 0.6.7 with default-features = false, features = ["alloc"]. The ed25519 feature is INTENTIONALLY OFF — D-P7-10 verified that Ed25519 parsing + Fingerprint::compute(HashAlg::Sha256) work without it (sha2 is unconditional; only the ed25519-dalek interop TryFrom impls are gated). This keeps the dep tree clean: ssh-key adds NO new ed25519-dalek version beyond the existing pgp 0.19.0-transitive 2.x and pkarr-direct 3.0.0-pre.5 (verified by tests/x509_dep_tree_guard.rs::dep_tree_ssh_key_does_not_pull_ed25519_dalek_2_x_independently).

Oracle hygiene (SSH-08): Every parse failure returns either Error::SshKeyFormatNotSupported (format-rejection class) OR Error::InvalidMaterial { variant: "ssh_key", reason } with a short curated reason literal — never an ssh-key crate internal type or message. Audit set: "malformed OpenSSH v1 blob", "trailing bytes after OpenSSH v1 blob", "accessor called on wrong variant". A test (tests/ssh_error_oracle.rs) enumerates each × 4 variants and asserts Display contains none of {ssh_key::Error, ssh_key::, ssh_encoding, ssh_cipher, PemError, ssh-key::}.

SHA-256-only fingerprint policy (D-P7-14): The acceptance-banner subblock (§5.2) renders ONLY the SHA-256 fingerprint via Fingerprint::Display (format SHA256:<base64-unpadded>, matching ssh-keygen -lf byte-for-byte). MD5 and SHA-1 fingerprints are NOT rendered — both are deprecated per OpenSSH 7.0+ release notes; surfacing them would invite users to verify against legacy outputs that share-collide.

Algorithm-deprecation [DEPRECATED] tag (D-P7-14): When the parsed key's algorithm is ssh-dss (any size) or ssh-rsa with bit length below 2048, the banner Key line is suffixed with [DEPRECATED]. The tag is display-only — it does NOT block acceptance; senders MAY legitimately migrate legacy infrastructure. The visible warning gives the recipient a chance to question the handoff before the typed-z32 prompt completes.

3.3 OuterRecord

Published as a JSON TXT record under DNS label _cipherpost (D-05) on the sender's derived key derive(sender_pub, share_ref) (§3.8) — not on the sender's bare identity key, as in v1.1. Each share thus gets its own PKARR packet, lifting the v1.1 one-outstanding-share-per- sender ceiling. The TXT value is the JSON serialization of OuterRecord. Inner signature is Ed25519 over JCS(OuterRecordSignable) by the sender's identity key (the derived key is used only for the outer BEP44 packet signature, §3.8); outer signature is the PKARR SignedPacket signature under the derived key. OuterRecord.pubkey remains the sender's real identity z32 (the recipient checks it against the URL's <sender-z32>, §4).

Field Type Wire encoding Description Source decision
blob String base64-STANDARD age-encrypted JCS bytes of Envelope D-WIRE-01, D-WIRE-04
created_at i64 JSON integer Unix seconds, inner-signed; single TTL source D-WIRE-02
protocol_version u16 JSON integer Always 2 (was 1 in v1.x) D-07
pubkey String z-base-32, 52 chars Sender's identity Ed25519/PKARR public key (NOT the derived address; the recipient checks it against the URL) D-04, IDENT-05
recipient String OR JSON null z-base-32 OR null Recipient pubkey; null for --self sends D-WIRE-04
share_ref String 32 lowercase hex chars 128-bit share ID: sha256(blob_bytes ‖ created_at_be_bytes)[..16] D-06, PAYL-05
signature String base64-STANDARD Inner Ed25519 signature over JCS(OuterRecordSignable) D-WIRE-03, SEND-04, D-16
ttl_seconds u64 JSON integer Share lifetime; default 86400 (24 h) D-WIRE-02, SEND-03

Signable projection: OuterRecordSignable = OuterRecord minus signature. JCS-canonicalize it, then Ed25519-sign. On verify, the receiver rebuilds OuterRecordSignable from the received OuterRecord, re-serializes to JCS, and performs verify_strict against the decoded signature, then additionally checks that re-serialization of the parsed input yields byte-identical JCS output (canonicalization-bypass guard, defense against non-canonical-but-parseable input).

share_ref derivation (D-06, PAYL-05):

share_ref_bytes = SHA-256(ciphertext_blob_bytes || created_at_i64_big_endian_bytes)[0..16]
share_ref_hex   = lowercase_hex(share_ref_bytes)  // 32 chars

where ciphertext_blob_bytes = raw bytes obtained by base64-STANDARD-decoding blob (i.e., the age ciphertext, not the base64 string), and created_at_i64_big_endian_bytes = i64::to_be_bytes(created_at) (8 bytes).

3.4 Receipt

Published by the recipient under the derived key derive(recipient_pub, share_ref) at the fixed DNS label _cprcpt (D-06; v2 derived-key addressing — the KEY encodes the share_ref, so the label no longer carries a suffix). Signed with the recipient's Ed25519 identity key. Receipts are public by design: no field is secret. Senders verify receipts using only public information (the recipient pubkey is supplied as verify context, see §3.4 signable).

Field Type Wire encoding Description Source decision
accepted_at i64 JSON integer Unix seconds when acceptance completed D-RS-02
ciphertext_hash String lowercase hex, 64 chars SHA-256(blob_base64_decoded_bytes) D-RS-04
cleartext_hash String lowercase hex, 64 chars SHA-256(JCS(Envelope)) — the decrypted canonical bytes D-RS-04
protocol_version u16 JSON integer Always 2 D-07
share_ref String 32 lowercase hex chars Same share_ref as the originating OuterRecord D-RS-01, D-06
signature String base64-STANDARD Ed25519 by recipient over JCS(ReceiptSignable) D-RS-05, D-RS-07

Slim v2 schema — no nonce, recipient_pubkey, or sender_pubkey. Under v2 derived-key addressing the receipt lives under its own key derive(recipient_pub, share_ref), so the recipient pubkey is verify/addressing CONTEXT — passed to verify_receipt(receipt, recipient_pub_z32) — not a wire field. The sender pubkey and the per-record nonce are dropped too (the derived key already makes each receipt's DHT address unique). This slim schema and the PROTOCOL_VERSION 1→2 bump ship together in v2.

No purpose field (changed 2.0.0-alpha). Earlier receipts carried a verbatim copy of Envelope.purpose. Because the receipt is published in cleartext on the public DHT, that leaked a signed, timestamped, descriptive social graph of secret handoffs — contradicting the envelope's encrypted-metadata guarantee. purpose is removed; it remains bound but not exposed via cleartext_hash = SHA-256(JCS(Envelope)), so a sender holding the original envelope can still verify a receipt corresponds to their exact purpose. Residual exposure (unavoidable for a public signed receipt): the receipt JSON reveals share_ref, accepted_at, and the two hashes in cleartext. Neither pubkey is a field anymore, but the receipt is addressed at derive(recipient_pub, share_ref) — so a party that already holds both the recipient pubkey and the share_ref (e.g. the sender) can locate and read it, learning that a handoff was accepted and when. A party lacking the share_ref cannot enumerate a recipient's receipts under the derived key (an improvement over v1's per-recipient _cprcpt-* enumeration). See THREAT-MODEL §Receipt privacy.

Signable projection: ReceiptSignable = Receipt minus signature. Same sign/verify discipline as OuterRecordSignable (D-RS-07).

Receipt publication (v2 derived-key addressing): The receipt is published under the blinded key derive(recipient_pub, share_ref) at label _cprcpt. Because each derived key is written by a single writer and holds exactly one record, there is no resolve-merge-republish and no CAS on this path — the receipt is signed (BEP44 hand-signed under the blinded key, which pkarr cannot sign directly from a seed-only keypair) and published as a single record. This lifts v1's packet-budget ceiling, where receipts shared the recipient's parent PKARR packet with outgoing shares and prior _cprcpt-* receipts and any two real records exceeded the ~1000-byte budget (the old Error::PacketBudgetExceeded merge path — D-MRG-01..06). DNS TTL on the receipt record = 300 seconds. See §3.8 for the derivation and the v1.1→v2 addressing migration, §3.5 for label stability, and §5.3 for the sender-side fetch (now --share-ref-keyed).

Publish sequencing (D-SEQ-01): The recipient publishes the receipt only AFTER local state commits (sentinel file + ledger line). Publish failure is degraded to a stderr warning with exit code 0 (D-SEQ-02) — the material was delivered safely; receipt loss is a sender-visible degradation. No auto-retry (D-SEQ-03).

3.5 DHT Label Stability

The DNS TXT record labels used in the wire format are part of the protocol surface. Under v2 derived-key addressing (§3.8) each record lives under its own derived key, so the label no longer carries the share_ref (the KEY encodes it):

  • _cipherpost — carried by OuterRecord (§3.3), published under derive(sender_pub, share_ref)
  • _cprcpt — carried by Receipt (§3.4), published under derive(recipient_pub, share_ref) (fixed label; no -<share_ref_hex> suffix, unlike v1.1)

These label strings are part of the wire format. Renaming either — in whole or in part — requires a protocol_version bump and a migration section in this SPEC. They are not changed silently. The v2 receipt label _cprcpt and the v1.1 legacy _cprcpt-<share_ref_hex> are distinct addressing schemes at different keys and do not interoperate (§3.8, migration §3.8).

Code constants enforcing these labels live in src/lib.rs (DHT_LABEL_OUTER = "_cipherpost", DHT_LABEL_RECEIPT = "_cprcpt", legacy DHT_LABEL_RECEIPT_PREFIX = "_cprcpt-"); a constant-match test (tests/dht_label_constants.rs) fails if code and SPEC drift.

3.6 PIN Crypto Stack (Phase 8 — D-P8-01..06)

PIN-protected shares (OuterRecord.pin_required = true) require BOTH the receiver's identity passphrase AND a PIN to decrypt. The PIN is a second factor layered via NESTED age encryption (CLAUDE.md chacha20poly1305 only via age invariant — no direct AEAD calls). Non-pin shares (pin_required absent or false) keep the pin_required field elided from JCS via skip_serializing_if = is_false, so a non-pin v2 record's bytes differ from a pin v2 record's only by that field. (In v1.x this elision also preserved v1.0 byte-identity; under v2 that lock-in is retired — every record now encodes protocol_version: 2, so v2 bytes are not byte-identical to v1.0 regardless.)

Architectural lineage: Forks cclink's pin_derive_key shape verbatim; diverges on AEAD path (cclink uses raw chacha20poly1305; cipherpost wraps the derived 32-byte scalar into an age::x25519::Identity and uses age::Encryptor::with_recipients). HKDF namespace adapted from cclink-pin-v1 to cipherpost/v1/pin per cipherpost's domain-separation convention (every HKDF info string starts with cipherpost/v1/; verified by tests/hkdf_info_enumeration.rs).

KDF parameters (locked):

  • Argon2id version 1.3 (V0x13): 64 MiB memory (m_cost=65536), 3 iterations, 1 lane (parallelism=1), 32-byte output. Distinct lifecycle from the identity-KEK Argon2id params (which are READ FROM the identity file's PHC header per Pitfall #8). PIN params are share-level constants; bumping them requires a protocol_version bump.
  • HKDF-SHA256: salt = the same 32-byte random salt; ikm = Argon2id 32-byte output; info = "cipherpost/v1/pin" (referenced via crate::crypto::hkdf_infos::PIN constant — NEVER inline-literal). Output: 32 bytes used as an X25519 scalar.
  • age Identity: built from the 32-byte X25519 scalar via crate::crypto::identity_from_x25519_bytes. The to_public() recipient becomes the inner age-encryption recipient.

Wire blob layout:

  • Non-pin shares: blob = base64-STANDARD(outer_age_ct) — exact v1.0 byte shape.
  • PIN shares: blob = base64-STANDARD(salt[32] || outer_age_ct) — salt is the FIRST 32 raw bytes (read BEFORE any age-decrypt to derive pin_recipient).

Nested age structure (PIN shares only):

  1. inner_ct = age_encrypt(envelope_jcs, pin_recipient)
  2. outer_ct = age_encrypt(inner_ct, receiver_recipient)
  3. blob = base64(salt || outer_ct)

Receive flow ordering (D-P8-07). PIN shares extend the §5.2 13-step pipeline with a PIN dispatch step (6a) inserted BETWEEN outer-verify (steps 2/3) and outer age-decrypt (step 7). The TAMPER-ZERO INVARIANT is preserved: outer-verify gates the PIN prompt, so a tampered share never reaches the prompt — exit 3 sig failure with no PIN-prompt side effect. Concretely, when record.pin_required = true, run_receive: (i) base64-decodes the blob, (ii) splits the first 32 bytes as the salt, (iii) calls prompt_pin(false) (no echo, single-shot — wrong PIN is the user's notification rather than a re-prompt), (iv) derives pin_identity from PIN+salt, (v) age-decrypts the outer ciphertext with the receiver identity to produce inner_ct, (vi) age-decrypts inner_ct with pin_identity to produce envelope_jcs. Only THEN does the §5.2 step 8 acceptance prompt run.

Error-oracle constraint (PIN-07). Wrong-PIN, wrong-passphrase, and tampered inner-ciphertext all surface as Error::DecryptFailed with the IDENTICAL user-facing Display ("wrong passphrase or identity decryption failed") and exit code 4. Sig-failures (Error::Signature*, exit 3) remain a DIFFERENT lane — distinguishable by exit code, but both lanes preserve user-facing Display equality WITHIN their lane (D-16 invariant for sig lane; PIN-07 narrow invariant for credential lane).

Entropy floor (PIN-02). PIN must be ≥ 8 characters, not all-same characters, not monotonic ascending, not monotonic descending, and not in the blocklist (password, qwerty, letmein, 12345678, 87654321, qwertyui, asdfghjk — case-insensitive). Rejection is GENERIC ("PIN does not meet entropy requirements", exit 1) — the specific reason is NEVER named in user-facing output (oracle hygiene per PITFALLS #23/#24; supersedes REQUIREMENTS PIN-02 wording per D-P8-12). The specific reason IS asserted at the test layer (tests/pin_validation.rs::rejects_*), so implementations remain testable. Length validation runs BEFORE Argon2id so length-failures don't leak via wall-clock timing (T-08-15).

Threat model. See THREAT-MODEL.md §6.5 PIN mode for the threat-model treatment — second-factor semantics, offline brute-force bound, intentional indistinguishability invariant, and multi-machine non-coordination caveat. PIN composes orthogonally with --burn (§3.7); the two flags are independent and neither silently overrides the other.

3.7 Burn Semantics (Phase 8 — D-P8-04, D-P8-09..12)

Burn shares (Envelope.burn_after_read = true) are single-consumption from the receiver's perspective. After a successful first receive, the local ledger records state: "burned" and any subsequent receive against the same share_ref returns exit 7 (Error::Declined) with stderr message share already consumed (burned at <timestamp>).

Architectural choice — local-state-only. Cipherpost burn is local-state-only and explicitly REJECTS cclink's burn pattern (which publishes an empty PKARR packet over the share's DHT slot to revoke it). Two reasons:

  1. Honest threat model. Public DHT ciphertext cannot be force-deleted; it remains queryable until TTL expires (24h default). Cryptographic destruction is impossible without the receiver's identity passphrase.
  2. No DHT mutation. Burn is a receiver-side semantics knob; mutating the DHT to enforce it would couple two threat surfaces unnecessarily, and would be ineffective against any observer who already cached the ciphertext.

Wire shape:

  • Envelope.burn_after_read: bool — inner-signed, post-decrypt. DHT observers do NOT see this field (CLAUDE.md ciphertext-only-on-wire principle).
  • #[serde(default, skip_serializing_if = "is_false")]burn_after_read is elided from JCS when false, so a non-burn record's bytes differ from a burn record's only by that field. (In v1.x this preserved v1.0 byte-identity; under v2 that lock-in is retired — records encode protocol_version: 2.)
  • JCS alphabetic placement: FIRST (before created_at because b < c).
  • Pinned by tests/fixtures/envelope_burn_signable.bin (~142 B).

Receive flow ordering (D-P8-12 emit-before-mark for burn). §5.2's 13-step pipeline gains a STEP 1 ledger pre-check (Phase 8 Plan 03) and an emit-then-mark dispatch at STEP 11/12 (Phase 8 Plan 04):

  1. STEP 1 — early ledger pre-check returns LedgerState:
    • LedgerState::Burned { burned_at } → return Error::Declined (exit 7); stderr share already consumed (burned at <ts>); NO new receipt published.
    • LedgerState::Accepted { ... } → existing v1.0 idempotent-success path (no re-decrypt, no new emit, no new receipt; exit 0).
    • LedgerState::None → proceed. 2-10. Standard receive flow (outer-verify → optional PIN prompt per §3.6 → nested age-decrypt when pin_required → inner-verify → JCS parse → typed-material preview render → acceptance banner with optional [BURN — you will only see this once] marker at TOP → typed-z32 acceptance prompt). The marker emits ONLY when burn_after_read=true; non-burn shares see the v1.0 banner shape verbatim.
  2. Emit decrypted bytes to stdout / file / sink.
  3. create_sentinel(&share_ref) then ledger row write. The dispatch selects the helper by envelope.burn_after_read:
    • Burn flow: append_ledger_entry_with_state(Some("burned"), ...). Crash sequence guarantee: emit (STEP 11) → sentinel (STEP 12 part 1) → ledger row with state: "burned" (STEP 12 part 2). A crash between STEP 11 (emit) and the ledger write leaves the share re-receivable on next invocation — this is the safer failure mode (the user keeps access to their data) compared to mark-then-emit, which would lose the user's data to a half-completed state write.
    • Accepted flow (v1.0 unchanged): append_ledger_entry(...). The ledger row has no state field; deserializes via serde default to state: None and maps to LedgerState::Accepted on read.
  4. Publish receipt — unconditional in v2, including self-shares (D-SEQ-06 RE-ENABLED). The receipt is published under its own derived key derive(recipient_pub, share_ref) (§3.8), so it no longer collides with the recipient's outgoing _cipherpost share or with any other receipt — the v1.1 reason for skipping self-shares (a single shared ~1000-byte parent packet) is gone. A self-share now yields a normal personal audit receipt, and the send --self → receive → send --self self-backup workflow is preserved because the share and its receipt occupy different derived keys. Burn does NOT suppress attestation (there is no if !envelope.burn_after_read guard) — receipt = delivery confirmation. Because each receipt has its own single-record packet, a recipient can hold many outstanding receipts (one per share_ref); the v1.1 "at most one outstanding receipt" ceiling and its PacketBudgetExceeded warn+degrade on the receipt path no longer apply. Publish remains best-effort: any failure is warn+degraded to a stderr line with exit 0 (D-SEQ-02, §5.2 step 13). Asserted by tests/phase3_coexistence_b_self_share_and_receipt.rs and tests/phase3_share_ref_filter.rs.

Burn ≠ cryptographic destruction. A second machine with a fresh ledger can still decrypt the same share until TTL expires. Burn IS:

  • A safeguard against accidental re-decryption on the same machine.
  • A signal of intent (the sender wanted single-consumption).

Burn is NOT:

  • Cryptographic erasure of the DHT ciphertext.
  • Multi-machine consumption coordination.
  • A replacement for TTL-based ciphertext expiry.

See THREAT-MODEL.md §Burn mode for the multi-machine race threat analysis (Plan 06 lands the prose).

PITFALLS.md #26 supersession. Phase 8's emit-before-mark write order supersedes the original mark-then-emit analysis in .planning/research/PITFALLS.md section #26. The header in that file documents the resolution; the original analysis is preserved as the rejected alternative. The rejection rationale: data-loss-on-crash is the worst outcome for burn (one-shot consume), while re-receivable-on-crash is acceptable (the share is still TTL-bounded, and the user keeps their data). v1.0's accepted flow keeps mark-then-emit unchanged (re-emit on crash is fine for the idempotent-persistence contract).

PIN × BURN compose orthogonality (D-P8-13). PIN and BURN are independent flags. PIN lives on OuterRecord.pin_required (outer-signed, DHT-visible — see §3.6); BURN lives on Envelope.burn_after_read (inner-signed, post-decrypt). A share can carry both flags simultaneously without collision; neither flag silently overrides the other.

Threat model. See THREAT-MODEL.md §6.6 Burn mode for the threat-model treatment — multi-machine race, DHT-survives-TTL, burn ≠ cryptographic destruction, and the emit-before-mark atomicity invariant. .planning/research/PITFALLS.md #26 carries the SUPERSEDED-by-D-P8-12 header preserving the rejected mark-then-emit alternative.

3.8 Derived-Key Addressing (v2)

v2 publishes each share and each receipt under its own per-record key derived from the parent's public key and the share_ref, rather than under a shared label (_cipherpost / _cprcpt-<ref>) on the parent identity key (as v1.1 did). A PKARR SignedPacket is per-key and capped at ~1000 bytes (BEP44), and any two real cipherpost records exceed that budget — so on the parent key a key held effectively one record (one outstanding share per sender, one receipt per identity). Giving each record its own derived key gives it its own packet and lifts that ceiling. The counterparty derives the same key from public inputs it already holds, so no discovery index is published (PRD constraint: no servers, no operator).

This is a frozen v2 wire contract. A re-implementation MUST reproduce the derivation byte-for-byte or it will publish/resolve at the wrong key. PROTOCOL_VERSION is 2. Source of truth: src/derive.rs (derive_public, derive_signer) and src/transport.rs (build_derived_signed_packet, bep44_signable); golden vector in §8.4.

Derivation — single-hop stealth blinding (Monero-subaddress shape). Given the parent Ed25519 public key A (= a·G, where a is the parent's clamped identity scalar) and a 128-bit share_ref:

t   = reduce_mod_ℓ( SHA-512( DERIVE_DOMAIN ‖ A_bytes ‖ raw16(share_ref) ) )   // public tweak
A'  = A + t·G                                                                  // derived public key (public derivation)
a'  = a + t   (mod ℓ)                                                          // derived scalar (secret side only)
  • DERIVE_DOMAIN = the ASCII bytes cipherpost/v2/derive-addr (domain separation; distinct from the HKDF cipherpost/v1/ info-string namespace, which is unrelated and unchanged).
  • raw16(share_ref) is the RAW 16 bytes decoded from the 32-char lowercase-hex share_refNOT the 32 ASCII-hex bytes. The derive API takes the hex string and decodes it internally, so callers cannot accidentally hash the ASCII form (which would produce a valid-signing but unresolvable address — a silent NotFound). Hashing the raw 16 bytes is the frozen contract; a re-implementation that hashes the hex string is non-conformant.
  • t is reduced mod ℓ via Scalar::from_bytes_mod_order_wide over the 64-byte SHA-512 output (a reduction, not a clamp). t and A' are public — anyone with (A, share_ref) computes A' (the no-index property). a' is computable only by the parent-secret holder (needs a, from the master seed via SigningKey::to_scalar()) and is never published — only signatures are.
  • Signing nonce prefix (secret): prefix' = SHA-512( DERIVE_PREFIX_DOMAIN ‖ master_hash_prefix ‖ raw16(share_ref) )[..32], where DERIVE_PREFIX_DOMAIN = cipherpost/v2/derive-prefix and master_hash_prefix is the second half of SHA-512(seed) (via ExpandedSecretKey). Deriving the nonce prefix from a secret keeps per-key signing nonces unpredictable.

Signing seam (pkarr cannot sign under a blinded key). pkarr::Keypair is seed-only with no raw-scalar constructor, so the derived-key packet is hand-signed:

  1. Build the DNS packet (single TXT record) with names normalized to the derived origin A'.to_z32(); encode with pkarr's compressed encoder.
  2. Compute the BEP44 signable bytes exactly as pkarr does: signable(ts, v) = b"3:seqi" ‖ ascii(ts) ‖ b"e1:v" ‖ ascii(v.len()) ‖ b":" ‖ v.
  3. sig = ed25519_dalek::hazmat::raw_sign::<Sha512>(&esk, &signable, &A') with esk = { scalar: a', hash_prefix: prefix' }; self-verify A'.verify_strict(&signable, &sig) before use (mandatory — raw_sign leaks the scalar if the passed public key ≠ a'·G).
  4. Assemble payload = sig(64) ‖ ts_be(8) ‖ encoded_packet and construct the packet via the public SignedPacket::from_relay_payload(&A', &payload), which re-verifies the BEP44 signature under A' — so successful assembly is itself a correctness gate.

Verification path (fetch side, public derivation). The counterparty derives A' from the parent pubkey + share_ref (no secret), resolves the packet at A', verifies the BEP44 signature under A', then applies the existing dual-signature discipline over the record body (inner Ed25519 by the parent identity key — the derived key is used only for the outer packet). Because A' is a deterministic function of (parent_pub, share_ref), a valid packet at A' cryptographically binds the record to that parent and that share_ref (defense-in-depth alongside the share_ref field check).

Security properties.

  • Unclamped a' is safe. a' is not a cofactor multiple, but Ed25519 verification checks S·G = R + H(R,A',M)·A', which holds for A' = a'·G regardless of clamping. A' = a'·G is always in the prime-order subgroup, so it is canonical and passes verify_strict — no small-subgroup exposure via the public key.
  • No secret leak. t/A' are public; recovering a = a' − t needs a', never published. Standard additive-blinding / stealth-address security for a single hop with a hashed, per-record tweak.
  • Unlinkability (privacy win). A passive DHT observer who does not know share_ref cannot link A' back to A — a recipient's receipt keys are no longer enumerable from their public identity (contrast v1.1's _cprcpt-* records all under one key). Only a party holding share_ref (the counterparty, or anyone with the URI) can derive A'.

Migration (v1.1 → v2). This changes addressing only: v1.1 and v2 records live at different keys and do not interoperate — a clean break, PROTOCOL_VERSION 1→2. There is no in-place migration; v1.1 records are ephemeral (TTL hours/day) and age out. The legacy _cprcpt-<share_ref> parent-key receipt label remains documented as legacy, but the v1.1 publish_receipt / resolve_all_cprcpt Transport methods were removed at v2.

4. Share URI

A share URI is a single copy-paste token that identifies where to resolve a share and what share_ref to expect:

cipherpost://<sender-z32>/<share_ref_hex>
  • <sender-z32> is the sender's identity PKARR public key in z-base-32 (52 chars) — matches OuterRecord.pubkey. In v2 the receiver derives the resolution key derive(<sender-z32>, <share_ref_hex>) (§3.8) from these two components; the URI itself is unchanged from v1.1 (the derivation is a resolve-time computation, not a URI-format change).
  • <share_ref_hex> is the 32-char lowercase hex share_ref.
  • Total length ≈ 99 characters.

Example:

cipherpost://yhigci4xwmadibrmj8wzmf45f3i8xg8mht9abnprq3r5cfxihj8y/0123456789abcdef0123456789abcdef

The receiver MUST require the full cipherpost:// URI form. Bare z-base-32 input is rejected with Error::InvalidShareUri (D-URI-03). After resolving OuterRecord, the receiver MUST check that url_share_ref == OuterRecord.share_ref; mismatch yields Error::ShareRefMismatch (D-URI-02; exit code 1, distinct from signature failures exit 3 and NotFound exit 5).

No query string or fragment parameters are defined in cipherpost/v2; unknown trailing components MUST be treated as Error::InvalidShareUri. Future versions may extend the URI syntax under a bumped protocol version.

5. Flows

5.1 Send

  1. Read payload from <path> or - (stdin). (SEND-01, PAYL-03)
  2. Ingest (cipherpost/v1.1): dispatch on --material <variant> (default generic-secret). Accepted values: generic-secret, x509-cert, pgp-key (Phase 7 Plan 01-04 — LIVE), ssh-key (Phase 7 Plan 05-08 — LIVE).
    • payload::ingest::x509_cert(raw) sniffs PEM vs DER (ASCII-whitespace- trim + -----BEGIN CERTIFICATE----- header check), normalizes PEM → canonical DER, and validates via x509-parser strict profile with an explicit trailing-bytes check.
    • payload::ingest::pgp_key(raw) strict-rejects ASCII armor (any -----BEGIN PGP prefix after whitespace skip), iterates top-level packets via pgp::packet::PacketParser, counts top-level Tag::PublicKey + Tag::SecretKey packets (rejects keyrings with N substituted), and asserts the sum of per-packet serialized lengths equals raw.len() (trailing-bytes invariant resilient to rpgp's silent-0xFF parser quirk). Returns Material::PgpKey { bytes: raw.to_vec() } with no canonical re-encode — the binary packet stream IS canonical (RFC 4880 §4.2).
    • payload::ingest::ssh_key(raw) strict-rejects non-OpenSSH-v1 input with Error::SshKeyFormatNotSupported (legacy PEM RSA/DSA/EC, RFC 4716 SSH2, OpenSSH-FIDO, garbage), checks for trailing bytes after the -----END OPENSSH PRIVATE KEY----- marker, parses via ssh-key's PrivateKey::from_openssh, and re-encodes canonically via to_openssh(LineEnding::LF) (D-P7-11). Returns Material::SshKey { bytes: <canonical OpenSSH v1 PEM bytes> }.
    • Parse failure → Error::InvalidMaterial { variant, reason } exit 1.
    • SSH-specific format-rejection → Error::SshKeyFormatNotSupported exit 1 (distinct variant; user message embeds ssh-keygen -p -o -f <path> hint).
  3. Plaintext cap (D-P6-16 / X509-06): reject if material.plaintext_size() > 65 536. For x509_cert, this is the decoded DER length — a 1 MB PEM input that decodes to 100 KB DER fails the cap on the decoded size, not the input size (PAYL-03). For pgp_key, this is the raw binary packet-stream length (no PEM-style decode applies; armor is rejected). For ssh_key, this is the canonical re-encoded UTF-8 PEM byte length (i.e., the bytes stored in Material::SshKey after to_openssh(LineEnding::LF), not the raw input).
  4. Build Envelope { purpose, material, created_at, protocol_version } with purpose control- stripped (D-WIRE-05). JCS-serialize.
  5. age-encrypt the JCS bytes to the recipient's X25519 (derived from their Ed25519 pubkey) or to the sender's own X25519 for --self (SEND-01, SEND-02). Base64-STANDARD-encode to produce blob.
  6. Compute share_ref = sha256(ciphertext_blob_bytes || created_at.to_be_bytes())[..16] (D-06).
  7. Build OuterRecordSignable { blob, created_at, protocol_version, pubkey, recipient, share_ref, ttl_seconds }.
  8. JCS-serialize OuterRecordSignable; Ed25519-sign with the sender's identity key; base64- encode to produce signature. Assemble OuterRecord (D-WIRE-03, SEND-04).
  9. Derive the sender's per-share key A' = derive(sender_pub, share_ref) (§3.8) and build a PKARR SignedPacket with a single TXT record under _cipherpost carrying the OuterRecord JSON, hand-signed under A' (BEP44, §3.8). Verify encoded SignedPacket size ≤ ~1000 bytes (BEP44 budget, SEND-05). Overflow = Error::WireBudgetExceeded.
  10. Transport::publish_derived(&derived_signer, "_cipherpost", &outer_record_json) — publishes under A', not the sender's bare identity key. Print the share URI (cipherpost://<sender-identity-z32>/<hex>) to stdout; the URI carries the sender's identity z32, from which the recipient re-derives A' to resolve (D-URI-01, SEND-01).

CLI flags (cipherpost/v1.1):

  • --material <VALUE> (default generic-secret) — selects the typed Material variant. Accepted: generic-secret (Phase 5), x509-cert (Phase 6), pgp-key (Phase 7 Plan 01-04 — LIVE), ssh-key (Phase 7 Plan 05-08 — LIVE).

--material pgp-key example:

cipherpost send --self -p 'alice keyshare' --material pgp-key --material-file ./alice.pgp

--material ssh-key example:

cipherpost send --self -p 'server bootstrap' --material ssh-key --material-file ./id_ed25519

--armor matrix (cipherpost/v1.1, FINAL):

--material --armor accepted? Behavior
x509-cert YES wraps as PEM -----BEGIN CERTIFICATE----- (Phase 6)
pgp-key YES wraps as ASCII armor via rpgp to_armored_bytes (Phase 7 Plan 03)
generic-secret NO Error::Config("--armor requires --material x509-cert or pgp-key") exit 1 (Plan 03 widened literal)
ssh-key NO Error::Config("--armor not applicable to ssh-key — OpenSSH v1 is self-armored") exit 1 (Plan 07; D-P7-13 — variant-specific rationale because OpenSSH v1 is ALREADY armored, wrapping again would produce nonsense)

Both --armor rejection literals fire BEFORE the preview parse runs (cost-on-error

  • pre-emit surface hygiene per D-RECV-01 / T-07-49).

--pin (cipherpost/v1.1, Phase 8 PIN-01): Require a PIN as a second factor. Bool flag — clap rejects argv-inline --pin <value> naturally (no Option<String> shape). PIN is read from TTY at send time with double-entry confirmation (prompt_pin(confirm=true) — a typo'd PIN bricks decryptability, so confirmation matches the rationale of identity generate's confirm_on_tty=true). The receiver is prompted at receive time (single-shot — wrong PIN funnels through Error::DecryptFailed exit 4, the user's notification). Both the receiver's identity passphrase AND the PIN are required to decrypt PIN-protected shares. Non-interactive PIN sources (--pin-file, --pin-fd, CIPHERPOST_PIN env) are deferred to a future release — v1.x keeps PIN as an intentionally human-in-the-loop second factor. PIN entropy validation runs at send time and rejects with exit 1 / generic "PIN does not meet entropy requirements" Display (oracle hygiene per PIN-07; specific reason is NEVER named). See §3.6 for the full KDF + wire-blob layout + receive-flow ordering.

cipherpost send --pin --self -p 'high-value backup' --material-file ./vault.key

--burn (cipherpost/v1.1, Phase 8 BURN-01): Mark the share as single-consumption. Bool flag. Sets Envelope.burn_after_read = true (inner-signed, post-decrypt — DHT observers cannot distinguish burn-marked shares from regular shares on the wire). Send-time stderr surfaces a warning that burn is local-state-only (BURN-05) — different machines with fresh ledgers can each decrypt the share once until TTL expires; burn is NOT cryptographic destruction. Receive-time prepends a [BURN — you will only see this once] marker above the acceptance banner (D-P8-08). On the FIRST successful receive, the local ledger writes state: "burned"; subsequent receives against the same share_ref return exit 7 (Error::Declined) with stderr message share already consumed (burned at <ts>). Receipt publication is UNCONDITIONAL on burn-receive (BURN-04 — burn does not suppress attestation). Composes orthogonally with --pin (D-P8-13); see §3.7 for the receive-flow ordering, emit-before-mark atomicity contract, and the ledger row schema migration path.

cipherpost send --burn --self -p 'one-shot bootstrap token' --material-file ./token.txt
cipherpost send --pin --burn --self -p 'pin+burn compose' --material-file ./secret.bin

5.2 Receive

Strict order (D-RECV-01 + D-SEQ-01 combined — 13 steps):

  1. Parse URI; extract sender_z32 and url_share_ref. Malformed → Error::InvalidShareUri (D-URI-03).

    cipherpost/v1.1.x: Per-share_ref advisory lock (Quick 260427-axn). Immediately after URI parse and BEFORE step 2's idempotency check, run_receive acquires an exclusive flock on ~/.cipherpost/state/locks/<url_share_ref>.lock (file mode 0600, directory mode 0700). The lock spans steps 2–12 (idempotency check → resolve → verify → decrypt → accept → emit → sentinel + ledger row) and is released BEFORE step 13's receipt publish. In v2 the receipt is published under its own derived key derive(recipient_pub, share_ref) (§3.8), a single-writer single-record packet with no CAS — so concurrent receives of different shares never contend on the receipt path, and a repeat receive of the same share short-circuits at step 2. (The v1.1 CAS contract (D-P9-A1) guarded the old shared-parent-packet receipt writes and was removed at v2; see §3 preamble.) The lock closes the same-host TOCTOU window where two concurrent cipherpost receive invocations on the same share_ref could both pass step 2's check_already_consumed, both decrypt + emit, and both append ledger rows. Lock granularity is per-share_ref, so distinct shares don't serialize. Lock-acquisition I/O failures collapse into Error::Io — no new public Error variant is introduced (Pitfall #16 oracle hygiene). At the top of each receive, a best-effort GC (prune_consumed_locks) unlinks lock files for already-consumed share_refs (those with a ledger row); this is race-safe because a consumed share short-circuits at step 2 before ever re-acquiring its lock, so locks/ stays bounded rather than growing one file per share_ref forever. Burn-flow emit-before-mark ordering (D-P8-12) is unchanged inside the lock; serialization is the only behavioral change. Cross-host coordination is still out of scope (D-STATE-01) — the lock is local-filesystem only. Regression coverage: tests/state_ledger_concurrency.rs (Barrier-synced accepted, burn, and distinct-share_ref cases).

  2. Check sentinel file at ~/.cipherpost/state/accepted/<url_share_ref>; if present, print prior acceptance timestamp and exit 0 (RECV-06, D-RECV-02, D-STATE-01). No network call.

    cipherpost/v1.1: BURN ledger pre-check (Phase 8 D-P8-09 / BURN-02). The sentinel step is augmented with a ledger-state probe: if the sentinel exists, look up the matching ledger row by share_ref. If the row carries state: "burned", return Error::Declined (exit 7) with stderr message share already consumed (burned at <timestamp>). v1.0 rows missing the state field deserialize via serde default to LedgerState::Accepted (T-08-17 conservative classification). See §3.7 for the receive-flow ordering and ledger schema details.

  3. Derive the sender's per-share key A' = derive(sender_pub, url_share_ref) (§3.8) and Transport::resolve_derived(&A', "_cipherpost") — returns OuterRecord only after the outer PKARR SignedPacket signature under A' passes (verified inside pkarr::ClientBlocking / on parse). Because A' is a deterministic function of (sender_pub, share_ref), a valid packet at A' cryptographically binds the record to that sender and that share_ref (defense-in-depth alongside step 5's field check). NotFound → exit 5.

  4. Verify inner Ed25519 signature on OuterRecord via verify_record (round-trip-reserialize guard included). Any signature failure → unified message, exit 3 (D-16, RECV-01).

  5. Check url_share_ref == OuterRecord.share_ref; mismatch → Error::ShareRefMismatch, exit 1 (D-URI-02).

  6. TTL check against OuterRecord.created_at + OuterRecord.ttl_seconds. Expired → exit 2 (RECV-02).

    cipherpost/v1.1: PIN dispatch (Phase 8 PIN-06). When OuterRecord.pin_required = true, step 6a runs after TTL and BEFORE step 7's age-decrypt: (a) base64-decode blob (≥ 32 bytes required, else Error::SignatureCanonicalMismatch exit 3 — same oracle- hygiene treatment as a malformed blob); (b) split first 32 bytes as the PIN salt; (c) prompt_pin(confirm=false) — TTY-only, single-shot, no echo (non-TTY context → Error::Config exit 1, no state mutation, no receipt published — share remains re-receivable when a PIN is later available); (d) derive pin_identity from PIN + salt via Argon2id + HKDF-SHA256 with info cipherpost/v1/pin (§3.6); (e) step 7's age-decrypt becomes NESTED: outer with the receiver identity produces inner_ct, inner with pin_identity produces envelope_jcs. Wrong PIN at the inner step → Error::DecryptFailed exit 4 with the IDENTICAL Display as a wrong identity-passphrase failure (PIN-07).

  7. age-decrypt OuterRecord.blob into a Zeroizing<Vec<u8>>. Decryption failure → exit 4 (RECV-03).

  8. Parse decrypted bytes as JCS → Envelope. JCS parse failure → Error::SignatureCanonicalMismatch, exit 3 (D-RECV-01 step 7).

  9. Render acceptance screen on stderr (D-ACCEPT-02). Layout:

    === CIPHERPOST ACCEPTANCE ===============================
    Purpose:     "<control-stripped purpose>"
    Sender:      ed25519:SHA256:<openssh-fingerprint>
                 <sender z32 52 chars>
    Share ref:   <32-char hex>
    Type:        generic_secret
    Size:        <N> bytes
    TTL:         <Xh Ym> remaining (expires <ISO UTC> / <local>)
    =========================================================
    To accept, paste the sender's z32 pubkey and press Enter:
    >
    

    cipherpost/v1.1: [BURN] banner marker (Phase 8 D-P8-08 / BURN-05). When Envelope.burn_after_read = true, a single literal-em-dash marker line [BURN — you will only see this once] is prepended to the acceptance banner ABOVE the Purpose: line (and ABOVE any X.509 / OpenPGP / SSH subblock). The marker fires AFTER inner-verify gates (the verify-before-reveal invariant below — burn is an inner-signed Envelope field, not an outer field — so a tampered share never surfaces the marker). Non-burn shares see the v1.0 banner shape verbatim. See §3.7 for the receive-flow ordering and the emit-before-mark atomicity contract.

    cipherpost/v1.1: X.509 subblock — when Type: x509_cert, a typed subblock is inserted between the Size: and TTL: lines (Phase 6 D-P6-09 / X509-04):

    --- X.509 -------------------------------------------------
    Subject:     CN=..., O=..., C=...           (OpenSSL-forward; truncated ≤80 chars)
    Issuer:      CN=..., O=..., C=...           (OpenSSL-forward; truncated ≤80 chars)
    Serial:      0x<hex>                         (truncated at 16 hex w/ `… (truncated)` if long)
    NotBefore:   YYYY-MM-DD HH:MM UTC
    NotAfter:    YYYY-MM-DD HH:MM UTC  [VALID]   (or `[EXPIRED]`)
    Key:         <human-readable>                (Ed25519, RSA-2048, ECDSA P-256, ...)
    SHA-256:     <64 hex chars lowercase>        (over canonical DER)
    

    The separator line is exactly --- X.509 + 57 dashes = 61 chars, matching the === banner border width. Phase 7 added analogous --- OpenPGP --- and --- SSH --- subblocks (below). Parse failures on the banner render return Error::InvalidMaterial { variant: "x509_cert", reason: "<short>" } with the same generic-reason set as ingest.

    cipherpost/v1.1: OpenPGP subblock (Phase 7 D-P7-07 / D-P7-08 / PGP-04) — when Type: pgp_key, a typed subblock is inserted between the Size: and TTL: lines:

    --- OpenPGP -----------------------------------------------    (53 dashes after prefix)
    Fingerprint: <40-hex for v4 keys; 64-hex for v5/v6>            (UPPER-case hex via rpgp Fingerprint UpperHex impl)
    Primary UID: <UID, truncated at 64 chars w/ `…`>               (control chars stripped — banner-injection mitigation)
    Key:         <Ed25519 | EdDSA-Legacy | RSA-N | ECDSA P-N | ECDH-curve | …>
    Subkeys:     <N (alg1, alg2, ...)  or  "0">
    Created:     YYYY-MM-DD HH:MM UTC
    

    The separator line is exactly --- OpenPGP + 53 dashes = 65 chars.

    SECRET-key warning (D-P7-07). When the primary packet is a Secret-Key packet (RFC 4880 §4.3 tag-5), the subblock is preceded by a warning line + blank line:

    [WARNING: SECRET key — unlocks cryptographic operations]
    
    --- OpenPGP -----------------------------------------------
    Fingerprint: ...
    ...
    

    The warning is visual emphasis only — it does NOT block acceptance. Senders MAY legitimately hand off secret keys (the core cipherpost use case); the typed-z32 acceptance gate still applies in either case.

    Parse failures on the PGP banner return Error::InvalidMaterial { variant: "pgp_key", reason: "malformed PGP packet stream" } — same single literal as ingest, so an oracle adversary cannot distinguish "ingest rejection" from "preview rejection" via the error string.

    cipherpost/v1.1: SSH subblock (Phase 7 D-P7-14 / D-P7-15 / SSH-04) — when Type: ssh_key, a typed subblock is inserted between the Size: and TTL: lines:

    --- SSH ---------------------------------------------------    (57 dashes after prefix)
    Key:         <ssh-ed25519 256 | ssh-rsa 2048 | ssh-rsa 1024 [DEPRECATED] | ssh-dss [DEPRECATED] | ecdsa-sha2-nistp256 256 | …>
    Fingerprint: SHA256:<43 base64-unpadded chars>                 (matches `ssh-keygen -lf` byte-for-byte)
    Comment:     [sender-attested] <comment, truncated 64 chars w/ `…`; `(none)` if empty>
    

    The separator line is exactly --- SSH + 57 dashes = 65 chars (matching the --- OpenPGP --- width). Algorithm names use ssh-key 0.6.7's Algorithm::as_str() wire-form output (ssh-ed25519, ssh-rsa, ssh-dss, ecdsa-sha2-nistp256/384/521) — NOT a friendly-name remapping, so the recipient sees the same identifier they'd see in ~/.ssh/authorized_keys and ssh-keygen -lf output.

    [DEPRECATED] tag (D-P7-14): Display-only. Triggered for ssh-dss (any size) and ssh-rsa keys with bit length below 2048. The tag does NOT block acceptance — senders MAY legitimately migrate legacy infrastructure. The user sees the warning before the typed-z32 prompt.

    SHA-256-only fingerprint (D-P7-14): MD5 and SHA-1 fingerprint forms are NOT rendered. Both are deprecated per OpenSSH 7.0+ release notes; surfacing them would invite users to verify against legacy outputs that share-collide.

    [sender-attested] comment label (D-P7-15): SSH key comments are attacker-mutable (any sender can put anything in the comment), so explicit labeling prevents user confusion ("I sent the alice key but it says bob in the comment"). The (none) placeholder for empty comments is rendered with the same [sender-attested] prefix for consistency.

    No SECRET-key warning on SSH (D-P7-14): Unlike the PGP subblock, SSH does NOT prepend a [WARNING: SECRET key …] line — OpenSSH v1 ALWAYS contains a private key, so warning every time is noise. The [DEPRECATED] algorithm tag is the softer concern the SSH subblock surfaces instead.

    Parse failures on the SSH banner return Error::InvalidMaterial { variant: "ssh_key", reason: "malformed OpenSSH v1 blob" } — same single literal as ingest, so an oracle adversary cannot distinguish "ingest rejection" from "preview rejection" via the error string.

    Stdin AND stderr MUST both be TTYs; else Error::Config, exit 1 (D-ACCEPT-03).

  10. Read user input; compare byte-equal (after trim()) to the sender's full 52-char z-base-32 pubkey. Mismatch → Error::Declined, exit 7 (D-ACCEPT-01, RECV-04).

  11. Write decrypted payload to --output <path> or stdout (default) (RECV-05). With --armor (cipherpost/v1.1):

    • x509-cert → wrapped as PEM (-----BEGIN CERTIFICATE----- + base64-STANDARD body 64-char-wrapped + -----END CERTIFICATE-----\n), byte-compatible with openssl x509 -in <der> -inform DER -outform PEM.
    • pgp-key (Phase 7) → wrapped as RFC 4880 ASCII armor via rpgp's SignedPublicKey::to_armored_bytes(ArmorOptions::default()) for tag-6 primaries (header -----BEGIN PGP PUBLIC KEY BLOCK-----) or SignedSecretKey::to_armored_bytes(ArmorOptions::default()) for tag-5 primaries (header -----BEGIN PGP PRIVATE KEY BLOCK-----). Default ArmorOptions = { headers: None, include_checksum: true } (CRC24 line per RFC 4880 §6.1).
    • generic-secret → REJECTED with Error::Config("--armor requires --material x509-cert or pgp-key") at exit 1 (Phase 7 Plan 03 widened literal).
    • ssh-key → REJECTED with Error::Config("--armor not applicable to ssh-key — OpenSSH v1 is self-armored") at exit 1 (Phase 7 Plan 07 / D-P7-13 — variant-specific rationale because OpenSSH v1 is ALREADY armored, wrapping again would produce nonsense). Both rejection literals fire BEFORE the preview parse runs (cost-on-error
      • pre-emit surface hygiene).

    Armor matrix (cipherpost/v1.1, FINAL):

    --armor accepted for:   x509-cert | pgp-key
    --armor rejected for:   generic-secret | ssh-key (each with a content-specific literal)
    
  12. Create sentinel ~/.cipherpost/state/accepted/<share_ref> (mode 0600); append a ledger line to ~/.cipherpost/state/accepted.jsonl (mode 0600) with receipt_published_at: null (D-STATE-01, D-SEQ-04).

  13. Publish receipt — unconditional in v2, self-shares included (D-SEQ-06 RE-ENABLED). Construct the slim Receipt (§3.4), sign with the recipient's identity Ed25519 key, derive derive(recipient_pub, share_ref) (§3.8), and call Transport::publish_derived(&recipient_derived_signer, "_cprcpt", &receipt_json). Self-shares are no longer skipped — the receipt has its own derived key and cannot collide with the recipient's outgoing share (that was the v1.1 skip reason). On success: append a new ledger line with receipt_published_at: <ISO-8601 UTC> (D-SEQ-04, D-SEQ-05). On any failure: print receipt publish failed: <user_message> to stderr, continue, exit 0 anyway (D-SEQ-02). No auto-retry (D-SEQ-03).

No payload field (including purpose) is printed to stdout or stderr before step 9 begins (D-RECV-01). This is the "verify before reveal" invariant.

5.3 Receipts (sender-side fetch and verify)

  1. cipherpost receipts --from <recipient-z32> --share-ref <ref> [--json] (RCPT-02). In v2 --share-ref is REQUIRED: receipts are addressed per-share at derive(recipient_pub, share_ref) and are NOT enumerable without the share_ref (that unlinkability is a v2 privacy property — §3.8). Omitting --share-ref is an Error::Config (exit 1). The sender always has the share_ref (they created it) and the recipient z32 (they chose it).
  2. Derive A' = derive(recipient_pub, share_ref) (§3.8) and Transport::resolve_derived(&A', "_cprcpt") → the single receipt record (or NotFound).
  3. Parse JSON → Receipt (malformed → Error::Config). verify_receipt(&receipt, from_z32) verifies the inner Ed25519 signature with the recipient z32 supplied as context (signature-failure → exit 3). Defense-in-depth: receipt.share_ref must equal the requested share_ref, else Error::ShareRefMismatch.
  4. Render on stdout (human single-receipt dump by default; --json emits a one-element JSON array — D-OUT-01); progress fetched 1 receipt; 1 valid on stderr (D-OUT-03).
  5. Exit codes (D-OUT-03):
    • receipt found + verifies: exit 0
    • receipt found but signature invalid: exit 3
    • no receipt at the derived key (NotFound): exit 5
    • malformed receipt JSON: exit 1

6. Exit Codes

Cipherpost exits with a narrow, fixed set of codes. All signature-verification failures collapse to exit 3 with a single user-facing message to prevent distinguishing-oracle attacks (D-16).

Code Meaning User-facing message Error variants (internal)
0 Success
1 Generic error <sanitized anyhow message> Config, InvalidShareUri, ShareRefMismatch, WireBudgetExceeded (payload too big), PacketBudgetExceeded (accumulated records under a key exceed the per-key packet — distinct message, no plaintext field), NotImplemented, PayloadTooLarge, InvalidMaterial { variant, reason } (X509-08 — content error at ingest, distinct from exit 3 sig failures; Display is invalid material: variant=..., reason=... with no parser internals leaked), SshKeyFormatNotSupported (Phase 7 Plan 05 / D-P7-12 — input not OpenSSH v1; distinct variant because Display embeds the ssh-keygen -p -o -f <path> conversion hint that would be wrong for non-SSH content errors; SPEC §3.2 SshKey), any unclassified
2 TTL expired share expired Expired
3 Signature verification failed signature verification failed SignatureOuter, SignatureInner, SignatureCanonicalMismatch (D-16 unified)
4 Passphrase / decryption failure wrong passphrase or identity decryption failed DecryptFailed (Phase 8 PIN-07: covers wrong identity-passphrase OR wrong PIN OR tampered inner age ciphertext — IDENTICAL Display across all three credential-failure modes; oracle hygiene — see §3.6 PIN Crypto Stack), IdentityPermissions, PassphraseInvalidInput
5 Not found on DHT not found NotFound
6 Network / DHT error network error or DHT timeout Network (incl. --dht-timeout expiry)
7 User declined acceptance OR (Phase 8 BURN-02) share already consumed (burned). Stderr message: declined for typed-z32 mismatch; share already consumed (burned at <timestamp>) for the burn-already-consumed case (§3.7). declined / share already consumed (burned at <ts>) Declined

Source chains are never displayed (D-15). The binary matches on the top-level Error variant to pick exit code + sanitized user message; the #[source] chain (e.g., age::DecryptError, pkarr::Error, io::Error) remains reachable for RUST_LOG=debug but never appears on stderr. A test (tests/debug_leak_scan.rs and related) scans stderr output for variants of bad-input invocations and asserts no age::, pkarr::, Os {, or similar substring leaks (D-15, CLI-05).

Network-layer errors (DHT request timeout, connection failure) surface as Error::Network with exit code 6 — used by the TRANS-04 receive --dht-timeout <secs> flag (default DEFAULT_DHT_TIMEOUT = 30s; 0 is rejected as a config error) and transport failures that are not NotFound.

CLI argument parse failures (e.g., --passphrase <value> inline argv) exit via clap's default path (typically exit 2 from clap, distinct from cipherpost's Error::Expired exit 2 — the clap-level exit only happens before cipherpost's dispatcher runs, so there is no ambiguity at runtime).

Supply-Chain Deferrals

Three acceptances documented here, with the rationale for each. Revisit when the noted upstream condition is satisfied.

MSRV bumped to Rust 1.88 (Phase 7 D-P7-20)

Required by pgp 0.19.0. Rust 1.88 has been stable since mid-2025 (~10 months at Phase 7 ship time), so low compat risk. No cipherpost downstream users yet (pre-v1.1 public); MSRV bump is low-impact. The bump touches both Cargo.toml (rust-version = "1.88") and rust-toolchain.toml (channel = "1.88") so the toolchain itself does not reject its own MSRV pin. Revisit if rpgp's minimum lowers.

RUSTSEC-2023-0071 (Marvin Attack via rsa 0.9) — ACCEPTED (Phase 7 D-P7-21)

Transitively pulled by pgp 0.19.0 for RFC 4880 RSA key support. No patched rsa version exists at Phase 7 ship time. Cipherpost uses the pgp crate only for packet parsing and metadata extraction — NO RSA decryption/signing operations anywhere in the code. The Marvin timing attack requires a network-observable decryption/signing oracle; no such surface exists in cipherpost's parse-only code path. Impact: low. Accepted via deny.toml [advisories] ignore entry. Revisit when upstream rsa crate ships a constant-time patched version.

ed25519-dalek dual-version coexistence (Phase 7 D-P7-22)

The pgp 0.19.0 crate unconditionally pulls ed25519-dalek 2.x (the >=2.1.1 cargo constraint resolves upward to the current latest 2.x release — measured 2.2.0 at Plan 01 ship time); cipherpost's core identity uses ed25519-dalek =3.0.0-pre.5 (pinned to match pkarr 5.0.x's required pre-release). The cipherpost binary therefore carries TWO ed25519-dalek implementations.

  • Runtime risk: LOW — each crate uses its own pinned version; no cross-crate interop of Ed25519 keys beyond what rpgp internally does for its own signatures.
  • Supply-chain signal: doubled for Ed25519 (two audited implementations in the dep closure).
  • Audit-test coverage: tests/x509_dep_tree_guard.rs::dep_tree_ed25519_dalek_coexistence_shape asserts BOTH versions are present and that no THIRD version has appeared.

Revisit when EITHER (a) pgp releases a version that drops ed25519-dalek 2.x, OR (b) pkarr migrates to a stable ed25519-dalek 3.x release (the =3.0.0-pre.5 pin can then drop the = exact-pin requirement).

Pitfall #22 — Wire-budget: what works today (consolidated)

Realistic typed-material payloads exceed the 1000-byte PKARR BEP44 ceiling. The current cipherpost protocol surfaces this as a clean Error::WireBudgetExceeded { encoded, budget: 1000, plaintext } at send time — NOT as an InvalidMaterial or PKARR-internal panic.

The architectural fix (two-tier storage: a small DHT manifest pointing to an encrypted blob in an external store) has shipped experimentally in v2-alpha behind the off-by-default large-payload feature. A new Material::LargePayload { hash, size } variant carries only the sha256 of the off-DHT age-ciphertext blob and its byte length; the blob's storage path on a Pubky homeserver is derived from hash (content-addressed), so the manifest stays well under the 1000-byte ceiling regardless of payload size. The send-large / receive-large commands drive this path; the small-share send / receive flow and the wire-budget matrix below are unchanged. See the README "Large payloads (v2)" section and THREAT-MODEL.md §10. (Chunking-over-DHT and an out-of-band escape hatch remain possible later additions for the no-homeserver case.)

This consolidated matrix (Phase 7 Plan 08, replacing the per-variant scattered notes from Phase 6 + Plan 04) tells users honestly which variants work today and which surface WireBudgetExceeded:

Variant Min fixture Predicted/measured encoded Round-trip today?
generic_secret (trivial payload ~20 B) ~20 B ~800 B YES (Phase 5 baseline)
x509_cert Ed25519 self-signed minimum ~234 B ~1290 B NO (#[ignore]'d in tests/x509_roundtrip.rs)
pgp_key rpgp-minimal Ed25519 (UID ≤20 chars, no subkeys, empty pref-subpackets) 202 B 1236 B (measured) NO (#[ignore]'d in tests/pgp_roundtrip.rs)
pgp_key realistic key (UID >20 chars, RSA, OR subkeys) ≥250 B >1000 B NO (positive WireBudgetExceeded test ACTIVE)
ssh_key Ed25519 OpenSSH v1 minimum (empty comment) 387 B (raw) 1589 B (measured Plan 08) NO (#[ignore]'d FROM DAY 1 in tests/ssh_roundtrip.rs)
ssh_key larger keys (RSA, longer comment) ≥500 B >2000 B NO (positive WireBudgetExceeded test ACTIVE)

Behavior matrix today (Phase 7 ship state):

  • x509_cert realistic-fixture sends surface Error::WireBudgetExceeded { encoded, budget: 1000, plaintext } cleanly (Phase 6).
  • pgp_key rpgp-minimal Ed25519 round-trip: #[ignore]'d (1236 B > 1000 B).
  • pgp_key realistic-fixture sends surface Error::WireBudgetExceeded cleanly (Plan 04 positive test pgp_send_realistic_key_surfaces_wire_budget_exceeded_cleanly).
  • ssh_key round-trip: #[ignore]'d FROM DAY 1 (D-P7-03 fallback active per research GAP for SSH; minimum 387 B Ed25519 OpenSSH v1 fixture encodes to ~1589 B per Plan 08 measurement).
  • ssh_key realistic-fixture sends surface Error::WireBudgetExceeded cleanly (Plan 08 positive test ssh_send_realistic_key_surfaces_wire_budget_exceeded_cleanly).
  • generic_secret + small payloads continue to round-trip as in Phase 5.

Note on PGP wire-budget reality: Research GAP-5 predicted raw × 4.16 ≈ encoded (~840 B for a 202 B fixture). Actual measurement at Plan 04 implementation time: 1236 B encoded, expansion factor ≈ 6.1× — about 50% higher than predicted. The overhead is split between JCS envelope framing (~180 B for {created_at, material: {type:pgp_key, bytes:b64}, protocol_version, purpose} plus base64 expansion of the bytes field), age encryption framing, and OuterRecord JSON wrapping.

Note on SSH wire-budget reality: Research forecast ~1340 B (Plan 05 prediction based on raw × 4.16). Actual Plan 08 measurement on the 387 B Ed25519 fixture: 1589 B encoded (plaintext 617 B; expansion factor ≈ 4.10× over raw and ≈ 2.58× over plaintext). The forecast was within ~16% — closer than PGP's 50% miss because SSH OpenSSH v1 PEM is already a fairly verbose format with limited compression opportunity at the canonical-re-encode layer.

Honest messaging discipline (D-P7-03): Phase 7 ships with #[ignore]'d round-trip tests + active WireBudgetExceeded tests for X.509 + PGP + SSH. The #[ignore]'d tests are the regression suite for a future fix that routes oversized typed-material sends through two-tier storage automatically (the two-tier mechanism shipped experimentally as the v2-alpha large-payload feature; transparent send integration is still future) — do NOT remove them. Each carries a wire-budget: … #[ignore] reason that points at this section.

Phase 8 wire-budget continuation (pin × burn × typed-material compose): PIN-required shares add ~165 B per nested-age layer + 32 B salt prefix; the worst-case pin + burn + pgp_key (secret-key) compose is predicted to brush the 1000 B BEP44 ceiling (08-RESEARCH.md Open Risk #5). The compose-grid test suite (tests/pin_burn_compose.rs, 23 tests covering pin × burn × { GenericSecret, X509Cert, PgpKey, SshKey }) uses the W3 split-macro pattern: compose_base_test_strict! for the single sub-budget happy path (generic_burn_only), compose_base_test_lenient! for every PIN path and every typed-material variant — lenient gracefully surfaces Error::WireBudgetExceeded as Ok with a skip note, asserting the failure mode is a CLEAN WireBudgetExceeded (NOT a panic, NOT a Transport-internal error, NOT a partial publish). The pre-flight test tests/pin_burn_compose.rs::pin_plus_burn_plus_pgp_wire_budget_surfaces_cleanly_or_succeeds pins this contract explicitly. Phase 9 (DHT-07) measures the wire-budget distribution empirically against the real DHT; a future release ships the transparent wire-budget escape hatch (chunking / auto two-tier storage / out-of-band; the two-tier mechanism shipped experimentally in v2-alpha).

Phase 9 composite measurement (DHT-07): pin_required=true + burn_after_read=true + Material::GenericSecret { bytes: vec![0u8; 2048] } exceeds the 1000-byte BEP44 ceiling and surfaces Error::WireBudgetExceeded { encoded, budget: 1000, plaintext } cleanly at send time — encoded = 5123 bytes vs budget = 1000 bytes (overflow = 4123 bytes, expansion factor ≈ 2.5× over the 2048 B plaintext). Test: tests/wire_budget_compose_pin_burn_pgp.rs::pin_burn_realistic_payload_surfaces_wire_budget_exceeded. Recorded for the regression-guard byte-count table in RELEASE-CHECKLIST.md when the transparent two-tier-storage send fix lands.

7. Passphrase Contract

Cipherpost's identity file is encrypted with a passphrase-derived key (Argon2id → HKDF → age). Passphrases are the only secret the user must remember; cipherpost enforces a strict contract to prevent leaks.

7.1 Precedence

Passphrase sources are consulted in priority order: fd > file > env > TTY. Inline --passphrase <value> is rejected at parse/runtime.

  1. --passphrase-fd <N> — no process-table exposure; file descriptor inherited from the caller. Fd 0 (stdin) is reserved for payload I/O and is rejected with exit 1.
  2. --passphrase-file <PATH> — no process-table exposure; file must be mode 0600 or 0400 (inode permission gate). Wider permissions return Error::IdentityPermissions.
  3. CIPHERPOST_PASSPHRASE environment variable — visible via /proc/<pid>/environ and ps auxe (PITFALL #35); use sparingly. Available primarily for CI contexts.
  4. TTY prompt — interactive only; cannot be scripted. Requires both stdin and stderr to be TTYs; otherwise cipherpost exits with Error::Config and exit code 1 rather than falling back to piped stdin (which would conflate payload input with passphrase input).

Inline --passphrase <value> is rejected at parse time (via a hidden-from-help flag whose value triggers Error::PassphraseInvalidInput at dispatch, exit 4) and at runtime. Inline argv bytes leak via /proc/<pid>/cmdline, ps, and shell history.

Setting both --passphrase-file and --passphrase-fd in a single invocation is rejected with Error::Config and exit 1. CIPHERPOST_PASSPHRASE plus one of the two flags is permitted — the flag takes precedence per the ordering above.

7.2 Newline-strip rule

Both --passphrase-fd and --passphrase-file strip exactly one trailing newline: one \r\n, else one \n, else nothing. Never a greedy .trim() (which would silently corrupt passphrases ending in a space — PITFALL #30).

Truth table:

Input bytes Stripped output
hunter2\r\n hunter2
hunter2\n hunter2
hunter2\n\n hunter2\n
hunter2 hunter2
hunter2 hunter2
hunter2\r hunter2\r

The bare \r case is deliberately preserved (not stripped) — a passphrase file authored by a text editor that emits CR-only line endings is a user-environment bug to fix at the editor, not something cipherpost silently mutates.

7.3 Wrong passphrase

Incorrect passphrase yields exit code 4 with the user-facing message passphrase failed. No hint about which character was wrong, no timing disclosure; the Argon2id KDF cost means each wrong attempt takes ~0.3 seconds regardless.

7.4 Identity file permissions

~/.cipherpost/secret_key MUST be at mode 0600. Identity files at wider permissions are refused at open time with a clear error and exit code (IDENT-03, PITFALL #15). The identity directory ~/.cipherpost/ is created at mode 0700.

8. Appendix: Test Vectors

All test vectors use a deterministic Ed25519 keypair derived from the all-zeros seed ([0u8; 32]). This key is labelled TEST VECTOR ONLY throughout this section.

WARNING: TEST VECTOR ONLY — DO NOT USE IN PRODUCTION. The [0u8; 32] Ed25519 seed used in this appendix is a known, non-secret value used exclusively for reproducibility. Any cipherpost identity created with this seed is compromised by definition.

Re-implementers can use these vectors to confirm byte-level compatibility without cloning this repository.

8.1 OuterRecordSignable Test Vector

Keypair source: Ed25519 SigningKey::from_bytes(&[0u8; 32]).

Input — pretty-printed JSON (for readability):

{
  "blob": "AAAA",
  "created_at": 1700000000,
  "protocol_version": 2,
  "pubkey": "pk-placeholder-z32",
  "recipient": "rcpt-placeholder-z32",
  "share_ref": "0123456789abcdef0123456789abcdef",
  "ttl_seconds": 86400
}

Canonical bytes (RFC 8785 JCS, 192 bytes):

7b22626c6f62223a2241414141222c22637265617465645f6174223a313730303030303030302c2270726f746f636f6c5f76657273696f6e223a322c227075626b6579223a22706b2d706c616365686f6c6465722d7a3332222c22726563697069656e74223a22726370742d706c616365686f6c6465722d7a3332222c2273686172655f726566223a223031323334353637383961626364656630313233343536373839616263646566222c2274746c5f7365636f6e6473223a38363430307d

Fixture file: tests/fixtures/outer_record_signable.bin (byte-compare to verify).

To reproduce:

  1. Serialize the pretty-printed JSON above through any RFC 8785 JCS implementation. The resulting bytes MUST equal the hex above (192 bytes).
  2. Ed25519-sign those bytes with SigningKey::from_bytes(&[0u8; 32]).
  3. The signature MUST match the base64 below.

Signature (base64-STANDARD):

ZDOIAeAdUdH3kom7W33HEwwe0crFJRk0YCQXUzFIj9qHazFPy+ywWquEIaKHiH/J/q+BJBxMP+5C5l9qfycLBg==

8.2 ReceiptSignable Test Vector

Keypair source: Same [0u8; 32] seed as §8.1.

Input — pretty-printed JSON:

{
  "accepted_at": 1700000000,
  "ciphertext_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
  "cleartext_hash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
  "protocol_version": 2,
  "share_ref": "0123456789abcdef0123456789abcdef"
}

Slim v2 schema — no nonce, recipient_pubkey, or sender_pubkey. Under v2 derived-key addressing a receipt is published under its OWN key derive(recipient_pub, share_ref), so the recipient pubkey is verify/addressing CONTEXT — an argument to verify_receipt(receipt, recipient_pub_z32) — not a wire field. The sender pubkey and the per-record nonce are likewise dropped (the derived key already makes each receipt's DHT address unique). purpose remains absent (removed 2.0.0-alpha): a receipt is published in cleartext on the public DHT, and a descriptive purpose would leak a signed, timestamped social graph of secret handoffs. The purpose is still boundcleartext_hash is SHA-256(JCS(Envelope)) and the Envelope carries purpose — so a sender who holds the original envelope can verify the receipt matches. This slim schema and the PROTOCOL_VERSION 1→2 bump ship together in v2 (crate 2.0.0-alpha.1): every previously issued share and receipt is invalidated.

Canonical bytes (RFC 8785 JCS, 263 bytes):

7b2261636365707465645f6174223a313730303030303030302c22636970686572746578745f68617368223a2261616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161222c22636c656172746578745f68617368223a2262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262222c2270726f746f636f6c5f76657273696f6e223a322c2273686172655f726566223a223031323334353637383961626364656630313233343536373839616263646566227d

Fixture file: tests/fixtures/receipt_signable.bin (byte-compare to verify).

To reproduce:

  1. Serialize the pretty-printed JSON above through any RFC 8785 JCS implementation. The resulting bytes MUST equal the hex above (263 bytes).
  2. Ed25519-sign those bytes with the same [0u8; 32] seed.
  3. The signature MUST match the base64 below.

Signature (base64-STANDARD):

Gv6xEyj1sYKfcnvk+kUta9mhy/d7JfEV2sHzcCd2h5qcOZX8O2dtrl6Nyv57IhLqRPQ2/6oGlvWCxQVGJNt6Ag==

8.3 Sanity check (implementer script)

A reference Rust test for regenerating both vectors is committed at tests/spec_test_vectors.rs (gated #[ignore]); run it with:

cargo test --features mock gen_spec_test_vectors -- --ignored --nocapture

Output MUST match the base64 signatures above byte-for-byte.

8.4 Derived-Key Address Vector (v2, §3.8)

Byte-exact golden vector for the v2 derivation. A re-implementation MUST reproduce A' (and, with the seed, a' / prefix') or it will address the wrong key. Pinned in code by src/derive.rs::golden_vector_seed7_ref11 and tests/derived_key_spike.rs.

Domain constants:

DERIVE_DOMAIN         = "cipherpost/v2/derive-addr"     (ASCII, 25 bytes)
DERIVE_PREFIX_DOMAIN  = "cipherpost/v2/derive-prefix"   (ASCII, 27 bytes)

Inputs:

parent seed   = [0x07; 32]                                                       (TEST VECTOR ONLY)
parent_pub A  = ea4a6c63e29c520abef5507b132ec5f9954776aebebe7b92421eea691446d22c  (hex)
              = 7jfgaa9nutjyixzikb7tgmsf9gkwq7iqz498zr1nd5ig1fng4esy              (z-base-32)
share_ref     = 11111111111111111111111111111111                                 (32-char hex)
              → raw16 = [0x11; 16]                                                (hashed as RAW bytes, not ASCII hex — §3.8)

Public derivation (needs only parent_pub + share_ref):

t   = reduce_mod_ℓ( SHA-512( DERIVE_DOMAIN ‖ A ‖ raw16(share_ref) ) )             (intermediate; compute per §3.8)
A'  = A + t·G
    = 5af3abc0070698cedb92d1c16da7d2c1bdcbe9ea5bbfce9fba32d4d9d72155f5            (hex)
    = mm34zoy8y4cc7sh148ys5j61ag6hz4xkmq9h7874gmkpui3bkz4o                        (z-base-32; the DHT origin)

Secret side (needs the parent seed; a'·G MUST equal A' above):

a'       (derived scalar, mod ℓ)   = ce663dca22ba636800f6c2377163db7591602ad123715f362fa1c364ed44b80d
prefix'  (derived nonce prefix)    = 5d365967bde2f0f03a762a726326ccd84f1aa43ecd5890f090d66849f9a45b1b

The share for this share_ref is published under _cipherpost at origin A'; a receipt from a recipient whose identity is this key would be published under _cprcpt at derive(recipient_pub, share_ref) (same math, recipient's key as parent).

9. Lineage

Cipherpost is a fork-and-diverge of cclink, a prior project by the same author that applied the same PKARR + age + Ed25519 + Mainline DHT primitives to Claude Code session-ID handoff. cclink is mothballed: no further development is planned upstream. Cipherpost was seeded in 2026-04 by vendoring cclink's crypto, identity, record, and transport layers essentially unchanged and adding a new payload and flow layer on top.

The primitives ported from cclink are reused without protocol-level modification: age 0.11 for payload encryption (X25519 derived from the identity Ed25519 key); ed25519-dalek =3.0.0-pre.5 for all signature operations; argon2 0.5 with parameters (64 MB memory, 3 iterations) stored in a PHC-format identity-file header; hkdf 0.12 with SHA-256 for key derivation; pkarr 5.0.3 for Mainline DHT rendezvous via SignedPacket. The crypto primitive stack MUST NOT be substituted; cipherpost takes cclink's v1.3.0 crypto pins verbatim. v2 addition: derived-key addressing (§3.8) adds curve25519-dalek for point/scalar arithmetic and uses ed25519-dalek's hazmat raw-signing under blinded keys — an addressing-layer addition on top of the unchanged primitive stack; payload encryption and the identity/inner-signature paths are untouched.

Cryptographic keys produced by cipherpost and keys produced by cclink are not interoperable despite sharing the primitive stack. All cipherpost HKDF call-sites use info strings prefixed cipherpost/v1/ (the HKDF_INFO_PREFIX constant in src/lib.rs and D-08). cclink uses a different prefix; any attempt to decrypt a cclink share with a cipherpost identity (or vice versa) will fail at the HKDF step. This domain separation is deliberate and tested via tests/hkdf_info_enumeration.rs.

The cipherpost delta from cclink lives purely at the payload and flow layer:

  1. Typed payload schemaEnvelope with Material enum (generic_secret shipped in v1.0; x509_cert added in v1.1 Phase 6; pgp_key and ssh_key added in v1.1 Phase 7).
  2. Explicit acceptance step — §5.2 step 9; the recipient MUST paste the sender's full 52-char z-base-32 pubkey to confirm (no y, no --yes flag). This prevents MFA-fatigue-style prompt bombing.
  3. Signed receipt — Receipt structure (§3.4) published under the recipient's derived key derive(recipient_pub, share_ref) at label _cprcpt (§3.8; v1.1 used _cprcpt-<share_ref_hex> on the recipient's parent key with resolve-merge-republish).

Fork point: cclink v1.3.0 (the last release before mothballing).

See also: THREAT-MODEL.md for the adversary model and SECURITY.md for the vulnerability disclosure policy.