diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index e2ac72d5..c329ceec 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -180,6 +180,41 @@ jobs: - name: Fmt run: cargo fmt --all -- --check + # TC-887: the at-rest ceremony CLI is its own Cargo workspace (with its own + # lockfile) so its DCAP verifier never changes the node's dependency graph. + at-rest-cli: + name: at-rest ceremony CLI (own workspace) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout TinyCloud repository + uses: actions/checkout@v4 + + - name: Install reviewed Rust toolchain + uses: dtolnay/rust-toolchain@1.97.1 + with: + components: clippy, rustfmt + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 + with: + shared-key: rust-1.97.1-at-rest-cli + workspaces: tinycloud-at-rest-cli -> target + save-if: ${{ github.ref == 'refs/heads/main' }} + + - name: Fmt + run: cargo fmt --manifest-path tinycloud-at-rest-cli/Cargo.toml -- --check + - name: Clippy + run: RUSTFLAGS="-Dwarnings" cargo clippy --locked --manifest-path tinycloud-at-rest-cli/Cargo.toml --all-targets + # Interop with the stock age CLI (tests/age_interop.rs), pinned by digest. + - name: Install age v1.3.2 + run: | + curl -fsSL -o "$RUNNER_TEMP/age.tgz" https://github.com/FiloSottile/age/releases/download/v1.3.2/age-v1.3.2-linux-amd64.tar.gz + echo "cbe24006683f8eb669266162894b9a522a1af52f2665fbc63a4bb032ed26ac10 $RUNNER_TEMP/age.tgz" | sha256sum -c - + tar -xzf "$RUNNER_TEMP/age.tgz" -C "$RUNNER_TEMP" + echo "AGE_BIN_DIR=$RUNNER_TEMP/age" >> "$GITHUB_ENV" + - name: Test + run: cargo test --locked --manifest-path tinycloud-at-rest-cli/Cargo.toml + capabilities: runs-on: ubuntu-latest steps: diff --git a/Cargo.lock b/Cargo.lock index f90966a2..dc09d963 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9,7 +9,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "087113bd50d9adce24850eed5d0476c7d199d532fce8fab5173650331e09033a" dependencies = [ "abnf-core", - "nom", + "nom 7.1.3", ] [[package]] @@ -18,7 +18,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c44e09c43ae1c368fb91a03a566472d0087c26cf7e1b9e8e289c14ede681dd7d" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -33,8 +33,8 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", - "generic-array", + "crypto-common 0.1.6", + "generic-array 0.14.9", ] [[package]] @@ -45,7 +45,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", "zeroize", ] @@ -64,6 +64,57 @@ dependencies = [ "zeroize", ] +[[package]] +name = "age" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd290633c2482479f70f6d1d96ae0e9f52c6a26cd5859edd47ee1fe33fc89f26" +dependencies = [ + "age-core", + "base64 0.22.1", + "bech32 0.11.1", + "chacha20poly1305", + "cipher", + "cookie-factory", + "hkdf", + "hmac", + "hpke", + "i18n-embed", + "i18n-embed-fl", + "lazy_static", + "ml-kem", + "nom 8.0.0", + "p256", + "pin-project", + "rand 0.8.5", + "rust-embed", + "scrypt", + "sha2 0.10.9", + "sha3", + "subtle", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "age-core" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d4375964d1501e5f1b32aef2ead573913893ff238448d4e9fdf1522d828656" +dependencies = [ + "base64 0.22.1", + "bech32 0.11.1", + "chacha20poly1305", + "cookie-factory", + "hkdf", + "hpke", + "io_tee", + "nom 8.0.0", + "rand 0.8.5", + "secrecy", + "sha2 0.10.9", +] + [[package]] name = "ahash" version = "0.7.8" @@ -625,6 +676,15 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + [[package]] name = "ark-ff" version = "0.3.0" @@ -1053,7 +1113,7 @@ dependencies = [ "asn1-rs-derive 0.1.0", "asn1-rs-impl 0.1.0", "displaydoc", - "nom", + "nom 7.1.3", "num-traits", "rusticata-macros", "thiserror 1.0.69", @@ -1069,7 +1129,7 @@ dependencies = [ "asn1-rs-derive 0.5.1", "asn1-rs-impl 0.2.0", "displaydoc", - "nom", + "nom 7.1.3", "num-traits", "rusticata-macros", "thiserror 1.0.69", @@ -1815,12 +1875,27 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + [[package]] name = "bech32" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf9ff0bbfd639f15c74af777d81383cf53efb7c93613f6cab67c6c11e05bbf8b" +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + [[package]] name = "bigdecimal" version = "0.4.10" @@ -1957,7 +2032,7 @@ dependencies = [ "cc", "cfg-if", "constant_time_eq 0.4.2", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -1966,7 +2041,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" dependencies = [ - "generic-array", + "generic-array 0.14.9", ] [[package]] @@ -1975,7 +2050,16 @@ version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "generic-array", + "generic-array 0.14.9", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array 0.4.10", ] [[package]] @@ -2225,7 +2309,7 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -2316,7 +2400,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.6", "inout", "zeroize", ] @@ -2419,7 +2503,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3bb320cac8a0750d7f25280aa97b09c26edfe161164238ecbbb31092b079e735" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "proptest", "serde_core", ] @@ -2436,6 +2520,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "const-random" version = "0.1.18" @@ -2526,6 +2616,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" +dependencies = [ + "futures", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -2570,6 +2669,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.4.0" @@ -2630,7 +2738,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03c6a1d5fa1de37e071642dfa44ec552ca5b299adb128fab16138e24b548fd21" dependencies = [ - "generic-array", + "generic-array 0.14.9", "subtle", ] @@ -2640,23 +2748,45 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ - "generic-array", + "generic-array 0.14.9", "rand_core 0.6.4", "subtle", "zeroize", ] +[[package]] +name = "crypto-bigint" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96272c2ff28b807e09250b180ad1fb7889a3258f7455759b5c3c58b719467130" +dependencies = [ + "num-traits", + "rand_core 0.6.4", + "serdect", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ - "generic-array", + "generic-array 0.14.9", "rand_core 0.6.4", "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array 0.4.10", +] + [[package]] name = "csv" version = "1.4.0" @@ -2703,7 +2833,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest 0.10.7", "fiat-crypto", @@ -2864,7 +2994,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.114", ] [[package]] @@ -2903,7 +3033,7 @@ checksum = "fe398ac75057914d7d07307bf67dc7f3f574a26783b4fc7805a20ffa9f506e82" dependencies = [ "asn1-rs 0.3.1", "displaydoc", - "nom", + "nom 7.1.3", "num-bigint", "num-traits", "rusticata-macros", @@ -2917,7 +3047,7 @@ checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" dependencies = [ "asn1-rs 0.6.2", "displaydoc", - "nom", + "nom 7.1.3", "num-bigint", "num-traits", "rusticata-macros", @@ -3104,7 +3234,7 @@ version = "0.3.2" source = "git+https://github.com/tinycloudlabs/ssi#9d519692ab1b07b53d71f27ebad8473d64c338bf" dependencies = [ "async-trait", - "bech32", + "bech32 0.8.1", "bs58 0.4.0", "chrono", "iref", @@ -3158,7 +3288,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" dependencies = [ - "generic-array", + "generic-array 0.14.9", ] [[package]] @@ -3169,10 +3299,21 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "const-oid 0.9.6", - "crypto-common", + "crypto-common 0.1.6", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -3323,7 +3464,7 @@ dependencies = [ "crypto-bigint 0.5.5", "digest 0.10.7", "ff", - "generic-array", + "generic-array 0.14.9", "group", "hkdf", "pem-rfc7468 0.7.0", @@ -3331,6 +3472,21 @@ dependencies = [ "rand_core 0.6.4", "sec1", "subtle", + "tap", + "zeroize", +] + +[[package]] +name = "elliptic-curve-tools" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de2b6fae800f08032a6ea32995b52925b1d451bff9d445c8ab2932323277faf" +dependencies = [ + "elliptic-curve", + "heapless", + "hex", + "multiexp", + "serde", "zeroize", ] @@ -3485,6 +3641,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ + "bitvec 1.0.1", "rand_core 0.6.4", "subtle", ] @@ -3520,6 +3677,15 @@ dependencies = [ "libredox", ] +[[package]] +name = "find-crate" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a98bbaacea1c0eb6a0876280051b892eb73594fd90cf3b20e9c817029c57d2" +dependencies = [ + "toml 0.5.11", +] + [[package]] name = "find-msvc-tools" version = "0.1.8" @@ -3568,6 +3734,51 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "fluent" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8137a6d5a2c50d6b0ebfcb9aaa91a28154e0a70605f112d30cb0cd4a78670477" +dependencies = [ + "fluent-bundle", + "unic-langid", +] + +[[package]] +name = "fluent-bundle" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01203cb8918f5711e73891b347816d932046f95f54207710bda99beaeb423bf4" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash", + "self_cell", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54f0d287c53ffd184d04d8677f590f4ac5379785529e5e08b1c8083acdd5c198" +dependencies = [ + "memchr", + "thiserror 2.0.18", +] + [[package]] name = "flume" version = "0.11.1" @@ -3591,6 +3802,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -3792,6 +4009,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "generic-array" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb130435a959a8d525e6bca66ff6c40981a300ee96d70e3ef56f046556d614a3" +dependencies = [ + "rustversion", + "typenum", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -3914,6 +4141,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -3950,7 +4186,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -3958,6 +4194,9 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] [[package]] name = "hashlink" @@ -3977,6 +4216,16 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.4.1" @@ -4046,6 +4295,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "hpke" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4917627a14198c3603282c5158b815ad5534795451d3c074b53cf3cee0960b11" +dependencies = [ + "aead", + "aes-gcm", + "chacha20poly1305", + "digest 0.10.7", + "generic-array 0.14.9", + "hkdf", + "hmac", + "p256", + "rand_core 0.6.4", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "http" version = "0.2.12" @@ -4113,6 +4382,24 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2d35805454dc9f8662a98d6d61886ffe26bd465f5960e0e55345c70d5c0d2a9" +dependencies = [ + "typenum", +] + +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "0.14.32" @@ -4276,6 +4563,72 @@ dependencies = [ "windows-registry", ] +[[package]] +name = "i18n-config" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e06b90c8a0d252e203c94344b21e35a30f3a3a85dc7db5af8f8df9f3e0c63ef" +dependencies = [ + "basic-toml", + "log", + "serde", + "serde_derive", + "thiserror 1.0.69", + "unic-langid", +] + +[[package]] +name = "i18n-embed" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a217bbb075dcaefb292efa78897fc0678245ca67f265d12c351e42268fcb0305" +dependencies = [ + "arc-swap", + "fluent", + "fluent-langneg", + "fluent-syntax", + "i18n-embed-impl", + "intl-memoizer", + "log", + "parking_lot", + "rust-embed", + "thiserror 1.0.69", + "unic-langid", + "walkdir", +] + +[[package]] +name = "i18n-embed-fl" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "602e6bd30c3db2749e13e38b363a3d98d9d41de1d8de7a79c31bb69e45b47cda" +dependencies = [ + "find-crate", + "fluent", + "fluent-syntax", + "i18n-config", + "i18n-embed", + "proc-macro-error3", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.114", + "unic-langid", +] + +[[package]] +name = "i18n-embed-impl" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2cc0e0523d1fe6fc2c6f66e5038624ea8091b3e7748b5e8e0c84b1698db6c2" +dependencies = [ + "find-crate", + "i18n-config", + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "iana-time-zone" version = "0.1.64" @@ -4507,7 +4860,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "generic-array", + "generic-array 0.14.9", ] [[package]] @@ -4519,6 +4872,25 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "intl-memoizer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + [[package]] name = "io-lifetimes" version = "1.0.11" @@ -4530,6 +4902,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "io_tee" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b3f7cef34251886990511df1c61443aa928499d598a9473929ab5a90a527304" + [[package]] name = "ipld-core" version = "0.4.2" @@ -4860,7 +5238,7 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -4883,6 +5261,16 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "kem" +version = "0.3.0-pre.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b8645470337db67b01a7f966decf7d0bafedbae74147d33e641c67a91df239f" +dependencies = [ + "rand_core 0.6.4", + "zeroize", +] + [[package]] name = "kv-log-macro" version = "1.0.7" @@ -5322,6 +5710,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -5349,6 +5747,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ml-kem" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de49b3df74c35498c0232031bb7e85f9389f913e2796169c8ab47a53993a18f" +dependencies = [ + "hybrid-array 0.2.3", + "kem", + "rand_core 0.6.4", + "sha3", +] + [[package]] name = "mown" version = "0.2.2" @@ -5417,6 +5827,20 @@ dependencies = [ "data-encoding-macro", ] +[[package]] +name = "multiexp" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ec2ce93a6f06ac6cae04c1da3f2a6a24fcfc1f0eb0b4e0f3d302f0df45326cb" +dependencies = [ + "ff", + "group", + "rand_core 0.6.4", + "rustversion", + "std-shims", + "zeroize", +] + [[package]] name = "multihash" version = "0.16.3" @@ -5539,6 +5963,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -5570,6 +6003,7 @@ checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", + "rand 0.8.5", ] [[package]] @@ -5595,6 +6029,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" dependencies = [ "num-traits", + "rand 0.8.5", ] [[package]] @@ -6257,7 +6692,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -6269,7 +6704,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -6390,6 +6825,16 @@ dependencies = [ "quote", ] +[[package]] +name = "proc-macro-error-attr3" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e564d14133360e1ae169ffde5da25881b5fa47261665b8e5713c212c27799da" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "proc-macro-error2" version = "2.0.1" @@ -6402,6 +6847,18 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "proc-macro-error3" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f0d4471b3436c22106b21913b1dda531558918ae9b7ec55d58aa84b43552233" +dependencies = [ + "proc-macro-error-attr3", + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "proc-macro2" version = "1.0.105" @@ -7216,6 +7673,41 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rust-embed" +version = "8.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19afa5b4b6a611de00bd1bdae6ae6f39084c9399f0679c3f52d8469cf335cc23" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0d8afda6374eac59e066abee06d265247ebbaf3006cf878e2879e8356e34053" +dependencies = [ + "mime_guess", + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.114", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d84e8ba78bd384263e5922f084cbe1b081c3b7e69add59c8fb097b879ba968a" +dependencies = [ + "sha2 0.11.1", + "walkdir", +] + [[package]] name = "rust_decimal" version = "1.40.0" @@ -7268,7 +7760,7 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -7468,6 +7960,15 @@ dependencies = [ "cipher", ] +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.28" @@ -7720,12 +8221,21 @@ checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ "base16ct", "der 0.7.10", - "generic-array", + "generic-array 0.14.9", "pkcs8 0.10.2", "subtle", "zeroize", ] +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -8034,6 +8544,16 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "serdect" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f42f67da2385b51a5f9652db9c93d78aeaf7610bf5ec366080b6de810604af53" +dependencies = [ + "base16ct", + "serde", +] + [[package]] name = "sha1" version = "0.10.6" @@ -8041,7 +8561,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.10.7", ] @@ -8053,7 +8573,7 @@ checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" dependencies = [ "block-buffer 0.9.0", "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.9.0", "opaque-debug", ] @@ -8065,10 +8585,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.10.7", ] +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + [[package]] name = "sha3" version = "0.10.8" @@ -8263,6 +8794,12 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" + [[package]] name = "spki" version = "0.5.4" @@ -8420,7 +8957,7 @@ dependencies = [ "futures-core", "futures-io", "futures-util", - "generic-array", + "generic-array 0.14.9", "hex", "hkdf", "hmac", @@ -9282,6 +9819,17 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "std-shims" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "227c4f8561598188d0df96dbe749824576174bba278b5b6bb2eacff1066067d0" +dependencies = [ + "hashbrown 0.16.1", + "rustversion", + "spin 0.10.1", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -9386,6 +9934,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + [[package]] name = "syn-solidity" version = "0.7.7" @@ -9626,6 +10184,29 @@ dependencies = [ "crunchy", ] +[[package]] +name = "tinycloud-at-rest-escrow" +version = "0.1.0" +dependencies = [ + "age", + "age-core", + "base64 0.22.1", + "bech32 0.11.1", + "hex", + "hkdf", + "hmac", + "hpke", + "p256", + "rand 0.8.5", + "serde", + "serde_json", + "sha2 0.10.9", + "subtle", + "thiserror 2.0.18", + "vsss-rs", + "zeroize", +] + [[package]] name = "tinycloud-auth" version = "1.3.0" @@ -9656,6 +10237,7 @@ version = "1.3.0" dependencies = [ "aes", "aes-gcm", + "age", "arrow", "async-std", "async-trait", @@ -9670,7 +10252,9 @@ dependencies = [ "iri-string", "k256", "libp2p", + "lru", "multihash-derive 0.9.1", + "parking_lot", "pin-project", "prometheus", "proptest", @@ -9688,6 +10272,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "time", + "tinycloud-at-rest-escrow", "tinycloud-auth", "tokio", "tracing", @@ -9702,6 +10287,7 @@ name = "tinycloud-node" version = "1.20.7" dependencies = [ "aes-gcm", + "age", "anyhow", "async-trait", "aws-config", @@ -9765,6 +10351,7 @@ dependencies = [ "thiserror 2.0.18", "tikv-jemallocator", "time", + "tinycloud-at-rest-escrow", "tinycloud-auth", "tinycloud-core", "tinycloud-sdk-wasm", @@ -9879,6 +10466,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -10307,6 +10895,15 @@ dependencies = [ "utf-8", ] +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash", +] + [[package]] name = "typed-builder" version = "0.14.0" @@ -10378,6 +10975,31 @@ dependencies = [ "version_check", ] +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "serde", + "tinystr", +] + +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -10429,7 +11051,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.6", "subtle", ] @@ -10541,6 +11163,26 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" +[[package]] +name = "vsss-rs" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ec751bdcc8bda099e269b24cc6b4ad14f9ce8b0490c1599174070e792ecd70c" +dependencies = [ + "crypto-bigint 0.6.1", + "elliptic-curve", + "elliptic-curve-tools", + "generic-array 1.4.2", + "hex", + "hybrid-array 0.4.10", + "num", + "rand_core 0.6.4", + "serde", + "sha3", + "subtle", + "zeroize", +] + [[package]] name = "wait-timeout" version = "0.2.1" @@ -10556,6 +11198,16 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -10725,6 +11377,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -11119,7 +11780,7 @@ dependencies = [ "data-encoding", "der-parser 7.0.0", "lazy_static", - "nom", + "nom 7.1.3", "oid-registry 0.4.0", "rusticata-macros", "thiserror 1.0.69", @@ -11136,7 +11797,7 @@ dependencies = [ "data-encoding", "der-parser 9.0.0", "lazy_static", - "nom", + "nom 7.1.3", "oid-registry 0.7.1", "rusticata-macros", "thiserror 1.0.69", @@ -11299,6 +11960,7 @@ version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", diff --git a/Cargo.toml b/Cargo.toml index cd75bfb3..1f08753a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "tinycloud-verifier-wasm", "tinycloud-sdk-rs", "tinycloud-core", + "tinycloud-at-rest-escrow", "dependencies/siwe", "dependencies/siwe-recap", "dependencies/cacao", @@ -21,6 +22,9 @@ exclude = [ "vendor/sqlx-core", "test/m1-realdata-e2e", "test/w5-policy-runtime-node-e2e", + # Own workspace and lockfile: its DCAP verifier (dcap-qvl) must not + # change the node's dependency graph or features (TC-887). + "tinycloud-at-rest-cli", ] [workspace.dependencies] diff --git a/Dockerfile b/Dockerfile index 827f3ba5..a16e3054 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,7 @@ COPY ./rust-toolchain.toml ./ COPY ./tinycloud-node-server/ ./tinycloud-node-server/ COPY ./tinycloud-auth/ ./tinycloud-auth/ COPY ./tinycloud-core/ ./tinycloud-core/ +COPY ./tinycloud-at-rest-escrow/ ./tinycloud-at-rest-escrow/ COPY ./tinycloud-sdk-rs/ ./tinycloud-sdk-rs/ COPY ./tinycloud-sdk-wasm/ ./tinycloud-sdk-wasm/ COPY ./tinycloud-verifier-wasm/ ./tinycloud-verifier-wasm/ diff --git a/docs/at-rest-escrow.md b/docs/at-rest-escrow.md new file mode 100644 index 00000000..2f38ac6c --- /dev/null +++ b/docs/at-rest-escrow.md @@ -0,0 +1,170 @@ +# At-rest key escrow (TC-887) + +The node provisions and escrows at-rest keys only when `at_rest` is configured (below). The default config does nothing: no key reads, no rows. + +- `tinycloud-at-rest-escrow` (a node workspace member) holds the escrow format: + - the 2-of-3 split of the at-rest root key (ARK); + - shares sealed to age recipients; + - the bundle and its fingerprint; + - the canary; + - the verification record; + - the recovery wire format. +- `tinycloud-at-rest-cli` is the `tinycloud-at-rest` tool that holders and operators run. + - It is a Cargo workspace of its own, with its own `Cargo.lock`. + - Its DCAP verifier and HTTP stack never enter the node's dependency graph or feature set. + +Build the tool from the release tag you are verifying: + +```sh +cargo build --release --locked --manifest-path tinycloud-at-rest-cli/Cargo.toml +# tinycloud-at-rest-cli/target/release/tinycloud-at-rest +``` + +## Formats + +`lp(x)` is `u32 BE length ‖ x`. Digests are SHA-256, written in lowercase hex. + +- **Share.** + - The ARK is split with Shamir over GF(256) (`vsss-rs` 5.4.0): threshold 2 of 3, and share `i` has x-coordinate `i`. + - The sealed plaintext is `"TCS1" ‖ ark_id(16) ‖ generation u32 BE ‖ i ‖ share(33)`, 58 bytes. +- **Recipients.** Each share is age-encrypted (`age` 0.12.1, ASCII armor) to one holder. The recipient type can be: + - `age1yubikey1…` (`piv-p256` stanza). This is what released age-plugin-yubikey (0.5.x) prints and decrypts. The stanza is produced exactly as the plugin's own `wrap_file_key`, so the TEE needs no plugin. + - `age1tag1…` (`p256tag` stanza). This is native age 1.3 tagged P-256. The released age-plugin-yubikey 0.5.1 **cannot** decrypt it, so every command warns when a holder uses one. + - `age1…` X25519. These are software keys, refused unless software recipients are explicitly allowed (`--allow-software` on the CLI; tests and self-hosted nodes only). Every command prints a warning while that is in effect. + - A recipient format proves which kind of key decrypts, **not** where the private key lives. That a P-256 key was generated on a YubiKey is established in the escrow ceremony. +- **Commitment.** `SHA-256(lp("tinycloud/at-rest/escrow-share-commit/v1") ‖ ark_id ‖ generation ‖ i ‖ lp(share))`. +- **Canary.** + - `canary = HKDF-SHA256(ikm = ARK, salt = ark_id, info = lp("tinycloud/at-rest/escrow-canary/v1") ‖ generation)`. + - `canary_fp = SHA-256(lp("tinycloud/at-rest/escrow-canary-fp/v1") ‖ ark_id ‖ generation ‖ lp(canary))`. +- **Bundle.** JSON with `version`, `ark_id`, `generation`, `threshold`, `canary_fp`, and `shares[{index, holder, recipient, commitment, ciphertext}]`. Unknown fields are rejected. +- **EFP (escrow fingerprint).** `SHA-256(lp("tinycloud/at-rest/escrow-bundle/v1") ‖ version ‖ ark_id ‖ generation ‖ threshold ‖ canary_fp ‖ n ‖ for each share: i ‖ lp(holder) ‖ lp(recipient) ‖ commitment ‖ SHA-256(ciphertext))`. +- **Verification record.** + - It is JSON naming the EFP, all three indices and all three pairs, with an HMAC-SHA256 under `HKDF(ARK, salt = ark_id, info = lp("tinycloud/at-rest/escrow-record/v1"))`. + - Only the ARK holder (the TEE) can make or check the MAC. `record-efp` checks only the public fields and says so. +- **Recovery binding.** + - The binding string is `xyz.tinycloud.at-rest/recovery-key/v1\0:::`, where `\0` is two characters. + - A TDX quote's REPORTDATA must be `SHA-256(binding) ‖ 32 zero bytes`. + +## Commands + +Every command that reads a bundle: +- refuses software holders unless `--allow-software` is given; +- accepts `--expect-efp E` (case-insensitive) and refuses a bundle with any other EFP. + +Bundles, records and the node's recovery response carry a `version`, which is checked before any other field. A newer format is reported as "version N; this tool reads version 1". + +Exit codes: +- `0`: OK. +- `1`: REFUSED. A check failed: don't release a share and don't pin a value. +- `2`: usage error. +- `3`: ERROR. A file, stdin or the PCCS couldn't be read. + +| Command | Reads | Prints | +|---|---|---| +| `recipients --holder NAME=RECIPIENT` ×3 | — | `[at_rest.escrow]` TOML and the compose env line | +| `self-test --recipient R [--out DIR]` | — | a throwaway escrow whose share 1 is sealed to `R` by the TEE's own code (ceremony step 0) | +| `nonce` | — | 32 random bytes, base64url | +| `inspect --bundle B` | bundle | EFP, ark_id, generation, canary_fp, each holder's recipient kind | +| `extract-share --bundle B --holder H` | bundle | holder H's armored age file | +| `check-share --bundle B` | decrypted share on **stdin** | which share it is; refuses on a commitment mismatch (e.g. a share from an earlier split) | +| `verify-compose --app-compose A --trust T` | the deployment's `app-compose.json` | OK if it hashes to `compose_hash` and its compose YAML passes the strict check below; also lists the other app-compose settings to review | +| `verify-node --bundle B --recovery R --nonce N --trust T [--collateral C \| --pccs-url U]` | node's recovery response | OK (with node state and shares held) or REFUSED | +| `share-wrap` (same flags) | decrypted share on **stdin** | after `verify-node` passes: the share sealed to the node's recovery key | +| `record-efp --bundle B --record V` | record | `TINYCLOUD_AT_REST__ESCROW__VERIFIED_FP=`, marked NOT AUTHENTICATED: the record's MAC is checked only in the TEE | + +A decrypted share only ever arrives on stdin (`age -d -i share.age | tinycloud-at-rest …`). It never goes on argv or to a file. + +### What `verify-node` and `share-wrap` check before releasing a share + +All of these must hold, or the command prints `REFUSED: …` and exits 1: + +1. The node answered as an attested TEE: `mode` is `dstack` and a quote is present. +2. The node's `efp`, `ark_id` and `generation` equal the holder's own bundle. +3. The node's binding is exactly the one recomputed from the recovery recipient, that EFP, and the holder's nonce (at least 22 characters). A response made for any other nonce is stale. +4. The quote passes Intel DCAP verification with `dcap-qvl` 0.7.0: signature, PCK chain, CRLs, TCB info and QE identity. Collateral comes from the PCCS (Phala's by default; Intel PCS also works) with a 60 s timeout, or from `--collateral`, and is checked at the current time. +5. The TCB status is one fixed class: + - `UpToDate` passes. + - `SWHardeningNeeded`, `ConfigurationNeeded` and `ConfigurationAndSWHardeningNeeded` pass only if the policy lists them and pins every advisory ID the TCB level carries. + - `OutOfDate*`, `TDRelaunchAdvised*`, `Revoked` and unknown statuses never pass. + - On a pass, the status, the advisory IDs and the collateral dates are printed. +6. The TD debug bit is clear. +7. MRTD and RTMR0-2 equal the trust policy. +8. The dstack event log replays to the quoted RTMR3: + - each `boot-mr-done` and `system-ready` marker appears once, in that order; + - exactly one `app-id` and one `compose-hash` event appear, each before `boot-mr-done`, and each equals the trust policy; + - v2 events must carry their canonical preimage. +9. The quoted REPORTDATA is `SHA-256(binding) ‖ 0³²`. + +Not pinned: `key-provider`, `instance-id`, `os-image-hash`, XFAM, MRCONFIGID, MROWNER and MROWNERCONFIG. RTMR0-2 and the compose hash cover what the per-boot, in-memory recovery key depends on. + +`compose_hash` pins container images only if `app-compose.json` references every image by digest. `verify-compose` parses the compose YAML with a real parser and accepts only a small subset: +- exactly one document; +- no anchors, aliases, merge keys, tags or duplicate keys; +- a `services` mapping, and no `include`; +- every service has a string `image` of the form `[:tag]@sha256:<64 lowercase hex>`, with no `build` or `extends`. + +Anything else is refused. Run it when building the trust policy. + +A PCCS can't forge collateral, which is Intel-signed. A malicious PCCS can, however, serve older collateral that is still valid (TCB info is valid for about 30 days), which could hide a TCB recovery for that long. Use Intel PCS when that matters. + +Trust policy (`--trust`), from a source independent of the node being checked: + +```json +{ + "app_id": "<40 hex>", + "compose_hash": "<64 hex: SHA-256 of app-compose.json>", + "mrtd": "<96 hex>", + "rtmr0": "<96 hex>", + "rtmr1": "<96 hex>", + "rtmr2": "<96 hex>", + "allowed_tcb_status": ["UpToDate"], + "allowed_advisories": [] +} +``` + +## Node configuration and endpoints + +```toml +[at_rest] +mode = "off" # off | encrypt | strict +escrow_none = false # self-hosted Static-root nodes only (refused in a TEE) + +[at_rest.escrow] +recipients = [ { holder = "sam", recipient = "age1yubikey1…" }, … ] # exactly 3 +verified_fp = "" # after the ceremony (record-efp) +allow_software = false # X25519 holders; tests and self-host only, refused in a TEE +``` + +The same settings as compose env: `TINYCLOUD_AT_REST__MODE`, `TINYCLOUD_AT_REST__ESCROW_NONE`, `TINYCLOUD_AT_REST__ESCROW__RECIPIENTS=[{holder="…",recipient="…"},…]`, `TINYCLOUD_AT_REST__ESCROW__VERIFIED_FP`, `TINYCLOUD_AT_REST__ESCROW__ALLOW_SOFTWARE`. + +- **Recipients set, `mode = "off"`:** + - at boot the node creates the ARK (sealed under its root) and the escrow bundle; + - it keeps a copy of the sealed key, the bundle and each space's wrapped data key in the block store (`_tc/at-rest/` in S3, `{datadir}/at-rest/` on disk). Data-key objects are named by a keyed hash, not the space; + - nothing is encrypted. +- **`encrypt` or `strict` refuses to start** unless the recipients equal the bundle's, `verified_fp` equals its EFP, and the stored ceremony record verifies under the key inside the TEE. A Static-root node with no recipients may use `escrow_none = true` instead, which acknowledges that no escrow exists. +- **If the node's root can no longer unseal the key** (new app id or KMS), it boots **locked**: + - `/info` shows `at_rest.state = "locked"` and encryption is unavailable; + - two holders recover it through the endpoints below, which needs `verified_fp` to be pinned; + - the node then re-seals the key under its new root, with no restart and no data rewrite. + +| Endpoint | Answer | +|---|---| +| `GET /at-rest/status` | `mode`, `state`, `encrypting`, `efp`, `generation`, `verified` | +| `GET /at-rest/escrow` | the active escrow bundle (or, on a locked node, the pinned one) | +| `GET /at-rest/escrow/record` | the ceremony record, only if its MAC verifies under the live key | +| `GET /at-rest/recovery?nonce=N` | `RecoveryInfo`: this boot's recovery recipient, bound with `N` and the EFP into a TDX quote (`mode = "classic"` with no quote outside a TEE) | +| `POST /at-rest/recovery/shares` | body: one share age-sealed to the recovery recipient (≤ 4 KiB). Answers `held` (with `shares_held`), `verified` (all three shares rebuilt the key), or `unlocked` (locked node recovered), with the gate result | + +The share endpoint: +- is rate limited per IP and per boot; +- checks every share against the bundle's commitments before it takes one of three slots; +- never logs share bytes or ciphertexts. + +Shares live in memory for the current boot only. + +### Key continuity + +- **Database restored to an earlier point.** At boot the node puts back any key generation or chain the database lacks from the block-store copy. A space's data key missing from the database is restored from its copy before a new one would be made; if the database holds a *different* key under the same id, the node refuses to use it. +- **No key is ever made over existing material.** The node creates a new root key only when the database has no key, escrow or data-key rows and the block store has nothing under `_tc/at-rest/`. A store or mirror error stops the boot instead. +- **Generations.** Each older generation is sealed under the next one's key (its chain), so escrowing the newest generation recovers all of them. At boot under the same root, a missing or broken chain is rebuilt and logged. During recovery under a new root it can't be rebuilt, and the shares are refused with the reason. +- The key tables' migration `down` refuses while they hold rows. diff --git a/release-plz.toml b/release-plz.toml index d0a52710..c8f98c07 100644 --- a/release-plz.toml +++ b/release-plz.toml @@ -71,6 +71,11 @@ name = "tinycloud-sdk-rs" release = false publish = false +[[package]] +name = "tinycloud-at-rest-escrow" +release = false +publish = false + [[package]] name = "tinycloud-sdk-wasm" release = false diff --git a/test/m1-realdata-e2e/Cargo.lock b/test/m1-realdata-e2e/Cargo.lock index 34ed970a..11d079ef 100644 --- a/test/m1-realdata-e2e/Cargo.lock +++ b/test/m1-realdata-e2e/Cargo.lock @@ -9,7 +9,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "087113bd50d9adce24850eed5d0476c7d199d532fce8fab5173650331e09033a" dependencies = [ "abnf-core", - "nom", + "nom 7.1.3", ] [[package]] @@ -18,7 +18,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c44e09c43ae1c368fb91a03a566472d0087c26cf7e1b9e8e289c14ede681dd7d" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -33,8 +33,8 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", - "generic-array", + "crypto-common 0.1.6", + "generic-array 0.14.9", ] [[package]] @@ -45,7 +45,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", "zeroize", ] @@ -64,6 +64,57 @@ dependencies = [ "zeroize", ] +[[package]] +name = "age" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd290633c2482479f70f6d1d96ae0e9f52c6a26cd5859edd47ee1fe33fc89f26" +dependencies = [ + "age-core", + "base64 0.22.1", + "bech32 0.11.1", + "chacha20poly1305", + "cipher", + "cookie-factory", + "hkdf", + "hmac", + "hpke", + "i18n-embed", + "i18n-embed-fl", + "lazy_static", + "ml-kem", + "nom 8.0.0", + "p256", + "pin-project", + "rand 0.8.5", + "rust-embed", + "scrypt", + "sha2 0.10.9", + "sha3", + "subtle", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "age-core" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d4375964d1501e5f1b32aef2ead573913893ff238448d4e9fdf1522d828656" +dependencies = [ + "base64 0.22.1", + "bech32 0.11.1", + "chacha20poly1305", + "cookie-factory", + "hkdf", + "hpke", + "io_tee", + "nom 8.0.0", + "rand 0.8.5", + "secrecy", + "sha2 0.10.9", +] + [[package]] name = "ahash" version = "0.7.8" @@ -173,6 +224,15 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -200,7 +260,7 @@ dependencies = [ "asn1-rs-derive", "asn1-rs-impl", "displaydoc", - "nom", + "nom 7.1.3", "num-traits", "rusticata-macros", "thiserror 1.0.69", @@ -801,12 +861,27 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + [[package]] name = "bech32" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf9ff0bbfd639f15c74af777d81383cf53efb7c93613f6cab67c6c11e05bbf8b" +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + [[package]] name = "bigdecimal" version = "0.4.10" @@ -857,10 +932,22 @@ version = "0.20.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7774144344a4faa177370406a7ff5f1da24303817368584c6206c8303eb07848" dependencies = [ - "funty", - "radium", + "funty 1.1.0", + "radium 0.6.2", + "tap", + "wyz 0.2.0", +] + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty 2.0.0", + "radium 0.7.0", "tap", - "wyz", + "wyz 0.5.1", ] [[package]] @@ -916,7 +1003,7 @@ dependencies = [ "cc", "cfg-if", "constant_time_eq 0.4.2", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -925,7 +1012,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" dependencies = [ - "generic-array", + "generic-array 0.14.9", ] [[package]] @@ -934,7 +1021,16 @@ version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "generic-array", + "generic-array 0.14.9", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array 0.4.10", ] [[package]] @@ -1075,7 +1171,7 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -1166,7 +1262,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.6", "inout", "zeroize", ] @@ -1253,6 +1349,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "const-str" version = "0.4.3" @@ -1288,6 +1390,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" +dependencies = [ + "futures", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -1332,6 +1443,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.4.0" @@ -1392,7 +1512,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03c6a1d5fa1de37e071642dfa44ec552ca5b299adb128fab16138e24b548fd21" dependencies = [ - "generic-array", + "generic-array 0.14.9", "subtle", ] @@ -1402,8 +1522,21 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ - "generic-array", + "generic-array 0.14.9", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-bigint" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96272c2ff28b807e09250b180ad1fb7889a3258f7455759b5c3c58b719467130" +dependencies = [ + "num-traits", "rand_core 0.6.4", + "serdect", "subtle", "zeroize", ] @@ -1414,11 +1547,20 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ - "generic-array", + "generic-array 0.14.9", "rand_core 0.6.4", "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array 0.4.10", +] + [[package]] name = "csv" version = "1.4.0" @@ -1478,7 +1620,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest 0.10.7", "fiat-crypto", @@ -1639,7 +1781,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.114", ] [[package]] @@ -1678,7 +1820,7 @@ checksum = "fe398ac75057914d7d07307bf67dc7f3f574a26783b4fc7805a20ffa9f506e82" dependencies = [ "asn1-rs", "displaydoc", - "nom", + "nom 7.1.3", "num-bigint", "num-traits", "rusticata-macros", @@ -1840,7 +1982,7 @@ version = "0.3.2" source = "git+https://github.com/tinycloudlabs/ssi#9d519692ab1b07b53d71f27ebad8473d64c338bf" dependencies = [ "async-trait", - "bech32", + "bech32 0.8.1", "bs58 0.4.0", "chrono", "iref", @@ -1894,7 +2036,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" dependencies = [ - "generic-array", + "generic-array 0.14.9", ] [[package]] @@ -1905,10 +2047,21 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "const-oid 0.9.6", - "crypto-common", + "crypto-common 0.1.6", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -2046,7 +2199,7 @@ dependencies = [ "crypto-bigint 0.5.5", "digest 0.10.7", "ff", - "generic-array", + "generic-array 0.14.9", "group", "hkdf", "pem-rfc7468 0.7.0", @@ -2054,6 +2207,21 @@ dependencies = [ "rand_core 0.6.4", "sec1", "subtle", + "tap", + "zeroize", +] + +[[package]] +name = "elliptic-curve-tools" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de2b6fae800f08032a6ea32995b52925b1d451bff9d445c8ab2932323277faf" +dependencies = [ + "elliptic-curve", + "heapless", + "hex", + "multiexp", + "serde", "zeroize", ] @@ -2170,6 +2338,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ + "bitvec 1.1.1", "rand_core 0.6.4", "subtle", ] @@ -2194,6 +2363,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "find-crate" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a98bbaacea1c0eb6a0876280051b892eb73594fd90cf3b20e9c817029c57d2" +dependencies = [ + "toml 0.5.11", +] + [[package]] name = "find-msvc-tools" version = "0.1.8" @@ -2219,6 +2397,51 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fluent" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8137a6d5a2c50d6b0ebfcb9aaa91a28154e0a70605f112d30cb0cd4a78670477" +dependencies = [ + "fluent-bundle", + "unic-langid", +] + +[[package]] +name = "fluent-bundle" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01203cb8918f5711e73891b347816d932046f95f54207710bda99beaeb423bf4" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash", + "self_cell", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54f0d287c53ffd184d04d8677f590f4ac5379785529e5e08b1c8083acdd5c198" +dependencies = [ + "memchr", + "thiserror 2.0.18", +] + [[package]] name = "flume" version = "0.11.1" @@ -2242,6 +2465,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -2272,6 +2501,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fed34cd105917e91daa4da6b3728c47b068749d6a62c59811f06ed2ac71d9da7" +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures" version = "0.3.31" @@ -2403,6 +2638,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "generic-array" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb130435a959a8d525e6bca66ff6c40981a300ee96d70e3ef56f046556d614a3" +dependencies = [ + "rustversion", + "typenum", +] + [[package]] name = "getrandom" version = "0.1.16" @@ -2521,6 +2766,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2557,7 +2811,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -2565,6 +2819,9 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] [[package]] name = "hashlink" @@ -2584,6 +2841,16 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.4.1" @@ -2641,6 +2908,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "hpke" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4917627a14198c3603282c5158b815ad5534795451d3c074b53cf3cee0960b11" +dependencies = [ + "aead", + "aes-gcm", + "chacha20poly1305", + "digest 0.10.7", + "generic-array 0.14.9", + "hkdf", + "hmac", + "p256", + "rand_core 0.6.4", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "http" version = "0.2.12" @@ -2708,6 +2995,24 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2d35805454dc9f8662a98d6d61886ffe26bd465f5960e0e55345c70d5c0d2a9" +dependencies = [ + "typenum", +] + +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "0.14.32" @@ -2870,6 +3175,72 @@ dependencies = [ "windows-registry", ] +[[package]] +name = "i18n-config" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e06b90c8a0d252e203c94344b21e35a30f3a3a85dc7db5af8f8df9f3e0c63ef" +dependencies = [ + "basic-toml", + "log", + "serde", + "serde_derive", + "thiserror 1.0.69", + "unic-langid", +] + +[[package]] +name = "i18n-embed" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a217bbb075dcaefb292efa78897fc0678245ca67f265d12c351e42268fcb0305" +dependencies = [ + "arc-swap", + "fluent", + "fluent-langneg", + "fluent-syntax", + "i18n-embed-impl", + "intl-memoizer", + "log", + "parking_lot", + "rust-embed", + "thiserror 1.0.69", + "unic-langid", + "walkdir", +] + +[[package]] +name = "i18n-embed-fl" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "602e6bd30c3db2749e13e38b363a3d98d9d41de1d8de7a79c31bb69e45b47cda" +dependencies = [ + "find-crate", + "fluent", + "fluent-syntax", + "i18n-config", + "i18n-embed", + "proc-macro-error3", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.114", + "unic-langid", +] + +[[package]] +name = "i18n-embed-impl" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2cc0e0523d1fe6fc2c6f66e5038624ea8091b3e7748b5e8e0c84b1698db6c2" +dependencies = [ + "find-crate", + "i18n-config", + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "iana-time-zone" version = "0.1.64" @@ -3081,7 +3452,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "generic-array", + "generic-array 0.14.9", ] [[package]] @@ -3093,6 +3464,31 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "intl-memoizer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "io_tee" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b3f7cef34251886990511df1c61443aa928499d598a9473929ab5a90a527304" + [[package]] name = "ipld-core" version = "0.4.2" @@ -3395,7 +3791,7 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -3408,6 +3804,16 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "kem" +version = "0.3.0-pre.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b8645470337db67b01a7f966decf7d0bafedbae74147d33e641c67a91df239f" +dependencies = [ + "rand_core 0.6.4", + "zeroize", +] + [[package]] name = "langtag" version = "0.4.0" @@ -3749,6 +4155,15 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "m1-realdata-e2e" version = "0.0.0" @@ -3826,6 +4241,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -3853,6 +4278,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ml-kem" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de49b3df74c35498c0232031bb7e85f9389f913e2796169c8ab47a53993a18f" +dependencies = [ + "hybrid-array 0.2.3", + "kem", + "rand_core 0.6.4", + "sha3", +] + [[package]] name = "mown" version = "0.2.2" @@ -3920,6 +4357,20 @@ dependencies = [ "data-encoding-macro", ] +[[package]] +name = "multiexp" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ec2ce93a6f06ac6cae04c1da3f2a6a24fcfc1f0eb0b4e0f3d302f0df45326cb" +dependencies = [ + "ff", + "group", + "rand_core 0.6.4", + "rustversion", + "std-shims", + "zeroize", +] + [[package]] name = "multihash" version = "0.16.3" @@ -4042,6 +4493,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -4051,6 +4511,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -4059,6 +4533,7 @@ checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", + "rand 0.8.5", ] [[package]] @@ -4077,6 +4552,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", + "rand 0.8.5", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -4713,7 +5198,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -4725,7 +5210,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -4835,6 +5320,16 @@ dependencies = [ "quote", ] +[[package]] +name = "proc-macro-error-attr3" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e564d14133360e1ae169ffde5da25881b5fa47261665b8e5713c212c27799da" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "proc-macro-error2" version = "2.0.1" @@ -4847,6 +5342,18 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "proc-macro-error3" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f0d4471b3436c22106b21913b1dda531558918ae9b7ec55d58aa84b43552233" +dependencies = [ + "proc-macro-error-attr3", + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "proc-macro2" version = "1.0.105" @@ -4968,6 +5475,12 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "643f8f41a8ebc4c5dc4515c82bb8abd397b527fc20fd681b7c011c2aee5d44fb" +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.7.3" @@ -5501,6 +6014,41 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rust-embed" +version = "8.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19afa5b4b6a611de00bd1bdae6ae6f39084c9399f0679c3f52d8469cf335cc23" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0d8afda6374eac59e066abee06d265247ebbaf3006cf878e2879e8356e34053" +dependencies = [ + "mime_guess", + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.114", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d84e8ba78bd384263e5922f084cbe1b081c3b7e69add59c8fb097b879ba968a" +dependencies = [ + "sha2 0.11.1", + "walkdir", +] + [[package]] name = "rust_decimal" version = "1.40.0" @@ -5512,6 +6060,12 @@ dependencies = [ "serde", ] +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -5527,7 +6081,7 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -5700,6 +6254,15 @@ dependencies = [ "cipher", ] +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.28" @@ -5937,12 +6500,21 @@ checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ "base16ct", "der 0.7.10", - "generic-array", + "generic-array 0.14.9", "pkcs8 0.10.2", "subtle", "zeroize", ] +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -6222,6 +6794,16 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "serdect" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f42f67da2385b51a5f9652db9c93d78aeaf7610bf5ec366080b6de810604af53" +dependencies = [ + "base16ct", + "serde", +] + [[package]] name = "sha1" version = "0.10.6" @@ -6229,7 +6811,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.10.7", ] @@ -6241,7 +6823,7 @@ checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" dependencies = [ "block-buffer 0.9.0", "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.9.0", "opaque-debug", ] @@ -6253,10 +6835,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.10.7", ] +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + [[package]] name = "sha3" version = "0.10.8" @@ -6426,6 +7019,12 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" + [[package]] name = "spki" version = "0.5.4" @@ -6574,7 +7173,7 @@ dependencies = [ "futures-core", "futures-io", "futures-util", - "generic-array", + "generic-array 0.14.9", "hex", "hkdf", "hmac", @@ -7237,7 +7836,7 @@ version = "0.6.1" source = "git+https://github.com/tinycloudlabs/ssi#9d519692ab1b07b53d71f27ebad8473d64c338bf" dependencies = [ "base64 0.22.1", - "bitvec", + "bitvec 0.20.4", "chrono", "educe 0.4.23", "flate2", @@ -7427,6 +8026,17 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "std-shims" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "227c4f8561598188d0df96dbe749824576174bba278b5b6bb2eacff1066067d0" +dependencies = [ + "hashbrown 0.16.1", + "rustversion", + "spin 0.10.1", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -7497,6 +8107,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "0.1.2" @@ -7705,6 +8325,27 @@ dependencies = [ "crunchy", ] +[[package]] +name = "tinycloud-at-rest-escrow" +version = "0.1.0" +dependencies = [ + "age", + "age-core", + "base64 0.22.1", + "hex", + "hkdf", + "hmac", + "p256", + "rand 0.8.5", + "serde", + "serde_json", + "sha2 0.10.9", + "subtle", + "thiserror 2.0.18", + "vsss-rs", + "zeroize", +] + [[package]] name = "tinycloud-auth" version = "1.3.0" @@ -7744,7 +8385,9 @@ dependencies = [ "hmac", "k256", "libp2p", + "lru", "multihash-derive 0.9.1", + "parking_lot", "pin-project", "prometheus", "rand 0.8.5", @@ -7761,6 +8404,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "time", + "tinycloud-at-rest-escrow", "tinycloud-auth", "tokio", "tracing", @@ -7825,6 +8469,7 @@ dependencies = [ "thiserror 2.0.18", "tikv-jemallocator", "time", + "tinycloud-at-rest-escrow", "tinycloud-auth", "tinycloud-core", "tokio", @@ -7846,6 +8491,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -8273,6 +8919,15 @@ dependencies = [ "utf-8", ] +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash", +] + [[package]] name = "typenum" version = "1.19.0" @@ -8321,6 +8976,31 @@ dependencies = [ "version_check", ] +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "serde", + "tinystr", +] + +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -8360,7 +9040,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.6", "subtle", ] @@ -8466,6 +9146,36 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" +[[package]] +name = "vsss-rs" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ec751bdcc8bda099e269b24cc6b4ad14f9ce8b0490c1599174070e792ecd70c" +dependencies = [ + "crypto-bigint 0.6.1", + "elliptic-curve", + "elliptic-curve-tools", + "generic-array 1.4.2", + "hex", + "hybrid-array 0.4.10", + "num", + "rand_core 0.6.4", + "serde", + "sha3", + "subtle", + "zeroize", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -8641,6 +9351,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -8994,6 +9713,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85e60b0d1b5f99db2556934e21937020776a5d31520bf169e851ac44e6420214" +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "x25519-dalek" version = "2.0.1" @@ -9017,7 +9745,7 @@ dependencies = [ "data-encoding", "der-parser", "lazy_static", - "nom", + "nom 7.1.3", "oid-registry", "rusticata-macros", "thiserror 1.0.69", @@ -9170,6 +9898,7 @@ version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", diff --git a/test/w5-policy-runtime-node-e2e/Cargo.lock b/test/w5-policy-runtime-node-e2e/Cargo.lock index 2eac9317..2c67a173 100644 --- a/test/w5-policy-runtime-node-e2e/Cargo.lock +++ b/test/w5-policy-runtime-node-e2e/Cargo.lock @@ -9,7 +9,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "087113bd50d9adce24850eed5d0476c7d199d532fce8fab5173650331e09033a" dependencies = [ "abnf-core", - "nom", + "nom 7.1.3", ] [[package]] @@ -18,7 +18,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c44e09c43ae1c368fb91a03a566472d0087c26cf7e1b9e8e289c14ede681dd7d" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -33,8 +33,8 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", - "generic-array", + "crypto-common 0.1.6", + "generic-array 0.14.9", ] [[package]] @@ -46,6 +46,7 @@ dependencies = [ "cfg-if", "cipher", "cpufeatures 0.2.17", + "zeroize", ] [[package]] @@ -60,6 +61,58 @@ dependencies = [ "ctr", "ghash", "subtle", + "zeroize", +] + +[[package]] +name = "age" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd290633c2482479f70f6d1d96ae0e9f52c6a26cd5859edd47ee1fe33fc89f26" +dependencies = [ + "age-core", + "base64 0.22.1", + "bech32 0.11.1", + "chacha20poly1305", + "cipher", + "cookie-factory", + "hkdf", + "hmac", + "hpke", + "i18n-embed", + "i18n-embed-fl", + "lazy_static", + "ml-kem", + "nom 8.0.0", + "p256", + "pin-project", + "rand 0.8.5", + "rust-embed", + "scrypt", + "sha2 0.10.9", + "sha3", + "subtle", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "age-core" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d4375964d1501e5f1b32aef2ead573913893ff238448d4e9fdf1522d828656" +dependencies = [ + "base64 0.22.1", + "bech32 0.11.1", + "chacha20poly1305", + "cookie-factory", + "hkdf", + "hpke", + "io_tee", + "nom 8.0.0", + "rand 0.8.5", + "secrecy", + "sha2 0.10.9", ] [[package]] @@ -153,7 +206,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -164,7 +217,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -182,6 +235,15 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -421,7 +483,7 @@ dependencies = [ "asn1-rs-derive", "asn1-rs-impl", "displaydoc", - "nom", + "nom 7.1.3", "num-traits", "rusticata-macros", "thiserror 1.0.69", @@ -970,12 +1032,27 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + [[package]] name = "bech32" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf9ff0bbfd639f15c74af777d81383cf53efb7c93613f6cab67c6c11e05bbf8b" +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + [[package]] name = "bigdecimal" version = "0.4.10" @@ -1106,7 +1183,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" dependencies = [ - "generic-array", + "generic-array 0.14.9", ] [[package]] @@ -1115,7 +1192,16 @@ version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "generic-array", + "generic-array 0.14.9", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array 0.4.10", ] [[package]] @@ -1418,7 +1504,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.6", "inout", "zeroize", ] @@ -1516,6 +1602,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "const-random" version = "0.1.18" @@ -1571,6 +1663,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" +dependencies = [ + "futures", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -1684,7 +1785,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03c6a1d5fa1de37e071642dfa44ec552ca5b299adb128fab16138e24b548fd21" dependencies = [ - "generic-array", + "generic-array 0.14.9", "subtle", ] @@ -1694,23 +1795,45 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ - "generic-array", + "generic-array 0.14.9", "rand_core 0.6.4", "subtle", "zeroize", ] +[[package]] +name = "crypto-bigint" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96272c2ff28b807e09250b180ad1fb7889a3258f7455759b5c3c58b719467130" +dependencies = [ + "num-traits", + "rand_core 0.6.4", + "serdect", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ - "generic-array", + "generic-array 0.14.9", "rand_core 0.6.4", "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array 0.4.10", +] + [[package]] name = "csv" version = "1.4.0" @@ -1918,7 +2041,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.114", ] [[package]] @@ -1957,7 +2080,7 @@ checksum = "fe398ac75057914d7d07307bf67dc7f3f574a26783b4fc7805a20ffa9f506e82" dependencies = [ "asn1-rs", "displaydoc", - "nom", + "nom 7.1.3", "num-bigint", "num-traits", "rusticata-macros", @@ -2130,7 +2253,7 @@ version = "0.3.2" source = "git+https://github.com/tinycloudlabs/ssi#9d519692ab1b07b53d71f27ebad8473d64c338bf" dependencies = [ "async-trait", - "bech32", + "bech32 0.8.1", "bs58 0.4.0", "chrono", "iref", @@ -2184,7 +2307,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" dependencies = [ - "generic-array", + "generic-array 0.14.9", ] [[package]] @@ -2195,10 +2318,21 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "const-oid 0.9.6", - "crypto-common", + "crypto-common 0.1.6", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -2331,7 +2465,7 @@ dependencies = [ "crypto-bigint 0.5.5", "digest 0.10.7", "ff", - "generic-array", + "generic-array 0.14.9", "group", "hkdf", "pem-rfc7468 0.7.0", @@ -2339,6 +2473,21 @@ dependencies = [ "rand_core 0.6.4", "sec1", "subtle", + "tap", + "zeroize", +] + +[[package]] +name = "elliptic-curve-tools" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de2b6fae800f08032a6ea32995b52925b1d451bff9d445c8ab2932323277faf" +dependencies = [ + "elliptic-curve", + "heapless", + "hex", + "multiexp", + "serde", "zeroize", ] @@ -2397,7 +2546,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2455,6 +2604,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ + "bitvec 1.1.1", "rand_core 0.6.4", "subtle", ] @@ -2489,6 +2639,15 @@ dependencies = [ "libc", ] +[[package]] +name = "find-crate" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a98bbaacea1c0eb6a0876280051b892eb73594fd90cf3b20e9c817029c57d2" +dependencies = [ + "toml 0.5.11", +] + [[package]] name = "find-msvc-tools" version = "0.1.8" @@ -2525,6 +2684,51 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "fluent" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8137a6d5a2c50d6b0ebfcb9aaa91a28154e0a70605f112d30cb0cd4a78670477" +dependencies = [ + "fluent-bundle", + "unic-langid", +] + +[[package]] +name = "fluent-bundle" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01203cb8918f5711e73891b347816d932046f95f54207710bda99beaeb423bf4" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash", + "self_cell", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54f0d287c53ffd184d04d8677f590f4ac5379785529e5e08b1c8083acdd5c198" +dependencies = [ + "memchr", + "thiserror 2.0.18", +] + [[package]] name = "flume" version = "0.11.1" @@ -2548,6 +2752,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -2715,6 +2925,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "generic-array" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb130435a959a8d525e6bca66ff6c40981a300ee96d70e3ef56f046556d614a3" +dependencies = [ + "rustversion", + "typenum", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -2837,6 +3057,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2873,7 +3102,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -2881,6 +3110,9 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] [[package]] name = "hashlink" @@ -2900,6 +3132,16 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.4.1" @@ -2957,6 +3199,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "hpke" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4917627a14198c3603282c5158b815ad5534795451d3c074b53cf3cee0960b11" +dependencies = [ + "aead", + "aes-gcm", + "chacha20poly1305", + "digest 0.10.7", + "generic-array 0.14.9", + "hkdf", + "hmac", + "p256", + "rand_core 0.6.4", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "http" version = "0.2.12" @@ -3024,6 +3286,24 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2d35805454dc9f8662a98d6d61886ffe26bd465f5960e0e55345c70d5c0d2a9" +dependencies = [ + "typenum", +] + +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "0.14.32" @@ -3187,6 +3467,72 @@ dependencies = [ "windows-registry", ] +[[package]] +name = "i18n-config" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e06b90c8a0d252e203c94344b21e35a30f3a3a85dc7db5af8f8df9f3e0c63ef" +dependencies = [ + "basic-toml", + "log", + "serde", + "serde_derive", + "thiserror 1.0.69", + "unic-langid", +] + +[[package]] +name = "i18n-embed" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a217bbb075dcaefb292efa78897fc0678245ca67f265d12c351e42268fcb0305" +dependencies = [ + "arc-swap", + "fluent", + "fluent-langneg", + "fluent-syntax", + "i18n-embed-impl", + "intl-memoizer", + "log", + "parking_lot", + "rust-embed", + "thiserror 1.0.69", + "unic-langid", + "walkdir", +] + +[[package]] +name = "i18n-embed-fl" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "602e6bd30c3db2749e13e38b363a3d98d9d41de1d8de7a79c31bb69e45b47cda" +dependencies = [ + "find-crate", + "fluent", + "fluent-syntax", + "i18n-config", + "i18n-embed", + "proc-macro-error3", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.114", + "unic-langid", +] + +[[package]] +name = "i18n-embed-impl" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2cc0e0523d1fe6fc2c6f66e5038624ea8091b3e7748b5e8e0c84b1698db6c2" +dependencies = [ + "find-crate", + "i18n-config", + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "iana-time-zone" version = "0.1.64" @@ -3398,7 +3744,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "generic-array", + "generic-array 0.14.9", ] [[package]] @@ -3410,6 +3756,31 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "intl-memoizer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "io_tee" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b3f7cef34251886990511df1c61443aa928499d598a9473929ab5a90a527304" + [[package]] name = "ipld-core" version = "0.4.2" @@ -3468,7 +3839,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3735,6 +4106,16 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "kem" +version = "0.3.0-pre.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b8645470337db67b01a7f966decf7d0bafedbae74147d33e641c67a91df239f" +dependencies = [ + "rand_core 0.6.4", + "zeroize", +] + [[package]] name = "langtag" version = "0.4.0" @@ -4093,6 +4474,15 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "lru-slab" version = "0.1.3" @@ -4147,6 +4537,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -4174,6 +4574,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ml-kem" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de49b3df74c35498c0232031bb7e85f9389f913e2796169c8ab47a53993a18f" +dependencies = [ + "hybrid-array 0.2.3", + "kem", + "rand_core 0.6.4", + "sha3", +] + [[package]] name = "mown" version = "0.2.2" @@ -4241,6 +4653,20 @@ dependencies = [ "data-encoding-macro", ] +[[package]] +name = "multiexp" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ec2ce93a6f06ac6cae04c1da3f2a6a24fcfc1f0eb0b4e0f3d302f0df45326cb" +dependencies = [ + "ff", + "group", + "rand_core 0.6.4", + "rustversion", + "std-shims", + "zeroize", +] + [[package]] name = "multihash" version = "0.16.3" @@ -4363,13 +4789,22 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4394,6 +4829,7 @@ checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", + "rand 0.8.5", ] [[package]] @@ -4419,6 +4855,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" dependencies = [ "num-traits", + "rand 0.8.5", ] [[package]] @@ -5120,6 +5557,16 @@ dependencies = [ "quote", ] +[[package]] +name = "proc-macro-error-attr3" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e564d14133360e1ae169ffde5da25881b5fa47261665b8e5713c212c27799da" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "proc-macro-error2" version = "2.0.1" @@ -5132,6 +5579,18 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "proc-macro-error3" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f0d4471b3436c22106b21913b1dda531558918ae9b7ec55d58aa84b43552233" +dependencies = [ + "proc-macro-error-attr3", + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "proc-macro2" version = "1.0.105" @@ -5265,7 +5724,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.36", - "socket2 0.5.10", + "socket2 0.6.1", "thiserror 2.0.18", "tokio", "tracing", @@ -5303,9 +5762,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.1", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5901,6 +6360,41 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rust-embed" +version = "8.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19afa5b4b6a611de00bd1bdae6ae6f39084c9399f0679c3f52d8469cf335cc23" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0d8afda6374eac59e066abee06d265247ebbaf3006cf878e2879e8356e34053" +dependencies = [ + "mime_guess", + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.114", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d84e8ba78bd384263e5922f084cbe1b081c3b7e69add59c8fb097b879ba968a" +dependencies = [ + "sha2 0.11.1", + "walkdir", +] + [[package]] name = "rust_decimal" version = "1.40.0" @@ -5938,7 +6432,7 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -5964,7 +6458,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6112,6 +6606,15 @@ dependencies = [ "cipher", ] +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.28" @@ -6355,12 +6858,21 @@ checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ "base16ct", "der 0.7.10", - "generic-array", + "generic-array 0.14.9", "pkcs8 0.10.2", "subtle", "zeroize", ] +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -6640,6 +7152,16 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "serdect" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f42f67da2385b51a5f9652db9c93d78aeaf7610bf5ec366080b6de810604af53" +dependencies = [ + "base16ct", + "serde", +] + [[package]] name = "sha1" version = "0.10.6" @@ -6675,6 +7197,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + [[package]] name = "sha3" version = "0.10.8" @@ -6844,6 +7377,12 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" + [[package]] name = "spki" version = "0.5.4" @@ -6992,7 +7531,7 @@ dependencies = [ "futures-core", "futures-io", "futures-util", - "generic-array", + "generic-array 0.14.9", "hex", "hkdf", "hmac", @@ -7845,6 +8384,17 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "std-shims" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "227c4f8561598188d0df96dbe749824576174bba278b5b6bb2eacff1066067d0" +dependencies = [ + "hashbrown 0.16.1", + "rustversion", + "spin 0.10.1", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -8067,7 +8617,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.3", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8179,6 +8729,27 @@ dependencies = [ "crunchy", ] +[[package]] +name = "tinycloud-at-rest-escrow" +version = "0.1.0" +dependencies = [ + "age", + "age-core", + "base64 0.22.1", + "hex", + "hkdf", + "hmac", + "p256", + "rand 0.8.5", + "serde", + "serde_json", + "sha2 0.10.9", + "subtle", + "thiserror 2.0.18", + "vsss-rs", + "zeroize", +] + [[package]] name = "tinycloud-auth" version = "1.3.0" @@ -8206,6 +8777,7 @@ dependencies = [ name = "tinycloud-core" version = "1.3.0" dependencies = [ + "aes", "aes-gcm", "arrow", "async-trait", @@ -8215,9 +8787,13 @@ dependencies = [ "duckdb", "futures", "hex", + "hkdf", + "hmac", "k256", "libp2p", + "lru", "multihash-derive 0.9.1", + "parking_lot", "pin-project", "prometheus", "rand 0.8.5", @@ -8234,12 +8810,14 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "time", + "tinycloud-at-rest-escrow", "tinycloud-auth", "tokio", "tracing", "ucan-capabilities-object", "unicode-normalization", "x25519-dalek", + "zeroize", ] [[package]] @@ -8297,6 +8875,7 @@ dependencies = [ "thiserror 2.0.18", "tikv-jemallocator", "time", + "tinycloud-at-rest-escrow", "tinycloud-auth", "tinycloud-core", "tokio", @@ -8318,6 +8897,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -8745,6 +9325,15 @@ dependencies = [ "utf-8", ] +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash", +] + [[package]] name = "typenum" version = "1.19.0" @@ -8793,6 +9382,31 @@ dependencies = [ "version_check", ] +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "serde", + "tinystr", +] + +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -8838,7 +9452,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.6", "subtle", ] @@ -8944,6 +9558,26 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" +[[package]] +name = "vsss-rs" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ec751bdcc8bda099e269b24cc6b4ad14f9ce8b0490c1599174070e792ecd70c" +dependencies = [ + "crypto-bigint 0.6.1", + "elliptic-curve", + "elliptic-curve-tools", + "generic-array 1.4.2", + "hex", + "hybrid-array 0.4.10", + "num", + "rand_core 0.6.4", + "serde", + "sha3", + "subtle", + "zeroize", +] + [[package]] name = "w5-policy-runtime-node-e2e" version = "0.0.0" @@ -8964,6 +9598,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -9133,6 +9777,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -9518,7 +10171,7 @@ dependencies = [ "data-encoding", "der-parser", "lazy_static", - "nom", + "nom 7.1.3", "oid-registry", "rusticata-macros", "thiserror 1.0.69", @@ -9681,6 +10334,7 @@ version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", diff --git a/tinycloud-at-rest-cli/.gitignore b/tinycloud-at-rest-cli/.gitignore new file mode 100644 index 00000000..ea8c4bf7 --- /dev/null +++ b/tinycloud-at-rest-cli/.gitignore @@ -0,0 +1 @@ +/target diff --git a/tinycloud-at-rest-cli/Cargo.lock b/tinycloud-at-rest-cli/Cargo.lock new file mode 100644 index 00000000..5195516f --- /dev/null +++ b/tinycloud-at-rest-cli/Cargo.lock @@ -0,0 +1,4144 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array 0.14.7", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "age" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd290633c2482479f70f6d1d96ae0e9f52c6a26cd5859edd47ee1fe33fc89f26" +dependencies = [ + "age-core", + "base64 0.22.1", + "bech32", + "chacha20poly1305", + "cipher", + "cookie-factory", + "hkdf", + "hmac 0.12.1", + "hpke", + "i18n-embed", + "i18n-embed-fl", + "lazy_static", + "ml-kem", + "nom", + "p256 0.13.2", + "pin-project", + "rand 0.8.8", + "rust-embed", + "scrypt", + "sha2 0.10.9", + "sha3", + "subtle", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "age-core" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d4375964d1501e5f1b32aef2ead573913893ff238448d4e9fdf1522d828656" +dependencies = [ + "base64 0.22.1", + "bech32", + "chacha20poly1305", + "cookie-factory", + "hkdf", + "hpke", + "io_tee", + "nom", + "rand 0.8.8", + "secrecy", + "sha2 0.10.9", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "asn1_der" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4858a9d740c5007a9069007c3b4e91152d0506f13c1b31dd49051fd537656156" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array 0.14.7", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array 0.4.15", +] + +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byte-slice-cast" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", + "serde", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", + "zeroize", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" +dependencies = [ + "futures", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array 0.14.7", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-bigint" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96272c2ff28b807e09250b180ad1fb7889a3258f7455759b5c3c58b719467130" +dependencies = [ + "num-traits", + "rand_core 0.6.4", + "serdect", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-bigint" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +dependencies = [ + "cpubits", + "ctutils", + "hybrid-array 0.4.15", + "num-traits", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array 0.14.7", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array 0.4.15", + "rand_core 0.10.1", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctutils" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +dependencies = [ + "cmov", + "subtle", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto 0.2.9", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "dcap-qvl" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b992c9511adf7135ae7c63c023c9303a3615c0f38d7b39514dcc262f91c6adda" +dependencies = [ + "anyhow", + "asn1_der", + "base64 0.23.1", + "borsh", + "byteorder", + "chrono", + "const-oid 0.10.2", + "dcap-qvl-webpki", + "der 0.8.2", + "derive_more 2.1.1", + "futures", + "hex", + "log", + "p256 0.14.0", + "parity-scale-codec", + "pem", + "reqwest", + "ring", + "rustls-pki-types", + "scale-info", + "serde", + "serde-human-bytes", + "serde_json", + "sha2 0.11.1", + "signature 3.0.0", + "tracing", + "urlencoding", + "x509-cert", +] + +[[package]] +name = "dcap-qvl-webpki" +version = "0.103.15+dcap.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e8f2341b6a0f80654c55d0753d420aa306c97b5234c3a47f7e4ccfb6a695b2e" +dependencies = [ + "ecdsa 0.17.0", + "ed25519-dalek", + "p256 0.14.0", + "p384", + "ring", + "rustls-pki-types", + "sha2 0.10.9", + "signature 2.2.0", + "untrusted", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" +dependencies = [ + "const-oid 0.10.2", + "der_derive", + "flagset", + "zeroize", +] + +[[package]] +name = "der_derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59600e2c2d636fde9b65e99cc6445ac770c63d3628195ff39932b8d6d7409903" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_more" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +dependencies = [ + "derive_more-impl 1.0.0", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl 2.1.1", +] + +[[package]] +name = "derive_more-impl" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest 0.10.7", + "elliptic-curve 0.13.8", + "rfc6979 0.4.0", + "signature 2.2.0", + "spki 0.7.3", +] + +[[package]] +name = "ecdsa" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" +dependencies = [ + "der 0.8.2", + "digest 0.11.3", + "elliptic-curve 0.14.1", + "rfc6979 0.6.0", + "signature 3.0.0", + "zeroize", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "signature 2.2.0", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "sha2 0.10.9", + "subtle", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct 0.2.0", + "crypto-bigint 0.5.5", + "digest 0.10.7", + "ff 0.13.1", + "generic-array 0.14.7", + "group 0.13.0", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1 0.7.3", + "subtle", + "tap", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" +dependencies = [ + "base16ct 1.0.0", + "crypto-bigint 0.7.5", + "crypto-common 0.2.2", + "digest 0.11.3", + "ff 0.14.0", + "group 0.14.0", + "hybrid-array 0.4.15", + "rand_core 0.10.1", + "sec1 0.8.1", + "subtle", + "zeroize", +] + +[[package]] +name = "elliptic-curve-tools" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de2b6fae800f08032a6ea32995b52925b1d451bff9d445c8ab2932323277faf" +dependencies = [ + "elliptic-curve 0.13.8", + "heapless", + "hex", + "multiexp", + "serde", + "zeroize", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "bitvec", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core 0.10.1", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + +[[package]] +name = "find-crate" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a98bbaacea1c0eb6a0876280051b892eb73594fd90cf3b20e9c817029c57d2" +dependencies = [ + "toml", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + +[[package]] +name = "fluent" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8137a6d5a2c50d6b0ebfcb9aaa91a28154e0a70605f112d30cb0cd4a78670477" +dependencies = [ + "fluent-bundle", + "unic-langid", +] + +[[package]] +name = "fluent-bundle" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01203cb8918f5711e73891b347816d932046f95f54207710bda99beaeb423bf4" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash", + "self_cell", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54f0d287c53ffd184d04d8677f590f4ac5379785529e5e08b1c8083acdd5c198" +dependencies = [ + "memchr", + "thiserror 2.0.21", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +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 = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "generic-array" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "337d46834ee672ab3e48caca2cb0c78cc174fb12b3a68d0d88f99a0519a5e36e" +dependencies = [ + "rustversion", + "typenum", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff 0.13.1", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff 0.14.0", + "rand_core 0.10.1", + "subtle", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hickory-net" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c480823ed7c2c5d0f09c41020cb6b7c28029ce60ec42dc942158dcf22f8e0a4d" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "hickory-proto", + "idna", + "ipnet", + "jni", + "rand 0.10.3", + "thiserror 2.0.21", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12b92608f679a6fa515dd1d15c1ff89443026e391200a2c840c7afcba482893d" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni", + "once_cell", + "prefix-trie", + "rand 0.10.3", + "ring", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3da5255c95d5a716857d54b5b8f4e8d67c3484d3beaaaae2ce25063b3ba981" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-net", + "hickory-proto", + "ipconfig", + "ipnet", + "jni", + "moka", + "ndk-context", + "once_cell", + "parking_lot", + "rand 0.10.3", + "resolv-conf", + "smallvec", + "system-configuration", + "thiserror 2.0.21", + "tokio", + "tracing", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "hpke" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4917627a14198c3603282c5158b815ad5534795451d3c074b53cf3cee0960b11" +dependencies = [ + "aead", + "aes-gcm", + "chacha20poly1305", + "digest 0.10.7", + "generic-array 0.14.7", + "hkdf", + "hmac 0.12.1", + "p256 0.13.2", + "rand_core 0.6.4", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2d35805454dc9f8662a98d6d61886ffe26bd465f5960e0e55345c70d5c0d2a9" +dependencies = [ + "typenum", +] + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "subtle", + "typenum", + "zeroize", +] + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "i18n-config" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e06b90c8a0d252e203c94344b21e35a30f3a3a85dc7db5af8f8df9f3e0c63ef" +dependencies = [ + "basic-toml", + "log", + "serde", + "serde_derive", + "thiserror 1.0.69", + "unic-langid", +] + +[[package]] +name = "i18n-embed" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a217bbb075dcaefb292efa78897fc0678245ca67f265d12c351e42268fcb0305" +dependencies = [ + "arc-swap", + "fluent", + "fluent-langneg", + "fluent-syntax", + "i18n-embed-impl", + "intl-memoizer", + "log", + "parking_lot", + "rust-embed", + "thiserror 1.0.69", + "unic-langid", + "walkdir", +] + +[[package]] +name = "i18n-embed-fl" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "602e6bd30c3db2749e13e38b363a3d98d9d41de1d8de7a79c31bb69e45b47cda" +dependencies = [ + "find-crate", + "fluent", + "fluent-syntax", + "i18n-config", + "i18n-embed", + "proc-macro-error3", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", + "unic-langid", +] + +[[package]] +name = "i18n-embed-impl" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2cc0e0523d1fe6fc2c6f66e5038624ea8091b3e7748b5e8e0c84b1698db6c2" +dependencies = [ + "find-crate", + "i18n-config", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "impl-trait-for-tuples" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array 0.14.7", +] + +[[package]] +name = "intl-memoizer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "io_tee" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b3f7cef34251886990511df1c61443aa928499d598a9473929ab5a90a527304" + +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2", + "widestring", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" +dependencies = [ + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + +[[package]] +name = "kem" +version = "0.3.0-pre.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b8645470337db67b01a7f966decf7d0bafedbae74147d33e641c67a91df239f" +dependencies = [ + "rand_core 0.6.4", + "zeroize", +] + +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "ml-kem" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de49b3df74c35498c0232031bb7e85f9389f913e2796169c8ab47a53993a18f" +dependencies = [ + "hybrid-array 0.2.3", + "kem", + "rand_core 0.6.4", + "sha3", +] + +[[package]] +name = "moka" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "multiexp" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ec2ce93a6f06ac6cae04c1da3f2a6a24fcfc1f0eb0b4e0f3d302f0df45326cb" +dependencies = [ + "ff 0.13.1", + "group 0.13.0", + "rand_core 0.6.4", + "rustversion", + "std-shims", + "zeroize", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", + "rand 0.8.8", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", + "rand 0.8.8", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder 0.13.6", + "sha2 0.10.9", +] + +[[package]] +name = "p256" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" +dependencies = [ + "ecdsa 0.17.0", + "elliptic-curve 0.14.1", + "primefield", + "primeorder 0.14.0", + "sha2 0.11.1", +] + +[[package]] +name = "p384" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d17b851e6b3e378ab4ecb07fa2ed23f4d15f075735f8fec9fa1e7bdce5f8301f" +dependencies = [ + "ecdsa 0.17.0", + "elliptic-curve 0.14.1", + "fiat-crypto 0.3.0", + "primefield", + "primeorder 0.14.0", + "sha2 0.11.1", +] + +[[package]] +name = "parity-scale-codec" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa" +dependencies = [ + "arrayvec", + "bitvec", + "byte-slice-cast", + "const_format", + "impl-trait-for-tuples", + "parity-scale-codec-derive", + "rustversion", + "serde", +] + +[[package]] +name = "parity-scale-codec-derive" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b4653168b563151153c9e4c08ebed57fb8262bebfa79711552fa983c623e7a" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac 0.12.1", +] + +[[package]] +name = "pem" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120" +dependencies = [ + "base64 0.23.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint 0.7.5", + "crypto-common 0.2.2", + "ff 0.14.0", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve 0.13.8", +] + +[[package]] +name = "primeorder" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" +dependencies = [ + "elliptic-curve 0.14.1", + "primefield", + "wnaf", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-error-attr3" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e564d14133360e1ae169ffde5da25881b5fa47261665b8e5713c212c27799da" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error3" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f0d4471b3436c22106b21913b1dda531558918ae9b7ec55d58aa84b43552233" +dependencies = [ + "proc-macro-error-attr3", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.21", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20 0.10.2", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "hickory-resolver", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "once_cell", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + +[[package]] +name = "rfc6979" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" +dependencies = [ + "crypto-bigint 0.7.5", + "hmac 0.13.0", +] + +[[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 = "rust-embed" +version = "8.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19afa5b4b6a611de00bd1bdae6ae6f39084c9399f0679c3f52d8469cf335cc23" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0d8afda6374eac59e066abee06d265247ebbaf3006cf878e2879e8356e34053" +dependencies = [ + "mime_guess", + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.119", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d84e8ba78bd384263e5922f084cbe1b081c3b7e69add59c8fb097b879ba968a" +dependencies = [ + "sha2 0.11.1", + "walkdir", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scale-info" +version = "2.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346a3b32eba2640d17a9cb5927056b08f3de90f65b72fe09402c2ad07d684d0b" +dependencies = [ + "bitvec", + "cfg-if", + "derive_more 1.0.0", + "parity-scale-codec", + "scale-info-derive", +] + +[[package]] +name = "scale-info-derive" +version = "2.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6630024bf739e2179b91fb424b28898baf819414262c5d376677dbff1fe7ebf" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "pbkdf2", + "salsa20", + "sha2 0.10.9", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct 0.2.0", + "der 0.7.10", + "generic-array 0.14.7", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct 1.0.0", + "ctutils", + "der 0.8.2", + "hybrid-array 0.4.15", + "subtle", + "zeroize", +] + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-human-bytes" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aff481ca1fe108deba0f217b45d9f1d494e7e7f906bcc7366d8a5648c5a1e65" +dependencies = [ + "base64 0.13.1", + "hex", + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "foldhash", + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serdect" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f42f67da2385b51a5f9652db9c93d78aeaf7610bf5ec366080b6de810604af53" +dependencies = [ + "base16ct 0.2.0", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" +dependencies = [ + "base64ct", + "der 0.8.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "std-shims" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "227c4f8561598188d0df96dbe749824576174bba278b5b6bb2eacff1066067d0" +dependencies = [ + "hashbrown 0.16.1", + "rustversion", + "spin", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[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.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[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 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tinycloud-at-rest-cli" +version = "0.1.0" +dependencies = [ + "age", + "age-core", + "anyhow", + "base64 0.22.1", + "bech32", + "clap", + "dcap-qvl", + "hex", + "hpke", + "p256 0.13.2", + "rand 0.8.8", + "serde", + "serde_json", + "sha2 0.10.9", + "tinycloud-at-rest-escrow", + "tokio", + "yaml-rust2", + "zeroize", +] + +[[package]] +name = "tinycloud-at-rest-escrow" +version = "0.1.0" +dependencies = [ + "age", + "age-core", + "base64 0.22.1", + "hex", + "hkdf", + "hmac 0.12.1", + "p256 0.13.2", + "rand 0.8.8", + "serde", + "serde_json", + "sha2 0.10.9", + "subtle", + "thiserror 2.0.21", + "vsss-rs", + "zeroize", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "serde", + "tinystr", +] + +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsss-rs" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ec751bdcc8bda099e269b24cc6b4ad14f9ce8b0490c1599174070e792ecd70c" +dependencies = [ + "crypto-bigint 0.6.1", + "elliptic-curve 0.13.8", + "elliptic-curve-tools", + "generic-array 1.4.5", + "hex", + "hybrid-array 0.4.15", + "num", + "rand_core 0.6.4", + "serde", + "sha3", + "subtle", + "zeroize", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +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.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wnaf" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa" +dependencies = [ + "ff 0.14.0", + "group 0.14.0", + "hybrid-array 0.4.15", + "primefield", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "x509-cert" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "105ef4642d9cb137ef83d623d0e4bf08b8adf69e9918ca904a174adb6d3d038b" +dependencies = [ + "const-oid 0.10.2", + "der 0.8.2", + "spki 0.8.1", +] + +[[package]] +name = "yaml-rust2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57e5b818a27a4cd30884ea380857a5e56f7ec3ba24a3990a3cc0b95af3238e18" +dependencies = [ + "arraydeque", + "encoding_rs", + "hashlink", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/tinycloud-at-rest-cli/Cargo.toml b/tinycloud-at-rest-cli/Cargo.toml new file mode 100644 index 00000000..0916d2af --- /dev/null +++ b/tinycloud-at-rest-cli/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "tinycloud-at-rest-cli" +version = "0.1.0" +edition = "2021" +publish = false +description = "Escrow ceremony and recovery tool for TinyCloud encryption at rest (TC-887)" + +# A workspace of its own, with its own Cargo.lock: the DCAP verifier and its +# HTTP stack must not change the node's dependency graph or features. +[workspace] + +[[bin]] +name = "tinycloud-at-rest" +path = "src/main.rs" + +[dependencies] +tinycloud-at-rest-escrow = { path = "../tinycloud-at-rest-escrow" } +# Intel DCAP quote verification (Phala), pinned: it decides whether a holder +# releases a share. +dcap-qvl = { version = "=0.7.0" } +anyhow = "1" +clap = { version = "4", features = ["derive"] } +base64 = "0.22" +hex = "0.4" +rand = "0.8" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +tokio = { version = "1", features = ["macros", "rt", "time"] } +zeroize = "1" +# `self-test`: throwaway P-256 co-holders encoded as age1yubikey1. +p256 = "0.13" +bech32 = "0.11" +yaml-rust2 = "=0.13.0" + +[dev-dependencies] +# Tests only: drive a genuine quote and collateral through every TCB status +# class. Never enabled in the built binary. +dcap-qvl = { version = "=0.7.0", features = ["danger-allow-tcb-override"] } +# Holder-side decryption and the p256tag interop vector in tests/. +age = { version = "=0.12.1", default-features = false, features = ["armor"] } +age-core = "=0.12.0" +hpke = { version = "0.12", default-features = false, features = ["alloc", "p256"] } diff --git a/tinycloud-at-rest-cli/src/attest.rs b/tinycloud-at-rest-cli/src/attest.rs new file mode 100644 index 00000000..eb0cbece --- /dev/null +++ b/tinycloud-at-rest-cli/src/attest.rs @@ -0,0 +1,1080 @@ +//! Deciding whether a TEE's recovery key is safe to release a share to. +//! +//! A holder releases a share only to a recovery recipient that a genuine, +//! up-to-date, non-debug TDX quote binds to, from the exact measured +//! deployment the holder pinned in a trust policy. The steps follow +//! `docs/attestation.md` and the registry's dstack verifier: +//! +//! 1. DCAP: quote signature, PCK chain, CRLs, TCB info and QE identity, with +//! collateral (`dcap-qvl`). The TCB status is a fixed class: `UpToDate` +//! passes; the hardening/configuration classes pass only if the policy +//! allows them and pins every advisory ID; `OutOfDate*`, `TDRelaunch*`, +//! `Revoked` and anything unknown never pass. +//! 2. TD attributes: the debug bit must be clear. +//! 3. MRTD and RTMR0-2 equal the pinned OS-image measurements. +//! 4. The dstack event log replays to the quoted RTMR3, and its `app-id` and +//! `compose-hash` events equal the pinned values. +//! 5. REPORTDATA equals SHA-256 of the binding the holder recomputes from +//! their own fresh nonce, the bundle's EFP and the recovery recipient. + +use std::time::Duration; + +use anyhow::{anyhow, bail, ensure, Context, Result}; +use dcap_qvl::TcbStatus; +use serde::Deserialize; +use sha2::{Digest, Sha256, Sha384}; +use tinycloud_at_rest_escrow::{ + binding_digest, recovery_binding, validate_nonce, EscrowBundle, RecoveryInfo, +}; + +/// A TCB status a trust policy may accept. `OutOfDate*`, `TDRelaunchAdvised*` +/// and `Revoked` are deliberately not representable. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum AcceptedTcb { + UpToDate, + SWHardeningNeeded, + ConfigurationNeeded, + ConfigurationAndSWHardeningNeeded, +} + +/// Measurements a holder trusts, from a source independent of the node +/// being verified (the release's published measurements, Phala's dashboard +/// cross-checked by Sam). Hex, lowercase. +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TrustPolicy { + /// dstack app id (20 bytes). + pub app_id: String, + /// SHA-256 of the deployed app-compose.json. + pub compose_hash: String, + /// dstack OS image measurements (48 bytes each). + pub mrtd: String, + pub rtmr0: String, + pub rtmr1: String, + pub rtmr2: String, + /// Accepted TCB statuses. Default: only `UpToDate`. + #[serde(default = "default_tcb")] + pub allowed_tcb_status: Vec, + /// Intel advisory IDs (e.g. `INTEL-SA-00837`) the holder accepts. Every + /// advisory the quote's TCB level carries must be listed here. + #[serde(default)] + pub allowed_advisories: Vec, +} + +fn default_tcb() -> Vec { + vec![AcceptedTcb::UpToDate] +} + +fn hex_exact(value: &str, bytes: usize, label: &str) -> Result> { + ensure!( + value.len() == bytes * 2 + && value + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)), + "{label} must be {bytes} bytes of lowercase hex" + ); + Ok(hex::decode(value)?) +} + +impl TrustPolicy { + pub fn validate(&self) -> Result<()> { + hex_exact(&self.app_id, 20, "trust policy app_id")?; + hex_exact(&self.compose_hash, 32, "trust policy compose_hash")?; + for (v, l) in [ + (&self.mrtd, "mrtd"), + (&self.rtmr0, "rtmr0"), + (&self.rtmr1, "rtmr1"), + (&self.rtmr2, "rtmr2"), + ] { + hex_exact(v, 48, &format!("trust policy {l}"))?; + } + ensure!( + !self.allowed_tcb_status.is_empty(), + "trust policy allowed_tcb_status is empty" + ); + for id in &self.allowed_advisories { + ensure!( + id.starts_with("INTEL-SA-") && id.len() <= 32, + "trust policy advisory {id:?} is not an INTEL-SA-… id" + ); + } + Ok(()) + } +} + +/// The fields of a DCAP-verified TD report this tool decides on. +#[derive(Clone, Debug)] +pub struct AttestedTd { + pub tcb_status: TcbStatus, + pub advisory_ids: Vec, + pub td_attributes: [u8; 8], + pub mr_td: [u8; 48], + pub rtmr: [[u8; 48]; 4], + pub report_data: [u8; 64], +} + +impl AttestedTd { + fn from_report(verified: &dcap_qvl::verify::VerifiedReport) -> Result { + // dcap-qvl prints the enum variant name; anything else is refused. + let tcb_status: TcbStatus = + serde_json::from_value(serde_json::Value::String(verified.status.clone())) + .map_err(|_| anyhow!("unknown TCB status {:?}", verified.status))?; + let td = verified + .report + .as_td10() + .ok_or_else(|| anyhow!("quote is not a TDX quote"))?; + Ok(Self { + tcb_status, + advisory_ids: verified.advisory_ids.clone(), + td_attributes: td.td_attributes, + mr_td: td.mr_td, + rtmr: [td.rt_mr0, td.rt_mr1, td.rt_mr2, td.rt_mr3], + report_data: td.report_data, + }) + } +} + +/// Step 1: full DCAP verification of `quote` against `collateral` at `now`. +pub fn verify_quote( + quote: &[u8], + collateral: &dcap_qvl::QuoteCollateralV3, + now_secs: u64, +) -> Result { + let verified = dcap_qvl::verify::verify(quote, collateral, now_secs) + .map_err(|e| anyhow!("TDX quote failed DCAP verification: {e}"))?; + AttestedTd::from_report(&verified) +} + +/// The TCB decision. Returns a line to print on success. +pub fn check_tcb(td: &AttestedTd, trust: &TrustPolicy) -> Result { + let accepted = match td.tcb_status { + TcbStatus::UpToDate => AcceptedTcb::UpToDate, + TcbStatus::SWHardeningNeeded => AcceptedTcb::SWHardeningNeeded, + TcbStatus::ConfigurationNeeded => AcceptedTcb::ConfigurationNeeded, + TcbStatus::ConfigurationAndSWHardeningNeeded => { + AcceptedTcb::ConfigurationAndSWHardeningNeeded + } + TcbStatus::OutOfDate + | TcbStatus::OutOfDateConfigurationNeeded + | TcbStatus::TDRelaunchAdvised + | TcbStatus::TDRelaunchAdvisedConfigurationNeeded + | TcbStatus::Revoked => bail!( + "TDX TCB status is {} (advisories {:?}); this class is never accepted", + td.tcb_status, + td.advisory_ids + ), + }; + ensure!( + trust.allowed_tcb_status.contains(&accepted), + "TDX TCB status is {} (advisories {:?}), not one of {:?}", + td.tcb_status, + td.advisory_ids, + trust.allowed_tcb_status + ); + // A non-UpToDate class always comes with the advisories that caused it; + // one without any is anomalous. + ensure!( + accepted == AcceptedTcb::UpToDate || !td.advisory_ids.is_empty(), + "TDX TCB status {} carries no advisory IDs, which is anomalous; refused", + td.tcb_status + ); + let unpinned: Vec<&String> = td + .advisory_ids + .iter() + .filter(|id| !trust.allowed_advisories.contains(id)) + .collect(); + ensure!( + unpinned.is_empty(), + "TDX TCB status {} carries advisories {unpinned:?} that the trust policy does not pin", + td.tcb_status + ); + Ok(format!( + "TCB status {} with advisories {:?}", + td.tcb_status, td.advisory_ids + )) +} + +/// Fetch collateral for `quote` from a PCCS (default: Phala's). Intel PCS +/// (`https://api.trustedservices.intel.com`) also works. +pub async fn fetch_collateral(quote: &[u8], pccs_url: &str) -> Result { + let fetch = async { + dcap_qvl::collateral::CollateralClient::with_default_http(pccs_url) + .map_err(|e| anyhow!("PCCS client: {e}"))? + .fetch(quote) + .await + .map_err(|e| anyhow!("fetching DCAP collateral from {pccs_url}: {e}")) + }; + tokio::time::timeout(Duration::from_secs(60), fetch) + .await + .map_err(|_| anyhow!("fetching DCAP collateral from {pccs_url} timed out"))? +} + +/// The issue/next-update window of the TCB info used, for the holder's record. +pub fn collateral_dates(collateral: &dcap_qvl::QuoteCollateralV3) -> String { + let info: Result = serde_json::from_str(&collateral.tcb_info); + match info { + Ok(i) => format!( + "TCB info issued {} (next update {}, evaluation data #{})", + i.issue_date, i.next_update, i.tcb_evaluation_data_number + ), + Err(_) => "TCB info dates unavailable".into(), + } +} + +/// What the node answered must describe an attested TEE before anything +/// else is checked: `mode` dstack, a non-empty hex quote. +pub fn require_attested(info: &RecoveryInfo) -> Result> { + ensure!( + info.mode == "dstack", + "the node is not in a TEE (mode {:?}); a share is only released to an attested TEE", + info.mode + ); + let quote = hex::decode(info.quote.as_deref().unwrap_or_default()) + .context("the node's quote is not hex")?; + ensure!(!quote.is_empty(), "the node sent no TDX quote"); + Ok(quote) +} + +/// What `verify-compose` found: each service's pinned image, plus the other +/// app-compose settings the hash covers. +#[derive(Debug)] +pub struct ComposeReport { + pub images: Vec<(String, String)>, + pub settings: Vec, +} + +/// `compose-hash` pins container images only if the compose file names each +/// image by digest. Check `app-compose.json` against the trust policy's +/// `compose_hash`, then parse its compose YAML strictly (see `compose.rs`). +pub fn check_app_compose(app_compose: &[u8], trust: &TrustPolicy) -> Result { + let digest = hex::encode(Sha256::digest(app_compose)); + ensure!( + digest == trust.compose_hash, + "app-compose.json hashes to {digest}, but the trust policy pins compose_hash {}", + trust.compose_hash + ); + let doc: serde_json::Value = + serde_json::from_slice(app_compose).context("app-compose.json is not JSON")?; + let compose = doc + .get("docker_compose_file") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("app-compose.json has no docker_compose_file"))?; + Ok(ComposeReport { + images: crate::compose::pinned_images(compose)?, + settings: crate::compose::other_settings(&doc), + }) +} + +const RUNTIME_EVENT_TYPE: u32 = 0x0800_0001; + +#[derive(Debug, Deserialize)] +struct Event { + imr: u32, + event_type: u32, + #[serde(default)] + digest: String, + #[serde(default)] + event: String, + #[serde(default)] + event_payload: String, + #[serde(default)] + version: Option, + #[serde(default)] + preimage: Option, +} + +fn runtime_event_digest(index: usize, e: &Event) -> Result<[u8; 48]> { + let payload = hex::decode(&e.event_payload) + .with_context(|| format!("runtime event {index} payload is not hex"))?; + let preimage = match e.version.unwrap_or(1) { + 1 => { + let mut p = RUNTIME_EVENT_TYPE.to_le_bytes().to_vec(); + p.push(b':'); + p.extend_from_slice(e.event.as_bytes()); + p.push(b':'); + p.extend_from_slice(&payload); + p + } + 2 => { + // dstack v2: canonical JSON {"name","payload","type"}; the log + // carries it and it must match exactly. + let canonical = serde_json::json!({ + "name": e.event, + "payload": hex::encode(&payload), + "type": RUNTIME_EVENT_TYPE, + }) + .to_string() + .into_bytes(); + let given = e + .preimage + .as_deref() + .ok_or_else(|| anyhow!("runtime event {index} has no preimage"))?; + ensure!( + hex::decode(given).ok().as_deref() == Some(canonical.as_slice()), + "runtime event {index} preimage is not canonical" + ); + canonical + } + _ => bail!("runtime event {index}: unsupported dstack event-log version"), + }; + Ok(Sha384::digest(&preimage).into()) +} + +/// Replay the RTMR3 events of a dstack event log. +fn replay_rtmr3(events: &[Event]) -> Result<[u8; 48]> { + let mut register = [0u8; 48]; + let mut runtime = 0usize; + for (index, e) in events.iter().enumerate() { + ensure!(e.imr <= 3, "event log entry {index} has an invalid IMR"); + if e.imr != 3 { + continue; + } + runtime += 1; + ensure!( + e.event_type == RUNTIME_EVENT_TYPE, + "runtime event {index} has an unsupported type" + ); + let rebuilt = runtime_event_digest(index, e)?; + // dstack's stripped log leaves runtime digests empty; the authenticated + // value is rebuilt from the payload either way. + if !e.digest.is_empty() { + ensure!( + hex::decode(&e.digest).ok().as_deref() == Some(rebuilt.as_slice()), + "runtime event {index} digest does not bind its contents" + ); + } + let mut h = Sha384::new(); + h.update(register); + h.update(rebuilt); + register = h.finalize().into(); + } + ensure!(runtime > 0, "dstack event log has no RTMR3 events"); + Ok(register) +} + +/// The single `name` event, measured before `boot-mr-done`, must carry `expected`. +fn require_boot_event(events: &[Event], name: &str, expected: &str) -> Result<()> { + let runtime: Vec<&Event> = events.iter().filter(|e| e.imr == 3).collect(); + let position = |n: &str| runtime.iter().position(|e| e.event == n); + let count = |n: &str| runtime.iter().filter(|e| e.event == n).count(); + ensure!( + count("boot-mr-done") == 1 && count("system-ready") == 1, + "dstack boot markers must each appear once" + ); + let boot_done = position("boot-mr-done").expect("counted"); + let system_ready = position("system-ready").expect("counted"); + ensure!( + boot_done > 0 && system_ready > boot_done, + "dstack boot event ordering is invalid" + ); + ensure!( + count(name) == 1, + "dstack event log must contain exactly one {name} event" + ); + let at = position(name).expect("counted"); + ensure!( + at < boot_done, + "{name} must be measured before boot completion" + ); + ensure!( + runtime[at].event_payload == expected, + "{name} in the attested event log is {}, but the trust policy pins {expected}", + runtime[at].event_payload + ); + Ok(()) +} + +/// Smallest nonce accepted for freshness: 128 bits as base64url. +pub const MIN_NONCE_LEN: usize = 22; + +/// Checks that need no network or quote, run first so a stale bundle or a +/// bad nonce is reported as such: the node's answer names the holder's +/// bundle, the nonce is fresh enough, and the node's binding is the one the +/// holder recomputes. Returns the binding digest the quote must carry. +pub fn check_local( + info: &RecoveryInfo, + bundle: &EscrowBundle, + nonce: &str, +) -> Result { + bundle.validate()?; + validate_nonce(nonce)?; + ensure!( + nonce.len() >= MIN_NONCE_LEN, + "nonce must be at least {MIN_NONCE_LEN} chars; use `tinycloud-at-rest nonce`" + ); + let efp = bundle.fingerprint(); + ensure!( + info.efp == efp && info.ark_id == bundle.ark_id && info.generation == bundle.generation, + "the node's escrow ({}) is not the bundle you hold ({efp}): your bundle is stale or for another node; get the current one", + info.efp + ); + // A reply made for another nonce is stale or replayed. + let expected = recovery_binding(&info.recovery_recipient, &efp, nonce); + ensure!( + info.binding == expected, + "the node's binding is not for your nonce: stale or replayed response; request a new one" + ); + let digest = binding_digest(&expected); + ensure!( + info.binding_sha256 == digest, + "binding_sha256 does not match binding" + ); + Ok(digest) +} + +/// Steps 2-5 on a DCAP-verified report. Returns the TCB line to show. +pub fn check_attestation( + info: &RecoveryInfo, + trust: &TrustPolicy, + td: &AttestedTd, + digest: &tinycloud_at_rest_escrow::Digest32, +) -> Result { + trust.validate()?; + let tcb = check_tcb(td, trust)?; + ensure!( + td.td_attributes[0] & 0x01 == 0, + "the TD is a debug TD (TUD.DEBUG set); its memory is readable by the host" + ); + for (got, want, label) in [ + (&td.mr_td, &trust.mrtd, "MRTD"), + (&td.rtmr[0], &trust.rtmr0, "RTMR0"), + (&td.rtmr[1], &trust.rtmr1, "RTMR1"), + (&td.rtmr[2], &trust.rtmr2, "RTMR2"), + ] { + ensure!( + hex::encode(got) == *want, + "{label} is {}, but the trust policy pins {want}", + hex::encode(got) + ); + } + + let log = info + .event_log + .as_deref() + .ok_or_else(|| anyhow!("the node sent no event log"))?; + let events: Vec = serde_json::from_str(log).context("event log is not a JSON array")?; + ensure!( + replay_rtmr3(&events)? == td.rtmr[3], + "event log does not replay to the quoted RTMR3" + ); + require_boot_event(&events, "app-id", &trust.app_id)?; + require_boot_event(&events, "compose-hash", &trust.compose_hash)?; + + let mut want_report = [0u8; 64]; + want_report[..32].copy_from_slice(&digest.0); + ensure!( + td.report_data == want_report, + "the quote's REPORTDATA does not bind this recovery key and your nonce: replayed or foreign quote" + ); + Ok(tcb) +} + +/// [`check_local`] then [`check_attestation`]. +#[cfg(test)] +fn check_recovery( + info: &RecoveryInfo, + bundle: &EscrowBundle, + nonce: &str, + trust: &TrustPolicy, + td: &AttestedTd, +) -> Result { + let digest = check_local(info, bundle, nonce)?; + check_attestation(info, trust, td, &digest) +} + +#[cfg(test)] +mod tests { + use super::*; + use tinycloud_at_rest_escrow::{ + Ark, ArkId, Digest32, HolderRecipient, RecipientPolicy, RecoveryIdentity, + }; + + const TDX_QUOTE: &[u8] = include_bytes!("../tests/fixtures/tdx_quote"); + const TDX_COLLATERAL: &[u8] = include_bytes!("../tests/fixtures/tdx_quote_collateral.json"); + const TDX_QUOTE_OUTDATED: &[u8] = include_bytes!("../tests/fixtures/tdx_quote_outdated"); + const TDX_COLLATERAL_OUTDATED: &[u8] = + include_bytes!("../tests/fixtures/tdx_quote_outdated_collateral.json"); + // Inside every validity window of the fixture collateral (TCB info and QE + // identity issued 2025-06-19 / 2026-02-18, valid 30 days). + const FIXTURE_NOW: u64 = 1_750_377_600; // 2025-06-20T00:00:00Z + const FIXTURE_NOW_OUTDATED: u64 = 1_771_459_200; // 2026-02-19T00:00:00Z + + const NONCE: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + const APP_ID: &str = "1111111111111111111111111111111111111111"; + const COMPOSE: &str = "2222222222222222222222222222222222222222222222222222222222222222"; + + fn bundle() -> EscrowBundle { + let holders: Vec = ["a", "b", "c"] + .into_iter() + .map(|h| HolderRecipient { + holder: h.into(), + recipient: RecoveryIdentity::generate().recipient(), + }) + .collect(); + EscrowBundle::create( + &Ark::generate(), + ArkId::generate(), + 1, + &holders, + RecipientPolicy::AllowSoftware, + ) + .unwrap() + } + + fn event(name: &str, payload: &str) -> serde_json::Value { + serde_json::json!({ + "imr": 3, "event_type": RUNTIME_EVENT_TYPE, "digest": "", + "event": name, "event_payload": payload, + }) + } + + fn boot_events(app_id: &str, compose: &str) -> Vec { + vec![ + serde_json::json!({"imr": 0, "event_type": 1, "digest": "00", "event": "", "event_payload": ""}), + event("system-preparing", ""), + event("app-id", app_id), + event("compose-hash", compose), + event("instance-id", "33"), + event("boot-mr-done", ""), + event("system-ready", ""), + ] + } + + fn event_log(app_id: &str, compose: &str) -> String { + serde_json::Value::Array(boot_events(app_id, compose)).to_string() + } + + fn rtmr3_of(log: &str) -> [u8; 48] { + replay_rtmr3(&serde_json::from_str::>(log).unwrap()).unwrap() + } + + /// A consistent, passing scenario built on synthetic measurements. + struct Scenario { + bundle: EscrowBundle, + info: RecoveryInfo, + trust: TrustPolicy, + td: AttestedTd, + } + + fn scenario() -> Scenario { + let bundle = bundle(); + let tee = RecoveryIdentity::generate(); + let binding = recovery_binding(&tee.recipient(), &bundle.fingerprint(), NONCE); + let digest = binding_digest(&binding); + let log = event_log(APP_ID, COMPOSE); + let mut report_data = [0u8; 64]; + report_data[..32].copy_from_slice(&digest.0); + let td = AttestedTd { + tcb_status: TcbStatus::UpToDate, + advisory_ids: vec![], + td_attributes: [0; 8], + mr_td: [0xa0; 48], + rtmr: [[0xa1; 48], [0xa2; 48], [0xa3; 48], rtmr3_of(&log)], + report_data, + }; + let trust = TrustPolicy { + app_id: APP_ID.into(), + compose_hash: COMPOSE.into(), + mrtd: hex::encode([0xa0; 48]), + rtmr0: hex::encode([0xa1; 48]), + rtmr1: hex::encode([0xa2; 48]), + rtmr2: hex::encode([0xa3; 48]), + allowed_tcb_status: default_tcb(), + allowed_advisories: vec![], + }; + let info = RecoveryInfo { + version: tinycloud_at_rest_escrow::RECOVERY_INFO_VERSION, + mode: "dstack".into(), + state: "unlocked".into(), + ark_id: bundle.ark_id, + generation: bundle.generation, + efp: bundle.fingerprint(), + canary_fp: bundle.canary_fp, + recovery_recipient: tee.recipient(), + binding: binding.clone(), + binding_sha256: digest, + quote: Some("00".into()), + event_log: Some(log), + shares_held: vec![], + }; + Scenario { + bundle, + info, + trust, + td, + } + } + + impl Scenario { + /// Replace the event log and keep RTMR3 consistent with it, as a TEE + /// that really measured those events would. + fn with_events(mut self, events: Vec) -> Self { + let log = serde_json::Value::Array(events).to_string(); + self.td.rtmr[3] = rtmr3_of(&log); + self.info.event_log = Some(log); + self + } + + /// Replace the event log with one that doesn't replay (RTMR3 unchanged). + fn with_events_unchecked(mut self, events: Vec) -> Self { + self.info.event_log = Some(serde_json::Value::Array(events).to_string()); + self + } + } + + fn check(s: &Scenario, nonce: &str) -> Result { + check_recovery(&s.info, &s.bundle, nonce, &s.trust, &s.td) + } + + fn assert_refused(s: &Scenario, nonce: &str, needle: &str) { + let err = check(s, nonce).expect_err("must refuse").to_string(); + assert!(err.contains(needle), "{err:?} lacks {needle:?}"); + } + + #[test] + fn consistent_attestation_passes() { + let line = check(&scenario(), NONCE).unwrap(); + assert!(line.contains("UpToDate"), "{line}"); + } + + #[test] + fn stale_nonce_is_refused() { + // The node answered for an older nonce; the holder checks with theirs. + let s = scenario(); + assert_refused( + &s, + "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + "not for your nonce", + ); + assert_refused(&s, "short", "at least 22"); + } + + #[test] + fn replayed_quote_with_other_report_data_is_refused() { + // A genuine quote for a different binding, with the node's JSON + // rewritten to name the attacker's recipient. + let mut s = scenario(); + let attacker = RecoveryIdentity::generate().recipient(); + s.info.recovery_recipient = attacker.clone(); + s.info.binding = recovery_binding(&attacker, &s.bundle.fingerprint(), NONCE); + s.info.binding_sha256 = binding_digest(&s.info.binding); + assert_refused(&s, NONCE, "REPORTDATA does not bind"); + } + + #[test] + fn wrong_measurements_are_refused() { + let mut s = scenario(); + s.td.mr_td[0] ^= 1; + assert_refused(&s, NONCE, "MRTD"); + + for (i, label) in [(0usize, "RTMR0"), (1, "RTMR1"), (2, "RTMR2")] { + let mut s = scenario(); + s.td.rtmr[i][47] ^= 1; + assert_refused(&s, NONCE, label); + } + + let mut s = scenario(); + s.td.rtmr[3][0] ^= 1; + assert_refused(&s, NONCE, "replay to the quoted RTMR3"); + + // Another app (its own consistent log and RTMR3) and another compose. + let other = "9999999999999999999999999999999999999999"; + let s = scenario().with_events(boot_events(other, COMPOSE)); + assert_refused(&s, NONCE, "app-id"); + let s = scenario().with_events(boot_events(APP_ID, &"9".repeat(64))); + assert_refused(&s, NONCE, "compose-hash"); + } + + #[test] + fn identity_events_after_boot_or_duplicated_are_refused() { + // A compose-hash (or app-id) measured after boot-mr-done is something + // the app itself could have extended: refused even if it matches. + for name in ["compose-hash", "app-id"] { + let mut events = boot_events(APP_ID, COMPOSE); + let at = events.iter().position(|e| e["event"] == name).unwrap(); + let moved = events.remove(at); + events.push(moved); + assert_refused( + &scenario().with_events(events), + NONCE, + "before boot completion", + ); + } + // Duplicated identity events, even with equal payloads. + for (name, payload) in [("app-id", APP_ID), ("compose-hash", COMPOSE)] { + let mut events = boot_events(APP_ID, COMPOSE); + events.insert(2, event(name, payload)); + assert_refused(&scenario().with_events(events), NONCE, "exactly one"); + } + // Missing, duplicated or reordered boot markers. + let mut events = boot_events(APP_ID, COMPOSE); + events.retain(|e| e["event"] != "boot-mr-done"); + assert_refused(&scenario().with_events(events), NONCE, "boot markers"); + let mut events = boot_events(APP_ID, COMPOSE); + events.push(event("boot-mr-done", "")); + assert_refused(&scenario().with_events(events), NONCE, "boot markers"); + let mut events = boot_events(APP_ID, COMPOSE); + let ready = events + .iter() + .position(|e| e["event"] == "system-ready") + .unwrap(); + let ready = events.remove(ready); + events.insert(1, ready); + assert_refused(&scenario().with_events(events), NONCE, "ordering"); + } + + fn v2_event(name: &str, payload_hex: &str, preimage: Option) -> serde_json::Value { + let canonical = serde_json::json!({ + "name": name, "payload": payload_hex, "type": RUNTIME_EVENT_TYPE, + }) + .to_string(); + serde_json::json!({ + "imr": 3, "event_type": RUNTIME_EVENT_TYPE, "digest": "", "version": 2, + "event": name, "event_payload": payload_hex, + "preimage": preimage.unwrap_or_else(|| hex::encode(canonical)), + }) + } + + #[test] + fn v2_events_replay_and_a_non_canonical_preimage_is_refused() { + let mut events = boot_events(APP_ID, COMPOSE); + events[3] = v2_event("compose-hash", COMPOSE, None); + check(&scenario().with_events(events.clone()), NONCE).unwrap(); + + // Same digest input, different bytes: a reordered-key preimage. + let reordered = serde_json::json!({"type": RUNTIME_EVENT_TYPE, "name": "compose-hash", "payload": COMPOSE}); + events[3] = v2_event( + "compose-hash", + COMPOSE, + Some(hex::encode(reordered.to_string())), + ); + let log = serde_json::Value::Array(events.clone()).to_string(); + let err = replay_rtmr3(&serde_json::from_str::>(&log).unwrap()).unwrap_err(); + assert!(err.to_string().contains("not canonical"), "{err}"); + + // A v2 event with no preimage, and an unknown version. + let mut no_pre = v2_event("compose-hash", COMPOSE, None); + no_pre.as_object_mut().unwrap().remove("preimage"); + events[3] = no_pre; + let log = serde_json::Value::Array(events.clone()).to_string(); + assert!(replay_rtmr3(&serde_json::from_str::>(&log).unwrap()).is_err()); + events[3]["version"] = serde_json::json!(3); + let log = serde_json::Value::Array(events).to_string(); + assert!(replay_rtmr3(&serde_json::from_str::>(&log).unwrap()).is_err()); + } + + #[test] + fn debug_td_is_refused() { + let mut s = scenario(); + s.td.td_attributes[0] |= 1; + assert_refused(&s, NONCE, "debug TD"); + } + + #[test] + fn node_escrow_must_match_the_holders_bundle() { + let mut s = scenario(); + s.info.efp = Digest32([7; 32]); + assert_refused(&s, NONCE, "not the bundle you hold"); + } + + #[test] + fn tampered_event_log_is_refused() { + let mut s = scenario(); + let mut events: Vec = + serde_json::from_str(s.info.event_log.as_deref().unwrap()).unwrap(); + events[2]["digest"] = serde_json::json!("00".repeat(48)); + s.info.event_log = Some(serde_json::Value::Array(events).to_string()); + assert_refused(&s, NONCE, "digest does not bind"); + } + + #[test] + fn malformed_event_logs_are_refused() { + let mut s = scenario(); + s.info.event_log = None; + assert_refused(&s, NONCE, "no event log"); + let mut s = scenario(); + s.info.event_log = Some("{not json".into()); + assert_refused(&s, NONCE, "not a JSON array"); + let mut s = scenario(); + s.info.event_log = Some("[]".into()); + assert_refused(&s, NONCE, "no RTMR3 events"); + + let mut events = boot_events(APP_ID, COMPOSE); + events[1]["imr"] = 4.into(); + assert_refused( + &scenario().with_events_unchecked(events), + NONCE, + "invalid IMR", + ); + let mut events = boot_events(APP_ID, COMPOSE); + events[2]["event_type"] = 1.into(); + assert_refused( + &scenario().with_events_unchecked(events), + NONCE, + "unsupported type", + ); + let mut events = boot_events(APP_ID, COMPOSE); + events[2]["event_payload"] = "xyz".into(); + assert_refused(&scenario().with_events_unchecked(events), NONCE, "not hex"); + } + + #[test] + fn binding_digest_must_match_the_binding() { + let mut s = scenario(); + s.info.binding_sha256 = Digest32([3; 32]); + assert_refused(&s, NONCE, "binding_sha256"); + } + + #[test] + fn local_problems_are_named_before_any_network_or_quote_check() { + // A quote that would fail every attestation check: the local cause wins. + let mut s = scenario(); + s.td.tcb_status = TcbStatus::Revoked; + s.td.mr_td = [0; 48]; + s.info.efp = Digest32([7; 32]); + let err = check_local(&s.info, &s.bundle, NONCE) + .unwrap_err() + .to_string(); + assert!(err.contains("stale"), "{err}"); + let s = scenario(); + let err = check_local(&s.info, &s.bundle, "short") + .unwrap_err() + .to_string(); + assert!(err.contains("at least 22"), "{err}"); + } + + #[test] + fn only_an_attested_tee_answer_is_considered() { + let mut s = scenario(); + assert!(require_attested(&s.info).is_ok()); + s.info.mode = "classic".into(); + assert!(require_attested(&s.info) + .unwrap_err() + .to_string() + .contains("not in a TEE")); + let mut s = scenario(); + s.info.quote = None; + assert!(require_attested(&s.info) + .unwrap_err() + .to_string() + .contains("no TDX quote")); + s.info.quote = Some("zz".into()); + assert!(require_attested(&s.info).is_err()); + } + + fn collateral(bytes: &[u8]) -> dcap_qvl::QuoteCollateralV3 { + serde_json::from_slice(bytes).unwrap() + } + + #[test] + fn genuine_quote_verifies_but_is_refused_as_foreign() { + // A real Intel-signed TDX quote passes DCAP, but its REPORTDATA binds + // someone else's data, so it can't vouch for our recovery key. + let td = verify_quote(TDX_QUOTE, &collateral(TDX_COLLATERAL), FIXTURE_NOW).unwrap(); + let mut s = scenario(); + s.trust.mrtd = hex::encode(td.mr_td); + s.trust.rtmr0 = hex::encode(td.rtmr[0]); + s.trust.rtmr1 = hex::encode(td.rtmr[1]); + s.trust.rtmr2 = hex::encode(td.rtmr[2]); + s.trust.allowed_tcb_status = vec![ + AcceptedTcb::UpToDate, + AcceptedTcb::SWHardeningNeeded, + AcceptedTcb::ConfigurationNeeded, + AcceptedTcb::ConfigurationAndSWHardeningNeeded, + ]; + s.trust.allowed_advisories = td.advisory_ids.clone(); + let log_rtmr3 = s.td.rtmr[3]; + s.td = td; + s.td.rtmr[3] = log_rtmr3; + assert_refused(&s, NONCE, "REPORTDATA does not bind"); + } + + /// The genuine fixture quote and collateral, with every TCB level of the + /// (otherwise genuine) TCB info forced to `status` and `advisories`. + fn real_quote_with_status(status: TcbStatus, advisories: &[&str]) -> Result { + let advisories: Vec = advisories.iter().map(|s| s.to_string()).collect(); + let verified = dcap_qvl::verify::QuoteVerifier::new_prod() + .dangerous_verify_with_tcb_override( + TDX_QUOTE, + &collateral(TDX_COLLATERAL), + FIXTURE_NOW, + |mut info| { + for level in &mut info.tcb_levels { + level.tcb_status = status; + level.advisory_ids = advisories.clone(); + } + info + }, + ) + .map_err(|e| anyhow!("DCAP: {e}"))?; + AttestedTd::from_report(&verified) + } + + #[test] + fn every_tcb_class_from_a_real_quote() { + const SA: &str = "INTEL-SA-00837"; + let default = scenario().trust; + let mut lenient = default.clone(); + lenient.allowed_tcb_status = vec![ + AcceptedTcb::UpToDate, + AcceptedTcb::SWHardeningNeeded, + AcceptedTcb::ConfigurationNeeded, + AcceptedTcb::ConfigurationAndSWHardeningNeeded, + ]; + let mut pinned = lenient.clone(); + pinned.allowed_advisories = vec![SA.into()]; + + let up = real_quote_with_status(TcbStatus::UpToDate, &[]).unwrap(); + assert_eq!(up.tcb_status, TcbStatus::UpToDate); + check_tcb(&up, &default).unwrap(); + + for status in [ + TcbStatus::SWHardeningNeeded, + TcbStatus::ConfigurationNeeded, + TcbStatus::ConfigurationAndSWHardeningNeeded, + ] { + let td = real_quote_with_status(status, &[SA]).unwrap(); + assert_eq!(td.tcb_status, status); + let err = check_tcb(&td, &default).unwrap_err().to_string(); + assert!(err.contains("not one of"), "{status}: {err}"); + let err = check_tcb(&td, &lenient).unwrap_err().to_string(); + assert!(err.contains("does not pin"), "{status}: {err}"); + let ok = check_tcb(&td, &pinned).unwrap(); + assert!(ok.contains(SA), "{ok}"); + } + + // A hardening class with no advisories at all is anomalous: refused. + let bare = real_quote_with_status(TcbStatus::SWHardeningNeeded, &[]).unwrap(); + let err = check_tcb(&bare, &pinned).unwrap_err().to_string(); + assert!(err.contains("anomalous"), "{err}"); + + // Never accepted, whatever the policy says. + for status in [ + TcbStatus::OutOfDate, + TcbStatus::OutOfDateConfigurationNeeded, + TcbStatus::TDRelaunchAdvised, + TcbStatus::TDRelaunchAdvisedConfigurationNeeded, + TcbStatus::Revoked, + ] { + match real_quote_with_status(status, &[SA]) { + // DCAP itself rejects some classes (e.g. Revoked). + Err(e) => assert!(e.to_string().contains("DCAP"), "{status}: {e}"), + Ok(td) => { + let err = check_tcb(&td, &pinned).unwrap_err().to_string(); + assert!(err.contains("never accepted"), "{status}: {err}"); + } + } + } + + // An unknown status string from the verifier is refused. + let mut report = dcap_qvl::verify::QuoteVerifier::new_prod() + .dangerous_verify_with_tcb_override( + TDX_QUOTE, + &collateral(TDX_COLLATERAL), + FIXTURE_NOW, + |info| info, + ) + .unwrap(); + report.status = "MostlyFine".into(); + assert!(AttestedTd::from_report(&report) + .unwrap_err() + .to_string() + .contains("unknown TCB status")); + } + + #[test] + fn trust_policy_cannot_name_refused_classes() { + let base = r#""app_id":"1111111111111111111111111111111111111111","compose_hash":"22","mrtd":"a","rtmr0":"a","rtmr1":"a","rtmr2":"a""#; + for status in ["OutOfDate", "Revoked", "TDRelaunchAdvised", "NotAStatus"] { + let json = format!(r#"{{{base},"allowed_tcb_status":["{status}"]}}"#); + assert!( + serde_json::from_str::(&json).is_err(), + "{status}" + ); + } + let json = format!(r#"{{{base},"allowed_tcb_status":["SWHardeningNeeded"]}}"#); + assert!(serde_json::from_str::(&json).is_ok()); + let mut t = scenario().trust; + t.allowed_advisories = vec!["CVE-2024-1".into()]; + assert!(t.validate().is_err()); + } + + #[test] + fn forged_quote_fails_dcap() { + // REPORTDATA, MRTD, and the ECDSA signature (after the 48-byte header, + // 584-byte TD body and 4-byte signature length). + for offset in [48 + 520, 48 + 136, 48 + 584 + 4 + 10] { + let mut forged = TDX_QUOTE.to_vec(); + forged[offset] ^= 0x01; + let err = verify_quote(&forged, &collateral(TDX_COLLATERAL), FIXTURE_NOW) + .expect_err("forged quote must fail"); + assert!(err.to_string().contains("DCAP"), "{err}"); + } + } + + #[test] + fn quote_outside_collateral_validity_fails_dcap() { + assert!(verify_quote( + TDX_QUOTE, + &collateral(TDX_COLLATERAL), + FIXTURE_NOW + 10 * 365 * 86_400 + ) + .is_err()); + } + + #[test] + fn platform_missing_from_the_tcb_info_is_refused() { + // A real quote whose platform TCB level the collateral doesn't list. + let err = verify_quote( + TDX_QUOTE_OUTDATED, + &collateral(TDX_COLLATERAL_OUTDATED), + FIXTURE_NOW_OUTDATED, + ) + .expect_err("unlisted platform must be refused"); + assert!(err.to_string().contains("DCAP"), "{err}"); + } + + #[test] + fn trust_policy_rejects_malformed_pins() { + let mut t = scenario().trust; + t.mrtd = "AB".repeat(48); + assert!(t.validate().is_err()); + let mut t = scenario().trust; + t.allowed_tcb_status.clear(); + assert!(t.validate().is_err()); + assert!(serde_json::from_str::(r#"{"app_id":"x","extra":1}"#).is_err()); + } + + fn app_compose(compose_yaml: &str) -> Vec { + serde_json::to_vec(&serde_json::json!({ + "manifest_version": 2, + "name": "tinycloud", + "runner": "docker-compose", + "docker_compose_file": compose_yaml, + })) + .unwrap() + } + + #[test] + fn app_compose_must_match_the_pin_and_reference_images_by_digest() { + let digest = "a".repeat(64); + let good = app_compose(&format!( + "services:\n node:\n image: ghcr.io/tinycloudlabs/tinycloud-node@sha256:{digest}\n ingress:\n image: \"caddy@sha256:{digest}\"\n" + )); + let mut trust = scenario().trust; + trust.compose_hash = hex::encode(Sha256::digest(&good)); + assert_eq!(check_app_compose(&good, &trust).unwrap().images.len(), 2); + + let tagged = app_compose(&format!( + "services:\n node:\n image: ghcr.io/tinycloudlabs/tinycloud-node@sha256:{digest}\n shipper:\n image: vector:0.40\n" + )); + trust.compose_hash = hex::encode(Sha256::digest(&tagged)); + let err = check_app_compose(&tagged, &trust).unwrap_err().to_string(); + assert!(err.contains("vector:0.40"), "{err}"); + + trust.compose_hash = "0".repeat(64); + let err = check_app_compose(&good, &trust).unwrap_err().to_string(); + assert!(err.contains("trust policy pins compose_hash"), "{err}"); + } +} diff --git a/tinycloud-at-rest-cli/src/compose.rs b/tinycloud-at-rest-cli/src/compose.rs new file mode 100644 index 00000000..bf326ee7 --- /dev/null +++ b/tinycloud-at-rest-cli/src/compose.rs @@ -0,0 +1,262 @@ +//! `verify-compose`: does `compose_hash` pin every container image? +//! +//! dstack's `compose_hash` is SHA-256 of `app-compose.json`, which embeds the +//! docker-compose YAML. That pins the images only if every service names its +//! image by digest. This parses the YAML with a real parser and accepts only +//! a deliberately small subset, so no YAML feature can hide an image: +//! +//! - one document, no anchors, aliases, merge keys or tags, no duplicate keys; +//! - a top-level `services` mapping (no `include`); +//! - every service has a string `image` of the form `[:tag]@sha256:<64 +//! lowercase hex>`, and no `build` or `extends`. + +use std::collections::HashSet; + +use anyhow::{anyhow, bail, ensure, Result}; +use sha2::Digest as _; +use yaml_rust2::parser::{Event, Parser}; + +#[derive(Debug)] +enum Node { + Scalar(String), + /// Items are validated (no aliases, anchors, tags) but not kept: no + /// check needs a sequence's contents. + Seq, + Map(Vec<(String, Node)>), +} + +struct Reader<'a> { + parser: Parser>, +} + +impl Reader<'_> { + fn next(&mut self) -> Result { + let (event, mark) = self + .parser + .next_token() + .map_err(|e| anyhow!("the compose file is not valid YAML: {e}"))?; + match &event { + Event::Alias(_) => bail!("YAML aliases are not allowed (line {})", mark.line()), + Event::Scalar(_, _, anchor, tag) + | Event::SequenceStart(anchor, tag) + | Event::MappingStart(anchor, tag) => { + ensure!( + *anchor == 0, + "YAML anchors are not allowed (line {})", + mark.line() + ); + ensure!( + tag.is_none(), + "YAML tags are not allowed (line {})", + mark.line() + ); + } + _ => {} + } + Ok(event) + } + + fn node(&mut self, event: Event) -> Result { + match event { + Event::Scalar(value, ..) => Ok(Node::Scalar(value)), + Event::SequenceStart(..) => loop { + match self.next()? { + Event::SequenceEnd => return Ok(Node::Seq), + e => { + self.node(e)?; + } + } + }, + Event::MappingStart(..) => { + let mut entries = Vec::new(); + let mut seen = HashSet::new(); + loop { + let key = match self.next()? { + Event::MappingEnd => return Ok(Node::Map(entries)), + Event::Scalar(key, ..) => key, + _ => bail!("only plain string mapping keys are allowed"), + }; + ensure!(key != "<<", "YAML merge keys are not allowed"); + ensure!(seen.insert(key.clone()), "duplicate key {key:?}"); + let value = self.next()?; + entries.push((key, self.node(value)?)); + } + } + other => bail!("unexpected YAML structure: {other:?}"), + } + } +} + +fn parse(yaml: &str) -> Result { + let mut r = Reader { + parser: Parser::new_from_str(yaml), + }; + ensure!(matches!(r.next()?, Event::StreamStart), "not a YAML stream"); + ensure!( + matches!(r.next()?, Event::DocumentStart), + "the compose file is empty" + ); + let first = r.next()?; + let root = r.node(first)?; + ensure!( + matches!(r.next()?, Event::DocumentEnd), + "trailing YAML content" + ); + ensure!( + matches!(r.next()?, Event::StreamEnd), + "the compose file must be a single YAML document" + ); + Ok(root) +} + +fn get<'a>(entries: &'a [(String, Node)], key: &str) -> Option<&'a Node> { + entries.iter().find(|(k, _)| k == key).map(|(_, v)| v) +} + +/// `[:tag]@sha256:<64 lowercase hex>`, no whitespace or comments. +fn pinned_reference(image: &str) -> bool { + let Some((name, digest)) = image.split_once("@sha256:") else { + return false; + }; + !name.is_empty() + && name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'/' | b':')) + && digest.len() == 64 + && digest + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +/// Every service's image, each pinned by digest; refuses anything else. +pub fn pinned_images(compose_yaml: &str) -> Result> { + let Node::Map(top) = parse(compose_yaml)? else { + bail!("the compose file must be a mapping"); + }; + ensure!( + get(&top, "include").is_none(), + "`include` pulls in other compose files and is not allowed" + ); + let Some(Node::Map(services)) = get(&top, "services") else { + bail!("the compose file has no `services` mapping"); + }; + ensure!(!services.is_empty(), "the compose file has no services"); + let mut images = Vec::new(); + for (name, service) in services { + let Node::Map(fields) = service else { + bail!("service {name:?} is not a mapping"); + }; + for forbidden in ["build", "extends"] { + ensure!( + get(fields, forbidden).is_none(), + "service {name:?} uses `{forbidden}`; only digest-pinned images are allowed" + ); + } + let image = match get(fields, "image") { + Some(Node::Scalar(image)) => image, + Some(_) => bail!("service {name:?} has a non-string image"), + None => bail!("service {name:?} has no image"), + }; + ensure!( + pinned_reference(image), + "service {name:?} image {image:?} is not referenced as @sha256:<64 hex>" + ); + images.push((name.clone(), image.clone())); + } + Ok(images) +} + +/// The `app-compose.json` fields besides the compose file that the hash also +/// covers, for the holder to review. +pub fn other_settings(app_compose: &serde_json::Value) -> Vec { + let Some(map) = app_compose.as_object() else { + return Vec::new(); + }; + map.iter() + .filter(|(k, _)| k.as_str() != "docker_compose_file") + .map(|(k, v)| { + let shown = match v { + serde_json::Value::String(s) if s.len() > 80 => { + format!( + "<{} chars, sha256 {}>", + s.len(), + hex::encode(sha2::Sha256::digest(s.as_bytes())) + ) + } + other => other.to_string(), + }; + format!("{k} = {shown}") + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn digest() -> String { + "a".repeat(64) + } + + #[test] + fn digest_pinned_services_pass() { + let d = digest(); + let yaml = format!( + "services:\n node:\n image: ghcr.io/tinycloudlabs/tinycloud-node@sha256:{d}\n environment:\n - A=1\n ingress:\n image: \"caddy:2.8@sha256:{d}\"\nvolumes:\n data: {{}}\n" + ); + let images = pinned_images(&yaml).unwrap(); + assert_eq!(images.len(), 2); + assert_eq!(images[1].1, format!("caddy:2.8@sha256:{d}")); + } + + #[test] + fn every_known_bypass_is_refused() { + let d = digest(); + let cases = [ + // 1. A comment is not part of the value: the image is nginx:latest. + (format!("services:\n web:\n image: nginx:latest # @sha256:{d}\n"), "nginx:latest"), + // 2. Flow mapping. + ("services:\n web: {image: nginx:latest}\n".to_string(), "nginx:latest"), + // 3. Quoted key. + ("services:\n web:\n \"image\": nginx:latest\n".to_string(), "nginx:latest"), + // 4. build instead of an image. + ("services:\n web:\n build: .\n".to_string(), "build"), + // Anchors, aliases and merges that hide the real image. + (format!("x: &img\n image: a@sha256:{d}\nservices:\n web:\n <<: *img\n image: nginx\n"), "anchor"), + (format!("services:\n a:\n image: &i a@sha256:{d}\n b:\n image: *i\n"), "anchor"), + // Duplicate keys: last-wins parsers would run the second. + (format!("services:\n web:\n image: a@sha256:{d}\n image: nginx\n"), "duplicate"), + // Tags, extends, include, tag-only, digest of the wrong length/case. + (format!("services:\n web:\n image: !!str a@sha256:{d}\n"), "tags"), + (format!("services:\n web:\n image: a@sha256:{d}\n extends: base\n"), "extends"), + (format!("include:\n - other.yaml\nservices:\n web:\n image: a@sha256:{d}\n"), "include"), + ("services:\n web:\n image: nginx:1.27\n".to_string(), "nginx:1.27"), + (format!("services:\n web:\n image: a@sha256:{}\n", "A".repeat(64)), "is not referenced"), + (format!("services:\n web:\n image: a@sha256:{d}0\n"), "is not referenced"), + ("services:\n web:\n environment: [A=1]\n".to_string(), "no image"), + // Several documents, or no services. + (format!("services:\n web:\n image: a@sha256:{d}\n---\nservices: {{}}\n"), "single YAML document"), + ("version: '3'\n".to_string(), "no `services`"), + ("services: [\n".to_string(), "not valid YAML"), + ]; + for (yaml, needle) in cases { + let err = pinned_images(&yaml).expect_err(&yaml).to_string(); + assert!(err.contains(needle), "{yaml:?}: {err}"); + } + } + + #[test] + fn other_settings_are_listed_for_review() { + let doc = serde_json::json!({ + "docker_compose_file": "services: {}", + "kms_enabled": true, + "pre_launch_script": "x".repeat(100), + }); + let lines = other_settings(&doc); + assert!(lines.iter().any(|l| l == "kms_enabled = true")); + assert!(lines + .iter() + .any(|l| l.starts_with("pre_launch_script = <100 chars"))); + assert!(!lines.iter().any(|l| l.starts_with("docker_compose_file"))); + } +} diff --git a/tinycloud-at-rest-cli/src/main.rs b/tinycloud-at-rest-cli/src/main.rs new file mode 100644 index 00000000..52302e2b --- /dev/null +++ b/tinycloud-at-rest-cli/src/main.rs @@ -0,0 +1,518 @@ +//! `tinycloud-at-rest`: escrow ceremony and recovery tool (TC-887). +//! +//! Secrets are never accepted on argv. A decrypted share is read from stdin +//! only (pipe it from `age -d`), and nothing here writes one to disk. +//! +//! Exit codes: 0 OK; 1 REFUSED (a check failed: don't release, don't pin); +//! 2 usage error; 3 ERROR (a file, stdin or network input couldn't be read). + +#![forbid(unsafe_code)] + +mod attest; +mod compose; + +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Result}; +use base64::Engine; +use clap::{Args, Parser, Subcommand}; +use tinycloud_at_rest_escrow::{ + validate_holders, wrap_share, Ark, ArkId, EscrowBundle, HolderRecipient, RecipientKind, + RecipientPolicy, RecoveryInfo, VerificationRecord, +}; +use zeroize::Zeroizing; + +use attest::TrustPolicy; + +/// An input couldn't be read at all (as opposed to a check that failed). +#[derive(Debug)] +struct InputError(String); + +impl std::fmt::Display for InputError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for InputError {} + +fn input_err(msg: String) -> anyhow::Error { + InputError(msg).into() +} + +#[derive(Parser)] +#[command( + name = "tinycloud-at-rest", + about = "TinyCloud at-rest key escrow ceremony and recovery" +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +/// Software (X25519) holders are refused unless this is given. +#[derive(Args)] +struct SoftwareFlag { + /// Accept X25519 software-key holders. Tests and self-hosted nodes only; + /// every command warns loudly while it is in effect. + #[arg(long)] + allow_software: bool, +} + +impl SoftwareFlag { + fn policy(&self) -> RecipientPolicy { + if self.allow_software { + RecipientPolicy::AllowSoftware + } else { + RecipientPolicy::HardwareOnly + } + } +} + +/// The bundle a command works on. +#[derive(Args)] +struct BundleArgs { + /// The escrow bundle you were given. + #[arg(long)] + bundle: PathBuf, + /// Refuse unless the bundle's EFP equals this (from Sam, out of band). + #[arg(long)] + expect_efp: Option, + #[command(flatten)] + software: SoftwareFlag, +} + +#[derive(Subcommand)] +enum Command { + /// Validate the three holder recipients and print the node config for them. + Recipients { + /// `NAME=RECIPIENT`, three times (e.g. `sam=age1yubikey1…`). + #[arg(long = "holder", required = true)] + holders: Vec, + #[command(flatten)] + software: SoftwareFlag, + }, + /// Ceremony step 0: seal a throwaway share to RECIPIENT with the exact + /// code the TEE uses, so a holder can prove their YubiKey and age tooling + /// decrypt it. Writes selftest-bundle.json and selftest-share.age to --out. + SelfTest { + #[arg(long)] + recipient: String, + #[arg(long, default_value = ".")] + out: PathBuf, + #[command(flatten)] + software: SoftwareFlag, + }, + /// Print the compressed P-256 public key (hex) inside an age1yubikey1 or + /// age1tag1 recipient, to compare with a YubiKey attestation certificate. + RecipientKey { + #[arg(long)] + recipient: String, + }, + /// Print a fresh 256-bit nonce for a recovery request. + Nonce, + /// Validate a bundle and print its fingerprint, canary and holders. + Inspect(BundleArgs), + /// Print one holder's sealed share (an armored age file) to stdout. + ExtractShare { + #[command(flatten)] + bundle: BundleArgs, + #[arg(long)] + holder: String, + }, + /// Offline check: read a decrypted share on stdin (from `age -d`) and + /// check it against the bundle's commitment. Prints no share bytes. + CheckShare(BundleArgs), + /// Check a deployment's app-compose.json against the trust policy's + /// compose_hash and require every image to be pinned by @sha256: digest. + VerifyCompose { + #[arg(long)] + app_compose: PathBuf, + #[arg(long)] + trust: PathBuf, + }, + /// Verify a node's recovery key: DCAP quote, TCB status, measurements, + /// event log, and the binding to your nonce. Releases nothing. + VerifyNode(NodeArgs), + /// Verify the node exactly as `verify-node` does, then read a decrypted + /// share on stdin, check it, and print it sealed to the node's recovery key. + ShareWrap(NodeArgs), + /// Print the EFP a ceremony record names, after checking that it names + /// this bundle and claims all three shares. Its authenticity (MAC) can't + /// be checked offline; the node checks it before enabling encryption. + RecordEfp { + #[command(flatten)] + bundle: BundleArgs, + #[arg(long)] + record: PathBuf, + }, +} + +#[derive(Args)] +struct NodeArgs { + #[command(flatten)] + bundle: BundleArgs, + /// The node's `GET /at-rest/recovery?nonce=…` response. + #[arg(long)] + recovery: PathBuf, + /// The nonce you generated for that request. + #[arg(long)] + nonce: String, + /// Trusted measurements (JSON; see docs/at-rest-escrow.md). + #[arg(long)] + trust: PathBuf, + /// DCAP collateral JSON for offline verification. Without it, collateral + /// is fetched from `--pccs-url`. + #[arg(long)] + collateral: Option, + /// PCCS to fetch collateral from (Phala's, or Intel PCS + /// `https://api.trustedservices.intel.com`). + #[arg(long, default_value = dcap_qvl::collateral::PHALA_PCCS_URL)] + pccs_url: String, +} + +fn read_file(path: &Path, what: &str) -> Result> { + std::fs::read(path).map_err(|e| input_err(format!("reading {what} {}: {e}", path.display()))) +} + +fn read_json(path: &Path, what: &str) -> Result { + let bytes = read_file(path, what)?; + serde_json::from_slice(&bytes) + .map_err(|e| input_err(format!("parsing {what} {}: {e}", path.display()))) +} + +/// Loud warnings for holder kinds that need care: software keys, and +/// `age1tag1` keys that the released plugin can't decrypt. +fn warn_software(holders: &[HolderRecipient], kinds: &[RecipientKind]) { + for (h, kind) in holders.iter().zip(kinds) { + match kind { + RecipientKind::X25519 => eprintln!( + "!!! WARNING: holder {:?} uses an X25519 SOFTWARE key, not a hardware key. \ + Accepted only because --allow-software was given (tests and self-host only). !!!", + h.holder + ), + RecipientKind::P256Tag => eprintln!( + "!!! WARNING: holder {:?} uses an age1tag1 (p256tag) recipient. The released \ + age-plugin-yubikey 0.5.1 CANNOT decrypt it; use the age1yubikey1 recipient \ + that `age-plugin-yubikey --list` prints. !!!", + h.holder + ), + RecipientKind::PivP256 => {} + } + } +} + +/// A file that isn't the expected JSON at all (a bad download, an HTML +/// error page) is an input error (exit 3); a well-formed but unacceptable +/// one is a refusal (exit 1). +fn parse_err(e: tinycloud_at_rest_escrow::EscrowError) -> anyhow::Error { + match e { + tinycloud_at_rest_escrow::EscrowError::Json { .. } => input_err(e.to_string()), + other => other.into(), + } +} + +/// Read and validate a bundle, apply the recipient policy and `--expect-efp`. +fn read_bundle(args: &BundleArgs) -> Result<(EscrowBundle, Vec)> { + let bytes = read_file(&args.bundle, "bundle")?; + let bundle = EscrowBundle::from_json(&bytes).map_err(parse_err)?; + let kinds = bundle.check_recipients(args.software.policy())?; + warn_software(&bundle.holders(), &kinds); + if let Some(expected) = &args.expect_efp { + let efp = bundle.fingerprint().to_string(); + if !expected.trim().eq_ignore_ascii_case(&efp) { + bail!( + "bundle {} has EFP {efp}, not the expected {expected}: it is stale or not the one Sam pinned", + args.bundle.display() + ); + } + } + Ok((bundle, kinds)) +} + +/// A decrypted share from stdin, bounded and zeroized. +fn read_share_stdin(bundle: &EscrowBundle) -> Result { + let mut buf = Zeroizing::new(Vec::with_capacity(64)); + std::io::stdin() + .lock() + .take(4096) + .read_to_end(&mut buf) + .map_err(|e| input_err(format!("reading the decrypted share from stdin: {e}")))?; + if buf.is_empty() { + return Err(input_err( + "no share on stdin: did `age -d` fail (wrong identity, YubiKey not inserted or not touched, wrong file)? \ + Pipe it as `age -d -i | tinycloud-at-rest …`" + .into(), + )); + } + Ok(bundle.open_share(&buf)?) +} + +async fn verify_node(args: &NodeArgs) -> Result<(EscrowBundle, RecoveryInfo)> { + let (bundle, _) = read_bundle(&args.bundle)?; + let info = RecoveryInfo::from_json(&read_file(&args.recovery, "recovery response")?) + .map_err(parse_err)?; + let trust: TrustPolicy = read_json(&args.trust, "trust policy")?; + trust.validate()?; + // Everything that needs no network first, so its cause is what's reported. + let quote = attest::require_attested(&info)?; + let digest = attest::check_local(&info, &bundle, &args.nonce)?; + let collateral = match &args.collateral { + Some(path) => read_json(path, "collateral")?, + None => attest::fetch_collateral("e, &args.pccs_url) + .await + .map_err(|e| input_err(format!("{e:#}")))?, + }; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs(); + let td = attest::verify_quote("e, &collateral, now)?; + let tcb = attest::check_attestation(&info, &trust, &td, &digest)?; + eprintln!("OK: genuine TDX quote; {tcb}"); + eprintln!(" {}", attest::collateral_dates(&collateral)); + eprintln!( + " MRTD, RTMR0-2, app-id and compose-hash match the trust policy; recovery key {} is bound to your nonce and escrow {}", + info.recovery_recipient, info.efp + ); + eprintln!( + " node state: {} ({}); shares it holds this boot: {:?}", + info.state, + if info.state == "locked" { + "recovery: two shares unlock it" + } else { + "ceremony test-unlock: all three shares are checked" + }, + info.shares_held + ); + Ok((bundle, info)) +} + +fn print_holders(bundle: &EscrowBundle, kinds: &[RecipientKind]) { + for (s, kind) in bundle.shares.iter().zip(kinds) { + println!( + "share {} holder {:<10} {:<8} recipient {}", + s.index, + s.holder, + match kind { + RecipientKind::PivP256 => "piv-p256", + RecipientKind::P256Tag => "p256tag", + RecipientKind::X25519 => "X25519!", + }, + s.recipient + ); + } +} + +async fn run(cli: Cli) -> Result<()> { + match cli.command { + Command::Recipients { holders, software } => { + let parsed = holders + .iter() + .map(|h| { + let (holder, recipient) = h.split_once('=').ok_or_else(|| { + input_err(format!("--holder {h:?} must be NAME=RECIPIENT")) + })?; + Ok(HolderRecipient { + holder: holder.into(), + recipient: recipient.into(), + }) + }) + .collect::>>()?; + let kinds = validate_holders(&parsed, software.policy())?; + warn_software(&parsed, &kinds); + eprintln!( + "Note: a holder name is only a label. Check each recipient really is that holder's key; \ + the ceremony test-unlock is what proves it." + ); + let inline = parsed + .iter() + .map(|h| format!("{{holder=\"{}\",recipient=\"{}\"}}", h.holder, h.recipient)) + .collect::>() + .join(","); + println!("# tinycloud.toml"); + println!("[at_rest.escrow]"); + println!("recipients = ["); + for h in &parsed { + println!( + " {{ holder = \"{}\", recipient = \"{}\" }},", + h.holder, h.recipient + ); + } + println!("]"); + println!(); + println!("# or compose environment"); + println!("TINYCLOUD_AT_REST__ESCROW__RECIPIENTS=[{inline}]"); + } + Command::SelfTest { + recipient, + out, + software, + } => { + // Two throwaway co-holders: fresh P-256 keys whose private halves + // are dropped here. The ARK is random and discarded too. + let dummy = || { + use p256::elliptic_curve::sec1::ToEncodedPoint; + let pk = p256::SecretKey::random(&mut rand::rngs::OsRng).public_key(); + let hrp = bech32::Hrp::parse("age1yubikey").expect("valid HRP"); + bech32::encode::(hrp, pk.to_encoded_point(true).as_bytes()) + .expect("33-byte key encodes") + }; + let holders = vec![ + HolderRecipient { + holder: "selftest".into(), + recipient, + }, + HolderRecipient { + holder: "dummy-a".into(), + recipient: dummy(), + }, + HolderRecipient { + holder: "dummy-b".into(), + recipient: dummy(), + }, + ]; + let kinds = validate_holders(&holders, software.policy())?; + warn_software(&holders, &kinds); + let bundle = EscrowBundle::create( + &Ark::generate(), + ArkId::generate(), + 1, + &holders, + software.policy(), + )?; + std::fs::create_dir_all(&out) + .map_err(|e| input_err(format!("creating {}: {e}", out.display())))?; + let bundle_path = out.join("selftest-bundle.json"); + let share_path = out.join("selftest-share.age"); + std::fs::write(&bundle_path, serde_json::to_vec_pretty(&bundle)?) + .map_err(|e| input_err(format!("writing {}: {e}", bundle_path.display())))?; + std::fs::write(&share_path, bundle.shares[0].ciphertext.as_bytes()) + .map_err(|e| input_err(format!("writing {}: {e}", share_path.display())))?; + eprintln!( + "Wrote a THROWAWAY test escrow ({:?} kind, sealed by the TEE's code). Now run:", + kinds[0] + ); + eprintln!( + " age -d -i {} | tinycloud-at-rest check-share --bundle {}{}", + share_path.display(), + bundle_path.display(), + if software.allow_software { + " --allow-software" + } else { + "" + } + ); + eprintln!("Expect: OK: share 1 (selftest) matches escrow …"); + } + Command::RecipientKey { recipient } => { + tinycloud_at_rest_escrow::parse_recipient("recipient", &recipient)?; + let (hrp, key) = bech32::decode(&recipient) + .map_err(|e| input_err(format!("recipient is not Bech32: {e}")))?; + if !matches!(hrp.as_str(), "age1yubikey" | "age1tag") || key.len() != 33 { + bail!("{recipient} is not a P-256 (age1yubikey1/age1tag1) recipient"); + } + println!("{}", hex::encode(key)); + } + Command::Nonce => { + let mut bytes = [0u8; 32]; + rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut bytes); + println!( + "{}", + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) + ); + } + Command::Inspect(args) => { + let (bundle, kinds) = read_bundle(&args)?; + println!("efp {}", bundle.fingerprint()); + println!("ark_id {}", bundle.ark_id); + println!("generation {}", bundle.generation); + println!("canary_fp {}", bundle.canary_fp); + print_holders(&bundle, &kinds); + } + Command::ExtractShare { bundle, holder } => { + let (bundle, _) = read_bundle(&bundle)?; + let Some(share) = bundle.share_for(&holder) else { + let names: Vec = bundle.holders().into_iter().map(|h| h.holder).collect(); + return Err(input_err(format!( + "no holder {holder:?} in this bundle (holders: {names:?})" + ))); + }; + std::io::stdout().write_all(share.ciphertext.as_bytes())?; + } + Command::CheckShare(args) => { + let (bundle, _) = read_bundle(&args)?; + let share = read_share_stdin(&bundle)?; + let holder = &bundle.shares[usize::from(share.index) - 1].holder; + eprintln!( + "OK: share {} ({holder}) matches escrow {} (ARK {})", + share.index, + bundle.fingerprint(), + bundle.ark_id + ); + } + Command::VerifyCompose { app_compose, trust } => { + let trust: TrustPolicy = read_json(&trust, "trust policy")?; + trust.validate()?; + let bytes = read_file(&app_compose, "app-compose.json")?; + let report = attest::check_app_compose(&bytes, &trust)?; + eprintln!( + "OK: app-compose.json matches compose_hash {}; every service's image is pinned by digest:", + trust.compose_hash + ); + for (service, image) in report.images { + eprintln!(" {service}: {image}"); + } + eprintln!("Other settings the hash covers (review them):"); + for line in report.settings { + eprintln!(" {line}"); + } + } + Command::VerifyNode(args) => { + verify_node(&args).await?; + } + Command::ShareWrap(args) => { + let (bundle, info) = verify_node(&args).await?; + let share = read_share_stdin(&bundle)?; + let wrapped = wrap_share(&info.recovery_recipient, &share)?; + std::io::stdout().write_all(wrapped.as_bytes())?; + eprintln!( + "OK: share {} sealed to this node's recovery key. POST it to the same node's /at-rest/recovery/shares \ + before it restarts (the key is per boot).", + share.index + ); + } + Command::RecordEfp { bundle, record } => { + let (bundle, _) = read_bundle(&bundle)?; + let record = + VerificationRecord::from_json(&read_file(&record, "record")?).map_err(parse_err)?; + record.covers(&bundle)?; + eprintln!( + "NOT AUTHENTICATED: this record names escrow {} and claims shares {:?} and pairs {:?} (verified_at {}),", + record.efp, record.verified_indices, record.pairs, record.verified_at + ); + eprintln!( + "but its MAC can only be checked inside the TEE. Pin this value only after you saw the node answer \ + the third share with \"verified\"; the node refuses to encrypt unless the stored record verifies." + ); + println!("TINYCLOUD_AT_REST__ESCROW__VERIFIED_FP={}", record.efp); + } + } + Ok(()) +} + +fn main() { + let cli = Cli::parse(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime"); + if let Err(e) = runtime.block_on(run(cli)) { + if e.downcast_ref::().is_some() { + eprintln!("ERROR: {e:#}"); + std::process::exit(3); + } + eprintln!("REFUSED: {e:#}"); + std::process::exit(1); + } +} diff --git a/tinycloud-at-rest-cli/tests/age_interop.rs b/tinycloud-at-rest-cli/tests/age_interop.rs new file mode 100644 index 00000000..196ddf24 --- /dev/null +++ b/tinycloud-at-rest-cli/tests/age_interop.rs @@ -0,0 +1,195 @@ +//! Interop with the stock `age` CLI (Go reference implementation). +//! +//! Needs `AGE_BIN_DIR` pointing at a directory with `age` and `age-keygen` +//! (CI downloads a sha256-pinned age v1.3.2). Skips locally when unset; in +//! CI (`CI` set) a missing directory is a failure, never a silent pass. + +use std::io::{Read, Write}; +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +use age_core::format::{FileKey, Stanza}; +use base64::Engine; +use hpke::Deserializable; +use p256::elliptic_curve::sec1::ToEncodedPoint; +use tinycloud_at_rest_escrow::{Ark, ArkId, EscrowBundle, HolderRecipient, RecipientPolicy}; + +const BIN: &str = env!("CARGO_BIN_EXE_tinycloud-at-rest"); + +fn age_dir(test: &str) -> Option { + match std::env::var_os("AGE_BIN_DIR") { + Some(d) if !d.is_empty() => Some(PathBuf::from(d)), + _ => { + assert!( + std::env::var_os("CI").is_none(), + "{test} needs AGE_BIN_DIR in CI" + ); + eprintln!("skipping {test}: AGE_BIN_DIR is unset"); + None + } + } +} + +fn pipe(cmd: &mut Command, input: &[u8]) -> std::process::Output { + let mut child = cmd + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child.stdin.take().unwrap().write_all(input).unwrap(); + child.wait_with_output().unwrap() +} + +#[test] +fn stock_age_decrypts_shares_and_the_cli_accepts_them() { + let Some(age) = age_dir("stock_age_decrypts_shares_and_the_cli_accepts_them") else { + return; + }; + let dir = std::env::temp_dir().join(format!("tc887-interop-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + + let mut holders = Vec::new(); + for name in ["sam", "hunter", "safe"] { + let key = dir.join(format!("{name}.key")); + let out = Command::new(age.join("age-keygen")) + .arg("-o") + .arg(&key) + .output() + .unwrap(); + assert!(out.status.success()); + let text = std::fs::read_to_string(&key).unwrap(); + let recipient = text + .lines() + .find_map(|l| l.strip_prefix("# public key: ")) + .unwrap() + .to_owned(); + holders.push(HolderRecipient { + holder: name.into(), + recipient, + }); + } + let bundle = EscrowBundle::create( + &Ark::generate(), + ArkId::generate(), + 1, + &holders, + RecipientPolicy::AllowSoftware, + ) + .unwrap(); + let bundle_path = dir.join("bundle.json"); + std::fs::write(&bundle_path, serde_json::to_vec(&bundle).unwrap()).unwrap(); + + for (i, h) in holders.iter().enumerate() { + // extract-share → stock age -d → check-share. + let share = Command::new(BIN) + .args([ + "extract-share", + "--allow-software", + "--holder", + &h.holder, + "--bundle", + ]) + .arg(&bundle_path) + .output() + .unwrap(); + assert!(share.status.success()); + let share_file = dir.join(format!("{}.age", h.holder)); + std::fs::write(&share_file, &share.stdout).unwrap(); + let plain = Command::new(age.join("age")) + .arg("-d") + .arg("-i") + .arg(dir.join(format!("{}.key", h.holder))) + .arg(&share_file) + .output() + .unwrap(); + assert!( + plain.status.success(), + "{}", + String::from_utf8_lossy(&plain.stderr) + ); + let checked = pipe( + Command::new(BIN) + .args(["check-share", "--allow-software", "--bundle"]) + .arg(&bundle_path), + &plain.stdout, + ); + let stderr = String::from_utf8_lossy(&checked.stderr); + assert!(checked.status.success(), "{stderr}"); + assert!( + stderr.contains(&format!("share {} ({})", i + 1, h.holder)), + "{stderr}" + ); + + // Another holder's identity can't open it. + let other = holders[(i + 1) % 3].holder.clone(); + let wrong = Command::new(age.join("age")) + .arg("-d") + .arg("-i") + .arg(dir.join(format!("{other}.key"))) + .arg(&share_file) + .output() + .unwrap(); + assert!(!wrong.status.success()); + } +} + +type Kem = hpke::kem::DhP256HkdfSha256; + +/// A software P-256 identity for `p256tag` stanzas (as a hardware key's +/// plugin would open them). +struct TagIdentity(::PrivateKey); + +impl age::Identity for TagIdentity { + fn unwrap_stanza(&self, stanza: &Stanza) -> Option> { + if stanza.tag != "p256tag" || stanza.args.len() != 2 { + return None; + } + let enc = base64::engine::general_purpose::STANDARD_NO_PAD + .decode(&stanza.args[1]) + .ok()?; + let enc = ::EncappedKey::from_bytes(&enc).ok()?; + let key = age_core::primitives::hpke_open::( + &enc, + &self.0, + b"age-encryption.org/p256tag", + &stanza.body, + ) + .ok()?; + let key: [u8; 16] = key.try_into().ok()?; + Some(Ok(FileKey::new(Box::new(key)))) + } +} + +#[test] +fn stock_age_p256tag_output_opens_with_the_p256_key() { + // Cross-implementation vector: Go age encrypts to an age1tag1 recipient; + // the Rust stack the TEE uses (age-core HPKE) opens it with the key. + let Some(age) = age_dir("stock_age_p256tag_output_opens_with_the_p256_key") else { + return; + }; + let sk = p256::SecretKey::random(&mut rand::rngs::OsRng); + let hrp = bech32::Hrp::parse("age1tag").unwrap(); + let recipient = + age_core::primitives::bech32_encode(hrp, sk.public_key().to_encoded_point(true).as_bytes()); + let message = b"tinycloud p256tag interop"; + let out = pipe( + Command::new(age.join("age")).args(["-e", "-r", &recipient]), + message, + ); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + + let identity = TagIdentity(::PrivateKey::from_bytes(&sk.to_bytes()).unwrap()); + let decryptor = age::Decryptor::new_buffered(&out.stdout[..]).unwrap(); + let mut reader = decryptor + .decrypt(std::iter::once(&identity as &dyn age::Identity)) + .unwrap(); + let mut plain = Vec::new(); + reader.read_to_end(&mut plain).unwrap(); + assert_eq!(plain, message); +} diff --git a/tinycloud-at-rest-cli/tests/cli.rs b/tinycloud-at-rest-cli/tests/cli.rs new file mode 100644 index 00000000..d820ae54 --- /dev/null +++ b/tinycloud-at-rest-cli/tests/cli.rs @@ -0,0 +1,469 @@ +//! Binary-level tests: what a holder sees and the exit code they get. +//! 0 OK, 1 REFUSED, 3 ERROR (input could not be read). + +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; + +use tinycloud_at_rest_escrow::{ + binding_digest, recovery_binding, Ark, ArkId, EscrowBundle, HolderRecipient, RecipientPolicy, + RecoveryIdentity, RecoveryInfo, SharePayload, RECOVERY_INFO_VERSION, +}; + +const BIN: &str = env!("CARGO_BIN_EXE_tinycloud-at-rest"); +const NONCE: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + +struct Fixture { + dir: PathBuf, + ids: Vec, + ark: Ark, + ark_id: ArkId, + holders: Vec, + bundle: EscrowBundle, +} + +impl Fixture { + fn new(name: &str) -> Self { + let dir = std::env::temp_dir().join(format!("tc887-cli-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let ids: Vec<_> = (0..3).map(|_| age::x25519::Identity::generate()).collect(); + let holders: Vec = ["sam", "hunter", "safe"] + .iter() + .zip(&ids) + .map(|(h, id)| HolderRecipient { + holder: h.to_string(), + recipient: id.to_public().to_string(), + }) + .collect(); + let (ark, ark_id) = (Ark::generate(), ArkId::generate()); + let bundle = + EscrowBundle::create(&ark, ark_id, 1, &holders, RecipientPolicy::AllowSoftware) + .unwrap(); + let f = Self { + dir, + ids, + ark, + ark_id, + holders, + bundle, + }; + f.write("bundle.json", &serde_json::to_vec(&f.bundle).unwrap()); + f + } + + fn path(&self, name: &str) -> PathBuf { + self.dir.join(name) + } + + fn write(&self, name: &str, bytes: &[u8]) -> PathBuf { + let p = self.path(name); + std::fs::write(&p, bytes).unwrap(); + p + } + + /// What holder `i` gets from `age -d`. + fn decrypted(&self, bundle: &EscrowBundle, i: usize) -> Vec { + let armored = &bundle.shares[i].ciphertext; + let decryptor = + age::Decryptor::new_buffered(age::armor::ArmoredReader::new(armored.as_bytes())) + .unwrap(); + let mut reader = decryptor + .decrypt(std::iter::once(&self.ids[i] as &dyn age::Identity)) + .unwrap(); + let mut out = Vec::new(); + reader.read_to_end(&mut out).unwrap(); + out + } + + fn info(&self, mode: &str, quote: Option<&str>) -> RecoveryInfo { + let tee = RecoveryIdentity::generate(); + let efp = self.bundle.fingerprint(); + let binding = recovery_binding(&tee.recipient(), &efp, NONCE); + RecoveryInfo { + version: RECOVERY_INFO_VERSION, + mode: mode.into(), + state: "unlocked".into(), + ark_id: self.bundle.ark_id, + generation: 1, + efp, + canary_fp: self.bundle.canary_fp, + recovery_recipient: tee.recipient(), + binding_sha256: binding_digest(&binding), + binding, + quote: quote.map(str::to_owned), + event_log: Some("[]".into()), + shares_held: vec![], + } + } +} + +fn run(args: &[&str], stdin: Option<&[u8]>) -> Output { + let mut child = Command::new(BIN) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + { + let mut pipe = child.stdin.take().unwrap(); + if let Some(bytes) = stdin { + pipe.write_all(bytes).unwrap(); + } + } + child.wait_with_output().unwrap() +} + +fn p(path: &Path) -> &str { + path.to_str().unwrap() +} + +#[track_caller] +fn expect(out: &Output, code: i32, needle: &str) { + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code(), Some(code), "stderr: {stderr}"); + assert!(stderr.contains(needle), "{stderr:?} lacks {needle:?}"); +} + +#[test] +fn software_holders_are_refused_unless_allowed_and_then_flagged() { + let f = Fixture::new("software"); + let b = f.path("bundle.json"); + expect( + &run(&["inspect", "--bundle", p(&b)], None), + 1, + "software keys", + ); + let out = run(&["inspect", "--bundle", p(&b), "--allow-software"], None); + expect(&out, 0, "!!! WARNING"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains(&f.bundle.fingerprint().to_string())); + assert_eq!(stdout.matches("X25519!").count(), 3); +} + +#[test] +fn expect_efp_guards_every_bundle_command() { + let f = Fixture::new("expect"); + let b = f.path("bundle.json"); + let efp = f.bundle.fingerprint().to_string(); + let other = "0".repeat(64); + for cmd in ["inspect", "check-share"] { + expect( + &run( + &[ + cmd, + "--bundle", + p(&b), + "--allow-software", + "--expect-efp", + &other, + ], + Some(b"x"), + ), + 1, + "not the expected", + ); + } + expect( + &run( + &[ + "extract-share", + "--bundle", + p(&b), + "--allow-software", + "--expect-efp", + &other, + "--holder", + "sam", + ], + None, + ), + 1, + "stale", + ); + // Case-insensitive. + let upper = efp.to_uppercase(); + let out = run( + &[ + "inspect", + "--bundle", + p(&b), + "--allow-software", + "--expect-efp", + &upper, + ], + None, + ); + assert_eq!(out.status.code(), Some(0)); +} + +#[test] +fn check_share_names_the_real_problem() { + let f = Fixture::new("check"); + let b = f.path("bundle.json"); + let args = ["check-share", "--bundle", p(&b), "--allow-software"]; + + let good = f.decrypted(&f.bundle, 1); + expect(&run(&args, Some(&good)), 0, "share 2 (hunter)"); + + // `age -d` failed and printed nothing. + expect(&run(&args, Some(b"")), 3, "did `age -d` fail"); + + // A share from an earlier split of the same key: commitment mismatch, with a hint. + let resplit = EscrowBundle::create( + &f.ark, + f.ark_id, + 1, + &f.holders, + RecipientPolicy::AllowSoftware, + ) + .unwrap(); + let old = f.decrypted(&resplit, 0); + expect(&run(&args, Some(&old)), 1, "re-split"); + + // Tampered bytes. + let mut bad = good.clone(); + bad[40] ^= 1; + expect(&run(&args, Some(&bad)), 1, "commitment"); + assert!(SharePayload::decode(&bad).is_ok()); +} + +#[test] +fn input_problems_are_errors_not_refusals() { + let f = Fixture::new("input"); + let b = f.path("bundle.json"); + expect( + &run(&["inspect", "--bundle", p(&f.path("missing.json"))], None), + 3, + "ERROR", + ); + expect( + &run( + &[ + "extract-share", + "--bundle", + p(&b), + "--allow-software", + "--holder", + "mallory", + ], + None, + ), + 3, + "no holder \"mallory\"", + ); + // A newer bundle format is named as such. + let mut v2: serde_json::Value = serde_json::to_value(&f.bundle).unwrap(); + v2["version"] = 2.into(); + v2["kdf"] = "x".into(); + let v2 = f.write("v2.json", &serde_json::to_vec(&v2).unwrap()); + expect(&run(&["inspect", "--bundle", p(&v2)], None), 1, "version 2"); + // A bad download (HTML, truncated JSON) is an input error, not a refusal. + let html = f.write("html.json", b"502 Bad Gateway"); + expect(&run(&["inspect", "--bundle", p(&html)], None), 3, "ERROR"); + // A recovery response in an unknown state is malformed input too. + let mut info = serde_json::to_value(f.info("dstack", Some("00"))).unwrap(); + info["state"] = "rebooting".into(); + let r = f.write("weird.json", &serde_json::to_vec(&info).unwrap()); + let out = run( + &[ + "verify-node", + "--bundle", + p(&b), + "--allow-software", + "--recovery", + p(&r), + "--nonce", + NONCE, + "--trust", + p(&b), + ], + None, + ); + expect(&out, 3, "state must be"); +} + +#[test] +fn verify_node_reports_local_causes_before_any_network() { + let f = Fixture::new("node"); + let b = f.path("bundle.json"); + let trust = f.write( + "trust.json", + serde_json::json!({ + "app_id": "1".repeat(40), "compose_hash": "2".repeat(64), + "mrtd": "a".repeat(96), "rtmr0": "a".repeat(96), + "rtmr1": "a".repeat(96), "rtmr2": "a".repeat(96), + }) + .to_string() + .as_bytes(), + ); + // An unroutable PCCS: reaching it would be a network error, so any local + // refusal must come first. + let node = |info: &RecoveryInfo, nonce: &str| { + let r = f.write("recovery.json", &serde_json::to_vec(info).unwrap()); + run( + &[ + "verify-node", + "--bundle", + p(&b), + "--allow-software", + "--recovery", + p(&r), + "--nonce", + nonce, + "--trust", + p(&trust), + "--pccs-url", + "http://127.0.0.1:9", + ], + None, + ) + }; + expect( + &node(&f.info("classic", Some("00")), NONCE), + 1, + "not in a TEE", + ); + expect(&node(&f.info("dstack", None), NONCE), 1, "no TDX quote"); + expect(&node(&f.info("dstack", Some("zz")), NONCE), 1, "not hex"); + expect( + &node(&f.info("dstack", Some("00")), "short"), + 1, + "at least 22", + ); + expect( + &node( + &f.info("dstack", Some("00")), + "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + ), + 1, + "not for your nonce", + ); + let mut stale = f.info("dstack", Some("00")); + stale.efp = tinycloud_at_rest_escrow::Digest32([9; 32]); + expect(&node(&stale, NONCE), 1, "stale"); + // Everything local passes: now the PCCS is contacted and fails as input. + expect(&node(&f.info("dstack", Some("00")), NONCE), 3, "collateral"); + // An older/newer node response format is named. + let r = f.write("recovery.json", br#"{"version":2,"mode":"dstack"}"#); + let out = run( + &[ + "verify-node", + "--bundle", + p(&b), + "--allow-software", + "--recovery", + p(&r), + "--nonce", + NONCE, + "--trust", + p(&trust), + ], + None, + ); + expect(&out, 1, "recovery response is version 2"); +} + +#[test] +fn record_efp_never_claims_authenticity() { + use tinycloud_at_rest_escrow::VerificationRecord; + let f = Fixture::new("record"); + let b = f.path("bundle.json"); + let shares: Vec = (0..3) + .map(|i| f.bundle.open_share(&f.decrypted(&f.bundle, i)).unwrap()) + .collect(); + let unlock = f.bundle.test_unlock(&f.ark, &shares).unwrap(); + let record = + VerificationRecord::seal(&f.ark, &f.bundle, &unlock, None, None, "t".into()).unwrap(); + let mut forged = serde_json::to_value(&record).unwrap(); + forged["mac"] = "0".repeat(64).into(); + forged["app_id"] = "attacker".into(); + let forged = f.write("forged.json", &serde_json::to_vec(&forged).unwrap()); + let out = run( + &[ + "record-efp", + "--bundle", + p(&b), + "--allow-software", + "--record", + p(&forged), + ], + None, + ); + expect(&out, 0, "NOT AUTHENTICATED"); + assert!(!String::from_utf8_lossy(&out.stderr).contains("OK")); + + // A record for another escrow is refused outright. + let other = EscrowBundle::create( + &f.ark, + f.ark_id, + 1, + &f.holders, + RecipientPolicy::AllowSoftware, + ) + .unwrap(); + let other = f.write("other.json", &serde_json::to_vec(&other).unwrap()); + let real = f.write("record.json", &serde_json::to_vec(&record).unwrap()); + expect( + &run( + &[ + "record-efp", + "--bundle", + p(&other), + "--allow-software", + "--record", + p(&real), + ], + None, + ), + 1, + "different escrow bundle", + ); +} + +#[test] +fn self_test_round_trips_and_refuses_software_by_default() { + let f = Fixture::new("selftest"); + let id = &f.ids[0]; + let recipient = id.to_public().to_string(); + expect( + &run( + &["self-test", "--recipient", &recipient, "--out", p(&f.dir)], + None, + ), + 1, + "software keys", + ); + let out = run( + &[ + "self-test", + "--recipient", + &recipient, + "--allow-software", + "--out", + // A directory that doesn't exist yet: self-test creates it. + p(&f.dir.join("st/new")), + ], + None, + ); + expect(&out, 0, "THROWAWAY"); + let armored = std::fs::read_to_string(f.dir.join("st/new/selftest-share.age")).unwrap(); + let decryptor = + age::Decryptor::new_buffered(age::armor::ArmoredReader::new(armored.as_bytes())).unwrap(); + let mut plain = Vec::new(); + decryptor + .decrypt(std::iter::once(id as &dyn age::Identity)) + .unwrap() + .read_to_end(&mut plain) + .unwrap(); + let bundle = f.dir.join("st/new/selftest-bundle.json"); + expect( + &run( + &["check-share", "--bundle", p(&bundle), "--allow-software"], + Some(&plain), + ), + 0, + "share 1 (selftest)", + ); +} diff --git a/tinycloud-at-rest-cli/tests/fixtures/NOTICE.md b/tinycloud-at-rest-cli/tests/fixtures/NOTICE.md new file mode 100644 index 00000000..cd896cbe --- /dev/null +++ b/tinycloud-at-rest-cli/tests/fixtures/NOTICE.md @@ -0,0 +1,5 @@ +`tdx_quote*` and `tdx_quote*_collateral.json` are copied unchanged from +`dcap-qvl` 0.7.0 `sample/` (MIT License, Copyright (c) 2026 Phala Network, +https://github.com/Phala-Network/dcap-qvl). They are real Intel-signed TDX +quotes with their DCAP collateral, used to test that genuine quotes verify and +that forged, foreign or outdated ones are refused. diff --git a/tinycloud-at-rest-cli/tests/fixtures/tdx_quote b/tinycloud-at-rest-cli/tests/fixtures/tdx_quote new file mode 100644 index 00000000..667f510d Binary files /dev/null and b/tinycloud-at-rest-cli/tests/fixtures/tdx_quote differ diff --git a/tinycloud-at-rest-cli/tests/fixtures/tdx_quote_collateral.json b/tinycloud-at-rest-cli/tests/fixtures/tdx_quote_collateral.json new file mode 100644 index 00000000..386a6196 --- /dev/null +++ b/tinycloud-at-rest-cli/tests/fixtures/tdx_quote_collateral.json @@ -0,0 +1,11 @@ +{ + "pck_crl_issuer_chain": "-----BEGIN CERTIFICATE-----\nMIICljCCAj2gAwIBAgIVAJVvXc29G+HpQEnJ1PQzzgFXC95UMAoGCCqGSM49BAMC\nMGgxGjAYBgNVBAMMEUludGVsIFNHWCBSb290IENBMRowGAYDVQQKDBFJbnRlbCBD\nb3Jwb3JhdGlvbjEUMBIGA1UEBwwLU2FudGEgQ2xhcmExCzAJBgNVBAgMAkNBMQsw\nCQYDVQQGEwJVUzAeFw0xODA1MjExMDUwMTBaFw0zMzA1MjExMDUwMTBaMHAxIjAg\nBgNVBAMMGUludGVsIFNHWCBQQ0sgUGxhdGZvcm0gQ0ExGjAYBgNVBAoMEUludGVs\nIENvcnBvcmF0aW9uMRQwEgYDVQQHDAtTYW50YSBDbGFyYTELMAkGA1UECAwCQ0Ex\nCzAJBgNVBAYTAlVTMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAENSB/7t21lXSO\n2Cuzpxw74eJB72EyDGgW5rXCtx2tVTLq6hKk6z+UiRZCnqR7psOvgqFeSxlmTlJl\neTmi2WYz3qOBuzCBuDAfBgNVHSMEGDAWgBQiZQzWWp00ifODtJVSv1AbOScGrDBS\nBgNVHR8ESzBJMEegRaBDhkFodHRwczovL2NlcnRpZmljYXRlcy50cnVzdGVkc2Vy\ndmljZXMuaW50ZWwuY29tL0ludGVsU0dYUm9vdENBLmRlcjAdBgNVHQ4EFgQUlW9d\nzb0b4elAScnU9DPOAVcL3lQwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYB\nAf8CAQAwCgYIKoZIzj0EAwIDRwAwRAIgXsVki0w+i6VYGW3UF/22uaXe0YJDj1Ue\nnA+TjD1ai5cCICYb1SAmD5xkfTVpvo4UoyiSYxrDWLmUR4CI9NKyfPN+\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIICjzCCAjSgAwIBAgIUImUM1lqdNInzg7SVUr9QGzknBqwwCgYIKoZIzj0EAwIw\naDEaMBgGA1UEAwwRSW50ZWwgU0dYIFJvb3QgQ0ExGjAYBgNVBAoMEUludGVsIENv\ncnBvcmF0aW9uMRQwEgYDVQQHDAtTYW50YSBDbGFyYTELMAkGA1UECAwCQ0ExCzAJ\nBgNVBAYTAlVTMB4XDTE4MDUyMTEwNDUxMFoXDTQ5MTIzMTIzNTk1OVowaDEaMBgG\nA1UEAwwRSW50ZWwgU0dYIFJvb3QgQ0ExGjAYBgNVBAoMEUludGVsIENvcnBvcmF0\naW9uMRQwEgYDVQQHDAtTYW50YSBDbGFyYTELMAkGA1UECAwCQ0ExCzAJBgNVBAYT\nAlVTMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEC6nEwMDIYZOj/iPWsCzaEKi7\n1OiOSLRFhWGjbnBVJfVnkY4u3IjkDYYL0MxO4mqsyYjlBalTVYxFP2sJBK5zlKOB\nuzCBuDAfBgNVHSMEGDAWgBQiZQzWWp00ifODtJVSv1AbOScGrDBSBgNVHR8ESzBJ\nMEegRaBDhkFodHRwczovL2NlcnRpZmljYXRlcy50cnVzdGVkc2VydmljZXMuaW50\nZWwuY29tL0ludGVsU0dYUm9vdENBLmRlcjAdBgNVHQ4EFgQUImUM1lqdNInzg7SV\nUr9QGzknBqwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQEwCgYI\nKoZIzj0EAwIDSQAwRgIhAOW/5QkR+S9CiSDcNoowLuPRLsWGf/Yi7GSX94BgwTwg\nAiEA4J0lrHoMs+Xo5o/sX6O9QWxHRAvZUGOdRQ7cvqRXaqI=\n-----END CERTIFICATE-----\n", + "root_ca_crl": "308201203081c8020101300a06082a8648ce3d0403023068311a301806035504030c11496e74656c2053475820526f6f74204341311a3018060355040a0c11496e74656c20436f72706f726174696f6e3114301206035504070c0b53616e746120436c617261310b300906035504080c024341310b3009060355040613025553170d3235303332303131323135375a170d3236303430333131323135375aa02f302d300a0603551d140403020101301f0603551d2304183016801422650cd65a9d3489f383b49552bf501b392706ac300a06082a8648ce3d0403020347003044022030c9fce1438da0a94e4fffdd46c9650e393be6e5a7862d4e4e73527932d04af302206539efe3f734c3d7df20d9dfc4630e1c7ff0439a0f8ece101f15b5eaff9b4f33", + "pck_crl": "30820a6330820a08020101300a06082a8648ce3d04030230703122302006035504030c19496e74656c205347582050434b20506c6174666f726d204341311a3018060355040a0c11496e74656c20436f72706f726174696f6e3114301206035504070c0b53616e746120436c617261310b300906035504080c024341310b3009060355040613025553170d3235303631393130303033355a170d3235303731393130303033355a30820934303302146fc34e5023e728923435d61aa4b83c618166ad35170d3235303631393130303033355a300c300a0603551d1504030a01013034021500efae6e9715fca13b87e333e8261ed6d990a926ad170d3235303631393130303033355a300c300a0603551d1504030a01013034021500fd608648629cba73078b4d492f4b3ea741ad08cd170d3235303631393130303033355a300c300a0603551d1504030a010130340215008af924184e1d5afddd73c3d63a12f5e8b5737e56170d3235303631393130303033355a300c300a0603551d1504030a01013034021500b1257978cfa9ccdd0759abf8c5ca72fae3a78a9b170d3235303631393130303033355a300c300a0603551d1504030a01013033021474fea614a972be0e2843f2059835811ed872f9b3170d3235303631393130303033355a300c300a0603551d1504030a01013034021500f9c4ef56b3ab48d577e108baedf4bf88014214b9170d3235303631393130303033355a300c300a0603551d1504030a010130330214071de0778f9e5fc4f2878f30d6b07c9a30e6b30b170d3235303631393130303033355a300c300a0603551d1504030a01013034021500cde2424f972cea94ff239937f4d80c25029dd60b170d3235303631393130303033355a300c300a0603551d1504030a0101303302146c3319e5109b64507d3cf1132ce00349ef527319170d3235303631393130303033355a300c300a0603551d1504030a01013034021500df08d756b66a7497f43b5bb58ada04d3f4f7a937170d3235303631393130303033355a300c300a0603551d1504030a01013033021428af485b6cf67e409a39d5cb5aee4598f7a8fa7b170d3235303631393130303033355a300c300a0603551d1504030a01013034021500fb8b2daec092cada8aa9bc4ff2f1c20d0346668c170d3235303631393130303033355a300c300a0603551d1504030a01013034021500cd4850ac52bdcc69a6a6f058c8bc57bbd0b5f864170d3235303631393130303033355a300c300a0603551d1504030a01013034021500994dd3666f5275fb805f95dd02bd50cb2679d8ad170d3235303631393130303033355a300c300a0603551d1504030a0101303302140702136900252274d9035eedf5457462fad0ef4c170d3235303631393130303033355a300c300a0603551d1504030a01013033021461f2bf73e39b4e04aa27d801bd73d24319b5bf80170d3235303631393130303033355a300c300a0603551d1504030a0101303302143992be851b96902eff38959e6c2eff1b0651a4b5170d3235303631393130303033355a300c300a0603551d1504030a0101303302140fda43a00b68ea79b7c2deaeac0b498bdfb2af90170d3235303631393130303033355a300c300a0603551d1504030a010130330214639f139a5040fdcff191e8a4fb1bf086ed603971170d3235303631393130303033355a300c300a0603551d1504030a01013034021500959d533f9249dc1e513544cdc830bf19b7f1f301170d3235303631393130303033355a300c300a0603551d1504030a0101303302147ae37748a9f912f4c63ba7ab07c593ce1d1d1181170d3235303631393130303033355a300c300a0603551d1504030a01013033021413884b33269938c195aa170fca75da177538df0b170d3235303631393130303033355a300c300a0603551d1504030a0101303402150085d3c9381b77a7e04d119c9e5ad6749ff3ffab87170d3235303631393130303033355a300c300a0603551d1504030a0101303402150093887ca4411e7a923bd1fed2819b2949f201b5b4170d3235303631393130303033355a300c300a0603551d1504030a0101303302142498dc6283930996fd8bf23a37acbe26a3bed457170d3235303631393130303033355a300c300a0603551d1504030a010130340215008a66f1a749488667689cc3903ac54c662b712e73170d3235303631393130303033355a300c300a0603551d1504030a01013034021500afc13610bdd36cb7985d106481a880d3a01fda07170d3235303631393130303033355a300c300a0603551d1504030a01013034021500efe04b2c33d036aac96ca673bf1e9a47b64d5cbb170d3235303631393130303033355a300c300a0603551d1504030a0101303402150083d9ac8d8bb509d1c6c809ad712e8430559ed7f3170d3235303631393130303033355a300c300a0603551d1504030a0101303302147931fd50b5071c1bbfc5b7b6ded8b45b9d8b8529170d3235303631393130303033355a300c300a0603551d1504030a0101303302141fa20e2970bde5d57f7b8ddf8339484e1f1d0823170d3235303631393130303033355a300c300a0603551d1504030a0101303302141e87b2c3b32d8d23e411cef34197b95af0c8adf5170d3235303631393130303033355a300c300a0603551d1504030a010130340215009afd2ee90a473550a167d996911437c7502d1f09170d3235303631393130303033355a300c300a0603551d1504030a0101303302144481b0f11728a13b696d3ea9c770a0b15ec58dda170d3235303631393130303033355a300c300a0603551d1504030a01013034021500a7859f57982ef0e67d37bc8ef2ef5ac835ff1aa9170d3235303631393130303033355a300c300a0603551d1504030a010130340215009d67753b81e47090aea763fbec4c4549bcdb9933170d3235303631393130303033355a300c300a0603551d1504030a01013033021434bfbb7a1d9c568147e118b614f7b76ed3ef68df170d3235303631393130303033355a300c300a0603551d1504030a0101303302142c3cc6fe9279db1516d5ce39f2a898cda5a175e1170d3235303631393130303033355a300c300a0603551d1504030a010130330214717948687509234be979e4b7dce6f31bef64b68c170d3235303631393130303033355a300c300a0603551d1504030a010130340215009d76ef2c39c136e8658b6e7396b1d7445a27631f170d3235303631393130303033355a300c300a0603551d1504030a01013034021500c3e025fca995f36f59b48467939e3e34e6361a6f170d3235303631393130303033355a300c300a0603551d1504030a010130340215008c5f6b3257da05b17429e2e61ba965d67330606a170d3235303631393130303033355a300c300a0603551d1504030a01013034021500a17c51722ec1e0c3278fe8bdf052059cbec4e648170d3235303631393130303033355a300c300a0603551d1504030a0101a02f302d300a0603551d140403020101301f0603551d23041830168014956f5dcdbd1be1e94049c9d4f433ce01570bde54300a06082a8648ce3d0403020349003046022100a8d1fdb9ca38f042df9aa14d3b1433860ddc1c7f6b873d5eecf2b63c313cb032022100cd59f446c89582be4a7d599df6e133533bbed9628c6a0264b7774074b44e52ef", + "tcb_info_issuer_chain": "-----BEGIN CERTIFICATE-----\nMIICjTCCAjKgAwIBAgIUfjiC1ftVKUpASY5FhAPpFJG99FUwCgYIKoZIzj0EAwIw\naDEaMBgGA1UEAwwRSW50ZWwgU0dYIFJvb3QgQ0ExGjAYBgNVBAoMEUludGVsIENv\ncnBvcmF0aW9uMRQwEgYDVQQHDAtTYW50YSBDbGFyYTELMAkGA1UECAwCQ0ExCzAJ\nBgNVBAYTAlVTMB4XDTI1MDUwNjA5MjUwMFoXDTMyMDUwNjA5MjUwMFowbDEeMBwG\nA1UEAwwVSW50ZWwgU0dYIFRDQiBTaWduaW5nMRowGAYDVQQKDBFJbnRlbCBDb3Jw\nb3JhdGlvbjEUMBIGA1UEBwwLU2FudGEgQ2xhcmExCzAJBgNVBAgMAkNBMQswCQYD\nVQQGEwJVUzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABENFG8xzydWRfK92bmGv\nP+mAh91PEyV7Jh6FGJd5ndE9aBH7R3E4A7ubrlh/zN3C4xvpoouGlirMba+W2lju\nypajgbUwgbIwHwYDVR0jBBgwFoAUImUM1lqdNInzg7SVUr9QGzknBqwwUgYDVR0f\nBEswSTBHoEWgQ4ZBaHR0cHM6Ly9jZXJ0aWZpY2F0ZXMudHJ1c3RlZHNlcnZpY2Vz\nLmludGVsLmNvbS9JbnRlbFNHWFJvb3RDQS5kZXIwHQYDVR0OBBYEFH44gtX7VSlK\nQEmORYQD6RSRvfRVMA4GA1UdDwEB/wQEAwIGwDAMBgNVHRMBAf8EAjAAMAoGCCqG\nSM49BAMCA0kAMEYCIQDdmmRuAo3qCO8TC1IoJMITAoOEw4dlgEBHzSz1TuMSTAIh\nAKVTqOkt59+co0O3m3hC+v5Fb00FjYWcgeu3EijOULo5\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIICjzCCAjSgAwIBAgIUImUM1lqdNInzg7SVUr9QGzknBqwwCgYIKoZIzj0EAwIw\naDEaMBgGA1UEAwwRSW50ZWwgU0dYIFJvb3QgQ0ExGjAYBgNVBAoMEUludGVsIENv\ncnBvcmF0aW9uMRQwEgYDVQQHDAtTYW50YSBDbGFyYTELMAkGA1UECAwCQ0ExCzAJ\nBgNVBAYTAlVTMB4XDTE4MDUyMTEwNDUxMFoXDTQ5MTIzMTIzNTk1OVowaDEaMBgG\nA1UEAwwRSW50ZWwgU0dYIFJvb3QgQ0ExGjAYBgNVBAoMEUludGVsIENvcnBvcmF0\naW9uMRQwEgYDVQQHDAtTYW50YSBDbGFyYTELMAkGA1UECAwCQ0ExCzAJBgNVBAYT\nAlVTMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEC6nEwMDIYZOj/iPWsCzaEKi7\n1OiOSLRFhWGjbnBVJfVnkY4u3IjkDYYL0MxO4mqsyYjlBalTVYxFP2sJBK5zlKOB\nuzCBuDAfBgNVHSMEGDAWgBQiZQzWWp00ifODtJVSv1AbOScGrDBSBgNVHR8ESzBJ\nMEegRaBDhkFodHRwczovL2NlcnRpZmljYXRlcy50cnVzdGVkc2VydmljZXMuaW50\nZWwuY29tL0ludGVsU0dYUm9vdENBLmRlcjAdBgNVHQ4EFgQUImUM1lqdNInzg7SV\nUr9QGzknBqwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQEwCgYI\nKoZIzj0EAwIDSQAwRgIhAOW/5QkR+S9CiSDcNoowLuPRLsWGf/Yi7GSX94BgwTwg\nAiEA4J0lrHoMs+Xo5o/sX6O9QWxHRAvZUGOdRQ7cvqRXaqI=\n-----END CERTIFICATE-----\n", + "tcb_info": "{\"id\":\"TDX\",\"version\":3,\"issueDate\":\"2025-06-19T10:16:03Z\",\"nextUpdate\":\"2025-07-19T10:16:03Z\",\"fmspc\":\"B0C06F000000\",\"pceId\":\"0000\",\"tcbType\":0,\"tcbEvaluationDataNumber\":17,\"tdxModule\":{\"mrsigner\":\"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000\",\"attributes\":\"0000000000000000\",\"attributesMask\":\"FFFFFFFFFFFFFFFF\"},\"tdxModuleIdentities\":[{\"id\":\"TDX_03\",\"mrsigner\":\"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000\",\"attributes\":\"0000000000000000\",\"attributesMask\":\"FFFFFFFFFFFFFFFF\",\"tcbLevels\":[{\"tcb\":{\"isvsvn\":3},\"tcbDate\":\"2024-03-13T00:00:00Z\",\"tcbStatus\":\"UpToDate\"}]},{\"id\":\"TDX_01\",\"mrsigner\":\"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000\",\"attributes\":\"0000000000000000\",\"attributesMask\":\"FFFFFFFFFFFFFFFF\",\"tcbLevels\":[{\"tcb\":{\"isvsvn\":4},\"tcbDate\":\"2024-03-13T00:00:00Z\",\"tcbStatus\":\"UpToDate\"},{\"tcb\":{\"isvsvn\":2},\"tcbDate\":\"2023-08-09T00:00:00Z\",\"tcbStatus\":\"OutOfDate\"}]}],\"tcbLevels\":[{\"tcb\":{\"sgxtcbcomponents\":[{\"svn\":2,\"category\":\"BIOS\",\"type\":\"Early Microcode Update\"},{\"svn\":2,\"category\":\"OS/VMM\",\"type\":\"SGX Late Microcode Update\"},{\"svn\":2,\"category\":\"OS/VMM\",\"type\":\"TXT SINIT\"},{\"svn\":2,\"category\":\"BIOS\"},{\"svn\":3,\"category\":\"BIOS\"},{\"svn\":1,\"category\":\"BIOS\"},{\"svn\":0},{\"svn\":5,\"category\":\"OS/VMM\",\"type\":\"SEAMLDR ACM\"},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0}],\"pcesvn\":11,\"tdxtcbcomponents\":[{\"svn\":5,\"category\":\"OS/VMM\",\"type\":\"TDX Module\"},{\"svn\":0,\"category\":\"OS/VMM\",\"type\":\"TDX Module\"},{\"svn\":2,\"category\":\"OS/VMM\",\"type\":\"TDX Late Microcode Update\"},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0}]},\"tcbDate\":\"2024-03-13T00:00:00Z\",\"tcbStatus\":\"UpToDate\"},{\"tcb\":{\"sgxtcbcomponents\":[{\"svn\":2,\"category\":\"BIOS\",\"type\":\"Early Microcode Update\"},{\"svn\":2,\"category\":\"OS/VMM\",\"type\":\"SGX Late Microcode Update\"},{\"svn\":2,\"category\":\"OS/VMM\",\"type\":\"TXT SINIT\"},{\"svn\":2,\"category\":\"BIOS\"},{\"svn\":3,\"category\":\"BIOS\"},{\"svn\":1,\"category\":\"BIOS\"},{\"svn\":0},{\"svn\":5,\"category\":\"OS/VMM\",\"type\":\"SEAMLDR ACM\"},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0}],\"pcesvn\":5,\"tdxtcbcomponents\":[{\"svn\":5,\"category\":\"OS/VMM\",\"type\":\"TDX Module\"},{\"svn\":0,\"category\":\"OS/VMM\",\"type\":\"TDX Module\"},{\"svn\":2,\"category\":\"OS/VMM\",\"type\":\"TDX Late Microcode Update\"},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0}]},\"tcbDate\":\"2018-01-04T00:00:00Z\",\"tcbStatus\":\"OutOfDate\",\"advisoryIDs\":[\"INTEL-SA-00106\",\"INTEL-SA-00115\",\"INTEL-SA-00135\",\"INTEL-SA-00203\",\"INTEL-SA-00220\",\"INTEL-SA-00233\",\"INTEL-SA-00270\",\"INTEL-SA-00293\",\"INTEL-SA-00320\",\"INTEL-SA-00329\",\"INTEL-SA-00381\",\"INTEL-SA-00389\",\"INTEL-SA-00477\",\"INTEL-SA-00837\"]}]}", + "tcb_info_signature": "027ef6ca41bac64e61edbbd672b1c97eb0b2997400c5018eee002e66421b3fd27e71676891c9df47dc6ea3ea2e757ad3e080f394da0e0cddd76b2debe6790b4f", + "qe_identity_issuer_chain": "-----BEGIN CERTIFICATE-----\nMIICjTCCAjKgAwIBAgIUfjiC1ftVKUpASY5FhAPpFJG99FUwCgYIKoZIzj0EAwIw\naDEaMBgGA1UEAwwRSW50ZWwgU0dYIFJvb3QgQ0ExGjAYBgNVBAoMEUludGVsIENv\ncnBvcmF0aW9uMRQwEgYDVQQHDAtTYW50YSBDbGFyYTELMAkGA1UECAwCQ0ExCzAJ\nBgNVBAYTAlVTMB4XDTI1MDUwNjA5MjUwMFoXDTMyMDUwNjA5MjUwMFowbDEeMBwG\nA1UEAwwVSW50ZWwgU0dYIFRDQiBTaWduaW5nMRowGAYDVQQKDBFJbnRlbCBDb3Jw\nb3JhdGlvbjEUMBIGA1UEBwwLU2FudGEgQ2xhcmExCzAJBgNVBAgMAkNBMQswCQYD\nVQQGEwJVUzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABENFG8xzydWRfK92bmGv\nP+mAh91PEyV7Jh6FGJd5ndE9aBH7R3E4A7ubrlh/zN3C4xvpoouGlirMba+W2lju\nypajgbUwgbIwHwYDVR0jBBgwFoAUImUM1lqdNInzg7SVUr9QGzknBqwwUgYDVR0f\nBEswSTBHoEWgQ4ZBaHR0cHM6Ly9jZXJ0aWZpY2F0ZXMudHJ1c3RlZHNlcnZpY2Vz\nLmludGVsLmNvbS9JbnRlbFNHWFJvb3RDQS5kZXIwHQYDVR0OBBYEFH44gtX7VSlK\nQEmORYQD6RSRvfRVMA4GA1UdDwEB/wQEAwIGwDAMBgNVHRMBAf8EAjAAMAoGCCqG\nSM49BAMCA0kAMEYCIQDdmmRuAo3qCO8TC1IoJMITAoOEw4dlgEBHzSz1TuMSTAIh\nAKVTqOkt59+co0O3m3hC+v5Fb00FjYWcgeu3EijOULo5\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIICjzCCAjSgAwIBAgIUImUM1lqdNInzg7SVUr9QGzknBqwwCgYIKoZIzj0EAwIw\naDEaMBgGA1UEAwwRSW50ZWwgU0dYIFJvb3QgQ0ExGjAYBgNVBAoMEUludGVsIENv\ncnBvcmF0aW9uMRQwEgYDVQQHDAtTYW50YSBDbGFyYTELMAkGA1UECAwCQ0ExCzAJ\nBgNVBAYTAlVTMB4XDTE4MDUyMTEwNDUxMFoXDTQ5MTIzMTIzNTk1OVowaDEaMBgG\nA1UEAwwRSW50ZWwgU0dYIFJvb3QgQ0ExGjAYBgNVBAoMEUludGVsIENvcnBvcmF0\naW9uMRQwEgYDVQQHDAtTYW50YSBDbGFyYTELMAkGA1UECAwCQ0ExCzAJBgNVBAYT\nAlVTMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEC6nEwMDIYZOj/iPWsCzaEKi7\n1OiOSLRFhWGjbnBVJfVnkY4u3IjkDYYL0MxO4mqsyYjlBalTVYxFP2sJBK5zlKOB\nuzCBuDAfBgNVHSMEGDAWgBQiZQzWWp00ifODtJVSv1AbOScGrDBSBgNVHR8ESzBJ\nMEegRaBDhkFodHRwczovL2NlcnRpZmljYXRlcy50cnVzdGVkc2VydmljZXMuaW50\nZWwuY29tL0ludGVsU0dYUm9vdENBLmRlcjAdBgNVHQ4EFgQUImUM1lqdNInzg7SV\nUr9QGzknBqwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQEwCgYI\nKoZIzj0EAwIDSQAwRgIhAOW/5QkR+S9CiSDcNoowLuPRLsWGf/Yi7GSX94BgwTwg\nAiEA4J0lrHoMs+Xo5o/sX6O9QWxHRAvZUGOdRQ7cvqRXaqI=\n-----END CERTIFICATE-----\n", + "qe_identity": "{\"id\":\"TD_QE\",\"version\":2,\"issueDate\":\"2025-06-19T10:32:27Z\",\"nextUpdate\":\"2025-07-19T10:32:27Z\",\"tcbEvaluationDataNumber\":17,\"miscselect\":\"00000000\",\"miscselectMask\":\"FFFFFFFF\",\"attributes\":\"11000000000000000000000000000000\",\"attributesMask\":\"FBFFFFFFFFFFFFFF0000000000000000\",\"mrsigner\":\"DC9E2A7C6F948F17474E34A7FC43ED030F7C1563F1BABDDF6340C82E0E54A8C5\",\"isvprodid\":2,\"tcbLevels\":[{\"tcb\":{\"isvsvn\":4},\"tcbDate\":\"2024-03-13T00:00:00Z\",\"tcbStatus\":\"UpToDate\"}]}", + "qe_identity_signature": "d6d709840544c26e2ab3d680067d04b6160551f78aa23062cc79ab1be2ffe5414e21bf0fa9f0bea3c69be6c97d0a16585b82f6cc481059ad4affdc1c9bccfa15" +} \ No newline at end of file diff --git a/tinycloud-at-rest-cli/tests/fixtures/tdx_quote_outdated b/tinycloud-at-rest-cli/tests/fixtures/tdx_quote_outdated new file mode 100644 index 00000000..2dec55d5 Binary files /dev/null and b/tinycloud-at-rest-cli/tests/fixtures/tdx_quote_outdated differ diff --git a/tinycloud-at-rest-cli/tests/fixtures/tdx_quote_outdated_collateral.json b/tinycloud-at-rest-cli/tests/fixtures/tdx_quote_outdated_collateral.json new file mode 100644 index 00000000..bd64f8ad --- /dev/null +++ b/tinycloud-at-rest-cli/tests/fixtures/tdx_quote_outdated_collateral.json @@ -0,0 +1 @@ +{"pck_crl_issuer_chain":"-----BEGIN CERTIFICATE-----\nMIICljCCAj2gAwIBAgIVAJVvXc29G+HpQEnJ1PQzzgFXC95UMAoGCCqGSM49BAMC\nMGgxGjAYBgNVBAMMEUludGVsIFNHWCBSb290IENBMRowGAYDVQQKDBFJbnRlbCBD\nb3Jwb3JhdGlvbjEUMBIGA1UEBwwLU2FudGEgQ2xhcmExCzAJBgNVBAgMAkNBMQsw\nCQYDVQQGEwJVUzAeFw0xODA1MjExMDUwMTBaFw0zMzA1MjExMDUwMTBaMHAxIjAg\nBgNVBAMMGUludGVsIFNHWCBQQ0sgUGxhdGZvcm0gQ0ExGjAYBgNVBAoMEUludGVs\nIENvcnBvcmF0aW9uMRQwEgYDVQQHDAtTYW50YSBDbGFyYTELMAkGA1UECAwCQ0Ex\nCzAJBgNVBAYTAlVTMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAENSB/7t21lXSO\n2Cuzpxw74eJB72EyDGgW5rXCtx2tVTLq6hKk6z+UiRZCnqR7psOvgqFeSxlmTlJl\neTmi2WYz3qOBuzCBuDAfBgNVHSMEGDAWgBQiZQzWWp00ifODtJVSv1AbOScGrDBS\nBgNVHR8ESzBJMEegRaBDhkFodHRwczovL2NlcnRpZmljYXRlcy50cnVzdGVkc2Vy\ndmljZXMuaW50ZWwuY29tL0ludGVsU0dYUm9vdENBLmRlcjAdBgNVHQ4EFgQUlW9d\nzb0b4elAScnU9DPOAVcL3lQwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYB\nAf8CAQAwCgYIKoZIzj0EAwIDRwAwRAIgXsVki0w+i6VYGW3UF/22uaXe0YJDj1Ue\nnA+TjD1ai5cCICYb1SAmD5xkfTVpvo4UoyiSYxrDWLmUR4CI9NKyfPN+\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIICjzCCAjSgAwIBAgIUImUM1lqdNInzg7SVUr9QGzknBqwwCgYIKoZIzj0EAwIw\naDEaMBgGA1UEAwwRSW50ZWwgU0dYIFJvb3QgQ0ExGjAYBgNVBAoMEUludGVsIENv\ncnBvcmF0aW9uMRQwEgYDVQQHDAtTYW50YSBDbGFyYTELMAkGA1UECAwCQ0ExCzAJ\nBgNVBAYTAlVTMB4XDTE4MDUyMTEwNDUxMFoXDTQ5MTIzMTIzNTk1OVowaDEaMBgG\nA1UEAwwRSW50ZWwgU0dYIFJvb3QgQ0ExGjAYBgNVBAoMEUludGVsIENvcnBvcmF0\naW9uMRQwEgYDVQQHDAtTYW50YSBDbGFyYTELMAkGA1UECAwCQ0ExCzAJBgNVBAYT\nAlVTMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEC6nEwMDIYZOj/iPWsCzaEKi7\n1OiOSLRFhWGjbnBVJfVnkY4u3IjkDYYL0MxO4mqsyYjlBalTVYxFP2sJBK5zlKOB\nuzCBuDAfBgNVHSMEGDAWgBQiZQzWWp00ifODtJVSv1AbOScGrDBSBgNVHR8ESzBJ\nMEegRaBDhkFodHRwczovL2NlcnRpZmljYXRlcy50cnVzdGVkc2VydmljZXMuaW50\nZWwuY29tL0ludGVsU0dYUm9vdENBLmRlcjAdBgNVHQ4EFgQUImUM1lqdNInzg7SV\nUr9QGzknBqwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQEwCgYI\nKoZIzj0EAwIDSQAwRgIhAOW/5QkR+S9CiSDcNoowLuPRLsWGf/Yi7GSX94BgwTwg\nAiEA4J0lrHoMs+Xo5o/sX6O9QWxHRAvZUGOdRQ7cvqRXaqI=\n-----END CERTIFICATE-----\n","root_ca_crl":"308201203081c8020101300a06082a8648ce3d0403023068311a301806035504030c11496e74656c2053475820526f6f74204341311a3018060355040a0c11496e74656c20436f72706f726174696f6e3114301206035504070c0b53616e746120436c617261310b300906035504080c024341310b3009060355040613025553170d3235303332303131323135375a170d3236303430333131323135375aa02f302d300a0603551d140403020101301f0603551d2304183016801422650cd65a9d3489f383b49552bf501b392706ac300a06082a8648ce3d0403020347003044022030c9fce1438da0a94e4fffdd46c9650e393be6e5a7862d4e4e73527932d04af302206539efe3f734c3d7df20d9dfc4630e1c7ff0439a0f8ece101f15b5eaff9b4f33","pck_crl":"30820d1730820cbd020101300a06082a8648ce3d04030230703122302006035504030c19496e74656c205347582050434b20506c6174666f726d204341311a3018060355040a0c11496e74656c20436f72706f726174696f6e3114301206035504070c0b53616e746120436c617261310b300906035504080c024341310b3009060355040613025553170d3236303231383130343131355a170d3236303332303130343131355a30820be9303302146fc34e5023e728923435d61aa4b83c618166ad35170d3236303231383130343131355a300c300a0603551d1504030a01013034021500efae6e9715fca13b87e333e8261ed6d990a926ad170d3236303231383130343131355a300c300a0603551d1504030a01013034021500fd608648629cba73078b4d492f4b3ea741ad08cd170d3236303231383130343131355a300c300a0603551d1504030a010130340215008af924184e1d5afddd73c3d63a12f5e8b5737e56170d3236303231383130343131355a300c300a0603551d1504030a01013034021500b1257978cfa9ccdd0759abf8c5ca72fae3a78a9b170d3236303231383130343131355a300c300a0603551d1504030a01013033021474fea614a972be0e2843f2059835811ed872f9b3170d3236303231383130343131355a300c300a0603551d1504030a01013034021500f9c4ef56b3ab48d577e108baedf4bf88014214b9170d3236303231383130343131355a300c300a0603551d1504030a010130330214071de0778f9e5fc4f2878f30d6b07c9a30e6b30b170d3236303231383130343131355a300c300a0603551d1504030a01013034021500cde2424f972cea94ff239937f4d80c25029dd60b170d3236303231383130343131355a300c300a0603551d1504030a0101303302146c3319e5109b64507d3cf1132ce00349ef527319170d3236303231383130343131355a300c300a0603551d1504030a01013034021500df08d756b66a7497f43b5bb58ada04d3f4f7a937170d3236303231383130343131355a300c300a0603551d1504030a01013033021428af485b6cf67e409a39d5cb5aee4598f7a8fa7b170d3236303231383130343131355a300c300a0603551d1504030a01013034021500fb8b2daec092cada8aa9bc4ff2f1c20d0346668c170d3236303231383130343131355a300c300a0603551d1504030a01013034021500cd4850ac52bdcc69a6a6f058c8bc57bbd0b5f864170d3236303231383130343131355a300c300a0603551d1504030a01013034021500994dd3666f5275fb805f95dd02bd50cb2679d8ad170d3236303231383130343131355a300c300a0603551d1504030a0101303302140702136900252274d9035eedf5457462fad0ef4c170d3236303231383130343131355a300c300a0603551d1504030a01013033021461f2bf73e39b4e04aa27d801bd73d24319b5bf80170d3236303231383130343131355a300c300a0603551d1504030a0101303302143992be851b96902eff38959e6c2eff1b0651a4b5170d3236303231383130343131355a300c300a0603551d1504030a0101303302140fda43a00b68ea79b7c2deaeac0b498bdfb2af90170d3236303231383130343131355a300c300a0603551d1504030a010130330214639f139a5040fdcff191e8a4fb1bf086ed603971170d3236303231383130343131355a300c300a0603551d1504030a01013034021500959d533f9249dc1e513544cdc830bf19b7f1f301170d3236303231383130343131355a300c300a0603551d1504030a0101303302147ae37748a9f912f4c63ba7ab07c593ce1d1d1181170d3236303231383130343131355a300c300a0603551d1504030a01013033021413884b33269938c195aa170fca75da177538df0b170d3236303231383130343131355a300c300a0603551d1504030a0101303402150085d3c9381b77a7e04d119c9e5ad6749ff3ffab87170d3236303231383130343131355a300c300a0603551d1504030a0101303402150093887ca4411e7a923bd1fed2819b2949f201b5b4170d3236303231383130343131355a300c300a0603551d1504030a0101303302142498dc6283930996fd8bf23a37acbe26a3bed457170d3236303231383130343131355a300c300a0603551d1504030a010130340215008a66f1a749488667689cc3903ac54c662b712e73170d3236303231383130343131355a300c300a0603551d1504030a01013034021500afc13610bdd36cb7985d106481a880d3a01fda07170d3236303231383130343131355a300c300a0603551d1504030a01013034021500efe04b2c33d036aac96ca673bf1e9a47b64d5cbb170d3236303231383130343131355a300c300a0603551d1504030a0101303402150083d9ac8d8bb509d1c6c809ad712e8430559ed7f3170d3236303231383130343131355a300c300a0603551d1504030a0101303302147931fd50b5071c1bbfc5b7b6ded8b45b9d8b8529170d3236303231383130343131355a300c300a0603551d1504030a0101303302141fa20e2970bde5d57f7b8ddf8339484e1f1d0823170d3236303231383130343131355a300c300a0603551d1504030a0101303302141e87b2c3b32d8d23e411cef34197b95af0c8adf5170d3236303231383130343131355a300c300a0603551d1504030a010130340215009afd2ee90a473550a167d996911437c7502d1f09170d3236303231383130343131355a300c300a0603551d1504030a0101303302144481b0f11728a13b696d3ea9c770a0b15ec58dda170d3236303231383130343131355a300c300a0603551d1504030a01013034021500a7859f57982ef0e67d37bc8ef2ef5ac835ff1aa9170d3236303231383130343131355a300c300a0603551d1504030a010130340215009d67753b81e47090aea763fbec4c4549bcdb9933170d3236303231383130343131355a300c300a0603551d1504030a01013033021434bfbb7a1d9c568147e118b614f7b76ed3ef68df170d3236303231383130343131355a300c300a0603551d1504030a0101303302142c3cc6fe9279db1516d5ce39f2a898cda5a175e1170d3236303231383130343131355a300c300a0603551d1504030a010130330214717948687509234be979e4b7dce6f31bef64b68c170d3236303231383130343131355a300c300a0603551d1504030a010130340215009d76ef2c39c136e8658b6e7396b1d7445a27631f170d3236303231383130343131355a300c300a0603551d1504030a01013034021500c3e025fca995f36f59b48467939e3e34e6361a6f170d3236303231383130343131355a300c300a0603551d1504030a010130340215008c5f6b3257da05b17429e2e61ba965d67330606a170d3236303231383130343131355a300c300a0603551d1504030a01013034021500a17c51722ec1e0c3278fe8bdf052059cbec4e648170d3236303231383130343131355a300c300a0603551d1504030a01013033021411c943b866fa04944e3057e5a67146596475a023170d3236303231383130343131355a300c300a0603551d1504030a01013034021500be6913785406155454a28885a515b3da5767d3a9170d3236303231383130343131355a300c300a0603551d1504030a0101303302140ac5ec91bd934c07b9ea41625e9cc09681002eb0170d3236303231383130343131355a300c300a0603551d1504030a0101303302146d51a0eabc1f9a1e9ddd5b36bdda1631ae6c182a170d3236303231383130343131355a300c300a0603551d1504030a01013034021500a52c5d71c4166b4fc0ded8b679951e5ee9193de5170d3236303231383130343131355a300c300a0603551d1504030a010130330214249779aedd85fcac93c8853516be5428c26b3bf8170d3236303231383130343131355a300c300a0603551d1504030a01013033021434ba4fd76bde5309210cf1dd1ffb494c638a9157170d3236303231383130343131355a300c300a0603551d1504030a010130330214043e04919daae13443248395094d2a2eacfc76fe170d3236303231383130343131355a300c300a0603551d1504030a01013033021447fc577d2d094cbdf270715ed6848a93855ad34b170d3236303231383130343131355a300c300a0603551d1504030a0101303302147d62a2f5e6f386e469653fffff045d0a8178e8e7170d3236303231383130343131355a300c300a0603551d1504030a01013034021500c4ed45fe026bb6a47eaec35ea80b7ef407ce062c170d3236303231383130343131355a300c300a0603551d1504030a01013034021500cf9831077a3ca4f1a2c56867bf55b18eccbeffd8170d3236303231383130343131355a300c300a0603551d1504030a0101303302146c2b81d7ea2e436720ce29f1d0b1ccb7a218600f170d3236303231383130343131355a300c300a0603551d1504030a0101a02f302d300a0603551d140403020101301f0603551d23041830168014956f5dcdbd1be1e94049c9d4f433ce01570bde54300a06082a8648ce3d0403020348003045022100f00668672d1a3079ecea223cc81ce18abb14c7945b485d4b2cd744bff993c95c02203055c33fdacd3b52c61c4a043945a6e607d8b361db1082cc2aa93dd7e83e377b","tcb_info_issuer_chain":"-----BEGIN CERTIFICATE-----\nMIICjTCCAjKgAwIBAgIUfjiC1ftVKUpASY5FhAPpFJG99FUwCgYIKoZIzj0EAwIw\naDEaMBgGA1UEAwwRSW50ZWwgU0dYIFJvb3QgQ0ExGjAYBgNVBAoMEUludGVsIENv\ncnBvcmF0aW9uMRQwEgYDVQQHDAtTYW50YSBDbGFyYTELMAkGA1UECAwCQ0ExCzAJ\nBgNVBAYTAlVTMB4XDTI1MDUwNjA5MjUwMFoXDTMyMDUwNjA5MjUwMFowbDEeMBwG\nA1UEAwwVSW50ZWwgU0dYIFRDQiBTaWduaW5nMRowGAYDVQQKDBFJbnRlbCBDb3Jw\nb3JhdGlvbjEUMBIGA1UEBwwLU2FudGEgQ2xhcmExCzAJBgNVBAgMAkNBMQswCQYD\nVQQGEwJVUzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABENFG8xzydWRfK92bmGv\nP+mAh91PEyV7Jh6FGJd5ndE9aBH7R3E4A7ubrlh/zN3C4xvpoouGlirMba+W2lju\nypajgbUwgbIwHwYDVR0jBBgwFoAUImUM1lqdNInzg7SVUr9QGzknBqwwUgYDVR0f\nBEswSTBHoEWgQ4ZBaHR0cHM6Ly9jZXJ0aWZpY2F0ZXMudHJ1c3RlZHNlcnZpY2Vz\nLmludGVsLmNvbS9JbnRlbFNHWFJvb3RDQS5kZXIwHQYDVR0OBBYEFH44gtX7VSlK\nQEmORYQD6RSRvfRVMA4GA1UdDwEB/wQEAwIGwDAMBgNVHRMBAf8EAjAAMAoGCCqG\nSM49BAMCA0kAMEYCIQDdmmRuAo3qCO8TC1IoJMITAoOEw4dlgEBHzSz1TuMSTAIh\nAKVTqOkt59+co0O3m3hC+v5Fb00FjYWcgeu3EijOULo5\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIICjzCCAjSgAwIBAgIUImUM1lqdNInzg7SVUr9QGzknBqwwCgYIKoZIzj0EAwIw\naDEaMBgGA1UEAwwRSW50ZWwgU0dYIFJvb3QgQ0ExGjAYBgNVBAoMEUludGVsIENv\ncnBvcmF0aW9uMRQwEgYDVQQHDAtTYW50YSBDbGFyYTELMAkGA1UECAwCQ0ExCzAJ\nBgNVBAYTAlVTMB4XDTE4MDUyMTEwNDUxMFoXDTQ5MTIzMTIzNTk1OVowaDEaMBgG\nA1UEAwwRSW50ZWwgU0dYIFJvb3QgQ0ExGjAYBgNVBAoMEUludGVsIENvcnBvcmF0\naW9uMRQwEgYDVQQHDAtTYW50YSBDbGFyYTELMAkGA1UECAwCQ0ExCzAJBgNVBAYT\nAlVTMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEC6nEwMDIYZOj/iPWsCzaEKi7\n1OiOSLRFhWGjbnBVJfVnkY4u3IjkDYYL0MxO4mqsyYjlBalTVYxFP2sJBK5zlKOB\nuzCBuDAfBgNVHSMEGDAWgBQiZQzWWp00ifODtJVSv1AbOScGrDBSBgNVHR8ESzBJ\nMEegRaBDhkFodHRwczovL2NlcnRpZmljYXRlcy50cnVzdGVkc2VydmljZXMuaW50\nZWwuY29tL0ludGVsU0dYUm9vdENBLmRlcjAdBgNVHQ4EFgQUImUM1lqdNInzg7SV\nUr9QGzknBqwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQEwCgYI\nKoZIzj0EAwIDSQAwRgIhAOW/5QkR+S9CiSDcNoowLuPRLsWGf/Yi7GSX94BgwTwg\nAiEA4J0lrHoMs+Xo5o/sX6O9QWxHRAvZUGOdRQ7cvqRXaqI=\n-----END CERTIFICATE-----\n","tcb_info":"{\"id\":\"TDX\",\"version\":3,\"issueDate\":\"2026-02-18T10:58:51Z\",\"nextUpdate\":\"2026-03-20T10:58:51Z\",\"fmspc\":\"90C06F000000\",\"pceId\":\"0000\",\"tcbType\":0,\"tcbEvaluationDataNumber\":18,\"tdxModule\":{\"mrsigner\":\"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000\",\"attributes\":\"0000000000000000\",\"attributesMask\":\"FFFFFFFFFFFFFFFF\"},\"tdxModuleIdentities\":[{\"id\":\"TDX_03\",\"mrsigner\":\"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000\",\"attributes\":\"0000000000000000\",\"attributesMask\":\"FFFFFFFFFFFFFFFF\",\"tcbLevels\":[{\"tcb\":{\"isvsvn\":3},\"tcbDate\":\"2024-11-13T00:00:00Z\",\"tcbStatus\":\"UpToDate\"}]},{\"id\":\"TDX_01\",\"mrsigner\":\"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000\",\"attributes\":\"0000000000000000\",\"attributesMask\":\"FFFFFFFFFFFFFFFF\",\"tcbLevels\":[{\"tcb\":{\"isvsvn\":6},\"tcbDate\":\"2024-11-13T00:00:00Z\",\"tcbStatus\":\"UpToDate\"},{\"tcb\":{\"isvsvn\":4},\"tcbDate\":\"2024-03-13T00:00:00Z\",\"tcbStatus\":\"OutOfDate\",\"advisoryIDs\":[\"INTEL-SA-01036\",\"INTEL-SA-01099\"]},{\"tcb\":{\"isvsvn\":2},\"tcbDate\":\"2023-08-09T00:00:00Z\",\"tcbStatus\":\"OutOfDate\",\"advisoryIDs\":[\"INTEL-SA-01036\",\"INTEL-SA-01099\"]}]}],\"tcbLevels\":[{\"tcb\":{\"sgxtcbcomponents\":[{\"svn\":3,\"category\":\"BIOS\",\"type\":\"Early Microcode Update\"},{\"svn\":3,\"category\":\"OS/VMM\",\"type\":\"SGX Late Microcode Update\"},{\"svn\":2,\"category\":\"OS/VMM\",\"type\":\"TXT SINIT\"},{\"svn\":2,\"category\":\"BIOS\"},{\"svn\":4,\"category\":\"BIOS\"},{\"svn\":1,\"category\":\"BIOS\"},{\"svn\":0},{\"svn\":5,\"category\":\"OS/VMM\",\"type\":\"SEAMLDR ACM\"},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0}],\"pcesvn\":13,\"tdxtcbcomponents\":[{\"svn\":5,\"category\":\"OS/VMM\",\"type\":\"TDX Module\"},{\"svn\":0,\"category\":\"OS/VMM\",\"type\":\"TDX Module\"},{\"svn\":3,\"category\":\"OS/VMM\",\"type\":\"TDX Late Microcode Update\"},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0}]},\"tcbDate\":\"2024-11-13T00:00:00Z\",\"tcbStatus\":\"UpToDate\"},{\"tcb\":{\"sgxtcbcomponents\":[{\"svn\":2,\"category\":\"BIOS\",\"type\":\"Early Microcode Update\"},{\"svn\":2,\"category\":\"OS/VMM\",\"type\":\"SGX Late Microcode Update\"},{\"svn\":2,\"category\":\"OS/VMM\",\"type\":\"TXT SINIT\"},{\"svn\":2,\"category\":\"BIOS\"},{\"svn\":3,\"category\":\"BIOS\"},{\"svn\":1,\"category\":\"BIOS\"},{\"svn\":0},{\"svn\":5,\"category\":\"OS/VMM\",\"type\":\"SEAMLDR ACM\"},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0}],\"pcesvn\":13,\"tdxtcbcomponents\":[{\"svn\":5,\"category\":\"OS/VMM\",\"type\":\"TDX Module\"},{\"svn\":0,\"category\":\"OS/VMM\",\"type\":\"TDX Module\"},{\"svn\":2,\"category\":\"OS/VMM\",\"type\":\"TDX Late Microcode Update\"},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0}]},\"tcbDate\":\"2024-03-13T00:00:00Z\",\"tcbStatus\":\"OutOfDate\",\"advisoryIDs\":[\"INTEL-SA-01036\",\"INTEL-SA-01079\",\"INTEL-SA-01099\",\"INTEL-SA-01103\",\"INTEL-SA-01111\"]},{\"tcb\":{\"sgxtcbcomponents\":[{\"svn\":2,\"category\":\"BIOS\",\"type\":\"Early Microcode Update\"},{\"svn\":2,\"category\":\"OS/VMM\",\"type\":\"SGX Late Microcode Update\"},{\"svn\":2,\"category\":\"OS/VMM\",\"type\":\"TXT SINIT\"},{\"svn\":2,\"category\":\"BIOS\"},{\"svn\":3,\"category\":\"BIOS\"},{\"svn\":1,\"category\":\"BIOS\"},{\"svn\":0},{\"svn\":5,\"category\":\"OS/VMM\",\"type\":\"SEAMLDR ACM\"},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0}],\"pcesvn\":5,\"tdxtcbcomponents\":[{\"svn\":5,\"category\":\"OS/VMM\",\"type\":\"TDX Module\"},{\"svn\":0,\"category\":\"OS/VMM\",\"type\":\"TDX Module\"},{\"svn\":2,\"category\":\"OS/VMM\",\"type\":\"TDX Late Microcode Update\"},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0},{\"svn\":0}]},\"tcbDate\":\"2018-01-04T00:00:00Z\",\"tcbStatus\":\"OutOfDate\",\"advisoryIDs\":[\"INTEL-SA-00106\",\"INTEL-SA-00115\",\"INTEL-SA-00135\",\"INTEL-SA-00203\",\"INTEL-SA-00220\",\"INTEL-SA-00233\",\"INTEL-SA-00270\",\"INTEL-SA-00293\",\"INTEL-SA-00320\",\"INTEL-SA-00329\",\"INTEL-SA-00381\",\"INTEL-SA-00389\",\"INTEL-SA-00477\",\"INTEL-SA-00837\",\"INTEL-SA-01036\",\"INTEL-SA-01079\",\"INTEL-SA-01099\",\"INTEL-SA-01103\",\"INTEL-SA-01111\"]}]}","tcb_info_signature":"bdd7c459dbae4634650fd5f7a6bab2f89f4c081e043ecd76dda8dd00362732b889ad8dcdb93daa18cdb5cda6d5aa9092908f5465863d390ebe23da7dd46a5f2f","qe_identity_issuer_chain":"-----BEGIN CERTIFICATE-----\nMIICjTCCAjKgAwIBAgIUfjiC1ftVKUpASY5FhAPpFJG99FUwCgYIKoZIzj0EAwIw\naDEaMBgGA1UEAwwRSW50ZWwgU0dYIFJvb3QgQ0ExGjAYBgNVBAoMEUludGVsIENv\ncnBvcmF0aW9uMRQwEgYDVQQHDAtTYW50YSBDbGFyYTELMAkGA1UECAwCQ0ExCzAJ\nBgNVBAYTAlVTMB4XDTI1MDUwNjA5MjUwMFoXDTMyMDUwNjA5MjUwMFowbDEeMBwG\nA1UEAwwVSW50ZWwgU0dYIFRDQiBTaWduaW5nMRowGAYDVQQKDBFJbnRlbCBDb3Jw\nb3JhdGlvbjEUMBIGA1UEBwwLU2FudGEgQ2xhcmExCzAJBgNVBAgMAkNBMQswCQYD\nVQQGEwJVUzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABENFG8xzydWRfK92bmGv\nP+mAh91PEyV7Jh6FGJd5ndE9aBH7R3E4A7ubrlh/zN3C4xvpoouGlirMba+W2lju\nypajgbUwgbIwHwYDVR0jBBgwFoAUImUM1lqdNInzg7SVUr9QGzknBqwwUgYDVR0f\nBEswSTBHoEWgQ4ZBaHR0cHM6Ly9jZXJ0aWZpY2F0ZXMudHJ1c3RlZHNlcnZpY2Vz\nLmludGVsLmNvbS9JbnRlbFNHWFJvb3RDQS5kZXIwHQYDVR0OBBYEFH44gtX7VSlK\nQEmORYQD6RSRvfRVMA4GA1UdDwEB/wQEAwIGwDAMBgNVHRMBAf8EAjAAMAoGCCqG\nSM49BAMCA0kAMEYCIQDdmmRuAo3qCO8TC1IoJMITAoOEw4dlgEBHzSz1TuMSTAIh\nAKVTqOkt59+co0O3m3hC+v5Fb00FjYWcgeu3EijOULo5\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIICjzCCAjSgAwIBAgIUImUM1lqdNInzg7SVUr9QGzknBqwwCgYIKoZIzj0EAwIw\naDEaMBgGA1UEAwwRSW50ZWwgU0dYIFJvb3QgQ0ExGjAYBgNVBAoMEUludGVsIENv\ncnBvcmF0aW9uMRQwEgYDVQQHDAtTYW50YSBDbGFyYTELMAkGA1UECAwCQ0ExCzAJ\nBgNVBAYTAlVTMB4XDTE4MDUyMTEwNDUxMFoXDTQ5MTIzMTIzNTk1OVowaDEaMBgG\nA1UEAwwRSW50ZWwgU0dYIFJvb3QgQ0ExGjAYBgNVBAoMEUludGVsIENvcnBvcmF0\naW9uMRQwEgYDVQQHDAtTYW50YSBDbGFyYTELMAkGA1UECAwCQ0ExCzAJBgNVBAYT\nAlVTMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEC6nEwMDIYZOj/iPWsCzaEKi7\n1OiOSLRFhWGjbnBVJfVnkY4u3IjkDYYL0MxO4mqsyYjlBalTVYxFP2sJBK5zlKOB\nuzCBuDAfBgNVHSMEGDAWgBQiZQzWWp00ifODtJVSv1AbOScGrDBSBgNVHR8ESzBJ\nMEegRaBDhkFodHRwczovL2NlcnRpZmljYXRlcy50cnVzdGVkc2VydmljZXMuaW50\nZWwuY29tL0ludGVsU0dYUm9vdENBLmRlcjAdBgNVHQ4EFgQUImUM1lqdNInzg7SV\nUr9QGzknBqwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQEwCgYI\nKoZIzj0EAwIDSQAwRgIhAOW/5QkR+S9CiSDcNoowLuPRLsWGf/Yi7GSX94BgwTwg\nAiEA4J0lrHoMs+Xo5o/sX6O9QWxHRAvZUGOdRQ7cvqRXaqI=\n-----END CERTIFICATE-----\n","qe_identity":"{\"id\":\"TD_QE\",\"version\":2,\"issueDate\":\"2026-02-18T10:42:15Z\",\"nextUpdate\":\"2026-03-20T10:42:15Z\",\"tcbEvaluationDataNumber\":18,\"miscselect\":\"00000000\",\"miscselectMask\":\"FFFFFFFF\",\"attributes\":\"11000000000000000000000000000000\",\"attributesMask\":\"FBFFFFFFFFFFFFFF0000000000000000\",\"mrsigner\":\"DC9E2A7C6F948F17474E34A7FC43ED030F7C1563F1BABDDF6340C82E0E54A8C5\",\"isvprodid\":2,\"tcbLevels\":[{\"tcb\":{\"isvsvn\":4},\"tcbDate\":\"2024-11-13T00:00:00Z\",\"tcbStatus\":\"UpToDate\"}]}","qe_identity_signature":"85037e9d4dbf39bc6f7f404e29fdf920e96d9e6e6f4afd288fcbe085c59bd52f09da1c284f13bbd342f2c787b6dba3003db958a75134b136bca068272f2392bf"} diff --git a/tinycloud-at-rest-escrow/Cargo.toml b/tinycloud-at-rest-escrow/Cargo.toml new file mode 100644 index 00000000..d343522f --- /dev/null +++ b/tinycloud-at-rest-escrow/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "tinycloud-at-rest-escrow" +version = "0.1.0" +edition = "2021" +publish = false +description = "Escrow of the TinyCloud at-rest root key: 2-of-3 Shamir shares sealed to pinned age recipients (TC-887)" + +[dependencies] +# Pinned exactly: share and bundle formats depend on these crates' behaviour. +age = { version = "=0.12.1", default-features = false, features = ["armor"] } +vsss-rs = { version = "=5.4.0", default-features = false, features = ["std", "zeroize"] } +# age-plugin-yubikey's `piv-p256` stanza (see src/piv.rs); same versions age uses. +age-core = "=0.12.0" +p256 = { version = "0.13", features = ["ecdh"] } +base64 = { workspace = true } +hkdf = "0.12" +hmac = "0.12" +sha2 = "0.10" +subtle = "2.6" +zeroize = { version = "1", features = ["derive"] } +rand = "0.8" +serde = { workspace = true } +serde_json = { workspace = true } +hex = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +# Software P-256 identities that open `p256tag` and `piv-p256` stanzas, so +# tests can round-trip shares sealed to hardware-key recipients. +hpke = { version = "0.12", default-features = false, features = ["alloc", "p256"] } +bech32 = "0.11" diff --git a/tinycloud-at-rest-escrow/src/bundle.rs b/tinycloud-at-rest-escrow/src/bundle.rs new file mode 100644 index 00000000..5f60781f --- /dev/null +++ b/tinycloud-at-rest-escrow/src/bundle.rs @@ -0,0 +1,586 @@ +//! The escrow bundle: three sealed shares, their commitments and the canary. + +use std::io::Write; + +use serde::{Deserialize, Serialize}; +use vsss_rs::Gf256; +use zeroize::Zeroizing; + +use crate::recipients::{ + parse, validate_holders, HolderRecipient, ParsedRecipient, RecipientKind, RecipientPolicy, +}; +use crate::share::{SharePayload, PAYLOAD_LEN}; +use crate::{ + canary_fingerprint, lp, sha256, Ark, ArkId, Digest32, Efp, EscrowError, HOLDERS, THRESHOLD, +}; + +const BUNDLE_VERSION: u32 = 1; +const EFP_DOMAIN: &[u8] = b"tinycloud/at-rest/escrow-bundle/v1"; +const ARMOR_BEGIN: &str = "-----BEGIN AGE ENCRYPTED FILE-----"; +/// An armored age file for a 58-byte payload and one stanza is under 1 KiB. +const MAX_CIPHERTEXT: usize = 4096; + +/// One holder's entry in a bundle. Public data. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EscrowShare { + /// 1..=3, the Shamir x-coordinate. + pub index: u8, + pub holder: String, + pub recipient: String, + /// See [`SharePayload::commitment`]. + pub commitment: Digest32, + /// ASCII-armored age file sealed to `recipient`. Save it as a file and + /// open it with `age -d -i `. + pub ciphertext: String, +} + +/// Everything needed to recover one ARK generation from two holders. Public +/// data: it is stored next to the node's data and handed to the holders. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EscrowBundle { + pub version: u32, + pub ark_id: ArkId, + pub generation: u32, + pub threshold: u8, + /// See [`canary_fingerprint`]. + pub canary_fp: Digest32, + pub shares: Vec, +} + +/// Proof that every pair of shares rebuilt the in-TEE ARK. Only +/// [`EscrowBundle::test_unlock`] makes one, so a +/// [`VerificationRecord`](crate::VerificationRecord) can't be sealed without +/// a real test-unlock. +#[derive(Debug)] +pub struct TestUnlock { + pub(crate) efp: Efp, + pub(crate) pairs: Vec<[u8; 2]>, +} + +impl TestUnlock { + pub fn efp(&self) -> Efp { + self.efp + } +} + +pub(crate) fn seal_armored( + recipient: &ParsedRecipient, + plaintext: &[u8], +) -> Result { + let age_err = |e: &dyn std::fmt::Display| EscrowError::Age(e.to_string()); + let encryptor = age::Encryptor::with_recipients(std::iter::once(recipient.as_age())) + .map_err(|e| age_err(&e))?; + let mut out = Vec::new(); + let armor = age::armor::ArmoredWriter::wrap_output(&mut out, age::armor::Format::AsciiArmor) + .map_err(|e| age_err(&e))?; + let mut writer = encryptor.wrap_output(armor).map_err(|e| age_err(&e))?; + writer.write_all(plaintext).map_err(|e| age_err(&e))?; + writer + .finish() + .and_then(|armor| armor.finish()) + .map_err(|e| age_err(&e))?; + String::from_utf8(out).map_err(|_| EscrowError::Age("armor is not UTF-8".into())) +} + +/// Combine two shares. The caller has already checked them against the bundle. +fn combine(a: &SharePayload, b: &SharePayload) -> Result { + let shares = Zeroizing::new(vec![a.share.to_vec(), b.share.to_vec()]); + let secret = Zeroizing::new(Gf256::combine_array(&*shares).map_err(|_| EscrowError::Shamir)?); + Ark::from_slice(&secret).ok_or(EscrowError::Shamir) +} + +impl EscrowBundle { + /// TEE only. Split `ark` 2-of-3 and seal share i to `holders[i]`. + /// Software (X25519) holders are refused unless `policy` allows them. + /// + /// Before returning, every pair of fresh shares is combined and compared + /// with `ark`, so a split that can't be recovered never leaves this call. + pub fn create( + ark: &Ark, + ark_id: ArkId, + generation: u32, + holders: &[HolderRecipient], + policy: RecipientPolicy, + ) -> Result { + validate_holders(holders, policy)?; + let split = Zeroizing::new( + Gf256::split_array( + usize::from(THRESHOLD), + usize::from(HOLDERS), + ark.expose(), + rand::rngs::OsRng, + ) + .map_err(|_| EscrowError::Shamir)?, + ); + let payloads = split + .iter() + .map(|s| SharePayload::new(ark_id, generation, s)) + .collect::, _>>()?; + let mut shares = Vec::with_capacity(holders.len()); + for (i, (holder, payload)) in holders.iter().zip(&payloads).enumerate() { + if usize::from(payload.index) != i + 1 { + return Err(EscrowError::Shamir); + } + let recipient = parse(&holder.holder, &holder.recipient)?; + shares.push(EscrowShare { + index: payload.index, + holder: holder.holder.clone(), + recipient: holder.recipient.clone(), + commitment: payload.commitment(), + ciphertext: seal_armored(&recipient, &payload.encode())?, + }); + } + let bundle = Self { + version: BUNDLE_VERSION, + ark_id, + generation, + threshold: THRESHOLD, + canary_fp: canary_fingerprint(ark, &ark_id, generation), + shares, + }; + bundle.validate()?; + bundle.test_unlock(ark, &payloads)?; + Ok(bundle) + } + + /// Parse a bundle: version first, then strict fields, then [`validate`](Self::validate). + pub fn from_json(bytes: &[u8]) -> Result { + let bundle: Self = crate::parse_versioned(bytes, "escrow bundle", BUNDLE_VERSION)?; + bundle.validate()?; + Ok(bundle) + } + + /// Structural checks. Every other method calls this first. Any recipient + /// kind passes here; apply a policy with [`check_recipients`](Self::check_recipients). + pub fn validate(&self) -> Result<(), EscrowError> { + if self.version != BUNDLE_VERSION { + return Err(EscrowError::Bundle("unsupported version")); + } + if self.threshold != THRESHOLD { + return Err(EscrowError::Bundle("threshold must be 2")); + } + if self.shares.len() != usize::from(HOLDERS) { + return Err(EscrowError::Bundle("bundle must hold exactly 3 shares")); + } + for (i, share) in self.shares.iter().enumerate() { + if usize::from(share.index) != i + 1 { + return Err(EscrowError::Bundle( + "shares must be indexed 1, 2, 3 in order", + )); + } + if share.ciphertext.len() > MAX_CIPHERTEXT || !share.ciphertext.starts_with(ARMOR_BEGIN) + { + return Err(EscrowError::Bundle( + "share ciphertext is not an armored age file", + )); + } + } + validate_holders(&self.holders(), RecipientPolicy::AllowSoftware)?; + Ok(()) + } + + /// The holders' recipient kinds, refusing software holders unless + /// `policy` allows them. + pub fn check_recipients( + &self, + policy: RecipientPolicy, + ) -> Result, EscrowError> { + self.validate()?; + validate_holders(&self.holders(), policy) + } + + /// The holders and recipients, in index order. + pub fn holders(&self) -> Vec { + self.shares + .iter() + .map(|s| HolderRecipient { + holder: s.holder.clone(), + recipient: s.recipient.clone(), + }) + .collect() + } + + pub fn share_for(&self, holder: &str) -> Option<&EscrowShare> { + self.shares.iter().find(|s| s.holder == holder) + } + + /// The escrow fingerprint: + /// `SHA-256(lp(domain) ‖ version ‖ ark_id ‖ generation ‖ threshold ‖ canary_fp ‖ n ‖ + /// for each share: index ‖ lp(holder) ‖ lp(recipient) ‖ commitment ‖ SHA-256(ciphertext))`. + pub fn fingerprint(&self) -> Efp { + let mut pre = Vec::with_capacity(512); + lp(&mut pre, EFP_DOMAIN); + pre.extend_from_slice(&self.version.to_be_bytes()); + pre.extend_from_slice(&self.ark_id.0); + pre.extend_from_slice(&self.generation.to_be_bytes()); + pre.push(self.threshold); + pre.extend_from_slice(&self.canary_fp.0); + pre.push(u8::try_from(self.shares.len()).unwrap_or(u8::MAX)); + for s in &self.shares { + pre.push(s.index); + lp(&mut pre, s.holder.as_bytes()); + lp(&mut pre, s.recipient.as_bytes()); + pre.extend_from_slice(&s.commitment.0); + pre.extend_from_slice(&sha256(s.ciphertext.as_bytes())); + } + Digest32(sha256(&pre)) + } + + /// Check one decrypted share against this bundle without a second share. + pub fn verify_share(&self, payload: &SharePayload) -> Result<(), EscrowError> { + self.validate()?; + if payload.ark_id != self.ark_id || payload.generation != self.generation { + return Err(EscrowError::ShareMismatch); + } + let entry = &self.shares[usize::from(payload.index) - 1]; + if entry.commitment != payload.commitment() { + return Err(EscrowError::Commitment { + index: payload.index, + }); + } + Ok(()) + } + + /// Parse and check a share as decrypted by a holder (`age -d` output). + pub fn open_share(&self, decrypted: &[u8]) -> Result { + if decrypted.len() != PAYLOAD_LEN { + return Err(EscrowError::ShareFormat); + } + let payload = SharePayload::decode(decrypted)?; + self.verify_share(&payload)?; + Ok(payload) + } + + /// TEE only (the result is the ARK). Rebuild the ARK from two distinct, + /// committed shares and check it against the canary. + pub fn recover(&self, a: &SharePayload, b: &SharePayload) -> Result { + self.verify_share(a)?; + self.verify_share(b)?; + if a.index == b.index { + return Err(EscrowError::DuplicateIndex { index: a.index }); + } + let ark = combine(a, b)?; + if canary_fingerprint(&ark, &self.ark_id, self.generation) != self.canary_fp { + return Err(EscrowError::Canary); + } + Ok(ark) + } + + /// TEE only. The ceremony check: given all three shares, every pair must + /// rebuild exactly `ark` (constant-time compare) and its canary. + pub fn test_unlock( + &self, + ark: &Ark, + shares: &[SharePayload], + ) -> Result { + self.validate()?; + if canary_fingerprint(ark, &self.ark_id, self.generation) != self.canary_fp { + return Err(EscrowError::Canary); + } + let mut by_index: [Option<&SharePayload>; HOLDERS as usize] = [None; HOLDERS as usize]; + for share in shares { + self.verify_share(share)?; + let slot = &mut by_index[usize::from(share.index) - 1]; + if slot.is_some() { + return Err(EscrowError::DuplicateIndex { index: share.index }); + } + *slot = Some(share); + } + let [Some(s1), Some(s2), Some(s3)] = by_index else { + return Err(EscrowError::Holders( + "test-unlock needs the shares of all three holders".into(), + )); + }; + let mut pairs = Vec::with_capacity(3); + for (a, b) in [(s1, s2), (s1, s3), (s2, s3)] { + if !self.recover(a, b)?.ct_eq(ark) { + return Err(EscrowError::Canary); + } + pairs.push([a.index, b.index]); + } + Ok(TestUnlock { + efp: self.fingerprint(), + pairs, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::*; + + type Mutation = Box; + + #[test] + fn create_then_two_holders_recover_and_one_cannot() { + let (ark, ark_id, holders, ids) = escrow_fixture(); + let bundle = + EscrowBundle::create(&ark, ark_id, 1, &holders, RecipientPolicy::AllowSoftware) + .unwrap(); + let shares = open_all(&bundle, &ids); + + for (a, b) in [(0, 1), (0, 2), (1, 2), (2, 0)] { + assert!(bundle.recover(&shares[a], &shares[b]).unwrap().ct_eq(&ark)); + } + // One share, twice, is still one share. + assert!(matches!( + bundle.recover(&shares[0], &shares[0]), + Err(EscrowError::DuplicateIndex { index: 1 }) + )); + } + + #[test] + fn a_single_share_is_independent_of_the_ark() { + // Same share-1 polynomial evaluation could come from any ARK: with + // threshold 2 the y-bytes of one share are uniform. Check that two + // splits of the same ARK give unrelated share bytes. + let (ark, ark_id, holders, ids) = escrow_fixture(); + let a = open_all( + &EscrowBundle::create(&ark, ark_id, 1, &holders, RecipientPolicy::AllowSoftware) + .unwrap(), + &ids, + ); + let b = open_all( + &EscrowBundle::create(&ark, ark_id, 1, &holders, RecipientPolicy::AllowSoftware) + .unwrap(), + &ids, + ); + assert_ne!(a[0].share.as_slice(), b[0].share.as_slice()); + assert_ne!(&a[0].share[1..], ark.expose().as_slice()); + } + + #[test] + fn tampered_share_or_wrong_ark_fails_before_combining() { + let (ark, ark_id, holders, ids) = escrow_fixture(); + let bundle = + EscrowBundle::create(&ark, ark_id, 1, &holders, RecipientPolicy::AllowSoftware) + .unwrap(); + let shares = open_all(&bundle, &ids); + + let mut flipped = shares[1].clone(); + flipped.share[5] ^= 0x40; + assert!(matches!( + bundle.recover(&shares[0], &flipped), + Err(EscrowError::Commitment { index: 2 }) + )); + + let other = EscrowBundle::create( + &Ark::generate(), + ArkId::generate(), + 1, + &holders, + RecipientPolicy::AllowSoftware, + ) + .unwrap(); + let foreign = open_all(&other, &ids); + assert!(matches!( + bundle.recover(&shares[0], &foreign[1]), + Err(EscrowError::ShareMismatch) + )); + } + + #[test] + fn forged_commitment_still_fails_the_canary() { + // An attacker who controls the stored bundle can rewrite a commitment + // to match a junk share. The canary still rejects the combination. + let (ark, ark_id, holders, ids) = escrow_fixture(); + let mut bundle = + EscrowBundle::create(&ark, ark_id, 1, &holders, RecipientPolicy::AllowSoftware) + .unwrap(); + let shares = open_all(&bundle, &ids); + let mut junk = shares[1].clone(); + junk.share[7] ^= 1; + bundle.shares[1].commitment = junk.commitment(); + assert!(matches!( + bundle.recover(&shares[0], &junk), + Err(EscrowError::Canary) + )); + } + + #[test] + fn test_unlock_needs_all_three_and_the_same_ark() { + let (ark, ark_id, holders, ids) = escrow_fixture(); + let bundle = + EscrowBundle::create(&ark, ark_id, 1, &holders, RecipientPolicy::AllowSoftware) + .unwrap(); + let shares = open_all(&bundle, &ids); + + let ok = bundle.test_unlock(&ark, &shares).unwrap(); + assert_eq!(ok.efp(), bundle.fingerprint()); + assert_eq!(ok.pairs, vec![[1, 2], [1, 3], [2, 3]]); + + assert!(bundle.test_unlock(&ark, &shares[..2]).is_err()); + let dup = [shares[0].clone(), shares[0].clone(), shares[2].clone()]; + assert!(matches!( + bundle.test_unlock(&ark, &dup), + Err(EscrowError::DuplicateIndex { index: 1 }) + )); + assert!(matches!( + bundle.test_unlock(&Ark::generate(), &shares), + Err(EscrowError::Canary) + )); + } + + #[test] + fn fingerprint_covers_recipients_commitments_ciphertexts_and_canary() { + let (ark, ark_id, holders, _) = escrow_fixture(); + let bundle = + EscrowBundle::create(&ark, ark_id, 1, &holders, RecipientPolicy::AllowSoftware) + .unwrap(); + let efp = bundle.fingerprint(); + let mutations: Vec = vec![ + Box::new(|b| b.shares[0].recipient = x25519_identity().to_public().to_string()), + Box::new(|b| b.shares[1].holder = "mallory".into()), + Box::new(|b| b.shares[2].commitment.0[0] ^= 1), + Box::new(|b| b.shares[0].ciphertext.push('\n')), + Box::new(|b| b.canary_fp.0[31] ^= 1), + Box::new(|b| b.generation = 2), + Box::new(|b| b.ark_id.0[0] ^= 1), + ]; + for mutate in mutations { + let mut changed = bundle.clone(); + mutate(&mut changed); + assert_ne!(changed.fingerprint(), efp); + } + // Stable across a JSON round trip (what holders and the node exchange). + let json = serde_json::to_string(&bundle).unwrap(); + let back: EscrowBundle = serde_json::from_str(&json).unwrap(); + assert_eq!(back.fingerprint(), efp); + } + + #[test] + fn validate_rejects_malformed_bundles() { + let (ark, ark_id, holders, _) = escrow_fixture(); + let bundle = + EscrowBundle::create(&ark, ark_id, 1, &holders, RecipientPolicy::AllowSoftware) + .unwrap(); + let mutations: Vec = vec![ + Box::new(|b| b.version = 2), + Box::new(|b| b.threshold = 1), + Box::new(|b| { + b.shares.pop(); + }), + Box::new(|b| b.shares.swap(0, 1)), + Box::new(|b| b.shares[0].ciphertext = "not age".into()), + Box::new(|b| b.shares[2].holder = b.shares[0].holder.clone()), + ]; + for mutate in mutations { + let mut bad = bundle.clone(); + mutate(&mut bad); + assert!(bad.validate().is_err()); + } + let unknown = serde_json::to_string(&bundle) + .unwrap() + .replacen('{', "{\"extra\":1,", 1); + assert!(serde_json::from_str::(&unknown).is_err()); + } + + #[test] + fn hardware_shares_open_with_their_own_p256_key_only() { + let (ark, ark_id, holders, ids) = escrow_fixture(); + let bundle = + EscrowBundle::create(&ark, ark_id, 1, &holders, RecipientPolicy::AllowSoftware) + .unwrap(); + // Share 1 is piv-p256 (age1yubikey1), share 2 is p256tag (age1tag1). + assert!(bundle.shares[0].recipient.starts_with("age1yubikey1")); + assert!(bundle.shares[1].recipient.starts_with("age1tag1")); + for (i, j) in [(0, 1), (1, 0), (0, 2), (1, 2)] { + assert!( + ids[j].open(&bundle.shares[i].ciphertext).is_err(), + "{j} opened {i}" + ); + } + for (i, (id, entry)) in ids.iter().zip(&bundle.shares).take(2).enumerate() { + let share = bundle + .open_share(&id.open(&entry.ciphertext).unwrap()) + .unwrap(); + assert_eq!(usize::from(share.index), i + 1); + } + } + + #[test] + fn software_holders_need_the_explicit_policy() { + let (ark, ark_id, holders, _) = escrow_fixture(); + assert!(matches!( + EscrowBundle::create(&ark, ark_id, 1, &holders, RecipientPolicy::HardwareOnly), + Err(EscrowError::Recipient { holder, .. }) if holder == "safe" + )); + let bundle = + EscrowBundle::create(&ark, ark_id, 1, &holders, RecipientPolicy::AllowSoftware) + .unwrap(); + assert!(bundle + .check_recipients(RecipientPolicy::HardwareOnly) + .is_err()); + assert_eq!( + bundle + .check_recipients(RecipientPolicy::AllowSoftware) + .unwrap(), + vec![ + RecipientKind::PivP256, + RecipientKind::P256Tag, + RecipientKind::X25519 + ] + ); + } + + #[test] + fn newer_versions_are_reported_as_versions_not_unknown_fields() { + let (ark, ark_id, holders, _) = escrow_fixture(); + let bundle = + EscrowBundle::create(&ark, ark_id, 1, &holders, RecipientPolicy::AllowSoftware) + .unwrap(); + let json = serde_json::to_vec(&bundle).unwrap(); + assert_eq!(EscrowBundle::from_json(&json).unwrap(), bundle); + + let mut v2: serde_json::Value = serde_json::from_slice(&json).unwrap(); + v2["version"] = 2.into(); + v2["kdf"] = "new".into(); + let err = EscrowBundle::from_json(&serde_json::to_vec(&v2).unwrap()).unwrap_err(); + assert!( + matches!( + err, + EscrowError::Version { + found: 2, + supported: 1, + .. + } + ), + "{err}" + ); + // Duplicate keys are refused (the typed parse sees them all). + let dup = String::from_utf8(json.clone()).unwrap().replacen( + "\"generation\":1", + "\"generation\":7,\"generation\":1", + 1, + ); + assert!(matches!( + EscrowBundle::from_json(dup.as_bytes()), + Err(EscrowError::Json { .. }) + )); + // Same version with an unknown field is still refused. + let mut extra: serde_json::Value = serde_json::from_slice(&json).unwrap(); + extra["kdf"] = "new".into(); + assert!(matches!( + EscrowBundle::from_json(&serde_json::to_vec(&extra).unwrap()), + Err(EscrowError::Json { .. }) + )); + assert!(matches!( + EscrowBundle::from_json(b"{}"), + Err(EscrowError::Json { .. }) + )); + + let info = serde_json::json!({"version": 9, "mode": "dstack", "future": true}); + assert!(matches!( + crate::RecoveryInfo::from_json(info.to_string().as_bytes()), + Err(EscrowError::Version { found: 9, .. }) + )); + let record = serde_json::json!({"version": 3, "efp": "x"}); + assert!(matches!( + crate::VerificationRecord::from_json(record.to_string().as_bytes()), + Err(EscrowError::Version { found: 3, .. }) + )); + } +} diff --git a/tinycloud-at-rest-escrow/src/lib.rs b/tinycloud-at-rest-escrow/src/lib.rs new file mode 100644 index 00000000..b65d89b9 --- /dev/null +++ b/tinycloud-at-rest-escrow/src/lib.rs @@ -0,0 +1,311 @@ +//! Escrow of the TinyCloud at-rest root key (ARK), TC-887. +//! +//! The node makes one random 32-byte ARK inside the TEE. This crate splits it +//! 2-of-3 with Shamir over GF(256), seals each share with age to a holder +//! recipient pinned in the node's config, and describes the result as an +//! [`EscrowBundle`]. The bundle's fingerprint ([`Efp`]) is what operators pin +//! once a ceremony has proven every share. Two shares rebuild the ARK; one +//! share reveals nothing about it. +//! +//! Three operations need the ARK and therefore run only inside the TEE: +//! [`EscrowBundle::create`], [`EscrowBundle::test_unlock`] and +//! [`EscrowBundle::recover`]. Everything a holder's machine does (inspecting +//! a bundle, checking a decrypted share against its commitment, re-sealing it +//! to a TEE's per-boot recovery recipient) uses public data only. +//! +//! All derivations use length-prefixed fields: `lp(x) = u32 BE len ‖ x`. + +#![forbid(unsafe_code)] + +mod bundle; +mod piv; +mod recipients; +mod record; +mod recovery; +mod share; + +use std::fmt; +use std::str::FromStr; + +use hkdf::Hkdf; +use sha2::{Digest, Sha256}; +use subtle::ConstantTimeEq; +use zeroize::Zeroizing; + +pub use bundle::{EscrowBundle, EscrowShare, TestUnlock}; +pub use recipients::{ + parse_recipient, validate_holders, HolderRecipient, RecipientKind, RecipientPolicy, +}; +pub use record::VerificationRecord; +pub use recovery::{ + binding_digest, recovery_binding, validate_nonce, wrap_share, RecoveryIdentity, RecoveryInfo, + MAX_WRAPPED_SHARE, RECOVERY_INFO_VERSION, +}; +pub use share::SharePayload; + +/// Shares needed to rebuild the ARK. +pub const THRESHOLD: u8 = 2; +/// Shares made, one per holder. +pub const HOLDERS: u8 = 3; + +/// Errors from escrow operations. None of them carry key or share bytes. +#[derive(Debug, thiserror::Error)] +pub enum EscrowError { + #[error("escrow holders: {0}")] + Holders(String), + #[error("recipient for holder {holder:?}: {reason}")] + Recipient { holder: String, reason: String }, + #[error("escrow bundle: {0}")] + Bundle(&'static str), + #[error("share is malformed")] + ShareFormat, + #[error("share belongs to a different ARK or generation")] + ShareMismatch, + #[error("share {index} does not match its commitment in this bundle: the share may come from a different escrow (a re-split) of the same key; check the bundle's EFP")] + Commitment { index: u8 }, + #[error("{what} is not valid: {reason}")] + Json { what: &'static str, reason: String }, + #[error("{what} is version {found}, but this tool reads version {supported}; use the tool built from the release that matches the node")] + Version { + what: &'static str, + found: u64, + supported: u32, + }, + #[error("two shares with index {index}")] + DuplicateIndex { index: u8 }, + #[error("combined shares do not reproduce the escrowed key (canary mismatch)")] + Canary, + #[error("Shamir split/combine failed")] + Shamir, + #[error("age: {0}")] + Age(String), + #[error("verification record: {0}")] + Record(&'static str), + #[error("recovery binding: {0}")] + Binding(&'static str), +} + +/// The at-rest root key. Zeroized on drop; `Debug` never prints it. +#[derive(Clone)] +pub struct Ark(Zeroizing<[u8; 32]>); + +impl Ark { + /// Take ownership of key bytes already in a zeroizing container. + pub fn from_zeroizing(bytes: Zeroizing<[u8; 32]>) -> Self { + Self(bytes) + } + + /// Copy 32 key bytes straight into a zeroizing buffer; `None` for any + /// other length. No plain array copy is made. + pub fn from_slice(bytes: &[u8]) -> Option { + if bytes.len() != 32 { + return None; + } + let mut key = Zeroizing::new([0u8; 32]); + key.copy_from_slice(bytes); + Some(Self(key)) + } + + /// A fresh key from the OS CSPRNG. + pub fn generate() -> Self { + let mut bytes = Zeroizing::new([0u8; 32]); + rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, bytes.as_mut()); + Self(bytes) + } + + pub fn expose(&self) -> &[u8; 32] { + &self.0 + } + + /// Constant-time equality. + pub fn ct_eq(&self, other: &Ark) -> bool { + self.0.ct_eq(&*other.0).into() + } + + /// HKDF-SHA256 with `salt = ark_id`. Every escrow-side key comes from here. + pub(crate) fn derive(&self, ark_id: &ArkId, info: &[u8]) -> Zeroizing<[u8; 32]> { + let hk = Hkdf::::new(Some(&ark_id.0), self.expose()); + let mut out = Zeroizing::new([0u8; 32]); + hk.expand(info, out.as_mut()) + .expect("32 bytes is a valid HKDF-SHA256 length"); + out + } +} + +impl fmt::Debug for Ark { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Ark()") + } +} + +/// Random 16-byte id of one ARK generation. Hex on the wire. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct ArkId(pub [u8; 16]); + +impl ArkId { + pub fn generate() -> Self { + let mut id = [0u8; 16]; + rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut id); + Self(id) + } +} + +impl fmt::Display for ArkId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&hex::encode(self.0)) + } +} + +impl fmt::Debug for ArkId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "ArkId({self})") + } +} + +impl FromStr for ArkId { + type Err = EscrowError; + fn from_str(s: &str) -> Result { + Ok(Self(hex32::<16>(s).ok_or(EscrowError::Bundle( + "ark_id must be 32 hex chars", + ))?)) + } +} + +impl serde::Serialize for ArkId { + fn serialize(&self, s: S) -> Result { + s.collect_str(self) + } +} + +impl<'de> serde::Deserialize<'de> for ArkId { + fn deserialize>(d: D) -> Result { + let s = String::deserialize(d)?; + s.parse().map_err(serde::de::Error::custom) + } +} + +/// A public 32-byte digest (fingerprint, commitment, canary). Lowercase hex on +/// the wire; equality is constant-time. +#[derive(Clone, Copy, Eq)] +pub struct Digest32(pub [u8; 32]); + +impl PartialEq for Digest32 { + fn eq(&self, other: &Self) -> bool { + self.0.ct_eq(&other.0).into() + } +} + +impl fmt::Display for Digest32 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&hex::encode(self.0)) + } +} + +impl fmt::Debug for Digest32 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{self}") + } +} + +impl FromStr for Digest32 { + type Err = EscrowError; + fn from_str(s: &str) -> Result { + Ok(Self(hex32::<32>(s).ok_or(EscrowError::Bundle( + "digest must be 64 hex chars", + ))?)) + } +} + +impl serde::Serialize for Digest32 { + fn serialize(&self, s: S) -> Result { + s.collect_str(self) + } +} + +impl<'de> serde::Deserialize<'de> for Digest32 { + fn deserialize>(d: D) -> Result { + let s = String::deserialize(d)?; + s.parse().map_err(serde::de::Error::custom) + } +} + +/// Escrow fingerprint: SHA-256 over the canonical encoding of a bundle, +/// including its recipients, commitments, ciphertext digests and canary. +/// This is the value pinned as `at_rest.escrow.verified_fp`. +pub type Efp = Digest32; + +/// Lowercase-only hex of exactly `N` bytes. +fn hex32(s: &str) -> Option<[u8; N]> { + if s.len() != N * 2 || s.bytes().any(|b| b.is_ascii_uppercase()) { + return None; + } + let mut out = [0u8; N]; + hex::decode_to_slice(s, &mut out).ok()?; + Some(out) +} + +pub(crate) fn lp(out: &mut Vec, bytes: &[u8]) { + let len = u32::try_from(bytes.len()).expect("field shorter than 4 GiB"); + out.extend_from_slice(&len.to_be_bytes()); + out.extend_from_slice(bytes); +} + +pub(crate) fn sha256(bytes: &[u8]) -> [u8; 32] { + Sha256::digest(bytes).into() +} + +/// Parse a versioned JSON document: read `version` first, so a newer format +/// is reported as such instead of as an unknown field; then parse the typed +/// struct strictly from the original bytes (duplicate keys and unknown +/// fields are refused). +pub(crate) fn parse_versioned( + bytes: &[u8], + what: &'static str, + supported: u32, +) -> Result { + #[derive(serde::Deserialize)] + struct Peek { + version: Option, + } + let json_err = |e: serde_json::Error| EscrowError::Json { + what, + reason: e.to_string(), + }; + let found = serde_json::from_slice::(bytes) + .map_err(json_err)? + .version + .ok_or_else(|| EscrowError::Json { + what, + reason: "no numeric `version` field".into(), + })?; + if found != u64::from(supported) { + return Err(EscrowError::Version { + what, + found, + supported, + }); + } + serde_json::from_slice(bytes).map_err(json_err) +} + +const CANARY_INFO: &[u8] = b"tinycloud/at-rest/escrow-canary/v1"; +const CANARY_FP_DOMAIN: &[u8] = b"tinycloud/at-rest/escrow-canary-fp/v1"; + +/// Public fingerprint of the ARK's canary key. A holder sees it in the bundle +/// before a test-unlock; the TEE reports it after rebuilding the ARK from +/// shares, so the two can be compared without the ARK leaving the TEE. +pub fn canary_fingerprint(ark: &Ark, ark_id: &ArkId, generation: u32) -> Digest32 { + let mut info = Vec::new(); + lp(&mut info, CANARY_INFO); + info.extend_from_slice(&generation.to_be_bytes()); + let canary = ark.derive(ark_id, &info); + let mut pre = Vec::new(); + lp(&mut pre, CANARY_FP_DOMAIN); + pre.extend_from_slice(ark_id.0.as_slice()); + pre.extend_from_slice(&generation.to_be_bytes()); + lp(&mut pre, canary.as_slice()); + Digest32(sha256(&pre)) +} + +#[cfg(test)] +pub(crate) mod test_support; diff --git a/tinycloud-at-rest-escrow/src/piv.rs b/tinycloud-at-rest-escrow/src/piv.rs new file mode 100644 index 00000000..3626ec02 --- /dev/null +++ b/tinycloud-at-rest-escrow/src/piv.rs @@ -0,0 +1,163 @@ +//! `age1yubikey1…` recipients: the `piv-p256` stanza of age-plugin-yubikey. +//! +//! Released age-plugin-yubikey (0.5.x) prints and decrypts only this type; +//! native `age1tag1` support is unreleased. Encrypting to it needs no +//! YubiKey, only the public key, so the TEE can seal shares to a YubiKey +//! without the plugin binary. This mirrors the plugin's own +//! `RecipientLine::wrap_file_key` (format.rs, 0.5.1): +//! +//! ```text +//! tag = SHA-256(pk_compressed)[..4] +//! epk = fresh P-256 key, compressed SEC-1 (33 bytes) +//! shared = ECDH(esk, pk).x +//! enc_key = HKDF-SHA256(salt = epk ‖ pk_compressed, ikm = shared, info = "piv-p256") +//! body = ChaCha20-Poly1305(enc_key, nonce = 0, file_key) (32 bytes) +//! stanza = "-> piv-p256 b64(tag) b64(epk)" + body +//! ``` + +use std::collections::HashSet; + +use age_core::{ + format::{FileKey, Stanza}, + primitives::{aead_encrypt, bech32_decode, hkdf}, + secrecy::ExposeSecret, +}; +use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine}; +use p256::{ + ecdh::EphemeralSecret, + elliptic_curve::sec1::{FromEncodedPoint, ToEncodedPoint}, + EncodedPoint, PublicKey, +}; +use sha2::{Digest, Sha256}; + +pub(crate) const HRP: &str = "age1yubikey"; +pub(crate) const STANZA_TAG: &str = "piv-p256"; +pub(crate) const STANZA_KEY_LABEL: &[u8] = b"piv-p256"; + +#[derive(Clone)] +pub(crate) struct PivRecipient(PublicKey); + +impl std::str::FromStr for PivRecipient { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + let bytes = bech32_decode( + s, + |_| "invalid Bech32 encoding", + |hrp| (hrp.as_str() == HRP).then_some(()).ok_or("incorrect HRP"), + |_, bytes| Ok(bytes.collect::>()), + )?; + let encoded = EncodedPoint::from_bytes(bytes).map_err(|_| "invalid SEC-1 encoding")?; + if !encoded.is_compressed() { + return Err("not a compressed SEC-1 encoding"); + } + PublicKey::from_encoded_point(&encoded) + .into_option() + .map(Self) + .ok_or("invalid P-256 point") + } +} + +impl PivRecipient { + fn compressed(&self) -> EncodedPoint { + self.0.to_encoded_point(true) + } +} + +impl age::Recipient for PivRecipient { + fn wrap_file_key( + &self, + file_key: &FileKey, + ) -> Result<(Vec, HashSet), age::EncryptError> { + let esk = EphemeralSecret::random(&mut rand::rngs::OsRng); + let epk = esk.public_key().to_encoded_point(true); + let shared = esk.diffie_hellman(&self.0); + let pk = self.compressed(); + + let mut salt = Vec::with_capacity(66); + salt.extend_from_slice(epk.as_bytes()); + salt.extend_from_slice(pk.as_bytes()); + let enc_key = hkdf(&salt, STANZA_KEY_LABEL, shared.raw_secret_bytes()); + let body = aead_encrypt(&enc_key, file_key.expose_secret()); + let tag = &Sha256::digest(pk.as_bytes())[..4]; + + Ok(( + vec![Stanza { + tag: STANZA_TAG.to_owned(), + args: vec![ + BASE64_STANDARD_NO_PAD.encode(tag), + BASE64_STANDARD_NO_PAD.encode(epk.as_bytes()), + ], + body, + }], + HashSet::new(), + )) + } +} + +#[cfg(test)] +mod tests { + use std::io::Read; + + use age::Identity as _; + + use super::*; + use crate::test_support::*; + + #[test] + fn the_released_plugins_output_opens_with_the_test_identity() { + let decryptor = + age::Decryptor::new_buffered(age::armor::ArmoredReader::new(PLUGIN_VECTOR.as_bytes())) + .unwrap(); + let identity = plugin_vector_identity(); + let mut plain = Vec::new(); + decryptor + .decrypt(std::iter::once(&identity as &dyn age::Identity)) + .unwrap() + .read_to_end(&mut plain) + .unwrap(); + assert_eq!(plain, PLUGIN_VECTOR_MESSAGE); + // Our recipient parser reads the plugin's recipient string. + assert!(PLUGIN_VECTOR_RECIPIENT.parse::().is_ok()); + } + + #[test] + fn our_stanza_has_the_plugins_shape_and_bad_shapes_are_refused() { + let identity = plugin_vector_identity(); + let recipient: PivRecipient = PLUGIN_VECTOR_RECIPIENT.parse().unwrap(); + let file_key = FileKey::new(Box::new([7u8; 16])); + let (stanzas, _) = age::Recipient::wrap_file_key(&recipient, &file_key).unwrap(); + let stanza = &stanzas[0]; + assert_eq!(stanza.tag, "piv-p256"); + assert_eq!((stanza.args[0].len(), stanza.args[1].len()), (6, 44)); + assert_eq!(stanza.body.len(), 32); + let opened = identity.unwrap_stanza(stanza).unwrap().unwrap(); + assert_eq!(opened.expose_secret(), &[7u8; 16]); + + // What the plugin rejects as structurally invalid, the identity does too. + let epk = BASE64_STANDARD_NO_PAD.decode(&stanza.args[1]).unwrap(); + let point = PublicKey::from_sec1_bytes(&epk).unwrap(); + let uncompressed = BASE64_STANDARD_NO_PAD.encode(point.to_encoded_point(false).as_bytes()); + type Mutation = Box; + let mutations: Vec = vec![ + Box::new(move |s| s.args[1] = uncompressed.clone()), + Box::new(|s| s.args[0].push('A')), + Box::new(|s| { + s.args.pop(); + }), + Box::new(|s| s.body.push(0)), + ]; + for mutate in mutations { + let mut bad = Stanza { + tag: stanza.tag.clone(), + args: stanza.args.clone(), + body: stanza.body.clone(), + }; + mutate(&mut bad); + assert!(matches!(identity.unwrap_stanza(&bad), Some(Err(_)))); + } + // Another key's stanza is not ours. + let (other, _) = piv_identity(); + assert!(other.unwrap_stanza(stanza).is_none()); + } +} diff --git a/tinycloud-at-rest-escrow/src/recipients.rs b/tinycloud-at-rest-escrow/src/recipients.rs new file mode 100644 index 00000000..cf36dc11 --- /dev/null +++ b/tinycloud-at-rest-escrow/src/recipients.rs @@ -0,0 +1,245 @@ +//! Holder recipients, as pinned in the node's `at_rest.escrow.recipients`. + +use std::collections::HashSet; + +use serde::{Deserialize, Serialize}; + +use crate::piv::PivRecipient; +use crate::{EscrowError, HOLDERS}; + +/// One escrow holder: a label and the age recipient their share is sealed to. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HolderRecipient { + /// 1-32 chars of `a-z 0-9 - _`, unique within the escrow. + pub holder: String, + /// See [`RecipientKind`]. + pub recipient: String, +} + +/// The recipient format. The format says which kind of key can decrypt; it +/// does not prove where the private key lives. That a P-256 key was made on +/// a YubiKey is established in the escrow ceremony, not here. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RecipientKind { + /// `age1yubikey1…` (`piv-p256` stanza): what released age-plugin-yubikey + /// prints and decrypts. + PivP256, + /// `age1tag1…` (`p256tag` stanza): native age tagged P-256 recipient, + /// meant for hardware keys. + P256Tag, + /// `age1…` X25519: a software key file. Test and self-host only. + X25519, +} + +impl RecipientKind { + pub fn is_software(self) -> bool { + self == Self::X25519 + } +} + +/// Which recipient kinds an escrow may use. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum RecipientPolicy { + /// P-256 key formats only (`age1yubikey1`, `age1tag1`). The default. + #[default] + HardwareOnly, + /// Also X25519 software keys. Only for tests and self-hosted nodes that + /// explicitly opt in; tools warn loudly whenever it is in effect. + AllowSoftware, +} + +pub(crate) enum ParsedRecipient { + Piv(PivRecipient), + Tag(age::tag::Recipient), + X25519(age::x25519::Recipient), +} + +impl ParsedRecipient { + pub(crate) fn as_age(&self) -> &dyn age::Recipient { + match self { + Self::Piv(r) => r, + Self::Tag(r) => r, + Self::X25519(r) => r, + } + } + + fn kind(&self) -> RecipientKind { + match self { + Self::Piv(_) => RecipientKind::PivP256, + Self::Tag(_) => RecipientKind::P256Tag, + Self::X25519(_) => RecipientKind::X25519, + } + } +} + +pub(crate) fn parse(holder: &str, recipient: &str) -> Result { + let err = |reason: &str| EscrowError::Recipient { + holder: holder.to_owned(), + reason: reason.to_owned(), + }; + if recipient.starts_with("age1yubikey1") { + recipient + .parse::() + .map(ParsedRecipient::Piv) + .map_err(err) + } else if recipient.starts_with("age1tag1") { + recipient + .parse::() + .map(ParsedRecipient::Tag) + .map_err(err) + } else if recipient.starts_with("age1") { + recipient + .parse::() + .map(ParsedRecipient::X25519) + .map_err(err) + } else { + Err(err( + "not an age1yubikey1, age1tag1 or age1 X25519 recipient", + )) + } +} + +/// Parse one recipient and report its kind. +pub fn parse_recipient(holder: &str, recipient: &str) -> Result { + parse(holder, recipient).map(|r| r.kind()) +} + +fn valid_holder_name(name: &str) -> bool { + (1..=32).contains(&name.len()) + && name + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_') +} + +/// Exactly three holders, valid unique names, unique recipients that parse, +/// and no software recipient unless `policy` allows it. +pub fn validate_holders( + holders: &[HolderRecipient], + policy: RecipientPolicy, +) -> Result, EscrowError> { + if holders.len() != usize::from(HOLDERS) { + return Err(EscrowError::Holders(format!( + "need exactly {HOLDERS} holders, got {}", + holders.len() + ))); + } + let mut names = HashSet::new(); + let mut recipients = HashSet::new(); + let mut kinds = Vec::with_capacity(holders.len()); + for h in holders { + if !valid_holder_name(&h.holder) { + return Err(EscrowError::Holders(format!( + "holder name {:?} must be 1-32 chars of a-z 0-9 - _", + h.holder + ))); + } + if !names.insert(h.holder.as_str()) { + return Err(EscrowError::Holders(format!( + "holder {:?} listed twice", + h.holder + ))); + } + if !recipients.insert(h.recipient.as_str()) { + return Err(EscrowError::Holders(format!( + "holder {:?} reuses another holder's recipient", + h.holder + ))); + } + let kind = parse_recipient(&h.holder, &h.recipient)?; + if kind.is_software() && policy == RecipientPolicy::HardwareOnly { + return Err(EscrowError::Recipient { + holder: h.holder.clone(), + reason: "X25519 recipients are software keys; use a YubiKey (age1yubikey1/age1tag1) or explicitly allow software recipients (tests and self-host only)".into(), + }); + } + kinds.push(kind); + } + Ok(kinds) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::{piv_identity, tag_identity, x25519_identity}; + + fn holder(name: &str, recipient: String) -> HolderRecipient { + HolderRecipient { + holder: name.into(), + recipient, + } + } + + #[test] + fn reports_each_kind_and_refuses_software_by_default() { + let holders = vec![ + holder("sam", piv_identity().1), + holder("hunter", tag_identity().1), + holder("safe", x25519_identity().to_public().to_string()), + ]; + assert_eq!( + validate_holders(&holders, RecipientPolicy::AllowSoftware).unwrap(), + vec![ + RecipientKind::PivP256, + RecipientKind::P256Tag, + RecipientKind::X25519 + ] + ); + let err = validate_holders(&holders, RecipientPolicy::HardwareOnly).unwrap_err(); + assert!( + matches!(&err, EscrowError::Recipient { holder, .. } if holder == "safe"), + "{err}" + ); + let hardware = vec![ + holder("sam", piv_identity().1), + holder("hunter", tag_identity().1), + holder("safe", piv_identity().1), + ]; + validate_holders(&hardware, RecipientPolicy::HardwareOnly).unwrap(); + } + + #[test] + fn rejects_count_names_duplicates_and_bad_recipients() { + let r = || piv_identity().1; + let v = |h: &[HolderRecipient]| validate_holders(h, RecipientPolicy::AllowSoftware); + assert!(v(&[holder("a", r()), holder("b", r())]).is_err()); + assert!(v(&[ + holder("a", r()), + holder("b", r()), + holder("c", r()), + holder("d", r()) + ]) + .is_err()); + assert!(v(&[holder("a", r()), holder("a", r()), holder("c", r())]).is_err()); + assert!(v(&[holder("Sam", r()), holder("b", r()), holder("c", r())]).is_err()); + assert!(v(&[holder("", r()), holder("b", r()), holder("c", r())]).is_err()); + let same = r(); + assert!(v(&[ + holder("a", same.clone()), + holder("b", same), + holder("c", r()) + ]) + .is_err()); + for bad in [ + "age1yubikey1qwerty", + "ssh-ed25519 AAAA", + "age1notavalidrecipient", + "age1tag1notvalid", + ] { + assert!( + v(&[holder("a", bad.into()), holder("b", r()), holder("c", r())]).is_err(), + "{bad}" + ); + } + } + + #[test] + fn parses_a_real_age_plugin_yubikey_recipient() { + // From age-plugin-yubikey 0.5.1 tests/integration.rs. + let real = "age1yubikey1q2w7u3vpya839jxxuq8g0sedh3d740d4xvn639sqhr95ejj8vu3hyfumptt"; + assert_eq!( + parse_recipient("sam", real).unwrap(), + RecipientKind::PivP256 + ); + } +} diff --git a/tinycloud-at-rest-escrow/src/record.rs b/tinycloud-at-rest-escrow/src/record.rs new file mode 100644 index 00000000..24a90020 --- /dev/null +++ b/tinycloud-at-rest-escrow/src/record.rs @@ -0,0 +1,207 @@ +//! The verification record a TEE writes after a successful test-unlock. + +use hmac::{Hmac, Mac}; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; + +use crate::{lp, Ark, ArkId, Digest32, Efp, EscrowBundle, EscrowError, TestUnlock}; + +const RECORD_VERSION: u32 = 1; +const MAC_INFO: &[u8] = b"tinycloud/at-rest/escrow-record/v1"; +const ALL_INDICES: [u8; 3] = [1, 2, 3]; +const ALL_PAIRS: [[u8; 2]; 3] = [[1, 2], [1, 3], [2, 3]]; + +/// Proof that all three escrowed shares were decrypted by their holders and +/// that every pair rebuilt the ARK inside the TEE. +/// +/// `mac` is HMAC-SHA256 under a key derived from the ARK, so storage that can +/// rewrite rows can't mint or alter a record. Only a holder of the ARK (the +/// TEE) can check it. Operators pin `efp` after reading a record. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct VerificationRecord { + pub version: u32, + pub efp: Efp, + pub ark_id: ArkId, + pub generation: u32, + pub verified_indices: Vec, + pub pairs: Vec<[u8; 2]>, + pub canary_fp: Digest32, + /// The TEE identity that ran the test-unlock, if any (informational). + pub app_id: Option, + pub compose_hash: Option, + /// RFC 3339, the TEE's clock (informational; not a freshness proof). + pub verified_at: String, + pub mac: Digest32, +} + +impl VerificationRecord { + /// Parse a record: version first, then strict fields. + pub fn from_json(bytes: &[u8]) -> Result { + crate::parse_versioned(bytes, "verification record", RECORD_VERSION) + } + + /// TEE only. Seal a record for a test-unlock of `bundle`. + pub fn seal( + ark: &Ark, + bundle: &EscrowBundle, + unlock: &TestUnlock, + app_id: Option, + compose_hash: Option, + verified_at: String, + ) -> Result { + if unlock.efp != bundle.fingerprint() { + return Err(EscrowError::Record( + "test-unlock was for a different bundle", + )); + } + let mut record = Self { + version: RECORD_VERSION, + efp: unlock.efp, + ark_id: bundle.ark_id, + generation: bundle.generation, + verified_indices: ALL_INDICES.to_vec(), + pairs: unlock.pairs.clone(), + canary_fp: bundle.canary_fp, + app_id, + compose_hash, + verified_at, + mac: Digest32([0; 32]), + }; + record.mac = record.compute_mac(ark); + Ok(record) + } + + fn compute_mac(&self, ark: &Ark) -> Digest32 { + let mut info = Vec::new(); + lp(&mut info, MAC_INFO); + let key = ark.derive(&self.ark_id, &info); + let mut mac = Hmac::::new_from_slice(key.as_slice()).expect("any key length"); + let mut body = Vec::with_capacity(256); + body.extend_from_slice(&self.version.to_be_bytes()); + body.extend_from_slice(&self.efp.0); + body.extend_from_slice(&self.ark_id.0); + body.extend_from_slice(&self.generation.to_be_bytes()); + lp(&mut body, &self.verified_indices); + lp(&mut body, &self.pairs.concat()); + body.extend_from_slice(&self.canary_fp.0); + for opt in [&self.app_id, &self.compose_hash] { + match opt { + None => body.push(0), + Some(v) => { + body.push(1); + lp(&mut body, v.as_bytes()); + } + } + } + lp(&mut body, self.verified_at.as_bytes()); + mac.update(&body); + Digest32(mac.finalize().into_bytes().into()) + } + + /// TEE only. The record is authentic (MAC under `ark`), belongs to + /// `bundle`, and covers all three shares and all three pairs. + pub fn verify(&self, ark: &Ark, bundle: &EscrowBundle) -> Result<(), EscrowError> { + if self.version != RECORD_VERSION { + return Err(EscrowError::Record("unsupported version")); + } + if self.compute_mac(ark) != self.mac { + return Err(EscrowError::Record("MAC does not verify under this ARK")); + } + self.covers(bundle) + } + + /// Public checks (no ARK): the record names `bundle` and claims full + /// coverage. Holders and operators run this before pinning `efp`. + pub fn covers(&self, bundle: &EscrowBundle) -> Result<(), EscrowError> { + if self.efp != bundle.fingerprint() + || self.ark_id != bundle.ark_id + || self.generation != bundle.generation + || self.canary_fp != bundle.canary_fp + { + return Err(EscrowError::Record( + "record is for a different escrow bundle", + )); + } + if self.verified_indices != ALL_INDICES || self.pairs != ALL_PAIRS { + return Err(EscrowError::Record( + "record does not cover all three shares and pairs", + )); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::*; + + type Edit = Box; + + fn sealed() -> (Ark, EscrowBundle, VerificationRecord) { + let (ark, ark_id, holders, ids) = escrow_fixture(); + let bundle = + EscrowBundle::create(&ark, ark_id, 1, &holders, RecipientPolicy::AllowSoftware) + .unwrap(); + let unlock = bundle.test_unlock(&ark, &open_all(&bundle, &ids)).unwrap(); + let record = VerificationRecord::seal( + &ark, + &bundle, + &unlock, + Some("app".into()), + None, + "2026-10-17T00:00:00Z".into(), + ) + .unwrap(); + (ark, bundle, record) + } + + #[test] + fn sealed_record_verifies_and_survives_json() { + let (ark, bundle, record) = sealed(); + record.verify(&ark, &bundle).unwrap(); + let back: VerificationRecord = + serde_json::from_str(&serde_json::to_string(&record).unwrap()).unwrap(); + back.verify(&ark, &bundle).unwrap(); + } + + #[test] + fn any_edit_or_other_ark_breaks_the_mac() { + let (ark, bundle, record) = sealed(); + assert!(record.verify(&Ark::generate(), &bundle).is_err()); + let edits: Vec = vec![ + Box::new(|r| r.app_id = Some("other".into())), + Box::new(|r| r.app_id = None), + Box::new(|r| r.compose_hash = Some(String::new())), + Box::new(|r| r.verified_at.push('x')), + Box::new(|r| r.mac.0[0] ^= 1), + ]; + for edit in edits { + let mut forged = record.clone(); + edit(&mut forged); + assert!(forged.verify(&ark, &bundle).is_err()); + } + } + + #[test] + fn record_does_not_cover_a_different_or_partial_escrow() { + let (ark, bundle, record) = sealed(); + // Same ARK, re-split to the same holders: new ciphertexts, new EFP. + let resplit = EscrowBundle::create( + &ark, + bundle.ark_id, + 1, + &bundle.holders(), + RecipientPolicy::AllowSoftware, + ) + .unwrap(); + assert!(record.verify(&ark, &resplit).is_err()); + + let mut partial = record.clone(); + partial.verified_indices = vec![1, 2]; + partial.pairs = vec![[1, 2]]; + partial.mac = partial.compute_mac(&ark); + assert!(partial.verify(&ark, &bundle).is_err()); + } +} diff --git a/tinycloud-at-rest-escrow/src/recovery.rs b/tinycloud-at-rest-escrow/src/recovery.rs new file mode 100644 index 00000000..1b2f4792 --- /dev/null +++ b/tinycloud-at-rest-escrow/src/recovery.rs @@ -0,0 +1,222 @@ +//! Moving a share from a holder into a TEE: the per-boot recovery identity, +//! the attested binding, and the wrap/unwrap of a share. + +use std::io::Read; + +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +use crate::bundle::seal_armored; +use crate::recipients::ParsedRecipient; +use crate::share::PAYLOAD_LEN; +use crate::{sha256, ArkId, Digest32, Efp, EscrowError, SharePayload}; + +/// Largest wrapped share the TEE reads. A real one is about 400 bytes. +pub const MAX_WRAPPED_SHARE: usize = 4096; +const BINDING_PREFIX: &str = "xyz.tinycloud.at-rest/recovery-key/v1\\0"; +/// Wire version of [`RecoveryInfo`]. +pub const RECOVERY_INFO_VERSION: u32 = 1; + +/// What `GET /at-rest/recovery?nonce=` returns. Every field is public. Only +/// what the holder recomputes and the verified quote prove anything; the +/// rest is a convenience for humans. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RecoveryInfo { + /// [`RECOVERY_INFO_VERSION`]; checked before any other field. + pub version: u32, + /// `"dstack"` when `quote` is a TDX quote, `"classic"` outside a TEE. + pub mode: String, + /// `"locked"` or `"unlocked"`. + pub state: String, + pub ark_id: ArkId, + pub generation: u32, + pub efp: Efp, + pub canary_fp: Digest32, + /// Per-boot X25519 age recipient. Its identity never leaves TEE memory. + pub recovery_recipient: String, + /// See [`recovery_binding`]. + pub binding: String, + pub binding_sha256: Digest32, + /// Hex TDX quote whose REPORTDATA is `binding_sha256 ‖ 32 zero bytes`. + pub quote: Option, + /// dstack event log (JSON text) for replaying RTMR3. + pub event_log: Option, + /// Indices of valid shares the TEE holds for this boot. + pub shares_held: Vec, +} + +impl RecoveryInfo { + /// Parse a node's answer: version first, then strict fields. + pub fn from_json(bytes: &[u8]) -> Result { + let info: Self = crate::parse_versioned(bytes, "recovery response", RECOVERY_INFO_VERSION)?; + let invalid = |reason: &str| EscrowError::Json { + what: "recovery response", + reason: reason.into(), + }; + if !matches!(info.mode.as_str(), "dstack" | "classic") { + return Err(invalid("mode must be \"dstack\" or \"classic\"")); + } + if !matches!(info.state.as_str(), "locked" | "unlocked") { + return Err(invalid("state must be \"locked\" or \"unlocked\"")); + } + Ok(info) + } +} + +/// `xyz.tinycloud.at-rest/recovery-key/v1\0:::`. The +/// `\0` is two characters (backslash, zero), as in `/attestation`. +pub fn recovery_binding(recipient: &str, efp: &Efp, nonce: &str) -> String { + format!("{BINDING_PREFIX}:{recipient}:{efp}:{nonce}") +} + +/// SHA-256 of the binding's UTF-8 bytes; the TDX REPORTDATA's first half. +pub fn binding_digest(binding: &str) -> Digest32 { + Digest32(sha256(binding.as_bytes())) +} + +/// 1-64 chars of `A-Z a-z 0-9 - _` (same rule as `/attestation`). +pub fn validate_nonce(nonce: &str) -> Result<(), EscrowError> { + if (1..=64).contains(&nonce.len()) + && nonce + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + { + Ok(()) + } else { + Err(EscrowError::Binding( + "nonce must be 1-64 chars of A-Z a-z 0-9 - _", + )) + } +} + +/// The TEE's per-boot recovery key. Made fresh at every boot, held only in +/// memory, never serialized. +pub struct RecoveryIdentity(age::x25519::Identity); + +impl Default for RecoveryIdentity { + fn default() -> Self { + Self::generate() + } +} + +impl RecoveryIdentity { + pub fn generate() -> Self { + Self(age::x25519::Identity::generate()) + } + + pub fn recipient(&self) -> String { + self.0.to_public().to_string() + } + + /// Decrypt a wrapped share (armored or binary age) and decode it. The + /// caller must still check it against the bundle. + pub fn open(&self, wrapped: &[u8]) -> Result { + if wrapped.len() > MAX_WRAPPED_SHARE { + return Err(EscrowError::ShareFormat); + } + let age_err = |_| EscrowError::Age("wrapped share does not decrypt".into()); + let decryptor = age::Decryptor::new_buffered(age::armor::ArmoredReader::new(wrapped)) + .map_err(age_err)?; + let mut reader = decryptor + .decrypt(std::iter::once(&self.0 as &dyn age::Identity)) + .map_err(age_err)?; + let mut plain = Zeroizing::new(Vec::with_capacity(PAYLOAD_LEN)); + // One byte past a payload is enough to reject anything longer. + (&mut reader) + .take(PAYLOAD_LEN as u64 + 1) + .read_to_end(&mut plain) + .map_err(|_| EscrowError::Age("wrapped share does not decrypt".into()))?; + SharePayload::decode(&plain) + } +} + +impl std::fmt::Debug for RecoveryIdentity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "RecoveryIdentity({})", self.recipient()) + } +} + +/// Holder side. Seal a decrypted share to a TEE recovery recipient. Only +/// X25519 recipients are accepted: the TEE's identity is software. +/// +/// Call this only after the TEE's quote and binding have been verified. +pub fn wrap_share(recovery_recipient: &str, share: &SharePayload) -> Result { + let recipient = recovery_recipient + .parse::() + .map_err(|_| EscrowError::Binding("recovery recipient is not an age1 X25519 recipient"))?; + seal_armored(&ParsedRecipient::X25519(recipient), &share.encode()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::*; + use crate::EscrowBundle; + + #[test] + fn wrapped_share_round_trips_through_the_recovery_identity_only() { + let (ark, ark_id, holders, ids) = escrow_fixture(); + let bundle = + EscrowBundle::create(&ark, ark_id, 1, &holders, RecipientPolicy::AllowSoftware) + .unwrap(); + let shares = open_all(&bundle, &ids); + let tee = RecoveryIdentity::generate(); + let wrapped = wrap_share(&tee.recipient(), &shares[2]).unwrap(); + + let opened = tee.open(wrapped.as_bytes()).unwrap(); + bundle.verify_share(&opened).unwrap(); + assert_eq!(opened.index, 3); + assert!(RecoveryIdentity::generate() + .open(wrapped.as_bytes()) + .is_err()); + } + + #[test] + fn open_rejects_oversize_and_non_share_plaintext() { + let tee = RecoveryIdentity::generate(); + assert!(tee.open(&vec![b'a'; MAX_WRAPPED_SHARE + 1]).is_err()); + assert!(tee.open(b"garbage").is_err()); + // A valid age file to the right key that does not hold a share. + let recipient: age::x25519::Recipient = tee.recipient().parse().unwrap(); + let junk = seal_armored( + &ParsedRecipient::X25519(recipient.clone()), + &[7u8; PAYLOAD_LEN], + ) + .unwrap(); + assert!(matches!( + tee.open(junk.as_bytes()), + Err(EscrowError::ShareFormat) + )); + let long = seal_armored(&ParsedRecipient::X25519(recipient), &[7u8; 10_000]).unwrap(); + assert!(tee.open(long.as_bytes()).is_err()); + } + + #[test] + fn wrap_refuses_tag_recipients() { + let (ark, ark_id, holders, ids) = escrow_fixture(); + let bundle = + EscrowBundle::create(&ark, ark_id, 1, &holders, RecipientPolicy::AllowSoftware) + .unwrap(); + let share = &open_all(&bundle, &ids)[0]; + assert!(wrap_share(&tag_identity().1, share).is_err()); + } + + #[test] + fn binding_and_nonce_rules() { + let efp = Digest32([0xab; 32]); + let b = recovery_binding("age1xyz", &efp, "n0nce"); + assert_eq!( + b, + format!("xyz.tinycloud.at-rest/recovery-key/v1\\0:age1xyz:{efp}:n0nce") + ); + assert_ne!( + binding_digest(&b), + binding_digest(&recovery_binding("age1xyz", &efp, "other")) + ); + validate_nonce("A-z_09").unwrap(); + for bad in ["", "a b", "a:b", &"x".repeat(65)] { + assert!(validate_nonce(bad).is_err(), "{bad:?}"); + } + } +} diff --git a/tinycloud-at-rest-escrow/src/share.rs b/tinycloud-at-rest-escrow/src/share.rs new file mode 100644 index 00000000..80501cb2 --- /dev/null +++ b/tinycloud-at-rest-escrow/src/share.rs @@ -0,0 +1,152 @@ +//! One holder's Shamir share and the binary form sealed inside its age file. + +use zeroize::Zeroizing; + +use crate::{lp, sha256, ArkId, Digest32, EscrowError, HOLDERS}; + +const MAGIC: &[u8; 4] = b"TCS1"; +/// x-coordinate byte followed by one y byte per ARK byte. +pub(crate) const SHARE_LEN: usize = 33; +/// `"TCS1" ‖ ark_id(16) ‖ generation u32 BE ‖ index u8 ‖ share(33)`. +pub(crate) const PAYLOAD_LEN: usize = 4 + 16 + 4 + 1 + SHARE_LEN; +const COMMIT_DOMAIN: &[u8] = b"tinycloud/at-rest/escrow-share-commit/v1"; + +/// A decrypted share. The share bytes are zeroized on drop and never printed. +#[derive(Clone)] +pub struct SharePayload { + pub ark_id: ArkId, + pub generation: u32, + /// 1..=3; equals the Shamir x-coordinate in `share[0]`. + pub index: u8, + pub(crate) share: Zeroizing<[u8; SHARE_LEN]>, +} + +impl std::fmt::Debug for SharePayload { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SharePayload") + .field("ark_id", &self.ark_id) + .field("generation", &self.generation) + .field("index", &self.index) + .finish_non_exhaustive() + } +} + +impl SharePayload { + pub(crate) fn new(ark_id: ArkId, generation: u32, share: &[u8]) -> Result { + let share: [u8; SHARE_LEN] = share.try_into().map_err(|_| EscrowError::Shamir)?; + let payload = Self { + ark_id, + generation, + index: share[0], + share: Zeroizing::new(share), + }; + payload.check_index()?; + Ok(payload) + } + + fn check_index(&self) -> Result<(), EscrowError> { + if self.index == 0 || self.index > HOLDERS || self.share[0] != self.index { + return Err(EscrowError::ShareFormat); + } + Ok(()) + } + + /// The bytes sealed to the holder. Zeroized on drop. + pub fn encode(&self) -> Zeroizing> { + let mut out = Zeroizing::new(Vec::with_capacity(PAYLOAD_LEN)); + out.extend_from_slice(MAGIC); + out.extend_from_slice(&self.ark_id.0); + out.extend_from_slice(&self.generation.to_be_bytes()); + out.push(self.index); + out.extend_from_slice(self.share.as_slice()); + out + } + + /// Strict inverse of [`encode`](Self::encode): exact length, known magic, + /// index in 1..=3 and equal to the share's x-coordinate. + pub fn decode(bytes: &[u8]) -> Result { + if bytes.len() != PAYLOAD_LEN || &bytes[..4] != MAGIC { + return Err(EscrowError::ShareFormat); + } + let ark_id = ArkId(bytes[4..20].try_into().expect("16 bytes")); + let generation = u32::from_be_bytes(bytes[20..24].try_into().expect("4 bytes")); + let index = bytes[24]; + let mut share = Zeroizing::new([0u8; SHARE_LEN]); + share.copy_from_slice(&bytes[25..]); + let payload = Self { + ark_id, + generation, + index, + share, + }; + payload.check_index()?; + Ok(payload) + } + + /// `SHA-256(lp(domain) ‖ ark_id ‖ generation ‖ index ‖ lp(share))`. + /// + /// The share is 32 uniformly random bytes plus its x-coordinate, so the + /// commitment can be public: it lets anyone holding the bundle check a + /// single share without a second one, and lets the TEE reject junk + /// before it occupies a slot. + pub fn commitment(&self) -> Digest32 { + let mut pre = Vec::with_capacity(96); + lp(&mut pre, COMMIT_DOMAIN); + pre.extend_from_slice(&self.ark_id.0); + pre.extend_from_slice(&self.generation.to_be_bytes()); + pre.push(self.index); + lp(&mut pre, self.share.as_slice()); + let digest = Digest32(sha256(&pre)); + zeroize::Zeroize::zeroize(&mut pre); + digest + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample(index: u8) -> SharePayload { + let mut share = [9u8; SHARE_LEN]; + share[0] = index; + SharePayload::new(ArkId([1; 16]), 1, &share).unwrap() + } + + #[test] + fn decode_rejects_wrong_length_magic_and_index() { + let good = sample(2).encode(); + assert_eq!(SharePayload::decode(&good).unwrap().index, 2); + assert!(SharePayload::decode(&good[..PAYLOAD_LEN - 1]).is_err()); + let mut extra = good.to_vec(); + extra.push(0); + assert!(SharePayload::decode(&extra).is_err()); + let mut bad_magic = good.to_vec(); + bad_magic[0] = b'X'; + assert!(SharePayload::decode(&bad_magic).is_err()); + for index in [0u8, 4, 255] { + let mut bad = good.to_vec(); + bad[24] = index; + bad[25] = index; + assert!(SharePayload::decode(&bad).is_err(), "index {index}"); + } + // Index byte disagreeing with the Shamir x-coordinate. + let mut skew = good.to_vec(); + skew[24] = 3; + assert!(SharePayload::decode(&skew).is_err()); + } + + #[test] + fn commitment_binds_every_field() { + let base = sample(1); + let c = base.commitment(); + let mut other_id = base.clone(); + other_id.ark_id = ArkId([2; 16]); + let mut other_gen = base.clone(); + other_gen.generation = 2; + let mut other_byte = base.clone(); + other_byte.share[32] ^= 1; + for changed in [other_id, other_gen, other_byte] { + assert_ne!(changed.commitment(), c); + } + } +} diff --git a/tinycloud-at-rest-escrow/src/test_support.rs b/tinycloud-at-rest-escrow/src/test_support.rs new file mode 100644 index 00000000..9b199753 --- /dev/null +++ b/tinycloud-at-rest-escrow/src/test_support.rs @@ -0,0 +1,196 @@ +//! Test-only identities: software P-256 keys that open `piv-p256` and +//! `p256tag` stanzas exactly as age-plugin-yubikey does with the key on a +//! YubiKey, so tests can round-trip shares sealed to hardware-key recipients. + +use std::io::Read; + +use age_core::format::{FileKey, Stanza}; +use base64::Engine; +use hpke::Deserializable; +use p256::elliptic_curve::sec1::{FromEncodedPoint, ToEncodedPoint}; +use zeroize::Zeroizing; + +pub(crate) use crate::RecipientPolicy; +use crate::{Ark, ArkId, EscrowBundle, HolderRecipient, SharePayload}; + +type Kem = hpke::kem::DhP256HkdfSha256; +const P256TAG_SALT: &[u8] = b"age-encryption.org/p256tag"; + +fn b64() -> base64::engine::GeneralPurpose { + base64::engine::general_purpose::STANDARD_NO_PAD +} + +pub(crate) struct TagIdentity(::PrivateKey); + +/// A fresh P-256 key and its `age1tag1…` recipient. +pub(crate) fn tag_identity() -> (TagIdentity, String) { + let sk = p256::SecretKey::random(&mut rand::rngs::OsRng); + let compressed = sk.public_key().to_encoded_point(true); + let hrp = bech32::Hrp::parse("age1tag").unwrap(); + let recipient = age_core::primitives::bech32_encode(hrp, compressed.as_bytes()); + let hpke_sk = ::PrivateKey::from_bytes(&sk.to_bytes()).unwrap(); + (TagIdentity(hpke_sk), recipient) +} + +impl age::Identity for TagIdentity { + fn unwrap_stanza(&self, stanza: &Stanza) -> Option> { + if stanza.tag != "p256tag" || stanza.args.len() != 2 { + return None; + } + let enc = b64().decode(&stanza.args[1]).ok()?; + let enc = ::EncappedKey::from_bytes(&enc).ok()?; + let opened = + age_core::primitives::hpke_open::(&enc, &self.0, P256TAG_SALT, &stanza.body) + .ok()?; + let key: [u8; 16] = opened.try_into().ok()?; + Some(Ok(FileKey::new(Box::new(key)))) + } +} + +pub(crate) struct PivIdentity(p256::SecretKey); + +/// A fresh P-256 key and its `age1yubikey1…` recipient. +pub(crate) fn piv_identity() -> (PivIdentity, String) { + let sk = p256::SecretKey::random(&mut rand::rngs::OsRng); + let compressed = sk.public_key().to_encoded_point(true); + let hrp = bech32::Hrp::parse("age1yubikey").unwrap(); + // age-plugin-yubikey encodes with standard Bech32 (bech32 0.9 Variant::Bech32). + let recipient = bech32::encode::(hrp, compressed.as_bytes()).unwrap(); + (PivIdentity(sk), recipient) +} + +/// The released plugin's `RecipientLine::from_stanza` argument check: +/// standard unpadded base64 of exactly `N` bytes. +fn plugin_base64_arg(arg: &str) -> Option<[u8; N]> { + if arg.len() != (4 * N).div_ceil(3) { + return None; + } + let mut out = [0u8; N]; + match b64().decode_slice_unchecked(arg, &mut out) { + Ok(n) if n == N => Some(out), + _ => None, + } +} + +impl age::Identity for PivIdentity { + /// age-plugin-yubikey 0.5.1: `RecipientLine::from_stanza` (structure) and + /// `YubiKey::unwrap_file_key` (math), with the YubiKey's scalar + /// multiplication done in software. Anything the plugin treats as a + /// structurally invalid stanza is an error here too. + fn unwrap_stanza(&self, stanza: &Stanza) -> Option> { + if stanza.tag != "piv-p256" { + return None; + } + let invalid = Some(Err(age::DecryptError::InvalidHeader)); + let [tag_arg, epk_arg] = &stanza.args[..] else { + return invalid; + }; + let (Some(tag), Some(epk_bytes)) = ( + plugin_base64_arg::<4>(tag_arg), + plugin_base64_arg::<33>(epk_arg), + ) else { + return invalid; + }; + let Ok(encoded) = p256::EncodedPoint::from_bytes(epk_bytes) else { + return invalid; + }; + let Some(epk) = p256::PublicKey::from_encoded_point(&encoded).into_option() else { + return invalid; + }; + if !encoded.is_compressed() || stanza.body.len() != 32 { + return invalid; + } + let pk = self.0.public_key().to_encoded_point(true); + if tag[..] != ::digest(pk.as_bytes())[..4] { + return None; + } + let shared = p256::ecdh::diffie_hellman(self.0.to_nonzero_scalar(), epk.as_affine()); + let mut salt = epk_bytes.to_vec(); + salt.extend_from_slice(pk.as_bytes()); + let enc_key = age_core::primitives::hkdf(&salt, b"piv-p256", shared.raw_secret_bytes()); + Some( + age_core::primitives::aead_decrypt(&enc_key, 16, &stanza.body) + .ok() + .and_then(|key| <[u8; 16]>::try_from(key).ok()) + .map(|key| FileKey::new(Box::new(key))) + .ok_or(age::DecryptError::DecryptionFailed), + ) + } +} + +/// The checked-in vector produced by the released plugin (see +/// `tests/fixtures/NOTICE.md`). +pub(crate) const PLUGIN_VECTOR: &str = include_str!("../tests/fixtures/piv_p256_plugin_0_5_1.age"); +pub(crate) const PLUGIN_VECTOR_MESSAGE: &[u8] = + b"tinycloud age-plugin-yubikey 0.5.1 piv-p256 vector"; +pub(crate) const PLUGIN_VECTOR_RECIPIENT: &str = + "age1yubikey1qg3fps8csqk30gjdt552hf7jyjnmt753z9yhk4p8f0x5vvr5m53mju3vr5u"; + +pub(crate) fn plugin_vector_identity() -> PivIdentity { + let scalar = + hex::decode("c2d3663dcc50dd691a502185a3f4de30f4c7ef664548414eca5c6a72790f36d2").unwrap(); + PivIdentity(p256::SecretKey::from_slice(&scalar).unwrap()) +} + +pub(crate) fn x25519_identity() -> age::x25519::Identity { + age::x25519::Identity::generate() +} + +pub(crate) enum TestIdentity { + Piv(PivIdentity), + Tag(TagIdentity), + X25519(age::x25519::Identity), +} + +impl TestIdentity { + /// What `age -d -i share.age` prints. + pub(crate) fn open(&self, armored: &str) -> Result>, age::DecryptError> { + let identity: &dyn age::Identity = match self { + Self::Piv(i) => i, + Self::Tag(i) => i, + Self::X25519(i) => i, + }; + let decryptor = + age::Decryptor::new_buffered(age::armor::ArmoredReader::new(armored.as_bytes()))?; + let mut reader = decryptor.decrypt(std::iter::once(identity))?; + let mut out = Zeroizing::new(Vec::new()); + reader.read_to_end(&mut out)?; + Ok(out) + } +} + +/// An ARK and three holders, one of each recipient kind: `age1yubikey1`, +/// `age1tag1` and X25519 (so bundles need `RecipientPolicy::AllowSoftware`). +pub(crate) fn escrow_fixture() -> (Ark, ArkId, Vec, Vec) { + let (sam, sam_r) = piv_identity(); + let (hunter, hunter_r) = tag_identity(); + let safe = x25519_identity(); + let safe_r = safe.to_public().to_string(); + let holders = [("sam", sam_r), ("hunter", hunter_r), ("safe", safe_r)] + .into_iter() + .map(|(holder, recipient)| HolderRecipient { + holder: holder.into(), + recipient, + }) + .collect(); + let ids = vec![ + TestIdentity::Piv(sam), + TestIdentity::Tag(hunter), + TestIdentity::X25519(safe), + ]; + (Ark::generate(), ArkId::generate(), holders, ids) +} + +/// Every holder opens their own share. +pub(crate) fn open_all(bundle: &EscrowBundle, ids: &[TestIdentity]) -> Vec { + bundle + .shares + .iter() + .zip(ids) + .map(|(share, id)| { + bundle + .open_share(&id.open(&share.ciphertext).unwrap()) + .unwrap() + }) + .collect() +} diff --git a/tinycloud-at-rest-escrow/tests/fixtures/NOTICE.md b/tinycloud-at-rest-escrow/tests/fixtures/NOTICE.md new file mode 100644 index 00000000..d1247130 --- /dev/null +++ b/tinycloud-at-rest-escrow/tests/fixtures/NOTICE.md @@ -0,0 +1,12 @@ +`piv_p256_plugin_0_5_1.age` was produced by the **released** age-plugin-yubikey +0.5.1 (crates.io, built `--locked`) through stock age v1.3.2: + +```sh +printf 'tinycloud age-plugin-yubikey 0.5.1 piv-p256 vector' > msg.txt +age -e -a -r age1yubikey1qg3fps8csqk30gjdt552hf7jyjnmt753z9yhk4p8f0x5vvr5m53mju3vr5u -o piv_p256_plugin_0_5_1.age msg.txt +``` + +The recipient is a software TEST key, not a YubiKey. Its secret scalar is +`c2d3663dcc50dd691a502185a3f4de30f4c7ef664548414eca5c6a72790f36d2`. It exists +only so tests can check, with the plugin's own output, that the test identity +opens `piv-p256` stanzas exactly as the plugin does and rejects what it rejects. diff --git a/tinycloud-at-rest-escrow/tests/fixtures/piv_p256_plugin_0_5_1.age b/tinycloud-at-rest-escrow/tests/fixtures/piv_p256_plugin_0_5_1.age new file mode 100644 index 00000000..6b5c4df9 --- /dev/null +++ b/tinycloud-at-rest-escrow/tests/fixtures/piv_p256_plugin_0_5_1.age @@ -0,0 +1,8 @@ +-----BEGIN AGE ENCRYPTED FILE----- +YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHBpdi1wMjU2IDdsVFhqZyBBaTBpbDZP +YnpDMGxPRmcvcmI1RDZWR3djSnhUUlNuRGlkaEM4ZkVhVFBOTwo3SUloU2t4ZWpD +OFZma3EyQjJTa2lDOGZNRSswZkpqcFNWVDFkTXE5MExZCi0tLSBjb0YxZ00xR2pF +Q3haQXdkaG9USENmdXczenJEQVo3QzJPQ3VzZHFBNDBRCsysaVBs1OAcJyu783vZ +9JKQ2RG8pY9YQeVp+EDVABD87jtO34f56POVXml2gQxEc6Ul7X8KTknNhFybxyGs +SuZdvLe9xOCw11pec2n3IPjujt0= +-----END AGE ENCRYPTED FILE----- diff --git a/tinycloud-core/Cargo.toml b/tinycloud-core/Cargo.toml index 1efb54dc..c9a62955 100644 --- a/tinycloud-core/Cargo.toml +++ b/tinycloud-core/Cargo.toml @@ -72,6 +72,10 @@ k256 = "0.13" hkdf = "0.12" hmac = "0.12" zeroize = "1" +# TC-887 S1: at-rest key hierarchy and escrow. +lru = "0.12" +parking_lot = "0.12" +tinycloud-at-rest-escrow = { path = "../tinycloud-at-rest-escrow" } prometheus = { version = "0.13.0", features = ["process"] } ryu-js = "0.2.2" # W1 (audit P1): full Unicode NFC normalization for policy capability path @@ -83,5 +87,7 @@ unicode-normalization = "0.1" sea-orm = { version = "1.1", features = ["runtime-tokio-rustls", "sqlx-sqlite"] } async-std = { version = "1", features = ["attributes"] } tokio.workspace = true +# Holder-side decryption of escrow shares in the at-rest keyring tests. +age = { version = "=0.12.1", default-features = false, features = ["armor"] } iri-string.workspace = true proptest = "1" diff --git a/tinycloud-core/src/at_rest/envelope.rs b/tinycloud-core/src/at_rest/envelope.rs index c1f401c6..4ea7d581 100644 --- a/tinycloud-core/src/at_rest/envelope.rs +++ b/tinycloud-core/src/at_rest/envelope.rs @@ -309,7 +309,7 @@ impl SegmentCipher { let mut aad = ctx; aad.extend_from_slice(header.as_bytes()); Self { - cipher: Aes256Gcm::new(k_obj.as_ref().into()), + cipher: Aes256Gcm::new((&*k_obj).into()), aad, segments: header.segment_count(), } diff --git a/tinycloud-core/src/at_rest/hierarchy.rs b/tinycloud-core/src/at_rest/hierarchy.rs new file mode 100644 index 00000000..4854d71a --- /dev/null +++ b/tinycloud-core/src/at_rest/hierarchy.rs @@ -0,0 +1,318 @@ +//! The at-rest key hierarchy below the node root R (TC-887 plan §1.1): +//! +//! ```text +//! W = HKDF-SHA256(R, info "tinycloud/at-rest/wrap/v1") never stored +//! ARK_g = 32 random bytes, sealed: nonce ‖ AES-256-GCM(W, ARK_g, AAD seal) +//! chain = nonce ‖ AES-256-GCM(HKDF(ARK_{g+1}, "…/ark-chain/v1"), ARK_g, AAD chain) +//! KEK_g = HKDF-SHA256(ARK_g, info "tinycloud/at-rest/kek/v1") +//! NK = HKDF-SHA256(ARK_1, info "tinycloud/at-rest/object-names/v1") +//! DEK = 32 random bytes per (space, dek_id): nonce ‖ AES-256-GCM(KEK_g, DEK, AAD dek) +//! TAG = HKDF-SHA256(ARK_1, info "tinycloud/at-rest/dek-mirror-tag/v1") +//! names a space's mirrored DEKs: hex(HKDF(TAG, info space)[..16]) +//! ``` +//! +//! Every AAD starts with a length-prefixed domain string; variable fields are +//! length-prefixed (`lp(x) = u32 BE len ‖ x`). Nonces are random: each key +//! seals a handful of values, far below AES-GCM's random-nonce bound. + +use aes_gcm::{ + aead::{Aead, KeyInit, Payload}, + Aes256Gcm, Nonce, +}; +use hkdf::Hkdf; +use rand::RngCore; +use sha2::Sha256; +use tinycloud_at_rest_escrow::{Ark, ArkId}; +use zeroize::Zeroizing; + +use super::{DataKey, DekId, ObjectNameKey}; + +/// The label of the current W. A different label means a different W; rows +/// record which one sealed them. +pub const W_LABEL: &str = "tinycloud/at-rest/wrap/v1"; +const KEK_INFO: &[u8] = b"tinycloud/at-rest/kek/v1"; +const NK_INFO: &[u8] = b"tinycloud/at-rest/object-names/v1"; +const DEK_TAG_INFO: &[u8] = b"tinycloud/at-rest/dek-mirror-tag/v1"; +const CHAIN_INFO: &[u8] = b"tinycloud/at-rest/ark-chain/v1"; +const SEAL_DOMAIN: &[u8] = b"tinycloud/at-rest/ark-seal/v1"; +const CHAIN_DOMAIN: &[u8] = b"tinycloud/at-rest/ark-chain-aad/v1"; +const DEK_DOMAIN: &[u8] = b"tinycloud/at-rest/dek/v1"; +const NONCE_LEN: usize = 12; + +/// An AEAD open failed: wrong key, wrong binding, or tampered bytes. +#[derive(Debug, thiserror::Error)] +#[error("at-rest key material does not authenticate")] +pub struct Unauthentic; + +fn lp(out: &mut Vec, bytes: &[u8]) { + let len = u32::try_from(bytes.len()).expect("field shorter than 4 GiB"); + out.extend_from_slice(&len.to_be_bytes()); + out.extend_from_slice(bytes); +} + +fn hkdf32(ikm: &[u8], info: &[u8]) -> Zeroizing<[u8; 32]> { + let mut out = Zeroizing::new([0u8; 32]); + Hkdf::::new(None, ikm) + .expand(info, out.as_mut()) + .expect("32 bytes is a valid HKDF-SHA256 length"); + out +} + +fn seal(key: &[u8; 32], aad: &[u8], plaintext: &[u8]) -> Vec { + let cipher = Aes256Gcm::new_from_slice(key).expect("32-byte key"); + let mut nonce = [0u8; NONCE_LEN]; + rand::rngs::OsRng.fill_bytes(&mut nonce); + let ct = cipher + .encrypt( + &Nonce::from(nonce), + Payload { + msg: plaintext, + aad, + }, + ) + .expect("AES-GCM encryption of 32 bytes cannot fail"); + let mut out = Vec::with_capacity(NONCE_LEN + ct.len()); + out.extend_from_slice(&nonce); + out.extend_from_slice(&ct); + out +} + +fn open32(key: &[u8; 32], aad: &[u8], sealed: &[u8]) -> Result, Unauthentic> { + if sealed.len() != NONCE_LEN + 32 + 16 { + return Err(Unauthentic); + } + let cipher = Aes256Gcm::new_from_slice(key).expect("32-byte key"); + let (nonce, ct) = sealed.split_at(NONCE_LEN); + let nonce: [u8; NONCE_LEN] = nonce.try_into().expect("length checked above"); + let plain = Zeroizing::new( + cipher + .decrypt(&Nonce::from(nonce), Payload { msg: ct, aad }) + .map_err(|_| Unauthentic)?, + ); + let mut out = Zeroizing::new([0u8; 32]); + out.copy_from_slice(&plain); + Ok(out) +} + +/// W, derived from the node root. Never stored. +pub struct WrapKey(Zeroizing<[u8; 32]>); + +impl WrapKey { + pub fn derive(root: &[u8]) -> Self { + Self(hkdf32(root, W_LABEL.as_bytes())) + } +} + +fn seal_aad(ark_id: &ArkId, generation: u32, w_label: &str) -> Vec { + let mut aad = Vec::with_capacity(96); + lp(&mut aad, SEAL_DOMAIN); + lp(&mut aad, &ark_id.0); + aad.extend_from_slice(&generation.to_be_bytes()); + lp(&mut aad, w_label.as_bytes()); + aad +} + +pub fn seal_ark(w: &WrapKey, ark: &Ark, ark_id: &ArkId, generation: u32) -> Vec { + seal(&w.0, &seal_aad(ark_id, generation, W_LABEL), ark.expose()) +} + +pub fn open_ark( + w: &WrapKey, + sealed: &[u8], + ark_id: &ArkId, + generation: u32, + w_label: &str, +) -> Result { + if w_label != W_LABEL { + return Err(Unauthentic); + } + open32(&w.0, &seal_aad(ark_id, generation, w_label), sealed).map(Ark::from_zeroizing) +} + +fn chain_aad(older_id: &ArkId, older_generation: u32, newer_id: &ArkId) -> Vec { + let mut aad = Vec::with_capacity(96); + lp(&mut aad, CHAIN_DOMAIN); + lp(&mut aad, &older_id.0); + aad.extend_from_slice(&older_generation.to_be_bytes()); + lp(&mut aad, &newer_id.0); + aad +} + +/// Seal generation g's ARK under generation g+1's chain key, so escrowing +/// the newest ARK recovers every older one. +pub fn chain_ark( + newer: &Ark, + newer_id: &ArkId, + older: &Ark, + older_id: &ArkId, + older_generation: u32, +) -> Vec { + let key = hkdf32(newer.expose(), CHAIN_INFO); + seal( + &key, + &chain_aad(older_id, older_generation, newer_id), + older.expose(), + ) +} + +pub fn open_chain( + newer: &Ark, + newer_id: &ArkId, + chain: &[u8], + older_id: &ArkId, + older_generation: u32, +) -> Result { + let key = hkdf32(newer.expose(), CHAIN_INFO); + open32( + &key, + &chain_aad(older_id, older_generation, newer_id), + chain, + ) + .map(Ark::from_zeroizing) +} + +/// KEK_g. Wraps the DEKs created while generation g was current. +pub struct Kek(Zeroizing<[u8; 32]>); + +impl Kek { + pub fn derive(ark: &Ark) -> Self { + Self(hkdf32(ark.expose(), KEK_INFO)) + } +} + +/// NK from ARK generation 1; constant for the deployment. +pub fn object_name_key(ark_1: &Ark) -> ObjectNameKey { + ObjectNameKey::from_zeroizing(hkdf32(ark_1.expose(), NK_INFO)) +} + +/// Names a space's mirrored DEK rows without naming the space. From ARK +/// generation 1, so the names survive rotation. +pub struct DekTagKey(Zeroizing<[u8; 32]>); + +impl DekTagKey { + pub fn derive(ark_1: &Ark) -> Self { + Self(hkdf32(ark_1.expose(), DEK_TAG_INFO)) + } + + pub fn tag(&self, space: &str) -> String { + hex::encode(&hkdf32(self.0.as_ref(), space.as_bytes())[..16]) + } +} + +fn dek_aad(space: &str, dek_id: DekId, generation: u32, ark_id: &ArkId) -> Vec { + let mut aad = Vec::with_capacity(64 + space.len()); + lp(&mut aad, DEK_DOMAIN); + lp(&mut aad, space.as_bytes()); + aad.extend_from_slice(&dek_id.get().to_be_bytes()); + aad.extend_from_slice(&generation.to_be_bytes()); + lp(&mut aad, &ark_id.0); + aad +} + +/// A fresh random DEK, wrapped. Returns the wrapped bytes and the key. +pub fn new_dek( + kek: &Kek, + space: &str, + dek_id: DekId, + generation: u32, + ark_id: &ArkId, +) -> (Vec, DataKey) { + let mut bytes = Zeroizing::new([0u8; 32]); + rand::rngs::OsRng.fill_bytes(bytes.as_mut()); + let wrapped = seal( + &kek.0, + &dek_aad(space, dek_id, generation, ark_id), + bytes.as_ref(), + ); + (wrapped, DataKey::from_zeroizing(dek_id, bytes)) +} + +pub fn unwrap_dek( + kek: &Kek, + wrapped: &[u8], + space: &str, + dek_id: DekId, + generation: u32, + ark_id: &ArkId, +) -> Result { + let bytes = open32(&kek.0, &dek_aad(space, dek_id, generation, ark_id), wrapped)?; + Ok(DataKey::from_zeroizing(dek_id, bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ark_seal_opens_only_under_the_same_root_and_binding() { + let w = WrapKey::derive(&[7u8; 64]); + let ark = Ark::generate(); + let id = ArkId([1; 16]); + let sealed = seal_ark(&w, &ark, &id, 1); + assert!(open_ark(&w, &sealed, &id, 1, W_LABEL).unwrap().ct_eq(&ark)); + + let other_root = WrapKey::derive(&[8u8; 64]); + assert!(open_ark(&other_root, &sealed, &id, 1, W_LABEL).is_err()); + assert!(open_ark(&w, &sealed, &ArkId([2; 16]), 1, W_LABEL).is_err()); + assert!(open_ark(&w, &sealed, &id, 2, W_LABEL).is_err()); + assert!(open_ark(&w, &sealed, &id, 1, "tinycloud/at-rest/wrap/v2").is_err()); + let mut flipped = sealed.clone(); + flipped[20] ^= 1; + assert!(open_ark(&w, &flipped, &id, 1, W_LABEL).is_err()); + assert!(open_ark(&w, &sealed[..sealed.len() - 1], &id, 1, W_LABEL).is_err()); + } + + #[test] + fn chain_opens_older_generation_from_newer_only() { + let (old, old_id) = (Ark::generate(), ArkId([1; 16])); + let (new, new_id) = (Ark::generate(), ArkId([2; 16])); + let chain = chain_ark(&new, &new_id, &old, &old_id, 1); + assert!(open_chain(&new, &new_id, &chain, &old_id, 1) + .unwrap() + .ct_eq(&old)); + assert!(open_chain(&old, &new_id, &chain, &old_id, 1).is_err()); + assert!(open_chain(&new, &new_id, &chain, &old_id, 2).is_err()); + assert!(open_chain(&new, &old_id, &chain, &old_id, 1).is_err()); + } + + #[test] + fn dek_wrap_binds_space_id_generation_and_ark() { + let ark = Ark::generate(); + let id = ArkId([3; 16]); + let kek = Kek::derive(&ark); + let (wrapped, dek) = new_dek(&kek, "space-a", DekId::new(1), 1, &id); + let back = unwrap_dek(&kek, &wrapped, "space-a", DekId::new(1), 1, &id).unwrap(); + assert_eq!(back.bytes(), dek.bytes()); + assert_eq!(back.id(), DekId::new(1)); + + // A row moved to another space, id, generation or ARK fails. + assert!(unwrap_dek(&kek, &wrapped, "space-b", DekId::new(1), 1, &id).is_err()); + assert!(unwrap_dek(&kek, &wrapped, "space-a", DekId::new(2), 1, &id).is_err()); + assert!(unwrap_dek(&kek, &wrapped, "space-a", DekId::new(1), 2, &id).is_err()); + assert!(unwrap_dek(&kek, &wrapped, "space-a", DekId::new(1), 1, &ArkId([4; 16])).is_err()); + assert!(unwrap_dek( + &Kek::derive(&Ark::generate()), + &wrapped, + "space-a", + DekId::new(1), + 1, + &id + ) + .is_err()); + // Length-prefixing: "ab"+"c" and "a"+"bc" style shifts can't collide. + assert_ne!( + dek_aad("ab", DekId::new(1), 1, &id), + dek_aad("a", DekId::new(1), 1, &id) + ); + } + + #[test] + fn derived_keys_are_independent() { + let ark = Ark::generate(); + let kek = Kek::derive(&ark); + let nk = object_name_key(&ark); + assert_ne!(&*kek.0, nk.bytes()); + assert_ne!(&*kek.0, ark.expose()); + assert_ne!(nk.bytes(), &*hkdf32(ark.expose(), CHAIN_INFO)); + } +} diff --git a/tinycloud-core/src/at_rest/key.rs b/tinycloud-core/src/at_rest/key.rs index 3a9c61d4..1ff70aa1 100644 --- a/tinycloud-core/src/at_rest/key.rs +++ b/tinycloud-core/src/at_rest/key.rs @@ -33,6 +33,11 @@ impl DataKey { } } + /// Take key bytes already in a zeroizing buffer, with no plain copy. + pub(crate) fn from_zeroizing(id: DekId, bytes: Zeroizing<[u8; 32]>) -> Self { + Self { id, bytes } + } + pub fn id(&self) -> DekId { self.id } @@ -60,6 +65,11 @@ impl ObjectNameKey { Self(Zeroizing::new(bytes)) } + /// Take key bytes already in a zeroizing buffer, with no plain copy. + pub(crate) fn from_zeroizing(bytes: Zeroizing<[u8; 32]>) -> Self { + Self(bytes) + } + pub(crate) fn bytes(&self) -> &[u8; 32] { &self.0 } diff --git a/tinycloud-core/src/at_rest/keyring.rs b/tinycloud-core/src/at_rest/keyring.rs new file mode 100644 index 00000000..a6cf4321 --- /dev/null +++ b/tinycloud-core/src/at_rest/keyring.rs @@ -0,0 +1,1139 @@ +//! The node's at-rest keyring (TC-887 S1): loads or provisions the ARK under +//! W, keeps every generation, serves per-space DEKs ([`DataKeyProvider`]), +//! maintains the escrow bundle, decides the encrypt gate, and runs the +//! in-TEE share intake for recovery ("locked, not lost") and the ceremony +//! test-unlock. + +use std::collections::BTreeMap; +use std::num::NonZeroUsize; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use lru::LruCache; +use parking_lot::{Mutex, RwLock}; +use sea_orm::DbErr; +use tinycloud_at_rest_escrow::{ + Ark, ArkId, Digest32, Efp, EscrowBundle, EscrowError, HolderRecipient, RecipientPolicy, + RecoveryIdentity, SharePayload, VerificationRecord, +}; + +use super::hierarchy::{ + chain_ark, new_dek, object_name_key, open_ark, open_chain, seal_ark, unwrap_dek, DekTagKey, + Kek, WrapKey, W_LABEL, +}; +use super::store::{ + ark_mirror_name, dek_mirror_name, escrow_mirror_name, AtRestStore, KeyMirror, MirroredArk, + MirroredDek, ARK_MIRROR_PREFIX, MIRROR_PREFIX, +}; +use super::{DataKey, DataKeyError, DataKeyProvider, DekId, ObjectNameKey}; +use crate::models::{at_rest_escrow, at_rest_key, space_data_key}; + +const DEK_CACHE: usize = 4096; +/// Share submissions per boot: a burst of this many, then one per refill. +const SHARE_BURST: u32 = 10; +const SHARE_REFILL: Duration = Duration::from_secs(6); + +/// How the node runs its keyring. +#[derive(Clone, Debug, Default)] +pub struct KeyringConfig { + /// Make ARK generation 1 if none exists. + pub provision: bool, + /// Escrow holders pinned in config; empty means no escrow. + pub holders: Vec, + /// Hardware-only by default; software (X25519) holders need the explicit + /// test/self-host opt-in. + pub recipient_policy: RecipientPolicy, + /// `at_rest.escrow.verified_fp`: the bundle a ceremony verified. + pub pinned_efp: Option, + /// The TEE identity written into verification records (informational). + pub app_id: Option, + pub compose_hash: Option, +} + +#[derive(Debug, thiserror::Error)] +pub enum AtRestError { + #[error("at-rest key store: {0}")] + Store(#[from] DbErr), + #[error("at-rest key mirror: {0}")] + Mirror(String), + #[error("at-rest key state is inconsistent: {0}")] + Inconsistent(&'static str), + #[error("at-rest key change lost a race: {0}")] + Conflict(&'static str), + #[error("at-rest escrow: {0}")] + Escrow(#[from] EscrowError), + #[error("at-rest keys are locked")] + Locked, + #[error("at-rest keys are not provisioned")] + NotProvisioned, +} + +/// Why `encrypt` may not be enabled. Every variant is an operator action. +#[derive(Debug, PartialEq, Eq, thiserror::Error)] +pub enum GateRefusal { + #[error("at-rest keys are locked; recover with two escrow shares first")] + Locked, + #[error("at-rest keys are not provisioned")] + NotProvisioned, + #[error("no escrow recipients are configured; set at_rest.escrow.recipients (or, on a self-hosted Static-root node, acknowledge at_rest.escrow_none)")] + NoEscrow, + #[error("the active escrow bundle's recipients differ from the configured recipients")] + RecipientsMismatch, + #[error("the escrow has software (X25519) holders, which only an explicit test/self-host opt-in allows")] + SoftwareRecipients, + #[error("at_rest.escrow.verified_fp is unset; run the escrow ceremony and pin the verified fingerprint {efp}")] + NotPinned { efp: String }, + #[error("at_rest.escrow.verified_fp ({pinned}) is not the active escrow bundle ({efp}); re-run the ceremony")] + PinMismatch { pinned: String, efp: String }, + #[error("escrow {efp} has no valid verification record; run the ceremony test-unlock")] + NotVerified { efp: String }, + /// Not a configuration problem: the store, mirror or stored escrow could + /// not be read. Fix that (or retry); don't re-run the ceremony. + #[error("could not evaluate the escrow: {0}")] + Unavailable(String), +} + +/// How a passing gate was satisfied. +#[derive(Debug, PartialEq, Eq)] +pub enum GatePass { + Verified { + efp: Efp, + }, + /// Self-host with `escrow_none` acknowledged: no escrow exists. + Unescrowed, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum KeyringState { + NotProvisioned, + Locked, + Unlocked, +} + +/// Public facts for `/info` and the recovery endpoint. +#[derive(Clone, Debug)] +pub struct EscrowView { + pub state: KeyringState, + pub ark_id: ArkId, + pub generation: u32, + pub efp: Efp, + pub canary_fp: Digest32, + pub verified: bool, + pub recovery_recipient: String, + pub shares_held: Vec, + pub bundle: EscrowBundle, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum ShareOutcome { + /// Valid; waiting for more shares. + Held { shares_held: Vec }, + /// Unlocked node: all three shares rebuilt the ARK; record written. + Verified { efp: Efp }, + /// Locked node: two shares recovered the ARK; resealed and unlocked. + Unlocked, +} + +#[derive(Debug, thiserror::Error)] +pub enum ShareRefusal { + #[error("too many share submissions; retry later")] + RateLimited, + #[error("no escrow bundle to recover against: {0}")] + NoBundle(&'static str), + #[error("share rejected: {0}")] + Invalid(#[from] EscrowError), + #[error("{0}")] + Keyring(#[from] AtRestError), +} + +struct Generation { + ark_id: ArkId, + ark: Ark, + kek: Kek, +} + +struct Keys { + gens: BTreeMap, + nk: ObjectNameKey, + dek_tag: DekTagKey, +} + +impl Keys { + fn newest(&self) -> (u32, &Generation) { + let (g, gen) = self + .gens + .last_key_value() + .expect("keys_from_newest loads ≥ 1 generation"); + (*g, gen) + } +} + +enum Phase { + NotProvisioned, + Locked { generation: u32, ark_id: ArkId }, + Unlocked(Arc), +} + +struct ShareIntake { + identity: RecoveryIdentity, + efp: Option, + held: [Option; 3], + tokens: u32, + refilled: Instant, +} + +impl ShareIntake { + fn new() -> Self { + Self { + identity: RecoveryIdentity::generate(), + efp: None, + held: [None, None, None], + tokens: SHARE_BURST, + refilled: Instant::now(), + } + } + + fn take_token(&mut self) -> bool { + let earned = (self.refilled.elapsed().as_secs() / SHARE_REFILL.as_secs()) as u32; + if earned > 0 { + self.tokens = (self.tokens + earned).min(SHARE_BURST); + self.refilled += SHARE_REFILL * earned; + } + if self.tokens == 0 { + return false; + } + self.tokens -= 1; + true + } + + fn held_indices(&self) -> Vec { + self.held.iter().flatten().map(|s| s.index).collect() + } + + fn clear(&mut self) { + self.held = [None, None, None]; + } +} + +fn now_rfc3339() -> String { + time::OffsetDateTime::now_utc() + .format(&time::format_description::well_known::Rfc3339) + .expect("RFC 3339 formats any UTC time") +} + +fn mirror_err(e: impl std::fmt::Display) -> AtRestError { + AtRestError::Mirror(e.to_string()) +} + +fn ark_id_of(row: &at_rest_key::Model) -> Result { + row.ark_id + .as_slice() + .try_into() + .map(ArkId) + .map_err(|_| AtRestError::Inconsistent("ark_id is not 16 bytes")) +} + +fn backend(e: impl std::error::Error + Send + Sync + 'static) -> DataKeyError { + DataKeyError::Backend(Box::new(e)) +} + +/// Generations must be exactly 1..=n. +fn check_contiguous(rows: &[at_rest_key::Model]) -> Result<(), AtRestError> { + if rows.is_empty() { + return Err(AtRestError::NotProvisioned); + } + for (i, row) in rows.iter().enumerate() { + if row.generation != i as i32 + 1 { + return Err(AtRestError::Inconsistent("ARK generations are not 1..n")); + } + } + Ok(()) +} + +/// A generation whose chain had to be rewritten: (generation, new chain). +type Rechain = (u32, Vec); + +/// Open every generation from the rows under this node's W. `Ok(None)` +/// means the newest doesn't open under W (locked). +fn unlock_rows( + w: &WrapKey, + rows: &[at_rest_key::Model], +) -> Result)>, AtRestError> { + check_contiguous(rows)?; + let newest = rows.last().expect("check_contiguous: non-empty"); + let newest_id = ark_id_of(newest)?; + let Ok(ark) = open_ark( + w, + &newest.sealed, + &newest_id, + newest.generation as u32, + &newest.w_label, + ) else { + return Ok(None); + }; + keys_from_newest(rows, ark, Some(w)).map(Some) +} + +/// Walk down from the newest ARK. Every older generation must open **by its +/// chain**: that is the only path recovery has, so a broken chain is never +/// accepted silently. +/// +/// With `repair_w` (boot under the root that sealed the rows), a chain that +/// is missing or doesn't open is rebuilt if the generation opens under W, +/// and returned for the caller to store. Without it (recovery under a new +/// root), a broken chain is an error. +fn keys_from_newest( + rows: &[at_rest_key::Model], + newest_ark: Ark, + repair_w: Option<&WrapKey>, +) -> Result<(Keys, Vec), AtRestError> { + check_contiguous(rows)?; + let mut gens = BTreeMap::new(); + let mut rechain = Vec::new(); + let mut newer: Option<(Ark, ArkId)> = None; + for row in rows.iter().rev() { + let id = ark_id_of(row)?; + let g = row.generation as u32; + let this = match &newer { + None => newest_ark.clone(), + Some((n_ark, n_id)) => { + let by_chain = row + .chain + .as_deref() + .and_then(|c| open_chain(n_ark, n_id, c, &id, g).ok()); + match (by_chain, repair_w) { + (Some(ark), _) => ark, + (None, Some(w)) => { + let ark = open_ark(w, &row.sealed, &id, g, &row.w_label).map_err(|_| { + AtRestError::Inconsistent( + "an older ARK generation opens neither by its chain nor under W", + ) + })?; + rechain.push((g, chain_ark(n_ark, n_id, &ark, &id, g))); + ark + } + (None, None) => { + return Err(AtRestError::Inconsistent( + "an older ARK generation's chain is missing or does not open", + )) + } + } + } + }; + newer = Some((this.clone(), id)); + gens.insert( + g, + Generation { + ark_id: id, + kek: Kek::derive(&this), + ark: this, + }, + ); + } + let ark_1 = &gens.get(&1).expect("check_contiguous: generation 1").ark; + let keys = Keys { + nk: object_name_key(ark_1), + dek_tag: DekTagKey::derive(ark_1), + gens, + }; + Ok((keys, rechain)) +} + +pub struct AtRestKeyring { + store: AtRestStore, + mirror: Arc, + w: WrapKey, + config: KeyringConfig, + phase: RwLock, + deks: Mutex>, + current: Mutex>, + /// The EFP last confirmed present in the mirror, so `/info` and the gate + /// don't GET it every call. + escrow_mirrored: Mutex>, + intake: tokio::sync::Mutex, + dek_create: tokio::sync::Mutex<()>, +} + +impl AtRestKeyring { + /// Load the keyring at boot. + /// + /// - Mirrored ARK generations missing from the database (a restore to + /// an earlier point) are put back first. + /// - Rows open under this root's W: unlocked. A chain that is missing or + /// doesn't open is rebuilt and logged. + /// - Rows exist but don't open: **locked** (data intact; recover with two + /// shares). + /// - No rows: provision generation 1 if `config.provision`, else not + /// provisioned. Never when any sealed material exists without an ARK + /// (data keys, escrow rows, any mirror object), and never after a + /// store or mirror error. + pub async fn open( + store: AtRestStore, + mirror: Arc, + root: &[u8], + config: KeyringConfig, + ) -> Result { + let w = WrapKey::derive(root); + Self::restore_rows(&store, &*mirror).await?; + let mut rows = store.keys().await?; + if rows.is_empty() { + if store.has_space_keys().await? + || store.has_escrow_rows().await? + || !mirror + .list(MIRROR_PREFIX) + .await + .map_err(mirror_err)? + .is_empty() + { + // Another process may have provisioned since we read: its + // ARK row is written before anything else. + rows = store.keys().await?; + if rows.is_empty() { + return Err(AtRestError::Inconsistent( + "sealed at-rest material exists but no ARK row or mirrored ARK was found", + )); + } + } else if config.provision { + let ark = Ark::generate(); + let ark_id = ArkId::generate(); + store + .insert_key(at_rest_key::Model { + generation: 1, + ark_id: ark_id.0.to_vec(), + w_label: W_LABEL.into(), + sealed: seal_ark(&w, &ark, &ark_id, 1), + chain: None, + created_at: now_rfc3339(), + }) + .await?; + // Another process may have won the insert; use what's stored. + rows = store.keys().await?; + if rows.first().map(|r| r.ark_id.as_slice()) == Some(&ark_id.0[..]) { + tracing::info!(generation = 1, %ark_id, "at-rest root key provisioned"); + } + } + } + let phase = if rows.is_empty() { + Phase::NotProvisioned + } else { + match unlock_rows(&w, &rows)? { + Some((keys, rechain)) => { + if !rechain.is_empty() { + Self::store_rechain(&store, rechain).await?; + rows = store.keys().await?; + } + for row in &rows { + Self::mirror_row(&*mirror, row).await?; + } + Phase::Unlocked(Arc::new(keys)) + } + None => { + let newest = rows.last().expect("non-empty"); + tracing::warn!( + generation = newest.generation, + "at-rest keys locked: this node's root cannot unseal the ARK" + ); + Phase::Locked { + generation: newest.generation as u32, + ark_id: ark_id_of(newest)?, + } + } + } + }; + let keyring = Self { + store, + mirror, + w, + config, + phase: RwLock::new(phase), + deks: Mutex::new(LruCache::new( + NonZeroUsize::new(DEK_CACHE).expect("non-zero"), + )), + current: Mutex::new(LruCache::new( + NonZeroUsize::new(DEK_CACHE).expect("non-zero"), + )), + escrow_mirrored: Mutex::new(None), + intake: tokio::sync::Mutex::new(ShareIntake::new()), + dek_create: tokio::sync::Mutex::new(()), + }; + if keyring.state() == KeyringState::Unlocked { + keyring.ensure_escrow().await?; + } + Ok(keyring) + } + + async fn store_rechain(store: &AtRestStore, rechain: Vec) -> Result<(), AtRestError> { + for (g, chain) in rechain { + tracing::warn!( + generation = g, + "at-rest ARK chain was missing or did not open; rebuilt it from the newer generation" + ); + store.set_chain(g as i32, chain).await?; + } + Ok(()) + } + + /// Put back mirrored ARK generations the database lacks, and chains the + /// database lost. Both are AEAD-checked when used, so a bad mirror + /// object can make the node fail closed but never feeds it a key. + async fn restore_rows(store: &AtRestStore, mirror: &dyn KeyMirror) -> Result<(), AtRestError> { + let mut names = mirror.list(ARK_MIRROR_PREFIX).await.map_err(mirror_err)?; + if names.is_empty() { + return Ok(()); + } + names.sort(); + let have: BTreeMap = store + .keys() + .await? + .into_iter() + .map(|r| (r.generation, r)) + .collect(); + for name in names { + let bytes = + mirror + .get(&name) + .await + .map_err(mirror_err)? + .ok_or(AtRestError::Inconsistent( + "mirrored ARK vanished while listing", + ))?; + let row = serde_json::from_slice::(&bytes) + .map_err(mirror_err)? + .into_row() + .map_err(mirror_err)?; + match have.get(&row.generation) { + None => { + tracing::warn!( + generation = row.generation, + "at-rest ARK row was missing from the database; restored from the mirror" + ); + store.insert_key(row).await?; + } + Some(db) + if db.chain.is_none() && row.chain.is_some() && db.ark_id == row.ark_id => + { + tracing::warn!( + generation = row.generation, + "at-rest ARK chain was missing from the database; restored from the mirror" + ); + store + .set_chain(row.generation, row.chain.expect("checked")) + .await?; + } + Some(_) => {} + } + } + Ok(()) + } + + async fn mirror_row( + mirror: &dyn KeyMirror, + row: &at_rest_key::Model, + ) -> Result<(), AtRestError> { + let name = ark_mirror_name(row.generation as u32, &hex::encode(&row.ark_id)); + let body = serde_json::to_vec(&MirroredArk::from_row(row)).expect("serializable"); + if mirror.get(&name).await.map_err(mirror_err)?.as_deref() != Some(body.as_slice()) { + mirror.put(&name, body).await.map_err(mirror_err)?; + } + Ok(()) + } + + pub fn state(&self) -> KeyringState { + match &*self.phase.read() { + Phase::NotProvisioned => KeyringState::NotProvisioned, + Phase::Locked { .. } => KeyringState::Locked, + Phase::Unlocked(_) => KeyringState::Unlocked, + } + } + + fn keys(&self) -> Result, AtRestError> { + match &*self.phase.read() { + Phase::Unlocked(keys) => Ok(keys.clone()), + Phase::Locked { .. } => Err(AtRestError::Locked), + Phase::NotProvisioned => Err(AtRestError::NotProvisioned), + } + } + + fn parse_bundle(row: &at_rest_escrow::Model) -> Result { + let bundle = EscrowBundle::from_json(row.bundle.as_bytes())?; + if bundle.fingerprint().to_string() != row.efp { + return Err(AtRestError::Inconsistent( + "stored escrow bundle does not match its fingerprint", + )); + } + Ok(bundle) + } + + /// Make sure the newest generation is escrowed to exactly the configured + /// holders. A different holder list re-splits the same ARK into a new + /// bundle (new EFP, so a new ceremony); old bundles are kept inactive. + async fn ensure_escrow(&self) -> Result, AtRestError> { + if self.config.holders.is_empty() { + return Ok(None); + } + let keys = self.keys()?; + let (g, gen) = keys.newest(); + if let Some(row) = self.store.active_escrow(g as i32).await? { + let bundle = Self::parse_bundle(&row)?; + if bundle.holders() == self.config.holders && bundle.ark_id == gen.ark_id { + self.mirror_escrow(&row).await?; + return Ok(Some(bundle)); + } + } + let bundle = EscrowBundle::create( + &gen.ark, + gen.ark_id, + g, + &self.config.holders, + self.config.recipient_policy, + )?; + let row = at_rest_escrow::Model { + efp: bundle.fingerprint().to_string(), + generation: g as i32, + bundle: serde_json::to_string(&bundle).expect("serializable"), + active: true, + record: None, + created_at: now_rfc3339(), + }; + self.store.activate_escrow(row.clone()).await?; + self.mirror_escrow(&row).await?; + tracing::warn!( + generation = g, + efp = %row.efp, + "at-rest escrow bundle created; the escrow ceremony must verify it before encryption can be enabled" + ); + Ok(Some(bundle)) + } + + async fn mirror_escrow(&self, row: &at_rest_escrow::Model) -> Result<(), AtRestError> { + if self.escrow_mirrored.lock().as_deref() == Some(row.efp.as_str()) { + return Ok(()); + } + let name = escrow_mirror_name(&row.efp); + if self.mirror.get(&name).await.map_err(mirror_err)?.is_none() { + self.mirror + .put(&name, row.bundle.clone().into_bytes()) + .await + .map_err(mirror_err)?; + } + *self.escrow_mirrored.lock() = Some(row.efp.clone()); + Ok(()) + } + + /// The pinned bundle, from the database or else the mirror. Only a bundle + /// whose fingerprint equals the pin is returned. + async fn pinned_bundle(&self) -> Result, AtRestError> { + let Some(pinned) = self.config.pinned_efp else { + return Ok(None); + }; + let efp = pinned.to_string(); + if let Some(row) = self.store.escrow(&efp).await? { + return Self::parse_bundle(&row).map(Some); + } + let Some(bytes) = self + .mirror + .get(&escrow_mirror_name(&efp)) + .await + .map_err(mirror_err)? + else { + return Ok(None); + }; + let bundle = EscrowBundle::from_json(&bytes)?; + Ok((bundle.fingerprint() == pinned).then_some(bundle)) + } + + /// The bundle shares are checked against: the active one when unlocked, + /// the pinned one when locked (storage is untrusted, the pin is attested). + async fn intake_bundle(&self) -> Result { + let locked = match &*self.phase.read() { + Phase::NotProvisioned => { + return Err(ShareRefusal::NoBundle("keys are not provisioned")) + } + Phase::Locked { generation, ark_id } => Some((*generation, *ark_id)), + Phase::Unlocked(_) => None, + }; + match locked { + Some((generation, ark_id)) => { + let bundle = self.pinned_bundle().await?.ok_or(ShareRefusal::NoBundle( + "no bundle matches at_rest.escrow.verified_fp", + ))?; + if bundle.generation != generation || bundle.ark_id != ark_id { + return Err(ShareRefusal::NoBundle( + "the pinned bundle escrows a different ARK generation", + )); + } + Ok(bundle) + } + None => self.ensure_escrow().await?.ok_or(ShareRefusal::NoBundle( + "no escrow recipients are configured", + )), + } + } + + /// Facts for `/info` and `GET /at-rest/recovery`. + pub async fn escrow_view(&self) -> Result, AtRestError> { + let state = self.state(); + let bundle = match self.intake_bundle().await { + Ok(b) => b, + Err(ShareRefusal::Keyring(e)) => return Err(e), + Err(_) => return Ok(None), + }; + let verified = match state { + KeyringState::Unlocked => self.verified_record(&bundle).await?.is_some(), + _ => false, + }; + let intake = self.intake.lock().await; + let shares_held = if intake.efp == Some(bundle.fingerprint()) { + intake.held_indices() + } else { + Vec::new() + }; + Ok(Some(EscrowView { + state, + ark_id: bundle.ark_id, + generation: bundle.generation, + efp: bundle.fingerprint(), + canary_fp: bundle.canary_fp, + verified, + recovery_recipient: intake.identity.recipient(), + shares_held, + bundle, + })) + } + + /// The active escrow's verification record, only if its MAC verifies + /// under the loaded ARK. `None` when locked, unescrowed or unverified. + pub async fn verification_record(&self) -> Result, AtRestError> { + if self.state() != KeyringState::Unlocked { + return Ok(None); + } + match self.ensure_escrow().await? { + Some(bundle) => self.verified_record(&bundle).await, + None => Ok(None), + } + } + + async fn verified_record( + &self, + bundle: &EscrowBundle, + ) -> Result, AtRestError> { + let keys = self.keys()?; + let Some(gen) = keys.gens.get(&bundle.generation) else { + return Ok(None); + }; + let Some(row) = self.store.escrow(&bundle.fingerprint().to_string()).await? else { + return Ok(None); + }; + let Some(json) = row.record else { + return Ok(None); + }; + // A record this node can't read (newer version, corrupt) is an + // error to surface, not "unverified". + let record = VerificationRecord::from_json(json.as_bytes())?; + Ok(record.verify(&gen.ark, bundle).is_ok().then_some(record)) + } + + /// The encrypt gate. `escrow_none` is the explicit self-host + /// acknowledgement that no escrow exists; it only applies when no + /// recipients are configured. + pub async fn gate(&self, escrow_none: bool) -> Result { + match self.state() { + KeyringState::Locked => return Err(GateRefusal::Locked), + KeyringState::NotProvisioned => return Err(GateRefusal::NotProvisioned), + KeyringState::Unlocked => {} + } + if self.config.holders.is_empty() { + return if escrow_none { + Ok(GatePass::Unescrowed) + } else { + Err(GateRefusal::NoEscrow) + }; + } + let bundle = match self.ensure_escrow().await { + Ok(Some(b)) => b, + Ok(None) => return Err(GateRefusal::NoEscrow), + Err(e) => return Err(GateRefusal::Unavailable(e.to_string())), + }; + if bundle.holders() != self.config.holders { + return Err(GateRefusal::RecipientsMismatch); + } + if bundle + .check_recipients(self.config.recipient_policy) + .is_err() + { + return Err(GateRefusal::SoftwareRecipients); + } + let efp = bundle.fingerprint(); + match self.config.pinned_efp { + None => { + return Err(GateRefusal::NotPinned { + efp: efp.to_string(), + }) + } + Some(p) if p != efp => { + return Err(GateRefusal::PinMismatch { + pinned: p.to_string(), + efp: efp.to_string(), + }) + } + Some(_) => {} + } + match self.verified_record(&bundle).await { + Ok(Some(_)) => Ok(GatePass::Verified { efp }), + Ok(None) => Err(GateRefusal::NotVerified { + efp: efp.to_string(), + }), + Err(e) => Err(GateRefusal::Unavailable(e.to_string())), + } + } + + /// Take one wrapped share (age file to this boot's recovery recipient). + /// + /// Each share is checked against its commitment before it occupies one of + /// three slots (one per index); submissions are rate limited per boot. + /// Share bytes are never logged. + pub async fn submit_share(&self, wrapped: &[u8]) -> Result { + let mut intake = self.intake.lock().await; + if !intake.take_token() { + return Err(ShareRefusal::RateLimited); + } + let share = intake.identity.open(wrapped)?; + let bundle = self.intake_bundle().await?; + bundle.verify_share(&share)?; + let efp = bundle.fingerprint(); + if intake.efp != Some(efp) { + intake.clear(); + intake.efp = Some(efp); + } + let index = share.index; + intake.held[usize::from(index) - 1] = Some(share); + tracing::info!(%efp, index, "at-rest escrow share accepted"); + + let held: Vec = intake.held.iter().flatten().cloned().collect(); + let outcome = match self.state() { + KeyringState::Locked if held.len() >= 2 => { + // A failed combine drops the held shares; holders resubmit. + let ark = bundle + .recover(&held[0], &held[1]) + .inspect_err(|_| intake.clear())?; + self.unlock_with(ark, &bundle).await?; + intake.clear(); + ShareOutcome::Unlocked + } + KeyringState::Unlocked if held.len() == 3 => { + let keys = self.keys()?; + let gen = keys + .gens + .get(&bundle.generation) + .ok_or(AtRestError::Inconsistent( + "escrowed generation is not loaded", + ))?; + let unlock = bundle + .test_unlock(&gen.ark, &held) + .inspect_err(|_| intake.clear())?; + let record = VerificationRecord::seal( + &gen.ark, + &bundle, + &unlock, + self.config.app_id.clone(), + self.config.compose_hash.clone(), + now_rfc3339(), + )?; + let json = serde_json::to_string(&record).expect("serializable"); + self.store + .save_record(&efp.to_string(), json.clone()) + .await + .map_err(AtRestError::from)?; + self.mirror + .put( + &format!("{MIRROR_PREFIX}record-{efp}.json"), + json.into_bytes(), + ) + .await + .map_err(mirror_err)?; + intake.clear(); + tracing::warn!(%efp, "at-rest escrow verified: all three shares rebuilt the ARK"); + ShareOutcome::Verified { efp } + } + _ => ShareOutcome::Held { + shares_held: intake.held_indices(), + }, + }; + Ok(outcome) + } + + /// Locked → unlocked with a recovered newest ARK: re-seal every + /// generation under this node's W, mirror, and serve keys. + async fn unlock_with(&self, ark: Ark, bundle: &EscrowBundle) -> Result<(), AtRestError> { + let rows = self.store.keys().await?; + let newest = rows.last().ok_or(AtRestError::NotProvisioned)?; + if newest.generation as u32 != bundle.generation || ark_id_of(newest)? != bundle.ark_id { + return Err(AtRestError::Inconsistent( + "recovered ARK is not the newest generation", + )); + } + // Under a new root the rows still carry the old W: every older + // generation must open by its chain, or recovery fails loudly. + let (keys, _) = keys_from_newest(&rows, ark, None)?; + for (g, gen) in &keys.gens { + self.store + .reseal_key( + *g as i32, + W_LABEL, + seal_ark(&self.w, &gen.ark, &gen.ark_id, *g), + ) + .await?; + } + for row in self.store.keys().await? { + Self::mirror_row(&*self.mirror, &row).await?; + } + *self.phase.write() = Phase::Unlocked(Arc::new(keys)); + tracing::warn!( + generation = bundle.generation, + "at-rest keys recovered from escrow and resealed under this node's root" + ); + Ok(()) + } + + /// Start ARK generation n+1. Older generations stay readable through the + /// chain; new DEKs wrap under the new KEK. The new generation has no + /// verified escrow until a ceremony runs, so the gate closes. + /// + /// The new row and the older generation's chain are written in one + /// transaction. If another node rotated first, nothing is written, this + /// keyring reloads what is stored, and the call returns + /// [`AtRestError::Conflict`]. + pub async fn rotate_ark(&self) -> Result { + let keys = self.keys()?; + let (g, old) = keys.newest(); + let next = g + 1; + let ark = Ark::generate(); + let ark_id = ArkId::generate(); + let won = self + .store + .rotate( + at_rest_key::Model { + generation: next as i32, + ark_id: ark_id.0.to_vec(), + w_label: W_LABEL.into(), + sealed: seal_ark(&self.w, &ark, &ark_id, next), + chain: None, + created_at: now_rfc3339(), + }, + chain_ark(&ark, &ark_id, &old.ark, &old.ark_id, g), + ) + .await?; + let rows = self.store.keys().await?; + let (keys, rechain) = unlock_rows(&self.w, &rows)?.ok_or(AtRestError::Inconsistent( + "the stored newest generation does not open under this node's root", + ))?; + Self::store_rechain(&self.store, rechain).await?; + for row in &self.store.keys().await? { + Self::mirror_row(&*self.mirror, row).await?; + } + *self.phase.write() = Phase::Unlocked(Arc::new(keys)); + self.current.lock().clear(); + self.ensure_escrow().await?; + if !won { + return Err(AtRestError::Conflict( + "another node rotated the ARK first; reloaded the stored generations", + )); + } + tracing::warn!(generation = next, %ark_id, "at-rest ARK rotated"); + Ok(next) + } + + /// A space's DEK row from the mirror, put back into the database. + async fn restore_dek( + &self, + keys: &Keys, + space: &str, + dek_id: u32, + ) -> Result, DataKeyError> { + let name = dek_mirror_name(&keys.dek_tag.tag(space), dek_id); + let Some(bytes) = self + .mirror + .get(&name) + .await + .map_err(|e| backend(mirror_err(e)))? + else { + return Ok(None); + }; + let row = serde_json::from_slice::(&bytes) + .map_err(|e| backend(mirror_err(e)))? + .into_row(space) + .map_err(|e| backend(mirror_err(e)))?; + if row.dek_id as u32 != dek_id { + return Err(backend(AtRestError::Inconsistent( + "mirrored data key has a different id than its name", + ))); + } + tracing::warn!( + dek_id, + "at-rest data key was missing from the database; restored from the mirror" + ); + self.store.insert_dek(row).await.map_err(backend)?; + let id = i32::try_from(dek_id).map_err(|_| DataKeyError::UnknownKey { + dek_id: DekId::new(dek_id), + })?; + self.store.dek(space, id).await.map_err(backend) + } + + /// Make sure the mirror holds exactly this DEK row before any object is + /// sealed under it. A different mirrored row with the same id means + /// objects may exist under another key: refuse, never overwrite. + async fn mirror_dek( + &self, + keys: &Keys, + row: &space_data_key::Model, + ) -> Result<(), DataKeyError> { + let name = dek_mirror_name(&keys.dek_tag.tag(&row.space), row.dek_id as u32); + match self + .mirror + .get(&name) + .await + .map_err(|e| backend(mirror_err(e)))? + { + None => { + let body = serde_json::to_vec(&MirroredDek::from_row(row)).expect("serializable"); + self.mirror + .put(&name, body) + .await + .map_err(|e| backend(mirror_err(e))) + } + Some(bytes) => { + let same = serde_json::from_slice::(&bytes) + .ok() + .and_then(|m| m.into_row(&row.space).ok()) + .is_some_and(|m| { + m.dek_id == row.dek_id + && m.generation == row.generation + && m.wrapped == row.wrapped + }); + if same { + Ok(()) + } else { + Err(backend(AtRestError::Inconsistent( + "the mirror holds a different data key with this id", + ))) + } + } + } + } + + /// Generate, store and return the space's first DEK row. If another + /// process won the insert, its row is returned. + async fn create_dek( + &self, + keys: &Keys, + space: &str, + ) -> Result { + let (g, gen) = keys.newest(); + let (wrapped, _) = new_dek(&gen.kek, space, DekId::new(1), g, &gen.ark_id); + self.store + .insert_dek(space_data_key::Model { + space: space.to_owned(), + dek_id: 1, + generation: g as i32, + wrapped, + created_at: now_rfc3339(), + }) + .await + .map_err(backend)?; + self.store + .dek(space, 1) + .await + .map_err(backend)? + .ok_or_else(|| backend(AtRestError::Inconsistent("inserted data key is missing"))) + } + + async fn load_dek( + &self, + keys: &Keys, + row: &space_data_key::Model, + ) -> Result { + let dek_id = DekId::new(row.dek_id as u32); + let gen = keys.gens.get(&(row.generation as u32)).ok_or_else(|| { + backend(AtRestError::Inconsistent( + "data key names an unknown ARK generation", + )) + })?; + let key = unwrap_dek( + &gen.kek, + &row.wrapped, + &row.space, + dek_id, + row.generation as u32, + &gen.ark_id, + ) + .map_err(backend)?; + self.deks + .lock() + .put((row.space.clone(), dek_id.get()), key.clone()); + Ok(key) + } +} + +fn locked(e: AtRestError) -> DataKeyError { + match e { + AtRestError::Locked | AtRestError::NotProvisioned => DataKeyError::Locked, + other => backend(other), + } +} + +#[async_trait::async_trait] +impl DataKeyProvider for AtRestKeyring { + async fn current_key(&self, space: &str) -> Result { + let keys = self.keys().map_err(locked)?; + let cached = self.current.lock().get(space).copied(); + if let Some(id) = cached { + return self.key(space, id).await; + } + let row = match self.store.newest_dek(space).await.map_err(backend)? { + Some(row) => row, + None => { + let _create = self.dek_create.lock().await; + match self.store.newest_dek(space).await.map_err(backend)? { + Some(row) => row, + // A database restored to before this space's first DEK: + // objects may still be sealed under the mirrored one. + None => match self.restore_dek(&keys, space, 1).await? { + Some(row) => row, + None => self.create_dek(&keys, space).await?, + }, + } + } + }; + // First use of this space's current key in this process. + self.mirror_dek(&keys, &row).await?; + let key = self.load_dek(&keys, &row).await?; + self.current.lock().put(space.to_owned(), key.id()); + Ok(key) + } + + async fn key(&self, space: &str, dek_id: DekId) -> Result { + let keys = self.keys().map_err(locked)?; + if let Some(key) = self.deks.lock().get(&(space.to_owned(), dek_id.get())) { + return Ok(key.clone()); + } + let Ok(id) = i32::try_from(dek_id.get()) else { + return Err(DataKeyError::UnknownKey { dek_id }); + }; + let row = match self.store.dek(space, id).await.map_err(backend)? { + Some(row) => Some(row), + None => self.restore_dek(&keys, space, dek_id.get()).await?, + }; + match row { + Some(row) => self.load_dek(&keys, &row).await, + None => Err(DataKeyError::UnknownKey { dek_id }), + } + } + + fn object_name_key(&self) -> Result { + Ok(self.keys().map_err(locked)?.nk.clone()) + } +} + +#[cfg(test)] +mod tests; diff --git a/tinycloud-core/src/at_rest/keyring/tests.rs b/tinycloud-core/src/at_rest/keyring/tests.rs new file mode 100644 index 00000000..81b6321b --- /dev/null +++ b/tinycloud-core/src/at_rest/keyring/tests.rs @@ -0,0 +1,944 @@ +use std::io::Read; +use std::sync::Arc; + +use sea_orm::{ + ColumnTrait, ConnectOptions, Database, DatabaseConnection, EntityTrait, QueryFilter, +}; +use sea_orm_migration::MigratorTrait; +use tinycloud_at_rest_escrow::{wrap_share, EscrowBundle, HolderRecipient, SharePayload}; + +use super::*; +use crate::at_rest::store::MemoryMirror; +use crate::models::{at_rest_escrow, at_rest_key, space_data_key}; + +const ROOT: [u8; 64] = [7; 64]; +const NEW_ROOT: [u8; 64] = [9; 64]; + +async fn db() -> DatabaseConnection { + let mut options = ConnectOptions::new("sqlite::memory:".to_string()); + options.max_connections(1).sqlx_logging(false); + let db = Database::connect(options).await.unwrap(); + crate::migrations::Migrator::up(&db, None).await.unwrap(); + db +} + +struct Holders { + ids: Vec, + recipients: Vec, +} + +fn holders() -> Holders { + let ids: Vec<_> = (0..3).map(|_| age::x25519::Identity::generate()).collect(); + let recipients = ["sam", "hunter", "safe"] + .iter() + .zip(&ids) + .map(|(h, id)| HolderRecipient { + holder: h.to_string(), + recipient: id.to_public().to_string(), + }) + .collect(); + Holders { ids, recipients } +} + +impl Holders { + /// What holder `i` gets from `age -d`. + fn decrypt(&self, bundle: &EscrowBundle, i: usize) -> SharePayload { + let armored = &bundle.shares[i].ciphertext; + let decryptor = + age::Decryptor::new_buffered(age::armor::ArmoredReader::new(armored.as_bytes())) + .unwrap(); + let mut reader = decryptor + .decrypt(std::iter::once(&self.ids[i] as &dyn age::Identity)) + .unwrap(); + let mut plain = Vec::new(); + reader.read_to_end(&mut plain).unwrap(); + bundle.open_share(&plain).unwrap() + } +} + +fn config(h: &Holders, pinned: Option) -> KeyringConfig { + KeyringConfig { + provision: true, + holders: h.recipients.clone(), + recipient_policy: RecipientPolicy::AllowSoftware, + pinned_efp: pinned, + app_id: Some("app".into()), + compose_hash: Some("compose".into()), + } +} + +async fn open( + db: &DatabaseConnection, + mirror: &Arc, + root: &[u8], + config: KeyringConfig, +) -> Result { + AtRestKeyring::open(AtRestStore::new(db.clone()), mirror.clone(), root, config).await +} + +/// Holder `i` wraps their share to the keyring's current recovery key. +async fn submit(k: &AtRestKeyring, h: &Holders, i: usize) -> Result { + let view = k.escrow_view().await.unwrap().unwrap(); + let share = h.decrypt(&view.bundle, i); + let wrapped = wrap_share(&view.recovery_recipient, &share).unwrap(); + k.submit_share(wrapped.as_bytes()).await +} + +#[tokio::test] +async fn provision_then_reopen_with_same_root_serves_the_same_keys() { + let (db, mirror, h) = (db().await, Arc::new(MemoryMirror::default()), holders()); + let k = open(&db, &mirror, &ROOT, config(&h, None)).await.unwrap(); + assert_eq!(k.state(), KeyringState::Unlocked); + let dek = k.current_key("space-a").await.unwrap(); + assert_eq!(dek.id(), DekId::new(1)); + let nk = k.object_name_key().unwrap(); + // Mirror holds the sealed ARK and the escrow bundle. + let names = mirror.names(); + assert!(names.iter().any(|n| n.starts_with(ARK_MIRROR_PREFIX))); + assert!(names.iter().any(|n| n.contains("/escrow-"))); + drop(k); + + let k = open(&db, &mirror, &ROOT, config(&h, None)).await.unwrap(); + assert_eq!(k.state(), KeyringState::Unlocked); + assert_eq!(k.current_key("space-a").await.unwrap().bytes(), dek.bytes()); + assert_eq!(k.object_name_key().unwrap().bytes(), nk.bytes()); + assert_eq!(at_rest_key::Entity::find().all(&db).await.unwrap().len(), 1); +} + +#[tokio::test] +async fn nothing_is_created_without_provisioning() { + let (db, mirror) = (db().await, Arc::new(MemoryMirror::default())); + let k = open(&db, &mirror, &ROOT, KeyringConfig::default()) + .await + .unwrap(); + assert_eq!(k.state(), KeyringState::NotProvisioned); + assert!(matches!( + k.current_key("s").await, + Err(DataKeyError::Locked) + )); + assert!(at_rest_key::Entity::find() + .all(&db) + .await + .unwrap() + .is_empty()); + assert!(mirror.names().is_empty()); +} + +#[tokio::test] +async fn data_keys_converge_are_bound_and_never_invented() { + let (db, mirror, h) = (db().await, Arc::new(MemoryMirror::default()), holders()); + let k = Arc::new(open(&db, &mirror, &ROOT, config(&h, None)).await.unwrap()); + // A second keyring on the same database stands in for another process. + let other = Arc::new(open(&db, &mirror, &ROOT, config(&h, None)).await.unwrap()); + let tasks: Vec<_> = (0..8) + .map(|i| { + let k = if i % 2 == 0 { k.clone() } else { other.clone() }; + tokio::spawn(async move { k.current_key("space-a").await.unwrap() }) + }) + .collect(); + let keys: Vec = futures::future::join_all(tasks) + .await + .into_iter() + .map(|r| r.unwrap()) + .collect(); + assert!(keys + .iter() + .all(|key| key.id() == DekId::new(1) && key.bytes() == keys[0].bytes())); + assert_eq!( + space_data_key::Entity::find().all(&db).await.unwrap().len(), + 1 + ); + + let b = k.current_key("space-b").await.unwrap(); + assert_ne!(b.bytes(), keys[0].bytes()); + assert!(matches!( + k.key("space-a", DekId::new(2)).await, + Err(DataKeyError::UnknownKey { dek_id }) if dek_id == DekId::new(2) + )); + assert!(matches!( + k.key("space-a", DekId::new(u32::MAX)).await, + Err(DataKeyError::UnknownKey { .. }) + )); + + // A wrapped DEK copied to another space fails authentication instead of + // serving space-a's key for space-c. + let row = space_data_key::Entity::find_by_id(("space-a".to_string(), 1)) + .one(&db) + .await + .unwrap() + .unwrap(); + AtRestStore::new(db.clone()) + .insert_dek(space_data_key::Model { + space: "space-c".into(), + ..row + }) + .await + .unwrap(); + let fresh = open(&db, &mirror, &ROOT, config(&h, None)).await.unwrap(); + assert!(matches!( + fresh.key("space-c", DekId::new(1)).await, + Err(DataKeyError::Backend(_)) + )); +} + +#[tokio::test] +async fn new_root_boots_locked_and_two_shares_unlock_without_restart() { + let (db, mirror, h) = (db().await, Arc::new(MemoryMirror::default()), holders()); + let k = open(&db, &mirror, &ROOT, config(&h, None)).await.unwrap(); + let dek = k.current_key("space-a").await.unwrap(); + let nk = k.object_name_key().unwrap(); + let efp = k.escrow_view().await.unwrap().unwrap().efp; + drop(k); + + // New app id / KMS: a different root. Locked, not lost. + let k = open(&db, &mirror, &NEW_ROOT, config(&h, Some(efp))) + .await + .unwrap(); + assert_eq!(k.state(), KeyringState::Locked); + assert!(matches!( + k.current_key("space-a").await, + Err(DataKeyError::Locked) + )); + assert!(matches!(k.object_name_key(), Err(DataKeyError::Locked))); + assert_eq!(k.gate(false).await, Err(GateRefusal::Locked)); + + // One share does nothing. + assert_eq!( + submit(&k, &h, 2).await.unwrap(), + ShareOutcome::Held { + shares_held: vec![3] + } + ); + assert_eq!(k.state(), KeyringState::Locked); + // A second distinct share recovers and reseals. + assert_eq!(submit(&k, &h, 0).await.unwrap(), ShareOutcome::Unlocked); + assert_eq!(k.state(), KeyringState::Unlocked); + assert_eq!( + k.key("space-a", DekId::new(1)).await.unwrap().bytes(), + dek.bytes() + ); + assert_eq!(k.object_name_key().unwrap().bytes(), nk.bytes()); + drop(k); + + // Resealed under the new root: it now boots unlocked; the old root is locked out. + let k = open(&db, &mirror, &NEW_ROOT, config(&h, Some(efp))) + .await + .unwrap(); + assert_eq!(k.state(), KeyringState::Unlocked); + assert_eq!(k.current_key("space-a").await.unwrap().bytes(), dek.bytes()); + let old = open(&db, &mirror, &ROOT, config(&h, Some(efp))) + .await + .unwrap(); + assert_eq!(old.state(), KeyringState::Locked); +} + +#[tokio::test] +async fn locked_node_trusts_only_the_pinned_bundle() { + let (db, mirror, h) = (db().await, Arc::new(MemoryMirror::default()), holders()); + let k = open(&db, &mirror, &ROOT, config(&h, None)).await.unwrap(); + let real = k.escrow_view().await.unwrap().unwrap(); + drop(k); + + // No pin: nothing to check shares against. + let k = open(&db, &mirror, &NEW_ROOT, config(&h, None)) + .await + .unwrap(); + assert!(k.escrow_view().await.unwrap().is_none()); + let share = h.decrypt(&real.bundle, 0); + let wrapped = wrap_share(&k.intake.lock().await.identity.recipient(), &share).unwrap(); + assert!(matches!( + k.submit_share(wrapped.as_bytes()).await, + Err(ShareRefusal::NoBundle(_)) + )); + + // A pin that isn't the stored bundle (e.g. storage swapped the bundle). + let k = open(&db, &mirror, &NEW_ROOT, config(&h, Some(Digest32([1; 32])))) + .await + .unwrap(); + assert!(k.escrow_view().await.unwrap().is_none()); + + // Shares of a different ARK (another bundle) are refused before a slot. + let k = open(&db, &mirror, &NEW_ROOT, config(&h, Some(real.efp))) + .await + .unwrap(); + let foreign = EscrowBundle::create( + &Ark::generate(), + ArkId::generate(), + 1, + &h.recipients, + RecipientPolicy::AllowSoftware, + ) + .unwrap(); + let wrapped = wrap_share( + &k.escrow_view().await.unwrap().unwrap().recovery_recipient, + &h.decrypt(&foreign, 1), + ) + .unwrap(); + assert!(matches!( + k.submit_share(wrapped.as_bytes()).await, + Err(ShareRefusal::Invalid(_)) + )); + assert!(k + .escrow_view() + .await + .unwrap() + .unwrap() + .shares_held + .is_empty()); + // Junk and wrong-key age files too. + assert!(k.submit_share(b"junk").await.is_err()); +} + +#[tokio::test] +async fn pitr_restore_without_ark_rows_recovers_from_the_mirror() { + let (db, mirror, h) = (db().await, Arc::new(MemoryMirror::default()), holders()); + let k = open(&db, &mirror, &ROOT, config(&h, None)).await.unwrap(); + let dek = k.current_key("space-a").await.unwrap(); + drop(k); + at_rest_key::Entity::delete_many().exec(&db).await.unwrap(); + + let k = open(&db, &mirror, &ROOT, config(&h, None)).await.unwrap(); + assert_eq!(k.state(), KeyringState::Unlocked); + assert_eq!( + k.key("space-a", DekId::new(1)).await.unwrap().bytes(), + dek.bytes() + ); +} + +#[tokio::test] +async fn never_provisions_over_uncertainty() { + let (db, mirror, h) = (db().await, Arc::new(MemoryMirror::default()), holders()); + let k = open(&db, &mirror, &ROOT, config(&h, None)).await.unwrap(); + k.current_key("space-a").await.unwrap(); + drop(k); + at_rest_key::Entity::delete_many().exec(&db).await.unwrap(); + + // Mirror unreachable: fail, don't make a fresh ARK. + mirror.set_failing(true); + assert!(matches!( + open(&db, &mirror, &ROOT, config(&h, None)).await, + Err(AtRestError::Mirror(_)) + )); + // Mirror empty but data keys exist: inconsistent, don't make a fresh ARK. + mirror.set_failing(false); + mirror.clear(); + assert!(matches!( + open(&db, &mirror, &ROOT, config(&h, None)).await, + Err(AtRestError::Inconsistent(_)) + )); + assert!(at_rest_key::Entity::find() + .all(&db) + .await + .unwrap() + .is_empty()); +} + +#[tokio::test] +async fn gate_requires_pinned_verified_escrow_for_the_configured_recipients() { + let (db, mirror, h) = (db().await, Arc::new(MemoryMirror::default()), holders()); + let k = open(&db, &mirror, &ROOT, config(&h, None)).await.unwrap(); + let efp = k.escrow_view().await.unwrap().unwrap().efp; + assert_eq!( + k.gate(false).await, + Err(GateRefusal::NotPinned { + efp: efp.to_string() + }) + ); + drop(k); + + let k = open(&db, &mirror, &ROOT, config(&h, Some(Digest32([2; 32])))) + .await + .unwrap(); + assert!(matches!( + k.gate(false).await, + Err(GateRefusal::PinMismatch { .. }) + )); + drop(k); + + // Pinned but no ceremony yet. + let k = open(&db, &mirror, &ROOT, config(&h, Some(efp))) + .await + .unwrap(); + assert_eq!( + k.gate(false).await, + Err(GateRefusal::NotVerified { + efp: efp.to_string() + }) + ); + + // Ceremony test-unlock: two shares are held, the third verifies. + assert_eq!( + submit(&k, &h, 0).await.unwrap(), + ShareOutcome::Held { + shares_held: vec![1] + } + ); + assert_eq!( + submit(&k, &h, 2).await.unwrap(), + ShareOutcome::Held { + shares_held: vec![1, 3] + } + ); + assert_eq!( + submit(&k, &h, 1).await.unwrap(), + ShareOutcome::Verified { efp } + ); + assert_eq!(k.gate(false).await, Ok(GatePass::Verified { efp })); + assert!(k.escrow_view().await.unwrap().unwrap().verified); + assert_eq!( + k.verification_record().await.unwrap().map(|r| r.efp), + Some(efp) + ); + let record = at_rest_escrow::Entity::find_by_id(efp.to_string()) + .one(&db) + .await + .unwrap() + .unwrap() + .record + .unwrap(); + let parsed: VerificationRecord = serde_json::from_str(&record).unwrap(); + assert_eq!(parsed.app_id.as_deref(), Some("app")); + drop(k); + + // A record edited in storage no longer verifies. + let mut forged: serde_json::Value = serde_json::from_str(&record).unwrap(); + forged["verified_at"] = serde_json::json!("2030-01-01T00:00:00Z"); + AtRestStore::new(db.clone()) + .save_record(&efp.to_string(), forged.to_string()) + .await + .unwrap(); + let k = open(&db, &mirror, &ROOT, config(&h, Some(efp))) + .await + .unwrap(); + assert!(matches!( + k.gate(false).await, + Err(GateRefusal::NotVerified { .. }) + )); + // A record this node can't read (a newer format) is not "unverified": + // the operator must fix the node, not re-run the ceremony. + let mut newer: serde_json::Value = serde_json::from_str(&record).unwrap(); + newer["version"] = serde_json::json!(2); + AtRestStore::new(db.clone()) + .save_record(&efp.to_string(), newer.to_string()) + .await + .unwrap(); + assert!(matches!( + k.gate(false).await, + Err(GateRefusal::Unavailable(e)) if e.contains("version 2") + )); + AtRestStore::new(db.clone()) + .save_record(&efp.to_string(), record) + .await + .unwrap(); + assert_eq!(k.gate(false).await, Ok(GatePass::Verified { efp })); + drop(k); + + // New recipients in config re-split the same ARK: a new bundle, and the + // old pin no longer opens the gate. + let changed = holders(); + let k = open(&db, &mirror, &ROOT, config(&changed, Some(efp))) + .await + .unwrap(); + let new_efp = k.escrow_view().await.unwrap().unwrap().efp; + assert_ne!(new_efp, efp); + assert!(matches!( + k.gate(false).await, + Err(GateRefusal::PinMismatch { .. }) + )); + let bundles = at_rest_escrow::Entity::find().all(&db).await.unwrap(); + assert_eq!(bundles.len(), 2); + assert_eq!(bundles.iter().filter(|b| b.active).count(), 1); +} + +#[tokio::test] +async fn software_holders_need_the_opt_in_to_escrow_and_to_pass_the_gate() { + let (db, mirror, h) = (db().await, Arc::new(MemoryMirror::default()), holders()); + let hardware_only = |pinned| KeyringConfig { + recipient_policy: RecipientPolicy::HardwareOnly, + ..config(&h, pinned) + }; + // Hardware-only config with X25519 holders: no escrow is made. + assert!(matches!( + open(&db, &mirror, &ROOT, hardware_only(None)).await, + Err(AtRestError::Escrow(_)) + )); + // Escrowed and verified under the opt-in... + let k = open(&db, &mirror, &ROOT, config(&h, None)).await.unwrap(); + let efp = k.escrow_view().await.unwrap().unwrap().efp; + drop(k); + let k = open(&db, &mirror, &ROOT, config(&h, Some(efp))) + .await + .unwrap(); + for i in 0..3 { + submit(&k, &h, i).await.unwrap(); + } + assert_eq!(k.gate(false).await, Ok(GatePass::Verified { efp })); + drop(k); + // ...still doesn't open the gate once the opt-in is removed. + let k = open(&db, &mirror, &ROOT, hardware_only(Some(efp))) + .await + .unwrap(); + assert_eq!(k.gate(false).await, Err(GateRefusal::SoftwareRecipients)); +} + +#[tokio::test] +async fn self_host_without_escrow_needs_the_explicit_acknowledgement() { + let (db, mirror) = (db().await, Arc::new(MemoryMirror::default())); + let config = KeyringConfig { + provision: true, + ..Default::default() + }; + let k = open(&db, &mirror, &ROOT, config).await.unwrap(); + assert_eq!(k.gate(false).await, Err(GateRefusal::NoEscrow)); + assert_eq!(k.gate(true).await, Ok(GatePass::Unescrowed)); + assert!(k.escrow_view().await.unwrap().is_none()); + assert!(at_rest_escrow::Entity::find() + .all(&db) + .await + .unwrap() + .is_empty()); +} + +#[tokio::test] +async fn rotation_keeps_old_generations_and_survives_recovery() { + let (db, mirror, h) = (db().await, Arc::new(MemoryMirror::default()), holders()); + let k = open(&db, &mirror, &ROOT, config(&h, None)).await.unwrap(); + let old = k.current_key("space-a").await.unwrap(); + let nk = k.object_name_key().unwrap(); + assert_eq!(k.rotate_ark().await.unwrap(), 2); + // Old DEK still served; a new space wraps under generation 2; NK pinned to gen 1. + assert_eq!( + k.key("space-a", DekId::new(1)).await.unwrap().bytes(), + old.bytes() + ); + k.current_key("space-b").await.unwrap(); + let b_row = space_data_key::Entity::find_by_id(("space-b".to_string(), 1)) + .one(&db) + .await + .unwrap() + .unwrap(); + assert_eq!(b_row.generation, 2); + assert_eq!(k.object_name_key().unwrap().bytes(), nk.bytes()); + let view = k.escrow_view().await.unwrap().unwrap(); + assert_eq!(view.generation, 2); + assert!(!view.verified); + drop(k); + + // Escrow of generation 2 alone recovers both generations on a new root. + let k = open(&db, &mirror, &NEW_ROOT, config(&h, Some(view.efp))) + .await + .unwrap(); + assert_eq!(k.state(), KeyringState::Locked); + submit(&k, &h, 0).await.unwrap(); + assert_eq!(submit(&k, &h, 1).await.unwrap(), ShareOutcome::Unlocked); + assert_eq!( + k.key("space-a", DekId::new(1)).await.unwrap().bytes(), + old.bytes() + ); + assert_eq!(k.object_name_key().unwrap().bytes(), nk.bytes()); + assert_eq!(at_rest_key::Entity::find().all(&db).await.unwrap().len(), 2); +} + +#[tokio::test] +async fn share_intake_is_rate_limited_per_boot() { + let (db, mirror, h) = (db().await, Arc::new(MemoryMirror::default()), holders()); + let k = open(&db, &mirror, &ROOT, config(&h, None)).await.unwrap(); + for _ in 0..SHARE_BURST { + assert!(!matches!( + k.submit_share(b"junk").await, + Err(ShareRefusal::RateLimited) + )); + } + assert!(matches!( + k.submit_share(b"junk").await, + Err(ShareRefusal::RateLimited) + )); + // Valid shares are throttled the same way. + assert!(matches!( + submit(&k, &h, 0).await, + Err(ShareRefusal::RateLimited) + )); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn postgres_concurrent_writers_converge_on_one_row_each() { + let Some(pg) = crate::test_support::PostgresTestSchema::new( + "postgres_concurrent_writers_converge_on_one_row_each", + ) + .await + else { + return; + }; + let (mirror, h) = (Arc::new(MemoryMirror::default()), holders()); + // Two processes booting at once against the same database (pool of 4). + let (a, b) = tokio::join!( + open(&pg.conn, &mirror, &ROOT, config(&h, None)), + open(&pg.conn, &mirror, &ROOT, config(&h, None)), + ); + let (a, b) = (Arc::new(a.unwrap()), Arc::new(b.unwrap())); + assert_eq!( + at_rest_key::Entity::find() + .all(&pg.conn) + .await + .unwrap() + .len(), + 1 + ); + assert_eq!( + a.object_name_key().unwrap().bytes(), + b.object_name_key().unwrap().bytes() + ); + + let tasks: Vec<_> = (0..16) + .map(|i| { + let k = if i % 2 == 0 { a.clone() } else { b.clone() }; + tokio::spawn(async move { k.current_key(&format!("space-{}", i % 4)).await.unwrap() }) + }) + .collect(); + let keys: Vec = futures::future::join_all(tasks) + .await + .into_iter() + .map(|r| r.unwrap()) + .collect(); + for (i, key) in keys.iter().enumerate() { + assert_eq!(key.id(), DekId::new(1)); + assert_eq!(key.bytes(), keys[i % 4].bytes(), "space-{} diverged", i % 4); + } + assert_eq!( + space_data_key::Entity::find() + .all(&pg.conn) + .await + .unwrap() + .len(), + 4 + ); + + // Store conflicts keep the first row, deterministically, on PG too. + store_conflicts_keep_the_first_row(&pg.conn).await; + + // Concurrent re-splits of one generation leave exactly one active bundle. + let store = AtRestStore::new(pg.conn.clone()); + for round in 0..50 { + let row = |n: u8| at_rest_escrow::Model { + efp: format!("{round:04}-{n}"), + generation: 1, + bundle: "{}".into(), + active: true, + record: None, + created_at: now_rfc3339(), + }; + let (x, y) = tokio::join!(store.activate_escrow(row(1)), store.activate_escrow(row(2))); + x.unwrap(); + y.unwrap(); + let active = at_rest_escrow::Entity::find() + .all(&pg.conn) + .await + .unwrap() + .into_iter() + .filter(|b| b.generation == 1 && b.active) + .count(); + assert_eq!(active, 1, "round {round}"); + } +} + +/// Both conflict branches return `Ok` and keep the first row; a rotation +/// onto an existing generation writes nothing. +async fn store_conflicts_keep_the_first_row(db: &DatabaseConnection) { + let store = AtRestStore::new(db.clone()); + let key = |ark_id: u8, generation: i32| at_rest_key::Model { + generation, + ark_id: vec![ark_id; 16], + w_label: W_LABEL.into(), + sealed: vec![ark_id; 60], + chain: None, + created_at: now_rfc3339(), + }; + let gen1 = store.keys().await.unwrap()[0].clone(); + store.insert_key(key(0xee, 1)).await.unwrap(); + assert_eq!(store.keys().await.unwrap()[0], gen1); + + let dek = |wrapped: u8| space_data_key::Model { + space: "conflict".into(), + dek_id: 1, + generation: 1, + wrapped: vec![wrapped; 60], + created_at: now_rfc3339(), + }; + store.insert_dek(dek(1)).await.unwrap(); + store.insert_dek(dek(2)).await.unwrap(); + assert_eq!( + store.dek("conflict", 1).await.unwrap().unwrap().wrapped, + vec![1; 60] + ); + + // Generation 1 exists: rotating "to" it writes neither row nor chain. + let before = store.keys().await.unwrap(); + assert!(!store.rotate(key(0xdd, 1), vec![1; 60]).await.unwrap()); + assert_eq!(store.keys().await.unwrap(), before); +} + +#[tokio::test] +async fn sqlite_store_conflicts_keep_the_first_row() { + let (db, mirror, h) = (db().await, Arc::new(MemoryMirror::default()), holders()); + open(&db, &mirror, &ROOT, config(&h, None)).await.unwrap(); + store_conflicts_keep_the_first_row(&db).await; +} + +async fn set_chain(db: &DatabaseConnection, generation: i32, chain: Option>) { + at_rest_key::Entity::update_many() + .col_expr(at_rest_key::Column::Chain, chain.into()) + .filter(at_rest_key::Column::Generation.eq(generation)) + .exec(db) + .await + .unwrap(); +} + +fn chain_of(rows: &[at_rest_key::Model], generation: i32) -> Option> { + rows.iter() + .find(|r| r.generation == generation) + .unwrap() + .chain + .clone() +} + +#[tokio::test] +async fn a_missing_or_foreign_chain_is_rebuilt_under_the_same_root() { + for broken in [None, Some(vec![0x5a; 60])] { + let (db, mirror, h) = (db().await, Arc::new(MemoryMirror::default()), holders()); + let k = open(&db, &mirror, &ROOT, config(&h, None)).await.unwrap(); + let old = k.current_key("space-a").await.unwrap(); + k.rotate_ark().await.unwrap(); + let efp = k.escrow_view().await.unwrap().unwrap().efp; + drop(k); + // A crash or a stale writer left generation 1 without a usable + // chain, and the mirror has no better copy. + set_chain(&db, 1, broken.clone()).await; + mirror.remove_prefix(ARK_MIRROR_PREFIX); + + let k = open(&db, &mirror, &ROOT, config(&h, None)).await.unwrap(); + assert_eq!(k.state(), KeyringState::Unlocked); + let rows = AtRestStore::new(db.clone()).keys().await.unwrap(); + let rebuilt = chain_of(&rows, 1).expect("chain rebuilt"); + assert_ne!(Some(rebuilt), broken); + drop(k); + + // The rebuilt chain is what recovery under a new root relies on. + let k = open(&db, &mirror, &NEW_ROOT, config(&h, Some(efp))) + .await + .unwrap(); + submit(&k, &h, 0).await.unwrap(); + assert_eq!(submit(&k, &h, 2).await.unwrap(), ShareOutcome::Unlocked); + assert_eq!( + k.key("space-a", DekId::new(1)).await.unwrap().bytes(), + old.bytes() + ); + } +} + +#[tokio::test] +async fn recovery_fails_loudly_on_a_broken_chain_or_missing_generation() { + let (db, mirror, h) = (db().await, Arc::new(MemoryMirror::default()), holders()); + let k = open(&db, &mirror, &ROOT, config(&h, None)).await.unwrap(); + k.current_key("space-a").await.unwrap(); + k.rotate_ark().await.unwrap(); + let efp = k.escrow_view().await.unwrap().unwrap().efp; + drop(k); + set_chain(&db, 1, None).await; + mirror.remove_prefix(ARK_MIRROR_PREFIX); + + // Under a new root nothing can rebuild the chain: two valid shares are + // refused with the reason, and the node stays locked. + let k = open(&db, &mirror, &NEW_ROOT, config(&h, Some(efp))) + .await + .unwrap(); + submit(&k, &h, 0).await.unwrap(); + assert!(matches!( + submit(&k, &h, 1).await, + Err(ShareRefusal::Keyring(AtRestError::Inconsistent(_))) + )); + assert_eq!(k.state(), KeyringState::Locked); + + // Generation 1 deleted after boot: an error, not a panic. The two + // valid shares stay held, so any submission retries the unlock. + at_rest_key::Entity::delete_by_id(1) + .exec(&db) + .await + .unwrap(); + assert!(matches!( + submit(&k, &h, 0).await, + Err(ShareRefusal::Keyring(AtRestError::Inconsistent(_))) + )); + assert_eq!(k.state(), KeyringState::Locked); +} + +#[tokio::test] +async fn a_stale_node_cannot_rotate_over_another_nodes_rotation() { + let (db, mirror, h) = (db().await, Arc::new(MemoryMirror::default()), holders()); + let a = open(&db, &mirror, &ROOT, config(&h, None)).await.unwrap(); + let b = open(&db, &mirror, &ROOT, config(&h, None)).await.unwrap(); + let old = a.current_key("space-a").await.unwrap(); + assert_eq!(a.rotate_ark().await.unwrap(), 2); + let chain = chain_of(&AtRestStore::new(db.clone()).keys().await.unwrap(), 1); + + // B still believes generation 1 is newest. + assert!(matches!( + b.rotate_ark().await, + Err(AtRestError::Conflict(_)) + )); + let rows = AtRestStore::new(db.clone()).keys().await.unwrap(); + assert_eq!(rows.len(), 2); + assert_eq!(chain_of(&rows, 1), chain, "A's chain is untouched"); + // B reloaded A's generation: new spaces wrap under generation 2. + b.current_key("space-b").await.unwrap(); + assert_eq!( + AtRestStore::new(db.clone()) + .dek("space-b", 1) + .await + .unwrap() + .unwrap() + .generation, + 2 + ); + assert_eq!(b.rotate_ark().await.unwrap(), 3); + let efp = b.escrow_view().await.unwrap().unwrap().efp; + drop((a, b)); + + // Generation 3's escrow alone recovers every generation. + let k = open(&db, &mirror, &NEW_ROOT, config(&h, Some(efp))) + .await + .unwrap(); + submit(&k, &h, 1).await.unwrap(); + assert_eq!(submit(&k, &h, 2).await.unwrap(), ShareOutcome::Unlocked); + assert_eq!( + k.key("space-a", DekId::new(1)).await.unwrap().bytes(), + old.bytes() + ); +} + +#[tokio::test] +async fn data_keys_survive_a_restore_to_before_they_were_created() { + let (db, mirror, h) = (db().await, Arc::new(MemoryMirror::default()), holders()); + let k = open(&db, &mirror, &ROOT, config(&h, None)).await.unwrap(); + let a = k.current_key("space-a").await.unwrap(); + // Mirror names don't reveal the space. + assert!(mirror.names().iter().all(|n| !n.contains("space-a"))); + drop(k); + space_data_key::Entity::delete_many() + .exec(&db) + .await + .unwrap(); + + // The restored database lacks the row; objects sealed under it remain. + let k = open(&db, &mirror, &ROOT, config(&h, None)).await.unwrap(); + assert_eq!( + k.key("space-a", DekId::new(1)).await.unwrap().bytes(), + a.bytes() + ); + space_data_key::Entity::delete_many() + .exec(&db) + .await + .unwrap(); + let k = open(&db, &mirror, &ROOT, config(&h, None)).await.unwrap(); + assert_eq!(k.current_key("space-a").await.unwrap().bytes(), a.bytes()); + drop(k); + + // A different DEK under the same id would strand every object sealed + // under the mirrored one: refused, and the mirror is not overwritten. + space_data_key::Entity::delete_many() + .exec(&db) + .await + .unwrap(); + let keys = open(&db, &mirror, &ROOT, config(&h, None)).await.unwrap(); + let gen = &keys.keys().unwrap(); + let (g, newest) = gen.newest(); + let (wrapped, _) = new_dek(&newest.kek, "space-a", DekId::new(1), g, &newest.ark_id); + AtRestStore::new(db.clone()) + .insert_dek(space_data_key::Model { + space: "space-a".into(), + dek_id: 1, + generation: g as i32, + wrapped, + created_at: now_rfc3339(), + }) + .await + .unwrap(); + assert!(matches!( + keys.current_key("space-a").await, + Err(DataKeyError::Backend(_)) + )); + space_data_key::Entity::delete_many() + .exec(&db) + .await + .unwrap(); + let k = open(&db, &mirror, &ROOT, config(&h, None)).await.unwrap(); + assert_eq!(k.current_key("space-a").await.unwrap().bytes(), a.bytes()); +} + +#[tokio::test] +async fn escrow_rows_or_any_mirror_object_block_provisioning() { + let (db, mirror, h) = (db().await, Arc::new(MemoryMirror::default()), holders()); + open(&db, &mirror, &ROOT, config(&h, None)).await.unwrap(); + at_rest_key::Entity::delete_many().exec(&db).await.unwrap(); + mirror.remove_prefix(ARK_MIRROR_PREFIX); + // Only escrow rows and escrow mirror objects are left. + assert!(matches!( + open(&db, &mirror, &ROOT, config(&h, None)).await, + Err(AtRestError::Inconsistent(_)) + )); + at_rest_escrow::Entity::delete_many() + .exec(&db) + .await + .unwrap(); + // Only a mirror object is left. + assert!(matches!( + open(&db, &mirror, &ROOT, config(&h, None)).await, + Err(AtRestError::Inconsistent(_)) + )); + assert!(at_rest_key::Entity::find() + .all(&db) + .await + .unwrap() + .is_empty()); +} + +#[tokio::test] +async fn a_newer_mirror_format_is_refused_not_guessed() { + let (db, mirror, h) = (db().await, Arc::new(MemoryMirror::default()), holders()); + open(&db, &mirror, &ROOT, config(&h, None)).await.unwrap(); + at_rest_key::Entity::delete_many().exec(&db).await.unwrap(); + let name = mirror + .names() + .into_iter() + .find(|n| n.starts_with(ARK_MIRROR_PREFIX)) + .unwrap(); + let mut obj: serde_json::Value = + serde_json::from_slice(&mirror.get(&name).await.unwrap().unwrap()).unwrap(); + obj["version"] = serde_json::json!(2); + mirror + .put(&name, serde_json::to_vec(&obj).unwrap()) + .await + .unwrap(); + assert!(matches!( + open(&db, &mirror, &ROOT, config(&h, None)).await, + Err(AtRestError::Mirror(e)) if e.contains("version 2") + )); +} + +#[tokio::test] +async fn migration_down_refuses_to_drop_keys() { + let (db, mirror, h) = (db().await, Arc::new(MemoryMirror::default()), holders()); + open(&db, &mirror, &ROOT, config(&h, None)).await.unwrap(); + assert!(crate::migrations::Migrator::down(&db, Some(1)) + .await + .is_err()); + assert_eq!(at_rest_key::Entity::find().all(&db).await.unwrap().len(), 1); + at_rest_escrow::Entity::delete_many() + .exec(&db) + .await + .unwrap(); + at_rest_key::Entity::delete_many().exec(&db).await.unwrap(); + crate::migrations::Migrator::down(&db, Some(1)) + .await + .unwrap(); + assert!(at_rest_key::Entity::find().all(&db).await.is_err()); +} diff --git a/tinycloud-core/src/at_rest/mod.rs b/tinycloud-core/src/at_rest/mod.rs index d63ad1fd..27914be0 100644 --- a/tinycloud-core/src/at_rest/mod.rs +++ b/tinycloud-core/src/at_rest/mod.rs @@ -2,9 +2,12 @@ //! is switched off until the key-continuity gate exists. mod envelope; +mod hierarchy; mod key; +pub mod keyring; mod names; mod provider; +pub mod store; mod stream; #[cfg(any(test, feature = "test-util"))] mod testing; @@ -21,3 +24,6 @@ pub use provider::{DataKeyError, DataKeyProvider}; pub use stream::{DecryptReader, EncryptReader, SegmentRange}; #[cfg(any(test, feature = "test-util"))] pub use testing::FixedKeyProvider; + +pub use keyring::{AtRestKeyring, KeyringConfig, KeyringState}; +pub use store::{AtRestStore, KeyMirror}; diff --git a/tinycloud-core/src/at_rest/store.rs b/tinycloud-core/src/at_rest/store.rs new file mode 100644 index 00000000..0d23466d --- /dev/null +++ b/tinycloud-core/src/at_rest/store.rs @@ -0,0 +1,463 @@ +//! Persistence for the at-rest keys: PG/SQLite rows plus a mirror of the +//! sealed ARK rows, escrow bundles and wrapped DEKs in the block store +//! (TC-887 plan §5). +//! +//! The mirror covers a database restored to a point before a key row was +//! written: sealed blobs in object storage outlive such a restore, and an +//! object sealed under a DEK must never meet a different DEK with its id. + +use std::sync::Arc; + +use base64::Engine; +use sea_orm::{ + sea_query::OnConflict, ActiveValue::Set, ColumnTrait, DbErr, EntityTrait, PaginatorTrait, + QueryFilter, QueryOrder, QuerySelect, TransactionTrait, +}; +use serde::{Deserialize, Serialize}; +use tokio::sync::Mutex; + +use crate::models::{at_rest_escrow, at_rest_key, space_data_key}; +use crate::RecoveringConnection; + +pub type MirrorError = Box; + +/// Named control objects next to the node's blocks (`_tc/at-rest/…`). The S3 +/// and filesystem block stores implement it in the server crate. +#[async_trait::async_trait] +pub trait KeyMirror: Send + Sync { + async fn put(&self, name: &str, bytes: Vec) -> Result<(), MirrorError>; + async fn get(&self, name: &str) -> Result>, MirrorError>; + /// Names under `prefix`, in any order. + async fn list(&self, prefix: &str) -> Result, MirrorError>; +} + +pub const MIRROR_PREFIX: &str = "_tc/at-rest/"; +pub(crate) const ARK_MIRROR_PREFIX: &str = "_tc/at-rest/ark-"; + +pub(crate) fn ark_mirror_name(generation: u32, ark_id_hex: &str) -> String { + format!("{ARK_MIRROR_PREFIX}{generation:010}-{ark_id_hex}.json") +} + +pub(crate) fn escrow_mirror_name(efp: &str) -> String { + format!("{MIRROR_PREFIX}escrow-{efp}.json") +} + +/// `tag` is a keyed hash of the space (see `hierarchy::dek_mirror_tag`), so +/// the bucket listing doesn't name spaces. +pub(crate) fn dek_mirror_name(tag: &str, dek_id: u32) -> String { + format!("{MIRROR_PREFIX}dek-{tag}-{dek_id:010}.json") +} + +/// Mirror object format version; a newer one is refused, never guessed at. +const MIRROR_VERSION: u32 = 1; + +fn check_version(version: u32) -> Result<(), MirrorError> { + if version == MIRROR_VERSION { + Ok(()) + } else { + Err(format!( + "mirror object version {version} is not supported (this node reads {MIRROR_VERSION})" + ) + .into()) + } +} + +/// The mirrored form of an `at_rest_key` row. +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct MirroredArk { + pub version: u32, + pub generation: u32, + pub ark_id: String, + pub w_label: String, + pub sealed: String, + pub chain: Option, + pub created_at: String, +} + +fn b64() -> base64::engine::GeneralPurpose { + base64::engine::general_purpose::STANDARD +} + +impl MirroredArk { + pub fn from_row(row: &at_rest_key::Model) -> Self { + Self { + version: MIRROR_VERSION, + generation: row.generation as u32, + ark_id: hex::encode(&row.ark_id), + w_label: row.w_label.clone(), + sealed: b64().encode(&row.sealed), + chain: row.chain.as_ref().map(|c| b64().encode(c)), + created_at: row.created_at.clone(), + } + } + + pub fn into_row(self) -> Result { + check_version(self.version)?; + Ok(at_rest_key::Model { + generation: i32::try_from(self.generation)?, + ark_id: hex::decode(&self.ark_id)?, + w_label: self.w_label, + sealed: b64().decode(&self.sealed)?, + chain: self.chain.map(|c| b64().decode(c)).transpose()?, + created_at: self.created_at, + }) + } +} + +/// The mirrored form of a `space_data_key` row. The space is not stored: +/// whoever restores the row knows it, and the wrap's AAD binds it. +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct MirroredDek { + pub version: u32, + pub dek_id: u32, + pub generation: u32, + pub wrapped: String, + pub created_at: String, +} + +impl MirroredDek { + pub fn from_row(row: &space_data_key::Model) -> Self { + Self { + version: MIRROR_VERSION, + dek_id: row.dek_id as u32, + generation: row.generation as u32, + wrapped: b64().encode(&row.wrapped), + created_at: row.created_at.clone(), + } + } + + pub fn into_row(self, space: &str) -> Result { + check_version(self.version)?; + Ok(space_data_key::Model { + space: space.to_owned(), + dek_id: i32::try_from(self.dek_id)?, + generation: i32::try_from(self.generation)?, + wrapped: b64().decode(&self.wrapped)?, + created_at: self.created_at, + }) + } +} + +/// Row access. On SQLite, writes take the node's shared writer lock. +#[derive(Clone)] +pub struct AtRestStore { + db: RecoveringConnection, + sqlite_writer_lock: Option>>, +} + +fn ignore_conflict(r: Result) -> Result<(), DbErr> { + match r { + Ok(_) | Err(DbErr::RecordNotInserted) => Ok(()), + Err(e) => Err(e), + } +} + +impl AtRestStore { + pub fn new(db: impl Into) -> Self { + Self { + db: db.into(), + sqlite_writer_lock: None, + } + } + + pub fn with_sqlite_writer_lock(mut self, lock: Option>>) -> Self { + self.sqlite_writer_lock = lock; + self + } + + async fn writer(&self) -> Option> { + match &self.sqlite_writer_lock { + Some(lock) => Some(lock.lock().await), + None => None, + } + } + + pub async fn keys(&self) -> Result, DbErr> { + at_rest_key::Entity::find() + .order_by_asc(at_rest_key::Column::Generation) + .all(&self.db) + .await + } + + /// Insert unless the generation exists. The caller re-reads. + pub async fn insert_key(&self, row: at_rest_key::Model) -> Result<(), DbErr> { + let _w = self.writer().await; + ignore_conflict( + at_rest_key::Entity::insert(at_rest_key::ActiveModel { + generation: Set(row.generation), + ark_id: Set(row.ark_id), + w_label: Set(row.w_label), + sealed: Set(row.sealed), + chain: Set(row.chain), + created_at: Set(row.created_at), + }) + .on_conflict( + OnConflict::column(at_rest_key::Column::Generation) + .do_nothing() + .to_owned(), + ) + .exec(&self.db) + .await, + ) + } + + /// Replace a generation's seal (after recovery under a new W). + pub async fn reseal_key( + &self, + generation: i32, + w_label: &str, + sealed: Vec, + ) -> Result<(), DbErr> { + let _w = self.writer().await; + at_rest_key::Entity::update_many() + .col_expr(at_rest_key::Column::WLabel, w_label.into()) + .col_expr(at_rest_key::Column::Sealed, sealed.into()) + .filter(at_rest_key::Column::Generation.eq(generation)) + .exec(&self.db) + .await + .map(|_| ()) + } + + /// Write generation `row.generation` and the chain that lets it open + /// generation `row.generation - 1`, in one transaction. Returns `false`, + /// writing nothing, if that generation already exists or the older one + /// is already chained: another node rotated first. + pub async fn rotate(&self, row: at_rest_key::Model, chain: Vec) -> Result { + let older = row.generation - 1; + let _w = self.writer().await; + let tx = self.db.begin().await?; + let inserted = at_rest_key::Entity::insert(at_rest_key::ActiveModel { + generation: Set(row.generation), + ark_id: Set(row.ark_id), + w_label: Set(row.w_label), + sealed: Set(row.sealed), + chain: Set(row.chain), + created_at: Set(row.created_at), + }) + .on_conflict( + OnConflict::column(at_rest_key::Column::Generation) + .do_nothing() + .to_owned(), + ) + .exec(&tx) + .await; + match inserted { + Ok(_) => {} + Err(DbErr::RecordNotInserted) => return Ok(false), + Err(e) => return Err(e), + } + let chained = at_rest_key::Entity::update_many() + .col_expr(at_rest_key::Column::Chain, chain.into()) + .filter(at_rest_key::Column::Generation.eq(older)) + .filter(at_rest_key::Column::Chain.is_null()) + .exec(&tx) + .await?; + if chained.rows_affected != 1 { + return Ok(false); + } + tx.commit().await?; + Ok(true) + } + + /// Replace a generation's chain. Only for repairing a chain that is + /// missing or doesn't open, with the newer ARK in hand. + pub async fn set_chain(&self, generation: i32, chain: Vec) -> Result<(), DbErr> { + let _w = self.writer().await; + at_rest_key::Entity::update_many() + .col_expr(at_rest_key::Column::Chain, chain.into()) + .filter(at_rest_key::Column::Generation.eq(generation)) + .exec(&self.db) + .await + .map(|_| ()) + } + + /// Any escrow row at all (sealed material that must block provisioning). + pub async fn has_escrow_rows(&self) -> Result { + Ok(at_rest_escrow::Entity::find().count(&self.db).await? > 0) + } + + pub async fn has_space_keys(&self) -> Result { + Ok(space_data_key::Entity::find().count(&self.db).await? > 0) + } + + /// The generation's active bundle. The partial unique index and + /// `activate_escrow`'s lock keep it unique; the order makes the choice + /// deterministic even if a database lacks the index. + pub async fn active_escrow( + &self, + generation: i32, + ) -> Result, DbErr> { + at_rest_escrow::Entity::find() + .filter(at_rest_escrow::Column::Generation.eq(generation)) + .filter(at_rest_escrow::Column::Active.eq(true)) + .order_by_desc(at_rest_escrow::Column::CreatedAt) + .order_by_asc(at_rest_escrow::Column::Efp) + .one(&self.db) + .await + } + + pub async fn escrow(&self, efp: &str) -> Result, DbErr> { + at_rest_escrow::Entity::find_by_id(efp.to_owned()) + .one(&self.db) + .await + } + + /// Make `row` the generation's only active bundle. Older bundles stay. + /// + /// Concurrent activations serialize on the generation's `at_rest_key` + /// row (`FOR UPDATE` on PG/MySQL; SQLite transactions are serializable + /// and in-process writers share the writer lock), so the deactivate + /// step always sees the other's committed bundle. + pub async fn activate_escrow(&self, row: at_rest_escrow::Model) -> Result<(), DbErr> { + let _w = self.writer().await; + let tx = self.db.begin().await?; + at_rest_key::Entity::find_by_id(row.generation) + .lock_exclusive() + .one(&tx) + .await? + .ok_or_else(|| DbErr::RecordNotFound("escrowed ARK generation".into()))?; + at_rest_escrow::Entity::update_many() + .col_expr(at_rest_escrow::Column::Active, false.into()) + .filter(at_rest_escrow::Column::Generation.eq(row.generation)) + .exec(&tx) + .await?; + ignore_conflict( + at_rest_escrow::Entity::insert(at_rest_escrow::ActiveModel { + efp: Set(row.efp.clone()), + generation: Set(row.generation), + bundle: Set(row.bundle), + active: Set(true), + record: Set(row.record), + created_at: Set(row.created_at), + }) + .on_conflict( + OnConflict::column(at_rest_escrow::Column::Efp) + .do_nothing() + .to_owned(), + ) + .exec(&tx) + .await, + )?; + at_rest_escrow::Entity::update_many() + .col_expr(at_rest_escrow::Column::Active, true.into()) + .filter(at_rest_escrow::Column::Efp.eq(row.efp)) + .exec(&tx) + .await?; + tx.commit().await + } + + pub async fn save_record(&self, efp: &str, record_json: String) -> Result<(), DbErr> { + let _w = self.writer().await; + at_rest_escrow::Entity::update_many() + .col_expr(at_rest_escrow::Column::Record, record_json.into()) + .filter(at_rest_escrow::Column::Efp.eq(efp)) + .exec(&self.db) + .await + .map(|_| ()) + } + + pub async fn newest_dek(&self, space: &str) -> Result, DbErr> { + space_data_key::Entity::find() + .filter(space_data_key::Column::Space.eq(space)) + .order_by_desc(space_data_key::Column::DekId) + .one(&self.db) + .await + } + + pub async fn dek( + &self, + space: &str, + dek_id: i32, + ) -> Result, DbErr> { + space_data_key::Entity::find_by_id((space.to_owned(), dek_id)) + .one(&self.db) + .await + } + + /// Insert unless `(space, dek_id)` exists. The caller re-reads, so + /// concurrent creators converge on the first row written. + pub async fn insert_dek(&self, row: space_data_key::Model) -> Result<(), DbErr> { + let _w = self.writer().await; + ignore_conflict( + space_data_key::Entity::insert(space_data_key::ActiveModel { + space: Set(row.space), + dek_id: Set(row.dek_id), + generation: Set(row.generation), + wrapped: Set(row.wrapped), + created_at: Set(row.created_at), + }) + .on_conflict( + OnConflict::columns([space_data_key::Column::Space, space_data_key::Column::DekId]) + .do_nothing() + .to_owned(), + ) + .exec(&self.db) + .await, + ) + } +} + +/// In-memory mirror for tests. +#[cfg(any(test, feature = "test-util"))] +#[derive(Default)] +pub struct MemoryMirror { + objects: parking_lot::Mutex>>, + fail: std::sync::atomic::AtomicBool, +} + +#[cfg(any(test, feature = "test-util"))] +impl MemoryMirror { + /// Make every call fail, like an unreachable bucket. + pub fn set_failing(&self, fail: bool) { + self.fail.store(fail, std::sync::atomic::Ordering::SeqCst); + } + + pub fn names(&self) -> Vec { + self.objects.lock().keys().cloned().collect() + } + + pub fn clear(&self) { + self.objects.lock().clear(); + } + + /// Drop every object under `prefix`, like a lost or rolled-back bucket. + pub fn remove_prefix(&self, prefix: &str) { + self.objects.lock().retain(|k, _| !k.starts_with(prefix)); + } + + fn check(&self) -> Result<(), MirrorError> { + if self.fail.load(std::sync::atomic::Ordering::SeqCst) { + Err("mirror unavailable".into()) + } else { + Ok(()) + } + } +} + +#[cfg(any(test, feature = "test-util"))] +#[async_trait::async_trait] +impl KeyMirror for MemoryMirror { + async fn put(&self, name: &str, bytes: Vec) -> Result<(), MirrorError> { + self.check()?; + self.objects.lock().insert(name.to_owned(), bytes); + Ok(()) + } + + async fn get(&self, name: &str) -> Result>, MirrorError> { + self.check()?; + Ok(self.objects.lock().get(name).cloned()) + } + + async fn list(&self, prefix: &str) -> Result, MirrorError> { + self.check()?; + Ok(self + .objects + .lock() + .keys() + .filter(|k| k.starts_with(prefix)) + .cloned() + .collect()) + } +} diff --git a/tinycloud-core/src/migrations/m20261015_000000_delegation_cursor_index.rs b/tinycloud-core/src/migrations/m20261015_000000_delegation_cursor_index.rs index 9083d249..7e03dea5 100644 --- a/tinycloud-core/src/migrations/m20261015_000000_delegation_cursor_index.rs +++ b/tinycloud-core/src/migrations/m20261015_000000_delegation_cursor_index.rs @@ -106,8 +106,14 @@ mod tests { return; }; let db = &fixture.conn; - // Back to the state a node upgrading to this migration boots with. - crate::migrations::Migrator::down(db, Some(1)) + // Back to the state a node upgrading to this migration boots with: + // roll back this migration and every later one, located by name. + let migrations = crate::migrations::Migrator::migrations(); + let index = migrations + .iter() + .position(|m| m.name() == Migration.name()) + .unwrap(); + crate::migrations::Migrator::down(db, Some((migrations.len() - index) as u32)) .await .unwrap(); assert!(!recorded(db).await); diff --git a/tinycloud-core/src/migrations/m20261017_000000_at_rest_keys.rs b/tinycloud-core/src/migrations/m20261017_000000_at_rest_keys.rs new file mode 100644 index 00000000..8dbab5f1 --- /dev/null +++ b/tinycloud-core/src/migrations/m20261017_000000_at_rest_keys.rs @@ -0,0 +1,162 @@ +//! TC-887 S1: tables for the at-rest key hierarchy and its escrow. Empty +//! until a node is configured to provision at-rest keys. + +use sea_orm_migration::prelude::*; +use sea_orm_migration::sea_orm::{DatabaseBackend, EntityTrait, PaginatorTrait}; + +use crate::models::{at_rest_escrow, at_rest_key, space_data_key}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(at_rest_key::Entity) + .if_not_exists() + .col( + ColumnDef::new(at_rest_key::Column::Generation) + .integer() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(at_rest_key::Column::ArkId).blob().not_null()) + .col( + ColumnDef::new(at_rest_key::Column::WLabel) + .string() + .not_null(), + ) + .col( + ColumnDef::new(at_rest_key::Column::Sealed) + .blob() + .not_null(), + ) + .col(ColumnDef::new(at_rest_key::Column::Chain).blob()) + .col( + ColumnDef::new(at_rest_key::Column::CreatedAt) + .string() + .not_null(), + ) + .to_owned(), + ) + .await?; + manager + .create_table( + Table::create() + .table(at_rest_escrow::Entity) + .if_not_exists() + .col( + ColumnDef::new(at_rest_escrow::Column::Efp) + .string() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(at_rest_escrow::Column::Generation) + .integer() + .not_null(), + ) + .col( + ColumnDef::new(at_rest_escrow::Column::Bundle) + .text() + .not_null(), + ) + .col( + ColumnDef::new(at_rest_escrow::Column::Active) + .boolean() + .not_null(), + ) + .col(ColumnDef::new(at_rest_escrow::Column::Record).text()) + .col( + ColumnDef::new(at_rest_escrow::Column::CreatedAt) + .string() + .not_null(), + ) + .to_owned(), + ) + .await?; + // At most one active bundle per generation. MySQL has no partial + // indexes (sea-query would emit a plain unique index, which would + // forbid keeping old bundles); there, the row lock taken by + // `AtRestStore::activate_escrow` alone serializes activations. + if manager.get_database_backend() != DatabaseBackend::MySql { + manager + .create_index( + Index::create() + .name("at_rest_escrow_one_active") + .table(at_rest_escrow::Entity) + .col(at_rest_escrow::Column::Generation) + .unique() + .if_not_exists() + .and_where(Expr::col(at_rest_escrow::Column::Active).eq(true)) + .to_owned(), + ) + .await?; + } + manager + .create_table( + Table::create() + .table(space_data_key::Entity) + .if_not_exists() + .col( + ColumnDef::new(space_data_key::Column::Space) + .string() + .not_null(), + ) + .col( + ColumnDef::new(space_data_key::Column::DekId) + .integer() + .not_null(), + ) + .col( + ColumnDef::new(space_data_key::Column::Generation) + .integer() + .not_null(), + ) + .col( + ColumnDef::new(space_data_key::Column::Wrapped) + .blob() + .not_null(), + ) + .col( + ColumnDef::new(space_data_key::Column::CreatedAt) + .string() + .not_null(), + ) + .primary_key( + Index::create() + .col(space_data_key::Column::Space) + .col(space_data_key::Column::DekId), + ) + .to_owned(), + ) + .await + } + + /// Refuses while any key row exists: dropping them would leave sealed + /// data depending on the mirror alone, or on nothing. + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + let rows = at_rest_key::Entity::find().count(db).await? + + at_rest_escrow::Entity::find().count(db).await? + + space_data_key::Entity::find().count(db).await?; + if rows > 0 { + return Err(DbErr::Migration( + "refusing to drop the at-rest key tables: they hold keys that exist nowhere else" + .into(), + )); + } + manager + .drop_table(Table::drop().table(space_data_key::Entity).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(at_rest_escrow::Entity).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(at_rest_key::Entity).to_owned()) + .await + } +} diff --git a/tinycloud-core/src/migrations/mod.rs b/tinycloud-core/src/migrations/mod.rs index e8f7c992..6adf6c9a 100644 --- a/tinycloud-core/src/migrations/mod.rs +++ b/tinycloud-core/src/migrations/mod.rs @@ -26,6 +26,7 @@ pub mod m20261007_000000_database_alias; pub mod m20261007_010000_database_identity_fence; pub mod m20261014_000000_delegation_cid_alias; pub mod m20261015_000000_delegation_cursor_index; +pub mod m20261017_000000_at_rest_keys; pub struct Migrator; @@ -60,6 +61,7 @@ impl MigratorTrait for Migrator { Box::new(m20261007_010000_database_identity_fence::Migration), Box::new(m20261014_000000_delegation_cid_alias::Migration), Box::new(m20261015_000000_delegation_cursor_index::Migration), + Box::new(m20261017_000000_at_rest_keys::Migration), ] } } diff --git a/tinycloud-core/src/models/at_rest_escrow.rs b/tinycloud-core/src/models/at_rest_escrow.rs new file mode 100644 index 00000000..f2a38ae8 --- /dev/null +++ b/tinycloud-core/src/models/at_rest_escrow.rs @@ -0,0 +1,26 @@ +//! Escrow bundles of ARK generations and their ceremony verification +//! records (TC-887). Superseded bundles are kept, marked inactive. + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "at_rest_escrow")] +pub struct Model { + /// Escrow fingerprint, lowercase hex. + #[sea_orm(primary_key, auto_increment = false)] + pub efp: String, + pub generation: i32, + /// `tinycloud_at_rest_escrow::EscrowBundle` as JSON. + pub bundle: String, + /// The bundle the node escrows to now (one per generation). + pub active: bool, + /// `tinycloud_at_rest_escrow::VerificationRecord` as JSON, after a + /// successful ceremony test-unlock. + pub record: Option, + pub created_at: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/tinycloud-core/src/models/at_rest_key.rs b/tinycloud-core/src/models/at_rest_key.rs new file mode 100644 index 00000000..e3dcf7f8 --- /dev/null +++ b/tinycloud-core/src/models/at_rest_key.rs @@ -0,0 +1,26 @@ +//! One generation of the at-rest root key (ARK), sealed under the node's +//! wrap key W. Rows are never deleted (TC-887). + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "at_rest_key")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub generation: i32, + /// 16 random bytes. + pub ark_id: Vec, + /// Which W sealed `sealed` (the HKDF info label). + pub w_label: String, + /// `nonce(12) ‖ AES-256-GCM(W, ARK)`. + pub sealed: Vec, + /// This generation's ARK sealed under the next generation's chain key; + /// set when the next generation is made. + pub chain: Option>, + pub created_at: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/tinycloud-core/src/models/mod.rs b/tinycloud-core/src/models/mod.rs index afefa687..b758657b 100644 --- a/tinycloud-core/src/models/mod.rs +++ b/tinycloud-core/src/models/mod.rs @@ -1,5 +1,7 @@ pub mod abilities; pub mod actor; +pub mod at_rest_escrow; +pub mod at_rest_key; pub mod current_kv; pub mod database_alias; pub mod database_artifact; @@ -40,3 +42,4 @@ pub mod share_policy_presentation_jti; pub mod share_session_handle; pub mod signed_kv_ticket; pub mod space; +pub mod space_data_key; diff --git a/tinycloud-core/src/models/space_data_key.rs b/tinycloud-core/src/models/space_data_key.rs new file mode 100644 index 00000000..fe28cdd8 --- /dev/null +++ b/tinycloud-core/src/models/space_data_key.rs @@ -0,0 +1,24 @@ +//! Per-space data keys (DEKs), wrapped under an ARK generation's KEK +//! (TC-887). Deleting a space's rows crypto-shreds its sealed blobs. + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "space_data_key")] +pub struct Model { + /// Canonical `SpaceId::to_string()`. + #[sea_orm(primary_key, auto_increment = false)] + pub space: String, + #[sea_orm(primary_key, auto_increment = false)] + pub dek_id: i32, + /// The ARK generation whose KEK wrapped `wrapped`. + pub generation: i32, + /// `nonce(12) ‖ AES-256-GCM(KEK_g, DEK)`. + pub wrapped: Vec, + pub created_at: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/tinycloud-node-server/Cargo.toml b/tinycloud-node-server/Cargo.toml index 5f9d96c9..02931945 100644 --- a/tinycloud-node-server/Cargo.toml +++ b/tinycloud-node-server/Cargo.toml @@ -102,6 +102,10 @@ features = ["sqlite", "postgres", "mysql", "tokio"] [dependencies.tinycloud-auth] path = "../tinycloud-auth/" +# TC-887: escrow bundle and recovery wire types for the at-rest routes. +[dependencies.tinycloud-at-rest-escrow] +path = "../tinycloud-at-rest-escrow/" + # TC-409: only for test builds, enable tinycloud-core's opt-in # verification-count test hook so node route tests can assert exactly-once # signature verification. Cargo only activates dev-dependency features when @@ -113,6 +117,8 @@ default-features = false features = ["sqlite", "postgres", "mysql", "tokio", "verification-count-test-hook"] [dev-dependencies] +# TC-887: escrow holders decrypting their shares in tests/at_rest_ceremony.rs. +age = { version = "=0.12.1", default-features = false, features = ["armor"] } # Parse production Rust items in the DID resolver source guard (tests only). syn = { version = "2", features = ["full", "visit"] } quote = "1" diff --git a/tinycloud-node-server/src/at_rest.rs b/tinycloud-node-server/src/at_rest.rs new file mode 100644 index 00000000..5900efdd --- /dev/null +++ b/tinycloud-node-server/src/at_rest.rs @@ -0,0 +1,217 @@ +//! Node wiring for encryption at rest (TC-887 S1): config → keyring, the +//! boot-time encrypt gate, and the runtime state storage and routes use. +//! +//! Two phases, because block storage opens before migrations and the +//! keyring needs its tables: [`AtRestRuntime::new`] before storage (storage +//! keeps the `Arc`), then [`setup`] after migrations installs the keyring. +//! +//! With the default config (`mode = "off"`, no recipients, no +//! `escrow_none`) nothing is set up: no key reads, no rows, no mirror. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, OnceLock}; + +use anyhow::{bail, Context, Result}; +use tinycloud_at_rest_escrow::{Efp, RecipientPolicy}; +use tinycloud_core::{ + at_rest::{ + keyring::{GatePass, GateRefusal}, + AtRestKeyring, AtRestStore, DataKeyProvider, KeyMirror, KeyringConfig, KeyringState, + }, + storage::either::Either, + RecoveringConnection, +}; + +use crate::config::{AtRestConfig, AtRestMode, Storage}; +use crate::storage::at_rest_mirror::{FsKeyMirror, S3KeyMirror}; +use crate::tee::TeeContext; + +/// At-rest state shared by block storage, the routes and `/info`. Rocket +/// manages it as `Arc`. +/// +/// Storage rules (S3): mode `off` behaves exactly as without at-rest. +/// Otherwise writes are sealed only while [`encrypting`](Self::encrypting) +/// (else 503, never plaintext); a sealed read whose key is `Locked` is a +/// 503; legacy plaintext reads are allowed unless the mode is `strict`. +pub struct AtRestRuntime { + mode: AtRestMode, + escrow_none: bool, + keyring: OnceLock>, + /// The gate passed for a non-`off` mode and the keys are unlocked: new + /// writes may be encrypted. Set at boot, or after a recovery unlocks a + /// node whose gate then passes. + encrypting: AtomicBool, +} + +/// The node's [`AtRestRuntime`], if it manages one. Unlike a `State` guard it +/// never fails, so a node without it (a test fixture) still launches. +pub struct AtRestState<'r>(pub Option<&'r AtRestRuntime>); + +#[rocket::async_trait] +impl<'r> rocket::request::FromRequest<'r> for AtRestState<'r> { + type Error = std::convert::Infallible; + + async fn from_request( + request: &'r rocket::Request<'_>, + ) -> rocket::request::Outcome { + rocket::request::Outcome::Success(AtRestState( + request + .rocket() + .state::>() + .map(|r| r.as_ref()), + )) + } +} + +impl AtRestRuntime { + /// Phase one, before block storage opens. No keys yet: `provider()` is + /// `None` and `encrypting()` is false until [`setup`] runs. + pub fn new(config: &AtRestConfig) -> Arc { + Arc::new(Self { + mode: config.mode, + escrow_none: config.escrow_none, + keyring: OnceLock::new(), + encrypting: AtomicBool::new(false), + }) + } + + pub fn mode(&self) -> AtRestMode { + self.mode + } + + /// Whether new writes may be sealed now: the gate passed (non-`off` + /// mode) and the keys are unlocked. + pub fn encrypting(&self) -> bool { + self.encrypting.load(Ordering::SeqCst) + } + + /// The data-key provider, once [`setup`] installed a keyring. `None` + /// when at-rest isn't configured. A locked keyring is still returned; + /// its calls fail with `DataKeyError::Locked`. + pub fn provider(&self) -> Option> { + self.keyring + .get() + .map(|k| k.clone() as Arc) + } + + pub fn keyring(&self) -> Option<&Arc> { + self.keyring.get() + } + + /// Re-run the gate, e.g. after a recovery unlocked the keys. Only a + /// non-`off` mode can turn encryption on. + pub async fn reevaluate(&self) -> Result { + let Some(keyring) = self.keyring.get() else { + return Err(GateRefusal::NotProvisioned); + }; + let result = keyring.gate(self.escrow_none).await; + let on = self.mode != AtRestMode::Off && result.is_ok(); + self.encrypting.store(on, Ordering::SeqCst); + result + } + + pub fn mode_name(&self) -> &'static str { + match self.mode { + AtRestMode::Off => "off", + AtRestMode::Encrypt => "encrypt", + AtRestMode::Strict => "strict", + } + } + + pub fn state_name(&self) -> &'static str { + match self.keyring.get().map(|k| k.state()) { + None => "disabled", + Some(KeyringState::NotProvisioned) => "not_provisioned", + Some(KeyringState::Locked) => "locked", + Some(KeyringState::Unlocked) => "unlocked", + } + } +} + +/// Phase two, after migrations ran: open the keyring and run the gate. +/// +/// Refuses (an error, so the node does not start) when `mode` is `encrypt` +/// or `strict` and the keys are unlocked but the escrow gate fails. A node +/// that boots **locked** starts anyway, with encryption unavailable, so two +/// holders can recover it. +pub async fn setup( + runtime: &AtRestRuntime, + config: &AtRestConfig, + storage: &Storage, + root: &[u8], + db: RecoveringConnection, + sqlite_writer_lock: Option>>, + tee: Option<&TeeContext>, +) -> Result<()> { + let escrow = &config.escrow; + let enabled = + config.mode != AtRestMode::Off || !escrow.recipients.is_empty() || config.escrow_none; + if !enabled { + return Ok(()); + } + if config.escrow_none && !escrow.recipients.is_empty() { + bail!("set at_rest.escrow.recipients or at_rest.escrow_none, not both"); + } + if tee.is_some() && config.escrow_none { + bail!("at_rest.escrow_none is only for self-hosted Static-root nodes; a TEE node must escrow its key"); + } + if tee.is_some() && escrow.allow_software { + bail!("at_rest.escrow.allow_software is refused inside a TEE: escrow holders must use hardware keys"); + } + let pinned_efp = escrow + .verified_fp + .as_deref() + .map(|s| s.trim().to_ascii_lowercase().parse::()) + .transpose() + .map_err(|_| anyhow::anyhow!("at_rest.escrow.verified_fp must be 64 hex characters"))?; + let mirror: Arc = match &storage.blocks { + Either::A(s3) => Arc::new(S3KeyMirror::new(s3).await), + Either::B(_) => Arc::new(FsKeyMirror::new(storage.datadir.join("at-rest"))), + }; + let keyring = AtRestKeyring::open( + AtRestStore::new(db).with_sqlite_writer_lock(sqlite_writer_lock), + mirror, + root, + KeyringConfig { + provision: true, + holders: escrow.recipients.clone(), + recipient_policy: if escrow.allow_software { + RecipientPolicy::AllowSoftware + } else { + RecipientPolicy::HardwareOnly + }, + pinned_efp, + app_id: tee.map(|t| t.app_id.clone()), + compose_hash: tee.map(|t| t.compose_hash.clone()), + }, + ) + .await + .context("at-rest keys")?; + if runtime.keyring.set(Arc::new(keyring)).is_err() { + bail!("at-rest keys were already set up"); + } + match runtime.keyring().map(|k| k.state()) { + Some(KeyringState::Unlocked) => { + let gate = runtime.reevaluate().await; + match (config.mode, gate) { + (AtRestMode::Off, Ok(_)) => { + ::tracing::info!("at-rest keys unlocked; escrow verified; mode off") + } + (AtRestMode::Off, Err(refusal)) => ::tracing::warn!( + reason = %refusal, + "at-rest keys unlocked; encryption could not be enabled yet" + ), + (mode, Ok(_)) => ::tracing::info!(?mode, "at-rest encryption enabled"), + (mode, Err(refusal)) => { + bail!("at_rest.mode = {mode:?} is refused: {refusal}") + } + } + } + Some(KeyringState::Locked) => ::tracing::error!( + mode = runtime.mode_name(), + "at-rest keys are LOCKED: encrypted data is unavailable until two escrow holders recover the key (POST /at-rest/recovery/shares)" + ), + _ => bail!("at-rest keys were not provisioned"), + } + Ok(()) +} diff --git a/tinycloud-node-server/src/config.rs b/tinycloud-node-server/src/config.rs index ef2b39d0..a49ae40a 100644 --- a/tinycloud-node-server/src/config.rs +++ b/tinycloud-node-server/src/config.rs @@ -50,6 +50,48 @@ pub struct Config { pub share_email: ShareEmailConfig, #[serde(default)] pub retention: RetentionConfig, + #[serde(default)] + pub at_rest: AtRestConfig, +} + +/// Encryption at rest (TC-887). Off by default; see `docs/at-rest-escrow.md`. +#[derive(Serialize, Deserialize, Debug, Default, Clone, Hash, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct AtRestConfig { + /// `off` writes plaintext; `encrypt` and `strict` are refused at boot + /// unless the escrow gate passes. + #[serde(default)] + pub mode: AtRestMode, + #[serde(default)] + pub escrow: AtRestEscrowConfig, + /// Self-hosted Static-root nodes only: acknowledge that no escrow exists, + /// so losing the root loses encrypted data. Refused inside a TEE. + #[serde(default)] + pub escrow_none: bool, +} + +#[derive(Serialize, Deserialize, Debug, Default, Clone, Copy, Hash, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum AtRestMode { + #[default] + Off, + Encrypt, + Strict, +} + +#[derive(Serialize, Deserialize, Debug, Default, Clone, Hash, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct AtRestEscrowConfig { + /// Exactly three holders. Setting them provisions the at-rest root key + /// and its escrow bundle at boot, even with `mode = "off"`. + #[serde(default)] + pub recipients: Vec, + /// The escrow fingerprint a ceremony verified (`tinycloud-at-rest record-efp`). + #[serde(default)] + pub verified_fp: Option, + /// Accept X25519 software-key holders (tests and self-host only). + #[serde(default)] + pub allow_software: bool, } /// Production exact-email composition. The capability remains unavailable diff --git a/tinycloud-node-server/src/lib.rs b/tinycloud-node-server/src/lib.rs index 886fbdff..682cb034 100644 --- a/tinycloud-node-server/src/lib.rs +++ b/tinycloud-node-server/src/lib.rs @@ -25,6 +25,7 @@ use std::{ }; pub mod allow_list; +pub mod at_rest; pub mod auth_guards; pub mod authorization; pub mod config; @@ -120,6 +121,10 @@ use routes::tc_bench::{ }; use routes::{ admin::{delete_quota, get_quota, get_usage, list_quotas, set_quota}, + at_rest::{ + at_rest_escrow, at_rest_escrow_record, at_rest_recovery, at_rest_recovery_share, + at_rest_status, + }, attestation::attestation, create_signed_kv_url, delegate, delegation_query, delegation_status, encryption::{ @@ -464,6 +469,11 @@ fn production_routes() -> Vec { public_kv_list, public_kv_options, attestation, + at_rest_status, + at_rest_escrow, + at_rest_escrow_record, + at_rest_recovery, + at_rest_recovery_share, set_quota, delete_quota, get_quota, @@ -622,6 +632,10 @@ pub async fn app_with_control( encryption_backend, ); + // TC-887: at-rest state, before block storage opens so storage can hold + // it; the keyring is installed after migrations (`at_rest::setup`). + let at_rest_runtime = at_rest::AtRestRuntime::new(&tinycloud_config.at_rest); + let tinycloud = TinyCloud::new_with_encryption( database_connection, tinycloud_config.storage.blocks.open().await?, @@ -642,6 +656,18 @@ pub async fn app_with_control( // `database_artifact` table now exists (seeding before migrations would // fail boot on a fresh datadir). Runs before Rocket serves any request. sql_sizes.seed_from(&seed_conn).await?; + // TC-887: at-rest keys (after migrations). Off unless configured; an + // `encrypt`/`strict` mode whose escrow gate fails stops the boot here. + at_rest::setup( + &at_rest_runtime, + &tinycloud_config.at_rest, + &tinycloud_config.storage, + key_setup.as_bytes(), + seed_conn.clone(), + tinycloud.sqlite_writer_lock(), + tee_context.as_ref(), + ) + .await?; #[cfg(feature = "tc-bench-v1")] let bench_state = BenchState::new( &tinycloud_config.tc_bench, @@ -785,6 +811,7 @@ pub async fn app_with_control( .manage(egress_policy) .manage(rate_limiter) .manage(tee_context) + .manage(at_rest_runtime) .manage(encryption_service) .manage(policy_v3_runtime) .manage(seed_conn.clone()) diff --git a/tinycloud-node-server/src/routes/at_rest.rs b/tinycloud-node-server/src/routes/at_rest.rs new file mode 100644 index 00000000..6320272f --- /dev/null +++ b/tinycloud-node-server/src/routes/at_rest.rs @@ -0,0 +1,247 @@ +//! Escrow ceremony and recovery endpoints (TC-887 S1). All public data; see +//! `docs/at-rest-escrow.md` and `tinycloud-at-rest` for the holder side. +//! +//! - `GET /at-rest/status`: mode, key state, escrow fingerprint, gate. +//! - `GET /at-rest/escrow`: the escrow bundle holders decrypt from. +//! - `GET /at-rest/escrow/record`: the ceremony verification record, only if +//! its MAC verifies under the live key. +//! - `GET /at-rest/recovery?nonce=`: this boot's recovery recipient, bound +//! with the holder's nonce and the EFP into a TDX quote. +//! - `POST /at-rest/recovery/shares`: one share sealed to that recipient. +//! +//! Share bodies are capped at 4 KiB, rate limited per IP and per boot, and +//! checked against the bundle's commitments before they occupy a slot. +//! Neither share bytes nor ciphertexts are logged. + +use rocket::data::{Data, ToByteUnit}; +use rocket::http::Status; +use rocket::serde::json::Json; +use rocket::State; +use serde::Serialize; +use tinycloud_at_rest_escrow::{ + binding_digest, recovery_binding, validate_nonce, EscrowBundle, RecoveryInfo, + VerificationRecord, MAX_WRAPPED_SHARE, RECOVERY_INFO_VERSION, +}; +use tinycloud_core::at_rest::keyring::{EscrowView, KeyringState, ShareOutcome, ShareRefusal}; + +use crate::at_rest::{AtRestRuntime, AtRestState}; +use crate::routes::public::{ClientIp, RateLimiter}; +use crate::tee::TeeContext; + +type Refusal = (Status, String); + +fn not_enabled() -> Refusal { + ( + Status::NotFound, + "at-rest keys are not enabled on this node".into(), + ) +} + +fn internal(e: impl std::fmt::Display) -> Refusal { + ::tracing::error!(error = %e, "at-rest keyring error"); + (Status::ServiceUnavailable, "at-rest keyring error".into()) +} + +async fn view(runtime: &AtRestRuntime) -> Result { + let keyring = runtime.keyring().ok_or_else(not_enabled)?; + keyring.escrow_view().await.map_err(internal)?.ok_or(( + Status::NotFound, + "no escrow bundle is available (no recipients configured, or a locked node without a pinned at_rest.escrow.verified_fp)".into(), + )) +} + +#[derive(Serialize)] +pub struct AtRestStatus { + mode: &'static str, + state: &'static str, + encrypting: bool, + #[serde(skip_serializing_if = "Option::is_none")] + efp: Option, + #[serde(skip_serializing_if = "Option::is_none")] + generation: Option, + verified: bool, +} + +#[get("/at-rest/status")] +pub async fn at_rest_status(at_rest: AtRestState<'_>) -> Result, Refusal> { + let runtime = at_rest.0.ok_or_else(not_enabled)?; + let escrow = match runtime.keyring() { + Some(k) => k.escrow_view().await.map_err(internal)?, + None => None, + }; + Ok(Json(AtRestStatus { + mode: runtime.mode_name(), + state: runtime.state_name(), + encrypting: runtime.encrypting(), + efp: escrow.as_ref().map(|v| v.efp.to_string()), + generation: escrow.as_ref().map(|v| v.generation), + verified: escrow.is_some_and(|v| v.verified), + })) +} + +#[get("/at-rest/escrow")] +pub async fn at_rest_escrow(at_rest: AtRestState<'_>) -> Result, Refusal> { + let runtime = at_rest.0.ok_or_else(not_enabled)?; + Ok(Json(view(runtime).await?.bundle)) +} + +#[get("/at-rest/escrow/record")] +pub async fn at_rest_escrow_record( + at_rest: AtRestState<'_>, +) -> Result, Refusal> { + let runtime = at_rest.0.ok_or_else(not_enabled)?; + let keyring = runtime.keyring().ok_or_else(not_enabled)?; + keyring + .verification_record() + .await + .map_err(internal)? + .map(Json) + .ok_or(( + Status::NotFound, + "no verified ceremony record for the active escrow".into(), + )) +} + +#[get("/at-rest/recovery?")] +pub async fn at_rest_recovery( + at_rest: AtRestState<'_>, + tee: &State>, + client_ip: ClientIp, + rate_limiter: &State, + nonce: String, +) -> Result, Refusal> { + rate_limiter + .check(client_ip.0) + .map_err(|s| (s, "Rate limit exceeded".to_string()))?; + validate_nonce(&nonce).map_err(|e| (Status::BadRequest, e.to_string()))?; + let runtime = at_rest.0.ok_or_else(not_enabled)?; + let view = view(runtime).await?; + let binding = recovery_binding(&view.recovery_recipient, &view.efp, &nonce); + let digest = binding_digest(&binding); + let (mode, quote, event_log) = quote_binding(tee.inner().as_ref(), &digest.0).await?; + Ok(Json(RecoveryInfo { + version: RECOVERY_INFO_VERSION, + mode: mode.into(), + state: match view.state { + KeyringState::Locked => "locked", + _ => "unlocked", + } + .into(), + ark_id: view.ark_id, + generation: view.generation, + efp: view.efp, + canary_fp: view.canary_fp, + recovery_recipient: view.recovery_recipient, + binding, + binding_sha256: digest, + quote, + event_log, + shares_held: view.shares_held, + })) +} + +/// A TDX quote over `digest`, or `classic` with no quote outside a TEE (a +/// holder's tool refuses to release a share then). +async fn quote_binding( + tee: Option<&TeeContext>, + digest: &[u8; 32], +) -> Result<(&'static str, Option, Option), Refusal> { + match tee { + #[cfg(feature = "dstack")] + Some(_) => { + let quote = crate::dstack::get_quote(&crate::dstack::socket_path(), digest) + .await + .map_err(|e| { + ::tracing::error!(error_class = %e, "TDX quote unavailable for at-rest recovery"); + ( + Status::ServiceUnavailable, + "quote generation failed".to_string(), + ) + })?; + Ok(("dstack", Some(quote.quote), Some(quote.event_log))) + } + _ => { + let _ = digest; + Ok(("classic", None, None)) + } + } +} + +#[derive(Debug, Serialize)] +pub struct ShareAnswer { + outcome: &'static str, + shares_held: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + efp: Option, + /// After a recovery: whether encryption is enabled again (the gate + /// passed), or why not. + #[serde(skip_serializing_if = "Option::is_none")] + gate: Option, +} + +#[post("/at-rest/recovery/shares", data = "")] +pub async fn at_rest_recovery_share( + at_rest: AtRestState<'_>, + client_ip: ClientIp, + rate_limiter: &State, + body: Data<'_>, +) -> Result, Refusal> { + rate_limiter + .check(client_ip.0) + .map_err(|s| (s, "Rate limit exceeded".to_string()))?; + let runtime = at_rest.0.ok_or_else(not_enabled)?; + let keyring = runtime.keyring().ok_or_else(not_enabled)?; + let wrapped = body + .open(MAX_WRAPPED_SHARE.bytes()) + .into_bytes() + .await + .map_err(|_| (Status::BadRequest, "could not read the share".to_string()))?; + if !wrapped.is_complete() { + return Err(( + Status::PayloadTooLarge, + format!("a wrapped share is at most {MAX_WRAPPED_SHARE} bytes"), + )); + } + match keyring.submit_share(&wrapped).await { + Ok(ShareOutcome::Held { shares_held }) => Ok(Json(ShareAnswer { + outcome: "held", + shares_held, + efp: None, + gate: None, + })), + Ok(ShareOutcome::Verified { efp }) => { + let gate = runtime.reevaluate().await; + Ok(Json(ShareAnswer { + outcome: "verified", + shares_held: vec![], + efp: Some(efp.to_string()), + gate: Some(match gate { + Ok(_) => "open".into(), + Err(r) => r.to_string(), + }), + })) + } + Ok(ShareOutcome::Unlocked) => { + let gate = match runtime.reevaluate().await { + Ok(_) => "open".to_string(), + Err(r) => { + ::tracing::error!(reason = %r, "at-rest keys recovered, but encryption stays off"); + r.to_string() + } + }; + Ok(Json(ShareAnswer { + outcome: "unlocked", + shares_held: vec![], + efp: None, + gate: Some(gate), + })) + } + Err(ShareRefusal::RateLimited) => Err(( + Status::TooManyRequests, + "too many share submissions this boot; retry later".into(), + )), + Err(ShareRefusal::NoBundle(why)) => Err((Status::Conflict, why.into())), + Err(ShareRefusal::Invalid(e)) => Err((Status::BadRequest, format!("share rejected: {e}"))), + Err(ShareRefusal::Keyring(e)) => Err(internal(e)), + } +} diff --git a/tinycloud-node-server/src/routes/mod.rs b/tinycloud-node-server/src/routes/mod.rs index 43031cb5..9bd531a5 100644 --- a/tinycloud-node-server/src/routes/mod.rs +++ b/tinycloud-node-server/src/routes/mod.rs @@ -65,6 +65,7 @@ use tinycloud_core::{ }; pub mod admin; +pub mod at_rest; pub mod attestation; pub mod encryption; pub mod hooks; @@ -206,6 +207,18 @@ pub struct NodeInfo { /// when the node's database is not PostgreSQL. #[serde(skip_serializing_if = "Option::is_none")] pub postgres: Option, + /// Encryption at rest (TC-887): omitted unless the node is configured + /// for it. Details at `/at-rest/status`. + #[serde(skip_serializing_if = "Option::is_none")] + pub at_rest: Option, +} + +#[derive(Serialize)] +pub struct AtRestInfo { + pub mode: &'static str, + /// `locked` means encrypted data is unavailable until recovery. + pub state: &'static str, + pub encrypting: bool, } /// Process-wide PostgreSQL connection counters in [`NodeInfo`]. @@ -222,6 +235,7 @@ fn build_info( quota_cache: &State, encryption: &State, database: Option<&tinycloud_core::RecoveringConnection>, + at_rest: Option<&crate::at_rest::AtRestRuntime>, ) -> NodeInfo { #[allow(unused_mut)] let mut features = vec!["kv", "delegation", "sharing", "sql"]; @@ -257,6 +271,13 @@ fn build_info( sockets_configured: sqlx_core::net::tcp_keepalive::sockets_configured(), connections_opened: crate::postgres_connections_opened(), }), + at_rest: at_rest + .filter(|r| r.keyring().is_some()) + .map(|r| AtRestInfo { + mode: r.mode_name(), + state: r.state_name(), + encrypting: r.encrypting(), + }), } } @@ -285,8 +306,9 @@ pub fn info( quota_cache: &State, encryption: &State, pool: NodePool<'_>, + at_rest: crate::at_rest::AtRestState<'_>, ) -> Json { - Json(build_info(tee, quota_cache, encryption, pool.0)) + Json(build_info(tee, quota_cache, encryption, pool.0, at_rest.0)) } #[get("/version")] @@ -295,8 +317,9 @@ pub fn version( quota_cache: &State, encryption: &State, pool: NodePool<'_>, + at_rest: crate::at_rest::AtRestState<'_>, ) -> Json { - Json(build_info(tee, quota_cache, encryption, pool.0)) + Json(build_info(tee, quota_cache, encryption, pool.0, at_rest.0)) } #[allow(clippy::let_unit_value)] diff --git a/tinycloud-node-server/src/storage/at_rest_mirror.rs b/tinycloud-node-server/src/storage/at_rest_mirror.rs new file mode 100644 index 00000000..70a9185a --- /dev/null +++ b/tinycloud-node-server/src/storage/at_rest_mirror.rs @@ -0,0 +1,212 @@ +//! Where the at-rest keyring mirrors its sealed ARK rows and escrow bundles +//! (TC-887): next to the blocks, so a database restored to before an ARK +//! row was written can still open blocks stored after it. +//! +//! - S3: objects under `_tc/at-rest/` in the block bucket. The boot size scan +//! only counts `{space}/{hash}` keys, so these are never charged to a space. +//! - Filesystem: files under `{datadir}/at-rest/`, outside the block +//! directory (whose size scan treats every subdirectory as a DID). + +use std::path::{Component, Path, PathBuf}; + +use aws_sdk_s3::{ + error::{GetObjectError, GetObjectErrorKind}, + types::{ByteStream, SdkError}, + Client, +}; +use futures::TryStreamExt; +use tinycloud_core::at_rest::{ + store::{MirrorError, MIRROR_PREFIX}, + KeyMirror, +}; + +use super::s3::{new_client, S3BlockConfig}; + +/// Control objects in the S3 block bucket. +pub struct S3KeyMirror { + client: Client, + bucket: String, +} + +impl S3KeyMirror { + pub async fn new(config: &S3BlockConfig) -> Self { + Self { + client: new_client(config).await, + bucket: config.bucket.clone(), + } + } +} + +fn check_name(name: &str) -> Result<(), MirrorError> { + let ok = name.starts_with(MIRROR_PREFIX) + && name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b'/')) + && !name.contains(".."); + if ok { + Ok(()) + } else { + Err(format!("invalid at-rest mirror name {name:?}").into()) + } +} + +#[async_trait::async_trait] +impl KeyMirror for S3KeyMirror { + async fn put(&self, name: &str, bytes: Vec) -> Result<(), MirrorError> { + check_name(name)?; + self.client + .put_object() + .bucket(&self.bucket) + .key(name) + .body(ByteStream::from(bytes)) + .send() + .await + .map_err(|e| aws_sdk_s3::Error::from(e).into()) + .map(|_| ()) + } + + async fn get(&self, name: &str) -> Result>, MirrorError> { + check_name(name)?; + match self + .client + .get_object() + .bucket(&self.bucket) + .key(name) + .send() + .await + { + Ok(o) => Ok(Some(o.body.collect().await?.into_bytes().to_vec())), + Err(SdkError::ServiceError { + err: + GetObjectError { + kind: GetObjectErrorKind::NoSuchKey(_), + .. + }, + .. + }) => Ok(None), + Err(e) => Err(aws_sdk_s3::Error::from(e).into()), + } + } + + async fn list(&self, prefix: &str) -> Result, MirrorError> { + check_name(prefix)?; + let pages = self + .client + .list_objects_v2() + .bucket(&self.bucket) + .prefix(prefix) + .into_paginator() + .send(); + let names = pages + .map_err(|e| -> MirrorError { aws_sdk_s3::Error::from(e).into() }) + .try_fold(Vec::new(), |mut acc, page| async move { + acc.extend( + page.contents + .into_iter() + .flatten() + .filter_map(|o| o.key().map(str::to_owned)), + ); + Ok(acc) + }) + .await?; + Ok(names) + } +} + +/// Control files under a local directory. +pub struct FsKeyMirror { + root: PathBuf, +} + +impl FsKeyMirror { + pub fn new(root: PathBuf) -> Self { + Self { root } + } + + fn path(&self, name: &str) -> Result { + check_name(name)?; + let rel = Path::new(name); + if rel.components().any(|c| !matches!(c, Component::Normal(_))) { + return Err(format!("invalid at-rest mirror name {name:?}").into()); + } + Ok(self.root.join(rel)) + } +} + +#[async_trait::async_trait] +impl KeyMirror for FsKeyMirror { + async fn put(&self, name: &str, bytes: Vec) -> Result<(), MirrorError> { + let path = self.path(name)?; + let dir = path.parent().ok_or("mirror path has no parent")?; + tokio::fs::create_dir_all(dir).await?; + // Write then rename, so a crash never leaves a half-written key file. + let tmp = path.with_extension("tmp"); + tokio::fs::write(&tmp, bytes).await?; + tokio::fs::rename(&tmp, &path).await?; + Ok(()) + } + + async fn get(&self, name: &str) -> Result>, MirrorError> { + match tokio::fs::read(self.path(name)?).await { + Ok(bytes) => Ok(Some(bytes)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.into()), + } + } + + async fn list(&self, prefix: &str) -> Result, MirrorError> { + check_name(prefix)?; + // Names are "_tc/at-rest/": one directory level to scan. + let (dir_name, file_prefix) = prefix.rsplit_once('/').ok_or("prefix has no directory")?; + let dir = self.path(&format!("{dir_name}/"))?; + let mut entries = match tokio::fs::read_dir(&dir).await { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(e.into()), + }; + let mut names = Vec::new(); + while let Some(entry) = entries.next_entry().await? { + if let Ok(file) = entry.file_name().into_string() { + if file.starts_with(file_prefix) && !file.ends_with(".tmp") { + names.push(format!("{dir_name}/{file}")); + } + } + } + Ok(names) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn fs_mirror_round_trips_lists_and_refuses_escapes() { + let dir = tempfile::tempdir().unwrap(); + let m = FsKeyMirror::new(dir.path().to_path_buf()); + assert!(m.list("_tc/at-rest/ark-").await.unwrap().is_empty()); + m.put("_tc/at-rest/ark-0000000001-ab.json", b"one".to_vec()) + .await + .unwrap(); + m.put("_tc/at-rest/escrow-cd.json", b"two".to_vec()) + .await + .unwrap(); + assert_eq!( + m.get("_tc/at-rest/ark-0000000001-ab.json").await.unwrap(), + Some(b"one".to_vec()) + ); + assert_eq!(m.get("_tc/at-rest/missing.json").await.unwrap(), None); + assert_eq!( + m.list("_tc/at-rest/ark-").await.unwrap(), + vec!["_tc/at-rest/ark-0000000001-ab.json".to_string()] + ); + for bad in [ + "../x", + "_tc/at-rest/../../etc", + "other/x", + "_tc/at-rest/a b", + ] { + assert!(m.put(bad, vec![]).await.is_err(), "{bad}"); + } + } +} diff --git a/tinycloud-node-server/src/storage/mod.rs b/tinycloud-node-server/src/storage/mod.rs index df299470..80d92074 100644 --- a/tinycloud-node-server/src/storage/mod.rs +++ b/tinycloud-node-server/src/storage/mod.rs @@ -1,3 +1,4 @@ +pub mod at_rest_mirror; pub mod file_system; pub mod s3; pub mod size; diff --git a/tinycloud-node-server/src/storage/s3.rs b/tinycloud-node-server/src/storage/s3.rs index 8c88c07d..0d109b38 100644 --- a/tinycloud-node-server/src/storage/s3.rs +++ b/tinycloud-node-server/src/storage/s3.rs @@ -59,7 +59,7 @@ impl StorageSetup for S3BlockStore { } } -async fn new_client(config: &S3BlockConfig) -> Client { +pub(crate) async fn new_client(config: &S3BlockConfig) -> Client { let general_config = aws_config().await; let sdk_config = aws_sdk_s3::config::Builder::from(&general_config); let sdk_config = match &config.endpoint { diff --git a/tinycloud-node-server/tests/at_rest_ceremony.rs b/tinycloud-node-server/tests/at_rest_ceremony.rs new file mode 100644 index 00000000..ad9506d3 --- /dev/null +++ b/tinycloud-node-server/tests/at_rest_ceremony.rs @@ -0,0 +1,315 @@ +//! TC-887 S1 through the real app: provisioning, the ceremony test-unlock, +//! the encrypt gate at boot, and recovery of a node booted under a new root. +//! SQLite file and filesystem blocks in a temp dir, so restarts keep state. + +use std::io::Read; +use std::net::SocketAddr; +use std::path::Path; + +use rocket::{ + figment::{providers::Serialized, Figment}, + http::{ContentType, Status}, + local::asynchronous::Client, +}; +use serde_json::{json, Value}; +use tinycloud_at_rest_escrow::{ + binding_digest, recovery_binding, wrap_share, EscrowBundle, RecoveryInfo, +}; + +const NONCE: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + +struct Holders(Vec); + +impl Holders { + fn new() -> Self { + Self((0..3).map(|_| age::x25519::Identity::generate()).collect()) + } + + fn recipients(&self) -> Value { + json!(["sam", "hunter", "safe"] + .iter() + .zip(&self.0) + .map(|(h, id)| json!({"holder": h, "recipient": id.to_public().to_string()})) + .collect::>()) + } + + /// Holder `i`: `age -d` their share, then seal it to the node's recovery key. + fn wrapped(&self, bundle: &EscrowBundle, info: &RecoveryInfo, i: usize) -> String { + let armored = &bundle.shares[i].ciphertext; + let decryptor = + age::Decryptor::new_buffered(age::armor::ArmoredReader::new(armored.as_bytes())) + .unwrap(); + let mut plain = Vec::new(); + decryptor + .decrypt(std::iter::once(&self.0[i] as &dyn age::Identity)) + .unwrap() + .read_to_end(&mut plain) + .unwrap(); + let share = bundle.open_share(&plain).unwrap(); + wrap_share(&info.recovery_recipient, &share).unwrap() + } +} + +async fn app(dir: &Path, root: u8, at_rest: Value) -> anyhow::Result { + let mut config = tinycloud::config::Config::default(); + config.storage.datadir = dir.into(); + config.storage.database = Some(format!("sqlite:{}?mode=rwc", dir.join("caps.db").display())); + config.keys = serde_json::from_value(json!({ + "type": "static", + "secret": base64::encode_config([root; 32], base64::URL_SAFE_NO_PAD), + }))?; + config.at_rest = serde_json::from_value(at_rest)?; + let figment = Figment::from(rocket::Config { + address: "127.0.0.1".parse()?, + log_level: rocket::config::LogLevel::Off, + ..rocket::Config::default() + }) + .merge(Serialized::defaults(&config)); + let rocket = tinycloud::app(&figment).await?; + Ok(Client::untracked(rocket).await?) +} + +/// The boot must fail; returns the full error chain. +async fn refused(dir: &Path, root: u8, at_rest: Value) -> String { + match app(dir, root, at_rest).await { + Ok(_) => panic!("the node started, but this configuration must be refused"), + Err(e) => format!("{e:#}"), + } +} + +fn remote() -> SocketAddr { + "127.0.0.1:40000".parse().unwrap() +} + +async fn get_json(client: &Client, uri: &str) -> (Status, Value) { + let res = client + .get(uri.to_string()) + .remote(remote()) + .dispatch() + .await; + let status = res.status(); + let body = res.into_string().await.unwrap_or_default(); + ( + status, + serde_json::from_str(&body).unwrap_or(Value::String(body)), + ) +} + +async fn post_share(client: &Client, body: impl Into>) -> (Status, Value) { + let res = client + .post("/at-rest/recovery/shares") + .remote(remote()) + .header(ContentType::Plain) + .body(body.into()) + .dispatch() + .await; + let status = res.status(); + let body = res.into_string().await.unwrap_or_default(); + ( + status, + serde_json::from_str(&body).unwrap_or(Value::String(body)), + ) +} + +async fn recovery(client: &Client) -> RecoveryInfo { + let (status, body) = get_json(client, &format!("/at-rest/recovery?nonce={NONCE}")).await; + assert_eq!(status, Status::Ok, "{body}"); + let info: RecoveryInfo = serde_json::from_value(body).unwrap(); + // No TEE here: no quote, and the holder tool would refuse to release. + assert_eq!(info.mode, "classic"); + assert!(info.quote.is_none()); + assert_eq!( + info.binding, + recovery_binding(&info.recovery_recipient, &info.efp, NONCE) + ); + assert_eq!(info.binding_sha256, binding_digest(&info.binding)); + info +} + +#[tokio::test] +async fn ceremony_gate_and_recovery_through_the_app() { + let dir = tempfile::tempdir().unwrap(); + let h = Holders::new(); + let escrow = |fp: Option<&str>| json!({"recipients": h.recipients(), "allow_software": true, "verified_fp": fp}); + + // Default config: nothing at rest, and /info says nothing about it. + let client = app(dir.path(), 1, json!({})).await.unwrap(); + let (_, info) = get_json(&client, "/info").await; + assert!(info.get("at_rest").is_none(), "{info}"); + let (_, status) = get_json(&client, "/at-rest/status").await; + assert_eq!(status["state"], "disabled"); + assert_eq!( + get_json(&client, "/at-rest/escrow").await.0, + Status::NotFound + ); + drop(client); + assert!(!dir.path().join("at-rest").exists()); + + // Release A: recipients pinned, mode off. The ARK and escrow are made. + let client = app(dir.path(), 1, json!({"escrow": escrow(None)})) + .await + .unwrap(); + let (_, info) = get_json(&client, "/info").await; + assert_eq!( + info["at_rest"], + json!({"mode":"off","state":"unlocked","encrypting":false}) + ); + let (_, bundle) = get_json(&client, "/at-rest/escrow").await; + let bundle: EscrowBundle = serde_json::from_value(bundle).unwrap(); + let efp = bundle.fingerprint().to_string(); + assert!(std::fs::read_dir(dir.path().join("at-rest/_tc/at-rest")) + .unwrap() + .any(|e| e.unwrap().file_name().to_string_lossy().starts_with("ark-"))); + + // Ceremony test-unlock: junk and oversize are refused, two shares are + // held, the third verifies. + assert_eq!(post_share(&client, "junk").await.0, Status::BadRequest); + assert_eq!( + post_share(&client, vec![b'a'; 8192]).await.0, + Status::PayloadTooLarge + ); + let info = recovery(&client).await; + assert_eq!(info.state, "unlocked"); + let (s, a) = post_share(&client, h.wrapped(&bundle, &info, 0)).await; + assert_eq!( + (s, &a["outcome"], &a["shares_held"]), + (Status::Ok, &json!("held"), &json!([1])) + ); + let (_, a) = post_share(&client, h.wrapped(&bundle, &info, 2)).await; + assert_eq!(a["shares_held"], json!([1, 3])); + assert_eq!(recovery(&client).await.shares_held, vec![1, 3]); + let (_, a) = post_share(&client, h.wrapped(&bundle, &info, 1)).await; + assert_eq!(a["outcome"], "verified"); + assert_eq!(a["efp"], json!(efp)); + let (status, record) = get_json(&client, "/at-rest/escrow/record").await; + assert_eq!(status, Status::Ok); + assert_eq!(record["efp"], json!(efp)); + let (_, st) = get_json(&client, "/at-rest/status").await; + assert_eq!( + (st["verified"].clone(), st["encrypting"].clone()), + (json!(true), json!(false)) + ); + drop(client); + + // encrypt without the pinned fingerprint: the node refuses to start. + let err = refused( + dir.path(), + 1, + json!({"mode": "encrypt", "escrow": escrow(None)}), + ) + .await; + assert!(err.contains("verified_fp is unset"), "{err}"); + let err = refused( + dir.path(), + 1, + json!({"mode": "encrypt", "escrow": escrow(Some(&"0".repeat(64)))}), + ) + .await; + assert!(err.contains("is not the active escrow"), "{err}"); + + // Pinned and verified: encryption is enabled. + let client = app( + dir.path(), + 1, + json!({"mode": "encrypt", "escrow": escrow(Some(&efp))}), + ) + .await + .unwrap(); + let (_, info) = get_json(&client, "/info").await; + assert_eq!( + info["at_rest"], + json!({"mode":"encrypt","state":"unlocked","encrypting":true}) + ); + drop(client); + + // New root (new app id / KMS): boots LOCKED, not refused. + let client = app( + dir.path(), + 2, + json!({"mode": "encrypt", "escrow": escrow(Some(&efp))}), + ) + .await + .unwrap(); + let (_, info) = get_json(&client, "/info").await; + assert_eq!( + info["at_rest"], + json!({"mode":"encrypt","state":"locked","encrypting":false}) + ); + let (status, _) = get_json(&client, "/at-rest/escrow/record").await; + assert_eq!(status, Status::NotFound); + let info = recovery(&client).await; + assert_eq!(info.state, "locked"); + // One share, even twice, does nothing. + let one = h.wrapped(&bundle, &info, 1); + assert_eq!( + post_share(&client, one.clone()).await.1["shares_held"], + json!([2]) + ); + assert_eq!(post_share(&client, one).await.1["shares_held"], json!([2])); + let (_, st) = get_json(&client, "/at-rest/status").await; + assert_eq!(st["state"], "locked"); + // A second holder unlocks it and the gate reopens. + let (_, a) = post_share(&client, h.wrapped(&bundle, &info, 2)).await; + assert_eq!( + (a["outcome"].clone(), a["gate"].clone()), + (json!("unlocked"), json!("open")) + ); + let (_, info) = get_json(&client, "/info").await; + assert_eq!( + info["at_rest"], + json!({"mode":"encrypt","state":"unlocked","encrypting":true}) + ); + drop(client); + + // Resealed under the new root: it now boots unlocked with encryption on. + let client = app( + dir.path(), + 2, + json!({"mode": "encrypt", "escrow": escrow(Some(&efp))}), + ) + .await + .unwrap(); + let (_, info) = get_json(&client, "/info").await; + assert_eq!(info["at_rest"]["encrypting"], json!(true)); +} + +#[tokio::test] +async fn misconfigurations_are_refused_at_boot() { + let dir = tempfile::tempdir().unwrap(); + let h = Holders::new(); + // Software holders without the opt-in. + let err = refused( + dir.path(), + 1, + json!({"escrow": {"recipients": h.recipients()}}), + ) + .await; + assert!(err.contains("software keys"), "{err}"); + // Both escrow and escrow_none. + let err = refused( + dir.path(), + 1, + json!({"escrow_none": true, "escrow": {"recipients": h.recipients(), "allow_software": true}}), + ) + .await; + assert!(err.contains("not both"), "{err}"); + // encrypt with no escrow at all on a Static root needs the acknowledgement. + let err = refused(dir.path(), 1, json!({"mode": "encrypt"})).await; + assert!(err.contains("escrow_none"), "{err}"); + let client = app( + dir.path(), + 1, + json!({"mode": "encrypt", "escrow_none": true}), + ) + .await + .unwrap(); + let (_, info) = get_json(&client, "/info").await; + assert_eq!(info["at_rest"]["encrypting"], json!(true)); + // Unknown keys in the section are refused, not ignored. + refused( + dir.path(), + 1, + json!({"mode": "encrypt", "escrow_nonee": true}), + ) + .await; +} diff --git a/tinycloud-node-server/tests/at_rest_env.rs b/tinycloud-node-server/tests/at_rest_env.rs new file mode 100644 index 00000000..b21dd836 --- /dev/null +++ b/tinycloud-node-server/tests/at_rest_env.rs @@ -0,0 +1,46 @@ +//! The compose env lines `tinycloud-at-rest recipients` prints parse into +//! `at_rest` exactly as the node loads them (main.rs's provider chain). +//! One test in its own process, since it sets process environment. + +use rocket::figment::{ + providers::{Env, Serialized}, + Figment, +}; +use tinycloud::config::{AtRestMode, Config}; + +#[test] +fn compose_env_lines_configure_at_rest() { + let recipients = "[{holder=\"sam\",recipient=\"age1yubikey1q2w7u3vpya839jxxuq8g0sedh3d740d4xvn639sqhr95ejj8vu3hyfumptt\"},\ +{holder=\"hunter\",recipient=\"age1qthuv2xtsp2nrz0apnfmj49fkdwp46zygpy4vv8ellvh47203psse695k4\"},\ +{holder=\"safe\",recipient=\"age1tag1qt8lw0ual6avlwmwatk888yqnmdamm7xfd0wak53ut6elz5c4swx2yqdj4e\"}]"; + let fp = "ab".repeat(32); + std::env::set_var("TINYCLOUD_AT_REST__MODE", "encrypt"); + std::env::set_var("TINYCLOUD_AT_REST__ESCROW__RECIPIENTS", recipients); + std::env::set_var("TINYCLOUD_AT_REST__ESCROW__VERIFIED_FP", &fp); + + let config: Config = Figment::from(rocket::Config::default()) + .merge(Serialized::defaults(Config::default())) + .merge(Env::prefixed("TINYCLOUD_").split("_").global()) + .merge(Env::prefixed("TINYCLOUD_").split("__").global()) + .extract() + .expect("at_rest env parses"); + + assert_eq!(config.at_rest.mode, AtRestMode::Encrypt); + let holders: Vec<_> = config + .at_rest + .escrow + .recipients + .iter() + .map(|h| h.holder.as_str()) + .collect(); + assert_eq!(holders, ["sam", "hunter", "safe"]); + assert!(config.at_rest.escrow.recipients[0] + .recipient + .starts_with("age1yubikey1q2w7")); + assert_eq!( + config.at_rest.escrow.verified_fp.as_deref(), + Some(fp.as_str()) + ); + assert!(!config.at_rest.escrow.allow_software); + assert!(!config.at_rest.escrow_none); +}