---
title: pvtcoms — Cryptographic Specification (for audit)
status: pre-audit
last_verified: 2026-06-12
verified_version: 0.1.48
---

# pvtcoms — Cryptographic Specification

Precise, implementation-accurate description of the cryptography, for a reviewer. Section refs point at
the source. All domain-separation strings and constants are quoted verbatim from the code. This
complements `DESIGN.md` §3.2 (the internal design doc shared with the auditor) with the *exact* constructions.

## 0. Primitives (audited crates, pinned)

- **X25519** key agreement (`x25519-dalek` 2.0.1), **Ed25519** signatures (`ed25519-dalek` 2.2.0).
- **ML-KEM-768** KEM (`libcrux-ml-kem` 0.0.9, formally verified; `ml-kem` 0.3.2 as a differential-KAT
  oracle only). PQ signatures are **not** used in v1 (Ed25519 for identity).
- **ChaCha20-Poly1305** AEAD (`chacha20poly1305` 0.10.1), **HKDF-SHA256** (`hkdf`), **HMAC-SHA256**
  (`hmac`), **BLAKE3** (token hashing), **subtle** (constant-time eq), **zeroize** (secret wiping).

## 1. Key-derivation core (`core/src/crypto.rs`)

```
derive_session_key(ikm, transcript) = HKDF-SHA256-Expand(
    PRK   = HKDF-Extract(salt = none, ikm),
    info  = KDF_INFO || transcript,
    L     = 32)
```
All session/sub keys are `Zeroizing<[u8;32]>`.

## 2. Hybrid PQ handshake (`core/src/handshake.rs`)

3-message, **identity-authenticated**, classical+PQ. Responder publishes an X25519 public key + an
ML-KEM-768 encapsulation key (`ek`); the initiator does the X25519 DH and ML-KEM encapsulation
(producing ciphertext `ct`).

**Session key (the critical MAL-BIND point):**
```
transcript    = auth_transcript = x_pub_R || x_pub_I || ek || ct        // every public value, both keys + ct
session_key   = hybrid_session_key(x25519_ss, ml_kem_ss, transcript)
              = derive_session_key( x25519_ss(32) || ml_kem_ss(32), transcript )
```
The combiner **absorbs the ML-KEM ciphertext and both public keys**, not just the two raw shared secrets
— ML-KEM alone is not MAL-BIND, so the ciphertext binding is mandatory. *(A prior version bound only the
X25519 public keys; that was fixed — please re-verify the binding is complete and canonical.)*

**Authentication:** both parties sign `HS_AUTH_CTX || role || transcript` with their long-term Ed25519
identity, where `HS_AUTH_CTX = "pvtcoms/handshake-auth/v1"` and `role ∈ {initiator, responder}`
(role-separated to stop reflection). Hello → AuthReply (initiator proof) → AuthConfirm (responder proof);
each side verifies the peer's identity signature before deriving/using the session, and aborts on
failure. First contact is **TOFU** (pin-on-first-use); reconnection enforces the pinned key.

## 3. Double Ratchet (`core/src/ratchet.rs`)

Symmetric-key ratchet + X25519 DH ratchet, seeded from the handshake.
```
root step:   (rk', ck) = HKDF-SHA256(salt = rk, ikm = dh_out, info = "pvtcoms/v1/ratchet-rk", L = 64)
chain step:  mk  = HMAC-SHA256(ck, 0x01)
             ck' = HMAC-SHA256(ck, 0x02)
msg key:     (key32 || nonce12) = HKDF-SHA256(ikm = mk, info = "pvtcoms/v1/msg", L = 44)
AEAD:        ChaCha20-Poly1305(key32, nonce12, aad = CHAT_AAD)
```
Skipped-message keys are stored to tolerate out-of-order/dropped messages, bounded by **`MAX_SKIP = 256`**
(anti-DoS). Each message uses a fresh `mk`/nonce — **no nonce is caller-supplied**.

## 4. Safety number / SAS (`core/src/crypto.rs::safety_emoji`)

```
raw   = derive_session_key(session_key, "sas")
sas   = 16 emoji, one per nibble of raw[..8]   →  64 bits, alphabet of 16 emoji
```
Both peers display the same 16 emoji iff there is no MITM. Unified across invite/live/verify (all from
the pairwise root `R`). *(64-bit was chosen after a 32-bit version was found grindable — re-assess the
security margin for the threat tiers.)*

## 5. At-rest sealing (`core/src/crypto.rs::seal_at_rest`)

```
salt    = 16 random bytes (OS CSPRNG)
subkey  = HKDF-SHA256(salt = salt, ikm = master_key, info = "pvtcoms/v1/at-rest/" || domain, L = 32)
blob    = salt || ChaCha20-Poly1305(subkey, nonce = 0^12, aad = domain, plaintext)
```
The **nonce is the fixed all-zero value**, which is safe *because the subkey is unique per random salt*
(salt → distinct subkey → first-and-only use of the zero nonce under that subkey). `domain`/AAD binds the
record kind so blobs aren't interchangeable. **Audit focus:** confirm there is no path where the same
`(master_key, salt, domain)` is reused, which would reuse the zero nonce under a repeated subkey. *(This
construction replaced an earlier fixed-nonce-on-mutable-data bug — see the nonce-audit report.)*

The device `master_key` itself: Windows DPAPI, macOS Keychain, Linux Secret Service (per platform); a
random dev-file key only as a headless fallback. See `client/src/oskeystore.rs` + ADR notes.

**Message-history DB (SQLCipher, `client/src/histdb.rs`):** the whole DB is page-encrypted (SQLCipher 4
defaults: AES-256-CBC + per-page HMAC-SHA512). It is keyed with the device key as a **raw key** and
opened with exactly:
```
PRAGMA key = "x'<64 hex>'";        -- raw key: bypasses PBKDF2 (the key is already high-entropy → kdf_iter is moot)
PRAGMA cipher_memory_security = ON;
PRAGMA secure_delete = ON;
PRAGMA trusted_schema = OFF;
PRAGMA journal_mode = WAL;
```
Opening **fails hard** (refuses to proceed) if the key is wrong / the file isn't a valid SQLCipher DB.
**Audit focus:** the raw-key choice + the consequence that `kdf_iter` is inert; WAL + the key lifecycle.

## 6. Offline / oblivious relay derivations (`core/src/offline.rs`, `directory.rs`, `mailbox.rs`)

All relay-facing tokens are HMAC/MAC of the **pairwise root `R`** (itself
`HMAC(session_key, "pvtcoms/v1/pairwise-root")`, directional A→B ≠ B→A), so the relay sees only opaque,
rotating, unlinkable tokens:
```
mailbox_token(R, sender_id, epoch)   = MAC(R, "pvtcoms/v1/mbx/loc",   sender_id, epoch_be)   // where to deposit
envelope_key(R, sender_id)           = MAC(R, "pvtcoms/v1/mbx/env",   sender_id)             // AEAD over the envelope
prekey_token(R, recipient_id, epoch) = MAC(R, "pvtcoms/v1/prekey/loc", recipient_id, epoch_be)
prekey_seal_key(R, recipient_id)     = MAC(R, "pvtcoms/v1/prekey/seal", recipient_id)
media_chunk_token(media_id, index)   = H("pvtcoms/v1/media-chunk", media_id, index)
```

**Directory records (connect-by-identity, `directory.rs`)** — the publisher-directional analogue, so a
contact resolves your current address by *who* you are, not a pasted token:
```
dir_token(R, publisher_id, epoch) = MAC(R, DIR_LOC_CTX, publisher_id, epoch_be)   // where to publish
dir_key(R, publisher_id)          = MAC(R, DIR_KEY_CTX, publisher_id)             // AEAD over the record
```
A record `{identity, seq, expires_at, addresses[]}` is Ed25519-signed (`SIGN_CTX || canonical`), sealed
under `dir_key` (synthetic nonce from `dir_key, epoch, seq`), and deposited under `dir_token`; the reader
verifies the signature against the **pinned** identity, checks `seq` monotonicity + `expires_at`. As of
v0.1.39 the client **auto-publishes** these on going online (`client/src/dir_client.rs`), in addition to
the manual command.

**Cover-deposit traffic (opt-in, `cover.rs`)** — masks *when* the user sends:
```
self_deposit_token(device_secret, epoch) = HMAC-SHA256(device_secret, "pvtcoms/v1/cover-self-token" || epoch_be)
cover_blob(seed, len)                     = HMAC-SHA256-CTR(seed, "pvtcoms/v1/cover-deposit")[..len]   // len = COVER_BLOB_LEN = 280
```
`device_secret` is the at-rest storage key (device-local; never the public identity), so only this device
can compute or reclaim the token — the relay and other circle members cannot. The blob is envelope-sized
and AEAD-ciphertext-indistinguishable (uniform-random-looking). **Audit focus:** confirm the self-token
is unguessable to the relay/circle, that a self-deposit + its later reclaim is indistinguishable from an
ordinary conversation token under the Tor circuit-isolation assumption, and that the timing-mask claim is
exactly as bounded as §M4/§3 of the claims doc states (idle-vs-active + volume, **not** per-send timing).
- **Forward secrecy** for offline messages: a symmetric send-chain (`sendchain.rs`, synthetic nonces,
  trial-then-commit, `MAX_SKIP=2000`).
- **Post-compromise security**: opportunistic **one-time hybrid prekeys** (X25519 + ML-KEM-768,
  `prekey.rs`), mixed into the chain when available; **fail-closed** when no prekey exists (no silent PCS
  downgrade) unless the user forces it.
- The relay envelope AEAD (`envelope_key`) hides the mix-header + length; payloads are padded into
  uniform buckets. **Audit focus:** linkability/traffic-analysis at the relay (tokens, sizes, timing,
  epoch windows); replay (random per-request salt + TTL); the directory records (signed, per-contact
  sealed, blinded).
- Anti-abuse: hashcash **PoW** (`pow.rs`) + a shared access-capability bearer secret (keeps strangers out
  without per-identity signatures that would deanonymize) — see ADR-005.

## 7. Wire format & serialization (`core/src/wire.rs` + relay/offline framing)

**Frame** (every transport message), `HEADER_LEN = 6`:
```
byte 0      version = 0x01            (read_frame rejects anything else)
byte 1      type    ∈ {HELLO=1, REPLY=2, MSG=3, AUTH_REPLY=5, CONFIRM=6}
bytes 2..6  length  = u32 big-endian  (rejected if > MAX_FRAME = 64 KiB)
bytes 6..   payload (exactly `length` bytes)
```
A **hand-written, length- and version-bounded** parser; **no** serde-derive/bincode/postcard on hostile
input. The AEAD tag is verified **before** acting on contents.

**Handshake transcript canonicalization** — all components are **fixed length**, so plain concatenation
is unambiguous (no length-prefix/canonicalization gap):
```
auth_transcript  = x_pub_R(32) || x_pub_I(32) || ek(1184) || ct(1088)      // X25519 pks; ML-KEM-768 ek/ct
signed_payload   = HS_AUTH_CTX || role(1) || auth_transcript               // fixed prefix + 1-byte role
```
`ENCAPSULATION_KEY_LEN = 1184`, `CIPHERTEXT_LEN = 1088`, X25519 public key = 32, all constant for
ML-KEM-768/X25519 — confirm the parser enforces these exact lengths and that no shorter field could let
one component "bleed" into the next.

**Audit focus:** memory/DoS safety, integer/length handling, that no decoded structure is used before
authentication, and that the fixed-length assumption holds for every signed/derived concatenation.

## 8. Known crypto caveats to weigh

- TOFU first contact (no PKI); SAS is the user's MITM defense — UX-dependent.
- No formal protocol model / proof; correctness rests on tests + this review.
- PQ protects key *agreement* (harvest-now-decrypt-later); the *ongoing* ratchet DH is classical X25519
  in v1 (a PQ ongoing ratchet is a documented v2 item).
- Ed25519 (not PQ) for long-term identity signatures in v1 (rationale in ADR-003).
