First consumer of the client side: buh_api::peer::probe_peer opens a PQ-mTLS
connection (presenting this node's leaf, pinning the peer's CA from the trust
registry), fetches /v1/health, and reports the peer's advertised CA fingerprint.
A probe succeeds only when BOTH nodes trust each other's CA, so it is a true
bidirectional trust check.
Deliberately not node↔node envelope forwarding — that belongs to the deferred
§10 mailbox-redundancy work and would be surface with no consumer today.
buh-cli gains `peer ping <host:port>`; it links buh-api (the node's own admin
tool). Documented caveat: Turso locks the datastore exclusively, so CLI commands
cannot run while this host's daemon holds the DB (a daemon-side admin path is the
fix — tracked for the next session).
cargo check/fmt/clippy(--all-features) clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EB3LjarCdXxqrJ4tFLn8LB
Every node is its own CA — no central PKI, no step-ca. Peers are pinned per-CA
by fingerprint (not a shared root); a peer is refused the instant its CA is
distrusted. The TLS key exchange is the X25519MLKEM768 hybrid, so a recorded
handshake is not harvest-now-decrypt-later material.
- buh-core: NodePki/NodeLeaf/PeerTrustRegistry/TrustedPeer ports (DER bytes +
fingerprint strings only — core stays free of rustls/rcgen); Ctx.pki +
Ctx.peer_trust.
- buh-data: RcgenNodeCa (ECDSA-P256 CA persisted under the pki dir; fingerprint
stable across restart; load_or_init/rekey/issue_leaf) and TursoPeerTrust
(migration 0002_peer_trust.sql). DataStack::with_node_pki.
- buh-api/src/tls.rs: pq_provider() (X25519MLKEM768 first), custom
PinnedClient/ServerCertVerifier (sync, pin CA-by-fingerprint at the chain tail
+ verify leaf↔CA signature + validity via x509-parser), RotatingResolver,
NodeTls. main.rs serves plain on `bind` OR PQ-mTLS on [pki].node_bind
(BUH_NODE_PORT) via tokio-rustls + hyper-util, with an in-process
leaf-rotation/trust-refresh timer. /v1/health advertises ca_fingerprint.
- buh-cli: ca init|show|rotate --force, peer trust|distrust|list.
- web: invite carries the node's real CA fingerprint (from /v1/health, zero in
plain dev); relay.pinCa/verifyPinnedCa make the app-layer pin explicit; demo
+ UI surface it.
- test: hermetic two-node handshake succeeds only with mutual per-CA pinning AND
X25519MLKEM768; refused without a pin; refused after distrust.
The CA signatures are classical (ECDSA-P256) by design: HNDL threatens
confidentiality (the key exchange, which is PQ), not signatures that only need
to hold at handshake time.
The node never links buh-crypto; buh-crypto links none of the TLS deps.
cargo test/clippy(--all-features)/fmt + wasm-pack + web build all green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EB3LjarCdXxqrJ4tFLn8LB
Large media never crosses the relay envelope. The sender seals a file once
under a fresh single-use content key, uploads the opaque ciphertext to a blob
node that cannot read it, and folds only {content key + locator} into the
ratchet. This lands that path end to end.
- buh-crypto `media`: seal_media/open_media over XChaCha20-Poly1305 with a
per-file content key, domain-separated from envelope sealing; MediaKey
(key‖nonce) serializes for folding into an envelope. Exposed through the WASM
facade (seal_media → SealedMedia{key_material, ciphertext}; open_media) and
re-exported in the web crypto index.
- buh-core: BlobStore is now a live port. `Ctx.blob` is Some only on nodes
running the blob role; `blob` orchestration gates on the role (501 otherwise),
clamps size (max_blob_bytes), and stays as thin and blind as the relay.
- buh-data: FsBlobStore (filesystem/ZFS, path-traversal-safe, atomic writes) is
always built; S3BlobStore (S3/MinIO) sits behind an opt-in `s3` feature so
filesystem nodes don't pull the AWS SDK. DataStack gains with_fs_blob /
with_s3_blob builders.
- buh-api: PUT/GET /v1/blob/{bucket}/{*key} serve opaque ciphertext, body-limit
bounded to max_blob_bytes; a [blob] config section opts the node into the role
and picks the backend. Integration test proves byte-verbatim round-trip,
404/501/400 edges, and that a non-blob node refuses.
Fix the web `wasm` npm script to an absolute --out-dir: wasm-pack resolves it
relative to the crate, so the old `../web/...` silently wrote the pkg to a stray
crates/web/ instead of the gitignored web/src/lib/crypto/pkg/.
cargo test/clippy(--all-features)/fmt, wasm-pack test --node, and the web build
all green. 57 buh-crypto native tests; 5 blob HTTP tests; Fs + core blob tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UMbqdSzDP4F7Y9AdK5LXaV
Per-message forward secrecy from HKDF-SHA256 symmetric chains plus
post-compromise healing from an X25519 DH step each time the conversation turns
(design.md §5.3). Hash chains are already quantum-safe, so the future PQ-rekey
layer slots in at the root KDF via the reserved wire tags — additive, no wire
break.
- chain.rs: KDF_RK / KDF_CK / message-key expansion, all HKDF-SHA256.
- header.rs: ratchet header (DH pub, PN, N) as canonical AEAD additional data, so
header tampering fails decryption.
- mod.rs: RatchetState with initiator/responder init handing off from pqxdh (the
responder's signed prekey is its first ratchet key), DH-ratchet steps, and a
bounded skipped-key store (MAX_SKIP per chain, hard cap across chains) for
out-of-order / dropped delivery.
Tests: full handshake → bidirectional chat over many DH steps; out-of-order,
dropped, replayed, and tampered messages handled correctly; proptest interop
over permuted+dropped delivery and long bidirectional runs (native only —
proptest's wait-timeout dep won't build for wasm). The whole client path
(hybrid PQXDH + ratchet) also runs green under wasm-bindgen-test in Node, the
same code the browser loads.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UMbqdSzDP4F7Y9AdK5LXaV
The two halves of the harvest-now-decrypt-later defence (design.md §5.2),
each a standalone primitive; pqxdh combines them into a root key next.
- X25519 (x25519-dalek): secret/public keys, encode round-trip, diffie_hellman.
Secrets seeded from getrandom directly (no rng-trait plumbing, wasm-clean).
- ML-KEM-768 (ml-kem 0.3.2, FIPS 203): keygen, encapsulate/decapsulate, key
encode/parse via kem's KeyExport; system-RNG methods route through getrandom
0.4 (wasm_js in browser).
Tests: X25519 DH agreement + encoding round-trip; ML-KEM encapsulate↔decapsulate
agreement, public-key round-trip, and the implicit-rejection property (a wrong
decapsulation key yields a different, non-erroring secret — so transcript
binding, not a decap error, is what authenticates). Compiles to wasm32.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UMbqdSzDP4F7Y9AdK5LXaV
Closes the Phase-0 scaffold: buh-crypto now compiles to WebAssembly and the web
client exercises the whole crypto stack in the browser before any session code
depends on it.
buh-crypto:
- `wasm` feature gates a small wasm-bindgen FFI (src/ffi.rs): echo plus
wire/aead/identity self-tests. `#![forbid(unsafe_code)]` is kept for the
native core and relaxed only under `wasm` (bindgen's glue is the only unsafe).
- getrandom standardised on 0.4 (the version crypto-common under ml-dsa pulls)
with the `wasm_js` backend selected via .cargo/config.toml for
wasm32-unknown-unknown; chacha20poly1305 default features off to drop the old
getrandom-0.2 path.
- tests/wasm.rs runs the wire / XChaCha20-Poly1305 / ML-DSA-65 KATs under
wasm-bindgen-test — the deterministic-signature digest matches native
byte-for-byte, so every primitive is now gated native AND wasm.
web/: Vite + React + SWC + TS app wired with vite-plugin-wasm +
vite-plugin-top-level-await over a `--target bundler` wasm-pack build. On load
it round-trips a Uint8Array through echo and runs the three KATs in-browser;
verified in Firefox — all four checks green, zero console errors. All crypto is
imported through src/lib/crypto/, never from the generated pkg/ directly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UMbqdSzDP4F7Y9AdK5LXaV
The user IS their key (design.md §4): identity is an ML-DSA-65 (FIPS 204)
keypair, hedged/randomized signing by default with a deterministic variant for
reproducible vectors. Fixed-size encode/parse for the public key (1952B) and
signature (3309B), verify-on-failure that leaks only that it failed.
AEAD: nonce-explicit XChaCha20-Poly1305 sealing primitive — the caller owns the
nonce (the ratchet will), so seal() is pure and KAT-pinnable; random_nonce() for
the one-off media-key cases. AAD is the place to bind the wire prelude+flags.
Tests gate the primitives:
- AEAD: the published draft-arciszewski-xchacha-03 A.1 vector (ciphertext+tag
byte-exact), plus tamper/ciphertext/tag/AAD-downgrade/wrong-key rejection.
- Identity: sign/verify round-trip, seed determinism, encode round-trip + length
checks, and a regression KAT pinning the seed=0 public key and deterministic
signature by SHA-256 digest (catches encoding / wasm divergence; upstream
ml-dsa carries the NIST ACVP vectors).
Adds ml-dsa (getrandom feature → hedged signing), chacha20poly1305, getrandom
0.3, sha2, hkdf to the workspace; only buh-crypto links them — the node never
does. CryptoError unifies wire/aead/signature/parse failures.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UMbqdSzDP4F7Y9AdK5LXaV
Establish the AGPL-3.0 cargo workspace per the org conventions, with the
crate layout for the anti-hub messenger: buh-entities (domain types/DTOs),
buh-crypto (client crypto core; compiles to WASM, never linked by the node),
and buh-core (business logic + MailboxRepo/BlobStore/SettlementBackend ports).
The settlement value types are deliberately chain- and money-free (design
§8.5); buh-core stays honestly thin, as a blind relay has little logic.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>