Status: DRAFT — current through v1.1 Real v1 (shipped 2026-04-26)
This document describes the protocol as shipped through v1.0 Walking Skeleton (Phases 1–4) and v1.1 Real v1 (Phases 5–9), covering all four
Materialvariants,--pin/--burnencryption modes, non-interactive automation, and CAS-protected receipt publication. Wire-format decisions documented here are stable — changes require a protocol version bump. v1.0 fixtures remain byte-identical; v1.1 additive fields preserve v1.0 byte-shape via#[serde(skip_serializing_if = "is_false")]. Editorial polish across the full v1.x scope continues.
Protocol version: cipherpost/v1
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). - Share — one published
OuterRecordcarrying an age-encrypted payload. - share_ref — 128-bit share identifier, 32-char lowercase hex (D-06, PAYL-05).
- Receipt — signed attestation published by the recipient under their own PKARR key after successful acceptance (D-RS-01..07).
- 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).
CAS contract on publish_receipt (Phase 9 lock-in): cas semantics on
publish_receipt are contractual: implementations MUST single-retry-then-fail on
pkarr::errors::ConcurrencyError (ConflictRisk / NotMostRecent / CasFailed —
all three are conflict-class signals per pkarr 5.0.4). Final-conflict failures
surface via Error::Transport (no public Error::CasConflict variant — error-oracle
hygiene per Pitfall #16). The retry loop lives inside the Transport trait method;
callers see Ok(()) or final Err. Divergence from this contract requires a
protocol_version bump.
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 1 in cipherpost/v1 |
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 v1.2 two-tier-storage architectural fix.
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 PKARR
key. The TXT value is the JSON serialization of OuterRecord. Inner signature is Ed25519
over JCS(OuterRecordSignable); outer signature is the PKARR SignedPacket signature handled
by pkarr::ClientBlocking.
| 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 1 |
D-07 |
pubkey |
String | z-base-32, 52 chars | Sender Ed25519/PKARR public key | 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 their own PKARR key at DNS label _cprcpt-<share_ref_hex>
(D-06). Signed with the recipient's Ed25519 identity key. Receipts are public by design:
no field is secret. Senders verify receipts using only public information.
| 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 |
nonce |
String | 32 lowercase hex chars | 128-bit random (OsRng) | D-RS-03 |
protocol_version |
u16 | JSON integer | Always 1 |
D-07 |
purpose |
String | UTF-8, control chars already stripped at send | Verbatim copy of Envelope.purpose |
D-RS-01, D-WIRE-05 |
recipient_pubkey |
String | z-base-32, 52 chars | Recipient Ed25519/PKARR public key | D-RS-01, D-RS-07 |
sender_pubkey |
String | z-base-32, 52 chars | Sender's PKARR public key (from OuterRecord.pubkey) |
D-RS-01 |
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 |
Signable projection: ReceiptSignable = Receipt minus signature. Same sign/verify
discipline as OuterRecordSignable (D-RS-07).
Receipt publication (TRANS-03, D-MRG-01..06): Receipts are published via resolve-merge-
republish under the recipient's PKARR key. The recipient resolves their existing SignedPacket,
re-builds a new SignedPacket preserving all existing TXT records (including _cipherpost
outgoing shares and any prior _cprcpt-* receipts), adds or replaces the TXT under label
_cprcpt-<this_share_ref_hex> with the new receipt's JSON bytes, and re-signs. DNS TTL on
receipt TXT records = 300 seconds. The wire budget (~1000 bytes total SignedPacket) applies
to the merged packet; overflow surfaces Error::WireBudgetExceeded { encoded, budget, plaintext: 0 }
(plaintext=0 distinguishes receipt overflow from share overflow) — D-MRG-06.
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 in cipherpost/v1 (D-SEQ-03).
The DNS TXT record labels used in the wire format are part of the protocol surface:
_cipherpost— published by senders carryingOuterRecord(§3.3)_cprcpt-<share_ref_hex>— published by recipients carryingReceipt(§3.4)
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.
Code constants enforcing these labels are covered by a constant-match test
(tests/dht_label_constants.rs) that 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) preserve the v1.0 wire shape byte-for-byte (the pin_required field
is elided from JCS via skip_serializing_if = is_false; no protocol_version
bump).
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")]— non-burn shares preserve v1.0 byte-identity (noprotocol_versionbump).- 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 (BURN-04). No
if !envelope.burn_after_readguard aroundpublish_receipt. Receipt = delivery confirmation; burn does NOT suppress attestation. Asserted bytests/burn_roundtrip.rs's receipt-count assertion (== 1 after first-then-second receive).
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.
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 PKARR public key in z-base-32 (52 chars) — matchesOuterRecord.pubkey.<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/v1; 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). - Build PKARR SignedPacket with TXT record under
_cipherpostcarrying theOuterRecordJSON. Verify encoded SignedPacket size ≤ ~1000 bytes (BEP44 budget, SEND-05). Overflow =Error::WireBudgetExceeded. Transport::publish(signed_packet). Print the share URI (cipherpost://<z32>/<hex>) to stdout (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 v1.2 — v1.1 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'spublish_receiptso the receipt path's existing CAS contract handles concurrent receipt writes (D-P9-A1;tests/cas_racer.rs). 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). 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. -
Transport::resolve(sender_z32)— returnsOuterRecordonly after the outer PKARR SignedPacket signature passes (verified insidepkarr::ClientBlocking). 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). -
Construct
Receipt, sign with recipient's Ed25519 key, callTransport::publish_receipt. On success: append a new ledger line withreceipt_published_at: <ISO-8601 UTC>(D-SEQ-04, D-SEQ-05). On 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).Transport::resolve_all_cprcpt(recipient_z32)→ iterator over all TXT records under recipient's PKARR key with label prefix_cprcpt-(D-OUT-03).- For each record: parse JSON →
Receipt. Parse failure → incrementmalformed_count. Otherwiseverify_receipt(&r). Signature-failure → incrementinvalid_count. Otherwise include. - If
--share-ref <ref>given, filter verified receipts to exact match after verification. - Render on stdout (human table by default;
--jsonemits JSON array on stdout — D-OUT-01); progressfetched N receipt(s); M valid, K malformed, L invalid-signatureon stderr (omit zero-count categories — D-OUT-03). - Exit codes (D-OUT-03):
- ≥1 valid: exit 0
- 0 valid + ≥1 invalid-signature: exit 3
- 0 valid + only malformed: exit 1
- 0 TXT records under
_cprcpt-: exit 5
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, 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 |
| 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 — reserved for TRANS-04 --dht-timeout 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 the v1.2 two-tier-storage
fix — do NOT remove them. Each carries a wire-budget: … #[ignore] reason
that points at this section + the v1.2 milestone.
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; v1.2 ships the wire-budget
escape hatch (chunking / two-tier storage / out-of-band).
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 v1.2's two-tier-storage 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": 1,
"pubkey": "pk-placeholder-z32",
"recipient": "rcpt-placeholder-z32",
"share_ref": "0123456789abcdef0123456789abcdef",
"ttl_seconds": 86400
}Canonical bytes (RFC 8785 JCS, 192 bytes):
7b22626c6f62223a2241414141222c22637265617465645f6174223a313730303030303030302c2270726f746f636f6c5f76657273696f6e223a312c227075626b6579223a22706b2d706c616365686f6c6465722d7a3332222c22726563697069656e74223a22726370742d706c616365686f6c6465722d7a3332222c2273686172655f726566223a223031323334353637383961626364656630313233343536373839616263646566222c2274746c5f7365636f6e6473223a38363430307d
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):
B1KQKUwXEHBLlXNekjU23LM+hkwz2w1XGjYg/X27tZSbX9opQozRgxKoVaAFbxmvfP2+HbOssOJ4DblpgcPdDw==
Keypair source: Same [0u8; 32] seed as §8.1.
Input — pretty-printed JSON:
{
"accepted_at": 1700000000,
"ciphertext_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"cleartext_hash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"nonce": "0123456789abcdef0123456789abcdef",
"protocol_version": 1,
"purpose": "canonical form fixture",
"recipient_pubkey": "rcpt-placeholder-z32",
"sender_pubkey": "sender-placeholder-z32",
"share_ref": "0123456789abcdef0123456789abcdef"
}Canonical bytes (RFC 8785 JCS, 424 bytes):
7b2261636365707465645f6174223a313730303030303030302c22636970686572746578745f68617368223a2261616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161222c22636c656172746578745f68617368223a2262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262222c226e6f6e6365223a223031323334353637383961626364656630313233343536373839616263646566222c2270726f746f636f6c5f76657273696f6e223a312c22707572706f7365223a2263616e6f6e6963616c20666f726d2066697874757265222c22726563697069656e745f7075626b6579223a22726370742d706c616365686f6c6465722d7a3332222c2273656e6465725f7075626b6579223a2273656e6465722d706c616365686f6c6465722d7a3332222c2273686172655f726566223a223031323334353637383961626364656630313233343536373839616263646566227d
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 (424 bytes).
- Ed25519-sign those bytes with the same
[0u8; 32]seed. - The signature MUST match the base64 below.
Signature (base64-STANDARD):
L8UWu/lYccsfB3pwZD6hoPu39ZWuNYt0/SRqDtI+xMpL7Z91Lof8vnFjFY2WtlQDDlZOH4H0srwf4LlmT6w7Aw==
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.
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/v1 takes cclink's v1.3.0
crypto pins verbatim.
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 PKARR key
at
_cprcpt-<share_ref_hex>, resolve-merge-republish to preserve coexisting records (TRANS-03).
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.