Compare commits

...

2 Commits

Author SHA1 Message Date
e2538c4cb0 chore(asset): hardened node deployment assets (Phase 7 part 2)
Ops files for running a buh node on an untrusted, third-party host: no central
control plane, no shared database, no central PKI.

- manifest.yml: per-node descriptor (prod PQ-mTLS+blob / dev plain loopback).
- systemd/buh-node.service: hardened unit (generic.md §8) — NO .path cert-reload
  unit, since the node is its own CA and rotates its leaf in process.
- systemd/buh-node-sweep.{service,timer}: periodic TTL sweep.
- buh.sysusers.conf, firewalld/buh-node.xml (opens BUH_NODE_PORT 8443).
- config/config.toml.tmpl ([relay]/[blob]/[pki]), deploy.sh (env-driven render,
  generates the node CA, prints the fingerprint), readme.md.

Not CI-tested — kept minimal and honest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EB3LjarCdXxqrJ4tFLn8LB
2026-06-30 15:47:15 +03:00
9a9cf0d609 feat(tls): decentralised PQ-mTLS ingress + per-node CA trust model (Phase 6)
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
2026-06-30 15:47:06 +03:00
34 changed files with 2317 additions and 28 deletions

338
Cargo.lock generated
View File

@@ -136,6 +136,45 @@ dependencies = [
"rustversion",
]
[[package]]
name = "asn1-rs"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048"
dependencies = [
"asn1-rs-derive",
"asn1-rs-impl",
"displaydoc",
"nom",
"num-traits",
"rusticata-macros",
"thiserror 1.0.69",
"time",
]
[[package]]
name = "asn1-rs-derive"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490"
dependencies = [
"proc-macro2",
"quote",
"syn",
"synstructure",
]
[[package]]
name = "asn1-rs-impl"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "async-trait"
version = "0.1.89"
@@ -205,6 +244,28 @@ dependencies = [
"zeroize",
]
[[package]]
name = "aws-lc-rs"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
]
[[package]]
name = "aws-runtime"
version = "1.5.16"
@@ -660,16 +721,24 @@ dependencies = [
"buh-entities",
"clap",
"figment",
"hex",
"http-body-util",
"hyper",
"hyper-util",
"rustls",
"rustls-post-quantum",
"serde",
"serde_json",
"sha2",
"tempfile",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tracing",
"tracing-subscriber",
"turso",
"x509-parser",
]
[[package]]
@@ -692,7 +761,7 @@ dependencies = [
"async-trait",
"buh-entities",
"chrono",
"thiserror",
"thiserror 2.0.18",
"tokio",
]
@@ -709,7 +778,7 @@ dependencies = [
"ml-kem",
"proptest",
"sha2",
"thiserror",
"thiserror 2.0.18",
"wasm-bindgen",
"wasm-bindgen-test",
"x25519-dalek",
@@ -726,10 +795,16 @@ dependencies = [
"buh-core",
"buh-entities",
"chrono",
"hex",
"rcgen",
"serde_json",
"sha2",
"tempfile",
"time",
"tokio",
"tracing",
"turso",
"x509-parser",
]
[[package]]
@@ -741,7 +816,7 @@ dependencies = [
"hex",
"serde",
"serde_json",
"thiserror",
"thiserror 2.0.18",
"uuid",
]
@@ -942,6 +1017,15 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "cmake"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
[[package]]
name = "cmov"
version = "0.5.4"
@@ -1130,6 +1214,12 @@ dependencies = [
"syn",
]
[[package]]
name = "data-encoding"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]]
name = "der"
version = "0.8.0"
@@ -1140,6 +1230,20 @@ dependencies = [
"zeroize",
]
[[package]]
name = "der-parser"
version = "9.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553"
dependencies = [
"asn1-rs",
"displaydoc",
"nom",
"num-bigint",
"num-traits",
"rusticata-macros",
]
[[package]]
name = "deranged"
version = "0.5.8"
@@ -1181,6 +1285,12 @@ dependencies = [
"syn",
]
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "either"
version = "1.16.0"
@@ -1294,6 +1404,12 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futures-channel"
version = "0.3.32"
@@ -1424,6 +1540,25 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "h2"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http 1.4.2",
"indexmap",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -1573,6 +1708,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http 1.4.2",
"http-body 1.0.1",
"httparse",
@@ -2113,6 +2249,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.1.0"
@@ -2138,6 +2284,15 @@ dependencies = [
"libm",
]
[[package]]
name = "oid-registry"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9"
dependencies = [
"asn1-rs",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -2229,6 +2384,16 @@ dependencies = [
"syn",
]
[[package]]
name = "pem"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
"base64",
"serde_core",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -2487,6 +2652,19 @@ dependencies = [
"rustversion",
]
[[package]]
name = "rcgen"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
dependencies = [
"pem",
"ring",
"rustls-pki-types",
"time",
"yasna",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -2531,6 +2709,20 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "roaring"
version = "0.11.3"
@@ -2562,6 +2754,15 @@ dependencies = [
"semver",
]
[[package]]
name = "rusticata-macros"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
dependencies = [
"nom",
]
[[package]]
name = "rustix"
version = "0.38.44"
@@ -2588,6 +2789,53 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-post-quantum"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0da3cd9229bac4fae1f589c8f875b3c891a058ddaa26eb3bde16b5e43dc174ce"
dependencies = [
"aws-lc-rs",
"rustls",
"rustls-webpki",
]
[[package]]
name = "rustls-webpki"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -2944,13 +3192,33 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
"thiserror-impl 2.0.18",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
@@ -3042,6 +3310,16 @@ dependencies = [
"syn",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
@@ -3161,7 +3439,7 @@ checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
dependencies = [
"crossbeam-channel",
"symlink",
"thiserror",
"thiserror 2.0.18",
"time",
"tracing-subscriber",
]
@@ -3236,7 +3514,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f2fe423c2c954948babb36edda12b737e321d8541d4eae519694f7d512ecab6"
dependencies = [
"mimalloc",
"thiserror",
"thiserror 2.0.18",
"tracing",
"tracing-subscriber",
"turso_sdk_kit",
@@ -3287,7 +3565,7 @@ dependencies = [
"strum",
"strum_macros",
"tempfile",
"thiserror",
"thiserror 2.0.18",
"tracing",
"tracing-subscriber",
"turso_ext",
@@ -3331,7 +3609,7 @@ dependencies = [
"miette",
"strum",
"strum_macros",
"thiserror",
"thiserror 2.0.18",
"turso_macros",
]
@@ -3376,7 +3654,7 @@ dependencies = [
"roaring",
"serde",
"serde_json",
"thiserror",
"thiserror 2.0.18",
"tracing",
"turso_core",
"turso_parser",
@@ -3454,6 +3732,12 @@ dependencies = [
"subtle",
]
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@@ -3729,6 +4013,15 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
@@ -3844,6 +4137,24 @@ dependencies = [
"zeroize",
]
[[package]]
name = "x509-parser"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69"
dependencies = [
"asn1-rs",
"data-encoding",
"der-parser",
"lazy_static",
"nom",
"oid-registry",
"ring",
"rusticata-macros",
"thiserror 1.0.69",
"time",
]
[[package]]
name = "xmlparser"
version = "0.13.6"
@@ -3856,6 +4167,15 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
[[package]]
name = "yasna"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
dependencies = [
"time",
]
[[package]]
name = "yoke"
version = "0.8.3"

View File

@@ -39,6 +39,17 @@ aws-config = { version = "1", default-features = false, features = ["behavior-ve
aws-sdk-s3 = { version = "1", default-features = false, features = ["rt-tokio"] }
aws-credential-types = "1"
# Node transport: decentralised PQ-mTLS (X25519MLKEM768) under a per-node CA. The node links
# these; buh-crypto (the client crypto core) must NOT — they are unrelated trust domains.
rustls = "0.23"
rustls-post-quantum = "0.2"
tokio-rustls = "0.26"
rcgen = "0.13"
# `verify` enables X509Certificate::verify_signature — the per-CA chain check in the TLS verifiers.
x509-parser = { version = "0.16", features = ["verify"] }
# rcgen leaf validity windows are `time::OffsetDateTime`; we compute them from the wall clock.
time = "0.3"
# Config
figment = { version = "0.10", features = ["toml", "env"] }

View File

@@ -0,0 +1,44 @@
# buh-api / buh-cli configuration template.
#
# deploy.sh renders this with values from manifest.yml, writing the result to
# /etc/buh/config.toml on the target. {{PLACEHOLDERS}} are substituted at deploy time. Env vars
# (BUH_*, nested with __, e.g. BUH_PKI__NODE_BIND) override any value here.
#
# There are no secrets in this file: the node holds no DB password (the datastore is an embedded
# Turso file) and generates its own CA/PKI on first start. The blob role, when using the `fs`
# backend, needs no credentials either.
bind = "{{BIND}}" # plain loopback health/debug; PQ-mTLS ingress is [pki].node_bind
db_path = "{{DB_PATH}}"
log_format = "{{LOG_FORMAT}}"
[relay]
default_ttl_seconds = {{DEFAULT_TTL_SECONDS}}
max_ttl_seconds = {{MAX_TTL_SECONDS}}
max_payload_bytes = {{MAX_PAYLOAD_BYTES}}
max_pull_limit = {{MAX_PULL_LIMIT}}
max_wait_seconds = {{MAX_WAIT_SECONDS}}
[blob]
# A node opts into the blob role. `fs` stores opaque client-encrypted ciphertext on local disk
# (or ZFS); `s3` (requires buh-api built with the `s3` feature) forwards to S3/MinIO.
enabled = {{BLOB_ENABLED}}
backend = "{{BLOB_BACKEND}}"
fs_root = "{{BLOB_FS_ROOT}}"
max_blob_bytes = {{MAX_BLOB_BYTES}}
s3_endpoint = "{{S3_ENDPOINT}}"
s3_region = "{{S3_REGION}}"
s3_access_key = "{{S3_ACCESS_KEY}}"
s3_secret_key = "{{S3_SECRET_KEY}}"
[pki]
# Decentralised PQ-mTLS (doc/design.md §5.1). When enabled, the node generates and self-serves
# its own CA, binds PQ-mTLS on node_bind (BUH_NODE_PORT), and rotates its leaf in process. There
# is no step-ca and no central PKI. Share the CA fingerprint (`buh-cli ca show`) with peers; trust
# theirs with `buh-cli peer trust <ca-fp>`.
enabled = {{PKI_ENABLED}}
dir = "{{PKI_DIR}}"
node_bind = "{{NODE_BIND}}"
sans = {{PKI_SANS}} # TOML array, e.g. ["node1.example.com"]
leaf_ttl_hours = {{LEAF_TTL_HOURS}}
rotate_every_hours = {{ROTATE_EVERY_HOURS}}

124
asset/deploy.sh Executable file
View File

@@ -0,0 +1,124 @@
#!/usr/bin/env bash
#
# Deploy one buh node on the local host (run as root on the target).
#
# buh has no central control plane, so this installs a single self-contained node: the binaries,
# a hardened systemd service, the TTL-sweep timer, the firewalld opening for BUH_NODE_PORT, and a
# rendered /etc/buh/config.toml. It then has the node generate its own CA and prints the
# fingerprint to share with peers. There is deliberately NO step-ca, NO central database
# bootstrap, and NO secret material to provision.
#
# Configuration values are taken from the environment (see DEFAULTS below) so this script stays a
# readable, dependency-free renderer; the committed manifest.yml documents the intended values per
# environment. Override any of them inline, e.g.:
#
# sudo BLOB_ENABLED=false PKI_SANS='["relay.example.com"]' ./deploy.sh
#
set -euo pipefail
if [[ ${EUID} -ne 0 ]]; then
echo "deploy.sh must run as root on the target node" >&2
exit 1
fi
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# --- Configuration (override via environment) --------------------------------------------------
: "${BIN_DIR:=/usr/local/bin}"
: "${CONFIG_DIR:=/etc/buh}"
: "${STATE_DIR:=/var/lib/buh}"
: "${BIND:=127.0.0.1:8080}"
: "${DB_PATH:=${STATE_DIR}/relay.db}"
: "${LOG_FORMAT:=json}"
: "${DEFAULT_TTL_SECONDS:=604800}"
: "${MAX_TTL_SECONDS:=2592000}"
: "${MAX_PAYLOAD_BYTES:=262144}"
: "${MAX_PULL_LIMIT:=100}"
: "${MAX_WAIT_SECONDS:=30}"
: "${BLOB_ENABLED:=true}"
: "${BLOB_BACKEND:=fs}"
: "${BLOB_FS_ROOT:=${STATE_DIR}/blobs}"
: "${MAX_BLOB_BYTES:=67108864}"
: "${S3_ENDPOINT:=}"
: "${S3_REGION:=us-east-1}"
: "${S3_ACCESS_KEY:=}"
: "${S3_SECRET_KEY:=}"
: "${PKI_ENABLED:=true}"
: "${PKI_DIR:=${STATE_DIR}/pki}"
: "${NODE_BIND:=0.0.0.0:8443}"
: "${PKI_SANS:=[\"localhost\"]}"
: "${LEAF_TTL_HOURS:=48}"
: "${ROTATE_EVERY_HOURS:=24}"
echo "==> Installing service account"
install -m 0644 "${here}/systemd/buh.sysusers.conf" /usr/lib/sysusers.d/buh.conf
systemd-sysusers
echo "==> Creating state tree ${STATE_DIR}"
install -d -m 0700 -o buh -g buh "${STATE_DIR}"
echo "==> Rendering ${CONFIG_DIR}/config.toml"
install -d -m 0755 "${CONFIG_DIR}"
render() {
local out="$1"
sed \
-e "s|{{BIND}}|${BIND}|g" \
-e "s|{{DB_PATH}}|${DB_PATH}|g" \
-e "s|{{LOG_FORMAT}}|${LOG_FORMAT}|g" \
-e "s|{{DEFAULT_TTL_SECONDS}}|${DEFAULT_TTL_SECONDS}|g" \
-e "s|{{MAX_TTL_SECONDS}}|${MAX_TTL_SECONDS}|g" \
-e "s|{{MAX_PAYLOAD_BYTES}}|${MAX_PAYLOAD_BYTES}|g" \
-e "s|{{MAX_PULL_LIMIT}}|${MAX_PULL_LIMIT}|g" \
-e "s|{{MAX_WAIT_SECONDS}}|${MAX_WAIT_SECONDS}|g" \
-e "s|{{BLOB_ENABLED}}|${BLOB_ENABLED}|g" \
-e "s|{{BLOB_BACKEND}}|${BLOB_BACKEND}|g" \
-e "s|{{BLOB_FS_ROOT}}|${BLOB_FS_ROOT}|g" \
-e "s|{{MAX_BLOB_BYTES}}|${MAX_BLOB_BYTES}|g" \
-e "s|{{S3_ENDPOINT}}|${S3_ENDPOINT}|g" \
-e "s|{{S3_REGION}}|${S3_REGION}|g" \
-e "s|{{S3_ACCESS_KEY}}|${S3_ACCESS_KEY}|g" \
-e "s|{{S3_SECRET_KEY}}|${S3_SECRET_KEY}|g" \
-e "s|{{PKI_ENABLED}}|${PKI_ENABLED}|g" \
-e "s|{{PKI_DIR}}|${PKI_DIR}|g" \
-e "s|{{NODE_BIND}}|${NODE_BIND}|g" \
-e "s|{{PKI_SANS}}|${PKI_SANS}|g" \
-e "s|{{LEAF_TTL_HOURS}}|${LEAF_TTL_HOURS}|g" \
-e "s|{{ROTATE_EVERY_HOURS}}|${ROTATE_EVERY_HOURS}|g" \
"${here}/config/config.toml.tmpl" > "${out}"
}
render "${CONFIG_DIR}/config.toml"
chmod 0640 "${CONFIG_DIR}/config.toml"
chgrp buh "${CONFIG_DIR}/config.toml"
echo "==> Installing systemd units"
install -m 0644 "${here}/systemd/buh-node.service" /etc/systemd/system/buh-node.service
install -m 0644 "${here}/systemd/buh-node-sweep.service" /etc/systemd/system/buh-node-sweep.service
install -m 0644 "${here}/systemd/buh-node-sweep.timer" /etc/systemd/system/buh-node-sweep.timer
systemctl daemon-reload
if [[ "${PKI_ENABLED}" == "true" ]]; then
echo "==> Opening BUH_NODE_PORT in firewalld"
install -m 0644 "${here}/firewalld/buh-node.xml" /etc/firewalld/services/buh-node.xml
firewall-cmd --reload
firewall-cmd --permanent --add-service=buh-node
firewall-cmd --reload
echo "==> Initialising the node CA"
runuser -u buh -- "${BIN_DIR}/buh-cli" --db-path "${DB_PATH}" --pki-dir "${PKI_DIR}" ca init
fi
echo "==> Enabling services"
systemctl enable --now buh-node.service
systemctl enable --now buh-node-sweep.timer
echo
echo "buh node deployed."
if [[ "${PKI_ENABLED}" == "true" ]]; then
echo "Share this CA fingerprint so peers/clients can pin you:"
runuser -u buh -- "${BIN_DIR}/buh-cli" --pki-dir "${PKI_DIR}" ca show
echo "Trust a peer with: buh-cli --db-path ${DB_PATH} peer trust <their-ca-fp>"
fi

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<service>
<short>buh-node</short>
<description>buh node PQ-mTLS ingress (BUH_NODE_PORT). This is the single port a node exposes:
the relay/blob API served over X25519MLKEM768 mutual TLS, with peers pinned per-CA. The plain
loopback health port (127.0.0.1:8080) is never opened. Forward this port from the edge
(OPNsense/router) to the node host.</description>
<port protocol="tcp" port="8443"/>
</service>

55
asset/manifest.yml Normal file
View File

@@ -0,0 +1,55 @@
app: buh
# buh nodes run on untrusted, third-party machines — there is no central control plane, no
# shared database, and no central PKI. Each node is its own CA (it self-serves PQ-mTLS and pins
# peers by CA fingerprint) and keeps all state in an embedded Turso datastore. This manifest is
# therefore a per-node deployment descriptor, not a fleet topology: `deploy.sh` renders one
# node's config from the block under the chosen environment/host.
environments:
prod:
components:
node:
# A node opts into roles. Every node runs the relay; this one also runs the blob role.
hosts: [node1.example.internal]
config:
# Plain loopback ingress is OFF in prod: the node is reachable only over PQ-mTLS on
# node_bind (the standardised BUH_NODE_PORT, forwarded from the OPNsense/firewalld edge).
bind: 127.0.0.1:8080 # health/debug on loopback only
db_path: /var/lib/buh/relay.db
log_format: json
relay:
default_ttl_seconds: 604800 # 7 days
max_ttl_seconds: 2592000 # 30 days
max_payload_bytes: 262144 # 256 KiB
max_pull_limit: 100
max_wait_seconds: 30
blob:
enabled: true
backend: fs # filesystem/ZFS; opaque client-encrypted ciphertext only
fs_root: /var/lib/buh/blobs
max_blob_bytes: 67108864 # 64 MiB
pki:
enabled: true
dir: /var/lib/buh/pki # CA key + cert; the node generates these on first start
node_bind: 0.0.0.0:8443 # BUH_NODE_PORT — the only port exposed at the edge
sans: [node1.example.com] # hostnames/IPs the leaf answers to
leaf_ttl_hours: 48
rotate_every_hours: 24 # in-process leaf rotation (no step-ca, no .path units)
dev:
components:
node:
hosts: [localhost]
config:
# Dev/web-demo mode: plain HTTP on loopback, PKI off, so the Vite proxy and the browser
# demo work without certificates. This is the mode the integration tests exercise.
bind: 127.0.0.1:8080
db_path: ./buh-relay.db
log_format: pretty
blob:
enabled: true
backend: fs
fs_root: ./buh-blobs
max_blob_bytes: 67108864
pki:
enabled: false

41
asset/readme.md Normal file
View File

@@ -0,0 +1,41 @@
# buh deployment assets
Ops files for running a buh node on a real host. **Not CI-tested** — kept minimal and honest.
A buh node runs on an untrusted, third-party machine, so these assets assume **no central control
plane, no shared database, and no central PKI**:
- `manifest.yml` — per-node deployment descriptor (relay + optional blob role, PQ-mTLS settings)
for the `prod` and `dev` environments. It is a descriptor, not a fleet topology.
- `config/config.toml.tmpl` — the rendered `buh-api`/`buh-cli` config. No secrets: the datastore
is an embedded Turso file and the node generates its own CA.
- `systemd/buh-node.service` — hardened unit (`generic.md` §8). **No `*.path` cert-reload unit**:
the node is its own CA and rotates its TLS leaf *in process* — that is the decentralised-CA
deviation. Nothing external watches or reloads a certificate.
- `systemd/buh-node-sweep.{service,timer}` — periodic TTL sweep of expired envelopes.
- `systemd/buh.sysusers.conf` — the unprivileged `buh` service account.
- `firewalld/buh-node.xml` — opens **`BUH_NODE_PORT` (8443)**, the single PQ-mTLS ingress port.
The plain loopback health port is never exposed.
- `deploy.sh` — installs the above on the local host, renders the config, has the node generate
its CA, and prints the CA fingerprint to share with peers.
## Quick start (on the target node, as root)
```sh
# build + install the binaries first
cargo build --release --features s3 # drop --features s3 for an fs-only blob node
install -m0755 target/release/buh-api target/release/buh-cli /usr/local/bin/
# then deploy (override any value via the environment — see deploy.sh DEFAULTS)
sudo PKI_SANS='["node1.example.com"]' ./asset/deploy.sh
```
## Trust between nodes
Each node pins peers by CA fingerprint — there is no shared root.
```sh
buh-cli ca show # print my fingerprint to hand to a peer
buh-cli peer trust <their-ca-fp> # accept that peer over PQ-mTLS
buh-cli peer distrust <their-ca-fp> # refuse them on the next handshake
buh-cli peer list # who I currently trust
```

View File

@@ -0,0 +1,25 @@
[Unit]
Description=buh TTL sweep — delete expired envelopes from the relay datastore
[Service]
Type=oneshot
User=buh
Group=buh
ExecStart=/usr/local/bin/buh-cli --db-path /var/lib/buh/relay.db sweep
# Same hardening posture as the node service; only the datastore is writable.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictRealtime=true
RestrictSUIDSGID=true
LockPersonality=true
MemoryDenyWriteExecute=true
SystemCallArchitectures=native
ReadWritePaths=/var/lib/buh
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6

View File

@@ -0,0 +1,13 @@
[Unit]
Description=Periodic buh TTL sweep of expired envelopes
[Timer]
# Envelopes also expire lazily on read; this sweep reclaims space for queues that are never
# pulled again. Hourly is ample for a store-and-forward relay.
OnCalendar=hourly
Persistent=true
RandomizedDelaySec=5m
Unit=buh-node-sweep.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,45 @@
[Unit]
Description=buh node — blind relay/mailbox (+ optional blob role) over PQ-mTLS
After=network-online.target
Wants=network-online.target
[Service]
Type=exec
User=buh
Group=buh
ExecStart=/usr/local/bin/buh-api --config /etc/buh/config.toml
Restart=on-failure
RestartSec=2
# The node is its own CA and rotates its own TLS leaf IN PROCESS on a timer (see [pki] in
# config.toml). This is the decentralised-CA deviation: there is deliberately NO step-ca and NO
# `.path` unit watching an externally-renewed certificate. Nothing external restarts this service
# for cert rotation.
# Hardening (generic.md §8) — relax an individual knob only if a feature genuinely requires it.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictRealtime=true
RestrictSUIDSGID=true
LockPersonality=true
MemoryDenyWriteExecute=true
SystemCallArchitectures=native
ProtectClock=true
ProtectHostname=true
RestrictNamespaces=true
# Writable state: embedded Turso datastore, the per-node CA/PKI material, and (if the blob role
# is enabled) the local blob store. All live under one tree.
ReadWritePaths=/var/lib/buh
# Network families the service needs (TCP over IPv4/IPv6; AF_UNIX for logging).
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,2 @@
#Type Name ID GECOS Home directory Shell
u buh - "buh node service account" /var/lib/buh /usr/sbin/nologin

View File

@@ -32,6 +32,17 @@ anyhow.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
# PQ-mTLS ingress (Phase 6). aws-lc-rs (pulled by rustls-post-quantum) supplies X25519MLKEM768.
rustls.workspace = true
rustls-post-quantum.workspace = true
tokio-rustls.workspace = true
x509-parser.workspace = true
sha2.workspace = true
hex.workspace = true
# Serve the axum router over a tokio-rustls acceptor (axum::serve cannot take a TLS listener).
hyper = { version = "1", features = ["http1", "http2"] }
hyper-util = { version = "0.1", features = ["tokio", "server-auto", "service"] }
[dev-dependencies]
tempfile.workspace = true
base64.workspace = true

View File

@@ -21,6 +21,30 @@ pub struct AppConfig {
pub relay: RelayConfig,
/// Blob-role configuration (disabled by default — a node opts into the blob role).
pub blob: BlobConfig,
/// PQ-mTLS ingress + per-node CA (disabled by default; `bind` stays plain loopback for dev).
pub pki: PkiConfig,
}
/// PQ-mTLS / per-node-CA configuration (`doc/design.md` §5.1, the decentralised-CA deviation).
///
/// When `enabled`, the node generates (on first start) and self-serves its own CA, binds a
/// PQ-mTLS listener on `node_bind` (the standardised `BUH_NODE_PORT`, forwarded from the edge),
/// and auto-rotates its leaf in process. When disabled, the node serves plain HTTP on `bind` —
/// the loopback mode the dev web demo and tests use, with no certificates.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PkiConfig {
/// Whether this node serves PQ-mTLS (and thus is its own CA). Off → plain loopback on `bind`.
pub enabled: bool,
/// Directory holding the persisted CA key + cert (`/var/lib/buh/pki` in prod).
pub dir: String,
/// Address for the PQ-mTLS ingress listener (the standardised `BUH_NODE_PORT`).
pub node_bind: String,
/// Subject alternative names stamped on issued leaves (hostnames/IPs the node answers to).
pub sans: Vec<String>,
/// Validity window of each issued leaf, in hours.
pub leaf_ttl_hours: u64,
/// How often the in-process timer issues a fresh leaf, in hours (well inside `leaf_ttl_hours`).
pub rotate_every_hours: u64,
}
/// Blob-role configuration. A node runs the blob role only when `enabled` is set; it then
@@ -84,6 +108,14 @@ impl Default for AppConfig {
s3_access_key: String::new(),
s3_secret_key: String::new(),
},
pki: PkiConfig {
enabled: false,
dir: "/var/lib/buh/pki".to_string(),
node_bind: "0.0.0.0:8443".to_string(),
sans: vec!["localhost".to_string()],
leaf_ttl_hours: 48,
rotate_every_hours: 24,
},
}
}
}

View File

@@ -22,9 +22,15 @@ use buh_entities::{
use crate::error::ApiError;
use crate::state::AppState;
/// Liveness probe.
pub async fn health() -> Json<Value> {
Json(json!({ "status": "ok" }))
/// Liveness probe. On a PQ-mTLS node it also advertises the node's CA fingerprint — the public
/// value clients pin (`doc/design.md` §5.1). Exposing it is safe (it is the node's public
/// identity) and lets a client confirm the fingerprint carried in an invite matches the node it
/// is actually talking to.
pub async fn health(State(state): State<AppState>) -> Json<Value> {
match state.ctx.pki.as_ref() {
Some(pki) => Json(json!({ "status": "ok", "ca_fingerprint": pki.ca_fingerprint() })),
None => Json(json!({ "status": "ok" })),
}
}
/// `POST /v1/queue/{queue_id}/envelopes` — push a sealed envelope.

View File

@@ -10,6 +10,7 @@ pub mod error;
pub mod handlers;
pub mod router;
pub mod state;
pub mod tls;
pub use config::AppConfig;
pub use router::router;

View File

@@ -7,15 +7,21 @@
#![forbid(unsafe_code)]
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use clap::Parser;
use hyper_util::rt::{TokioExecutor, TokioIo};
use hyper_util::server::conn::auto::Builder as ConnBuilder;
use hyper_util::service::TowerToHyperService;
use tokio::net::TcpListener;
use tokio_rustls::TlsAcceptor;
use tracing_subscriber::EnvFilter;
use buh_api::config::{AppConfig, BlobConfig};
use buh_api::config::{AppConfig, BlobConfig, PkiConfig};
use buh_api::router::router;
use buh_api::state::AppState;
use buh_api::tls::{NodeTls, TrustStore};
use buh_data::DataStack;
/// Command-line arguments.
@@ -46,21 +52,131 @@ async fn main() -> anyhow::Result<()> {
tracing::info!(backend = %config.blob.backend, "blob role enabled");
}
// PQ-mTLS opt-in: a node serving the decentralised per-node CA needs its PKI + trust ports.
if config.pki.enabled {
stack = stack.with_node_pki(
&config.pki.dir,
config.pki.sans.clone(),
Duration::from_secs(config.pki.leaf_ttl_hours * 3600),
)?;
}
let state = AppState {
ctx: stack.ctx.clone(),
max_wait: Duration::from_secs(config.relay.max_wait_seconds),
};
let app = router(state);
let listener = TcpListener::bind(&config.bind).await?;
tracing::info!(bind = %config.bind, "buh-api listening");
if config.pki.enabled {
serve_pqmtls(app, &stack, &config.pki).await
} else {
serve_plain(app, &config.bind).await
}
}
axum::serve(listener, router(state))
/// Plain-HTTP loopback ingress: the dev/web-demo mode (and what the integration tests exercise
/// through the router directly). No certificates.
async fn serve_plain(app: axum::Router, bind: &str) -> anyhow::Result<()> {
let listener = TcpListener::bind(bind).await?;
tracing::warn!(bind = %bind, "buh-api listening (PLAIN HTTP — dev/loopback mode, PQ-mTLS off)");
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
Ok(())
}
/// PQ-mTLS ingress: serve the router over X25519MLKEM768 mutual TLS, pinning peer CAs from the
/// trust registry, with the leaf auto-rotating on an in-process timer.
async fn serve_pqmtls(app: axum::Router, stack: &DataStack, pki: &PkiConfig) -> anyhow::Result<()> {
let node_pki = stack
.ctx
.pki
.clone()
.expect("pki port set when pki.enabled");
let registry = stack
.ctx
.peer_trust
.clone()
.expect("peer_trust port set when pki.enabled");
// Load the trusted peer-CA set into the live snapshot the verifiers read.
let trust = TrustStore::new();
refresh_trust(&trust, registry.as_ref()).await;
let node_tls = NodeTls::new(node_pki.clone(), trust.clone())?;
tracing::info!(
ca_fingerprint = %node_pki.ca_fingerprint(),
node_bind = %pki.node_bind,
"PQ-mTLS node: share this CA fingerprint with peers/clients to be trusted"
);
// In-process leaf rotation + periodic trust refresh.
{
let node_tls = node_tls.clone();
let trust = trust.clone();
let registry = registry.clone();
let every = Duration::from_secs(pki.rotate_every_hours.max(1) * 3600);
tokio::spawn(async move {
let mut tick = tokio::time::interval(every);
tick.tick().await; // consume the immediate first tick
loop {
tick.tick().await;
match node_tls.rotate_leaf() {
Ok(exp) => tracing::info!(not_after_ms = exp, "rotated PQ-mTLS leaf"),
Err(e) => tracing::error!(error = %e, "leaf rotation failed"),
}
refresh_trust(&trust, registry.as_ref()).await;
}
});
}
let acceptor = TlsAcceptor::from(Arc::new(node_tls.server_config()?));
let listener = TcpListener::bind(&pki.node_bind).await?;
tracing::info!(node_bind = %pki.node_bind, "buh-api listening (PQ-mTLS)");
let shutdown = shutdown_signal();
tokio::pin!(shutdown);
loop {
tokio::select! {
() = &mut shutdown => {
tracing::info!("shutdown signal received, no longer accepting connections");
break;
}
accepted = listener.accept() => {
let (stream, peer) = match accepted {
Ok(pair) => pair,
Err(e) => { tracing::warn!(error = %e, "accept failed"); continue; }
};
let acceptor = acceptor.clone();
let svc = TowerToHyperService::new(app.clone());
tokio::spawn(async move {
match acceptor.accept(stream).await {
Ok(tls) => {
let io = TokioIo::new(tls);
if let Err(e) =
ConnBuilder::new(TokioExecutor::new()).serve_connection(io, svc).await
{
tracing::debug!(error = %e, "connection closed with error");
}
}
// A refused handshake (unpinned/distrusted peer) is normal, not an error.
Err(e) => tracing::debug!(error = %e, peer = %peer, "TLS handshake refused"),
}
});
}
}
}
Ok(())
}
/// Replace the live trust snapshot with the current registry contents.
async fn refresh_trust(trust: &TrustStore, registry: &dyn buh_core::PeerTrustRegistry) {
match registry.list().await {
Ok(peers) => trust.replace(peers.into_iter().map(|p| p.ca_fingerprint)),
Err(e) => tracing::error!(error = %e, "failed to load peer trust registry"),
}
}
/// Attach the configured blob backend to the data stack, enabling the node's blob role. The
/// `s3` backend requires the daemon to be built with the `s3` feature.
fn wire_blob(stack: DataStack, blob: &BlobConfig) -> anyhow::Result<DataStack> {

412
crates/buh-api/src/tls.rs Normal file
View File

@@ -0,0 +1,412 @@
//! Decentralised PQ-mTLS for node ingress and node↔node calls (`doc/design.md` §5.1).
//!
//! This is the per-node-CA deviation made concrete. There is **no central PKI and no step-ca**:
//!
//! - **Key exchange is post-quantum.** The [`CryptoProvider`] offers **X25519MLKEM768** first, so
//! a handshake recorded today is not harvest-now-decrypt-later material.
//! - **Trust is per-CA, pinned by fingerprint — not a shared root.** Each node is its own CA
//! ([`buh_core::NodePki`]); a peer is accepted only if the CA at the tail of its presented chain
//! has a fingerprint in this node's [`TrustStore`], *and* its leaf cryptographically chains to
//! that CA. Distrusting a CA refuses it on the very next handshake.
//! - **Leaves auto-rotate in process.** The server reads the current leaf through a
//! [`RotatingResolver`]; a timer swaps a freshly issued leaf in without dropping connections.
//!
//! The custom verifiers ([`PinnedClientCertVerifier`], [`PinnedServerCertVerifier`]) are the heart
//! of the model. They are synchronous (as rustls requires), so they read a cached snapshot of the
//! trust set rather than touching the database on the handshake path.
use std::collections::HashSet;
use std::fmt;
use std::sync::{Arc, RwLock};
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::crypto::{CryptoProvider, verify_tls12_signature, verify_tls13_signature};
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName, UnixTime};
use rustls::server::danger::{ClientCertVerified, ClientCertVerifier};
use rustls::server::{ClientHello, ResolvesServerCert};
use rustls::sign::CertifiedKey;
use rustls::{
ClientConfig, DigitallySignedStruct, DistinguishedName, Error, ServerConfig, SignatureScheme,
};
use x509_parser::prelude::*;
use buh_core::{CoreError, NodeLeaf, NodePki};
use buh_data::fingerprint;
/// A live, shareable snapshot of the peer-CA fingerprints this node trusts.
///
/// Cloned into the certificate verifiers; the daemon swaps its contents (via [`Self::replace`])
/// whenever the operator changes trust, so the verifiers see the change without a restart.
#[derive(Clone, Default)]
pub struct TrustStore {
inner: Arc<RwLock<HashSet<String>>>,
}
impl fmt::Debug for TrustStore {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let n = self.inner.read().map(|s| s.len()).unwrap_or(0);
write!(f, "TrustStore({n} pinned)")
}
}
impl TrustStore {
/// An empty trust store (trusts nobody until told to).
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Build from an initial set of (already-normalised) CA fingerprints.
#[must_use]
pub fn from_fingerprints(fps: impl IntoIterator<Item = String>) -> Self {
Self {
inner: Arc::new(RwLock::new(fps.into_iter().collect())),
}
}
/// Atomically replace the whole trusted set (used to refresh from the registry).
pub fn replace(&self, fps: impl IntoIterator<Item = String>) {
let mut g = self.inner.write().expect("trust store poisoned");
*g = fps.into_iter().collect();
}
/// Whether a CA fingerprint is currently trusted.
#[must_use]
pub fn contains(&self, ca_fingerprint: &str) -> bool {
self.inner
.read()
.expect("trust store poisoned")
.contains(ca_fingerprint)
}
}
/// Build the post-quantum [`CryptoProvider`]: aws-lc-rs with **X25519MLKEM768 preferred**, then
/// classical X25519 as a fallback for peers that have not yet enabled the hybrid group.
#[must_use]
pub fn pq_provider() -> Arc<CryptoProvider> {
use rustls::crypto::aws_lc_rs;
let mut provider = aws_lc_rs::default_provider();
provider.kx_groups = vec![
aws_lc_rs::kx_group::X25519MLKEM768,
aws_lc_rs::kx_group::X25519,
];
Arc::new(provider)
}
/// Verify a presented chain `[leaf, …, ca]` against the pinned trust set: the CA (chain tail) must
/// be trusted by fingerprint, must be a self-consistent CA, and the leaf must chain to it and be
/// currently valid. This is the single trust decision shared by both verifiers.
fn verify_pinned_chain(
end_entity: &CertificateDer<'_>,
intermediates: &[CertificateDer<'_>],
now: UnixTime,
trust: &TrustStore,
) -> Result<(), Error> {
// Our nodes always present [leaf, CA]; the CA is the tail.
let ca_der = intermediates
.last()
.ok_or_else(|| Error::General("peer presented no issuing CA".into()))?;
// 1. Pin: the CA fingerprint must be one we were told to trust.
if !trust.contains(&fingerprint(ca_der)) {
return Err(Error::General("peer CA is not trusted (unpinned)".into()));
}
// 2. Parse both certificates.
let (_, ca) = X509Certificate::from_der(ca_der)
.map_err(|_| Error::General("malformed CA cert".into()))?;
let (_, leaf) = X509Certificate::from_der(end_entity)
.map_err(|_| Error::General("malformed leaf cert".into()))?;
// 3. The pinned cert must actually be a CA, self-signed (it is a root).
match ca.basic_constraints() {
Ok(Some(bc)) if bc.value.ca => {}
_ => return Err(Error::General("pinned cert is not a CA".into())),
}
ca.verify_signature(None)
.map_err(|_| Error::General("CA self-signature invalid".into()))?;
// 4. The leaf must be signed by that CA.
leaf.verify_signature(Some(ca.public_key()))
.map_err(|_| Error::General("leaf does not chain to pinned CA".into()))?;
// 5. Both must be temporally valid.
let now_secs = i64::try_from(now.as_secs()).unwrap_or(i64::MAX);
check_validity(&leaf, now_secs, "leaf")?;
check_validity(&ca, now_secs, "CA")?;
Ok(())
}
/// Reject a certificate whose validity window does not contain `now_secs`.
fn check_validity(cert: &X509Certificate<'_>, now_secs: i64, what: &str) -> Result<(), Error> {
let v = cert.validity();
if now_secs < v.not_before.timestamp() {
return Err(Error::General(format!("{what} not yet valid")));
}
if now_secs > v.not_after.timestamp() {
return Err(Error::General(format!("{what} expired")));
}
Ok(())
}
/// Server-side verifier: accept a client whose CA we pin and whose leaf chains to it.
#[derive(Debug)]
pub struct PinnedClientCertVerifier {
provider: Arc<CryptoProvider>,
trust: TrustStore,
/// We give no root hints (each node has a single leaf and sends it unprompted).
no_hints: Vec<DistinguishedName>,
}
impl PinnedClientCertVerifier {
#[must_use]
fn new(provider: Arc<CryptoProvider>, trust: TrustStore) -> Self {
Self {
provider,
trust,
no_hints: Vec::new(),
}
}
}
impl ClientCertVerifier for PinnedClientCertVerifier {
fn root_hint_subjects(&self) -> &[DistinguishedName] {
&self.no_hints
}
fn verify_client_cert(
&self,
end_entity: &CertificateDer<'_>,
intermediates: &[CertificateDer<'_>],
now: UnixTime,
) -> Result<ClientCertVerified, Error> {
verify_pinned_chain(end_entity, intermediates, now, &self.trust)?;
Ok(ClientCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, Error> {
verify_tls12_signature(
message,
cert,
dss,
&self.provider.signature_verification_algorithms,
)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, Error> {
verify_tls13_signature(
message,
cert,
dss,
&self.provider.signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
self.provider
.signature_verification_algorithms
.supported_schemes()
}
}
/// Client-side verifier: accept a server whose CA we pin and whose leaf chains to it. Trust is by
/// CA fingerprint, so the TLS server name is irrelevant and not checked.
#[derive(Debug)]
pub struct PinnedServerCertVerifier {
provider: Arc<CryptoProvider>,
trust: TrustStore,
}
impl PinnedServerCertVerifier {
#[must_use]
fn new(provider: Arc<CryptoProvider>, trust: TrustStore) -> Self {
Self { provider, trust }
}
}
impl ServerCertVerifier for PinnedServerCertVerifier {
fn verify_server_cert(
&self,
end_entity: &CertificateDer<'_>,
intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
now: UnixTime,
) -> Result<ServerCertVerified, Error> {
verify_pinned_chain(end_entity, intermediates, now, &self.trust)?;
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, Error> {
verify_tls12_signature(
message,
cert,
dss,
&self.provider.signature_verification_algorithms,
)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, Error> {
verify_tls13_signature(
message,
cert,
dss,
&self.provider.signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
self.provider
.signature_verification_algorithms
.supported_schemes()
}
}
/// A server cert resolver whose current leaf can be swapped atomically by the rotation timer.
#[derive(Debug)]
pub struct RotatingResolver {
current: RwLock<Arc<CertifiedKey>>,
}
impl RotatingResolver {
fn new(initial: Arc<CertifiedKey>) -> Self {
Self {
current: RwLock::new(initial),
}
}
fn store(&self, next: Arc<CertifiedKey>) {
*self.current.write().expect("resolver poisoned") = next;
}
}
impl ResolvesServerCert for RotatingResolver {
fn resolve(&self, _client_hello: ClientHello<'_>) -> Option<Arc<CertifiedKey>> {
Some(self.current.read().expect("resolver poisoned").clone())
}
}
/// Turn a freshly issued [`NodeLeaf`] into a rustls [`CertifiedKey`].
fn certified_key(
provider: &CryptoProvider,
leaf: &NodeLeaf,
) -> Result<Arc<CertifiedKey>, CoreError> {
let certs: Vec<CertificateDer<'static>> = leaf
.chain_der
.iter()
.cloned()
.map(CertificateDer::from)
.collect();
let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(leaf.private_key_der.clone()));
let signing_key = provider
.key_provider
.load_private_key(key_der)
.map_err(tls("load leaf key"))?;
Ok(Arc::new(CertifiedKey::new(certs, signing_key)))
}
/// The assembled PQ-mTLS material for one node: the PQ provider, its trust snapshot, the rotating
/// server leaf, and the [`NodePki`] used to keep issuing leaves.
#[derive(Clone)]
pub struct NodeTls {
provider: Arc<CryptoProvider>,
trust: TrustStore,
resolver: Arc<RotatingResolver>,
pki: Arc<dyn NodePki>,
}
impl NodeTls {
/// Assemble from this node's [`NodePki`] and a trust snapshot, issuing the first leaf.
pub fn new(pki: Arc<dyn NodePki>, trust: TrustStore) -> Result<Self, CoreError> {
let provider = pq_provider();
let leaf = pki.issue_leaf()?;
let resolver = Arc::new(RotatingResolver::new(certified_key(&provider, &leaf)?));
Ok(Self {
provider,
trust,
resolver,
pki,
})
}
/// The trust snapshot (so the daemon can refresh it from the registry).
#[must_use]
pub fn trust(&self) -> &TrustStore {
&self.trust
}
/// Issue a fresh leaf and swap it into the live resolver. Returns its expiry (epoch ms) so the
/// caller can schedule the next rotation. Existing connections keep their handshake leaf.
pub fn rotate_leaf(&self) -> Result<i64, CoreError> {
let leaf = self.pki.issue_leaf()?;
let next = certified_key(&self.provider, &leaf)?;
self.resolver.store(next);
Ok(leaf.not_after_ms)
}
/// A TLS 1.3-only server config that requires a client cert (mutual auth), pins the client CA,
/// and serves the rotating leaf.
pub fn server_config(&self) -> Result<ServerConfig, CoreError> {
let verifier = Arc::new(PinnedClientCertVerifier::new(
self.provider.clone(),
self.trust.clone(),
));
ServerConfig::builder_with_provider(self.provider.clone())
.with_protocol_versions(&[&rustls::version::TLS13])
.map_err(tls("server protocol versions"))
.map(|b| {
b.with_client_cert_verifier(verifier)
.with_cert_resolver(self.resolver.clone())
})
}
/// A TLS 1.3-only client config that presents a freshly issued leaf and pins the server CA.
/// Used for node↔node calls and by the hermetic handshake test.
pub fn client_config(&self) -> Result<ClientConfig, CoreError> {
let leaf = self.pki.issue_leaf()?;
let certs: Vec<CertificateDer<'static>> = leaf
.chain_der
.iter()
.cloned()
.map(CertificateDer::from)
.collect();
let key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(leaf.private_key_der.clone()));
let verifier = Arc::new(PinnedServerCertVerifier::new(
self.provider.clone(),
self.trust.clone(),
));
ClientConfig::builder_with_provider(self.provider.clone())
.with_protocol_versions(&[&rustls::version::TLS13])
.map_err(tls("client protocol versions"))?
.dangerous()
.with_custom_certificate_verifier(verifier)
.with_client_auth_cert(certs, key)
.map_err(tls("client auth cert"))
}
}
/// Map a rustls error into a [`CoreError`].
fn tls(ctx: &'static str) -> impl Fn(Error) -> CoreError {
move |e| CoreError::Internal(format!("tls: {ctx}: {e}"))
}

View File

@@ -0,0 +1,192 @@
//! Hermetic PQ-mTLS handshake between two in-process nodes (`doc/design.md` §5.1, Phase 6).
//!
//! No external CA, no network beyond loopback. Each node is its own CA; a handshake succeeds
//! only when **each side pins the other's CA fingerprint**, the key exchange is the post-quantum
//! X25519MLKEM768 hybrid, and a peer is refused the instant its CA is distrusted.
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio_rustls::{TlsAcceptor, TlsConnector};
use rustls::NamedGroup;
use rustls::pki_types::ServerName;
use buh_api::tls::{NodeTls, TrustStore};
use buh_core::NodePki;
use buh_data::RcgenNodeCa;
/// Spin up a node CA in a fresh temp dir. The dir is leaked so the CA files outlive the call.
fn node_ca() -> Arc<dyn NodePki> {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().to_path_buf();
std::mem::forget(dir);
Arc::new(
RcgenNodeCa::load_or_init(path, vec!["node".to_string()], Duration::from_secs(3600))
.expect("init node CA"),
)
}
/// Run a one-shot PQ-mTLS server with `server_tls`; it accepts a single connection, echoes one
/// byte, and reports back the negotiated key-exchange group (or `None` if the handshake failed).
async fn run_server(
server_tls: NodeTls,
) -> (
std::net::SocketAddr,
tokio::task::JoinHandle<Option<NamedGroup>>,
) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let acceptor = TlsAcceptor::from(Arc::new(server_tls.server_config().unwrap()));
let handle = tokio::spawn(async move {
let (stream, _) = listener.accept().await.ok()?;
let mut tls = acceptor.accept(stream).await.ok()?;
let group = tls
.get_ref()
.1
.negotiated_key_exchange_group()
.map(|g| g.name());
let mut buf = [0u8; 1];
let _ = tls.read_exact(&mut buf).await;
let _ = tls.write_all(b"!").await;
let _ = tls.shutdown().await;
group
});
(addr, handle)
}
/// Attempt a client handshake to `addr` with `client_tls`. On success returns the negotiated
/// key-exchange group; any handshake/verification failure is surfaced as `Err`.
async fn try_client(addr: std::net::SocketAddr, client_tls: NodeTls) -> Result<NamedGroup, String> {
let connector = TlsConnector::from(Arc::new(
client_tls.client_config().map_err(|e| e.to_string())?,
));
let stream = TcpStream::connect(addr).await.map_err(|e| e.to_string())?;
let name = ServerName::try_from("node").unwrap();
let mut tls = connector
.connect(name, stream)
.await
.map_err(|e| e.to_string())?;
let group = tls
.get_ref()
.1
.negotiated_key_exchange_group()
.map(|g| g.name())
.ok_or_else(|| "no kx group".to_string())?;
tls.write_all(b"?").await.map_err(|e| e.to_string())?;
let mut buf = [0u8; 1];
tls.read_exact(&mut buf).await.map_err(|e| e.to_string())?;
Ok(group)
}
#[tokio::test]
async fn handshake_succeeds_when_each_pins_the_other_and_uses_x25519mlkem768() {
let a = node_ca();
let b = node_ca();
// A trusts B's CA; B trusts A's CA.
let a_tls = NodeTls::new(
a.clone(),
TrustStore::from_fingerprints([b.ca_fingerprint().to_string()]),
)
.unwrap();
let b_tls = NodeTls::new(
b.clone(),
TrustStore::from_fingerprints([a.ca_fingerprint().to_string()]),
)
.unwrap();
let (addr, server) = run_server(a_tls).await;
let client_group = try_client(addr, b_tls)
.await
.expect("handshake should succeed");
let server_group = server
.await
.unwrap()
.expect("server handshake should succeed");
assert_eq!(
client_group,
NamedGroup::X25519MLKEM768,
"client kx must be PQ hybrid"
);
assert_eq!(
server_group,
NamedGroup::X25519MLKEM768,
"server kx must be PQ hybrid"
);
}
#[tokio::test]
async fn handshake_refused_when_server_does_not_pin_the_client_ca() {
let a = node_ca();
let b = node_ca();
// A trusts B, but B is NOT trusted by A's client-cert verifier… invert: A trusts *nobody*,
// so B's client certificate is rejected even though B pins A.
let a_tls = NodeTls::new(a.clone(), TrustStore::new()).unwrap();
let b_tls = NodeTls::new(
b.clone(),
TrustStore::from_fingerprints([a.ca_fingerprint().to_string()]),
)
.unwrap();
let (addr, server) = run_server(a_tls).await;
let result = try_client(addr, b_tls).await;
assert!(result.is_err(), "client must be refused: {result:?}");
assert!(
server.await.unwrap().is_none(),
"server must reject the handshake"
);
}
#[tokio::test]
async fn handshake_refused_when_client_does_not_pin_the_server_ca() {
let a = node_ca();
let b = node_ca();
// A trusts B's client cert, but B pins nobody, so B rejects A's server certificate.
let a_tls = NodeTls::new(
a.clone(),
TrustStore::from_fingerprints([b.ca_fingerprint().to_string()]),
)
.unwrap();
let b_tls = NodeTls::new(b.clone(), TrustStore::new()).unwrap();
let (addr, server) = run_server(a_tls).await;
let result = try_client(addr, b_tls).await;
assert!(
result.is_err(),
"client must refuse the unpinned server: {result:?}"
);
let _ = server.await;
}
#[tokio::test]
async fn distrust_refuses_a_previously_trusted_peer() {
let a = node_ca();
let b = node_ca();
let a_trust = TrustStore::from_fingerprints([b.ca_fingerprint().to_string()]);
let a_tls = NodeTls::new(a.clone(), a_trust.clone()).unwrap();
let b_tls = NodeTls::new(
b.clone(),
TrustStore::from_fingerprints([a.ca_fingerprint().to_string()]),
)
.unwrap();
// Distrust B before any connection: replace A's trust set with the empty set.
a_trust.replace(std::iter::empty());
let (addr, server) = run_server(a_tls).await;
let result = try_client(addr, b_tls).await;
assert!(
result.is_err(),
"distrusted peer must be refused: {result:?}"
);
assert!(server.await.unwrap().is_none());
}

View File

@@ -1,15 +1,21 @@
//! buh operator/admin CLI.
//!
//! Milestone 1 covers datastore migration and the TTL sweep. Phase 6 adds `ca init|rotate`
//! and `peer trust|distrust`; later phases add queue stats and blob verification.
//! Covers datastore migration, the TTL sweep, the per-node CA (`ca init|rotate|show`), and the
//! peer-CA trust registry (`peer trust|distrust|list`) — the operator surface of the
//! decentralised PQ-mTLS deviation (`doc/design.md` §5.1).
#![forbid(unsafe_code)]
use std::time::Duration;
use clap::{Parser, Subcommand};
use tracing_subscriber::EnvFilter;
use buh_core::{CoreConfig, mailbox};
use buh_data::DataStack;
use buh_core::{CoreConfig, NodePki, PeerTrustRegistry, mailbox};
use buh_data::{DataStack, RcgenNodeCa, TursoPeerTrust};
/// Leaf validity is irrelevant to CA-management commands; any value loads the CA.
const NOMINAL_LEAF_TTL: Duration = Duration::from_secs(48 * 3600);
/// buh operator/admin CLI.
#[derive(Debug, Parser)]
@@ -18,6 +24,9 @@ struct Cli {
/// Path to the embedded Turso datastore.
#[arg(long, env = "BUH_DB_PATH", default_value = "buh-relay.db")]
db_path: String,
/// Directory holding this node's CA (key + cert).
#[arg(long, env = "BUH_PKI__DIR", default_value = "/var/lib/buh/pki")]
pki_dir: String,
#[command(subcommand)]
command: Command,
}
@@ -28,6 +37,46 @@ enum Command {
Migrate,
/// Delete expired envelopes (TTL sweep). Prints the number removed.
Sweep,
/// Manage this node's CA (the identity peers pin).
#[command(subcommand)]
Ca(CaCommand),
/// Manage the peer-CA trust registry (which peers this node accepts over PQ-mTLS).
#[command(subcommand)]
Peer(PeerCommand),
}
#[derive(Debug, Subcommand)]
enum CaCommand {
/// Create the node CA if absent (idempotent), then print its fingerprint.
Init,
/// Print this node's CA fingerprint (the value peers/clients pin).
Show,
/// Re-key the CA — generate a brand-new CA, backing up the old one to `*.bak`. Destructive:
/// every peer must re-pin the new fingerprint. Requires `--force`.
Rotate {
/// Confirm the destructive re-key.
#[arg(long)]
force: bool,
},
}
#[derive(Debug, Subcommand)]
enum PeerCommand {
/// Trust a peer node by its CA fingerprint (lowercase hex SHA-256; `:` separators allowed).
Trust {
/// The peer CA fingerprint to pin.
ca_fp: String,
/// Optional note recording who/what this CA belongs to.
#[arg(long)]
note: Option<String>,
},
/// Stop trusting a peer CA. Refused on the peer's next handshake.
Distrust {
/// The peer CA fingerprint to remove.
ca_fp: String,
},
/// List the trusted peer CAs.
List,
}
#[tokio::main]
@@ -37,19 +86,91 @@ async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt().with_env_filter(filter).init();
let cli = Cli::parse();
let stack = DataStack::connect(&cli.db_path, CoreConfig::default()).await?;
match cli.command {
Command::Migrate => {
stack.migrate().await?;
stack(&cli.db_path).await?.migrate().await?;
println!("migrations applied");
}
Command::Sweep => {
let stack = stack(&cli.db_path).await?;
stack.migrate().await?;
let removed = mailbox::sweep(&stack.ctx).await?;
println!("swept {removed} expired envelope(s)");
}
Command::Ca(cmd) => run_ca(&cli.pki_dir, cmd)?,
Command::Peer(cmd) => run_peer(&cli.db_path, cmd).await?,
}
Ok(())
}
/// Open the datastore (no roles attached — CLI commands wire what they need).
async fn stack(db_path: &str) -> anyhow::Result<DataStack> {
Ok(DataStack::connect(db_path, CoreConfig::default()).await?)
}
fn run_ca(pki_dir: &str, cmd: CaCommand) -> anyhow::Result<()> {
match cmd {
CaCommand::Init => {
let ca = RcgenNodeCa::load_or_init(pki_dir, default_sans(), NOMINAL_LEAF_TTL)?;
println!("CA ready in {pki_dir}");
println!("ca_fingerprint {}", ca.ca_fingerprint());
}
CaCommand::Show => {
let ca = RcgenNodeCa::load_or_init(pki_dir, default_sans(), NOMINAL_LEAF_TTL)?;
println!("{}", ca.ca_fingerprint());
}
CaCommand::Rotate { force } => {
if !force {
anyhow::bail!(
"ca rotate re-keys the CA and changes the fingerprint every peer pins; \
re-run with --force to confirm"
);
}
let ca = RcgenNodeCa::rekey(pki_dir, default_sans(), NOMINAL_LEAF_TTL)?;
println!("CA re-keyed; old material backed up to *.bak in {pki_dir}");
println!("ca_fingerprint {}", ca.ca_fingerprint());
println!("peers must now re-pin this fingerprint");
}
}
Ok(())
}
async fn run_peer(db_path: &str, cmd: PeerCommand) -> anyhow::Result<()> {
let stack = stack(db_path).await?;
stack.migrate().await?;
let registry = TursoPeerTrust::new(stack.db.clone());
match cmd {
PeerCommand::Trust { ca_fp, note } => {
registry.trust(&ca_fp, note.as_deref()).await?;
println!("trusting peer CA {ca_fp}");
}
PeerCommand::Distrust { ca_fp } => {
if registry.distrust(&ca_fp).await? {
println!("distrusted peer CA {ca_fp}");
} else {
println!("peer CA {ca_fp} was not trusted");
}
}
PeerCommand::List => {
let peers = registry.list().await?;
if peers.is_empty() {
println!("no trusted peer CAs");
}
for p in peers {
match p.note {
Some(note) => println!("{} {}", p.ca_fingerprint, note),
None => println!("{}", p.ca_fingerprint),
}
}
}
}
Ok(())
}
/// SANs are only meaningful for issued leaves, not CA management; stamp a sane default.
fn default_sans() -> Vec<String> {
vec!["localhost".to_string()]
}

View File

@@ -124,6 +124,8 @@ mod tests {
Ctx {
mailbox: Arc::new(NoMailbox),
blob,
pki: None,
peer_trust: None,
config: CoreConfig::default(),
}
}

View File

@@ -5,7 +5,7 @@
use std::sync::Arc;
use crate::ports::{BlobStore, MailboxRepo};
use crate::ports::{BlobStore, MailboxRepo, NodePki, PeerTrustRegistry};
/// Non-secret tuning knobs for relay logic.
#[derive(Debug, Clone)]
@@ -44,6 +44,10 @@ pub struct Ctx {
pub mailbox: Arc<dyn MailboxRepo>,
/// Opaque media object store — `Some` only on nodes running the blob role.
pub blob: Option<Arc<dyn BlobStore>>,
/// This node's own PKI (CA + leaf issuance) — `Some` once PQ-mTLS ingress is configured.
pub pki: Option<Arc<dyn NodePki>>,
/// Peer-CA trust registry — `Some` alongside [`Ctx::pki`] on a PQ-mTLS node.
pub peer_trust: Option<Arc<dyn PeerTrustRegistry>>,
/// Tuning knobs.
pub config: CoreConfig,
}

View File

@@ -15,4 +15,6 @@ pub mod ports;
pub use context::{CoreConfig, Ctx};
pub use error::{CoreError, CoreResult};
pub use ports::{BlobStore, MailboxRepo, SettlementBackend};
pub use ports::{
BlobStore, MailboxRepo, NodeLeaf, NodePki, PeerTrustRegistry, SettlementBackend, TrustedPeer,
};

View File

@@ -81,6 +81,72 @@ pub trait BlobStore: Send + Sync {
) -> Result<String, CoreError>;
}
/// A freshly issued node leaf certificate plus its private key, DER-encoded.
///
/// Returned by [`NodePki::issue_leaf`]; the transport layer (`buh-api`) turns it into a rustls
/// signing key. Core stays free of rustls/rcgen types — only opaque DER bytes cross this seam.
#[derive(Clone)]
pub struct NodeLeaf {
/// The certificate chain, **leaf first then the issuing node CA**, each DER-encoded. Peers
/// pin the CA (the last element) by fingerprint; the leaf is verified to chain to it.
pub chain_der: Vec<Vec<u8>>,
/// The leaf private key, PKCS#8 DER.
pub private_key_der: Vec<u8>,
/// Leaf expiry as epoch milliseconds — drives the in-process rotation timer.
pub not_after_ms: i64,
}
/// A node's own PKI: a long-lived CA whose fingerprint peers and clients pin, and the
/// short-lived leaves it issues for the PQ-mTLS listener (`doc/design.md` §5.1 — the
/// decentralised per-node-CA deviation).
///
/// There is **no central PKI and no step-ca**: every node is its own root of trust. Trust is
/// established peer-to-peer by pinning a CA fingerprint (see [`PeerTrustRegistry`]), never via a
/// shared root. Implemented by `buh-data`'s `RcgenNodeCa`.
pub trait NodePki: Send + Sync {
/// The CA fingerprint clients pin: **lowercase hex SHA-256 of the CA certificate DER**.
fn ca_fingerprint(&self) -> &str;
/// The node CA certificate, DER-encoded (distributed out of band / carried in invites).
fn ca_cert_der(&self) -> &[u8];
/// Issue a fresh short-lived leaf signed by the CA. Called once on startup and again on each
/// tick of the in-process rotation timer.
fn issue_leaf(&self) -> Result<NodeLeaf, CoreError>;
}
/// One trusted peer-CA entry.
#[derive(Debug, Clone)]
pub struct TrustedPeer {
/// The pinned CA fingerprint (lowercase hex SHA-256 of the CA cert DER).
pub ca_fingerprint: String,
/// Optional operator note (who/what this CA belongs to).
pub note: Option<String>,
/// When trust was recorded.
pub trusted_at: DateTime<Utc>,
}
/// Per-CA peer trust registry: the set of peer-node CA fingerprints this node will accept on a
/// PQ-mTLS handshake. Backed by the embedded Turso DB (`doc/design.md` §5.1, Node trust model).
///
/// Pinning is **per CA, not a shared root**: a node trusts exactly the CAs it has been told to,
/// and a peer is refused the instant its CA is distrusted. The transport layer reads a cached
/// snapshot of this set inside its (synchronous) certificate verifiers.
#[async_trait]
pub trait PeerTrustRegistry: Send + Sync {
/// Trust a peer CA by fingerprint (idempotent; updates the note if already present).
async fn trust(&self, ca_fingerprint: &str, note: Option<&str>) -> Result<(), CoreError>;
/// Remove trust for a peer CA. Returns `false` if it was not trusted.
async fn distrust(&self, ca_fingerprint: &str) -> Result<bool, CoreError>;
/// Whether a CA fingerprint is currently trusted.
async fn is_trusted(&self, ca_fingerprint: &str) -> Result<bool, CoreError>;
/// All trusted peer CAs, newest first.
async fn list(&self) -> Result<Vec<TrustedPeer>, CoreError>;
}
/// Edge settlement backend (Phase 7 / `doc/design.md` §8.5).
///
/// **The one component with no architectural opinion worth defending — pure value-in/value-out

View File

@@ -21,6 +21,14 @@ chrono.workspace = true
tokio.workspace = true
tracing.workspace = true
# Per-node PQ-mTLS CA (the decentralised-CA deviation): generate the node CA and issue
# short-lived leaves. rcgen signs with a classical algorithm — the post-quantum strength is in
# the TLS key exchange (X25519MLKEM768), which is what harvest-now-decrypt-later threatens.
rcgen = { workspace = true, features = ["pem"] }
time.workspace = true
sha2.workspace = true
hex.workspace = true
# Blob backends.
aws-config = { workspace = true, optional = true }
aws-sdk-s3 = { workspace = true, optional = true }
@@ -29,3 +37,4 @@ aws-credential-types = { workspace = true, optional = true }
[dev-dependencies]
tempfile.workspace = true
serde_json.workspace = true
x509-parser.workspace = true

View File

@@ -0,0 +1,12 @@
-- Peer-CA trust registry (Phase 6 / doc/design.md §5.1, the decentralised per-node-CA deviation).
--
-- The set of peer-node CA fingerprints this node will accept on a PQ-mTLS handshake. Trust is
-- per-CA: a fingerprint is pinned (lowercase hex SHA-256 of the peer CA cert DER), never a shared
-- root. There is deliberately no peer identity, address, or social graph here — only the opaque
-- fingerprints an operator has chosen to trust, and an optional human note.
CREATE TABLE IF NOT EXISTS peer_trust (
ca_fingerprint TEXT PRIMARY KEY, -- lowercase hex SHA-256 of the peer CA cert DER
note TEXT, -- optional operator note (who/what this CA is)
trusted_at INTEGER NOT NULL -- epoch milliseconds
);

View File

@@ -10,6 +10,8 @@
mod error;
mod fs_blob;
mod migrate;
mod node_ca;
mod peer_trust;
mod stack;
mod stub_settlement;
mod turso_mailbox;
@@ -18,6 +20,8 @@ mod turso_mailbox;
mod s3_blob;
pub use fs_blob::FsBlobStore;
pub use node_ca::{RcgenNodeCa, fingerprint};
pub use peer_trust::TursoPeerTrust;
pub use stack::DataStack;
pub use stub_settlement::StubSettlement;
pub use turso_mailbox::TursoMailboxRepo;

View File

@@ -13,7 +13,10 @@ use buh_core::CoreError;
use crate::error::repo;
/// The ordered set of embedded migrations: `(version, sql)`.
const MIGRATIONS: &[(i64, &str)] = &[(1, include_str!("../migrations/0001_init.sql"))];
const MIGRATIONS: &[(i64, &str)] = &[
(1, include_str!("../migrations/0001_init.sql")),
(2, include_str!("../migrations/0002_peer_trust.sql")),
];
/// Apply any migrations not yet recorded in `schema_version`, in order.
pub async fn run(conn: &Connection) -> Result<(), CoreError> {

View File

@@ -0,0 +1,315 @@
//! [`NodePki`] implemented with `rcgen`: the decentralised per-node CA (`doc/design.md` §5.1).
//!
//! Each node is its own root of trust — **no central PKI, no step-ca**. On first start a node
//! generates a long-lived CA, persists it under the configurable PKI dir, and thereafter issues
//! its own short-lived TLS leaves which auto-rotate in process. Peers/clients pin the node by its
//! CA fingerprint (lowercase hex SHA-256 of the CA cert DER); the leaf is verified to chain to it.
//!
//! The CA signs with a classical algorithm (ECDSA P-256). That is deliberate: the post-quantum
//! property buh needs at the transport is *confidentiality* against harvest-now-decrypt-later,
//! which is provided by the X25519MLKEM768 key exchange in [`crate`]'s sibling `buh-api` TLS
//! layer. Certificate *signatures* only need to be unforgeable at handshake time, so a classical
//! signature is sufficient and avoids depending on not-yet-standardised PQ certificate formats.
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;
use rcgen::{
BasicConstraints, Certificate, CertificateParams, DistinguishedName, DnType,
ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose,
};
use sha2::{Digest, Sha256};
use time::OffsetDateTime;
use buh_core::{CoreError, NodeLeaf, NodePki};
/// Filename of the persisted CA certificate (DER — the exact bytes the fingerprint covers).
const CA_CERT_FILE: &str = "ca.cert.der";
/// Filename of the persisted CA private key (PKCS#8 PEM).
const CA_KEY_FILE: &str = "ca.key.pem";
/// Subject/issuer common name stamped on the node CA. Rebuilt identically on load.
const CA_COMMON_NAME: &str = "buh node CA";
/// Subject common name stamped on issued leaves.
const LEAF_COMMON_NAME: &str = "buh node leaf";
/// Backdate leaves slightly to tolerate small clock skew between peers.
const CLOCK_SKEW: Duration = Duration::from_secs(300);
/// A node's own CA plus the parameters needed to keep issuing leaves that chain to it.
///
/// Cheap to clone? No — holds key material; wrap in `Arc` (it implements [`NodePki`]).
pub struct RcgenNodeCa {
/// The CA key pair (signs every issued leaf).
ca_key: KeyPair,
/// A reconstructed CA certificate used only as the *issuer* when signing leaves. Its own DER
/// is irrelevant — leaves chain to [`Self::ca_der`] cryptographically, by the CA public key.
ca_issuer: Certificate,
/// The canonical, persisted CA certificate DER (fingerprint source + chain element).
ca_der: Vec<u8>,
/// Lowercase hex SHA-256 of [`Self::ca_der`] — the value clients pin.
ca_fingerprint: String,
/// Subject alternative names stamped on issued leaves.
sans: Vec<String>,
/// Validity window applied to each issued leaf.
leaf_ttl: Duration,
}
impl RcgenNodeCa {
/// Load the node CA from `pki_dir`, generating and persisting a fresh one on first start.
///
/// `sans` are the subject alternative names stamped on issued leaves (hostnames/IPs the node
/// answers to). `leaf_ttl` is each leaf's validity window; the caller re-issues on a timer
/// well inside it. Files are written `0600`/`0700` on Unix.
pub fn load_or_init(
pki_dir: impl Into<PathBuf>,
sans: Vec<String>,
leaf_ttl: Duration,
) -> Result<Self, CoreError> {
let dir = pki_dir.into();
let cert_path = dir.join(CA_CERT_FILE);
let key_path = dir.join(CA_KEY_FILE);
let (ca_key, ca_der) = if cert_path.exists() && key_path.exists() {
let key_pem = fs::read_to_string(&key_path).map_err(io("read CA key"))?;
let ca_key = KeyPair::from_pem(&key_pem).map_err(pki("parse CA key"))?;
let ca_der = fs::read(&cert_path).map_err(io("read CA cert"))?;
(ca_key, ca_der)
} else {
let ca_key = KeyPair::generate().map_err(pki("generate CA key"))?;
let ca_cert = ca_params()?
.self_signed(&ca_key)
.map_err(pki("self-sign CA"))?;
let ca_der = ca_cert.der().to_vec();
persist(&dir, &cert_path, &key_path, &ca_der, &ca_key)?;
(ca_key, ca_der)
};
Self::from_loaded(ca_key, ca_der, sans, leaf_ttl)
}
/// Re-key the node CA: back up any existing CA material (to `*.bak`) and generate a fresh CA,
/// changing the node's pinned fingerprint. Destructive — every peer must re-pin the new
/// fingerprint. Used by `buh-cli ca rotate`.
pub fn rekey(
pki_dir: impl Into<PathBuf>,
sans: Vec<String>,
leaf_ttl: Duration,
) -> Result<Self, CoreError> {
let dir = pki_dir.into();
let cert_path = dir.join(CA_CERT_FILE);
let key_path = dir.join(CA_KEY_FILE);
for path in [&cert_path, &key_path] {
if path.exists() {
let bak = path.with_file_name(format!(
"{}.bak",
path.file_name().and_then(|n| n.to_str()).unwrap_or("ca")
));
fs::rename(path, &bak).map_err(io("back up old CA"))?;
}
}
Self::load_or_init(dir, sans, leaf_ttl)
}
/// Finish construction from loaded/generated CA material: rebuild the signing issuer and
/// compute the pinned fingerprint.
fn from_loaded(
ca_key: KeyPair,
ca_der: Vec<u8>,
sans: Vec<String>,
leaf_ttl: Duration,
) -> Result<Self, CoreError> {
// Rebuild a CA certificate object to use as the signing issuer. Deterministic params +
// the same key reproduce the same subject DN and key identifier, so issued leaves chain
// to the persisted CA DER regardless of this object's own (ignored) serialization.
let ca_issuer = ca_params()?
.self_signed(&ca_key)
.map_err(pki("rebuild CA issuer"))?;
let ca_fingerprint = fingerprint(&ca_der);
Ok(Self {
ca_key,
ca_issuer,
ca_der,
ca_fingerprint,
sans,
leaf_ttl,
})
}
}
impl NodePki for RcgenNodeCa {
fn ca_fingerprint(&self) -> &str {
&self.ca_fingerprint
}
fn ca_cert_der(&self) -> &[u8] {
&self.ca_der
}
fn issue_leaf(&self) -> Result<NodeLeaf, CoreError> {
let leaf_key = KeyPair::generate().map_err(pki("generate leaf key"))?;
let now = OffsetDateTime::now_utc();
let not_before = now - self.leaf_ttl_skew();
let not_after = now + self.leaf_ttl;
let mut params = CertificateParams::new(self.sans.clone()).map_err(pki("leaf params"))?;
params.distinguished_name = dn(LEAF_COMMON_NAME);
params.is_ca = IsCa::NoCa;
params.not_before = not_before;
params.not_after = not_after;
params.use_authority_key_identifier_extension = true;
params.key_usages = vec![KeyUsagePurpose::DigitalSignature];
// The node is both a TLS server (ingress) and a TLS client (node↔node), so the leaf
// carries both purposes.
params.extended_key_usages = vec![
ExtendedKeyUsagePurpose::ServerAuth,
ExtendedKeyUsagePurpose::ClientAuth,
];
let leaf = params
.signed_by(&leaf_key, &self.ca_issuer, &self.ca_key)
.map_err(pki("sign leaf"))?;
Ok(NodeLeaf {
chain_der: vec![leaf.der().to_vec(), self.ca_der.clone()],
private_key_der: leaf_key.serialize_der(),
not_after_ms: (not_after.unix_timestamp()) * 1000,
})
}
}
impl RcgenNodeCa {
fn leaf_ttl_skew(&self) -> time::Duration {
time::Duration::seconds(CLOCK_SKEW.as_secs() as i64)
}
}
/// Compute the pinned fingerprint of a CA certificate DER: lowercase hex SHA-256, no separators.
#[must_use]
pub fn fingerprint(ca_der: &[u8]) -> String {
hex::encode(Sha256::digest(ca_der))
}
/// Deterministic parameters for the node CA. Rebuilt identically on every load so the
/// reconstructed issuer matches the persisted certificate.
fn ca_params() -> Result<CertificateParams, CoreError> {
let mut params = CertificateParams::new(Vec::<String>::new()).map_err(pki("CA params"))?;
params.distinguished_name = dn(CA_COMMON_NAME);
params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0));
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
Ok(params)
}
/// A distinguished name carrying a single common name.
fn dn(common_name: &str) -> DistinguishedName {
let mut dn = DistinguishedName::new();
dn.push(DnType::CommonName, common_name);
dn
}
/// Persist the CA cert (DER) and key (PEM) with restrictive permissions.
fn persist(
dir: &Path,
cert_path: &Path,
key_path: &Path,
ca_der: &[u8],
ca_key: &KeyPair,
) -> Result<(), CoreError> {
fs::create_dir_all(dir).map_err(io("create PKI dir"))?;
restrict_dir(dir)?;
fs::write(cert_path, ca_der).map_err(io("write CA cert"))?;
fs::write(key_path, ca_key.serialize_pem()).map_err(io("write CA key"))?;
restrict_file(key_path)?;
Ok(())
}
#[cfg(unix)]
fn restrict_dir(dir: &Path) -> Result<(), CoreError> {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(dir, fs::Permissions::from_mode(0o700)).map_err(io("chmod PKI dir"))
}
#[cfg(unix)]
fn restrict_file(path: &Path) -> Result<(), CoreError> {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(io("chmod CA key"))
}
#[cfg(not(unix))]
fn restrict_dir(_dir: &Path) -> Result<(), CoreError> {
Ok(())
}
#[cfg(not(unix))]
fn restrict_file(_path: &Path) -> Result<(), CoreError> {
Ok(())
}
/// Map an rcgen error into a [`CoreError`].
fn pki(ctx: &'static str) -> impl Fn(rcgen::Error) -> CoreError {
move |e| CoreError::Internal(format!("pki: {ctx}: {e}"))
}
/// Map an I/O error into a [`CoreError`].
fn io(ctx: &'static str) -> impl Fn(std::io::Error) -> CoreError {
move |e| CoreError::Internal(format!("pki: {ctx}: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
fn ca(dir: &Path) -> RcgenNodeCa {
RcgenNodeCa::load_or_init(
dir,
vec!["localhost".to_string()],
Duration::from_secs(3600),
)
.expect("init CA")
}
#[test]
fn fingerprint_is_64_hex_chars() {
let dir = tempfile::tempdir().unwrap();
let pki = ca(dir.path());
assert_eq!(pki.ca_fingerprint().len(), 64);
assert!(pki.ca_fingerprint().bytes().all(|b| b.is_ascii_hexdigit()));
assert_eq!(pki.ca_fingerprint(), &fingerprint(pki.ca_cert_der()));
}
#[test]
fn fingerprint_is_stable_across_reload() {
let dir = tempfile::tempdir().unwrap();
let first = ca(dir.path()).ca_fingerprint().to_string();
// Reloading from disk must reproduce the exact same pinned identity.
let second = ca(dir.path()).ca_fingerprint().to_string();
assert_eq!(first, second);
}
#[test]
fn issued_leaf_chains_to_ca_and_carries_chain() {
let dir = tempfile::tempdir().unwrap();
let pki = ca(dir.path());
let leaf = pki.issue_leaf().expect("issue leaf");
assert_eq!(leaf.chain_der.len(), 2, "leaf + CA");
assert_eq!(leaf.chain_der[1], pki.ca_cert_der(), "CA is the chain tail");
assert!(!leaf.private_key_der.is_empty());
assert!(leaf.not_after_ms > chrono::Utc::now().timestamp_millis());
// Cryptographically verify the leaf is signed by the CA public key.
use x509_parser::prelude::*;
let (_, ca_cert) = X509Certificate::from_der(pki.ca_cert_der()).unwrap();
let (_, leaf_cert) = X509Certificate::from_der(&leaf.chain_der[0]).unwrap();
leaf_cert
.verify_signature(Some(ca_cert.public_key()))
.expect("leaf chains to CA");
}
#[test]
fn distinct_dirs_have_distinct_cas() {
let a = tempfile::tempdir().unwrap();
let b = tempfile::tempdir().unwrap();
assert_ne!(ca(a.path()).ca_fingerprint(), ca(b.path()).ca_fingerprint());
}
}

View File

@@ -0,0 +1,164 @@
//! [`PeerTrustRegistry`] over the embedded Turso datastore (`doc/design.md` §5.1).
//!
//! The set of peer-node CA fingerprints this node will accept on a PQ-mTLS handshake. Trust is
//! per-CA (pinned fingerprints), never a shared root: a peer is refused the moment its CA is
//! distrusted. The transport layer loads a cached snapshot of this set into its synchronous
//! certificate verifiers and refreshes it when the operator changes trust.
use async_trait::async_trait;
use chrono::{TimeZone, Utc};
use turso::{Database, Value};
use buh_core::{CoreError, PeerTrustRegistry, TrustedPeer};
use crate::error::repo;
/// Peer-CA trust registry backed by Turso.
pub struct TursoPeerTrust {
db: Database,
}
impl TursoPeerTrust {
/// Build a registry over an already-opened database handle.
#[must_use]
pub fn new(db: Database) -> Self {
Self { db }
}
}
/// Normalise a CA fingerprint to the canonical form stored and compared: lowercase hex with any
/// `:` separators and surrounding whitespace stripped (so `AA:BB…` from a CLI flag matches).
fn normalize(fingerprint: &str) -> String {
fingerprint
.trim()
.chars()
.filter(|c| *c != ':')
.flat_map(char::to_lowercase)
.collect()
}
#[async_trait]
impl PeerTrustRegistry for TursoPeerTrust {
async fn trust(&self, ca_fingerprint: &str, note: Option<&str>) -> Result<(), CoreError> {
let fp = normalize(ca_fingerprint);
let conn = self.db.connect().map_err(repo)?;
conn.execute(
"INSERT INTO peer_trust (ca_fingerprint, note, trusted_at) VALUES (?1, ?2, ?3) \
ON CONFLICT(ca_fingerprint) DO UPDATE SET note = excluded.note",
(
fp,
note.map_or(Value::Null, |n| Value::Text(n.to_string())),
Utc::now().timestamp_millis(),
),
)
.await
.map_err(repo)?;
Ok(())
}
async fn distrust(&self, ca_fingerprint: &str) -> Result<bool, CoreError> {
let fp = normalize(ca_fingerprint);
let conn = self.db.connect().map_err(repo)?;
let affected = conn
.execute("DELETE FROM peer_trust WHERE ca_fingerprint = ?1", (fp,))
.await
.map_err(repo)?;
Ok(affected > 0)
}
async fn is_trusted(&self, ca_fingerprint: &str) -> Result<bool, CoreError> {
let fp = normalize(ca_fingerprint);
let conn = self.db.connect().map_err(repo)?;
let mut rows = conn
.query("SELECT 1 FROM peer_trust WHERE ca_fingerprint = ?1", (fp,))
.await
.map_err(repo)?;
Ok(rows.next().await.map_err(repo)?.is_some())
}
async fn list(&self) -> Result<Vec<TrustedPeer>, CoreError> {
let conn = self.db.connect().map_err(repo)?;
let mut rows = conn
.query(
"SELECT ca_fingerprint, note, trusted_at FROM peer_trust ORDER BY trusted_at DESC",
(),
)
.await
.map_err(repo)?;
let mut out = Vec::new();
while let Some(row) = rows.next().await.map_err(repo)? {
let ca_fingerprint = match row.get_value(0).map_err(repo)? {
Value::Text(s) => s,
other => return Err(CoreError::Repo(format!("ca_fingerprint: {other:?}"))),
};
let note = match row.get_value(1).map_err(repo)? {
Value::Text(s) => Some(s),
Value::Null => None,
other => return Err(CoreError::Repo(format!("note: {other:?}"))),
};
let trusted_at = match row.get_value(2).map_err(repo)? {
Value::Integer(ms) => Utc
.timestamp_millis_opt(ms)
.single()
.ok_or_else(|| CoreError::Repo(format!("bad trusted_at: {ms}")))?,
other => return Err(CoreError::Repo(format!("trusted_at: {other:?}"))),
};
out.push(TrustedPeer {
ca_fingerprint,
note,
trusted_at,
});
}
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
use turso::Builder;
async fn registry() -> TursoPeerTrust {
let db = Builder::new_local(":memory:").build().await.unwrap();
let conn = db.connect().unwrap();
crate::migrate::run(&conn).await.unwrap();
// `db.connect()` returns connections to the same in-memory store, so the registry sees
// the schema migrated above.
TursoPeerTrust::new(db)
}
#[tokio::test]
async fn trust_then_distrust_roundtrip() {
let reg = registry().await;
let fp = "ab".repeat(32);
assert!(!reg.is_trusted(&fp).await.unwrap());
reg.trust(&fp, Some("peer one")).await.unwrap();
assert!(reg.is_trusted(&fp).await.unwrap());
assert!(reg.distrust(&fp).await.unwrap());
assert!(!reg.is_trusted(&fp).await.unwrap());
assert!(
!reg.distrust(&fp).await.unwrap(),
"second distrust is false"
);
}
#[tokio::test]
async fn fingerprint_is_normalized() {
let reg = registry().await;
reg.trust("AA:BB:CC", None).await.unwrap();
assert!(reg.is_trusted("aabbcc").await.unwrap());
assert!(reg.is_trusted(" aa:bb:cc ").await.unwrap());
}
#[tokio::test]
async fn trust_is_idempotent_and_updates_note() {
let reg = registry().await;
let fp = "cd".repeat(32);
reg.trust(&fp, Some("first")).await.unwrap();
reg.trust(&fp, Some("second")).await.unwrap();
let list = reg.list().await.unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0].note.as_deref(), Some("second"));
}
}

View File

@@ -2,6 +2,7 @@
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use turso::Builder;
@@ -10,6 +11,8 @@ use buh_core::context::{CoreConfig, Ctx};
use crate::error::repo;
use crate::fs_blob::FsBlobStore;
use crate::node_ca::RcgenNodeCa;
use crate::peer_trust::TursoPeerTrust;
use crate::turso_mailbox::TursoMailboxRepo;
/// The assembled data stack: the database handle (for migrations) and a ready-to-use [`Ctx`].
@@ -30,6 +33,8 @@ impl DataStack {
let ctx = Ctx {
mailbox,
blob: None,
pki: None,
peer_trust: None,
config: core_config,
};
@@ -51,6 +56,21 @@ impl DataStack {
self
}
/// Enable PQ-mTLS: load (or generate) this node's CA under `pki_dir`, issuing leaves valid
/// for `leaf_ttl` and stamped with `sans`, and attach the Turso-backed peer-trust registry.
/// Both ports are set together — a node serving PQ-mTLS also needs a trust registry.
pub fn with_node_pki(
mut self,
pki_dir: impl Into<PathBuf>,
sans: Vec<String>,
leaf_ttl: Duration,
) -> Result<Self, CoreError> {
let pki = RcgenNodeCa::load_or_init(pki_dir, sans, leaf_ttl)?;
self.ctx.pki = Some(Arc::new(pki));
self.ctx.peer_trust = Some(Arc::new(TursoPeerTrust::new(self.db.clone())));
Ok(self)
}
/// Run the embedded migrations.
pub async fn migrate(&self) -> Result<(), CoreError> {
let conn = self.db.connect().map_err(repo)?;

View File

@@ -106,6 +106,25 @@ export default function App() {
</tbody>
</table>
<h2>Node CA pin (PQ-mTLS)</h2>
<p style={{ color: "#555", fontSize: "0.9rem" }}>
{result.caFingerprint ? (
<>
The invite pins the queue node's CA{" "}
<span style={mono} data-testid="ca-fingerprint">
{result.caFingerprint.slice(0, 32)}
</span>{" "}
{result.caPinVerified ? "verified against the node" : "advertised but unverified"}.
Native nodenode clients enforce this pin at the TLS layer (X25519MLKEM768).
</>
) : (
<>
The dev node serves plain HTTP (loopback), so the invite carries no CA pin. With{" "}
<code>[pki] enabled</code> the node is its own CA and the invite pins its fingerprint.
</>
)}
</p>
<h2>Invite</h2>
<p style={{ ...mono, fontSize: "0.75rem", wordBreak: "break-all", color: "#666" }}>
{result.inviteUri.slice(0, 120)}

View File

@@ -19,6 +19,22 @@ export function fromBase64(b64: string): Uint8Array {
return out;
}
/// Parse lowercase/uppercase hex into bytes (inverse of [`toHex`]). Throws on odd length.
export function fromHex(hex: string): Uint8Array {
const clean = hex.trim();
if (clean.length % 2 !== 0) throw new Error("hex string has odd length");
const out = new Uint8Array(clean.length / 2);
for (let i = 0; i < out.length; i++) {
out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
}
return out;
}
/// Whether every byte is zero (an unset/placeholder fingerprint carries no pin).
export function isAllZero(bytes: Uint8Array): boolean {
return bytes.every((b) => b === 0);
}
/// 32 cryptographically-random bytes — used here to mint opaque queue ids.
export function randomQueueId(): Uint8Array {
return crypto.getRandomValues(new Uint8Array(32));

View File

@@ -16,7 +16,7 @@ import {
parseInvite,
publishablePrekeyBundle,
} from "./crypto";
import { randomQueueId, toBase64, toHex } from "./bytes";
import { fromHex, isAllZero, randomQueueId, toBase64, toHex } from "./bytes";
import type { KeyStore } from "./keystore";
import * as relay from "./relay";
@@ -37,6 +37,10 @@ export interface DemoResult {
aliceDecrypted: string;
bobDecrypted: string;
relayView: RelayEnvelopeView[];
/// The queue node's CA fingerprint carried in the invite (hex), or "" when unpinned (dev).
caFingerprint: string;
/// Whether the relay client verified the pinned CA against the node it talked to.
caPinVerified: boolean;
}
const fingerprint = (idPub: Uint8Array) => toHex(idPub).slice(0, 16);
@@ -67,7 +71,16 @@ export async function runDemo(
await store.put("alice/queue", aliceQueue);
const nonce = crypto.getRandomValues(new Uint8Array(16));
const caFingerprint = new Uint8Array(32); // real per-node CA pinning arrives in Phase 6
// The invite pins the queue node's CA. When the node serves PQ-mTLS it advertises its real CA
// fingerprint on /v1/health; in plain dev/loopback mode there is no CA, so the field stays
// zero (an explicit "unpinned" marker the client treats as inert).
const advertisedCa = await relay.nodeCaFingerprint();
const caFingerprint = advertisedCa ? fromHex(advertisedCa) : new Uint8Array(32);
log(
advertisedCa
? `Alice: pinning node CA ${advertisedCa.slice(0, 16)}… in the invite.`
: "Alice: node serves plain HTTP (dev) — invite carries an unpinned CA placeholder.",
);
const inviteUri = createInvite(
aliceId,
aliceQueue,
@@ -82,6 +95,20 @@ export async function runDemo(
// --- Bob receives the invite out-of-band, verifies it, and opens a session. ---
log("Bob: parsing + verifying invite…");
const parsed = parseInvite(inviteUri); // throws if the signature/bundle don't verify
// Bob makes the relay client's TLS trust decision: pin the CA fingerprint the verified invite
// carries, then confirm the node he is about to talk to presents it. (Native clients enforce
// this at the TLS layer; the browser checks the node's advertised fingerprint — see relay.ts.)
const caFingerprintHex = toHex(parsed.ca_fingerprint);
let caPinVerified = false;
if (!isAllZero(parsed.ca_fingerprint)) {
relay.pinCa(caFingerprintHex);
caPinVerified = await relay.verifyPinnedCa(); // throws on a genuine CA mismatch
log(`Bob: CA pin ${caPinVerified ? "verified" : "inert (dev node)"} for ${caFingerprintHex.slice(0, 16)}`);
} else {
log("Bob: invite carries no CA pin (dev node) — skipping TLS trust check.");
}
const bobId = generateIdentity();
await store.put("bob/identity", bobId);
const bobQueue = randomQueueId();
@@ -142,5 +169,7 @@ export async function runDemo(
aliceDecrypted,
bobDecrypted,
relayView,
caFingerprint: isAllZero(parsed.ca_fingerprint) ? "" : caFingerprintHex,
caPinVerified,
};
}

View File

@@ -56,3 +56,47 @@ export async function health(): Promise<boolean> {
return false;
}
}
// --- Per-node CA pinning (Phase 6, doc/design.md §5.1) -----------------------------------------
//
// Trust is per-CA: a verified invite carries the queue node's CA fingerprint, and the client
// pins it. On a NATIVE node↔node client this pin is enforced *cryptographically* at the TLS
// layer (the Rust `PinnedServerCertVerifier` refuses any node whose CA fingerprint is not the
// pinned one). Browsers cannot pin a custom CA on `fetch`, so in this web demo the same pinned
// value is checked at the application layer against the fingerprint the node advertises on
// `/v1/health` — a best-effort consistency check, not a substitute for TLS-layer pinning.
let pinnedCa: string | null = null;
/// Pin the node's expected CA fingerprint (lowercase hex), as carried in a verified invite.
export function pinCa(caFingerprintHex: string): void {
pinnedCa = caFingerprintHex.toLowerCase();
}
/// The CA fingerprint the node advertises on `/v1/health` (hex), or `null` if it serves plain
/// HTTP (the dev loopback mode, where there is no CA and nothing to pin).
export async function nodeCaFingerprint(): Promise<string | null> {
try {
const res = await fetch("/v1/health");
if (!res.ok) return null;
const body = await res.json();
return typeof body.ca_fingerprint === "string" ? body.ca_fingerprint.toLowerCase() : null;
} catch {
return null;
}
}
/// The relay client's TLS trust decision, made explicit. Returns `true` when the pin is verified
/// against the node's advertised CA fingerprint; `false` when there is no real pin or the node
/// serves plain HTTP (pin inert in dev); throws on a genuine mismatch (a different node's CA).
export async function verifyPinnedCa(): Promise<boolean> {
if (!pinnedCa || /^0*$/.test(pinnedCa)) return false; // unset/zero-filled: no pin to enforce
const advertised = await nodeCaFingerprint();
if (advertised === null) return false; // plain dev node: nothing to compare against
if (advertised !== pinnedCa) {
throw new Error(
`CA pin mismatch: invite pins ${pinnedCa.slice(0, 16)}…, node serves ${advertised.slice(0, 16)}`,
);
}
return true;
}