diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index a25f3805..7d67f8f4 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 9a04ebca..70cc4a40 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" @@ -814,6 +874,12 @@ dependencies = [ "rand 0.8.5", ] +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + [[package]] name = "arrayref" version = "0.3.9" @@ -1053,7 +1119,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 +1135,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 +1881,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 +2038,7 @@ dependencies = [ "cc", "cfg-if", "constant_time_eq 0.4.2", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -1966,7 +2047,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 +2056,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 +2315,7 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -2316,7 +2406,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 +2509,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 +2526,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 +2622,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 +2675,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 +2744,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,8 +2754,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", ] @@ -2652,11 +2779,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" @@ -2703,7 +2839,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 +3000,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 +3039,7 @@ checksum = "fe398ac75057914d7d07307bf67dc7f3f574a26783b4fc7805a20ffa9f506e82" dependencies = [ "asn1-rs 0.3.1", "displaydoc", - "nom", + "nom 7.1.3", "num-bigint", "num-traits", "rusticata-macros", @@ -2917,7 +3053,7 @@ checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" dependencies = [ "asn1-rs 0.6.2", "displaydoc", - "nom", + "nom 7.1.3", "num-bigint", "num-traits", "rusticata-macros", @@ -3104,7 +3240,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 +3294,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 +3305,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 +3470,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 +3478,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 +3647,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 +3683,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 +3740,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 +3808,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 +4015,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 +4147,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 +4192,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -3958,6 +4200,18 @@ 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 = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] [[package]] name = "hashlink" @@ -3977,6 +4231,25 @@ dependencies = [ "hashbrown 0.15.5", ] +[[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.4.1" @@ -4046,6 +4319,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 +4406,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 +4587,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 +4884,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 +4896,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 +4926,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 +5262,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 +5285,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 +5734,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 +5771,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 +5851,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 +5987,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 +6027,7 @@ checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", + "rand 0.8.5", ] [[package]] @@ -5595,6 +6053,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" dependencies = [ "num-traits", + "rand 0.8.5", ] [[package]] @@ -6257,7 +6716,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 +6728,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 +6849,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 +6871,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 +7697,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 +7784,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 +7984,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 +8245,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 +8568,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 +8585,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 +8597,7 @@ checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" dependencies = [ "block-buffer 0.9.0", "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.9.0", "opaque-debug", ] @@ -8065,10 +8609,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 +8818,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" @@ -8421,7 +8982,7 @@ dependencies = [ "futures-core", "futures-io", "futures-util", - "generic-array", + "generic-array 0.14.9", "hex", "hkdf", "hmac", @@ -9279,6 +9840,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" @@ -9383,6 +9955,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" @@ -9602,6 +10184,30 @@ 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", + "yaml-rust2", + "zeroize", +] + [[package]] name = "tinycloud-auth" version = "1.3.0" @@ -9858,6 +10464,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -10286,6 +10893,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" @@ -10357,6 +10973,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" @@ -10408,7 +11049,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", ] @@ -10520,6 +11161,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" @@ -10535,6 +11196,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" @@ -10704,6 +11375,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" @@ -11098,7 +11778,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", @@ -11115,7 +11795,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", @@ -11159,6 +11839,17 @@ dependencies = [ "thiserror 1.0.69", ] +[[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 0.12.2", +] + [[package]] name = "yansi" version = "1.0.1" @@ -11278,6 +11969,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 6db1b214..d67f9088 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", @@ -23,6 +24,9 @@ exclude = [ "vendor/sqlx-postgres", "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/docker-compose.dstack-postgres.yaml b/docker-compose.dstack-postgres.yaml index d661d530..73fae181 100644 --- a/docker-compose.dstack-postgres.yaml +++ b/docker-compose.dstack-postgres.yaml @@ -65,8 +65,8 @@ services: # entrypoint cannot work here) and a dstack deployment uploads only # this compose file (so there is no host path to bind-mount from). # - # Seal the base64 of the reviewed trust-bundle.production.json — the - # same bytes share-api serves — into the CVM's encrypted environment as + # Seal the base64 of the reviewed trust-bundle.production.json (the + # same bytes share-api serves) into the CVM's encrypted environment as # SHARE_TRUST_BUNDLE_BASE64. Same variable name and same wire form # share-api already uses, so one sealed value feeds both services and # cannot drift between them. diff --git a/docs/at-rest-escrow.md b/docs/at-rest-escrow.md new file mode 100644 index 00000000..908477e9 --- /dev/null +++ b/docs/at-rest-escrow.md @@ -0,0 +1,126 @@ +# At-rest key escrow (TC-887) + +Nothing in the node binary uses these two crates yet. + +- `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` reads it with a real parser (`tinycloud_at_rest_escrow::compose`, shared with the node) and accepts only a small subset: +- `runner` is `docker-compose`; +- the compose text is newlines and printable ASCII only, because YAML parsers disagree on Unicode line breaks such as U+2028; +- 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`; +- `TINYCLOUD_AT_REST__ESCROW__RECIPIENTS` and `TINYCLOUD_AT_REST__ESCROW__VERIFIED_FP`, if set, are literal values: no `$` interpolation and no pass-through from the runtime environment. + +Anything else is refused. `verify-compose` prints the escrow values the compose sets and every other app-compose setting in full (scripts included). All untrusted text is escaped, so control characters and terminal escapes show up as `\u{…}`. 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": [] +} +``` 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/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..032f8656 --- /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", + "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", + "yaml-rust2", + "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..144498ec --- /dev/null +++ b/tinycloud-at-rest-cli/Cargo.toml @@ -0,0 +1,42 @@ +[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" + +[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..c3c7fa6c --- /dev/null +++ b/tinycloud-at-rest-cli/src/attest.rs @@ -0,0 +1,1081 @@ +//! 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::compose::{escape, AppCompose, RECIPIENTS_ENV, VERIFIED_FP_ENV}; +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, the escrow +/// values the compose sets literally, and the other app-compose settings +/// the hash covers. All text is escaped for printing. +#[derive(Debug)] +pub struct ComposeReport { + pub images: Vec<(String, String)>, + pub recipients: Option, + pub verified_fp: Option, + pub settings: Vec<(String, String)>, +} + +/// `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 read it strictly (`tinycloud_at_rest_escrow::compose`). +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 parsed = AppCompose::parse(app_compose)?; + Ok(ComposeReport { + images: parsed.pinned_images()?, + recipients: parsed.literal_env(RECIPIENTS_ENV)?.map(|v| escape(&v)), + verified_fp: parsed.literal_env(VERIFIED_FP_ENV)?.map(|v| escape(&v)), + settings: parsed.other_settings(), + }) +} + +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/main.rs b/tinycloud-at-rest-cli/src/main.rs new file mode 100644 index 00000000..5053170c --- /dev/null +++ b/tinycloud-at-rest-cli/src/main.rs @@ -0,0 +1,535 @@ +//! `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; + +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::compose::escape; +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 {}", + escape(&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!("At-rest escrow values set literally in the compose (compare with the bundle and the pin):"); + for (key, value) in [ + ( + tinycloud_at_rest_escrow::compose::RECIPIENTS_ENV, + report.recipients, + ), + ( + tinycloud_at_rest_escrow::compose::VERIFIED_FP_ENV, + report.verified_fp, + ), + ] { + eprintln!(" {key} = {}", value.as_deref().unwrap_or("(not set)")); + } + eprintln!("Other settings the hash covers, in full (review them):"); + for (key, value) in report.settings { + eprintln!(" {key} = {value}"); + } + } + 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, + escape(&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: {}", escape(&format!("{e:#}"))); + std::process::exit(3); + } + eprintln!("REFUSED: {}", escape(&format!("{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..a96496e9 --- /dev/null +++ b/tinycloud-at-rest-escrow/Cargo.toml @@ -0,0 +1,34 @@ +[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 } +# Strict app-compose reading (src/compose.rs); pinned: what it accepts is a +# security property. +yaml-rust2 = "=0.13.0" + +[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/compose.rs b/tinycloud-at-rest-escrow/src/compose.rs new file mode 100644 index 00000000..e6a8f9df --- /dev/null +++ b/tinycloud-at-rest-escrow/src/compose.rs @@ -0,0 +1,650 @@ +//! What a dstack `app-compose.json` pins, read strictly. +//! +//! dstack's `compose_hash` is SHA-256 of `app-compose.json`, which embeds the +//! docker-compose YAML. Two things depend on reading that YAML exactly as +//! docker compose does: +//! +//! - the holders' `verify-compose`: `compose_hash` pins the images only if +//! every service names its image by digest; +//! - the node in a TEE: the escrow recipients and the pinned fingerprint +//! must be literal values in the measured compose. +//! +//! Only a deliberately small subset is accepted, so no YAML feature, and no +//! difference between this parser and docker compose's, can hide anything: +//! +//! - the compose text is `\n` plus printable ASCII only (YAML parsers +//! disagree on Unicode line breaks such as U+2028); +//! - one document; no anchors, aliases, merge keys, tags or duplicate keys; +//! - a top-level `services` mapping, and no `include`; +//! - `runner` is `docker-compose`. + +use std::collections::HashSet; + +use yaml_rust2::parser::{Event, Parser}; + +use crate::EscrowError; + +/// The compose environment variables that must be literal in a TEE. +pub const RECIPIENTS_ENV: &str = "TINYCLOUD_AT_REST__ESCROW__RECIPIENTS"; +pub const VERIFIED_FP_ENV: &str = "TINYCLOUD_AT_REST__ESCROW__VERIFIED_FP"; +type Result = std::result::Result; + +fn err(msg: impl Into) -> Result { + Err(EscrowError::Compose(msg.into())) +} + +macro_rules! ensure { + ($cond:expr, $($fmt:tt)+) => { + if !$cond { + return err(format!($($fmt)+)); + } + }; +} + +/// Untrusted text, made safe to print: printable ASCII stays, everything +/// else (control characters, terminal escapes, any non-ASCII) becomes a +/// visible `\u{…}` escape. +pub fn escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + ' '..='~' if c != '\\' => out.push(c), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + other => out.push_str(&format!("\\u{{{:x}}}", other as u32)), + } + } + out +} + +#[derive(Debug)] +enum Node { + Scalar(String), + Seq(Vec), + 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| { + EscrowError::Compose(format!("the compose file is not valid YAML: {e}")) + })?; + match &event { + Event::Alias(_) => { + return err(format!( + "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(..) => { + let mut items = Vec::new(); + loop { + match self.next()? { + Event::SequenceEnd => return Ok(Node::Seq(items)), + e => items.push(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, + _ => return err("only plain string mapping keys are allowed"), + }; + ensure!(key != "<<", "YAML merge keys are not allowed"); + ensure!(seen.insert(key.clone()), "duplicate key {:?}", escape(&key)); + let value = self.next()?; + entries.push((key, self.node(value)?)); + } + } + other => err(format!("unexpected YAML structure: {other:?}")), + } + } +} + +/// Only `\n` and printable ASCII: no Unicode line breaks (U+0085, U+2028, +/// U+2029), no CR, tabs, BOM or control characters. +fn check_charset(yaml: &str) -> Result<()> { + if let Some((i, b)) = yaml + .bytes() + .enumerate() + .find(|(_, b)| *b != b'\n' && !(0x20..=0x7e).contains(b)) + { + return err(format!( + "the compose file may contain only newlines and printable ASCII; byte 0x{b:02x} at offset {i} is not allowed" + )); + } + Ok(()) +} + +fn parse(yaml: &str) -> Result> { + check_charset(yaml)?; + 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" + ); + let Node::Map(top) = root else { + return err("the compose file must be a mapping"); + }; + ensure!( + get(&top, "include").is_none(), + "`include` pulls in other compose files and is not allowed" + ); + Ok(top) +} + +fn get<'a>(entries: &'a [(String, Node)], key: &str) -> Option<&'a Node> { + entries.iter().find(|(k, _)| k == key).map(|(_, v)| v) +} + +/// A YAML mapping's entries, in document order. +type Entries = [(String, Node)]; + +fn services(top: &Entries) -> Result> { + let Some(Node::Map(services)) = get(top, "services") else { + return err("the compose file has no `services` mapping"); + }; + ensure!(!services.is_empty(), "the compose file has no services"); + services + .iter() + .map(|(name, service)| match service { + Node::Map(fields) => Ok((name.as_str(), fields.as_slice())), + _ => err(format!("service {:?} is not a mapping", escape(name))), + }) + .collect() +} + +/// `[: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)) +} + +/// The parsed `app-compose.json` and its compose YAML. +pub struct AppCompose { + doc: serde_json::Map, + top: Vec<(String, Node)>, +} + +/// Walks any JSON value and fails on an object key that appears twice at +/// any depth. serde_json keeps the last duplicate; dstack's own struct +/// parse would refuse it, so every reader of the hashed bytes must too. +struct NoDuplicateKeys; + +impl<'de> serde::Deserialize<'de> for NoDuplicateKeys { + fn deserialize>(d: D) -> std::result::Result { + d.deserialize_any(NoDuplicateKeysVisitor) + } +} + +struct NoDuplicateKeysVisitor; + +impl<'de> serde::de::Visitor<'de> for NoDuplicateKeysVisitor { + type Value = NoDuplicateKeys; + + fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("a JSON value") + } + + fn visit_bool(self, _: bool) -> std::result::Result { + Ok(NoDuplicateKeys) + } + + fn visit_i64(self, _: i64) -> std::result::Result { + Ok(NoDuplicateKeys) + } + + fn visit_u64(self, _: u64) -> std::result::Result { + Ok(NoDuplicateKeys) + } + + fn visit_f64(self, _: f64) -> std::result::Result { + Ok(NoDuplicateKeys) + } + + fn visit_str(self, _: &str) -> std::result::Result { + Ok(NoDuplicateKeys) + } + + fn visit_unit(self) -> std::result::Result { + Ok(NoDuplicateKeys) + } + + fn visit_seq>( + self, + mut seq: A, + ) -> std::result::Result { + while seq.next_element::()?.is_some() {} + Ok(NoDuplicateKeys) + } + + fn visit_map>( + self, + mut map: A, + ) -> std::result::Result { + let mut seen = HashSet::new(); + while let Some(key) = map.next_key::()? { + if !seen.insert(key.clone()) { + return Err(serde::de::Error::custom(format!( + "duplicate key {:?}", + escape(&key) + ))); + } + map.next_value::()?; + } + Ok(NoDuplicateKeys) + } +} + +impl AppCompose { + /// Parse `app-compose.json` and its `docker_compose_file` strictly. + pub fn parse(app_compose: &[u8]) -> Result { + serde_json::from_slice::(app_compose) + .map_err(|e| EscrowError::Compose(format!("app-compose.json is not JSON: {e}")))?; + let doc: serde_json::Value = serde_json::from_slice(app_compose) + .map_err(|e| EscrowError::Compose(format!("app-compose.json is not JSON: {e}")))?; + let serde_json::Value::Object(doc) = doc else { + return err("app-compose.json is not a JSON object"); + }; + match doc.get("runner").and_then(|v| v.as_str()) { + Some("docker-compose") => {} + other => { + return err(format!( + "app-compose.json runner must be \"docker-compose\", not {:?}", + other.map(escape) + )) + } + } + let compose = doc + .get("docker_compose_file") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + EscrowError::Compose("app-compose.json has no docker_compose_file".into()) + })?; + let top = parse(compose)?; + services(&top)?; + Ok(Self { doc, top }) + } + + /// Every service's image, each pinned by digest; refuses anything else. + pub fn pinned_images(&self) -> Result> { + let mut images = Vec::new(); + for (name, fields) in services(&self.top)? { + let name = escape(name); + 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(_) => return err(format!("service {name:?} has a non-string image")), + None => return err(format!("service {name:?} has no image")), + }; + ensure!( + pinned_reference(image), + "service {name:?} image {:?} is not referenced as @sha256:<64 hex>", + escape(image) + ); + images.push((name, image.clone())); + } + Ok(images) + } + + /// The other `app-compose.json` fields the hash also covers, in full + /// (scripts included), escaped, for the holder to review. + pub fn other_settings(&self) -> Vec<(String, String)> { + self.doc + .iter() + .filter(|(k, _)| k.as_str() != "docker_compose_file") + .map(|(k, v)| { + let shown = match v { + serde_json::Value::String(s) => escape(s), + other => escape(&other.to_string()), + }; + (escape(k), shown) + }) + .collect() + } + + /// The literal value of `key` in the services' `environment`, if set. + /// + /// Refused: a value that docker compose would interpolate (`$`), a key + /// passed through from the runtime environment (`- KEY` or `KEY:` + /// with no value), and different values in different services. + pub fn literal_env(&self, key: &str) -> Result> { + let mut found: Option = None; + for (service, fields) in services(&self.top)? { + let service = escape(service); + let value = match get(fields, "environment") { + None => None, + Some(Node::Map(env)) => match get(env, key) { + None => None, + Some(Node::Scalar(v)) => Some(v.clone()), + Some(_) => { + return err(format!("service {service:?} sets {key} to a non-string")) + } + }, + Some(Node::Seq(items)) => { + let mut value = None; + for item in items { + let Node::Scalar(item) = item else { + return err(format!( + "service {service:?} has a non-string environment entry" + )); + }; + let (k, v) = match item.split_once('=') { + Some((k, v)) => (k, Some(v)), + None => (item.as_str(), None), + }; + if k == key { + ensure!(value.is_none(), "service {service:?} sets {key} twice"); + value = Some(v.map(str::to_owned).ok_or_else(|| { + EscrowError::Compose(format!( + "service {service:?} passes {key} through from the runtime environment; it must be a literal value" + )) + })?); + } + } + value + } + Some(_) => { + return err(format!( + "service {service:?} environment is not a mapping or list" + )) + } + }; + let Some(value) = value else { continue }; + // Empty or a YAML 1.2 null (any spelling docker compose reads as + // null) passes the variable through from the runtime. + ensure!( + !matches!(value.as_str(), "" | "~" | "null" | "Null" | "NULL"), + "service {service:?} passes {key} through from the runtime environment; it must be a literal value" + ); + ensure!( + !value.contains('$'), + "service {service:?} sets {key} by interpolation; it must be a literal value" + ); + match &found { + Some(prev) => ensure!(*prev == value, "services set different values for {key}"), + None => found = Some(value), + } + } + Ok(found) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn digest() -> String { + "a".repeat(64) + } + + fn app(yaml: &str) -> Vec { + serde_json::to_vec(&serde_json::json!({ + "manifest_version": 2, + "runner": "docker-compose", + "docker_compose_file": yaml, + })) + .unwrap() + } + + fn images(yaml: &str) -> Result> { + AppCompose::parse(&app(yaml))?.pinned_images() + } + + #[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 = 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 = [ + // 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"), + // Flow mapping. + ("services:\n web: {image: nginx:latest}\n".to_string(), "nginx:latest"), + // Quoted key. + ("services:\n web:\n \"image\": nginx:latest\n".to_string(), "nginx:latest"), + // 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"), + // Anything but newline and printable ASCII. + (format!("services:\r\n web:\r\n image: a@sha256:{d}\r\n"), "0x0d"), + (format!("\u{feff}services:\n web:\n image: a@sha256:{d}\n"), "0xef"), + (format!("services:\n\tweb:\n image: a@sha256:{d}\n"), "0x09"), + ]; + for (yaml, needle) in cases { + let err = images(&yaml).expect_err(&yaml).to_string(); + assert!(err.contains(needle), "{yaml:?}: {err}"); + } + } + + /// The security review's proofs of concept: Unicode line breaks that Go + /// yaml.v3 (docker compose) honours and yaml-rust2 doesn't, so each one + /// runs `nginx:latest` while a lax reader sees only the pinned image. + #[test] + fn unicode_line_breaks_are_refused() { + for (name, yaml) in [ + ( + "U+2028", + include_str!("../tests/fixtures/compose/ls_comment_quote.yaml"), + ), + ( + "U+0085", + include_str!("../tests/fixtures/compose/nel_comment_quote.yaml"), + ), + ( + "U+2029", + include_str!("../tests/fixtures/compose/ps_comment_quote.yaml"), + ), + ( + "hidden service", + include_str!("../tests/fixtures/compose/ls_extra_service.yaml"), + ), + ] { + assert!(!yaml.is_ascii(), "{name}: fixture lost its line break"); + let err = images(yaml).expect_err(name).to_string(); + assert!(err.contains("printable ASCII"), "{name}: {err}"); + } + } + + #[test] + fn production_compose_passes() { + let yaml = include_str!("../../docker-compose.dstack-postgres.yaml"); + assert_eq!(images(yaml).unwrap().len(), 4); + } + + #[test] + fn duplicate_json_keys_are_refused_at_any_depth() { + let yaml = format!("services:\\n w:\\n image: a@sha256:{}\\n", digest()); + for doc in [ + format!( + r#"{{"runner":"docker-compose","runner":"bash","docker_compose_file":"{yaml}"}}"# + ), + format!( + r#"{{"runner":"docker-compose","docker_compose_file":"{yaml}","x":{{"a":1,"a":2}}}}"# + ), + ] { + let err = AppCompose::parse(doc.as_bytes()).err().unwrap().to_string(); + assert!(err.contains("duplicate key"), "{doc}: {err}"); + } + let ok = format!( + r#"{{"runner":"docker-compose","docker_compose_file":"{yaml}","x":[{{"a":1}},{{"a":2}}]}}"# + ); + AppCompose::parse(ok.as_bytes()).unwrap(); + } + + #[test] + fn only_the_docker_compose_runner_is_accepted() { + let d = digest(); + let yaml = format!("services:\n web:\n image: a@sha256:{d}\n"); + for runner in [serde_json::json!("bash"), serde_json::Value::Null] { + let doc = serde_json::json!({"runner": runner, "docker_compose_file": yaml}); + let err = AppCompose::parse(&serde_json::to_vec(&doc).unwrap()) + .err() + .unwrap() + .to_string(); + assert!(err.contains("runner"), "{err}"); + } + } + + #[test] + fn other_settings_are_shown_in_full_and_escaped() { + let script = format!("docker pull nginx:latest && {}", "x".repeat(200)); + let doc = serde_json::json!({ + "runner": "docker-compose", + "docker_compose_file": format!("services:\n w:\n image: a@sha256:{}\n", digest()), + "kms_enabled": true, + "pre_launch_script": script, + "zz\u{1b}[1A\u{1b}[2K": "x", + }); + let parsed = AppCompose::parse(&serde_json::to_vec(&doc).unwrap()).unwrap(); + let settings = parsed.other_settings(); + assert!(settings.contains(&("kms_enabled".into(), "true".into()))); + assert!(settings.contains(&("pre_launch_script".into(), script.clone()))); + assert!(settings.contains(&("zz\\u{1b}[1A\\u{1b}[2K".into(), "x".into()))); + assert!(!settings.iter().any(|(k, _)| k == "docker_compose_file")); + assert!(settings.iter().all(|(k, v)| k + .bytes() + .chain(v.bytes()) + .all(|b| (0x20..=0x7e).contains(&b)))); + } + + #[test] + fn escrow_values_must_be_literal() { + let d = digest(); + let with_env = |env: &str| { + AppCompose::parse(&app(&format!( + "services:\n node:\n image: a@sha256:{d}\n environment:\n{env}" + ))) + .unwrap() + }; + let r = "'[{holder=\"sam\",recipient=\"age1yubikey1q\"}]'"; + // Map and list forms give the literal. + let map = with_env(&format!( + " {RECIPIENTS_ENV}: {r}\n {VERIFIED_FP_ENV}: {d}\n" + )); + assert_eq!( + map.literal_env(RECIPIENTS_ENV).unwrap().as_deref(), + Some("[{holder=\"sam\",recipient=\"age1yubikey1q\"}]") + ); + assert_eq!(map.literal_env(VERIFIED_FP_ENV).unwrap(), Some(d.clone())); + let list = with_env(&format!(" - {VERIFIED_FP_ENV}={d}\n")); + assert_eq!(list.literal_env(VERIFIED_FP_ENV).unwrap(), Some(d.clone())); + assert_eq!(list.literal_env(RECIPIENTS_ENV).unwrap(), None); + // Interpolated or passed through from the runtime environment. + for env in [ + format!(" {VERIFIED_FP_ENV}: ${{FP}}\n"), + format!(" {VERIFIED_FP_ENV}: $FP\n"), + format!(" {VERIFIED_FP_ENV}:\n"), + format!(" {VERIFIED_FP_ENV}: Null\n"), + format!(" {VERIFIED_FP_ENV}: NULL\n"), + format!(" {VERIFIED_FP_ENV}: null\n"), + format!(" {VERIFIED_FP_ENV}: ~\n"), + format!(" - {VERIFIED_FP_ENV}\n"), + format!(" - {VERIFIED_FP_ENV}=${{FP:-{d}}}\n"), + ] { + let err = with_env(&env) + .literal_env(VERIFIED_FP_ENV) + .expect_err(&env) + .to_string(); + assert!(err.contains("literal"), "{env:?}: {err}"); + } + // Two services that disagree. + let two = AppCompose::parse(&app(&format!( + "services:\n a:\n image: a@sha256:{d}\n environment:\n {VERIFIED_FP_ENV}: {d}\n b:\n image: a@sha256:{d}\n environment:\n {VERIFIED_FP_ENV}: {}\n", + "b".repeat(64) + ))) + .unwrap(); + assert!(two.literal_env(VERIFIED_FP_ENV).is_err()); + } + + #[test] + fn escape_makes_untrusted_text_printable() { + assert_eq!(escape("ok -_./:@"), "ok -_./:@"); + assert_eq!( + escape("\u{1b}[2K\r\u{2028}\\"), + "\\u{1b}[2K\\u{d}\\u{2028}\\\\" + ); + } +} diff --git a/tinycloud-at-rest-escrow/src/lib.rs b/tinycloud-at-rest-escrow/src/lib.rs new file mode 100644 index 00000000..0600f8ca --- /dev/null +++ b/tinycloud-at-rest-escrow/src/lib.rs @@ -0,0 +1,314 @@ +//! 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; +pub mod compose; +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), + #[error("app-compose: {0}")] + Compose(String), +} + +/// 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/compose/ls_comment_quote.yaml b/tinycloud-at-rest-escrow/tests/fixtures/compose/ls_comment_quote.yaml new file mode 100644 index 00000000..4914cd97 --- /dev/null +++ b/tinycloud-at-rest-escrow/tests/fixtures/compose/ls_comment_quote.yaml @@ -0,0 +1,4 @@ +services: + web: #
 image: nginx:latest
 x-hide: ' + image: ghcr.io/x/node@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + #' diff --git a/tinycloud-at-rest-escrow/tests/fixtures/compose/ls_extra_service.yaml b/tinycloud-at-rest-escrow/tests/fixtures/compose/ls_extra_service.yaml new file mode 100644 index 00000000..e0a5f79e --- /dev/null +++ b/tinycloud-at-rest-escrow/tests/fixtures/compose/ls_extra_service.yaml @@ -0,0 +1,3 @@ +services: + web: + image: ghcr.io/x/node@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa #
 evil:
 image: nginx:latest diff --git a/tinycloud-at-rest-escrow/tests/fixtures/compose/nel_comment_quote.yaml b/tinycloud-at-rest-escrow/tests/fixtures/compose/nel_comment_quote.yaml new file mode 100644 index 00000000..5a91bc22 --- /dev/null +++ b/tinycloud-at-rest-escrow/tests/fixtures/compose/nel_comment_quote.yaml @@ -0,0 +1,4 @@ +services: + web: #… image: nginx:latest… x-hide: ' + image: ghcr.io/x/node@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + #' diff --git a/tinycloud-at-rest-escrow/tests/fixtures/compose/ps_comment_quote.yaml b/tinycloud-at-rest-escrow/tests/fixtures/compose/ps_comment_quote.yaml new file mode 100644 index 00000000..31766e16 --- /dev/null +++ b/tinycloud-at-rest-escrow/tests/fixtures/compose/ps_comment_quote.yaml @@ -0,0 +1,4 @@ +services: + web: #
 image: nginx:latest
 x-hide: ' + image: ghcr.io/x/node@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + #' 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-----