Status: DRAFT — current through v2 derived-key addressing (
PROTOCOL_VERSION = 2, crate2.0.0-alpha.1), which supersedes the v1.1 Real v1 wire format (shipped 2026-04-26). Also covers the experimental, off-by-defaultlarge-payloadfeature (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
Materialvariants,--pin/--burnencryption modes, non-interactive automation — and the v2 derived-key addressing redesign, in which each share and each receipt is published under its own keyderive(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: thePROTOCOL_VERSION1→2 bump invalidates every previously issued share and receipt, and the "v1.0 byte-identity" lock-in is retired for v2 (records now encodeprotocol_version: 2, changing their signed bytes). The#[serde(skip_serializing_if = "is_false")]elision forpin_required/burn_after_readis 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)
- Introduction
- Terminology
- Wire Format
- Share URI
- Flows
- Exit Codes
- Passphrase Contract
- Appendix: Test Vectors
- Lineage
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.
- age — the payload encryption format (X25519 + ChaCha20-Poly1305). Cipherpost uses the
agecrate exclusively; no directchacha20poly1305calls (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
pkarrinherits. - 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_jsoncrate. - 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); seeCargo.tomlfor the exact pin in effect. - sender-attested purpose — the human-readable
purposestring 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 underderive(sender_pub, share_ref)and each receipt underderive(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
OuterRecordcarrying an age-encrypted payload (v2: published under the sender's derived keyderive(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.
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.)
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):
purposeis 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. SeeTHREAT-MODEL.md§4 Sender-Purpose Adversary.
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.
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).
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).
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 byOuterRecord(§3.3), published underderive(sender_pub, share_ref)_cprcpt— carried byReceipt(§3.4), published underderive(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.
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 aprotocol_versionbump. - HKDF-SHA256:
salt = the same 32-byte random salt;ikm = Argon2id 32-byte output;info = "cipherpost/v1/pin"(referenced viacrate::crypto::hkdf_infos::PINconstant — NEVER inline-literal). Output: 32 bytes used as an X25519 scalar. - age
Identity: built from the 32-byte X25519 scalar viacrate::crypto::identity_from_x25519_bytes. Theto_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 derivepin_recipient).
Nested age structure (PIN shares only):
inner_ct = age_encrypt(envelope_jcs, pin_recipient)outer_ct = age_encrypt(inner_ct, receiver_recipient)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.
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:
- 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.
- 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_readis 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 encodeprotocol_version: 2.)- JCS alphabetic placement: FIRST (before
created_atbecauseb<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):
- STEP 1 — early ledger pre-check returns
LedgerState:LedgerState::Burned { burned_at }→ returnError::Declined(exit 7); stderrshare 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 whenpin_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 whenburn_after_read=true; non-burn shares see the v1.0 banner shape verbatim.
- Emit decrypted bytes to stdout / file / sink.
create_sentinel(&share_ref)then ledger row write. The dispatch selects the helper byenvelope.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 withstate: "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 nostatefield; deserializes via serde default tostate: Noneand maps toLedgerState::Acceptedon read.
- Burn flow:
- 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_cipherpostshare 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 thesend --self → receive → send --selfself-backup workflow is preserved because the share and its receipt occupy different derived keys. Burn does NOT suppress attestation (there is noif !envelope.burn_after_readguard) — receipt = delivery confirmation. Because each receipt has its own single-record packet, a recipient can hold many outstanding receipts (one pershare_ref); the v1.1 "at most one outstanding receipt" ceiling and itsPacketBudgetExceededwarn+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 bytests/phase3_coexistence_b_self_share_and_receipt.rsandtests/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.
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 bytescipherpost/v2/derive-addr(domain separation; distinct from the HKDFcipherpost/v1/info-string namespace, which is unrelated and unchanged).raw16(share_ref)is the RAW 16 bytes decoded from the 32-char lowercase-hexshare_ref— NOT the 32 ASCII-hex bytes. ThederiveAPI 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 silentNotFound). Hashing the raw 16 bytes is the frozen contract; a re-implementation that hashes the hex string is non-conformant.tis reduced mod ℓ viaScalar::from_bytes_mod_order_wideover the 64-byte SHA-512 output (a reduction, not a clamp).tandA'are public — anyone with(A, share_ref)computesA'(the no-index property).a'is computable only by the parent-secret holder (needsa, from the master seed viaSigningKey::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], whereDERIVE_PREFIX_DOMAIN=cipherpost/v2/derive-prefixandmaster_hash_prefixis the second half ofSHA-512(seed)(viaExpandedSecretKey). 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:
- Build the DNS packet (single TXT record) with names normalized to the derived origin
A'.to_z32(); encode with pkarr's compressed encoder. - 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. sig = ed25519_dalek::hazmat::raw_sign::<Sha512>(&esk, &signable, &A')withesk = { scalar: a', hash_prefix: prefix' }; self-verifyA'.verify_strict(&signable, &sig)before use (mandatory —raw_signleaks the scalar if the passed public key ≠a'·G).- Assemble
payload = sig(64) ‖ ts_be(8) ‖ encoded_packetand construct the packet via the publicSignedPacket::from_relay_payload(&A', &payload), which re-verifies the BEP44 signature underA'— 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 checksS·G = R + H(R,A',M)·A', which holds forA' = a'·Gregardless of clamping.A' = a'·Gis always in the prime-order subgroup, so it is canonical and passesverify_strict— no small-subgroup exposure via the public key. - No secret leak.
t/A'are public; recoveringa = a' − tneedsa', 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_refcannot linkA'back toA— 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 holdingshare_ref(the counterparty, or anyone with the URI) can deriveA'.
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.
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) — matchesOuterRecord.pubkey. In v2 the receiver derives the resolution keyderive(<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.
- Read payload from
<path>or-(stdin). (SEND-01, PAYL-03) - Ingest (cipherpost/v1.1): dispatch on
--material <variant>(defaultgeneric-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 viax509-parserstrict profile with an explicit trailing-bytes check.payload::ingest::pgp_key(raw)strict-rejects ASCII armor (any-----BEGIN PGPprefix after whitespace skip), iterates top-level packets viapgp::packet::PacketParser, counts top-level Tag::PublicKey + Tag::SecretKey packets (rejects keyrings with N substituted), and asserts the sum of per-packet serialized lengths equalsraw.len()(trailing-bytes invariant resilient to rpgp's silent-0xFF parser quirk). ReturnsMaterial::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 withError::SshKeyFormatNotSupported(legacy PEM RSA/DSA/EC, RFC 4716 SSH2, OpenSSH-FIDO, garbage), checks for trailing bytes after the-----END OPENSSH PRIVATE KEY-----marker, parses viassh-key'sPrivateKey::from_openssh, and re-encodes canonically viato_openssh(LineEnding::LF)(D-P7-11). ReturnsMaterial::SshKey { bytes: <canonical OpenSSH v1 PEM bytes> }.- Parse failure →
Error::InvalidMaterial { variant, reason }exit 1. - SSH-specific format-rejection →
Error::SshKeyFormatNotSupportedexit 1 (distinct variant; user message embedsssh-keygen -p -o -f <path>hint).
- Plaintext cap (D-P6-16 / X509-06): reject if
material.plaintext_size() > 65 536. Forx509_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). Forpgp_key, this is the raw binary packet-stream length (no PEM-style decode applies; armor is rejected). Forssh_key, this is the canonical re-encoded UTF-8 PEM byte length (i.e., the bytes stored inMaterial::SshKeyafterto_openssh(LineEnding::LF), not the raw input). - Build
Envelope { purpose, material, created_at, protocol_version }withpurposecontrol- stripped (D-WIRE-05). JCS-serialize. - 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 produceblob. - Compute
share_ref = sha256(ciphertext_blob_bytes || created_at.to_be_bytes())[..16](D-06). - Build
OuterRecordSignable { blob, created_at, protocol_version, pubkey, recipient, share_ref, ttl_seconds }. - JCS-serialize
OuterRecordSignable; Ed25519-sign with the sender's identity key; base64- encode to producesignature. AssembleOuterRecord(D-WIRE-03, SEND-04). - 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_cipherpostcarrying theOuterRecordJSON, hand-signed underA'(BEP44, §3.8). Verify encoded SignedPacket size ≤ ~1000 bytes (BEP44 budget, SEND-05). Overflow =Error::WireBudgetExceeded. Transport::publish_derived(&derived_signer, "_cipherpost", &outer_record_json)— publishes underA', 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-derivesA'to resolve (D-URI-01, SEND-01).
CLI flags (cipherpost/v1.1):
--material <VALUE>(defaultgeneric-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
Strict order (D-RECV-01 + D-SEQ-01 combined — 13 steps):
-
Parse URI; extract
sender_z32andurl_share_ref. Malformed →Error::InvalidShareUri(D-URI-03).cipherpost/v1.1.x: Per-
share_refadvisory lock (Quick 260427-axn). Immediately after URI parse and BEFORE step 2's idempotency check,run_receiveacquires an exclusiveflockon~/.cipherpost/state/locks/<url_share_ref>.lock(file mode0600, directory mode0700). 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 keyderive(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 concurrentcipherpost receiveinvocations on the sameshare_refcould both pass step 2'scheck_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 intoError::Io— no new publicErrorvariant is introduced (Pitfall #16 oracle hygiene). At the top of each receive, a best-effort GC (prune_consumed_locks) unlinks lock files for already-consumedshare_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, solocks/stays bounded rather than growing one file pershare_refforever. 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). -
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 carriesstate: "burned", returnError::Declined(exit 7) with stderr messageshare already consumed (burned at <timestamp>). v1.0 rows missing thestatefield deserialize via serde default toLedgerState::Accepted(T-08-17 conservative classification). See §3.7 for the receive-flow ordering and ledger schema details. -
Derive the sender's per-share key
A' = derive(sender_pub, url_share_ref)(§3.8) andTransport::resolve_derived(&A', "_cipherpost")— returnsOuterRecordonly after the outer PKARR SignedPacket signature underA'passes (verified insidepkarr::ClientBlocking/ on parse). BecauseA'is a deterministic function of(sender_pub, share_ref), a valid packet atA'cryptographically binds the record to that sender and thatshare_ref(defense-in-depth alongside step 5's field check). NotFound → exit 5. -
Verify inner Ed25519 signature on
OuterRecordviaverify_record(round-trip-reserialize guard included). Any signature failure → unified message, exit 3 (D-16, RECV-01). -
Check
url_share_ref == OuterRecord.share_ref; mismatch →Error::ShareRefMismatch, exit 1 (D-URI-02). -
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-decodeblob(≥ 32 bytes required, elseError::SignatureCanonicalMismatchexit 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::Configexit 1, no state mutation, no receipt published — share remains re-receivable when a PIN is later available); (d) derivepin_identityfrom PIN + salt via Argon2id + HKDF-SHA256 with infocipherpost/v1/pin(§3.6); (e) step 7's age-decrypt becomes NESTED: outer with the receiver identity producesinner_ct, inner withpin_identityproducesenvelope_jcs. Wrong PIN at the inner step →Error::DecryptFailedexit 4 with the IDENTICAL Display as a wrong identity-passphrase failure (PIN-07). -
age-decrypt
OuterRecord.blobinto aZeroizing<Vec<u8>>. Decryption failure → exit 4 (RECV-03). -
Parse decrypted bytes as JCS →
Envelope. JCS parse failure →Error::SignatureCanonicalMismatch, exit 3 (D-RECV-01 step 7). -
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 thePurpose: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-signedEnvelopefield, 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 theSize:andTTL: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 returnError::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 theSize:andTTL: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 UTCThe 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 theSize:andTTL: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'sAlgorithm::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_keysandssh-keygen -lfoutput.[DEPRECATED]tag (D-P7-14): Display-only. Triggered forssh-dss(any size) andssh-rsakeys 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). -
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). -
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 withopenssl x509 -in <der> -inform DER -outform PEM.pgp-key(Phase 7) → wrapped as RFC 4880 ASCII armor via rpgp'sSignedPublicKey::to_armored_bytes(ArmorOptions::default())for tag-6 primaries (header-----BEGIN PGP PUBLIC KEY BLOCK-----) orSignedSecretKey::to_armored_bytes(ArmorOptions::default())for tag-5 primaries (header-----BEGIN PGP PRIVATE KEY BLOCK-----). DefaultArmorOptions={ headers: None, include_checksum: true }(CRC24 line per RFC 4880 §6.1).generic-secret→ REJECTED withError::Config("--armor requires --material x509-cert or pgp-key")at exit 1 (Phase 7 Plan 03 widened literal).ssh-key→ REJECTED withError::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) -
Create sentinel
~/.cipherpost/state/accepted/<share_ref>(mode 0600); append a ledger line to~/.cipherpost/state/accepted.jsonl(mode 0600) withreceipt_published_at: null(D-STATE-01, D-SEQ-04). -
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, derivederive(recipient_pub, share_ref)(§3.8), and callTransport::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 withreceipt_published_at: <ISO-8601 UTC>(D-SEQ-04, D-SEQ-05). On any failure: printreceipt 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.
cipherpost receipts --from <recipient-z32> --share-ref <ref> [--json](RCPT-02). In v2--share-refis REQUIRED: receipts are addressed per-share atderive(recipient_pub, share_ref)and are NOT enumerable without theshare_ref(that unlinkability is a v2 privacy property — §3.8). Omitting--share-refis anError::Config(exit 1). The sender always has theshare_ref(they created it) and the recipient z32 (they chose it).- Derive
A' = derive(recipient_pub, share_ref)(§3.8) andTransport::resolve_derived(&A', "_cprcpt")→ the single receipt record (or NotFound). - 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_refmust equal the requestedshare_ref, elseError::ShareRefMismatch. - Render on stdout (human single-receipt dump by default;
--jsonemits a one-element JSON array — D-OUT-01); progressfetched 1 receipt; 1 validon stderr (D-OUT-03). - 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
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).
Three acceptances documented here, with the rationale for each. Revisit when the noted upstream condition is satisfied.
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.
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.
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_shapeasserts 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).
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_certrealistic-fixture sends surfaceError::WireBudgetExceeded { encoded, budget: 1000, plaintext }cleanly (Phase 6).pgp_keyrpgp-minimal Ed25519 round-trip:#[ignore]'d (1236 B > 1000 B).pgp_keyrealistic-fixture sends surfaceError::WireBudgetExceededcleanly (Plan 04 positive testpgp_send_realistic_key_surfaces_wire_budget_exceeded_cleanly).ssh_keyround-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_keyrealistic-fixture sends surfaceError::WireBudgetExceededcleanly (Plan 08 positive testssh_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.
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.
Passphrase sources are consulted in priority order: fd > file > env > TTY. Inline
--passphrase <value> is rejected at parse/runtime.
--passphrase-fd <N>— no process-table exposure; file descriptor inherited from the caller. Fd0(stdin) is reserved for payload I/O and is rejected with exit1.--passphrase-file <PATH>— no process-table exposure; file must be mode0600or0400(inode permission gate). Wider permissions returnError::IdentityPermissions.CIPHERPOST_PASSPHRASEenvironment variable — visible via/proc/<pid>/environandps auxe(PITFALL #35); use sparingly. Available primarily for CI contexts.- TTY prompt — interactive only; cannot be scripted. Requires both stdin and stderr
to be TTYs; otherwise cipherpost exits with
Error::Configand exit code1rather 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.
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.
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.
~/.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.
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.
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:
- Serialize the pretty-printed JSON above through any RFC 8785 JCS implementation. The resulting bytes MUST equal the hex above (192 bytes).
- Ed25519-sign those bytes with
SigningKey::from_bytes(&[0u8; 32]). - The signature MUST match the base64 below.
Signature (base64-STANDARD):
ZDOIAeAdUdH3kom7W33HEwwe0crFJRk0YCQXUzFIj9qHazFPy+ywWquEIaKHiH/J/q+BJBxMP+5C5l9qfycLBg==
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, orsender_pubkey. Under v2 derived-key addressing a receipt is published under its OWN keyderive(recipient_pub, share_ref), so the recipient pubkey is verify/addressing CONTEXT — an argument toverify_receipt(receipt, recipient_pub_z32)— not a wire field. The sender pubkey and the per-recordnonceare likewise dropped (the derived key already makes each receipt's DHT address unique).purposeremains absent (removed 2.0.0-alpha): a receipt is published in cleartext on the public DHT, and a descriptivepurposewould leak a signed, timestamped social graph of secret handoffs. The purpose is still bound —cleartext_hashis SHA-256(JCS(Envelope)) and the Envelope carriespurpose— so a sender who holds the original envelope can verify the receipt matches. This slim schema and thePROTOCOL_VERSION1→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:
- Serialize the pretty-printed JSON above through any RFC 8785 JCS implementation. The resulting bytes MUST equal the hex above (263 bytes).
- Ed25519-sign those bytes with the same
[0u8; 32]seed. - The signature MUST match the base64 below.
Signature (base64-STANDARD):
Gv6xEyj1sYKfcnvk+kUta9mhy/d7JfEV2sHzcCd2h5qcOZX8O2dtrl6Nyv57IhLqRPQ2/6oGlvWCxQVGJNt6Ag==
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.
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).
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:
- Typed payload schema —
EnvelopewithMaterialenum (generic_secretshipped in v1.0;x509_certadded in v1.1 Phase 6;pgp_keyandssh_keyadded in v1.1 Phase 7). - 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--yesflag). This prevents MFA-fatigue-style prompt bombing. - 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.