Compare commits
2 Commits
cf1a925603
...
e2538c4cb0
| Author | SHA1 | Date | |
|---|---|---|---|
|
e2538c4cb0
|
|||
|
9a9cf0d609
|
338
Cargo.lock
generated
338
Cargo.lock
generated
@@ -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"
|
||||
|
||||
11
Cargo.toml
11
Cargo.toml
@@ -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"] }
|
||||
|
||||
|
||||
44
asset/config/config.toml.tmpl
Normal file
44
asset/config/config.toml.tmpl
Normal 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
124
asset/deploy.sh
Executable 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
|
||||
9
asset/firewalld/buh-node.xml
Normal file
9
asset/firewalld/buh-node.xml
Normal 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
55
asset/manifest.yml
Normal 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
41
asset/readme.md
Normal 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
|
||||
```
|
||||
25
asset/systemd/buh-node-sweep.service
Normal file
25
asset/systemd/buh-node-sweep.service
Normal 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
|
||||
13
asset/systemd/buh-node-sweep.timer
Normal file
13
asset/systemd/buh-node-sweep.timer
Normal 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
|
||||
45
asset/systemd/buh-node.service
Normal file
45
asset/systemd/buh-node.service
Normal 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
|
||||
2
asset/systemd/buh.sysusers.conf
Normal file
2
asset/systemd/buh.sysusers.conf
Normal file
@@ -0,0 +1,2 @@
|
||||
#Type Name ID GECOS Home directory Shell
|
||||
u buh - "buh node service account" /var/lib/buh /usr/sbin/nologin
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
412
crates/buh-api/src/tls.rs
Normal 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}"))
|
||||
}
|
||||
192
crates/buh-api/tests/pqmtls_handshake.rs
Normal file
192
crates/buh-api/tests/pqmtls_handshake.rs
Normal 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());
|
||||
}
|
||||
@@ -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()]
|
||||
}
|
||||
|
||||
@@ -124,6 +124,8 @@ mod tests {
|
||||
Ctx {
|
||||
mailbox: Arc::new(NoMailbox),
|
||||
blob,
|
||||
pki: None,
|
||||
peer_trust: None,
|
||||
config: CoreConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
12
crates/buh-data/migrations/0002_peer_trust.sql
Normal file
12
crates/buh-data/migrations/0002_peer_trust.sql
Normal 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
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
315
crates/buh-data/src/node_ca.rs
Normal file
315
crates/buh-data/src/node_ca.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
164
crates/buh-data/src/peer_trust.rs
Normal file
164
crates/buh-data/src/peer_trust.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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 node↔node 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)}…
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user