From fc33da01cb8c48c48fce6b0fa089ad4c76e0c988 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lomig=20Me=CC=81gard?= Date: Sat, 26 Sep 2026 14:59:14 +0200 Subject: [PATCH 1/3] feat: a salted transcript chain, evidence bundles and disclosure The transcript opens with a header that records the run's inputs, and each fact is committed as a salted leaf in a SHA-256 chain, so the fingerprint survives redaction. Confidentiality levels are ordered integers named in the header, and each fact type has a default level: attestations are public, failed attempts, prompt answers and deviations restricted, persons and material digests confidential. Roles are declared once, and steps name their role by id. Transcript lines put the fields every line has first. rite bundle create packages a run as an evidence bundle: the transcript, the ceremony definition and the artifacts, under an index. rite bundle disclose derives a disclosure at a level from a bundle; withheld lines keep their time, level and checkpoints, so the disclosure verifies to the same fingerprint. rite verify checks both kinds of bundle: a line withheld at or below the threshold fails, a line disclosed above it fails, a disclosure holding the ceremony definition fails, and the index must list every file the transcript records. A report of a disclosure says what it withholds. The transcript writer refuses a level its header does not declare, as the reader does. rite run ends with the commands that verify, report and bundle the run. Before 1.0 the transcript format, the fact vocabulary and the bundle format are 0: they change between releases without a new number, the header's producer names the release, and rite verify warns when another release wrote the transcript. A backend operation names the backend the step ran with in a field of its own; the backend's identity is on its BackendBound fact. Actions record operations through Reporter::backend_operation, which takes the step and the backend from the step being run. The resolver refuses numbers outside the range canonical JSON records exactly (integers within 2^53 - 1). JSON Schemas for the transcript and the bundle index are published under docs/schema, generated from the model types in tests, with descriptions of their own and every bound the format has. Each schema's $id is https://ritely.io/schemas//, and bundle.json and the transcript's first line name it as $schema. The demo folder holds a bundle of one run, its public disclosure and a report of each. docs/transcript-format.md specifies the transcript for anyone writing a verifier, and docs/evidence-bundles.md describes bundles and disclosures. --- Cargo.lock | 307 +++- Cargo.toml | 2 + README.md | 23 +- crates/rite-model/Cargo.toml | 6 + crates/rite-model/src/bundle.rs | 332 +++++ crates/rite-model/src/canonical.rs | 240 ++++ crates/rite-model/src/commitment.rs | 182 +++ crates/rite-model/src/digest.rs | 168 +++ crates/rite-model/src/ir/ceremony.rs | 4 + crates/rite-model/src/ir/ids.rs | 14 +- crates/rite-model/src/lib.rs | 13 +- crates/rite-model/src/schema/mod.rs | 407 ++++++ crates/rite-model/src/transcript.rs | 1097 +++++++++++++- crates/rite-render/src/report/data.rs | 51 +- crates/rite-render/src/report/mod.rs | 3 +- crates/rite-render/src/view.rs | 25 +- .../rite-render/templates/report.html.jinja | 7 +- crates/rite-resolver/src/diagnostic.rs | 1 + crates/rite-resolver/src/error.rs | 11 + crates/rite-resolver/src/lib.rs | 14 + crates/rite-resolver/src/lower.rs | 93 ++ crates/rite-resolver/src/resolve.rs | 58 +- crates/rite-resolver/src/schema.rs | 13 +- crates/rite-runtime/src/display.rs | 44 +- crates/rite-runtime/src/executor.rs | 15 +- crates/rite-runtime/src/lib.rs | 11 +- crates/rite-runtime/src/reporter.rs | 80 +- crates/rite-runtime/src/runner.rs | 272 +++- crates/rite-runtime/src/test_support.rs | 55 +- crates/rite-runtime/src/transcript_sink.rs | 1269 +++++++++++++---- crates/rite-stdlib/src/crypto/decrypt_data.rs | 21 +- crates/rite-stdlib/src/crypto/encrypt_data.rs | 31 +- .../rite-stdlib/src/crypto/export_public.rs | 15 +- crates/rite-stdlib/src/crypto/generate_key.rs | 33 +- crates/rite-stdlib/src/crypto/import_key.rs | 25 +- crates/rite-stdlib/src/crypto/mod.rs | 5 +- crates/rite-stdlib/src/crypto/sign_data.rs | 19 +- crates/rite-stdlib/src/crypto/unwrap_key.rs | 19 +- .../src/crypto/verify_signature.rs | 26 +- crates/rite-stdlib/src/crypto/wrap_key.rs | 27 +- crates/rite-stdlib/src/piv/attest.rs | 13 +- .../rite-stdlib/src/piv/read_certificate.rs | 13 +- crates/rite-stdlib/src/piv/sign.rs | 16 +- crates/rite-stdlib/src/pki/generate_csr.rs | 19 +- .../rite-stdlib/src/pki/issue_certificate.rs | 19 +- .../rite-stdlib/src/sharing/combine_shares.rs | 15 +- .../rite-stdlib/src/sharing/split_secret.rs | 18 +- .../src/verification/machine_info.rs | 9 +- crates/rite-stdlib/tests/actions.rs | 10 +- crates/rite-tui/src/model.rs | 7 +- crates/rite-tui/src/update.rs | 31 +- crates/rite/src/bundle/create.rs | 310 ++++ crates/rite/src/bundle/disclose.rs | 232 +++ crates/rite/src/bundle/mod.rs | 113 ++ crates/rite/src/console.rs | 11 +- crates/rite/src/container_checks.rs | 61 +- crates/rite/src/headless.rs | 6 +- crates/rite/src/main.rs | 18 + crates/rite/src/report.rs | 63 +- crates/rite/src/run.rs | 74 +- crates/rite/src/verify.rs | 709 +++++++-- docs/demo/README.md | 29 + docs/demo/demo-bundle/artifacts/root_cert.pem | 30 + .../demo-bundle/artifacts/root_public_key.pem | 14 + docs/demo/demo-bundle/bundle.json | 26 + .../demo-bundle/definition/ceremony.rite.yaml | 98 ++ docs/demo/demo-bundle/transcript.jsonl | 43 + docs/demo/demo-disclosure-report.html | 571 ++++++++ .../demo-disclosure/artifacts/root_cert.pem | 30 + .../artifacts/root_public_key.pem | 14 + docs/demo/demo-disclosure/bundle.json | 23 + docs/demo/demo-disclosure/transcript.jsonl | 43 + docs/demo/demo-report.html | 30 +- docs/demo/demo-transcript.jsonl | 37 - docs/demo/render-prints.sh | 39 +- docs/development/runtime-and-frontend.md | 53 +- docs/evidence-bundles.md | 96 ++ docs/schema/README.md | 51 + docs/schema/bundle.schema.json | 131 ++ docs/schema/transcript.schema.json | 1204 ++++++++++++++++ docs/transcript-format.md | 174 +++ 81 files changed, 8674 insertions(+), 867 deletions(-) create mode 100644 crates/rite-model/src/bundle.rs create mode 100644 crates/rite-model/src/canonical.rs create mode 100644 crates/rite-model/src/commitment.rs create mode 100644 crates/rite-model/src/digest.rs create mode 100644 crates/rite-model/src/schema/mod.rs create mode 100644 crates/rite/src/bundle/create.rs create mode 100644 crates/rite/src/bundle/disclose.rs create mode 100644 crates/rite/src/bundle/mod.rs create mode 100644 docs/demo/README.md create mode 100644 docs/demo/demo-bundle/artifacts/root_cert.pem create mode 100644 docs/demo/demo-bundle/artifacts/root_public_key.pem create mode 100644 docs/demo/demo-bundle/bundle.json create mode 100644 docs/demo/demo-bundle/definition/ceremony.rite.yaml create mode 100644 docs/demo/demo-bundle/transcript.jsonl create mode 100644 docs/demo/demo-disclosure-report.html create mode 100644 docs/demo/demo-disclosure/artifacts/root_cert.pem create mode 100644 docs/demo/demo-disclosure/artifacts/root_public_key.pem create mode 100644 docs/demo/demo-disclosure/bundle.json create mode 100644 docs/demo/demo-disclosure/transcript.jsonl delete mode 100644 docs/demo/demo-transcript.jsonl create mode 100644 docs/evidence-bundles.md create mode 100644 docs/schema/README.md create mode 100644 docs/schema/bundle.schema.json create mode 100644 docs/schema/transcript.schema.json create mode 100644 docs/transcript-format.md diff --git a/Cargo.lock b/Cargo.lock index 08e956f..e0264eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,20 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -136,6 +150,21 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.13.2" @@ -192,6 +221,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytes" version = "1.12.1" @@ -539,6 +574,12 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "der" version = "0.7.10" @@ -669,6 +710,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "ecdsa" version = "0.16.9" @@ -710,6 +757,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "encode_unicode" version = "1.0.0" @@ -741,6 +797,17 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "fancy-regex" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -807,6 +874,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" +[[package]] +name = "fraction" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e246562084dde8ebbcc943b261c406ce4f68e5032ec28029a251a47d6a295500" +dependencies = [ + "num", + "num-bigint", +] + [[package]] name = "futures" version = "0.3.34" @@ -905,6 +982,20 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -913,7 +1004,7 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "rand_core 0.10.1", ] @@ -1151,6 +1242,59 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonschema" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71160ed5f6dbe36a2d6be79f4ca03ee09a99d2866f340faed49458913449aefa" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom 0.3.4", + "itoa", + "jsonschema-regex", + "jsonschema-value", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "serde", + "serde_json", + "strum", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "jsonschema-regex" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48d2120d8466ffcdc1b3be4b88ff0e9191bf5b6b09b1b1af1eef9aff8f465ea3" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "jsonschema-value" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbfa40a42415369d940b3848f3ce08099f1f1ac04d73e8580f4d652617c4f44" +dependencies = [ + "ahash", + "bytecount", + "fraction", + "getrandom 0.3.4", + "num-cmp", + "num-traits", + "serde_json", + "zmij", +] + [[package]] name = "kasuari" version = "0.4.12" @@ -1275,6 +1419,12 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b" +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + [[package]] name = "minijinja" version = "2.24.0" @@ -1322,6 +1472,30 @@ dependencies = [ "winapi", ] +[[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", +] + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -1339,6 +1513,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -1364,6 +1553,17 @@ dependencies = [ "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" @@ -1461,6 +1661,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "p256" version = "0.13.2" @@ -1702,6 +1908,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -1850,6 +2062,23 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "referencing" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b06f6798be4fed305e74df8b1fb90cf59c6b8cc17aa3febeb1830c0c6b627b3e" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.17.1", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + [[package]] name = "regex" version = "1.13.1" @@ -1939,10 +2168,13 @@ dependencies = [ "base16ct 1.0.0", "base64ct", "indexmap", + "jsonschema", "regex-lite", "rite-sdk", + "schemars", "serde", "serde_json", + "sha2 0.11.0", "zeroize", ] @@ -2164,6 +2396,31 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.4", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2238,6 +2495,17 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "serde_json" version = "1.0.151" @@ -2684,6 +2952,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2730,6 +3004,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -2742,6 +3026,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "vte" version = "0.14.1" @@ -2766,6 +3056,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.127" @@ -2952,6 +3251,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "x509-cert" version = "0.2.5" diff --git a/Cargo.toml b/Cargo.toml index 7fa5754..e5f83be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,8 @@ marked-yaml = { version = "0.8.0", features = ["serde"] } indexmap = { version = "2.14.0", features = ["serde"] } chrono = { version = "0.4.44", default-features = false, features = ["clock", "std", "serde"] } insta = { version = "1.47.2", features = ["yaml", "filters"] } +schemars = "1.2.2" +jsonschema = { version = "0.57.0", default-features = false } assert_cmd = "2.2.2" secrecy = "0.10.3" rpassword = "7.5.2" diff --git a/README.md b/README.md index bb62972..a560536 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ A ceremony unfolds in phases. The same YAML drives all of them: 2. **Validate** — `rite check` catches missing references, undefined roles, schema errors, and step parameters no action can act on. 3. **Prepare** — `rite script` produces the printed protocol participants follow and complete by hand during the ceremony, archived alongside the digital transcript. 4. **Execute** — `rite run` walks operators and witnesses through the steps, recording every action in an append-only transcript. -5. **Audit** — `rite verify` confirms transcript integrity; `rite report` produces a human-readable audit document for stakeholders. +5. **Audit** — `rite verify` confirms transcript integrity; `rite bundle create` packages the run with its definition as an evidence bundle; `rite bundle disclose` derives a publishable part of it; `rite report` produces a human-readable audit document for stakeholders. ## Example @@ -96,6 +96,26 @@ rite verify root-ca-key-generation-20260511T201639 # verify integrity rite report root-ca-key-generation-20260511T201639 # generate audit report ``` +To keep the evidence, package the run with the ceremony it ran from. `rite bundle create` checks the +definition against the digest the transcript records, and each artifact against its own: + +```sh +rite bundle create root-ca-key-generation-20260511T201639 --definition ceremony.rite.yaml -o root-ca-bundle +rite verify root-ca-bundle +``` + +To publish part of it, derive a disclosure. Every fact recorded above the level is withheld but +stays committed, so the disclosure verifies to the same fingerprint as the complete record: + +```sh +rite bundle disclose root-ca-bundle --level public -o root-ca-public +rite verify root-ca-public +``` + +[Evidence bundles](docs/evidence-bundles.md) covers bundles and disclosures, and +[the transcript format](docs/transcript-format.md) specifies the transcript for anyone writing +their own verifier. + ## Installation Install with Homebrew: @@ -149,6 +169,7 @@ Both bundle the `rite-ls` language server. For other LSP-aware editors, run `rit - [ ] Plugin system for out-of-process backends - [x] **Evidence and verification** - [x] Transcript generation and `rite verify` + - [x] Evidence bundles (`rite bundle create`) and verifiable partial disclosure (`rite bundle disclose`) - [x] Verifiable randomness - [ ] Hardware-attested execution (TPM PCR measurements and signed quotes) - [ ] RFC3161 trusted timestamps diff --git a/crates/rite-model/Cargo.toml b/crates/rite-model/Cargo.toml index 4c8ec9f..25f9037 100644 --- a/crates/rite-model/Cargo.toml +++ b/crates/rite-model/Cargo.toml @@ -20,6 +20,12 @@ regex-lite = { workspace = true } base16ct = { workspace = true } base64ct = { workspace = true } zeroize = { workspace = true } +sha2 = { workspace = true } + +[dev-dependencies] +# The published JSON Schemas are generated, and checked, by this crate's tests. +schemars = { workspace = true } +jsonschema = { workspace = true } [lints] workspace = true diff --git a/crates/rite-model/src/bundle.rs b/crates/rite-model/src/bundle.rs new file mode 100644 index 0000000..a8fbff2 --- /dev/null +++ b/crates/rite-model/src/bundle.rs @@ -0,0 +1,332 @@ +//! The evidence bundle: a ceremony run packaged with its definition. +//! +//! A bundle is a directory with a fixed layout: +//! +//! ```text +//! bundle.json this index +//! transcript.jsonl the transcript, byte for byte as the run wrote it +//! definition/ceremony.rite.yaml the ceremony YAML the run was resolved from +//! artifacts/ artifacts the transcript records with a digest +//! ``` +//! +//! The index locates files and says what each one is. It repeats no digest: +//! every file other than the transcript is bound by a digest the transcript +//! commits to (the template digest on `CeremonyStarted`, the digest on each +//! `ArtifactWritten`), so a verifier checks files against the transcript and +//! the index adds nothing that would need protecting or redacting on its own. +//! The trust anchor is the transcript fingerprint, never the index. +//! +//! A disclosure bundle has the same layout. Its transcript withholds every +//! line above the index's threshold and folds to the same fingerprint as the +//! complete one; it carries only the artifacts whose facts it discloses, and +//! never the definition, which can name people. + +use serde::{Deserialize, Serialize}; + +use crate::Sha256Digest; +use crate::transcript::Level; + +/// Bundle format version this crate writes and reads. 0 while rite is before +/// 1.0, like [`TRANSCRIPT_FORMAT`](crate::TRANSCRIPT_FORMAT). +pub const BUNDLE_FORMAT: u32 = 0; + +/// URL of the JSON Schema for the index this release writes, which the index +/// names as its `$schema` so editors validate it. +pub const BUNDLE_SCHEMA: &str = concat!( + "https://ritely.io/schemas/", + env!("CARGO_PKG_VERSION"), + "/bundle.schema.json" +); + +/// Name of the index file at the bundle root. +pub const INDEX_FILE: &str = "bundle.json"; + +/// Path of the transcript within a bundle. +pub const TRANSCRIPT_FILE: &str = "transcript.jsonl"; + +/// Path of the ceremony definition within a bundle. +pub const DEFINITION_FILE: &str = "definition/ceremony.rite.yaml"; + +/// Directory holding artifacts within a bundle. +pub const ARTIFACTS_DIR: &str = "artifacts"; + +/// The index at the root of a bundle. +#[non_exhaustive] +#[cfg_attr(test, derive(schemars::JsonSchema))] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr( + test, + schemars( + description = "The bundle.json index of an evidence bundle: what kind of bundle it is \ + and which files it holds. It repeats no digest: every file other than the transcript is \ + bound by a digest the transcript records, so a verifier checks the files against the \ + transcript." + ) +)] +pub struct BundleIndex { + /// URL of the JSON Schema the index was written against + /// ([`BUNDLE_SCHEMA`]). Editors read it; a reader ignores it. + #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")] + #[cfg_attr( + test, + schemars( + description = "The URL of the JSON Schema this index was written against, for \ + editors. A reader ignores it.", + extend("format" = "uri") + ) + )] + pub schema: Option, + /// Bundle format version ([`BUNDLE_FORMAT`]). A reader refuses a value it + /// does not know. + #[cfg_attr(test, schemars(description = "Version of the bundle format."))] + pub rite_bundle: u32, + /// Whether this is the complete record or a disclosure derived from it, + /// written as `kind`, with a disclosure's `threshold` beside it. + #[serde(flatten)] + pub kind: BundleKind, + /// Fingerprint of the transcript in the bundle. A convenience for + /// indexing; a verifier recomputes it from the transcript. + #[cfg_attr( + test, + schemars( + description = "The fingerprint of the bundle's transcript, for indexing. A \ + verifier computes it from the transcript and compares." + ) + )] + pub fingerprint: Sha256Digest, + /// Every file in the bundle other than the index, in a stable order. + #[cfg_attr( + test, + schemars(description = "Every file in the bundle other than the index.") + )] + pub files: Vec, +} + +impl BundleIndex { + /// An index for a complete bundle. + #[must_use] + pub fn complete(fingerprint: Sha256Digest, files: Vec) -> Self { + Self { + schema: Some(BUNDLE_SCHEMA.to_string()), + rite_bundle: BUNDLE_FORMAT, + kind: BundleKind::Complete, + fingerprint, + files, + } + } + + /// An index for a disclosure: every line above `threshold` is withheld. + #[must_use] + pub fn disclosure(fingerprint: Sha256Digest, threshold: Level, files: Vec) -> Self { + Self { + schema: Some(BUNDLE_SCHEMA.to_string()), + rite_bundle: BUNDLE_FORMAT, + kind: BundleKind::Disclosure { threshold }, + fingerprint, + files, + } + } +} + +/// What a bundle holds relative to the run. +#[non_exhaustive] +#[cfg_attr(test, derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum BundleKind { + /// The complete transcript, with every fact. + #[cfg_attr( + test, + schemars( + description = "The complete record: the transcript with every fact, the ceremony \ + definition if it was included, and the artifacts the run kept." + ) + )] + Complete, + /// A transcript with lines withheld, derived from a complete one. + #[cfg_attr( + test, + schemars( + description = "A disclosure derived from a complete bundle: every line above the \ + threshold is withheld, and only the artifacts whose facts are disclosed are included. \ + The ceremony definition is never included." + ) + )] + Disclosure { + /// Every line at or below the threshold is disclosed, every line + /// above it withheld. + #[cfg_attr( + test, + schemars( + description = "The widest level disclosed: every line at or below it is \ + disclosed, every line above it withheld." + ) + )] + threshold: Level, + }, +} + +/// Where an artifact recorded under `file` is stored, relative to the bundle +/// or run directory: `artifacts/`. +/// +/// The name comes from a transcript, which is untrusted input: a name that is +/// not a single safe path component yields `None`, so a crafted transcript +/// cannot point a reader at files outside the artifacts directory. +#[must_use] +pub fn artifact_path(file: &str) -> Option { + crate::is_safe_component(file).then(|| format!("{ARTIFACTS_DIR}/{file}")) +} + +/// One file in a bundle. +#[non_exhaustive] +#[cfg_attr(test, derive(schemars::JsonSchema))] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(test, schemars(description = "One file in the bundle."))] +pub struct BundleFile { + /// Path relative to the bundle root, `/`-separated. + #[cfg_attr( + test, + schemars( + description = "The file's path from the bundle root, with `/` between components." + ) + )] + pub path: String, + /// What the file is, which also says what binds it. + #[cfg_attr(test, schemars(description = "What the file is."))] + pub role: FileRole, + /// The artifact's name as declared in the ceremony, for an artifact. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr( + test, + schemars(description = "For an artifact, its name as declared in the ceremony.") + )] + pub name: Option, +} + +impl BundleFile { + /// The transcript. + #[must_use] + pub fn transcript() -> Self { + Self { + path: TRANSCRIPT_FILE.to_string(), + role: FileRole::Transcript, + name: None, + } + } + + /// The ceremony definition. + #[must_use] + pub fn definition() -> Self { + Self { + path: DEFINITION_FILE.to_string(), + role: FileRole::Definition, + name: None, + } + } + + /// An artifact, stored at its [`artifact_path`]. + #[must_use] + pub fn artifact(name: &str, path: &str) -> Self { + Self { + path: path.to_string(), + role: FileRole::Artifact, + name: Some(name.to_string()), + } + } +} + +/// What a bundle file is. +#[non_exhaustive] +#[cfg_attr(test, derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[cfg_attr( + test, + schemars(description = "What a file is, which also says what binds it to the transcript.") +)] +pub enum FileRole { + /// The transcript: the record the fingerprint identifies. + #[cfg_attr( + test, + schemars(description = "The transcript, which the fingerprint identifies.") + )] + Transcript, + /// The ceremony YAML, bound by the template digest on `CeremonyStarted`. + #[cfg_attr( + test, + schemars( + description = "The ceremony definition, bound by the template digest on \ + ceremony_started." + ) + )] + Definition, + /// An artifact, bound by the digest on its `ArtifactWritten`. + #[cfg_attr( + test, + schemars(description = "An artifact, bound by the digest on its artifact_written fact.") + )] + Artifact, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn index_wire_shape() { + let index = BundleIndex::complete( + Sha256Digest::of(b"t"), + vec![ + BundleFile::transcript(), + BundleFile::definition(), + BundleFile::artifact("root_cert", "artifacts/root_cert.pem"), + ], + ); + assert_eq!( + serde_json::to_value(&index).expect("serialize"), + json!({ + "$schema": BUNDLE_SCHEMA, + "rite_bundle": 0, + "kind": "complete", + "fingerprint": Sha256Digest::of(b"t").as_str(), + "files": [ + { "path": "transcript.jsonl", "role": "transcript" }, + { "path": "definition/ceremony.rite.yaml", "role": "definition" }, + { "path": "artifacts/root_cert.pem", "role": "artifact", "name": "root_cert" }, + ], + }) + ); + } + + #[test] + fn a_disclosure_writes_its_threshold_beside_its_kind() { + let index = BundleIndex::disclosure(Sha256Digest::of(b"t"), Level::PUBLIC, vec![]); + let value = serde_json::to_value(&index).expect("serialize"); + assert_eq!(value.get("kind"), Some(&json!("disclosure"))); + assert_eq!(value.get("threshold"), Some(&json!(10))); + let back: BundleIndex = serde_json::from_value(value).expect("deserialize"); + assert_eq!(back, index); + } + + #[test] + fn a_disclosure_without_a_threshold_does_not_parse() { + let value = json!({ + "rite_bundle": 0, + "kind": "disclosure", + "fingerprint": Sha256Digest::of(b"t").as_str(), + "files": [], + }); + assert!(serde_json::from_value::(value).is_err()); + } + + #[test] + fn artifact_paths_stay_in_the_artifacts_directory() { + assert_eq!( + artifact_path("cert.pem").as_deref(), + Some("artifacts/cert.pem") + ); + assert_eq!(artifact_path("../cert.pem"), None); + assert_eq!(artifact_path("a/b"), None); + } +} diff --git a/crates/rite-model/src/canonical.rs b/crates/rite-model/src/canonical.rs new file mode 100644 index 0000000..4fa089d --- /dev/null +++ b/crates/rite-model/src/canonical.rs @@ -0,0 +1,240 @@ +//! Canonical JSON (RFC 8785, JCS) for the values the transcript commits to. +//! +//! A fact is committed as the SHA-256 of its canonical form, so any +//! implementation that canonicalises the same value gets the same bytes. The +//! transcript holds no floating-point numbers, and integers stay within the +//! range JavaScript represents exactly, so this writer covers that subset and +//! refuses anything else rather than guess at the number formatting RFC 8785 +//! borrows from ECMAScript. + +use std::fmt; +use std::fmt::Write as _; + +use serde_json::{Number, Value}; + +/// Largest integer magnitude written: `2^53 - 1`, the edge of the range an +/// IEEE 754 double represents exactly. +const MAX_SAFE_INTEGER: u64 = (1 << 53) - 1; + +/// A value outside the subset the transcript's canonical form covers. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum CanonicalJsonError { + /// A number that is not an integer. + NotAnInteger(String), + /// An integer beyond `±(2^53 - 1)`. + IntegerOutOfRange(String), +} + +impl fmt::Display for CanonicalJsonError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + CanonicalJsonError::NotAnInteger(n) => { + write!( + f, + "{n} is not an integer; the transcript records integers only" + ) + } + CanonicalJsonError::IntegerOutOfRange(n) => { + write!(f, "{n} is beyond the integer range the transcript records") + } + } + } +} + +impl std::error::Error for CanonicalJsonError {} + +/// The RFC 8785 canonical form of `value`. +/// +/// Members are sorted by their UTF-16 code units and written without +/// whitespace, so any implementation that reads the same value writes the +/// same bytes. +/// +/// ``` +/// use rite_model::canonical_json; +/// use serde_json::json; +/// +/// let value = json!({ "b": 2, "a": [true, null, "x"] }); +/// assert_eq!(canonical_json(&value)?, r#"{"a":[true,null,"x"],"b":2}"#); +/// assert!(canonical_json(&json!({ "ratio": 1.5 })).is_err()); +/// # Ok::<(), rite_model::CanonicalJsonError>(()) +/// ``` +/// +/// # Errors +/// +/// Returns [`CanonicalJsonError`] for a non-integer number or an integer +/// beyond `±(2^53 - 1)`. +pub fn canonical_json(value: &Value) -> Result { + let mut out = String::new(); + write_value(value, &mut out)?; + Ok(out) +} + +/// Checks that every number in `value` is one the canonical form writes. +/// +/// A ceremony checks its values with this before a run, so a number the +/// transcript cannot record is refused by `rite check` rather than mid-run. +/// +/// # Errors +/// +/// Returns [`CanonicalJsonError`] for the first number that is not an integer +/// or is beyond `±(2^53 - 1)`. +pub fn check_numbers(value: &Value) -> Result<(), CanonicalJsonError> { + match value { + Value::Null | Value::Bool(_) | Value::String(_) => Ok(()), + Value::Number(n) => check_number(n), + Value::Array(items) => items.iter().try_for_each(check_numbers), + Value::Object(members) => members.values().try_for_each(check_numbers), + } +} + +fn check_number(n: &Number) -> Result<(), CanonicalJsonError> { + let magnitude = if let Some(i) = n.as_i64() { + i.unsigned_abs() + } else if let Some(u) = n.as_u64() { + u + } else { + return Err(CanonicalJsonError::NotAnInteger(n.to_string())); + }; + if magnitude > MAX_SAFE_INTEGER { + return Err(CanonicalJsonError::IntegerOutOfRange(n.to_string())); + } + Ok(()) +} + +fn write_value(value: &Value, out: &mut String) -> Result<(), CanonicalJsonError> { + match value { + Value::Null => out.push_str("null"), + Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }), + Value::Number(n) => { + check_number(n)?; + out.push_str(&n.to_string()); + } + Value::String(s) => write_string(s, out), + Value::Array(items) => { + out.push('['); + for (i, item) in items.iter().enumerate() { + if i > 0 { + out.push(','); + } + write_value(item, out)?; + } + out.push(']'); + } + Value::Object(map) => { + // RFC 8785 orders members by the UTF-16 code units of their + // names, which differs from byte order for characters above + // U+FFFF. + let mut members: Vec<(&String, &Value)> = map.iter().collect(); + members.sort_by(|a, b| a.0.encode_utf16().cmp(b.0.encode_utf16())); + out.push('{'); + for (i, (name, member)) in members.into_iter().enumerate() { + if i > 0 { + out.push(','); + } + write_string(name, out); + out.push(':'); + write_value(member, out)?; + } + out.push('}'); + } + } + Ok(()) +} + +/// A string in RFC 8785 form: `"` and `\` escaped, the five short control +/// escapes, other control characters as lowercase `\u00xx`, everything else +/// as itself. +fn write_string(s: &str, out: &mut String) { + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\u{08}' => out.push_str("\\b"), + '\t' => out.push_str("\\t"), + '\n' => out.push_str("\\n"), + '\u{0C}' => out.push_str("\\f"), + '\r' => out.push_str("\\r"), + c if c < ' ' => { + let _ = write!(out, "\\u{:04x}", u32::from(c)); + } + c => out.push(c), + } + } + out.push('"'); +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn members_are_ordered_by_utf16_code_units() { + // The ordering example from RFC 8785 section 3.2.3. + let value: Value = serde_json::from_str( + r#"{"\u20ac":"Euro Sign","\r":"Carriage Return","\ufb33":"Hebrew Letter Dalet With Dagesh","1":"One","\ud83d\ude00":"Emoji: Grinning Face","\u0080":"Control","\u00f6":"Latin Small Letter O With Diaeresis"}"#, + ) + .expect("parse"); + // The emoji (a surrogate pair from 0xD83D) sorts before U+FB33, + // though its UTF-8 bytes sort after. + let expected = concat!( + r#"{"\r":"Carriage Return","1":"One","#, + "\"\u{80}\":\"Control\",", + "\"\u{f6}\":\"Latin Small Letter O With Diaeresis\",", + "\"\u{20ac}\":\"Euro Sign\",", + "\"\u{1f600}\":\"Emoji: Grinning Face\",", + "\"\u{fb33}\":\"Hebrew Letter Dalet With Dagesh\"}", + ); + assert_eq!(canonical_json(&value).expect("canonical"), expected); + } + + #[test] + fn strings_use_the_rfc_escapes() { + // The string example from RFC 8785 section 3.2.2.2. + let value: Value = + serde_json::from_str(r#""\u20ac$\u000F\u000aA'\u0042\u0022\u005c\\\"\/""#) + .expect("parse"); + assert_eq!( + canonical_json(&value).expect("canonical"), + r#""€$\u000f\nA'B\"\\\\\"/""# + ); + } + + #[test] + fn nesting_and_literals() { + let value = json!({ "b": [true, null, -3, {"z": 1, "a": "x"}], "a": 0 }); + assert_eq!( + canonical_json(&value).expect("canonical"), + r#"{"a":0,"b":[true,null,-3,{"a":"x","z":1}]}"# + ); + } + + #[test] + fn floats_and_large_integers_are_refused() { + assert!(matches!( + canonical_json(&json!(1.5)), + Err(CanonicalJsonError::NotAnInteger(_)) + )); + assert!(matches!( + canonical_json(&json!(1_u64 << 53)), + Err(CanonicalJsonError::IntegerOutOfRange(_)) + )); + assert!(canonical_json(&json!(MAX_SAFE_INTEGER)).is_ok()); + assert!(canonical_json(&json!(-(1_i64 << 53) + 1)).is_ok()); + } + + #[test] + fn check_numbers_walks_nested_values() { + assert!(check_numbers(&json!({ "a": [1, "x", { "b": -2 }] })).is_ok()); + assert!(matches!( + check_numbers(&json!({ "a": [1, { "b": 0.5 }] })), + Err(CanonicalJsonError::NotAnInteger(_)) + )); + assert!(matches!( + check_numbers(&json!([u64::MAX])), + Err(CanonicalJsonError::IntegerOutOfRange(_)) + )); + } +} diff --git a/crates/rite-model/src/commitment.rs b/crates/rite-model/src/commitment.rs new file mode 100644 index 0000000..764eb27 --- /dev/null +++ b/crates/rite-model/src/commitment.rs @@ -0,0 +1,182 @@ +//! The commitment chain: how a transcript's lines fold into its fingerprint. +//! +//! ```text +//! node_0 = SHA-256(0x02 ‖ JCS(header)) +//! leaf_i = SHA-256(0x00 ‖ salt_i ‖ JCS(fact_i)) +//! node_i = SHA-256(0x01 ‖ node_{i-1} ‖ len(at_i) ‖ at_i ‖ level_i ‖ leaf_i) +//! fingerprint = node_n +//! ``` +//! +//! `JCS` is the RFC 8785 canonical form ([`canonical_json`](crate::canonical_json)). +//! The functions here take it already computed, since the writer puts the same +//! text on the line. A salt is [`SALT_LEN`] bytes from the OS random source, +//! fresh for every fact; it is never drawn from the ceremony entropy source, +//! which is public by design and would let anyone holding a redacted +//! transcript test guesses against a withheld fact. A line writes the salt as 32 lowercase hex digits, and the +//! leaf hashes the 16 decoded bytes, so the commitment does not depend on how +//! the line spells them. `at` is UTF-8, preceded by its length. Lengths and +//! the level are 8-byte big-endian integers. +//! +//! The leaf commits to the fact; the node commits to the leaf, the time and +//! the level, and to everything before it. A line whose fact is withheld keeps +//! its leaf, so it folds to the same node, and a transcript with withheld lines +//! has the same fingerprint as the complete one. The one-byte prefixes keep a +//! leaf, a node and the header from ever hashing the same input. +//! +//! # Example +//! +//! Folding a header and one fact, as a verifier does line by line; the +//! values are the construction's test vectors. +//! +//! ``` +//! use rite_model::commitment::{chain_node, fact_leaf, header_node}; +//! use rite_model::{Level, Sha256Digest, canonical_json}; +//! use serde_json::json; +//! +//! let header = json!({ +//! "rite_transcript": 1, +//! "vocabulary": 1, +//! "producer": "rite 0.6.0", +//! "run_id": "00000000000000000000000000000000", +//! "dry_run": false, +//! "levels": { "public": 10, "restricted": 20, "confidential": 30 }, +//! }); +//! let node_0 = header_node(&canonical_json(&header)?); +//! +//! let fact = json!({ "type": "act_started", "id": "setup", "label": "Setup" }); +//! let leaf = fact_leaf(&[0x11; 16], &canonical_json(&fact)?); +//! let node_1 = chain_node(&node_0, "2026-09-22T10:00:00.000000Z", Level::PUBLIC, &leaf); +//! +//! assert_eq!( +//! Sha256Digest::from_bytes(&node_1).as_str(), +//! "sha256:e1942a1ab439cad63eb64ed52692d425d898c2c16b0fac24c08e5c04f71a1af5" +//! ); +//! # Ok::<(), rite_model::CanonicalJsonError>(()) +//! ``` + +use sha2::{Digest, Sha256}; + +use crate::transcript::Level; + +/// Length of a fact's salt, in bytes. +pub const SALT_LEN: usize = 16; + +const LEAF_PREFIX: u8 = 0x00; +const NODE_PREFIX: u8 = 0x01; +const HEADER_PREFIX: u8 = 0x02; + +/// `node_0`: the commitment to the header, given as its canonical JSON. +#[must_use] +pub fn header_node(canonical_header: &str) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update([HEADER_PREFIX]); + hasher.update(canonical_header.as_bytes()); + hasher.finalize().into() +} + +/// `leaf_i`: the commitment to one fact, given as its canonical JSON, under +/// its salt. +#[must_use] +pub fn fact_leaf(salt: &[u8; SALT_LEN], canonical_fact: &str) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update([LEAF_PREFIX]); + hasher.update(salt); + hasher.update(canonical_fact.as_bytes()); + hasher.finalize().into() +} + +/// `node_i`: the chain value after one line. +#[must_use] +pub fn chain_node(previous: &[u8; 32], at: &str, level: Level, leaf: &[u8; 32]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update([NODE_PREFIX]); + hasher.update(previous); + update_with_length(&mut hasher, at.as_bytes()); + hasher.update(u64::from(level.value()).to_be_bytes()); + hasher.update(leaf); + hasher.finalize().into() +} + +fn update_with_length(hasher: &mut Sha256, bytes: &[u8]) { + let len = u64::try_from(bytes.len()).unwrap_or(u64::MAX); + hasher.update(len.to_be_bytes()); + hasher.update(bytes); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::canonical_json; + use serde_json::{Value, json}; + + fn hex(bytes: &[u8; 32]) -> String { + base16ct::lower::encode_string(bytes) + } + + fn jcs(value: &Value) -> String { + canonical_json(value).expect("canonical") + } + + /// Fixed vectors: a change to the construction shows up here, and a + /// third-party implementation can check itself against them. + #[test] + fn construction_vectors() { + let header = json!({ + "rite_transcript": 1, + "vocabulary": 1, + "producer": "rite 0.6.0", + "run_id": "00000000000000000000000000000000", + "dry_run": false, + "levels": { "public": 10, "restricted": 20, "confidential": 30 }, + }); + let node_0 = header_node(&jcs(&header)); + assert_eq!( + hex(&node_0), + "acd329c9b34b4e9443db1b47b4a5908b3f66affd630005935e5c5201c3f123ec" + ); + + let salt = [0x11; SALT_LEN]; + let fact = json!({ "type": "act_started", "id": "setup", "label": "Setup" }); + let leaf = fact_leaf(&salt, &jcs(&fact)); + assert_eq!( + hex(&leaf), + "60d495f6e5117daeaa777bcac4b9aa5360c43aff76bdcf5461380864e0c053d0" + ); + + let node_1 = chain_node(&node_0, "2026-09-22T10:00:00.000000Z", Level::PUBLIC, &leaf); + assert_eq!( + hex(&node_1), + "e1942a1ab439cad63eb64ed52692d425d898c2c16b0fac24c08e5c04f71a1af5" + ); + } + + #[test] + fn every_committed_input_changes_the_node() { + let base = chain_node(&[0; 32], "t", Level::PUBLIC, &[1; 32]); + assert_ne!(base, chain_node(&[9; 32], "t", Level::PUBLIC, &[1; 32])); + assert_ne!(base, chain_node(&[0; 32], "u", Level::PUBLIC, &[1; 32])); + assert_ne!(base, chain_node(&[0; 32], "t", Level::RESTRICTED, &[1; 32])); + assert_ne!(base, chain_node(&[0; 32], "t", Level::PUBLIC, &[2; 32])); + } + + #[test] + fn the_salt_and_the_fact_both_change_the_leaf() { + let fact = jcs(&json!({ "type": "x" })); + let leaf = fact_leaf(&[0; SALT_LEN], &fact); + assert_ne!(leaf, fact_leaf(&[1; SALT_LEN], &fact)); + assert_ne!( + leaf, + fact_leaf(&[0; SALT_LEN], &jcs(&json!({ "type": "y" }))) + ); + } + + #[test] + fn member_order_does_not_change_the_leaf() { + let a: Value = serde_json::from_str(r#"{"a":1,"b":2}"#).expect("parse"); + let b: Value = serde_json::from_str(r#"{"b":2,"a":1}"#).expect("parse"); + assert_eq!( + fact_leaf(&[0; SALT_LEN], &jcs(&a)), + fact_leaf(&[0; SALT_LEN], &jcs(&b)) + ); + } +} diff --git a/crates/rite-model/src/digest.rs b/crates/rite-model/src/digest.rs new file mode 100644 index 0000000..0599f91 --- /dev/null +++ b/crates/rite-model/src/digest.rs @@ -0,0 +1,168 @@ +//! The `sha256:` digest used for every hash the transcript records. + +use std::fmt; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +const PREFIX: &str = "sha256:"; + +/// A SHA-256 digest written as `sha256:` followed by 64 lowercase hex digits. +/// +/// The one hash encoding in the transcript. Parsing rejects any other form, +/// so a recorded digest compares correctly as a string. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct Sha256Digest(String); + +#[cfg(test)] +impl schemars::JsonSchema for Sha256Digest { + fn schema_name() -> std::borrow::Cow<'static, str> { + "Sha256Digest".into() + } + + fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ + "description": "A SHA-256 digest: `sha256:` followed by 64 lowercase hex digits.", + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$", + }) + } +} + +/// A string that is not a `sha256:<64 lowercase hex>` digest. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DigestFormatError(String); + +impl fmt::Display for DigestFormatError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "'{}' is not a sha256:<64 lowercase hex digits> digest", + self.0 + ) + } +} + +impl std::error::Error for DigestFormatError {} + +impl Sha256Digest { + /// The digest of `data`. + #[must_use] + pub fn of(data: &[u8]) -> Self { + let hash = Sha256::digest(data); + Self(format!("{PREFIX}{}", base16ct::lower::encode_string(&hash))) + } + + /// The written form of a raw 32-byte digest. + #[must_use] + pub fn from_bytes(bytes: &[u8; 32]) -> Self { + Self(format!("{PREFIX}{}", base16ct::lower::encode_string(bytes))) + } + + /// The raw 32 bytes. + #[must_use] + pub fn to_bytes(&self) -> [u8; 32] { + let mut out = [0u8; 32]; + // `parse` admits only 64 lowercase hex digits, so this decodes. + let _ = base16ct::lower::decode(&self.0[PREFIX.len()..], &mut out); + out + } + + /// Parse a digest in its written form. + /// + /// ``` + /// use rite_model::Sha256Digest; + /// + /// let digest = Sha256Digest::of(b"ceremony"); + /// assert_eq!(Sha256Digest::parse(digest.as_str()), Ok(digest)); + /// // Any other spelling is refused. + /// assert!(Sha256Digest::parse("SHA256:00").is_err()); + /// ``` + /// + /// # Errors + /// + /// Returns [`DigestFormatError`] unless `s` is `sha256:` followed by + /// exactly 64 lowercase hex digits. + pub fn parse(s: &str) -> Result { + let valid = s.strip_prefix(PREFIX).is_some_and(|hex| { + hex.len() == 64 && hex.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) + }); + if valid { + Ok(Self(s.to_string())) + } else { + Err(DigestFormatError(s.to_string())) + } + } + + /// The written form, `sha256:`. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for Sha256Digest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl TryFrom for Sha256Digest { + type Error = DigestFormatError; + + fn try_from(s: String) -> Result { + Self::parse(&s) + } +} + +impl From for String { + fn from(d: Sha256Digest) -> Self { + d.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn digest_of_known_bytes() { + assert_eq!( + Sha256Digest::of(b"hello world").as_str(), + "sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" + ); + } + + #[test] + fn bytes_round_trip() { + let d = Sha256Digest::of(b"x"); + assert_eq!(Sha256Digest::from_bytes(&d.to_bytes()), d); + } + + #[test] + fn parse_accepts_the_written_form() { + let d = Sha256Digest::of(b"x"); + assert_eq!(Sha256Digest::parse(d.as_str()), Ok(d)); + } + + #[test] + fn parse_rejects_other_forms() { + let hex = "a".repeat(64); + for bad in [ + hex.clone(), + format!("sha256:{}", "A".repeat(64)), + format!("sha256:{}", &hex[..63]), + format!("sha512:{hex}"), + format!("sha256:{hex}0"), + ] { + assert!(Sha256Digest::parse(&bad).is_err(), "accepted {bad}"); + } + } + + #[test] + fn deserialize_validates() { + let err = serde_json::from_str::("\"sha256:zz\""); + assert!(err.is_err()); + } +} diff --git a/crates/rite-model/src/ir/ceremony.rs b/crates/rite-model/src/ir/ceremony.rs index d3f3b5b..0835d8a 100644 --- a/crates/rite-model/src/ir/ceremony.rs +++ b/crates/rite-model/src/ir/ceremony.rs @@ -52,6 +52,10 @@ pub struct Ceremony { /// Post-ceremony duties, in declaration order. pub after: Vec, + + /// Digest of the ceremony YAML this ceremony was resolved from. Covers + /// the template only: run-time inputs are merged into the fields above. + pub source_digest: crate::Sha256Digest, } /// A resolved role. diff --git a/crates/rite-model/src/ir/ids.rs b/crates/rite-model/src/ir/ids.rs index 0f593a5..3253684 100644 --- a/crates/rite-model/src/ir/ids.rs +++ b/crates/rite-model/src/ir/ids.rs @@ -10,8 +10,9 @@ use std::hash::Hash; macro_rules! define_id { ($(#[$meta:meta])* $name:ident) => { - $(#[$meta])* #[derive(Clone, Eq, PartialEq, Hash, Debug, serde::Serialize, serde::Deserialize)] + #[cfg_attr(test, derive(schemars::JsonSchema))] + $(#[$meta])* #[serde(transparent)] pub struct $name(String); @@ -60,11 +61,13 @@ macro_rules! define_id { define_id!( /// Unique identifier for a role in the ceremony. + #[cfg_attr(test, schemars(description = "A role's identifier, as written in the ceremony."))] RoleId ); define_id!( /// Unique identifier for a step in the ceremony. + #[cfg_attr(test, schemars(description = "A step's identifier, as written in the ceremony."))] StepId ); @@ -75,16 +78,25 @@ define_id!( define_id!( /// Unique identifier for an act in the ceremony. + #[cfg_attr(test, schemars(description = "An act's identifier, as written in the ceremony."))] ActId ); define_id!( /// Unique identifier for a parameter in the ceremony. + #[cfg_attr( + test, + schemars(description = "A parameter's identifier, as written in the ceremony.") + )] ParamId ); define_id!( /// Unique identifier for a material in the ceremony. + #[cfg_attr( + test, + schemars(description = "A material's identifier, as written in the ceremony.") + )] MaterialId ); diff --git a/crates/rite-model/src/lib.rs b/crates/rite-model/src/lib.rs index 5a394d6..f9a7c3c 100644 --- a/crates/rite-model/src/lib.rs +++ b/crates/rite-model/src/lib.rs @@ -18,16 +18,24 @@ #![warn(missing_docs)] +pub mod bundle; +mod canonical; +pub mod commitment; +mod digest; pub mod expression; pub mod ir; mod material; pub mod params; pub mod safe_path; +#[cfg(test)] +mod schema; pub mod transcript; mod types; pub use rite_sdk::BackendConfig; +pub use canonical::{CanonicalJsonError, canonical_json, check_numbers}; +pub use digest::{DigestFormatError, Sha256Digest}; pub use material::MaterialSource; pub use safe_path::{PathSafetyError, confine, is_safe_component, safe_join, validate_component}; @@ -45,6 +53,7 @@ pub use ir::{ }; pub use transcript::{ - ErrorClass, ErrorRecord, Format, PLACEHOLDER_LIMIT, Prompt, ResponseRecord, StepFact, - StepOutcome, ValidatorSpec, compile_pattern, + ErrorClass, ErrorRecord, FACT_TYPES, FACT_VOCABULARY, Format, Level, PLACEHOLDER_LIMIT, Prompt, + ResponseRecord, StepFact, StepOutcome, TRANSCRIPT_FORMAT, TRANSCRIPT_SCHEMA, TranscriptHeader, + ValidatorSpec, compile_pattern, }; diff --git a/crates/rite-model/src/schema/mod.rs b/crates/rite-model/src/schema/mod.rs new file mode 100644 index 0000000..7bef2b8 --- /dev/null +++ b/crates/rite-model/src/schema/mod.rs @@ -0,0 +1,407 @@ +//! The published JSON Schemas for the transcript and the bundle index. +//! +//! The schemas are generated from the types that read and write the files, +//! and committed under `docs/schema/`. A test fails when the committed file +//! differs from the generated one; run it with `RITE_UPDATE_SCHEMA=1` to +//! write the new schema instead. +//! +//! The schemas are public, so their descriptions are written for readers of +//! the files, apart from the Rust doc comments: each type, variant and field +//! carries its own `schemars(description = ...)`. The line types below exist +//! only for the schema, so their doc comments are that text. +//! +//! A schema checks the shape of a line. It does not check what makes a +//! transcript evidence: that each leaf and chain value recomputes, that the +//! fact is written in canonical form, that each level is declared in the +//! header. `rite verify` checks those. + +use std::path::PathBuf; +use std::sync::OnceLock; + +use schemars::generate::SchemaSettings; +use schemars::transform::{Transform, transform_subschemas}; +use schemars::{JsonSchema, Schema}; +use serde::Deserialize; +use serde_json::Value; + +use crate::Sha256Digest; +use crate::bundle::{BUNDLE_FORMAT, BUNDLE_SCHEMA, BundleIndex}; +use crate::transcript::{ + FACT_TYPES, FACT_VOCABULARY, Level, StepFact, TRANSCRIPT_FORMAT, TRANSCRIPT_SCHEMA, + TranscriptHeader, +}; + +#[derive(Deserialize, JsonSchema)] +#[serde(untagged)] +#[allow(dead_code)] +#[schemars( + description = "One line of a transcript.jsonl file. The first line is the header; every other \ + line records one fact, either complete or withheld. Each line is a JSON object on its own \ + line, and the lines are chained: each line's chain value commits to the line and to every line \ + before it." +)] +enum TranscriptLine { + Header(HeaderLine), + Complete(CompleteLine), + Withheld(WithheldLine), +} + +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[allow(dead_code)] +#[schemars( + description = "The first line of a transcript: what the file is, and the start of the chain." +)] +struct HeaderLine { + #[serde(rename = "$schema")] + #[schemars( + description = "The URL of the JSON Schema of the release that wrote the transcript, for \ + editors and other tools. The chain does not commit to it, and a reader ignores it.", + extend("format" = "uri") + )] + schema: Option, + header: TranscriptHeader, + #[schemars( + description = "The first chain value: SHA-256 over the byte 0x02 followed by the header in \ + RFC 8785 canonical JSON." + )] + chain: Sha256Digest, +} + +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[allow(dead_code)] +#[schemars( + description = "A fact line with its content: the fact, the salt that hides it, and the values \ + that commit to both." +)] +struct CompleteLine { + at: At, + level: Level, + #[schemars( + description = "The commitment to the fact: SHA-256 over the byte 0x00, the 16 salt bytes, \ + and the fact in RFC 8785 canonical JSON." + )] + leaf: Sha256Digest, + #[schemars( + description = "The chain value after this line: SHA-256 over the byte 0x01, the previous \ + chain value, the length of `at` as an 8-byte big-endian integer, `at` in UTF-8, the level \ + as an 8-byte big-endian integer, and the leaf. The chain value of the last line is the \ + transcript's fingerprint." + )] + chain: Sha256Digest, + #[schemars(regex(pattern = r"^[0-9a-f]{32}$"))] + #[schemars( + description = "16 random bytes, written as 32 lowercase hex digits. The leaf hashes the \ + decoded bytes. The salt keeps a withheld fact from being guessed from its leaf." + )] + salt: String, + fact: StepFact, +} + +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[allow(dead_code)] +#[schemars( + description = "A fact line whose fact is withheld from a disclosure. It keeps the time, the \ + level, the leaf and the chain value, so it folds into the chain exactly as the complete line \ + does, and the transcript keeps its fingerprint." +)] +struct WithheldLine { + at: At, + level: Level, + #[schemars(description = "The commitment to the withheld fact.")] + leaf: Sha256Digest, + #[schemars(description = "The chain value after this line.")] + chain: Sha256Digest, +} + +#[derive(Deserialize, JsonSchema)] +#[schemars(extend( + "format" = "date-time", + "pattern" = r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}Z$" +))] +#[allow(dead_code)] +#[schemars( + description = "When the fact was recorded: an RFC 3339 time in UTC with six fractional digits. \ + The chain commits to this exact string." +)] +struct At(String); + +/// Bounds every integer to `±(2^53 - 1)`, the range the canonical form +/// writes, or to its Rust type's range where that is narrower. Covers an +/// optional integer, whose type is `["integer", "null"]`. +#[derive(Clone)] +struct SafeIntegers; + +const MAX_SAFE_INTEGER: i64 = (1 << 53) - 1; + +impl Transform for SafeIntegers { + fn transform(&mut self, schema: &mut Schema) { + let integer = match schema.get("type") { + Some(Value::String(kind)) => kind == "integer", + Some(Value::Array(kinds)) => kinds.iter().any(|kind| kind == "integer"), + _ => false, + }; + if integer { + // `uint32` and friends describe a Rust type, not the wire; only + // the bound they imply is kept. + let (low, high) = match schema.remove("format").as_ref().and_then(Value::as_str) { + Some("uint8") => (0, i64::from(u8::MAX)), + Some("uint16") => (0, i64::from(u16::MAX)), + Some("uint32") => (0, i64::from(u32::MAX)), + Some("int8") => (i64::from(i8::MIN), i64::from(i8::MAX)), + Some("int16") => (i64::from(i16::MIN), i64::from(i16::MAX)), + Some("int32") => (i64::from(i32::MIN), i64::from(i32::MAX)), + _ => (-MAX_SAFE_INTEGER, MAX_SAFE_INTEGER), + }; + let minimum = schema + .get("minimum") + .and_then(Value::as_i64) + .map_or(low, |min| min.max(low)); + schema.insert("minimum".to_string(), minimum.into()); + schema.insert("maximum".to_string(), high.into()); + } + transform_subschemas(self, schema); + } +} + +fn schema_of() -> Schema { + SchemaSettings::draft2020_12() + .with_transform(SafeIntegers) + .into_generator() + .into_root_schema_for::() +} + +/// The schema describes one format version: pin the version fields to it. +fn pin(schema: &mut Schema, pointer: &str, value: u32) { + schema + .pointer_mut(pointer) + .and_then(Value::as_object_mut) + .unwrap_or_else(|| panic!("no {pointer} in the schema")) + .insert("const".to_string(), value.into()); +} + +/// Name the schema: its `$id`, where the release publishes it, and a title. +/// Both go first, after `$schema`, where a reader looks for them. +fn identify(schema: Schema, id: &str, title: &str) -> Schema { + let Value::Object(generated) = schema.to_value() else { + panic!("a root schema is an object"); + }; + let mut named = serde_json::Map::new(); + for (key, value) in generated { + if key == "title" { + continue; + } + let first = key == "$schema"; + named.insert(key, value); + if first { + named.insert("$id".to_string(), id.into()); + named.insert("title".to_string(), title.into()); + } + } + Schema::try_from(Value::Object(named)).expect("still a schema") +} + +fn transcript_schema() -> Schema { + let mut schema = identify( + schema_of::(), + TRANSCRIPT_SCHEMA, + &format!("Rite transcript line (format {TRANSCRIPT_FORMAT}, vocabulary {FACT_VOCABULARY})"), + ); + let header = "/$defs/TranscriptHeader/properties"; + pin( + &mut schema, + &format!("{header}/rite_transcript"), + TRANSCRIPT_FORMAT, + ); + pin( + &mut schema, + &format!("{header}/vocabulary"), + FACT_VOCABULARY, + ); + schema +} + +fn bundle_schema() -> Schema { + let mut schema = identify( + schema_of::(), + BUNDLE_SCHEMA, + &format!("Rite bundle index (format {BUNDLE_FORMAT})"), + ); + pin(&mut schema, "/properties/rite_bundle", BUNDLE_FORMAT); + schema +} + +fn schema_path(file: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../docs/schema") + .join(file) +} + +/// Compare the committed schema with the generated one, or write it when +/// `RITE_UPDATE_SCHEMA` is set. +fn check_committed(file: &str, schema: &Schema) { + let mut generated = serde_json::to_string_pretty(schema).expect("serialize schema"); + generated.push('\n'); + let path = schema_path(file); + if std::env::var_os("RITE_UPDATE_SCHEMA").is_some() { + std::fs::create_dir_all(path.parent().expect("a parent")).expect("schema dir"); + std::fs::write(&path, generated).expect("write schema"); + return; + } + let committed = std::fs::read_to_string(&path).unwrap_or_default(); + assert!( + committed == generated, + "docs/schema/{file} is not the generated schema; run \ + `RITE_UPDATE_SCHEMA=1 cargo test -p rite-model schema` and commit the result" + ); +} + +fn validator(schema: &Schema) -> jsonschema::Validator { + jsonschema::draft202012::new(schema.as_value()).expect("a valid schema") +} + +fn transcript_validator() -> &'static jsonschema::Validator { + static VALIDATOR: OnceLock = OnceLock::new(); + VALIDATOR.get_or_init(|| validator(&transcript_schema())) +} + +/// Check one serialized fact against the published transcript schema, as +/// the fact of a complete line. +pub(crate) fn assert_fact_matches_schema(fact: &Value) { + let line = serde_json::json!({ + "at": "2026-09-22T10:00:00.000000Z", + "level": 10, + "salt": "00000000000000000000000000000000", + "fact": fact, + "leaf": Sha256Digest::of(b"leaf").as_str(), + "chain": Sha256Digest::of(b"chain").as_str(), + }); + let errors: Vec = transcript_validator() + .iter_errors(&line) + .map(|e| e.to_string()) + .collect(); + assert!( + errors.is_empty(), + "fact does not match the schema: {errors:?}\n{fact}" + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::bundle::BundleFile; + + /// A field without its own description falls back to its doc comment, + /// which shows as Rust link syntax or as the comment's line breaks. + #[test] + fn descriptions_hold_no_rust_syntax() { + for schema in [transcript_schema(), bundle_schema()] { + let text = serde_json::to_string(&schema).expect("serialize"); + for doc_comment in ["](", "::", "\\n"] { + assert!(!text.contains(doc_comment), "{doc_comment} in {text}"); + } + } + } + + #[test] + fn committed_schemas_are_the_generated_ones() { + check_committed("transcript.schema.json", &transcript_schema()); + check_committed("bundle.schema.json", &bundle_schema()); + } + + /// The fact types the schema lists are the ones a reader knows. + #[test] + fn the_schema_lists_every_fact_type() { + let schema = transcript_schema(); + let variants = schema + .pointer("/$defs/StepFact/oneOf") + .and_then(Value::as_array) + .expect("StepFact variants"); + let mut tags: Vec<&str> = variants + .iter() + .filter_map(|v| v.pointer("/properties/type/const").and_then(Value::as_str)) + .collect(); + let mut known = FACT_TYPES.to_vec(); + tags.sort_unstable(); + known.sort_unstable(); + assert_eq!(tags, known); + } + + fn demo_file(path: &str) -> String { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../docs/demo") + .join(path); + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display())) + } + + /// The demo bundle's complete transcript, and its public disclosure, + /// which holds withheld lines. + #[test] + fn every_line_of_the_demo_transcripts_matches() { + for file in [ + "demo-bundle/transcript.jsonl", + "demo-disclosure/transcript.jsonl", + ] { + for (i, line) in demo_file(file).lines().enumerate() { + let value: Value = serde_json::from_str(line).expect("json"); + assert!( + transcript_validator().is_valid(&value), + "{file} line {} does not match the schema", + i + 1 + ); + } + } + } + + #[test] + fn the_demo_bundle_indexes_match() { + let validator = validator(&bundle_schema()); + for file in ["demo-bundle/bundle.json", "demo-disclosure/bundle.json"] { + let value: Value = serde_json::from_str(&demo_file(file)).expect("json"); + assert!(validator.is_valid(&value), "{file}"); + } + } + + #[test] + fn a_withheld_line_matches_and_a_mixed_one_does_not() { + let withheld = serde_json::json!({ + "at": "2026-09-22T10:00:00.000000Z", + "level": 30, + "leaf": Sha256Digest::of(b"leaf").as_str(), + "chain": Sha256Digest::of(b"chain").as_str(), + }); + assert!(transcript_validator().is_valid(&withheld)); + + let mut salt_only = withheld; + salt_only + .as_object_mut() + .expect("an object") + .insert("salt".to_string(), "00".repeat(16).into()); + assert!(!transcript_validator().is_valid(&salt_only)); + } + + #[test] + fn integers_beyond_the_canonical_range_do_not_match() { + let line = serde_json::json!({ + "at": "2026-09-22T10:00:00.000000Z", + "level": 1_u64 << 53, + "leaf": Sha256Digest::of(b"leaf").as_str(), + "chain": Sha256Digest::of(b"chain").as_str(), + }); + assert!(!transcript_validator().is_valid(&line)); + } + + #[test] + fn bundle_indexes_match() { + let validator = validator(&bundle_schema()); + for index in [ + BundleIndex::complete(Sha256Digest::of(b"t"), vec![BundleFile::transcript()]), + BundleIndex::disclosure(Sha256Digest::of(b"t"), Level::PUBLIC, vec![]), + ] { + let value = serde_json::to_value(&index).expect("serialize"); + assert!(validator.is_valid(&value), "{value}"); + } + } +} diff --git a/crates/rite-model/src/transcript.rs b/crates/rite-model/src/transcript.rs index 56f7b49..03bf349 100644 --- a/crates/rite-model/src/transcript.rs +++ b/crates/rite-model/src/transcript.rs @@ -10,13 +10,212 @@ //! `Response`, `Icon`, …) lives in `rite-runtime` next to the executor //! that owns the channels. The boundary is **persisted vs in-flight**. -use std::path::PathBuf; +use std::collections::BTreeMap; use base64ct::Encoding as _; use serde::{Deserialize, Serialize}; use zeroize::Zeroizing; -use crate::ir::{ActId, RoleId, StepId}; +use crate::Sha256Digest; +use crate::ir::{ActId, MaterialId, ParamId, RoleId, StepId}; + +/// Transcript format version this crate writes and reads: the line envelope +/// and the chain rule. +/// +/// 0 while rite is before 1.0: the format changes between releases without +/// a new number, and the header's `producer` names the release that wrote a +/// transcript. +pub const TRANSCRIPT_FORMAT: u32 = 0; + +/// Version of the [`StepFact`] vocabulary this crate writes and reads. 0 +/// before 1.0, like [`TRANSCRIPT_FORMAT`]. +pub const FACT_VOCABULARY: u32 = 0; + +/// URL of the JSON Schema for the transcript lines this release writes, +/// written on the header line for editors and other tools. A reader ignores +/// it. +pub const TRANSCRIPT_SCHEMA: &str = concat!( + "https://ritely.io/schemas/", + env!("CARGO_PKG_VERSION"), + "/transcript.schema.json" +); + +/// First line of every transcript: what the file is, read before any fact. +/// +/// Chained like every other line, so it cannot be swapped without breaking +/// the fingerprint. A reader checks [`rite_transcript`](Self::rite_transcript) +/// before parsing anything else and refuses a value it does not know. +#[non_exhaustive] +#[cfg_attr(test, derive(schemars::JsonSchema))] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr( + test, + schemars( + description = "What the file is. A reader checks `rite_transcript` before reading \ + anything else, and refuses a version it does not know." + ) +)] +pub struct TranscriptHeader { + /// Format version of the envelope and chain rule ([`TRANSCRIPT_FORMAT`]). + #[cfg_attr( + test, + schemars(description = "Version of the line format and the chain rule.") + )] + pub rite_transcript: u32, + /// Version of the fact vocabulary ([`FACT_VOCABULARY`]). + #[cfg_attr( + test, + schemars( + description = "Version of the fact vocabulary: which fact types and fields the \ + transcript uses." + ) + )] + pub vocabulary: u32, + /// Program and version that wrote the transcript, as it reports itself. + /// Informational: a binary cannot vouch for its own identity. + #[cfg_attr( + test, + schemars( + description = "The program and version that wrote the transcript, as it reports \ + itself. Informational: a program cannot vouch for its own identity." + ) + )] + pub producer: String, + /// Random identifier of this run, 32 lowercase hex digits. + #[cfg_attr( + test, + schemars( + description = "A random identifier of this run: 32 lowercase hex digits.", + extend("pattern" = "^[0-9a-f]{32}$") + ) + )] + pub run_id: String, + /// Whether the run was a dry run. A dry-run transcript is a rehearsal + /// record, never evidence of a ceremony. + #[cfg_attr( + test, + schemars( + description = "Whether the run was a dry run. A dry-run transcript is a rehearsal \ + record, never evidence of a ceremony." + ) + )] + pub dry_run: bool, + /// Every level this transcript uses, by name. Holds the built-in levels + /// at their fixed values, and any level the ceremony declares. + #[cfg_attr( + test, + schemars( + description = "Every level the transcript uses, by name. It holds public, \ + restricted and confidential at their fixed values, and any other level the ceremony \ + declares. No two names share a value." + ) + )] + pub levels: BTreeMap, +} + +impl TranscriptHeader { + /// A header for the current format and vocabulary. + #[must_use] + pub fn new(producer: &str, run_id: &str, dry_run: bool) -> Self { + Self { + rite_transcript: TRANSCRIPT_FORMAT, + vocabulary: FACT_VOCABULARY, + producer: producer.to_string(), + run_id: run_id.to_string(), + dry_run, + levels: Level::BUILT_IN + .iter() + .map(|(name, level)| ((*name).to_string(), *level)) + .collect(), + } + } + + /// Whether the header declares `level`. + #[must_use] + pub fn declares(&self, level: Level) -> bool { + self.levels.values().any(|declared| *declared == level) + } + + /// A declared level, by the name the header gives it or by its number. + #[must_use] + pub fn level(&self, name_or_value: &str) -> Option { + if let Some(level) = self.levels.get(name_or_value) { + return Some(*level); + } + let level = Level::new(name_or_value.parse().ok()?); + self.declares(level).then_some(level) + } +} + +/// Confidentiality level of a transcript line: who may see the fact. +/// +/// An integer, ordered from widest audience to narrowest, so a disclosure is +/// a threshold and checking it needs no names. The level is committed in the +/// chain with the line. Secrets are not a level: a secret value is never +/// recorded at all. +/// +/// Three levels are built in, at fixed values that mean the same thing in +/// every transcript. The gaps leave room for an organisation to declare its +/// own levels between and beyond them; the header names every level a +/// transcript uses. +/// +/// | Level | Value | Audience | TLP 2.0 | +/// |---|---|---|---| +/// | `public` | 10 | anyone | CLEAR | +/// | `restricted` | 20 | auditors, under agreement | AMBER | +/// | `confidential` | 30 | the ceremony's own organisation | RED | +#[cfg_attr(test, derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +#[cfg_attr( + test, + schemars( + description = "The confidentiality level of a line: who may see its fact. Levels are \ + integers ordered from the widest audience to the narrowest, so a disclosure up to a level \ + withholds every line above it. Three levels are built in at fixed values: public (10, \ + anyone), restricted (20, auditors under agreement) and confidential (30, the ceremony's \ + own organisation). Every level a transcript uses is declared in its header." + ) +)] +pub struct Level(u32); + +impl Level { + /// Anyone. + pub const PUBLIC: Level = Level(10); + /// Auditors, under agreement. + pub const RESTRICTED: Level = Level(20); + /// The ceremony's own organisation. + pub const CONFIDENTIAL: Level = Level(30); + + /// The built-in levels with their names, widest audience first. + pub const BUILT_IN: [(&'static str, Level); 3] = [ + ("public", Level::PUBLIC), + ("restricted", Level::RESTRICTED), + ("confidential", Level::CONFIDENTIAL), + ]; + + /// A level at `value`. + #[must_use] + pub const fn new(value: u32) -> Self { + Self(value) + } + + /// The level's value, as written in the transcript and committed in the + /// chain. + #[must_use] + pub const fn value(self) -> u32 { + self.0 + } +} + +impl std::fmt::Display for Level { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match Level::BUILT_IN.iter().find(|(_, level)| level == self) { + Some((name, _)) => f.write_str(name), + None => write!(f, "level {}", self.0), + } + } +} /// Validator applied by the runtime to a typed response before it is /// accepted. @@ -26,28 +225,55 @@ use crate::ir::{ActId, RoleId, StepId}; /// is the one place a rule is applied, so `rite check` and the running /// ceremony agree on what a pattern accepts. #[non_exhaustive] -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "kind", content = "value", rename_all = "snake_case")] +#[cfg_attr(test, schemars(description = "The check an answer had to pass."))] pub enum ValidatorSpec { /// Reject empty or whitespace-only input. - #[default] + #[cfg_attr(test, schemars(description = "The answer is not empty or blank."))] NonEmpty, /// Input must match this regular expression in full. - Regex(String), + #[cfg_attr( + test, + schemars(description = "The answer matches a regular expression in full.") + )] + Regex(#[cfg_attr(test, schemars(description = "The regular expression."))] String), /// Named, runtime-defined predicate (e.g. `serial_number`). - Predefined(String), + #[cfg_attr( + test, + schemars(description = "The answer passes a check the program defines, by name.") + )] + Predefined( + #[cfg_attr( + test, + schemars(description = "The name of the check, such as serial_number.") + )] + String, + ), /// A value of one [`Format`], with a length within bounds. /// /// What a PIN or a key component needs, stated without a pattern the /// person writing the ceremony has to get right. The length is counted /// in the format's own units: characters for text, bytes for an /// encoding. + #[cfg_attr( + test, + schemars( + description = "The answer is a value of one format, with a length within \ + bounds, counted in characters for text and in bytes for an \ + encoding." + ) + )] Format { /// What kind of value this is. + #[cfg_attr(test, schemars(description = "What kind of value the answer is."))] format: Format, /// Fewest units accepted, if bounded. + #[cfg_attr(test, schemars(description = "The fewest units accepted, if bounded."))] min_length: Option, /// Most units accepted, if bounded. + #[cfg_attr(test, schemars(description = "The most units accepted, if bounded."))] max_length: Option, }, } @@ -59,18 +285,28 @@ pub enum ValidatorSpec { /// encoding. An encoding is forgiving of the grouping a person types it in, /// so whitespace is dropped before decoding. #[non_exhaustive] +#[cfg_attr(test, derive(schemars::JsonSchema))] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] +#[cfg_attr(test, schemars(description = "What kind of value a person types."))] pub enum Format { /// Anything the person can type. + #[cfg_attr(test, schemars(description = "Anything the person can type."))] Text, /// `0` to `9`. + #[cfg_attr(test, schemars(description = "Decimal digits only."))] Digits, /// ASCII letters and digits. + #[cfg_attr(test, schemars(description = "ASCII letters and digits."))] Alphanumeric, /// Bytes as hexadecimal, in either case, two digits per byte. + #[cfg_attr( + test, + schemars(description = "Bytes as hexadecimal, in either case, two digits per byte.") + )] Hex, /// Bytes as standard base64 with padding, as `openssl base64` writes it. + #[cfg_attr(test, schemars(description = "Bytes as standard base64 with padding."))] Base64, } @@ -274,45 +510,82 @@ pub fn compile_pattern(pattern: &str) -> Result { /// Request for user input, recorded into the transcript as part of /// [`StepFact::PromptAnswered`]. #[non_exhaustive] +#[cfg_attr(test, derive(schemars::JsonSchema))] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] +#[cfg_attr(test, schemars(description = "A prompt as it was shown."))] pub enum Prompt { /// Yes / no question with an optional default. + #[cfg_attr(test, schemars(description = "A yes or no question."))] Confirm { /// Question shown to the user. + #[cfg_attr(test, schemars(description = "The question."))] question: String, /// Default selection if the user presses Enter without choosing. + #[cfg_attr( + test, + schemars( + description = "The answer given when the person confirms without choosing, if \ + any." + ) + )] default: Option, }, /// Free-form text input, validated against a [`ValidatorSpec`]. + #[cfg_attr( + test, + schemars(description = "A request for free text, checked before it is accepted.") + )] Text { /// Label shown to the user. + #[cfg_attr(test, schemars(description = "The label shown."))] label: String, /// Validator applied before the response is accepted. + #[cfg_attr(test, schemars(description = "The check the answer had to pass."))] validator: ValidatorSpec, }, /// Sensitive input (PIN, password). Echo is suppressed; plaintext is /// never serialized to the transcript. + #[cfg_attr( + test, + schemars( + description = "A request for a secret, such as a PIN. The answer is never \ + recorded." + ) + )] Secret { /// Label shown to the user. + #[cfg_attr(test, schemars(description = "The label shown."))] label: String, - /// Validator applied before the response is accepted. Absent in - /// transcripts written before a secret carried one, which asked for - /// nothing beyond a non-empty value. - #[serde(default)] + /// Validator applied before the response is accepted. + #[cfg_attr(test, schemars(description = "The check the answer had to pass."))] validator: ValidatorSpec, }, /// User must type a specific literal string exactly. Validation is /// performed by the runtime against `expected`. + #[cfg_attr( + test, + schemars( + description = "A request to type a given text exactly, such as a confirmation \ + phrase." + ) + )] Literal { /// Label shown to the user. + #[cfg_attr(test, schemars(description = "The label shown."))] label: String, /// Exact string the user must type. + #[cfg_attr(test, schemars(description = "The text that had to be typed."))] expected: String, }, /// Wait for the user to acknowledge before proceeding. Used for pacing. + #[cfg_attr( + test, + schemars(description = "A pause until the person is ready to continue.") + )] Continue { /// Optional hint such as "Press Enter when ready". + #[cfg_attr(test, schemars(description = "The hint shown, if any."))] hint: Option, }, } @@ -324,17 +597,29 @@ pub enum Prompt { /// in-flight `Response` type lives next to the channel protocol in /// `rite-runtime`; conversion happens at the moment the prompt is accepted. #[non_exhaustive] +#[cfg_attr(test, derive(schemars::JsonSchema))] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] +#[cfg_attr( + test, + schemars( + description = "The answer to a prompt, as recorded. A secret answer is never \ + recorded, not even as a digest." + ) +)] pub enum ResponseRecord { /// Yes / no answer. + #[cfg_attr(test, schemars(description = "A yes or no answer."))] Bool { /// The answer. + #[cfg_attr(test, schemars(description = "The answer."))] value: bool, }, /// Free-form text answer. + #[cfg_attr(test, schemars(description = "A free-text answer."))] Text { /// The answer. + #[cfg_attr(test, schemars(description = "The answer, as typed."))] value: String, }, /// Secret answer. The plaintext is never stored, and no digest of it is @@ -345,8 +630,16 @@ pub enum ResponseRecord { // Note: a salted, per-run HMAC could later attest that two prompts received // the same secret without reintroducing the low-entropy guessing oracle. // Deferred until a concrete use case needs it. + #[cfg_attr( + test, + schemars( + description = "A secret was entered. Only the fact that it was entered is \ + recorded." + ) + )] SecretRedacted {}, /// Acknowledgement of a [`Prompt::Continue`]. + #[cfg_attr(test, schemars(description = "The person continued past a pause."))] Acknowledged, } @@ -359,32 +652,73 @@ pub enum ResponseRecord { /// because some classes never map cleanly onto retriability (an `Abort` is a /// decision, not an error). #[non_exhaustive] +#[cfg_attr(test, derive(schemars::JsonSchema))] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] +#[cfg_attr(test, schemars(description = "What kind of bad outcome this was."))] pub enum ErrorClass { /// The world wasn't ready; the step's work did not happen (token absent, /// loose cable, PIN required). + #[cfg_attr( + test, + schemars( + description = "Something around the ceremony was not ready, such as a device not \ + connected, and the step's work did not happen." + ) + )] Environmental, /// The ceremony's own logic concluded badly (a verification mismatch, a /// refused attestation). A result, not a recoverable condition. + #[cfg_attr( + test, + schemars( + description = "The ceremony's own logic concluded badly, such as a verification \ + that did not match or a refused attestation." + ) + )] Procedural, /// The run itself is compromised or the definition is broken (transcript /// write failed, channel lost, unknown action, invalid params). + #[cfg_attr( + test, + schemars( + description = "The run itself cannot be trusted to continue, or the ceremony \ + definition is broken." + ) + )] Integrity, /// The operator chose to stop. Not an error at all, but recorded on the /// terminal fact so abort is distinguishable from failure. + #[cfg_attr( + test, + schemars(description = "An operator chose to stop the ceremony.") + )] Abort, } /// Structured error record for transcript serialization. #[non_exhaustive] +#[cfg_attr(test, derive(schemars::JsonSchema))] #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr( + test, + schemars( + description = "What went wrong: a class for audit, a stable kind, and a message for \ + people." + ) +)] pub struct ErrorRecord { /// Audit classification of this error. + #[cfg_attr(test, schemars(description = "The kind of bad outcome, for audit."))] pub class: ErrorClass, /// Stable kind label (e.g. `aborted`, `step_failed`, `material_load_failed`). + #[cfg_attr( + test, + schemars(description = "A stable label for the error, such as aborted or step_failed.") + )] pub kind: String, /// Human-readable message. + #[cfg_attr(test, schemars(description = "A description of the error for people."))] pub message: String, } @@ -401,12 +735,19 @@ impl ErrorRecord { /// Outcome of a single step, carried by [`StepFact::StepCompleted`]. #[non_exhaustive] +#[cfg_attr(test, derive(schemars::JsonSchema))] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "status", rename_all = "snake_case")] +#[cfg_attr(test, schemars(description = "How a step ended."))] pub enum StepOutcome { /// Step executed successfully. + #[cfg_attr(test, schemars(description = "The step did its work."))] Completed { /// Human-readable completion message. + #[cfg_attr( + test, + schemars(description = "A short description of what the step did.") + )] message: String, }, } @@ -421,57 +762,304 @@ pub enum StepOutcome { /// [`BackendOperation`]: StepFact::BackendOperation /// [`AttestationRecorded`]: StepFact::AttestationRecorded #[non_exhaustive] +#[cfg_attr(test, derive(schemars::JsonSchema))] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] +#[cfg_attr( + test, + schemars( + description = "One recorded fact, identified by its `type`. Each fact type has a \ + fixed set of fields in a given vocabulary." + ) +)] pub enum StepFact { /// Ceremony has started running. + #[cfg_attr(test, schemars(description = "The ceremony started."))] CeremonyStarted { /// Ceremony name from the DSL. + #[cfg_attr(test, schemars(description = "The ceremony's name."))] + name: String, + /// Digest of the ceremony YAML the run was resolved from. The inputs + /// supplied for this run are recorded as their own facts, one each, + /// so this digest covers the template only. + #[cfg_attr( + test, + schemars( + description = "Digest of the ceremony definition file the run was resolved \ + from. The inputs of the run are recorded as their own facts, so this digest covers \ + the definition only." + ) + )] + template: Sha256Digest, + }, + /// A role the ceremony defines, with its display name. + /// + /// Recorded once per role at ceremony start, whether or not anyone is + /// assigned to it. Later facts name the role by id only. + #[cfg_attr( + test, + schemars( + description = "A role the ceremony defines. Recorded once per role at the start, \ + whether or not anyone is assigned to it; later facts name the role by its identifier." + ) + )] + RoleDeclared { + /// Role identifier. + #[cfg_attr(test, schemars(description = "The role."))] + role: RoleId, + /// Human-readable role name, from the ceremony's role definition. + #[cfg_attr(test, schemars(description = "The role's display name."))] + name: String, + }, + /// A person was assigned to a role for this run. + /// + /// One fact per assignment, so each can be withheld on its own. + #[cfg_attr( + test, + schemars( + description = "A person was assigned to a role for this run. One fact per \ + assignment, so each can be withheld on its own." + ) + )] + RoleAssigned { + /// Role identifier. + #[cfg_attr(test, schemars(description = "The role."))] + role: RoleId, + /// The person, as supplied at run time. Recorded as given; nothing + /// in the ceremony proves who the person is. + #[cfg_attr( + test, + schemars( + description = "The person, as supplied when the run started. Recorded as \ + given: nothing in the transcript proves who the person is." + ) + )] + person: String, + }, + /// A parameter took its value for this run, supplied or defaulted. + #[cfg_attr( + test, + schemars(description = "A parameter took its value for this run, supplied or defaulted.") + )] + ParameterBound { + /// Parameter identifier. + #[cfg_attr(test, schemars(description = "The parameter."))] + name: ParamId, + /// The resolved value. + #[cfg_attr(test, schemars(description = "The value, as the ceremony used it."))] + value: serde_json::Value, + }, + /// A material was loaded at ceremony start. + /// + /// The digest of a digital material is a separate fact, + /// [`MaterialDigest`](StepFact::MaterialDigest), so the two can be + /// disclosed to different audiences. + #[cfg_attr( + test, + schemars( + description = "A material was loaded at the start of the run. The digest of a \ + digital material is a separate fact, material_digest, so the two can be disclosed to \ + different audiences." + ) + )] + MaterialLoaded { + /// Material identifier. + #[cfg_attr(test, schemars(description = "The material."))] + name: MaterialId, + /// Identifier of a physical or pre-provisioned material, such as a + /// serial number, when the ceremony supplied one. + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr( + test, + schemars( + description = "The identifier of a physical or pre-provisioned material, such \ + as a serial number, when one was supplied." + ) + )] + identifier: Option, + }, + /// The digest of a digital material's bytes. + #[cfg_attr( + test, + schemars(description = "The digest of a digital material's bytes, as loaded.") + )] + MaterialDigest { + /// Material identifier. + #[cfg_attr(test, schemars(description = "The material."))] + name: MaterialId, + /// Digest of the file as loaded. + #[cfg_attr(test, schemars(description = "The digest of the material's bytes."))] + digest: Sha256Digest, + }, + /// A backend was first acquired in this run. + /// + /// Recorded once per backend, before the first operation on it. Backend + /// operations name the backend and do not repeat its identity, which can + /// carry device serials. + #[cfg_attr( + test, + schemars( + description = "A backend was used for the first time in this run. Recorded once \ + per backend, before its first operation; operations name the backend and do not repeat \ + its identity." + ) + )] + BackendBound { + /// Backend name from the ceremony. + #[cfg_attr(test, schemars(description = "The backend's name in the ceremony."))] name: String, + /// Backend provider (e.g. `openssl`, `yubikey`, `pkcs11`). + #[cfg_attr( + test, + schemars(description = "The kind of backend, such as openssl, yubikey or pkcs11.") + )] + provider: String, + /// Identity the backend reports for itself, such as a device serial + /// and firmware version. + #[cfg_attr( + test, + schemars( + description = "The identity the backend reports for itself, such as a device \ + serial number and firmware version." + ) + )] + identity: String, + }, + /// A snapshot of the machine the ceremony ran on. + #[cfg_attr( + test, + schemars(description = "A description of the machine the ceremony ran on.") + )] + MachineInfoRecorded { + /// Step that captured the snapshot. + #[cfg_attr(test, schemars(description = "The step that recorded it."))] + step: StepId, + /// The parts of the host snapshot the step was asked to record. + #[cfg_attr( + test, + schemars( + description = "The parts of the machine description the step was asked to \ + record." + ) + )] + info: serde_json::Value, }, /// Beginning of an act (named subdivision of a ceremony). + #[cfg_attr( + test, + schemars(description = "An act, a named part of the ceremony, started.") + )] ActStarted { /// Act identifier. + #[cfg_attr(test, schemars(description = "The act."))] id: ActId, /// Act label as authored in the DSL. + #[cfg_attr( + test, + schemars(description = "The act's label, as written in the ceremony.") + )] label: String, }, /// Beginning of a step. + #[cfg_attr(test, schemars(description = "A step started."))] StepStarted { /// Step identifier. + #[cfg_attr(test, schemars(description = "The step."))] id: StepId, /// Step label as authored in the DSL. + #[cfg_attr( + test, + schemars(description = "The step's label, as written in the ceremony.") + )] label: String, - /// Role responsible for this step (stable id). + /// Role responsible for this step. Its name is on the role's + /// [`RoleDeclared`](StepFact::RoleDeclared) fact. + #[cfg_attr( + test, + schemars( + description = "The role responsible for the step. Its display name is on the \ + role's role_declared fact." + ) + )] role: RoleId, - /// Human-readable role name, from the ceremony's role definition. - /// Carried alongside the id so transcripts stay self-contained for - /// reports and verifiers without re-reading the ceremony YAML. - role_name: String, }, /// A prompt has been answered and validated. + #[cfg_attr( + test, + schemars(description = "Someone answered a prompt, and the answer was accepted.") + )] PromptAnswered { /// Step that issued the prompt, if any. `None` for ceremony-level /// prompts emitted before the first step (e.g. the ceremony-start /// confirmation) or after the last step. #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr( + test, + schemars( + description = "The step that asked, or absent for a prompt asked outside any \ + step." + ) + )] step: Option, /// The prompt as issued. + #[cfg_attr(test, schemars(description = "The prompt, as shown."))] prompt: Prompt, /// Redacted response record. + #[cfg_attr(test, schemars(description = "The answer, as recorded."))] response: ResponseRecord, }, /// A backend operation completed and produced structured evidence. + #[cfg_attr( + test, + schemars( + description = "A backend performed an operation. The inputs and outputs are \ + specific to the kind of operation." + ) + )] BackendOperation { /// Step under which the operation ran. + #[cfg_attr(test, schemars(description = "The step the operation ran in."))] step: StepId, /// Stable operation kind (e.g. `generate_key`, `sign_data`). + #[cfg_attr( + test, + schemars(description = "The kind of operation, such as generate_key or sign_data.") + )] kind: String, + /// The backend the step ran with, by the name the ceremony gives it; + /// its identity is on that backend's [`StepFact::BackendBound`]. + /// Absent for an operation done in software. + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr( + test, + schemars( + description = "The backend the step ran with, by the name the ceremony gives it. Its identity is on that backend's backend_bound fact. Absent for an operation done in software." + ) + )] + backend: Option, /// Structured inputs to the operation (parameters, references). + #[cfg_attr( + test, + schemars( + description = "What the operation was given: parameters and references to \ + artifacts or materials." + ) + )] inputs: serde_json::Value, /// Structured outputs from the operation (artifact ids, hashes). + #[cfg_attr( + test, + schemars(description = "What the operation produced: artifact names and digests.") + )] outputs: serde_json::Value, /// Optional fingerprint of the produced material. + #[cfg_attr( + test, + schemars( + description = "A fingerprint of the material the operation produced, when it \ + has one." + ) + )] fingerprint: Option, }, /// A key held outside the ceremony was used as the recipient of a wrap. @@ -484,78 +1072,180 @@ pub enum StepFact { /// ceremony corroborates that they belong to the intended recipient, and /// nothing shows the recipient can open the result. The fact states an /// assumption the ceremony rests on, not something Rite proved. + #[cfg_attr( + test, + schemars( + description = "A key held outside the ceremony received a wrapped key. A separate \ + fact from the operation, so the method of a wrap can be published while the recipient \ + stays withheld." + ) + )] WrapRecipientRecorded { /// Step that wrapped to this recipient. + #[cfg_attr(test, schemars(description = "The step that wrapped the key."))] step: StepId, /// The artifact or material the recipient key came from, as named in /// the ceremony. + #[cfg_attr( + test, + schemars( + description = "The artifact or material the recipient's key came from, by its \ + name in the ceremony." + ) + )] source: String, /// `sha256:` over the recipient's SPKI DER. + #[cfg_attr( + test, + schemars( + description = "The digest of the recipient's public key, as DER-encoded \ + SubjectPublicKeyInfo.", + with = "Sha256Digest" + ) + )] fingerprint: String, /// Whether the ceremony declared this fingerprint in advance, so the /// runtime checked the key it received against the definition rather /// than recording whatever arrived. + #[cfg_attr( + test, + schemars( + description = "Whether the ceremony named this fingerprint in advance. When \ + it did, the key received was checked against it; otherwise the fingerprint records \ + whatever key arrived." + ) + )] declared: bool, }, /// A human attestation was recorded. + #[cfg_attr(test, schemars(description = "A person made an attestation."))] AttestationRecorded { /// Step under which the attestation was recorded. + #[cfg_attr(test, schemars(description = "The step the attestation was made in."))] step: StepId, /// Role that issued the attestation. + #[cfg_attr(test, schemars(description = "The role that made the attestation."))] role: RoleId, /// Verbatim attestation statement. + #[cfg_attr(test, schemars(description = "The statement, word for word."))] statement: String, }, - /// An artifact was written to disk. + /// An artifact was written to the run's `artifacts/` directory. + #[cfg_attr( + test, + schemars(description = "An artifact was written to the run's artifacts directory.") + )] ArtifactWritten { /// Step that produced the artifact. + #[cfg_attr(test, schemars(description = "The step that produced the artifact."))] step: StepId, /// Artifact name as declared in the DSL. + #[cfg_attr( + test, + schemars(description = "The artifact's name, as declared in the ceremony.") + )] name: String, - /// Path on disk. - path: PathBuf, - /// Lowercase hex SHA-256 of the artifact bytes. - sha256: String, + /// File name under `artifacts/`: one path component, no directory. + #[cfg_attr( + test, + schemars( + description = "The file name in the artifacts directory: a single path \ + component." + ) + )] + file: String, + /// Digest of the file's bytes. Absent when the artifact is content a + /// step opened: a digest of a secret can be tested against guesses, + /// so none is recorded. + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr( + test, + schemars( + description = "The digest of the file's bytes. Absent for content a step \ + opened, since a digest of a secret can be tested against guesses." + ) + )] + digest: Option, }, /// A deviation was logged by the operator. + #[cfg_attr(test, schemars(description = "An operator recorded a deviation."))] DeviationRecorded { /// Step in which the deviation was logged, if any. `None` for /// deviations logged outside of a step (before the first step or /// while a ceremony-level prompt is pending). #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr( + test, + schemars( + description = "The step during which the deviation was recorded, or absent \ + outside any step." + ) + )] step: Option, /// Verbatim deviation text. + #[cfg_attr(test, schemars(description = "The deviation, word for word."))] text: String, }, /// A step attempt failed. Recorded per attempt, so a retried step shows /// `StepAttemptFailed{attempt: 1}` followed by the operator's retry /// decision and, on success, `StepCompleted`. The final attempt of a step /// that the run gives up on is followed by the terminal `CeremonyFailed`. + #[cfg_attr( + test, + schemars( + description = "One attempt at a step failed. Recorded per attempt: a step that is \ + retried and then succeeds has this fact for each failed attempt, then step_completed." + ) + )] StepAttemptFailed { /// Step whose attempt failed. + #[cfg_attr(test, schemars(description = "The step."))] step: StepId, /// 1-based attempt number within this step. + #[cfg_attr( + test, + schemars(description = "The attempt's number within the step, starting at 1.") + )] attempt: u32, /// Structured error record for the failed attempt. + #[cfg_attr(test, schemars(description = "What went wrong in this attempt."))] error: ErrorRecord, }, /// Step finished executing. + #[cfg_attr(test, schemars(description = "A step finished."))] StepCompleted { /// Step identifier. + #[cfg_attr(test, schemars(description = "The step."))] id: StepId, - /// Outcome (completed or skipped). + /// How the step ended. + #[cfg_attr(test, schemars(description = "How the step ended."))] outcome: StepOutcome, }, /// Ceremony finished successfully. /// - /// The transcript's cryptographic identity is `SHA-256(line_bytes)` - /// for this line, recoverable by any reader, so the fact itself - /// carries no fingerprint field. The runtime forwards the value to - /// frontends through an out-of-band channel event. + /// The transcript's fingerprint is the `chain` value of this line, which + /// any reader recomputes, so the fact itself carries no fingerprint + /// field. The runtime forwards the value to frontends through an + /// out-of-band channel event. + #[cfg_attr( + test, + schemars( + description = "The ceremony finished. It is the last line of a transcript whose \ + run completed." + ) + )] CeremonyCompleted {}, /// Ceremony failed or was aborted. + #[cfg_attr( + test, + schemars( + description = "The ceremony failed or was aborted. It is the last line of a \ + transcript whose run did not complete." + ) + )] CeremonyFailed { /// Structured error record. + #[cfg_attr(test, schemars(description = "What went wrong."))] error: ErrorRecord, }, /// The ceremony entropy source was seeded with machine randomness. @@ -566,14 +1256,40 @@ pub enum StepFact { /// alone. Part of the [entropy source](StepFact::EntropyDrawn) family. /// /// Like every fact, it is timed by the `at` on its chain envelope. + #[cfg_attr( + test, + schemars( + description = "The ceremony's entropy source was seeded with machine randomness. \ + Recorded once, at the start, so every value drawn from the source can be derived again \ + from the transcript." + ) + )] EntropySeeded { /// Lowercase hex of the gathered machine entropy `m`. + #[cfg_attr( + test, + schemars( + description = "The machine randomness, as lowercase hex.", + extend("pattern" = "^(?:[0-9a-f]{2})+$") + ) + )] m: String, /// Provenance of `m` (e.g. `os`). A single label today; comma-separated /// if more than one source is ever mixed. + #[cfg_attr( + test, + schemars(description = "Where the machine randomness came from, such as os.") + )] source: String, /// Frozen derivation-scheme tag (e.g. `rite-kdf/v1`) that pins the /// entire construction. A verifier rejects an unrecognised value. + #[cfg_attr( + test, + schemars( + description = "The derivation scheme, such as rite-kdf/v1. A verifier refuses \ + a scheme it does not know." + ) + )] derivation: String, }, /// A human folded additional entropy into the seed, advancing the ratchet. @@ -582,12 +1298,31 @@ pub enum StepFact { /// contribution is recorded so the epoch chain re-folds identically; it is /// public, witnessed entropy, not a secret. Timed by its enclosing step /// boundaries (and by the `PromptAnswered` that captured the input). + #[cfg_attr( + test, + schemars( + description = "A person added their own randomness to the entropy source, \ + starting a new epoch." + ) + )] EntropyContributed { /// Step under which the contribution was gathered. + #[cfg_attr(test, schemars(description = "The step the contribution was made in."))] step: StepId, /// Epoch index produced by this fold (1 for the first contribution). + #[cfg_attr( + test, + schemars(description = "The epoch this contribution starts, counting from 1.") + )] epoch: u32, /// Verbatim operator contribution, fed as UTF-8 into the ratchet. + #[cfg_attr( + test, + schemars( + description = "The contribution, word for word, mixed into the source as \ + UTF-8." + ) + )] contribution: String, }, /// A value was drawn from the entropy source (a nonce, certificate serial, @@ -597,18 +1332,109 @@ pub enum StepFact { /// derivation `path` plus the recorded seed let `rite verify` re-derive /// the value and confirm the right value reached the right consumer. Like /// other action-emitted evidence, it is timed by its enclosing step. + #[cfg_attr( + test, + schemars( + description = "A value was drawn from the entropy source, such as a nonce, a \ + certificate serial number or a challenge. A verifier derives it again from the seed, \ + the contributions and the path." + ) + )] EntropyDrawn { /// Step that drew the value. + #[cfg_attr(test, schemars(description = "The step that drew the value."))] step: StepId, /// Derivation path `//`. + #[cfg_attr( + test, + schemars(description = "The derivation path: `//`.") + )] path: String, /// Lowercase hex of the derived bytes. Its length fixes the byte count, /// so `rite verify` re-derives exactly this many bytes from the seed. + #[cfg_attr( + test, + schemars( + description = "The value, as lowercase hex. Its length is the number of bytes \ + drawn.", + extend("pattern" = "^(?:[0-9a-f]{2})*$") + ) + )] value: String, }, } +/// The `type` tag of every [`StepFact`] variant in this vocabulary. +/// +/// A reader uses it to tell a fact from a newer vocabulary, which it counts and +/// skips, from a known fact that does not parse, which is an error. +pub const FACT_TYPES: &[&str] = &[ + "ceremony_started", + "role_declared", + "role_assigned", + "parameter_bound", + "material_loaded", + "material_digest", + "backend_bound", + "machine_info_recorded", + "act_started", + "step_started", + "prompt_answered", + "backend_operation", + "wrap_recipient_recorded", + "attestation_recorded", + "artifact_written", + "deviation_recorded", + "step_attempt_failed", + "step_completed", + "ceremony_completed", + "ceremony_failed", + "entropy_seeded", + "entropy_contributed", + "entropy_drawn", +]; + impl StepFact { + /// The level a fact of this type is recorded at. + /// + /// Method facts (what was done, in what order, with what result) are + /// public, and so are attestations, whose statement is authored in the + /// ceremony. Free text, answers typed at prompts, the error message of a + /// failed attempt, and identities of devices and inputs are restricted. + /// Persons, material digests and the host snapshot are confidential. + /// + /// A failed ceremony stays public despite its error message: a public + /// disclosure needs its terminal fact. + #[must_use] + pub fn default_level(&self) -> Level { + match self { + StepFact::CeremonyStarted { .. } + | StepFact::RoleDeclared { .. } + | StepFact::ActStarted { .. } + | StepFact::StepStarted { .. } + | StepFact::BackendOperation { .. } + | StepFact::ArtifactWritten { .. } + | StepFact::AttestationRecorded { .. } + | StepFact::StepCompleted { .. } + | StepFact::CeremonyCompleted {} + | StepFact::CeremonyFailed { .. } + // Re-deriving the entropy source needs all three. + | StepFact::EntropySeeded { .. } + | StepFact::EntropyContributed { .. } + | StepFact::EntropyDrawn { .. } => Level::PUBLIC, + StepFact::ParameterBound { .. } + | StepFact::MaterialLoaded { .. } + | StepFact::BackendBound { .. } + | StepFact::PromptAnswered { .. } + | StepFact::WrapRecipientRecorded { .. } + | StepFact::StepAttemptFailed { .. } + | StepFact::DeviationRecorded { .. } => Level::RESTRICTED, + StepFact::RoleAssigned { .. } + | StepFact::MaterialDigest { .. } + | StepFact::MachineInfoRecorded { .. } => Level::CONFIDENTIAL, + } + } + /// Whether this fact evidences work performed on the world: a backend /// operation, a written artifact, a captured attestation, or consumed /// entropy. Interaction records (an answered prompt, an operator @@ -626,6 +1452,15 @@ impl StepFact { | StepFact::EntropyContributed { .. } | StepFact::EntropyDrawn { .. } => true, StepFact::CeremonyStarted { .. } + // Run inputs and identities, recorded before any step works. + | StepFact::RoleDeclared { .. } + | StepFact::RoleAssigned { .. } + | StepFact::ParameterBound { .. } + | StepFact::MaterialLoaded { .. } + | StepFact::MaterialDigest { .. } + | StepFact::BackendBound { .. } + // Records the host, not work performed on it. + | StepFact::MachineInfoRecorded { .. } | StepFact::ActStarted { .. } | StepFact::StepStarted { .. } | StepFact::PromptAnswered { .. } @@ -788,19 +1623,6 @@ mod validator_tests { } assert!(Format::Text.placeholder(PLACEHOLDER_LIMIT + 1).is_none()); } - - /// A transcript written before secrets carried a rule still reads. - #[test] - fn a_secret_prompt_without_a_validator_still_parses() { - let prompt: Prompt = serde_json::from_str(r#"{"type":"secret","label":"PIN"}"#).unwrap(); - assert!(matches!( - prompt, - Prompt::Secret { - validator: ValidatorSpec::NonEmpty, - .. - } - )); - } } #[cfg(test)] @@ -811,6 +1633,34 @@ mod schema_snapshot_tests { fn assert_json(fact: &StepFact, expected: &serde_json::Value) { let actual = serde_json::to_value(fact).expect("serialize StepFact"); assert_eq!(&actual, expected, "wire-format drift for {fact:?}"); + let tag = actual + .get("type") + .and_then(serde_json::Value::as_str) + .expect("a type tag"); + assert!(FACT_TYPES.contains(&tag), "{tag} missing from FACT_TYPES"); + crate::schema::assert_fact_matches_schema(&actual); + } + + #[test] + fn a_level_is_named_or_numbered_and_must_be_declared() { + let header = TranscriptHeader::new("rite test", "0", false); + assert_eq!(header.level("public"), Some(Level::PUBLIC)); + assert_eq!(header.level("20"), Some(Level::RESTRICTED)); + assert_eq!(header.level("15"), None); + assert_eq!(header.level("partners"), None); + } + + #[test] + fn levels_are_ordered_integers() { + assert!(Level::PUBLIC < Level::RESTRICTED); + assert!(Level::RESTRICTED < Level::CONFIDENTIAL); + assert!(Level::new(15) > Level::PUBLIC && Level::new(15) < Level::RESTRICTED); + assert_eq!( + serde_json::to_value(Level::CONFIDENTIAL).expect("serialize"), + json!(30) + ); + assert_eq!(Level::RESTRICTED.to_string(), "restricted"); + assert_eq!(Level::new(15).to_string(), "level 15"); } #[test] @@ -818,10 +1668,127 @@ mod schema_snapshot_tests { assert_json( &StepFact::CeremonyStarted { name: "Root CA".to_string(), + template: Sha256Digest::of(b"ceremony"), }, &json!({ "type": "ceremony_started", "name": "Root CA", + "template": Sha256Digest::of(b"ceremony").as_str(), + }), + ); + } + + #[test] + fn header() { + let header = TranscriptHeader::new("rite 0.6.0", &"0".repeat(32), false); + assert_eq!( + serde_json::to_value(&header).expect("serialize header"), + json!({ + "rite_transcript": 0, + "vocabulary": 0, + "producer": "rite 0.6.0", + "run_id": "0".repeat(32), + "dry_run": false, + "levels": { "public": 10, "restricted": 20, "confidential": 30 }, + }), + ); + } + + #[test] + fn role_assigned() { + assert_json( + &StepFact::RoleAssigned { + role: RoleId::new("crypto_officer"), + person: "Alice Rivera".to_string(), + }, + &json!({ + "type": "role_assigned", + "role": "crypto_officer", + "person": "Alice Rivera", + }), + ); + } + + #[test] + fn parameter_bound() { + assert_json( + &StepFact::ParameterBound { + name: ParamId::new("validity_days"), + value: json!(3650), + }, + &json!({ + "type": "parameter_bound", + "name": "validity_days", + "value": 3650, + }), + ); + } + + #[test] + fn material_loaded() { + assert_json( + &StepFact::MaterialLoaded { + name: MaterialId::new("usb_drive"), + identifier: Some("SN-1234".to_string()), + }, + &json!({ + "type": "material_loaded", + "name": "usb_drive", + "identifier": "SN-1234", + }), + ); + assert_json( + &StepFact::MaterialLoaded { + name: MaterialId::new("csr"), + identifier: None, + }, + &json!({ "type": "material_loaded", "name": "csr" }), + ); + } + + #[test] + fn material_digest() { + assert_json( + &StepFact::MaterialDigest { + name: MaterialId::new("csr"), + digest: Sha256Digest::of(b"csr"), + }, + &json!({ + "type": "material_digest", + "name": "csr", + "digest": Sha256Digest::of(b"csr").as_str(), + }), + ); + } + + #[test] + fn backend_bound() { + assert_json( + &StepFact::BackendBound { + name: "token".to_string(), + provider: "yubikey".to_string(), + identity: "yubikey-serial=12345678+firmware=5.7.1".to_string(), + }, + &json!({ + "type": "backend_bound", + "name": "token", + "provider": "yubikey", + "identity": "yubikey-serial=12345678+firmware=5.7.1", + }), + ); + } + + #[test] + fn machine_info_recorded() { + assert_json( + &StepFact::MachineInfoRecorded { + step: StepId::new("capture"), + info: json!({ "arch": "x86_64", "hostname": null }), + }, + &json!({ + "type": "machine_info_recorded", + "step": "capture", + "info": { "arch": "x86_64", "hostname": null }, }), ); } @@ -848,14 +1815,27 @@ mod schema_snapshot_tests { id: StepId::new("s1"), label: "2.1".to_string(), role: RoleId::new("crypto_officer"), - role_name: "Crypto Officer".to_string(), }, &json!({ "type": "step_started", "id": "s1", "label": "2.1", "role": "crypto_officer", - "role_name": "Crypto Officer", + }), + ); + } + + #[test] + fn role_declared() { + assert_json( + &StepFact::RoleDeclared { + role: RoleId::new("crypto_officer"), + name: "Crypto Officer".to_string(), + }, + &json!({ + "type": "role_declared", + "role": "crypto_officer", + "name": "Crypto Officer", }), ); } @@ -1029,6 +2009,7 @@ mod schema_snapshot_tests { &StepFact::BackendOperation { step: StepId::new("s1"), kind: "generate_key".to_string(), + backend: Some("hsm".to_string()), inputs: json!({ "algorithm": "rsa", "bits": 4096 }), outputs: json!({ "key_id": "k1" }), fingerprint: Some("sha256:deadbeef".to_string()), @@ -1037,6 +2018,7 @@ mod schema_snapshot_tests { "type": "backend_operation", "step": "s1", "kind": "generate_key", + "backend": "hsm", "inputs": { "algorithm": "rsa", "bits": 4096 }, "outputs": { "key_id": "k1" }, "fingerprint": "sha256:deadbeef", @@ -1046,18 +2028,19 @@ mod schema_snapshot_tests { #[test] fn wrap_recipient_recorded() { + let fingerprint = Sha256Digest::of(b"escrow spki").to_string(); assert_json( &StepFact::WrapRecipientRecorded { step: StepId::new("s1"), source: "escrow_pubkey".to_string(), - fingerprint: "sha256:deadbeef".to_string(), + fingerprint: fingerprint.clone(), declared: true, }, &json!({ "type": "wrap_recipient_recorded", "step": "s1", "source": "escrow_pubkey", - "fingerprint": "sha256:deadbeef", + "fingerprint": fingerprint, "declared": true, }), ); @@ -1085,16 +2068,34 @@ mod schema_snapshot_tests { assert_json( &StepFact::ArtifactWritten { step: StepId::new("s1"), - name: "root.crt".to_string(), - path: "artifacts/root.crt".into(), - sha256: "a".repeat(64), + name: "root_cert".to_string(), + file: "root_cert.crt".to_string(), + digest: Some(Sha256Digest::of(b"cert")), + }, + &json!({ + "type": "artifact_written", + "step": "s1", + "name": "root_cert", + "file": "root_cert.crt", + "digest": Sha256Digest::of(b"cert").as_str(), + }), + ); + } + + #[test] + fn artifact_written_secret_has_no_digest() { + assert_json( + &StepFact::ArtifactWritten { + step: StepId::new("s1"), + name: "opened".to_string(), + file: "opened.bin".to_string(), + digest: None, }, &json!({ "type": "artifact_written", "step": "s1", - "name": "root.crt", - "path": "artifacts/root.crt", - "sha256": "a".repeat(64), + "name": "opened", + "file": "opened.bin", }), ); } diff --git a/crates/rite-render/src/report/data.rs b/crates/rite-render/src/report/data.rs index 621ce78..3597001 100644 --- a/crates/rite-render/src/report/data.rs +++ b/crates/rite-render/src/report/data.rs @@ -35,6 +35,18 @@ pub struct ReportData { pub artifacts: Vec, /// Deviations recorded during the ceremony. pub deviations: Vec, + /// Set when the transcript is a disclosure: what it withholds. + pub withheld: Option, +} + +/// What a disclosed transcript withholds, stated on the report's face. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ReportWithheld { + /// Number of withheld facts. + pub facts: usize, + /// Names of the levels the withheld facts were recorded at, narrowest + /// last. + pub levels: Vec, } /// Terminal status of a ceremony. @@ -105,10 +117,11 @@ pub struct ReportArtifact { pub step_id: String, /// Artifact name as declared in the DSL. pub name: String, - /// Path on disk. - pub path: String, - /// Lowercase hex SHA-256 of the artifact bytes. - pub sha256: String, + /// File name under the run's `artifacts/` directory. + pub file: String, + /// `sha256:` of the file's bytes. `None` for opened content, whose + /// digest the transcript does not record. + pub digest: Option, } /// A deviation logged by the operator during the ceremony. @@ -148,6 +161,7 @@ struct Builder { failure: Option, steps: Vec, step_index: HashMap, + role_names: HashMap, artifacts: Vec, deviations: Vec, } @@ -163,6 +177,7 @@ impl Builder { failure: None, steps: Vec::new(), step_index: HashMap::new(), + role_names: HashMap::new(), artifacts: Vec::new(), deviations: Vec::new(), } @@ -174,18 +189,23 @@ impl Builder { self.ceremony_name.clone_from(name); self.started_at = Some(at); } - StepFact::StepStarted { - id, - label, - role_name, - .. - } => { + StepFact::RoleDeclared { role, name } => { + self.role_names + .insert(role.as_str().to_string(), name.clone()); + } + StepFact::StepStarted { id, label, role } => { let step_id = id.as_str().to_string(); self.step_index.insert(step_id.clone(), self.steps.len()); + // A role whose declaration was withheld shows by its id. + let role = self + .role_names + .get(role.as_str()) + .cloned() + .unwrap_or_else(|| role.as_str().to_string()); self.steps.push(ReportStep { step_id, label: label.clone(), - role: role_name.clone(), + role, started_at: at, completed_at: None, outcome_status: "in_progress".to_string(), @@ -227,14 +247,14 @@ impl Builder { StepFact::ArtifactWritten { step, name, - path, - sha256, + file, + digest, } => { self.artifacts.push(ReportArtifact { step_id: step.as_str().to_string(), name: name.clone(), - path: path.display().to_string(), - sha256: sha256.clone(), + file: file.clone(), + digest: digest.as_ref().map(|d| d.as_str().to_string()), }); } StepFact::DeviationRecorded { step, text } => { @@ -283,6 +303,7 @@ impl Builder { steps: self.steps, artifacts: self.artifacts, deviations: self.deviations, + withheld: None, } } } diff --git a/crates/rite-render/src/report/mod.rs b/crates/rite-render/src/report/mod.rs index 06d27cc..ab85330 100644 --- a/crates/rite-render/src/report/mod.rs +++ b/crates/rite-render/src/report/mod.rs @@ -9,5 +9,6 @@ pub mod data; pub use data::{ - ReportArtifact, ReportData, ReportDeviation, ReportStatus, ReportStep, build_report_data, + ReportArtifact, ReportData, ReportDeviation, ReportStatus, ReportStep, ReportWithheld, + build_report_data, }; diff --git a/crates/rite-render/src/view.rs b/crates/rite-render/src/view.rs index ae9df4f..f7b60e9 100644 --- a/crates/rite-render/src/view.rs +++ b/crates/rite-render/src/view.rs @@ -556,6 +556,8 @@ pub struct ReportView { pub duration: Option, /// Transcript fingerprint. pub transcript_fingerprint: String, + /// What a disclosed transcript withholds, when it is one. + pub withheld: Option, /// Failure summary, when the ceremony failed. pub failure: Option, /// Failed step attempts (retries), across all steps. @@ -572,6 +574,16 @@ pub struct ReportView { pub rite_version: String, } +/// The line a report of a disclosed transcript carries on its face. +fn withheld_notice(withheld: &crate::report::ReportWithheld) -> String { + format!( + "{} fact(s) withheld, recorded at {}. This report shows the disclosed part of the \ + transcript; the fingerprint covers the complete record.", + withheld.facts, + withheld.levels.join(", ") + ) +} + /// A failure summary in a report. #[derive(Debug, Clone, Serialize)] pub struct FailureView { @@ -618,10 +630,10 @@ pub struct ArtifactView { pub name: String, /// Producing step id. pub step_id: String, - /// Path on disk. - pub path: String, - /// Lowercase hex SHA-256. - pub sha256: String, + /// File name under the run's `artifacts/` directory. + pub file: String, + /// `sha256:` of the file, `None` for opened content. + pub digest: Option, } /// A role in the report's roles legend. @@ -673,8 +685,8 @@ impl ReportView { .map(|a| ArtifactView { name: a.name.clone(), step_id: a.step_id.clone(), - path: a.path.clone(), - sha256: a.sha256.clone(), + file: a.file.clone(), + digest: a.digest.clone(), }) .collect(); let attempts = data @@ -744,6 +756,7 @@ impl ReportView { .duration_seconds .map(|secs| crate::report::data::format_duration(Duration::seconds(secs))), transcript_fingerprint: data.transcript_fingerprint.clone(), + withheld: data.withheld.as_ref().map(withheld_notice), failure: data.failure.as_ref().map(|f| FailureView { class: error_class_label(f.class).to_string(), kind: f.kind.clone(), diff --git a/crates/rite-render/templates/report.html.jinja b/crates/rite-render/templates/report.html.jinja index deef519..e645867 100644 --- a/crates/rite-render/templates/report.html.jinja +++ b/crates/rite-render/templates/report.html.jinja @@ -32,6 +32,9 @@

Duration: {{ report.duration }}

{%- endif %}

Transcript fingerprint: {{ report.transcript_fingerprint }}

+{%- if report.withheld %} +

Disclosure: {{ report.withheld }}

+{%- endif %} {%- if report.failure %}

Failure: {{ report.failure.message }} ({{ report.failure.kind }}, {{ report.failure.class }})

{%- endif %} @@ -64,10 +67,10 @@ {%- if report.artifacts %}

Artifacts

- + {%- for a in report.artifacts %} - + {%- endfor %}
NameStepPathFingerprint
NameStepFileFingerprint
{{ a.name }}{{ a.step_id }}{{ a.path }}{{ a.sha256 }}
{{ a.name }}{{ a.step_id }}{{ a.file }}{% if a.digest %}{{ a.digest }}{% else %}not recorded (opened content){% endif %}
diff --git a/crates/rite-resolver/src/diagnostic.rs b/crates/rite-resolver/src/diagnostic.rs index 80ccc5e..8cc01b6 100644 --- a/crates/rite-resolver/src/diagnostic.rs +++ b/crates/rite-resolver/src/diagnostic.rs @@ -368,6 +368,7 @@ impl SpanMap { ResolveError::UnknownParam { param, .. } | ResolveError::RequiredParamMissing(param) | ResolveError::ParamTypeMismatch { param, .. } + | ResolveError::ParamOutOfRange { param, .. } | ResolveError::InvalidDateFormat { param, .. } => self.params.get(param).copied(), ResolveError::UnknownMaterial { material, .. } | ResolveError::RequiredMaterialMissing(material) diff --git a/crates/rite-resolver/src/error.rs b/crates/rite-resolver/src/error.rs index 70d2727..38dbd7f 100644 --- a/crates/rite-resolver/src/error.rs +++ b/crates/rite-resolver/src/error.rs @@ -140,6 +140,17 @@ pub enum ResolveError { got: String, }, + /// Integer parameter beyond the range the transcript records. + #[error( + "Parameter '{param}' is {value}, beyond ±(2^53 - 1), the integer range the transcript records" + )] + ParamOutOfRange { + /// The parameter ID. + param: ParamId, + /// The value received. + value: String, + }, + /// Parameter has invalid date format. #[error("Parameter '{param}' has invalid date format: '{value}' (expected YYYY-MM-DD)")] InvalidDateFormat { diff --git a/crates/rite-resolver/src/lib.rs b/crates/rite-resolver/src/lib.rs index d7ac3ff..8fbc76d 100644 --- a/crates/rite-resolver/src/lib.rs +++ b/crates/rite-resolver/src/lib.rs @@ -774,6 +774,20 @@ output: assert_eq!(text, "my_param"); } + #[test] + fn dispatch_param_out_of_range_spans_param_declaration() { + let span_map = span_map_for(DISPATCH_YAML); + let text = dispatch_span_text( + DISPATCH_YAML, + &span_map, + &ResolveError::ParamOutOfRange { + param: ParamId::new("my_param"), + value: "12345678901234567890".to_string(), + }, + ); + assert_eq!(text, "my_param"); + } + #[test] fn dispatch_required_material_missing_spans_material_declaration() { let span_map = span_map_for(DISPATCH_YAML); diff --git a/crates/rite-resolver/src/lower.rs b/crates/rite-resolver/src/lower.rs index 9f3007a..e30dd10 100644 --- a/crates/rite-resolver/src/lower.rs +++ b/crates/rite-resolver/src/lower.rs @@ -69,6 +69,8 @@ pub(crate) fn lower_ceremony( let ceremony_opt = match marked_yaml::from_node::(&node) { Ok(mut c) => { coerce_ceremony_json_scalars(&mut c); + diags.extend(number_diagnostics(path, &c, &span_map)); + c.source_digest = rite_model::Sha256Digest::of(yaml.as_bytes()); Some(c) } Err(from_node_err) => { @@ -574,6 +576,47 @@ fn coerce_ceremony_json_scalars(ceremony: &mut Ceremony) { } } +/// Errors for numbers in step values and parameter defaults that the +/// transcript cannot record: a number there reaches the transcript, which +/// holds integers within `±(2^53 - 1)` only. +fn number_diagnostics( + path: Option<&Path>, + ceremony: &Ceremony, + spans: &SpanMap, +) -> Vec { + let mut diags = Vec::new(); + let mut check = |value: &serde_json::Value, what: String, span: Option| { + if let Err(e) = rite_model::check_numbers(value) { + diags.push(Diagnostic { + path: path.map(Path::to_owned), + span, + severity: Severity::Error, + message: format!("{what}: {e}; numbers are integers within ±(2^53 - 1)"), + }); + } + }; + for section in ceremony.sections.values() { + for (name, step) in §ion.steps { + let span = spans.steps.get(&StepId::new(name)).copied(); + if let Some(with) = &step.with { + check(with, format!("step '{name}' `with`"), span); + } + if let Some(reads) = &step.reads { + check(reads, format!("step '{name}' `reads`"), span); + } + } + } + let mut params: Vec<_> = ceremony.parameters.iter().collect(); + params.sort_by_key(|(name, _)| *name); + for (name, param) in params { + if let Some(default) = ¶m.default { + let span = spans.params.get(&ParamId::new(name)).copied(); + check(default, format!("parameter '{name}' default"), span); + } + } + diags +} + /// Recursively coerce string scalars in a `serde_json::Value` to their proper types. fn coerce_yaml_scalars(value: &mut serde_json::Value) { coerce_yaml_scalars_at(value, 0); @@ -706,6 +749,56 @@ sections: ); } + #[test] + fn numbers_the_transcript_cannot_record_are_errors() { + let yaml = r#" +version: "0.3" +name: "Test" +roles: + alice: {} +parameters: + serial: + type: integer + default: 9007199254740992 +sections: + main: + role: ${role.alice} + steps: + ratio: + action: attest + with: + statement: "ok" + weight: 1.5 + fine: + action: attest + with: + statement: "ok" + count: 9007199254740991 +"#; + let (_, _, diags) = lower_ceremony(None, yaml); + let errors: Vec<(&str, &str)> = diags + .iter() + .filter(|d| d.severity == Severity::Error) + .map(|d| { + let span = d.span.expect("a span"); + (d.message.as_str(), span_text(yaml, span)) + }) + .collect(); + assert_eq!(errors.len(), 2, "{errors:?}"); + assert!( + errors.iter().any(|(m, at)| m.contains("step 'ratio'") + && m.contains("not an integer") + && *at == "ratio"), + "{errors:?}" + ); + assert!( + errors.iter().any(|(m, at)| m.contains("parameter 'serial'") + && m.contains("beyond") + && *at == "serial"), + "{errors:?}" + ); + } + #[test] fn unknown_top_level_key_warning_covers_full_key() { let yaml = r#" diff --git a/crates/rite-resolver/src/resolve.rs b/crates/rite-resolver/src/resolve.rs index b2281e6..327af33 100644 --- a/crates/rite-resolver/src/resolve.rs +++ b/crates/rite-resolver/src/resolve.rs @@ -96,6 +96,7 @@ pub(crate) fn resolve_ceremony( backends, execution_plan, after, + source_digest: ceremony.source_digest, }; let mut result = ResolveResult::ok(resolved); @@ -328,16 +329,25 @@ impl ResolveContext { // CLI/env/prompt inputs are commonly strings. Coerce them to declared scalar types // so users can pass --param threshold=5 and --param enabled=true naturally. ParameterType::Integer => { - if value.is_i64() || value.is_u64() { - return Some(value.clone()); - } - if let Some(s) = value.as_str() { - if let Ok(i) = s.parse::() { - return Some(serde_json::Value::Number(i.into())); - } - if let Ok(u) = s.parse::() { - return Some(serde_json::Value::Number(u.into())); + let number = if value.is_i64() || value.is_u64() { + Some(value.clone()) + } else if let Some(s) = value.as_str() { + s.parse::() + .map(serde_json::Value::from) + .or_else(|_| s.parse::().map(serde_json::Value::from)) + .ok() + } else { + None + }; + if let Some(number) = number { + if rite_model::check_numbers(&number).is_err() { + self.add_error(ResolveError::ParamOutOfRange { + param: id.clone(), + value: number.to_string(), + }); + return None; } + return Some(number); } self.add_error(ResolveError::ParamTypeMismatch { param: id.clone(), @@ -1257,6 +1267,7 @@ mod tests { prerequisites: vec![], output: HashMap::new(), after: IndexMap::new(), + source_digest: rite_model::Sha256Digest::of(b"test"), } } @@ -1480,6 +1491,35 @@ sections: assert_eq!(threshold.value, serde_json::json!(5)); } + #[test] + fn refuses_an_integer_input_beyond_the_transcript_range() { + let mut ceremony = minimal_ceremony(); + ceremony.parameters.insert( + "serial".to_string(), + schema::Parameter { + param_type: ParameterType::Integer, + description: None, + default: None, + }, + ); + + let mut inputs = CeremonyInputs::default(); + inputs.parameters.insert( + "serial".to_string(), + serde_json::json!("12345678901234567890"), + ); + + let result = resolve_ceremony(ceremony, Some(&inputs)); + assert!( + result + .errors + .iter() + .any(|e| matches!(e, ResolveError::ParamOutOfRange { .. })), + "Errors: {:?}", + result.errors + ); + } + #[test] fn coerces_string_boolean_input_to_json_boolean() { let mut ceremony = minimal_ceremony(); diff --git a/crates/rite-resolver/src/schema.rs b/crates/rite-resolver/src/schema.rs index 70bb882..61c5849 100644 --- a/crates/rite-resolver/src/schema.rs +++ b/crates/rite-resolver/src/schema.rs @@ -7,7 +7,7 @@ use crate::serde_utils; use indexmap::IndexMap; -use rite_model::{ActionType, BackendConfig, DutyType, OutputType, ParameterType}; +use rite_model::{ActionType, BackendConfig, DutyType, OutputType, ParameterType, Sha256Digest}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -46,6 +46,17 @@ pub(crate) struct Ceremony { /// Duties to be completed after the ceremony runtime stops (duty ID → body). #[serde(default, skip_serializing_if = "IndexMap::is_empty")] pub(crate) after: IndexMap, + /// Digest of the YAML text this definition was parsed from. + /// + /// Never read from YAML. `lower_ceremony`, the only place a definition is + /// deserialized, overwrites the placeholder with the digest of the text it + /// parsed. + #[serde(skip, default = "unset_source_digest")] + pub(crate) source_digest: Sha256Digest, +} + +fn unset_source_digest() -> Sha256Digest { + Sha256Digest::of(b"") } /// A role definition. diff --git a/crates/rite-runtime/src/display.rs b/crates/rite-runtime/src/display.rs index f4bb26c..36c83e0 100644 --- a/crates/rite-runtime/src/display.rs +++ b/crates/rite-runtime/src/display.rs @@ -22,8 +22,9 @@ const UNSUMMARISED: &str = "unknown fact variant"; /// /// Returns `None` for facts the live UI shouldn't surface: /// - `PromptAnswered`: the operator just typed it. -/// - `BackendOperation` / `AttestationRecorded`: the surrounding action -/// handler already calls `Reporter::log` with its own narrative line. +/// - `BackendOperation` / `AttestationRecorded` / `MachineInfoRecorded`: the +/// surrounding action handler already calls `Reporter::log` with its own +/// narrative line. /// - `CeremonyCompleted`: the frontend renders a dedicated completion /// screen with the fingerprint. #[must_use] @@ -31,22 +32,39 @@ pub fn fact_summary(fact: &StepFact) -> Option<(Icon, String)> { match fact { StepFact::CeremonyStarted { name, .. } => Some((Icon::Info, format!("Ceremony: {name}"))), StepFact::ActStarted { label, .. } => Some((Icon::Info, format!("Act: {label}"))), - StepFact::StepStarted { - id, - label, - role_name, - .. - } => Some(( - Icon::Info, - format!("Step {label} ({id}), role: {role_name}"), - )), + StepFact::StepStarted { id, label, role } => { + Some((Icon::Info, format!("Step {label} ({id}), role: {role}"))) + } StepFact::PromptAnswered { .. } | StepFact::BackendOperation { .. } | StepFact::AttestationRecorded { .. } + | StepFact::MachineInfoRecorded { .. } + // Roles show by name on each step, where a frontend that keeps the + // declarations resolves them. + | StepFact::RoleDeclared { .. } | StepFact::CeremonyCompleted { .. } => None, - StepFact::ArtifactWritten { path, .. } => Some(( + StepFact::RoleAssigned { role, person } => { + Some((Icon::Info, format!("Role {role}: {person}"))) + } + StepFact::ParameterBound { name, value } => { + Some((Icon::Info, format!("Parameter {name} = {value}"))) + } + StepFact::MaterialLoaded { name, identifier } => Some(( + Icon::Checkmark, + match identifier { + Some(identifier) => format!("Material loaded: {name} ({identifier})"), + None => format!("Material loaded: {name}"), + }, + )), + StepFact::MaterialDigest { name, digest } => { + Some((Icon::Info, format!("Material {name}: {digest}"))) + } + StepFact::BackendBound { name, identity, .. } => { + Some((Icon::Info, format!("Backend {name}: {identity}"))) + } + StepFact::ArtifactWritten { file, .. } => Some(( Icon::Checkmark, - format!("Artifact written: {}", path.display()), + format!("Artifact written: artifacts/{file}"), )), StepFact::DeviationRecorded { text, .. } => { Some((Icon::Warning, format!("Deviation: {text}"))) diff --git a/crates/rite-runtime/src/executor.rs b/crates/rite-runtime/src/executor.rs index e642af2..a879bda 100644 --- a/crates/rite-runtime/src/executor.rs +++ b/crates/rite-runtime/src/executor.rs @@ -3,8 +3,9 @@ use crate::actions::ArtifactValue; use crate::step_info::StepInfo; -use crate::transcript::compute_fingerprint; -use rite_model::{ActionType, ArtifactId, Material, MaterialKind, MaterialSource, Step, StepId}; +use rite_model::{ + ActionType, ArtifactId, Material, MaterialKind, MaterialSource, Sha256Digest, Step, StepId, +}; use rite_sdk::BackendError; use std::fs; use std::io; @@ -135,7 +136,11 @@ pub(crate) fn load_material_artifact( /// destination (which could otherwise redirect artifact bytes outside the run directory) and /// cannot clobber an existing file. On Unix the file is created with `0o600` so artifact /// material is not world-readable. -fn write_new_file(path: &Path, bytes: &[u8]) -> io::Result<()> { +/// +/// # Errors +/// +/// Returns the I/O error if the file exists or cannot be written and synced. +pub fn write_new_file(path: &Path, bytes: &[u8]) -> io::Result<()> { let mut options = fs::OpenOptions::new(); options.write(true).create_new(true); #[cfg(unix)] @@ -153,7 +158,7 @@ pub(crate) fn write_artifact_to_disk( artifact_id: &ArtifactId, artifact_value: &ArtifactValue, output_config: &OutputConfig, -) -> Result<(PathBuf, String, u64, Option), ExecutionError> { +) -> Result<(PathBuf, Sha256Digest, u64, Option), ExecutionError> { let serialized = artifact_value .serialize(None) @@ -180,7 +185,7 @@ pub(crate) fn write_artifact_to_disk( // Hashed from the buffer just written and synced, not read back from the // file: a read-back would be one more unwiped copy of opened content. - let hash = compute_fingerprint(&bytes); + let hash = Sha256Digest::of(&bytes); let size = fs::metadata(&path) .map_err(|e| ExecutionError::OutputWriteFailed { diff --git a/crates/rite-runtime/src/lib.rs b/crates/rite-runtime/src/lib.rs index 0f554e8..bfa74e6 100644 --- a/crates/rite-runtime/src/lib.rs +++ b/crates/rite-runtime/src/lib.rs @@ -40,7 +40,7 @@ mod transcript; mod transcript_sink; // Execution -pub use executor::ExecutionError; +pub use executor::{ExecutionError, write_new_file}; // Actions pub use actions::{ArtifactValue, Share, ShareSet}; @@ -79,15 +79,16 @@ pub use reporter::{Reporter, ReporterError}; // Executor and its action trait. pub use runner::{ - Action, ActionError, ActionRegistry, ExecutionSummary, Executor, StepUnsupportedParam, - parse_params, + Action, ActionError, ActionRegistry, ExecutionSummary, Executor, PRODUCER, + StepUnsupportedParam, parse_params, }; // Transcript sink, the durable consumer of `StepFact`s. pub use transcript_sink::{ EntropyVerified, InMemorySink, JsonlFileSink, LoadedTranscript, TimedFact, - TranscriptFingerprint, TranscriptSink, TranscriptVerified, VerifyError, - read_verified_transcript, verify_entropy, verify_transcript as verify_step_fact_transcript, + TranscriptFingerprint, TranscriptSink, TranscriptVerified, UnknownFact, VerifyError, + WithheldLine, disclose_transcript, read_verified_transcript, verify_entropy, verify_jsonl, + verify_transcript as verify_step_fact_transcript, }; // State (needed by `Action` implementors in downstream crates). diff --git a/crates/rite-runtime/src/reporter.rs b/crates/rite-runtime/src/reporter.rs index 02da885..eee5415 100644 --- a/crates/rite-runtime/src/reporter.rs +++ b/crates/rite-runtime/src/reporter.rs @@ -26,7 +26,7 @@ use std::sync::Arc; use crossbeam_channel::{Receiver, Sender, TryRecvError}; -use rite_model::{ErrorClass, ErrorRecord, Prompt, ResponseRecord, StepFact, StepId}; +use rite_model::{ErrorClass, ErrorRecord, Level, Prompt, ResponseRecord, StepFact, StepId}; use secrecy::ExposeSecret; use thiserror::Error; @@ -107,6 +107,9 @@ pub struct Reporter<'a> { transcript: &'a mut dyn TranscriptSink, next_prompt_id: u64, current_step: Option, + /// The backend the current step runs with, named on every + /// [`StepFact::BackendOperation`] it records. + current_backend: Option, /// The ceremony entropy source: the single auditable origin of every /// random value drawn during the run. `None` until the runner seeds it at /// ceremony start; a draw before then is an internal ordering bug and fails @@ -139,6 +142,7 @@ impl<'a> Reporter<'a> { transcript, next_prompt_id: 0, current_step: None, + current_backend: None, random: None, clock, side_effects_emitted: 0, @@ -169,6 +173,12 @@ impl<'a> Reporter<'a> { self.current_step = step; } + /// Set the backend the current step runs with, by its name in the + /// ceremony. Called by the executor with [`Reporter::set_current_step`]. + pub fn set_current_backend(&mut self, backend: Option) { + self.current_backend = backend; + } + /// The current step, if any. Mostly useful for tests. #[must_use] pub fn current_step(&self) -> Option<&StepId> { @@ -189,10 +199,23 @@ impl<'a> Reporter<'a> { /// Returns [`ReporterError::Transcript`] if the sink fails to persist, /// or [`ReporterError::Disconnected`] if the frontend is gone. pub fn fact(&mut self, fact: StepFact) -> Result<(), ReporterError> { + let level = fact.default_level(); + self.fact_at(level, fact) + } + + /// Record a durable fact at `level` instead of its type's default. + /// + /// For the runtime, where the level depends on what the fact describes + /// rather than on its type alone, such as an artifact's content. + /// + /// # Errors + /// + /// Same as [`Reporter::fact`]. + pub fn fact_at(&mut self, level: Level, fact: StepFact) -> Result<(), ReporterError> { // Stamp the event time once, from the run clock, and use it for both // the durable record and the live UI event so they never disagree. let at = self.clock.now(); - self.transcript.record(at, &fact)?; + self.transcript.record(at, level, &fact)?; if fact.is_side_effect() { self.side_effects_emitted = self.side_effects_emitted.saturating_add(1); } @@ -202,6 +225,34 @@ impl<'a> Reporter<'a> { Ok(()) } + /// Record a [`StepFact::BackendOperation`] of the current step, naming + /// the backend the step runs with. + /// + /// # Errors + /// + /// [`ReporterError::NoCurrentStep`] if called outside a step, or the + /// errors of [`Reporter::fact`]. + pub fn backend_operation( + &mut self, + kind: &str, + inputs: serde_json::Value, + outputs: serde_json::Value, + fingerprint: Option, + ) -> Result<(), ReporterError> { + let step = self + .current_step + .clone() + .ok_or(ReporterError::NoCurrentStep("backend_operation"))?; + self.fact(StepFact::BackendOperation { + step, + kind: kind.to_string(), + backend: self.current_backend.clone(), + inputs, + outputs, + fingerprint, + }) + } + /// Draw `len` bytes from the ceremony entropy source for `purpose`. /// /// The generic primitive behind every nonce, certificate serial, and @@ -507,7 +558,6 @@ mod tests { use super::*; use crate::clock::SystemClock; - use crate::transcript_sink::InMemorySink; use rite_model::ValidatorSpec; fn ids(step: &str) -> StepId { @@ -524,15 +574,13 @@ mod tests { let (event_tx, event_rx) = unbounded(); let (_cmd_tx, cmd_rx) = unbounded::(); - let mut sink = InMemorySink::new(); + let mut sink = crate::test_support::begun_sink(); let fixed = fixed_test_time(); let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, Arc::new(FixedClock(fixed))); reporter - .fact(StepFact::CeremonyStarted { - name: "T".to_string(), - }) + .fact(crate::test_support::ceremony_started("T")) .expect("fact"); match event_rx.recv().expect("event") { @@ -545,7 +593,7 @@ mod tests { fn fact_writes_to_sink_and_forwards_to_ui() { let (event_tx, event_rx) = unbounded(); let (_cmd_tx, cmd_rx) = unbounded::(); - let mut sink = InMemorySink::new(); + let mut sink = crate::test_support::begun_sink(); let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock()); reporter.set_current_step(Some(ids("s1"))); @@ -575,7 +623,7 @@ mod tests { // the step/seed checks, so no seeding is needed to exercise it. let (event_tx, _event_rx) = unbounded(); let (_cmd_tx, cmd_rx) = unbounded::(); - let mut sink = InMemorySink::new(); + let mut sink = crate::test_support::begun_sink(); let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock()); reporter.set_current_step(Some(ids("s1"))); @@ -590,7 +638,7 @@ mod tests { fn log_does_not_touch_transcript() { let (event_tx, event_rx) = unbounded(); let (_cmd_tx, cmd_rx) = unbounded::(); - let mut sink = InMemorySink::new(); + let mut sink = crate::test_support::begun_sink(); let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock()); reporter.set_current_step(Some(ids("s1"))); @@ -607,7 +655,7 @@ mod tests { fn check_abort_returns_aborted_when_abort_in_queue() { let (event_tx, _event_rx) = unbounded::(); let (cmd_tx, cmd_rx) = unbounded(); - let mut sink = InMemorySink::new(); + let mut sink = crate::test_support::begun_sink(); let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock()); reporter.set_current_step(Some(ids("s1"))); @@ -620,7 +668,7 @@ mod tests { fn check_abort_processes_deviation_then_returns_ok() { let (event_tx, event_rx) = unbounded(); let (cmd_tx, cmd_rx) = unbounded(); - let mut sink = InMemorySink::new(); + let mut sink = crate::test_support::begun_sink(); let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock()); reporter.set_current_step(Some(ids("s1"))); @@ -696,7 +744,7 @@ mod tests { fn prompt_round_trip_emits_await_and_records_fact() { let (event_tx, event_rx) = unbounded(); let (cmd_tx, cmd_rx) = unbounded(); - let mut sink = InMemorySink::new(); + let mut sink = crate::test_support::begun_sink(); let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock()); reporter.set_current_step(Some(ids("s1"))); @@ -729,7 +777,7 @@ mod tests { fn prompt_redacts_secret_in_record() { let (event_tx, event_rx) = unbounded(); let (cmd_tx, cmd_rx) = unbounded(); - let mut sink = InMemorySink::new(); + let mut sink = crate::test_support::begun_sink(); let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock()); reporter.set_current_step(Some(ids("s1"))); @@ -764,7 +812,7 @@ mod tests { fn prompt_loop_handles_deviation_then_response() { let (event_tx, event_rx) = unbounded(); let (cmd_tx, cmd_rx) = unbounded(); - let mut sink = InMemorySink::new(); + let mut sink = crate::test_support::begun_sink(); let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock()); reporter.set_current_step(Some(ids("s1"))); @@ -895,7 +943,7 @@ mod tests { fn prompt_re_emits_with_rejection_reason_when_validator_fails() { let (event_tx, event_rx) = unbounded(); let (cmd_tx, cmd_rx) = unbounded(); - let mut sink = InMemorySink::new(); + let mut sink = crate::test_support::begun_sink(); let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock()); reporter.set_current_step(Some(ids("s1"))); diff --git a/crates/rite-runtime/src/runner.rs b/crates/rite-runtime/src/runner.rs index 859d0bd..24b07c5 100644 --- a/crates/rite-runtime/src/runner.rs +++ b/crates/rite-runtime/src/runner.rs @@ -31,18 +31,20 @@ //! finalizes it before returning. Action handlers receive a `&mut Reporter` //! and emit transcript-worthy facts through it. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use crossbeam_channel::{Receiver, Sender}; use rand::TryRng; use rand::rngs::SysRng; use rite_model::{ - ActId, ActionType, ArtifactId, Ceremony, MaterialId, OutputId, ParamId, RoleId, Step, + ActId, ActionType, ArtifactId, Ceremony, Level, MaterialId, MaterialKind, MaterialSource, + OutputId, ParamId, RoleId, Sha256Digest, Step, TranscriptHeader, }; use rite_sdk::{Backend, BackendError, Retriability}; use thiserror::Error; +use crate::actions::ArtifactValue; use crate::backend::BackendRegistry; use crate::clock::{Clock, SystemClock}; use crate::entropy::DERIVATION_V1; @@ -51,7 +53,7 @@ use crate::executor::{ }; use crate::expressions; use crate::output_config::OutputConfig; -use crate::protocol::{ExecEvent, Icon, MaterialOverview, Response, UiCommand, UiSignal}; +use crate::protocol::{ExecEvent, MaterialOverview, Response, UiCommand, UiSignal}; use crate::reporter::{Reporter, ReporterError}; use crate::state::{ExecutionState, HandlerContext, StepResult}; use crate::step_info::StepInfo; @@ -80,6 +82,20 @@ fn gather_machine_entropy(dry_run: bool) -> Result<([u8; 32], String), Execution Ok((m, "os".to_string())) } +/// Program and version recorded in the transcript header. +/// Name and version of the program writing transcripts, as the header +/// records it. +pub const PRODUCER: &str = concat!("rite ", env!("CARGO_PKG_VERSION")); + +/// A fresh run identifier: 16 bytes from the OS RNG, as lowercase hex. +fn new_run_id() -> Result { + let mut id = [0u8; 16]; + SysRng + .try_fill_bytes(&mut id) + .map_err(|e| ExecutionError::EntropyError(e.to_string()))?; + Ok(base16ct::lower::encode_string(&id)) +} + /// Errors that may surface from an [`Action`] handler. #[derive(Debug, Error)] pub enum ActionError { @@ -385,6 +401,12 @@ impl Executor { ) -> Result { validate_parameters(&self.ceremony)?; + // The header goes first: it says what the file is before any fact. + let header = TranscriptHeader::new(PRODUCER, &new_run_id()?, self.dry_run); + transcript_sink + .begin(&header) + .map_err(|e| ExecutionError::TranscriptError(e.to_string()))?; + let resolved_params: HashMap = self .ceremony .parameters @@ -428,7 +450,7 @@ impl Executor { let at = clock.now(); let completed = StepFact::CeremonyCompleted {}; transcript_sink - .record(at, &completed) + .record(at, completed.default_level(), &completed) .map_err(|e| ExecutionError::TranscriptError(e.to_string()))?; let _ = event_tx.send(ExecEvent::Fact { at, @@ -450,7 +472,7 @@ impl Executor { let at = clock.now(); let record = err.to_error_record(); let failed = StepFact::CeremonyFailed { error: record }; - let _ = transcript_sink.record(at, &failed); + let _ = transcript_sink.record(at, failed.default_level(), &failed); let _ = event_tx.send(ExecEvent::Fact { at, fact: failed }); if let Ok(fingerprint) = transcript_sink.finalize() { let _ = event_tx.send(ExecEvent::Finalized { @@ -474,6 +496,7 @@ impl Executor { reporter.fact(StepFact::CeremonyStarted { name: self.ceremony.metadata.name.clone(), + template: self.ceremony.source_digest.clone(), })?; // Establish the ceremony entropy source before any step can draw from @@ -488,6 +511,30 @@ impl Executor { derivation: DERIVATION_V1.to_string(), })?; + // The inputs this run was given, one fact each so each can be + // disclosed on its own. The template digest on `CeremonyStarted` + // covers none of them. + for (id, role) in self.ceremony.roles.iter() { + reporter.fact(StepFact::RoleDeclared { + role: id.clone(), + name: role.name.clone(), + })?; + } + for (id, role) in self.ceremony.roles.iter() { + if let Some(person) = &role.person { + reporter.fact(StepFact::RoleAssigned { + role: id.clone(), + person: person.clone(), + })?; + } + } + for (id, parameter) in self.ceremony.parameters.iter() { + reporter.fact(StepFact::ParameterBound { + name: id.clone(), + value: parameter.value.clone(), + })?; + } + // Pre-ceremony overview: descriptive metadata for the UI's // Overview screen. Sent as a UI-only signal, not a transcript // fact, because the YAML is the source of truth for these fields @@ -522,14 +569,40 @@ impl Executor { let mut state = ExecutionState::new(resolved_params, roles, materials_map, dry_run); - // Load materials. Pre-step logs are attributed to no specific step. + // Load materials. The digest of a file is its own fact, so an + // audience can learn that an input was loaded without being able to + // test guesses against its content. for (id, material) in self.ceremony.materials.iter() { let artifact = load_material_artifact(id.as_str(), material)?; + let identifier = match &material.kind { + MaterialKind::Physical { identifier, .. } => identifier.clone(), + MaterialKind::Digital { + source: Some(MaterialSource::Identifier { identifier }), + } => Some(identifier.clone()), + MaterialKind::Digital { .. } => None, + }; + // A file loads as bytes; an identifier or physical item as text. + let digest = match &artifact { + ArtifactValue::Bytes(bytes) => Some(Sha256Digest::of(bytes)), + _ => None, + }; + reporter.fact(StepFact::MaterialLoaded { + name: id.clone(), + identifier, + })?; + if let Some(digest) = digest { + reporter.fact(StepFact::MaterialDigest { + name: id.clone(), + digest, + })?; + } state = state.with_material(ArtifactId::new(id.as_str()), artifact); - let display_name = material.display_name(); - reporter.log(Icon::Checkmark, format!("Loaded material: {display_name}"))?; } + let mut backends = RunBackends { + registry: &mut self.backend_registry, + bound: HashSet::new(), + }; let has_acts = !self.ceremony.acts.is_empty(); let mut current_act: Option = None; let mut counts = StepCounts::default(); @@ -562,17 +635,11 @@ impl Executor { } reporter.set_current_step(Some(step.id.clone())); - let role_id = step.role.clone().unwrap_or_else(|| RoleId::new("")); - let role_name = self - .ceremony - .roles - .get(&role_id) - .map_or_else(|| role_id.as_str().to_string(), |r| r.name.clone()); + reporter.set_current_backend(step.backend.clone()); reporter.fact(StepFact::StepStarted { id: step.id.clone(), label: step.step_label.clone(), - role: role_id, - role_name, + role: step.role.clone().unwrap_or_else(|| RoleId::new("")), })?; // Pacing: gate the step body on operator acknowledgement. The @@ -606,7 +673,7 @@ impl Executor { &ctx, ¶ms, reporter, - &mut self.backend_registry, + &mut backends, )?; // StepCompleted @@ -643,29 +710,43 @@ impl Executor { } })?; - let (path, hash, _size, _mime_type) = + let (path, digest, _size, _mime_type) = write_artifact_to_disk(artifact_id, artifact_value, &self.output_config)?; - // Record the location relative to the run directory so the - // transcript stays portable and never embeds the operator's - // filesystem layout. `verify` re-anchors artifacts under the - // transcript's own `artifacts/` directory regardless. - let recorded_path = match path.strip_prefix(self.output_config.base_dir()) { - Ok(relative) => relative.to_path_buf(), - Err(_) => path.clone(), + // Only the file name is recorded: the file lives under + // the run's `artifacts/` directory, and the operator's + // filesystem layout stays out of the transcript. + let file = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| ExecutionError::OutputWriteFailed { + name: artifact_id.as_str().to_string(), + reason: format!("'{}' has no UTF-8 file name", path.display()), + })? + .to_string(); + + // Opened content gets no digest: a digest of a secret can + // be tested against guesses. + let digest = match artifact_value { + ArtifactValue::Secret(_) => None, + _ => Some(digest), }; - reporter.fact(StepFact::ArtifactWritten { - step: step.id.clone(), - name: artifact_id.as_str().to_string(), - path: recorded_path, - sha256: hash, - })?; + reporter.fact_at( + artifact_level(step.action, artifact_value), + StepFact::ArtifactWritten { + step: step.id.clone(), + name: artifact_id.as_str().to_string(), + file, + digest, + }, + )?; } } } reporter.set_current_step(None); + reporter.set_current_backend(None); drop(plan); Ok(counts) @@ -677,6 +758,63 @@ struct StepCounts { completed: usize, } +/// The level an artifact is recorded at, which decides whether a disclosure +/// carries its file. +/// +/// Certificates and public keys exist to be distributed. Bytes are public +/// only from the actions that make distributable bytes, a CSR or a signature; +/// from any other step they may be a document the ceremony handled, so they +/// stay restricted. Wrapped keys and encrypted content are ciphertext: not +/// secret, and not for everyone either. Opened content is confidential and is +/// never bundled at all. +fn artifact_level(action: ActionType, value: &ArtifactValue) -> Level { + match value { + ArtifactValue::Certificate(_) | ArtifactValue::PublicKey(_) => Level::PUBLIC, + ArtifactValue::Bytes(_) + if matches!( + action, + ActionType::GenerateCsr | ActionType::SignData | ActionType::PivSign + ) => + { + Level::PUBLIC + } + ArtifactValue::Bytes(_) + | ArtifactValue::Text(_) + | ArtifactValue::WrappedKey(_) + | ArtifactValue::EncryptedData(_) + | ArtifactValue::BackendKey { .. } + | ArtifactValue::Shares(_) => Level::RESTRICTED, + ArtifactValue::Secret(_) => Level::CONFIDENTIAL, + } +} + +/// The run's backends, and which of them have had their identity recorded. +struct RunBackends<'a> { + registry: &'a mut BackendRegistry, + bound: HashSet, +} + +impl RunBackends<'_> { + /// Hand out a backend, recording its identity the first time it is + /// acquired. Operations name the backend and do not repeat the identity, + /// which can carry device serials. + fn acquire( + &mut self, + name: &str, + reporter: &mut Reporter<'_>, + ) -> Result<&mut dyn Backend, ActionError> { + let backend = self.registry.get_mut(name)?; + if self.bound.insert(name.to_string()) { + reporter.fact(StepFact::BackendBound { + name: name.to_string(), + provider: backend.provider().to_string(), + identity: backend.fingerprint(), + })?; + } + Ok(backend) + } +} + /// Run one step, retrying transient failures under operator control. /// /// Each attempt re-acquires the backend handle (it is moved into the handler) @@ -693,7 +831,7 @@ fn execute_step_with_retry( ctx: &HandlerContext, params: &serde_json::Value, reporter: &mut Reporter<'_>, - backend_registry: &mut BackendRegistry, + backends: &mut RunBackends<'_>, ) -> Result { let mut attempt: u32 = 1; loop { @@ -703,10 +841,9 @@ fn execute_step_with_retry( // initialization (device unplugged, daemon down) carries the same // retriability semantics as a backend error inside the handler. let attempt_result = match &step_info.backend { - Some(name) => match backend_registry.get_mut(name) { - Ok(backend) => handler.execute(step_info, ctx, params, reporter, Some(backend)), - Err(e) => Err(ActionError::Backend(e)), - }, + Some(name) => backends.acquire(name, reporter).and_then(|backend| { + handler.execute(step_info, ctx, params, reporter, Some(backend)) + }), None => handler.execute(step_info, ctx, params, reporter, None), }; @@ -887,7 +1024,7 @@ mod tests { reporter: &mut Reporter<'_>, _backend: Option<&mut dyn Backend>, ) -> Result { - reporter.log(Icon::Info, "ping")?; + reporter.log(crate::protocol::Icon::Info, "ping")?; Ok(StepResult::completed("pinged".to_string())) } } @@ -1048,6 +1185,8 @@ sections: vec![ "ceremony_started", "entropy_seeded", + "role_declared", + "role_assigned", "step_started", "step_completed", "ceremony_completed", @@ -1170,13 +1309,17 @@ sections: let summary = result.expect("ceremony runs"); assert_eq!(summary.steps_completed, 1); let written = facts.iter().find_map(|f| match f { - StepFact::ArtifactWritten { name, path, .. } => Some((name.clone(), path.clone())), + StepFact::ArtifactWritten { + name, file, digest, .. + } => Some((name.clone(), file.clone(), digest.clone())), _ => None, }); - let (name, path) = written.expect("the artifact is written"); + let (name, file, digest) = written.expect("the artifact is written"); assert_eq!(name, "opened"); + // Opened content: no digest of a secret goes into the record. + assert_eq!(digest, None); assert_eq!( - std::fs::read(dir.path().join(path)).expect("read artifact"), + std::fs::read(dir.path().join("artifacts").join(file)).expect("read artifact"), b"the recovery phrase" ); } @@ -1481,7 +1624,7 @@ sections: fn execute( &self, - step: &StepInfo, + _step: &StepInfo, _ctx: &HandlerContext, _params: &serde_json::Value, reporter: &mut Reporter<'_>, @@ -1491,13 +1634,12 @@ sections: match self.before_fail { BeforeFail::Nothing => {} BeforeFail::SideEffect => { - reporter.fact(StepFact::BackendOperation { - step: step.id.clone(), - kind: "test_op".to_string(), - inputs: serde_json::Value::Null, - outputs: serde_json::Value::Null, - fingerprint: None, - })?; + reporter.backend_operation( + "test_op", + serde_json::Value::Null, + serde_json::Value::Null, + None, + )?; } BeforeFail::Prompt => { reporter.prompt(&Prompt::Secret { @@ -1837,6 +1979,33 @@ sections: assert_eq!(attempt_numbers(&facts), vec![1]); } + #[test] + fn artifact_levels_follow_what_the_artifact_is() { + use secrecy::SecretBox; + + let bytes = || ArtifactValue::Bytes(b"x".to_vec()); + assert_eq!( + artifact_level(ActionType::SignData, &bytes()), + Level::PUBLIC + ); + assert_eq!( + artifact_level(ActionType::GenerateCsr, &bytes()), + Level::PUBLIC + ); + // Bytes from any other step may be a document the ceremony handled. + assert_eq!( + artifact_level(ActionType::DecryptData, &bytes()), + Level::RESTRICTED + ); + assert_eq!( + artifact_level( + ActionType::DecryptData, + &ArtifactValue::Secret(SecretBox::new(Box::new(b"x".to_vec()))) + ), + Level::CONFIDENTIAL + ); + } + fn fact_kind(fact: &StepFact) -> &'static str { match fact { StepFact::CeremonyStarted { .. } => "ceremony_started", @@ -1854,6 +2023,13 @@ sections: StepFact::EntropySeeded { .. } => "entropy_seeded", StepFact::EntropyContributed { .. } => "entropy_contributed", StepFact::EntropyDrawn { .. } => "entropy_drawn", + StepFact::RoleDeclared { .. } => "role_declared", + StepFact::RoleAssigned { .. } => "role_assigned", + StepFact::ParameterBound { .. } => "parameter_bound", + StepFact::MaterialLoaded { .. } => "material_loaded", + StepFact::MaterialDigest { .. } => "material_digest", + StepFact::BackendBound { .. } => "backend_bound", + StepFact::MachineInfoRecorded { .. } => "machine_info_recorded", _ => "unknown", } } diff --git a/crates/rite-runtime/src/test_support.rs b/crates/rite-runtime/src/test_support.rs index 9c27601..ffdac56 100644 --- a/crates/rite-runtime/src/test_support.rs +++ b/crates/rite-runtime/src/test_support.rs @@ -14,8 +14,8 @@ use rite_model::StepId; use crate::clock::{Clock, SystemClock}; use crate::protocol::{ExecEvent, PromptId, Response, UiCommand}; use crate::reporter::Reporter; -use crate::transcript_sink::InMemorySink; -use rite_model::StepFact; +use crate::transcript_sink::{InMemorySink, JsonlFileSink, TranscriptSink}; +use rite_model::{Sha256Digest, StepFact, TranscriptHeader}; /// Owns the channels and sink needed to build a [`Reporter`] for tests. /// @@ -38,7 +38,7 @@ impl ReporterHarness { let (event_tx, event_rx) = unbounded(); let (cmd_tx, cmd_rx) = unbounded(); Self { - sink: InMemorySink::new(), + sink: begun_sink(), event_tx, _event_rx: event_rx, cmd_tx, @@ -100,6 +100,55 @@ impl Default for ReporterHarness { } } +/// A header for tests: a fixed run id, not a dry run. +#[must_use] +pub fn test_header() -> TranscriptHeader { + TranscriptHeader::new("rite test", &"0".repeat(32), false) +} + +/// An in-memory sink with [`test_header`] already written, ready to record +/// facts. +/// +/// # Panics +/// +/// Never in practice: writing the first header to a fresh in-memory sink +/// cannot fail. +#[must_use] +#[allow(clippy::expect_used)] +pub fn begun_sink() -> InMemorySink { + let mut sink = InMemorySink::new(); + sink.begin(&test_header()) + .expect("a fresh in-memory sink takes a header"); + sink +} + +/// Write `transcript.jsonl` in `dir`: [`test_header`], then each fact at its +/// default level and [`fixed_test_time`]. Returns the fingerprint. +/// +/// # Errors +/// +/// Returns the I/O error if the file cannot be created or written. +pub fn write_transcript( + dir: &std::path::Path, + facts: &[StepFact], +) -> std::io::Result { + let mut sink = JsonlFileSink::create(dir)?; + sink.begin(&test_header())?; + for fact in facts { + sink.record(fixed_test_time(), fact.default_level(), fact)?; + } + sink.finalize() +} + +/// A `CeremonyStarted` fact for tests, over a fixed template digest. +#[must_use] +pub fn ceremony_started(name: &str) -> StepFact { + StepFact::CeremonyStarted { + name: name.to_string(), + template: Sha256Digest::of(b"test ceremony"), + } +} + /// A fixed instant for tests that need a deterministic event time. Arbitrary /// but stable, so recorded `at` values and snapshots stay reproducible. #[must_use] diff --git a/crates/rite-runtime/src/transcript_sink.rs b/crates/rite-runtime/src/transcript_sink.rs index 2898eef..17c5635 100644 --- a/crates/rite-runtime/src/transcript_sink.rs +++ b/crates/rite-runtime/src/transcript_sink.rs @@ -9,22 +9,41 @@ //! //! # On-disk format (`transcript.jsonl`) //! -//! Each line is a JSON object with three fields: +//! The first line is the header, which says what the file is before any fact +//! is read: //! //! ```jsonc -//! {"prev_hash": "sha256:…", "at": "2026-06-01T20:34:51Z", "fact": { "type": "step_started", … }} +//! {"$schema": "https://ritely.io/schemas/…/transcript.schema.json", "header": {"dry_run": false, "levels": {"public": 10, …}, …}, "chain": "sha256:…"} //! ``` //! +//! `$schema` names the JSON Schema of the release that wrote the file, for +//! editors and other tools. It is outside the header, so the chain does not +//! commit to it, and a reader ignores it. +//! +//! Every following line is one fact: its time, its confidentiality level and +//! two checkpoints, which every line has, then a fresh random salt and the +//! fact itself: +//! +//! ```jsonc +//! {"at": "2026-06-01T20:34:51.123456Z", "level": 10, "leaf": "sha256:…", "chain": "sha256:…", "salt": "…", "fact": { "type": "step_started", … }} +//! ``` +//! +//! A line whose fact is withheld from a disclosure keeps `at`, `level`, +//! `leaf` and `chain`, and drops `salt` and `fact`. +//! +//! The chain rule is in [`rite_model::commitment`]. `leaf` and `chain` are +//! derivable, and stored so a reader can name the first line where its own +//! computation disagrees: a `leaf` mismatch means the fact or its canonical +//! form differs, a `chain` mismatch with a matching leaf means the time, the +//! level or an earlier line differs. They do not stop a forger, who rewrites +//! them; what ties a transcript to a ceremony is the fingerprint, the `chain` +//! of the last line, compared against the value written down at the end of +//! the run. +//! //! `at` is the event's wall-clock time, supplied by the executor's clock when //! it emits the fact (the sink records it rather than choosing it, so the time -//! is independent of the storage backend). It is the single uniform timestamp -//! for every event, and part of the hashed line, so it is tamper-evident like -//! the rest of the envelope. -//! -//! The chain is verified by recomputing each line's SHA-256, accumulating it -//! as the expected `prev_hash` of the next line, starting from -//! [`GENESIS_HASH`]. The final transcript fingerprint is the hash of the -//! last line; the JSONL is self-identifying and no sidecar file is written. +//! is independent of the storage backend). It is committed in the chain as +//! the exact string on the line. //! //! # Implementations //! @@ -36,45 +55,21 @@ use std::fs::{File, OpenOptions}; use std::io::{self, BufRead, BufReader, BufWriter, Write}; use std::path::{Path, PathBuf}; -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; +use chrono::{DateTime, SecondsFormat, Utc}; +use rand::TryRng; +use rand::rngs::SysRng; +use serde::Deserialize; use thiserror::Error; -use crate::transcript::compute_fingerprint; -use rite_model::StepFact; +use rite_model::bundle::TRANSCRIPT_FILE; +use rite_model::commitment::{SALT_LEN, chain_node, fact_leaf, header_node}; +use rite_model::{ + FACT_TYPES, Level, Sha256Digest, StepFact, TRANSCRIPT_FORMAT, TRANSCRIPT_SCHEMA, + TranscriptHeader, canonical_json, +}; -/// SHA-256 fingerprint produced by a [`TranscriptSink::finalize`] call. -/// -/// Encoded as `sha256:`, matching the convention used -/// throughout the runtime for artifact and transcript fingerprints. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TranscriptFingerprint(String); - -impl TranscriptFingerprint { - /// Build a fingerprint from an already-formatted `sha256:` string. - #[must_use] - pub fn from_string(s: String) -> Self { - Self(s) - } - - /// `sha256:` representation. - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } -} - -impl std::fmt::Display for TranscriptFingerprint { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.0) - } -} - -/// Genesis `prev_hash` value used on the very first line of every transcript. -/// -/// `sha256:` followed by 64 zero hex digits. -pub const GENESIS_HASH: &str = - "sha256:0000000000000000000000000000000000000000000000000000000000000000"; +/// A transcript's fingerprint: the `chain` of its last line. +pub type TranscriptFingerprint = Sha256Digest; /// Synchronous, durable observer of [`StepFact`]s. /// @@ -83,18 +78,29 @@ pub const GENESIS_HASH: &str = /// relies on this to maintain the invariant that the UI never sees a fact /// that has not been durably persisted. pub trait TranscriptSink: Send { - /// Record a single fact, stamped with the caller-supplied event time `at`. - /// Must persist before returning, the file-backed implementation calls - /// `sync_data` so a power loss after `record` returns cannot drop the fact. + /// Write the header line. Must be called once, before any fact. + /// + /// # Errors /// - /// `at` is supplied by the executor (the clock owner), not read here, so the - /// recorded time is independent of the persistence implementation and the - /// sink stays a deterministic serializer. + /// Returns an error if the header was already written, or the underlying + /// I/O error if the sink cannot persist it. + fn begin(&mut self, header: &TranscriptHeader) -> io::Result<()>; + + /// Record a single fact at `level`, stamped with the caller-supplied + /// event time `at`. Must persist before returning, the file-backed + /// implementation calls `sync_data` so a power loss after `record` returns + /// cannot drop the fact. + /// + /// `at` and `level` are supplied by the executor, not chosen here, so the + /// sink stays a serializer with no policy of its own. /// /// # Errors /// - /// Returns the underlying I/O error if the sink cannot persist the fact. - fn record(&mut self, at: DateTime, fact: &StepFact) -> io::Result<()>; + /// Returns an error if the header has not been written or the transcript + /// is finalized, if the header does not declare `level`, if the fact holds + /// a value the canonical form does not cover, or the underlying I/O error + /// if the sink cannot persist the fact. + fn record(&mut self, at: DateTime, level: Level, fact: &StepFact) -> io::Result<()>; /// Finalize the transcript and return its fingerprint. /// @@ -104,24 +110,148 @@ pub trait TranscriptSink: Send { /// /// # Errors /// - /// Returns the underlying I/O error if any pending state cannot be - /// persisted. + /// Returns an error if the header was never written, or the underlying + /// I/O error if any pending state cannot be persisted. fn finalize(&mut self) -> io::Result; } -/// JSONL file sink with SHA-256 chain integrity. +/// The chain as a sink builds it: the last node, the levels the header +/// declares, and whether the run is over. /// -/// Writes `transcript.jsonl` to a target directory. Every `record` call -/// serializes the fact, prepends the current chain head as `prev_hash`, -/// writes one line, flushes, and advances the chain head. The transcript -/// is self-identifying: `SHA-256` of the last line *is* the cryptographic -/// fingerprint; no sidecar file is written. +/// Committing to a line does not advance the chain. The sink advances it once +/// the line is persisted, so a failed write leaves the chain where the file is. +#[derive(Debug, Default)] +struct Chain { + node: Option<[u8; 32]>, + levels: Vec, + finalized: bool, +} + +/// A line committed to but not yet persisted: its canonical content and the +/// values the line records beside it. +enum Committed { + Header { + canonical: String, + node: [u8; 32], + levels: Vec, + }, + Fact { + at: String, + level: Level, + salt: [u8; SALT_LEN], + canonical: String, + leaf: [u8; 32], + node: [u8; 32], + }, +} + +impl Committed { + fn node(&self) -> [u8; 32] { + match self { + Committed::Header { node, .. } | Committed::Fact { node, .. } => *node, + } + } + + /// The line as written to `transcript.jsonl`. + fn line(&self) -> String { + match self { + Committed::Header { + canonical, node, .. + } => format!( + "{{\"$schema\":\"{TRANSCRIPT_SCHEMA}\",\"header\":{canonical},\"chain\":\"{}\"}}", + Sha256Digest::from_bytes(node), + ), + Committed::Fact { + at, + level, + salt, + canonical, + leaf, + node, + } => format!( + "{{\"at\":\"{at}\",\"level\":{},\"leaf\":\"{}\",\"chain\":\"{}\",\"salt\":\"{}\",\"fact\":{canonical}}}", + level.value(), + Sha256Digest::from_bytes(leaf), + Sha256Digest::from_bytes(node), + base16ct::lower::encode_string(salt), + ), + } + } +} + +impl Chain { + fn header(&self, header: &TranscriptHeader) -> io::Result { + if self.node.is_some() { + return Err(io::Error::other("transcript header already written")); + } + let value = serde_json::to_value(header).map_err(io::Error::other)?; + let canonical = canonical_json(&value).map_err(io::Error::other)?; + let node = header_node(&canonical); + Ok(Committed::Header { + canonical, + node, + levels: header.levels.values().copied().collect(), + }) + } + + fn fact(&self, at: DateTime, level: Level, fact: &StepFact) -> io::Result { + let previous = self + .node + .ok_or_else(|| io::Error::other("transcript header not written"))?; + if self.finalized { + return Err(io::Error::other("transcript already finalized")); + } + // A reader refuses a line at a level the header does not name. + if !self.levels.contains(&level) { + return Err(io::Error::other(format!( + "level {} is not declared in the transcript header", + level.value() + ))); + } + let value = serde_json::to_value(fact).map_err(io::Error::other)?; + let canonical = canonical_json(&value).map_err(io::Error::other)?; + let mut salt = [0u8; SALT_LEN]; + SysRng.try_fill_bytes(&mut salt).map_err(io::Error::other)?; + let leaf = fact_leaf(&salt, &canonical); + let at = at.to_rfc3339_opts(SecondsFormat::Micros, true); + let node = chain_node(&previous, &at, level, &leaf); + Ok(Committed::Fact { + at, + level, + salt, + canonical, + leaf, + node, + }) + } + + fn advance(&mut self, committed: &Committed) { + self.node = Some(committed.node()); + if let Committed::Header { levels, .. } = committed { + self.levels.clone_from(levels); + } + } + + fn finalize(&mut self) -> io::Result { + let node = self + .node + .ok_or_else(|| io::Error::other("transcript header not written"))?; + self.finalized = true; + Ok(Sha256Digest::from_bytes(&node)) + } +} + +/// JSONL file sink with a salted commitment chain. +/// +/// Writes `transcript.jsonl` to a target directory, one line per call, +/// flushed and synced before returning. The transcript is self-identifying: +/// the `chain` of its last line is the fingerprint, and no sidecar file is +/// written. #[derive(Debug)] pub struct JsonlFileSink { jsonl_path: PathBuf, writer: BufWriter, - current_hash: String, - finalized: Option, + chain: Chain, } impl JsonlFileSink { @@ -131,7 +261,7 @@ impl JsonlFileSink { /// /// Returns an I/O error if the JSONL file cannot be created. pub fn create(dir: &Path) -> io::Result { - let jsonl_path = dir.join("transcript.jsonl"); + let jsonl_path = dir.join(TRANSCRIPT_FILE); let file = OpenOptions::new() .create_new(true) .write(true) @@ -139,31 +269,13 @@ impl JsonlFileSink { Ok(Self { jsonl_path, writer: BufWriter::new(file), - current_hash: GENESIS_HASH.to_string(), - finalized: None, + chain: Chain::default(), }) } - /// Path to the JSONL file this sink is writing to. - #[must_use] - pub fn jsonl_path(&self) -> &Path { - &self.jsonl_path - } -} - -impl TranscriptSink for JsonlFileSink { - fn record(&mut self, at: DateTime, fact: &StepFact) -> io::Result<()> { - if self.finalized.is_some() { - return Err(io::Error::other("transcript already finalized")); - } - let line = ChainedFact { - prev_hash: &self.current_hash, - at, - fact, - }; - let bytes = serde_json::to_vec(&line).map_err(io::Error::other)?; - self.current_hash = compute_fingerprint(&bytes); - self.writer.write_all(&bytes)?; + /// Append one line and sync it to storage. + fn append(&mut self, line: &str) -> io::Result<()> { + self.writer.write_all(line.as_bytes())?; self.writer.write_all(b"\n")?; // `flush` drains the BufWriter into the File; `sync_data` then // forces the kernel to push the page-cache pages to the storage @@ -172,40 +284,59 @@ impl TranscriptSink for JsonlFileSink { // facts even though the executor moved on. Cost at ceremony pace // (one record every few seconds of human pace) is negligible. self.writer.flush()?; - self.writer.get_ref().sync_data()?; + self.writer.get_ref().sync_data() + } + + /// Path to the JSONL file this sink is writing to. + #[must_use] + pub fn jsonl_path(&self) -> &Path { + &self.jsonl_path + } +} + +impl TranscriptSink for JsonlFileSink { + fn begin(&mut self, header: &TranscriptHeader) -> io::Result<()> { + let committed = self.chain.header(header)?; + self.append(&committed.line())?; + self.chain.advance(&committed); + Ok(()) + } + + fn record(&mut self, at: DateTime, level: Level, fact: &StepFact) -> io::Result<()> { + let committed = self.chain.fact(at, level, fact)?; + self.append(&committed.line())?; + self.chain.advance(&committed); Ok(()) } fn finalize(&mut self) -> io::Result { - if let Some(existing) = &self.finalized { - return Ok(existing.clone()); - } - let fingerprint = TranscriptFingerprint(self.current_hash.clone()); - self.finalized = Some(fingerprint.clone()); - Ok(fingerprint) + self.chain.finalize() } } /// In-memory sink that collects every recorded [`StepFact`]. /// /// Useful for tests and for tooling that wants to inspect the fact stream -/// without going through disk. +/// without going through disk. It builds the same chain as the file sink, so +/// its fingerprint is the one the file would have. #[derive(Debug, Default)] pub struct InMemorySink { + header: Option, facts: Vec, - current_hash: String, - finalized: Option, + chain: Chain, } impl InMemorySink { /// Create an empty sink. #[must_use] pub fn new() -> Self { - Self { - facts: Vec::new(), - current_hash: GENESIS_HASH.to_string(), - finalized: None, - } + Self::default() + } + + /// The header, once written. + #[must_use] + pub fn header(&self) -> Option<&TranscriptHeader> { + self.header.as_ref() } /// Recorded facts in the order they arrived. @@ -228,62 +359,105 @@ impl InMemorySink { } impl TranscriptSink for InMemorySink { - fn record(&mut self, at: DateTime, fact: &StepFact) -> io::Result<()> { - if self.finalized.is_some() { - return Err(io::Error::other("transcript already finalized")); - } - let line = ChainedFact { - prev_hash: &self.current_hash, - at, - fact, - }; - let bytes = serde_json::to_vec(&line).map_err(io::Error::other)?; - self.current_hash = compute_fingerprint(&bytes); + fn begin(&mut self, header: &TranscriptHeader) -> io::Result<()> { + let committed = self.chain.header(header)?; + self.chain.advance(&committed); + self.header = Some(header.clone()); + Ok(()) + } + + fn record(&mut self, at: DateTime, level: Level, fact: &StepFact) -> io::Result<()> { + let committed = self.chain.fact(at, level, fact)?; + self.chain.advance(&committed); self.facts.push(fact.clone()); Ok(()) } fn finalize(&mut self) -> io::Result { - if let Some(existing) = &self.finalized { - return Ok(existing.clone()); - } - let fingerprint = TranscriptFingerprint(self.current_hash.clone()); - self.finalized = Some(fingerprint.clone()); - Ok(fingerprint) + self.chain.finalize() } } -#[derive(Serialize)] -struct ChainedFact<'a> { - prev_hash: &'a str, - at: DateTime, - fact: &'a StepFact, +/// The header line as read. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct HeaderLine { + /// Read so a line that carries it parses; nothing depends on it. + #[serde(rename = "$schema", default)] + _schema: Option, + header: serde_json::Value, + chain: Sha256Digest, } +/// A fact line as read, complete or withheld. #[derive(Deserialize)] -struct OwnedChainedFact { - prev_hash: String, - at: DateTime, - fact: StepFact, +#[serde(deny_unknown_fields)] +struct FactLine { + at: String, + level: Level, + #[serde(default, deserialize_with = "present")] + salt: Option, + #[serde(default, deserialize_with = "present")] + fact: Option, + leaf: Sha256Digest, + chain: Sha256Digest, } -/// A recorded fact paired with its envelope timestamp: the uniform event time, -/// which lives on the chain envelope rather than on individual facts. Returned -/// by [`read_verified_transcript`] so consumers read event time uniformly. +/// A member that is on the line is `Some`, `null` included, so only an absent +/// `salt` and `fact` make a withheld line, as the schema has it. +fn present<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, + T: Deserialize<'de>, +{ + T::deserialize(deserializer).map(Some) +} + +/// A recorded fact with its line's time and level. +/// +/// The event time lives on the line rather than on individual facts, so +/// consumers read it uniformly. #[derive(Debug, Clone)] pub struct TimedFact { + /// 1-based line number in the transcript. + pub line: usize, /// Wall-clock time the fact was recorded. pub at: DateTime, + /// Confidentiality level the line was recorded at. + pub level: Level, /// The recorded fact. pub fact: StepFact, } +/// A line whose fact is withheld: the transcript was disclosed without it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WithheldLine { + /// 1-based line number. + pub line: usize, + /// Level the withheld fact was recorded at. + pub level: Level, +} + +/// A line whose fact has a type this reader does not know, from a newer +/// vocabulary. Its commitment is checked; its content is not read. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnknownFact { + /// 1-based line number. + pub line: usize, + /// Level the line was recorded at. + pub level: Level, + /// The fact's `type` tag. + pub type_name: String, +} + /// Outcome of verifying a transcript on disk. #[derive(Debug, Clone)] pub struct TranscriptVerified { - /// Number of facts read and verified. + /// The header line. + pub header: TranscriptHeader, + /// Number of facts read and verified, the header not included. pub fact_count: usize, - /// Final transcript fingerprint (hash of the last line). + /// Final transcript fingerprint (the `chain` of the last line). pub fingerprint: TranscriptFingerprint, /// `true` if the last fact is a terminal one (`CeremonyCompleted` or /// `CeremonyFailed`). `false` means the transcript was cut off @@ -297,28 +471,52 @@ pub enum VerifyError { /// Underlying I/O error reading the transcript. #[error("io error: {0}")] Io(#[from] io::Error), - /// A line did not parse as a [`ChainedFact`]. - #[error("line {line} is not a valid chained fact: {reason}")] + /// A line did not parse as a transcript line. + #[error("line {line} is not a valid transcript line: {reason}")] InvalidLine { /// 1-indexed line number. line: usize, /// Parse error. reason: String, }, - /// The chain is broken: a line's `prev_hash` did not match the - /// expected value computed from the previous line. - #[error("broken chain at line {line}: expected prev_hash {expected}, got {actual}")] - BrokenChain { + /// A fact does not match the leaf recorded beside it: the fact, its + /// salt, or the way it was canonicalised differs from what was committed. + #[error( + "line {line}: the fact does not match its leaf (computed {computed}, recorded {recorded})" + )] + LeafMismatch { + /// 1-indexed line number. + line: usize, + /// Leaf computed from the salt and fact on the line. + computed: String, + /// Leaf recorded on the line. + recorded: String, + }, + /// A line's chain value is not the one its time, level, leaf and the + /// line before it produce. + #[error("line {line}: the chain breaks here (computed {computed}, recorded {recorded})")] + ChainMismatch { /// 1-indexed line number where the break was detected. line: usize, - /// The expected `prev_hash` (= hash of the previous line). - expected: String, - /// The `prev_hash` actually recorded on the line. - actual: String, + /// Chain value computed from the line and the one before. + computed: String, + /// Chain value recorded on the line. + recorded: String, }, /// The transcript file has zero lines. #[error("transcript is empty")] Empty, + /// The first line is not a transcript header. + #[error("line 1 is not a transcript header: {0}")] + MissingHeader(String), + /// The header's level table is unusable: a built-in level is missing or + /// moved, or two names share a value. + #[error("the header's levels are invalid: {0}")] + InvalidLevels(String), + /// The header declares a transcript format this verifier does not know. + /// The format fixes how every line is read, so nothing else is attempted. + #[error("unknown transcript format {0} (this verifier reads format {TRANSCRIPT_FORMAT})")] + UnknownFormat(u64), /// A value was drawn (or a contribution folded) before any /// `EntropySeeded` fact established the source. #[error("entropy source used before it was seeded")] @@ -391,89 +589,321 @@ pub enum VerifyError { /// Verify a JSONL transcript file produced by [`JsonlFileSink`]. /// -/// Reads the file line-by-line, recomputes each line's SHA-256, and -/// checks that `prev_hash` matches the previous line's hash. +/// Reads the file line by line, recomputes every leaf and chain value, and +/// checks each against the checkpoints recorded on the line. /// /// # Errors /// /// Returns [`VerifyError`] for any I/O failure, malformed line, or -/// chain break. +/// commitment mismatch. pub fn verify_transcript(jsonl_path: &Path) -> Result { let loaded = read_verified_transcript(jsonl_path)?; Ok(TranscriptVerified { + header: loaded.header, fact_count: loaded.facts.len(), fingerprint: loaded.fingerprint, terminated: loaded.terminated, }) } -/// Verified transcript contents, chain-walked facts plus the final -/// fingerprint and a flag for whether the run reached a terminal fact. +/// Verified transcript contents: the facts, what could not be read, the +/// fingerprint, and whether the run reached a terminal fact. #[derive(Debug, Clone)] pub struct LoadedTranscript { - /// Facts in the order they were recorded, each paired with the envelope - /// timestamp the sink stamped when it wrote the line. + /// The header line. + pub header: TranscriptHeader, + /// Facts in the order they were recorded, each with its line's time and + /// level. Withheld and unknown facts are not in this list. pub facts: Vec, - /// Final transcript fingerprint (hash of the last line). + /// Lines whose fact is withheld. + pub withheld: Vec, + /// Lines whose fact has a type this reader does not know. + pub unknown: Vec, + /// Final transcript fingerprint (the `chain` of the last line). pub fingerprint: TranscriptFingerprint, - /// `true` if the last fact is `CeremonyCompleted` or `CeremonyFailed`. + /// `true` if the last line is a disclosed `CeremonyCompleted` or + /// `CeremonyFailed`. pub terminated: bool, } /// Verify a JSONL transcript and return its facts. /// -/// Same chain check as [`verify_transcript`], plus returns the -/// deserialized [`StepFact`] stream and whether the last line is a -/// terminal fact. +/// Same check as [`verify_transcript`], plus returns the deserialized +/// [`StepFact`] stream, the withheld and unknown lines, and whether the last +/// line is a terminal fact. /// /// # Errors /// /// Same as [`verify_transcript`]. pub fn read_verified_transcript(jsonl_path: &Path) -> Result { let file = File::open(jsonl_path)?; - let reader = BufReader::new(file); + verify_lines(BufReader::new(file).lines()) +} - let mut expected_prev = GENESIS_HASH.to_string(); - let mut last_hash = expected_prev.clone(); - let mut facts: Vec = Vec::new(); +/// [`read_verified_transcript`] over a transcript already in memory. +/// +/// # Errors +/// +/// Same as [`verify_transcript`]. +pub fn verify_jsonl(jsonl: &str) -> Result { + verify_lines(jsonl.lines().map(|line| Ok(line.to_string()))) +} - for (idx, line) in reader.lines().enumerate() { +fn verify_lines( + mut lines: impl Iterator>, +) -> Result { + let first = lines.next().ok_or(VerifyError::Empty)??; + let (header, mut node) = read_header(&first)?; + + let mut loaded = LoadedTranscript { + header, + facts: Vec::new(), + withheld: Vec::new(), + unknown: Vec::new(), + fingerprint: Sha256Digest::from_bytes(&node), + terminated: false, + }; + + for (idx, line) in lines.enumerate() { let line = line?; - let line_no = idx.saturating_add(1); - let parsed: OwnedChainedFact = - serde_json::from_str(&line).map_err(|e| VerifyError::InvalidLine { - line: line_no, - reason: e.to_string(), + // Line 1 is the header; facts start on line 2. + let line_no = idx.saturating_add(2); + node = read_fact_line(&line, line_no, &node, &mut loaded)?; + } + + loaded.fingerprint = Sha256Digest::from_bytes(&node); + Ok(loaded) +} + +/// Withhold every fact recorded above `threshold`, keeping each line's +/// commitments, so the result folds to the same fingerprint. +/// +/// The header and every line at or below the threshold are copied unchanged. +/// A withheld line keeps its `at`, `level`, `leaf` and `chain` and loses its +/// `salt` and `fact`. Lines already withheld stay withheld. The input is not +/// verified here; verify it before, and the output after. +/// +/// # Errors +/// +/// Returns [`VerifyError::Empty`] for an empty transcript, or +/// [`VerifyError::InvalidLine`] for a fact line that does not parse. +pub fn disclose_transcript(jsonl: &str, threshold: Level) -> Result { + let mut lines = jsonl.lines(); + let header = lines.next().ok_or(VerifyError::Empty)?; + let mut out = String::with_capacity(jsonl.len()); + out.push_str(header); + out.push('\n'); + for (idx, line) in lines.enumerate() { + // Line 1 is the header; facts start on line 2. + let line_no = idx.saturating_add(2); + let parsed: FactLine = serde_json::from_str(line).map_err(|e| invalid(line_no, &e))?; + if parsed.level > threshold && parsed.fact.is_some() { + out.push_str(&withheld_line(&parsed)?); + } else { + out.push_str(line); + } + out.push('\n'); + } + Ok(out) +} + +/// The withheld form of a line: its commitments without its content. +fn withheld_line(line: &FactLine) -> Result { + let at = serde_json::to_string(&line.at).map_err(|e| invalid(0, &e))?; + Ok(format!( + "{{\"at\":{at},\"level\":{},\"leaf\":\"{}\",\"chain\":\"{}\"}}", + line.level.value(), + line.leaf, + line.chain, + )) +} + +/// Read and check the header line, returning the header and `node_0`. +fn read_header(line: &str) -> Result<(TranscriptHeader, [u8; 32]), VerifyError> { + let parsed: HeaderLine = + serde_json::from_str(line).map_err(|e| VerifyError::MissingHeader(e.to_string()))?; + // The format decides how everything else is read, so it is checked + // before the header's own commitment. + let format = parsed + .header + .get("rite_transcript") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| VerifyError::MissingHeader("no rite_transcript version".to_string()))?; + if format != u64::from(TRANSCRIPT_FORMAT) { + return Err(VerifyError::UnknownFormat(format)); + } + let canonical = canonical_json(&parsed.header).map_err(|e| invalid(1, &e))?; + let node = header_node(&canonical); + check_checkpoint(&parsed.chain, &node, |computed, recorded| { + VerifyError::ChainMismatch { + line: 1, + computed, + recorded, + } + })?; + let header: TranscriptHeader = serde_json::from_value(parsed.header) + .map_err(|e| VerifyError::MissingHeader(e.to_string()))?; + check_levels(&header)?; + Ok((header, node)) +} + +/// The built-in levels must appear at their fixed values, and no two names may +/// share a value, so every line's level has exactly one name. +fn check_levels(header: &TranscriptHeader) -> Result<(), VerifyError> { + for (name, level) in Level::BUILT_IN { + if header.levels.get(name) != Some(&level) { + return Err(VerifyError::InvalidLevels(format!( + "'{name}' must be {}", + level.value() + ))); + } + } + let mut seen = HashSet::new(); + for level in header.levels.values() { + if !seen.insert(*level) { + return Err(VerifyError::InvalidLevels(format!( + "more than one name for {}", + level.value() + ))); + } + } + Ok(()) +} + +/// Read and check one fact line, adding its fact to `loaded`, and return the +/// chain value after it. +fn read_fact_line( + line: &str, + line_no: usize, + previous: &[u8; 32], + loaded: &mut LoadedTranscript, +) -> Result<[u8; 32], VerifyError> { + let parsed: FactLine = serde_json::from_str(line).map_err(|e| invalid(line_no, &e))?; + if !loaded.header.declares(parsed.level) { + return Err(invalid( + line_no, + &format!( + "level {} is not declared in the header", + parsed.level.value() + ), + )); + } + let at: DateTime = DateTime::parse_from_rfc3339(&parsed.at) + .map_err(|e| invalid(line_no, &e))? + .with_timezone(&Utc); + + let leaf = match (parsed.salt, parsed.fact) { + (Some(salt), Some(fact)) => { + let salt = decode_salt(&salt).ok_or_else(|| { + invalid( + line_no, + &format!("salt is not {SALT_LEN} bytes of lowercase hex"), + ) + })?; + let canonical = canonical_json(&fact).map_err(|e| invalid(line_no, &e))?; + let leaf = fact_leaf(&salt, &canonical); + check_checkpoint(&parsed.leaf, &leaf, |computed, recorded| { + VerifyError::LeafMismatch { + line: line_no, + computed, + recorded, + } })?; - if parsed.prev_hash != expected_prev { - return Err(VerifyError::BrokenChain { + loaded.terminated = add_fact(fact, at, parsed.level, line_no, loaded)?; + leaf + } + (None, None) => { + loaded.withheld.push(WithheldLine { line: line_no, - expected: expected_prev, - actual: parsed.prev_hash, + level: parsed.level, }); + loaded.terminated = false; + parsed.leaf.to_bytes() + } + _ => { + return Err(invalid( + line_no, + &"a line carries both `salt` and `fact`, or neither", + )); + } + }; + + let node = chain_node(previous, &parsed.at, parsed.level, &leaf); + check_checkpoint(&parsed.chain, &node, |computed, recorded| { + VerifyError::ChainMismatch { + line: line_no, + computed, + recorded, } - last_hash = compute_fingerprint(line.as_bytes()); - expected_prev.clone_from(&last_hash); - facts.push(TimedFact { - at: parsed.at, - fact: parsed.fact, + })?; + Ok(node) +} + +/// Parse a disclosed fact into `loaded`, returning whether it is terminal. +/// +/// A fact whose type this vocabulary does not know is counted rather than +/// read; a known type that does not parse is an error. +fn add_fact( + fact: serde_json::Value, + at: DateTime, + level: Level, + line_no: usize, + loaded: &mut LoadedTranscript, +) -> Result { + let type_name = fact + .get("type") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| invalid(line_no, &"the fact has no `type`"))? + .to_string(); + if !FACT_TYPES.contains(&type_name.as_str()) { + loaded.unknown.push(UnknownFact { + line: line_no, + level, + type_name, }); + return Ok(false); } + let fact: StepFact = serde_json::from_value(fact).map_err(|e| invalid(line_no, &e))?; + let terminal = matches!( + fact, + StepFact::CeremonyCompleted { .. } | StepFact::CeremonyFailed { .. } + ); + loaded.facts.push(TimedFact { + line: line_no, + at, + level, + fact, + }); + Ok(terminal) +} - if facts.is_empty() { - return Err(VerifyError::Empty); - } +fn decode_salt(hex: &str) -> Option<[u8; SALT_LEN]> { + let mut salt = [0u8; SALT_LEN]; + let decoded = base16ct::lower::decode(hex, &mut salt).ok()?; + (decoded.len() == SALT_LEN).then_some(salt) +} - let terminated = matches!( - facts.last().map(|t| &t.fact), - Some(StepFact::CeremonyCompleted { .. } | StepFact::CeremonyFailed { .. }), - ); +/// Compare a computed value against the checkpoint recorded on a line. +fn check_checkpoint( + recorded: &Sha256Digest, + computed: &[u8; 32], + mismatch: impl FnOnce(String, String) -> VerifyError, +) -> Result<(), VerifyError> { + if recorded.to_bytes() == *computed { + Ok(()) + } else { + Err(mismatch( + Sha256Digest::from_bytes(computed).to_string(), + recorded.to_string(), + )) + } +} - Ok(LoadedTranscript { - facts, - fingerprint: TranscriptFingerprint(last_hash), - terminated, - }) +fn invalid(line: usize, reason: &impl std::fmt::Display) -> VerifyError { + VerifyError::InvalidLine { + line, + reason: reason.to_string(), + } } /// Outcome of re-deriving a transcript's entropy source. @@ -621,16 +1051,22 @@ mod tests { use crate::test_support::fixed_test_time as test_at; fn sample_fact() -> StepFact { - StepFact::CeremonyStarted { - name: "Test".to_string(), - } + crate::test_support::ceremony_started("Test") + } + + fn begun_file_sink(dir: &Path) -> JsonlFileSink { + let mut sink = JsonlFileSink::create(dir).expect("create"); + sink.begin(&crate::test_support::test_header()) + .expect("header"); + sink } #[test] fn in_memory_sink_records_and_finalizes() { - let mut sink = InMemorySink::new(); + let mut sink = crate::test_support::begun_sink(); assert!(sink.is_empty()); - sink.record(test_at(), &sample_fact()).expect("record"); + sink.record(test_at(), Level::PUBLIC, &sample_fact()) + .expect("record"); assert_eq!(sink.len(), 1); let fp = sink.finalize().expect("finalize"); assert!(fp.as_str().starts_with("sha256:")); @@ -638,24 +1074,27 @@ mod tests { #[test] fn in_memory_sink_rejects_record_after_finalize() { - let mut sink = InMemorySink::new(); - sink.record(test_at(), &sample_fact()).expect("record"); + let mut sink = crate::test_support::begun_sink(); + sink.record(test_at(), Level::PUBLIC, &sample_fact()) + .expect("record"); sink.finalize().expect("finalize"); let err = sink - .record(test_at(), &sample_fact()) + .record(test_at(), Level::PUBLIC, &sample_fact()) .expect_err("should fail"); assert!(err.to_string().contains("already finalized")); } #[test] fn in_memory_sink_chain_advances() { - let mut sink = InMemorySink::new(); - let initial = sink.current_hash.clone(); - sink.record(test_at(), &sample_fact()).expect("record"); - assert_ne!(sink.current_hash, initial); - let after_first = sink.current_hash.clone(); - sink.record(test_at(), &sample_fact()).expect("record"); - assert_ne!(sink.current_hash, after_first); + let mut sink = crate::test_support::begun_sink(); + let initial = sink.chain.node; + sink.record(test_at(), Level::PUBLIC, &sample_fact()) + .expect("record"); + assert_ne!(sink.chain.node, initial); + let after_first = sink.chain.node; + sink.record(test_at(), Level::PUBLIC, &sample_fact()) + .expect("record"); + assert_ne!(sink.chain.node, after_first); } #[test] @@ -663,9 +1102,10 @@ mod tests { // The sink records the `at` it is given (it does not read a clock), and // the reader recovers exactly that instant from the envelope. let tmp = tempfile::tempdir().expect("tempdir"); - let mut sink = JsonlFileSink::create(tmp.path()).expect("create"); + let mut sink = begun_file_sink(tmp.path()); let at = test_at(); - sink.record(at, &sample_fact()).expect("record"); + sink.record(at, Level::PUBLIC, &sample_fact()) + .expect("record"); sink.finalize().expect("finalize"); let loaded = read_verified_transcript(sink.jsonl_path()).expect("read"); @@ -673,32 +1113,59 @@ mod tests { } #[test] - fn jsonl_file_sink_writes_and_chains() { + fn jsonl_file_sink_writes_checkpoints_that_fold_to_the_fingerprint() { let tmp = tempfile::tempdir().expect("tempdir"); - let mut sink = JsonlFileSink::create(tmp.path()).expect("create"); - sink.record(test_at(), &sample_fact()).expect("record"); - sink.record(test_at(), &sample_fact()).expect("record"); + let mut sink = begun_file_sink(tmp.path()); + sink.record(test_at(), Level::PUBLIC, &sample_fact()) + .expect("record"); + sink.record(test_at(), Level::RESTRICTED, &sample_fact()) + .expect("record"); let fp = sink.finalize().expect("finalize"); let jsonl = std::fs::read_to_string(sink.jsonl_path()).expect("read jsonl"); - let mut lines = jsonl.lines(); - let raw_first = lines.next().expect("first line"); - let raw_second = lines.next().expect("second line"); - assert!(lines.next().is_none(), "expected exactly two lines"); - - let first: serde_json::Value = serde_json::from_str(raw_first).expect("parse first"); - let second: serde_json::Value = serde_json::from_str(raw_second).expect("parse second"); + let lines: Vec = jsonl + .lines() + .map(|l| serde_json::from_str(l).expect("parse line")) + .collect(); + assert_eq!(lines.len(), 3, "expected a header and two facts"); + assert!(lines.first().and_then(|l| l.get("header")).is_some()); + let second = lines.get(2).expect("second fact"); assert_eq!( - first.get("prev_hash").and_then(serde_json::Value::as_str), - Some(GENESIS_HASH), + second.get("level").and_then(serde_json::Value::as_u64), + Some(20) ); - let first_hash = compute_fingerprint(raw_first.as_bytes()); assert_eq!( - second.get("prev_hash").and_then(serde_json::Value::as_str), - Some(first_hash.as_str()), + lines + .last() + .and_then(|l| l.get("chain")) + .and_then(serde_json::Value::as_str), + Some(fp.as_str()), + "the last chain value is the fingerprint" ); - let second_hash = compute_fingerprint(raw_second.as_bytes()); - assert_eq!(fp.as_str(), second_hash); + } + + #[test] + fn two_records_of_the_same_fact_get_different_salts() { + let tmp = tempfile::tempdir().expect("tempdir"); + let mut sink = begun_file_sink(tmp.path()); + sink.record(test_at(), Level::PUBLIC, &sample_fact()) + .expect("record"); + sink.record(test_at(), Level::PUBLIC, &sample_fact()) + .expect("record"); + let jsonl = std::fs::read_to_string(sink.jsonl_path()).expect("read jsonl"); + let salts: Vec = jsonl + .lines() + .skip(1) + .map(|l| { + let v: serde_json::Value = serde_json::from_str(l).expect("parse"); + v.get("salt") + .and_then(serde_json::Value::as_str) + .expect("salt") + .to_string() + }) + .collect(); + assert_eq!(salts.len(), 2); + assert_ne!(salts.first(), salts.last()); } #[test] @@ -712,10 +1179,13 @@ mod tests { #[test] fn verify_round_trip_succeeds_on_well_formed_transcript() { let tmp = tempfile::tempdir().expect("tempdir"); - let mut sink = JsonlFileSink::create(tmp.path()).expect("create"); - sink.record(test_at(), &sample_fact()).expect("record"); - sink.record(test_at(), &sample_fact()).expect("record"); - sink.record(test_at(), &sample_fact()).expect("record"); + let mut sink = begun_file_sink(tmp.path()); + sink.record(test_at(), Level::PUBLIC, &sample_fact()) + .expect("record"); + sink.record(test_at(), Level::PUBLIC, &sample_fact()) + .expect("record"); + sink.record(test_at(), Level::PUBLIC, &sample_fact()) + .expect("record"); let written = sink.finalize().expect("finalize"); let verified = verify_transcript(sink.jsonl_path()).expect("verify"); @@ -725,24 +1195,208 @@ mod tests { assert!(!verified.terminated); } + /// Write a transcript of a header and three facts, then return its + /// path and lines for a test to edit. + fn three_fact_transcript(dir: &Path) -> (PathBuf, Vec) { + let mut sink = begun_file_sink(dir); + for _ in 0..3 { + sink.record(test_at(), Level::PUBLIC, &sample_fact()) + .expect("record"); + } + let path = sink.jsonl_path().to_path_buf(); + let lines = std::fs::read_to_string(&path) + .expect("read") + .lines() + .map(String::from) + .collect(); + (path, lines) + } + + fn rewrite(path: &Path, lines: &[String]) { + std::fs::write(path, lines.join("\n") + "\n").expect("write"); + } + + fn edit_line(lines: &mut [String], index: usize, edit: impl FnOnce(&mut serde_json::Value)) { + let line = lines.get_mut(index).expect("line"); + let mut value: serde_json::Value = serde_json::from_str(line).expect("parse"); + edit(&mut value); + *line = serde_json::to_string(&value).expect("serialize"); + } + #[test] - fn verify_detects_chain_break() { + fn an_edited_fact_is_caught_at_its_leaf() { let tmp = tempfile::tempdir().expect("tempdir"); - let mut sink = JsonlFileSink::create(tmp.path()).expect("create"); - sink.record(test_at(), &sample_fact()).expect("record"); - sink.record(test_at(), &sample_fact()).expect("record"); - let _ = sink.finalize().expect("finalize"); + let (path, mut lines) = three_fact_transcript(tmp.path()); + edit_line(&mut lines, 2, |v| { + if let Some(fact) = v.get_mut("fact").and_then(serde_json::Value::as_object_mut) { + fact.insert("name".to_string(), serde_json::json!("Other")); + } + }); + rewrite(&path, &lines); + let err = verify_transcript(&path).expect_err("tampered fact"); + assert!(matches!(err, VerifyError::LeafMismatch { line: 3, .. })); + } - let path = sink.jsonl_path().to_path_buf(); - let content = std::fs::read_to_string(&path).expect("read"); - let mut lines: Vec = content.lines().map(String::from).collect(); - if let Some(second) = lines.get_mut(1) { - *second = second.replace("prev_hash\":\"sha256:", "prev_hash\":\"sha256:ff"); + #[test] + fn an_edited_level_is_caught_at_its_chain_value() { + let tmp = tempfile::tempdir().expect("tempdir"); + let (path, mut lines) = three_fact_transcript(tmp.path()); + edit_line(&mut lines, 2, |v| { + if let Some(obj) = v.as_object_mut() { + obj.insert("level".to_string(), serde_json::json!(30)); + } + }); + rewrite(&path, &lines); + let err = verify_transcript(&path).expect_err("tampered level"); + assert!(matches!(err, VerifyError::ChainMismatch { line: 3, .. })); + } + + #[test] + fn a_deleted_line_breaks_the_chain_at_the_next_one() { + let tmp = tempfile::tempdir().expect("tempdir"); + let (path, mut lines) = three_fact_transcript(tmp.path()); + lines.remove(2); + rewrite(&path, &lines); + let err = verify_transcript(&path).expect_err("deleted line"); + assert!(matches!(err, VerifyError::ChainMismatch { line: 3, .. })); + } + + #[test] + fn a_withheld_line_folds_to_the_same_fingerprint() { + let tmp = tempfile::tempdir().expect("tempdir"); + let (path, mut lines) = three_fact_transcript(tmp.path()); + let complete = verify_transcript(&path).expect("complete").fingerprint; + edit_line(&mut lines, 2, |v| { + if let Some(obj) = v.as_object_mut() { + obj.remove("salt"); + obj.remove("fact"); + } + }); + rewrite(&path, &lines); + let loaded = read_verified_transcript(&path).expect("withheld"); + assert_eq!(loaded.fingerprint, complete); + assert_eq!( + loaded.withheld, + vec![WithheldLine { + line: 3, + level: Level::PUBLIC + }] + ); + assert_eq!(loaded.facts.len(), 2); + } + + #[test] + fn disclosing_withholds_above_the_threshold_and_keeps_the_fingerprint() { + let tmp = tempfile::tempdir().expect("tempdir"); + let mut sink = begun_file_sink(tmp.path()); + for level in [Level::PUBLIC, Level::CONFIDENTIAL, Level::RESTRICTED] { + sink.record(test_at(), level, &sample_fact()) + .expect("record"); } - std::fs::write(&path, lines.join("\n") + "\n").expect("write"); + let complete = sink.finalize().expect("finalize"); + let text = std::fs::read_to_string(sink.jsonl_path()).expect("read"); + + let disclosed = disclose_transcript(&text, Level::RESTRICTED).expect("disclose"); + let path = tmp.path().join("disclosed.jsonl"); + std::fs::write(&path, &disclosed).expect("write"); + let loaded = read_verified_transcript(&path).expect("verify"); + + assert_eq!(loaded.fingerprint, complete); + assert_eq!( + loaded.withheld, + vec![WithheldLine { + line: 3, + level: Level::CONFIDENTIAL + }] + ); + assert_eq!(loaded.facts.len(), 2); + // Disclosing again at the same threshold changes nothing. + assert_eq!( + disclose_transcript(&disclosed, Level::RESTRICTED).expect("again"), + disclosed + ); + } + + #[test] + fn a_line_with_a_fact_and_no_salt_is_invalid() { + let tmp = tempfile::tempdir().expect("tempdir"); + let (path, mut lines) = three_fact_transcript(tmp.path()); + edit_line(&mut lines, 2, |v| { + if let Some(obj) = v.as_object_mut() { + obj.remove("salt"); + } + }); + rewrite(&path, &lines); + let err = verify_transcript(&path).expect_err("half-withheld line"); + assert!(matches!(err, VerifyError::InvalidLine { line: 3, .. })); + } - let err = verify_transcript(&path).expect_err("should detect tamper"); - assert!(matches!(err, VerifyError::BrokenChain { .. })); + #[test] + fn a_line_with_null_salt_and_fact_is_not_withheld() { + let tmp = tempfile::tempdir().expect("tempdir"); + let (path, mut lines) = three_fact_transcript(tmp.path()); + edit_line(&mut lines, 2, |v| { + if let Some(obj) = v.as_object_mut() { + obj.insert("salt".to_string(), serde_json::Value::Null); + obj.insert("fact".to_string(), serde_json::Value::Null); + } + }); + rewrite(&path, &lines); + let err = verify_transcript(&path).expect_err("null members"); + assert!(matches!(err, VerifyError::InvalidLine { line: 3, .. })); + } + + #[test] + fn an_unknown_fact_type_is_counted_not_refused() { + let tmp = tempfile::tempdir().expect("tempdir"); + let mut sink = begun_file_sink(tmp.path()); + sink.record(test_at(), Level::PUBLIC, &sample_fact()) + .expect("record"); + let path = sink.jsonl_path().to_path_buf(); + let mut lines: Vec = std::fs::read_to_string(&path) + .expect("read") + .lines() + .map(String::from) + .collect(); + // A fact from a newer vocabulary, committed correctly. + let previous: serde_json::Value = + serde_json::from_str(lines.last().expect("last")).expect("parse"); + let previous_node = Sha256Digest::parse( + previous + .get("chain") + .and_then(serde_json::Value::as_str) + .expect("chain"), + ) + .expect("digest") + .to_bytes(); + let fact = serde_json::json!({ "type": "future_fact", "detail": 1 }); + let salt = [7u8; SALT_LEN]; + let leaf = fact_leaf(&salt, &canonical_json(&fact).expect("canonical")); + let at = "2026-09-22T10:00:00.000000Z"; + let node = chain_node(&previous_node, at, Level::PUBLIC, &leaf); + lines.push( + serde_json::json!({ + "at": at, + "level": 10, + "salt": base16ct::lower::encode_string(&salt), + "fact": fact, + "leaf": Sha256Digest::from_bytes(&leaf).as_str(), + "chain": Sha256Digest::from_bytes(&node).as_str(), + }) + .to_string(), + ); + rewrite(&path, &lines); + + let loaded = read_verified_transcript(&path).expect("read"); + assert_eq!( + loaded.unknown, + vec![UnknownFact { + line: 3, + level: Level::PUBLIC, + type_name: "future_fact".to_string() + }] + ); + assert_eq!(loaded.facts.len(), 1); } #[test] @@ -754,6 +1408,145 @@ mod tests { assert!(matches!(err, VerifyError::Empty)); } + #[test] + fn a_fact_before_the_header_is_refused() { + let mut sink = InMemorySink::new(); + let err = sink + .record(test_at(), Level::PUBLIC, &sample_fact()) + .expect_err("no header yet"); + assert!(err.to_string().contains("header not written")); + } + + #[test] + fn a_second_header_is_refused() { + let mut sink = crate::test_support::begun_sink(); + let err = sink + .begin(&crate::test_support::test_header()) + .expect_err("second header"); + assert!(err.to_string().contains("already written")); + } + + #[test] + fn the_reader_returns_the_header() { + let tmp = tempfile::tempdir().expect("tempdir"); + let mut sink = begun_file_sink(tmp.path()); + sink.record(test_at(), Level::PUBLIC, &sample_fact()) + .expect("record"); + let loaded = read_verified_transcript(sink.jsonl_path()).expect("read"); + assert_eq!(loaded.header, crate::test_support::test_header()); + assert_eq!(loaded.facts.len(), 1); + } + + /// Write a transcript whose header is `header`, with a correct header + /// commitment and no facts. + fn transcript_with_header(dir: &Path, header: &TranscriptHeader) -> PathBuf { + let value = serde_json::to_value(header).expect("header json"); + let node = header_node(&canonical_json(&value).expect("canonical")); + let line = serde_json::json!({ + "header": value, + "chain": Sha256Digest::from_bytes(&node).as_str(), + }) + .to_string(); + let path = dir.join("transcript.jsonl"); + std::fs::write(&path, format!("{line}\n")).expect("write"); + path + } + + #[test] + fn an_unknown_format_is_refused() { + let tmp = tempfile::tempdir().expect("tempdir"); + let mut header = crate::test_support::test_header(); + header.rite_transcript = 2; + let path = transcript_with_header(tmp.path(), &header); + let err = verify_transcript(&path).expect_err("unknown format"); + assert!(matches!(err, VerifyError::UnknownFormat(2))); + } + + #[test] + fn a_newer_vocabulary_is_read() { + let tmp = tempfile::tempdir().expect("tempdir"); + let mut header = crate::test_support::test_header(); + header.vocabulary = 9; + let path = transcript_with_header(tmp.path(), &header); + let loaded = read_verified_transcript(&path).expect("newer vocabulary"); + assert_eq!(loaded.header.vocabulary, 9); + } + + #[test] + fn a_moved_built_in_level_is_refused() { + let tmp = tempfile::tempdir().expect("tempdir"); + let mut header = crate::test_support::test_header(); + header.levels.insert("public".to_string(), Level::new(5)); + let path = transcript_with_header(tmp.path(), &header); + let err = verify_transcript(&path).expect_err("moved built-in"); + assert!(matches!(err, VerifyError::InvalidLevels(_))); + } + + #[test] + fn a_line_at_an_undeclared_level_is_refused() { + let tmp = tempfile::tempdir().expect("tempdir"); + let (path, mut lines) = three_fact_transcript(tmp.path()); + edit_line(&mut lines, 2, |v| { + if let Some(obj) = v.as_object_mut() { + obj.insert("level".to_string(), 15.into()); + } + }); + rewrite(&path, &lines); + let err = verify_transcript(&path).expect_err("undeclared level"); + assert!( + matches!(err, VerifyError::InvalidLine { line: 3, .. }), + "{err}" + ); + } + + #[test] + fn the_sink_refuses_a_level_the_header_does_not_declare() { + let tmp = tempfile::tempdir().expect("tempdir"); + let mut sink = begun_file_sink(tmp.path()); + let err = sink + .record(test_at(), Level::new(15), &sample_fact()) + .expect_err("undeclared level"); + assert!(err.to_string().contains("not declared"), "{err}"); + // Nothing was written, and the chain did not move. + sink.record(test_at(), Level::PUBLIC, &sample_fact()) + .expect("record"); + let loaded = read_verified_transcript(sink.jsonl_path()).expect("verify"); + assert_eq!(loaded.facts.len(), 1); + } + + #[test] + fn a_declared_organisation_level_is_accepted() { + let tmp = tempfile::tempdir().expect("tempdir"); + let mut header = crate::test_support::test_header(); + header.levels.insert("partners".to_string(), Level::new(15)); + let mut sink = JsonlFileSink::create(tmp.path()).expect("create"); + sink.begin(&header).expect("header"); + sink.record(test_at(), Level::new(15), &sample_fact()) + .expect("record"); + let loaded = read_verified_transcript(sink.jsonl_path()).expect("read"); + assert_eq!(loaded.facts.first().map(|t| t.level), Some(Level::new(15))); + } + + #[test] + fn an_edited_header_is_caught() { + let tmp = tempfile::tempdir().expect("tempdir"); + let path = transcript_with_header(tmp.path(), &crate::test_support::test_header()); + let text = std::fs::read_to_string(&path).expect("read"); + std::fs::write(&path, text.replace("\"dry_run\":false", "\"dry_run\":true")) + .expect("write"); + let err = verify_transcript(&path).expect_err("edited header"); + assert!(matches!(err, VerifyError::ChainMismatch { line: 1, .. })); + } + + #[test] + fn a_transcript_starting_with_a_fact_is_refused() { + let tmp = tempfile::tempdir().expect("tempdir"); + let (path, lines) = three_fact_transcript(tmp.path()); + rewrite(&path, lines.get(1..).expect("facts")); + let err = verify_transcript(&path).expect_err("no header"); + assert!(matches!(err, VerifyError::MissingHeader(_))); + } + use rite_model::StepId; fn seeded_fact(m: &[u8]) -> StepFact { diff --git a/crates/rite-stdlib/src/crypto/decrypt_data.rs b/crates/rite-stdlib/src/crypto/decrypt_data.rs index 6411d39..67f0768 100644 --- a/crates/rite-stdlib/src/crypto/decrypt_data.rs +++ b/crates/rite-stdlib/src/crypto/decrypt_data.rs @@ -1,6 +1,6 @@ //! `decrypt_data` action, recover the content of an encrypted-data artifact. -use rite_model::{ActionType, StepFact}; +use rite_model::ActionType; use rite_runtime::{ Action, ActionError, ArtifactValue, HandlerContext, Icon, Reporter, StepInfo, StepResult, compute_fingerprint, resolve_backend_key, @@ -91,7 +91,7 @@ impl Action for DecryptDataAction { format!("Decrypting '{}' under {scheme}...", data_ref.display_name()), )?; - let (transport, backend_name, backend_fingerprint) = + let (transport, _) = crate::crypto::transport_backend(backend, &key_backend, "open a data key")?; // Taken from the container rather than assumed. The reader admits only @@ -115,21 +115,20 @@ impl Action for DecryptDataAction { reporter.log(Icon::Checkmark, "Content decrypted")?; - reporter.fact(StepFact::BackendOperation { - step: step.id.clone(), - kind: "decrypt_data".to_string(), - inputs: json!({ + reporter.backend_operation( + "decrypt_data", + json!({ "scheme": scheme.to_string(), "encrypted_data": data_ref.display_name(), "encrypted_data_fingerprint": data_fingerprint, "decryption_key": key_ref.display_name(), "decryption_key_check_value": check_value.to_string(), }), - outputs: produced(&backend_name, &backend_fingerprint, plaintext.len()), + produced(plaintext.len()), // The artifact that was opened, which is what links this step to // the encrypt that produced it. The content names itself nowhere. - fingerprint: Some(data_fingerprint), - })?; + Some(data_fingerprint), + )?; let message = format!("{} bytes decrypted", plaintext.len()); // The buffer moves from the cipher's wiping wrapper into the artifact's @@ -160,10 +159,8 @@ impl Action for DecryptDataAction { /// protect, and hashing it would put a digest of a secret in the record. What /// links this step to the encrypt that produced it is the artifact fingerprint, /// which the fact carries on its own. -fn produced(backend_name: &str, backend_fingerprint: &str, content_bytes: usize) -> Value { +fn produced(content_bytes: usize) -> Value { json!({ - "backend": backend_name, - "backend_fingerprint": backend_fingerprint, "content_bytes": content_bytes, }) } diff --git a/crates/rite-stdlib/src/crypto/encrypt_data.rs b/crates/rite-stdlib/src/crypto/encrypt_data.rs index 2226e39..dadaf6c 100644 --- a/crates/rite-stdlib/src/crypto/encrypt_data.rs +++ b/crates/rite-stdlib/src/crypto/encrypt_data.rs @@ -1,6 +1,6 @@ //! `encrypt_data` action, encrypt content for a key the backend holds. -use rite_model::{ActionType, StepFact}; +use rite_model::ActionType; use rite_runtime::{ Action, ActionError, ArtifactValue, HandlerContext, Icon, Reporter, StepInfo, StepResult, compute_fingerprint, parse_params, resolve_artifact_bytes, resolve_backend_key, @@ -80,7 +80,7 @@ impl Action for EncryptDataAction { ), )?; - let (transport, backend_name, backend_fingerprint) = + let (transport, _) = crate::crypto::transport_backend(backend, &key_backend, "protect a data key")?; // The backend makes the content-encryption key and the copy only the @@ -92,10 +92,9 @@ impl Action for EncryptDataAction { let fingerprint = compute_fingerprint(encrypted.data()); reporter.log(Icon::Checkmark, "Content encrypted")?; - reporter.fact(StepFact::BackendOperation { - step: step.id.clone(), - kind: "encrypt_data".to_string(), - inputs: json!({ + reporter.backend_operation( + "encrypt_data", + json!({ "scheme": scheme.to_string(), "data": data_ref.display_name(), // How much was encrypted, and deliberately no digest of it. @@ -105,14 +104,9 @@ impl Action for EncryptDataAction { "encryption_key": key_ref.display_name(), "encryption_key_check_value": check_value.to_string(), }), - outputs: produced( - &backend_name, - &backend_fingerprint, - &fingerprint, - &encrypted, - ), - fingerprint: Some(fingerprint.clone()), - })?; + produced(&fingerprint, &encrypted), + Some(fingerprint.clone()), + )?; let message = format!("{} bytes encrypted under {scheme}", payload.len()); let value = ArtifactValue::EncryptedData(encrypted); @@ -179,15 +173,8 @@ fn seal_into_container( /// The description is read out of the artifact and recorded whole, so a /// verifier compares the one it re-derives from the blob against this one and a /// field added to it is checked without touching either side. -fn produced( - backend_name: &str, - backend_fingerprint: &str, - fingerprint: &str, - encrypted: &EncryptedData, -) -> serde_json::Value { +fn produced(fingerprint: &str, encrypted: &EncryptedData) -> serde_json::Value { json!({ - "backend": backend_name, - "backend_fingerprint": backend_fingerprint, "encrypted_data_fingerprint": fingerprint, "encryption": encrypted.description(), }) diff --git a/crates/rite-stdlib/src/crypto/export_public.rs b/crates/rite-stdlib/src/crypto/export_public.rs index d873e87..73a597f 100644 --- a/crates/rite-stdlib/src/crypto/export_public.rs +++ b/crates/rite-stdlib/src/crypto/export_public.rs @@ -1,6 +1,6 @@ //! `export_public` action, export a public key from a backend keypair. -use rite_model::{ActionType, StepFact, StepInputs}; +use rite_model::{ActionType, StepInputs}; use rite_runtime::{ Action, ActionError, ArtifactValue, HandlerContext, Icon, Reporter, StepInfo, StepResult, compute_fingerprint, resolve_backend_key, @@ -62,13 +62,12 @@ impl Action for ExportPublicAction { let fingerprint = compute_fingerprint(exported.as_bytes()); let public_key = ArtifactValue::PublicKey(exported); - reporter.fact(StepFact::BackendOperation { - step: step.id.clone(), - kind: "export_public".to_string(), - inputs: json!({ "source_artifact": source_ref.display_name() }), - outputs: json!({ "exported_key_fingerprint": fingerprint }), - fingerprint: Some(fingerprint), - })?; + reporter.backend_operation( + "export_public", + json!({ "source_artifact": source_ref.display_name() }), + json!({ "exported_key_fingerprint": fingerprint }), + Some(fingerprint), + )?; if let Some(produces) = &step.produces { reporter.log( diff --git a/crates/rite-stdlib/src/crypto/generate_key.rs b/crates/rite-stdlib/src/crypto/generate_key.rs index 39506b5..0fa172a 100644 --- a/crates/rite-stdlib/src/crypto/generate_key.rs +++ b/crates/rite-stdlib/src/crypto/generate_key.rs @@ -1,6 +1,6 @@ //! `generate_key` action, produce a key through a backend. -use rite_model::{ActionType, StepFact}; +use rite_model::ActionType; use rite_runtime::{ Action, ActionError, ArtifactValue, HandlerContext, Icon, Reporter, StepInfo, StepResult, compute_fingerprint, parse_params, @@ -67,7 +67,6 @@ impl Action for GenerateKeyAction { })?; let backend_name = backend.name().to_string(); - let backend_fingerprint = backend.fingerprint(); let keystore = backend.as_keystore_mut().ok_or_else(|| { ActionError::Failed(format!( @@ -106,18 +105,12 @@ impl Action for GenerateKeyAction { check_value: metadata.check_value.clone(), }; - reporter.fact(StepFact::BackendOperation { - step: step.id.clone(), - kind: "generate_key".to_string(), - inputs: requested(&typed, &policy), - outputs: produced( - &backend_name, - &backend_fingerprint, - &metadata, - public_key_fingerprint.as_deref(), - ), - fingerprint: public_key_fingerprint, - })?; + reporter.backend_operation( + "generate_key", + requested(&typed, &policy), + produced(&metadata, public_key_fingerprint.as_deref()), + public_key_fingerprint, + )?; let message = format!("{} {kind} generated", typed.algorithm); if let Some(produces) = &step.produces { @@ -155,18 +148,8 @@ fn requested(typed: &GenerateKeyParams, policy: &KeyPolicy) -> serde_json::Value /// A keypair is named by the fingerprint of its public half and a symmetric /// key by its check value. Without one or the other the record says a key was /// made and nothing that identifies which. -fn produced( - backend_name: &str, - backend_fingerprint: &str, - metadata: &KeyMetadata, - public_key_fingerprint: Option<&str>, -) -> serde_json::Value { +fn produced(metadata: &KeyMetadata, public_key_fingerprint: Option<&str>) -> serde_json::Value { let mut outputs = serde_json::Map::new(); - outputs.insert("backend".to_string(), backend_name.into()); - outputs.insert( - "backend_fingerprint".to_string(), - backend_fingerprint.into(), - ); outputs.insert("key_id".to_string(), metadata.key_id.as_str().into()); if let Some(fingerprint) = public_key_fingerprint { outputs.insert("public_key_fingerprint".to_string(), fingerprint.into()); diff --git a/crates/rite-stdlib/src/crypto/import_key.rs b/crates/rite-stdlib/src/crypto/import_key.rs index e9e8937..e543fe0 100644 --- a/crates/rite-stdlib/src/crypto/import_key.rs +++ b/crates/rite-stdlib/src/crypto/import_key.rs @@ -1,6 +1,6 @@ //! `import_key` action, lift bytes the ceremony holds into a backend key. -use rite_model::{ActionType, ArtifactRef, StepFact}; +use rite_model::{ActionType, ArtifactRef}; use rite_runtime::{ Action, ActionError, ArtifactValue, HandlerContext, Icon, Reporter, StepInfo, StepResult, compute_fingerprint, parse_params, resolve_artifact_bytes, @@ -79,7 +79,6 @@ impl Action for ImportKeyAction { let backend = backend .ok_or_else(|| ActionError::Failed("Backend required to import a key".to_string()))?; let backend_name = backend.name().to_string(); - let backend_fingerprint = backend.fingerprint(); let keystore = backend.as_keystore_mut().ok_or_else(|| { ActionError::Failed(format!( @@ -115,24 +114,18 @@ impl Action for ImportKeyAction { &backend_name, )?; - reporter.fact(StepFact::BackendOperation { - step: step.id.clone(), - kind: "import_key".to_string(), - inputs: requested( + reporter.backend_operation( + "import_key", + requested( &typed, &material_ref.display_name(), passphrase_ref.map(ArtifactRef::display_name).as_deref(), &label, &policy, ), - outputs: produced( - &backend_name, - &backend_fingerprint, - &metadata, - imported_fingerprint.as_deref(), - ), - fingerprint: imported_identity, - })?; + produced(&metadata, imported_fingerprint.as_deref()), + imported_identity, + )?; let key = ArtifactValue::BackendKey { backend_name, @@ -262,14 +255,10 @@ fn requested( /// A keypair is named by the fingerprint of its public half and a symmetric key /// by its check value, so exactly one of the two is present. fn produced( - backend_name: &str, - backend_fingerprint: &str, metadata: &rite_sdk::KeyMetadata, imported_fingerprint: Option<&str>, ) -> serde_json::Value { json!({ - "backend": backend_name, - "backend_fingerprint": backend_fingerprint, "imported_key_id": metadata.key_id.as_str(), "imported_key_algorithm": metadata.algorithm.to_string(), "imported_key_fingerprint": imported_fingerprint, diff --git a/crates/rite-stdlib/src/crypto/mod.rs b/crates/rite-stdlib/src/crypto/mod.rs index d1d794d..dfd1f04 100644 --- a/crates/rite-stdlib/src/crypto/mod.rs +++ b/crates/rite-stdlib/src/crypto/mod.rs @@ -43,11 +43,10 @@ pub(crate) fn transport_backend<'a>( backend: Option<&'a mut dyn Backend>, key_backend: &str, operation: &str, -) -> Result<(&'a mut dyn KeyTransportBackend, String, String), ActionError> { +) -> Result<(&'a mut dyn KeyTransportBackend, String), ActionError> { let backend = backend.ok_or_else(|| ActionError::Failed(format!("Backend required to {operation}")))?; let name = backend.name().to_string(); - let fingerprint = backend.fingerprint(); if name != key_backend { return Err(ActionError::Failed(format!( "Key owned by backend '{key_backend}', but current backend is '{name}'" @@ -56,5 +55,5 @@ pub(crate) fn transport_backend<'a>( let transport = backend .as_transport_mut() .ok_or_else(|| ActionError::Failed(format!("Backend '{name}' cannot {operation}")))?; - Ok((transport, name, fingerprint)) + Ok((transport, name)) } diff --git a/crates/rite-stdlib/src/crypto/sign_data.rs b/crates/rite-stdlib/src/crypto/sign_data.rs index 35bf666..31f078d 100644 --- a/crates/rite-stdlib/src/crypto/sign_data.rs +++ b/crates/rite-stdlib/src/crypto/sign_data.rs @@ -1,6 +1,6 @@ //! `sign_data` action: sign arbitrary data with a backend-managed key. -use rite_model::{ActionType, StepFact}; +use rite_model::ActionType; use rite_runtime::{ Action, ActionError, ArtifactValue, HandlerContext, Icon, Reporter, StepInfo, StepResult, compute_fingerprint, parse_params, resolve_artifact_bytes, resolve_backend_key, @@ -69,8 +69,6 @@ impl Action for SignDataAction { let backend = backend .ok_or_else(|| ActionError::Failed("Backend required for sign_data".to_string()))?; - let backend_name = backend.name().to_string(); - let backend_fingerprint = backend.fingerprint(); let sign_backend = require_sign_backend(backend, key.backend_name, &key_ref.display_name())?; @@ -81,22 +79,19 @@ impl Action for SignDataAction { // result rather than a log line repeating it. let summary = format!("Signature produced ({} bytes)", signature.len()); - reporter.fact(StepFact::BackendOperation { - step: step.id.clone(), - kind: "sign_data".to_string(), - inputs: json!({ + reporter.backend_operation( + "sign_data", + json!({ "key_artifact": key_ref.display_name(), "data_artifact": data_ref.display_name(), "key_algorithm": key_algorithm.to_string(), "algorithm": algorithm.to_string(), }), - outputs: json!({ - "backend": backend_name, - "backend_fingerprint": backend_fingerprint, + json!({ "signature_len": signature.len(), }), - fingerprint: Some(signature_fingerprint), - })?; + Some(signature_fingerprint), + )?; if let Some(produces) = &step.produces { reporter.log( diff --git a/crates/rite-stdlib/src/crypto/unwrap_key.rs b/crates/rite-stdlib/src/crypto/unwrap_key.rs index 64f4321..86f957c 100644 --- a/crates/rite-stdlib/src/crypto/unwrap_key.rs +++ b/crates/rite-stdlib/src/crypto/unwrap_key.rs @@ -1,6 +1,6 @@ //! `unwrap_key` action, unwrap a transport-wrapped key into a backend keypair. -use rite_model::{ActionType, StepFact}; +use rite_model::ActionType; use rite_runtime::{ Action, ActionError, ArtifactValue, HandlerContext, Icon, Reporter, StepInfo, StepResult, compute_fingerprint, parse_params, resolve_backend_key, @@ -89,7 +89,7 @@ impl Action for UnwrapKeyAction { reporter.log(Icon::Spinner, format!("Unwrapping key using {scheme}..."))?; - let (unwrap_backend, backend_name, backend_fingerprint) = + let (unwrap_backend, backend_name) = crate::crypto::transport_backend(backend, unwrapping_key.backend_name, "unwrap a key")?; let default_policy = installed_key_default_policy(expected); @@ -143,10 +143,9 @@ impl Action for UnwrapKeyAction { } } - reporter.fact(StepFact::BackendOperation { - step: step.id.clone(), - kind: "unwrap_key".to_string(), - inputs: json!({ + reporter.backend_operation( +"unwrap_key", +json!({ "scheme": scheme.to_string(), "unwrapping_key": unwrapping_key_ref.display_name(), "wrapped_data": wrapped_data_ref.display_name(), @@ -162,9 +161,7 @@ impl Action for UnwrapKeyAction { // an auditor should not have to know the defaults. "policy": crate::params::policy_json(&policy), }), - outputs: json!({ - "backend": backend_name, - "backend_fingerprint": backend_fingerprint, +json!({ "unwrapped_key_id": key_metadata.key_id.as_str(), "unwrapped_key_algorithm": key_metadata.algorithm.to_string(), "unwrapped_key_fingerprint": recovered_fingerprint, @@ -172,8 +169,8 @@ impl Action for UnwrapKeyAction { // record names the recovered key in whichever form it has one. "unwrapped_key_check_value": key_metadata.check_value.as_ref().map(ToString::to_string), }), - fingerprint: recovered_fingerprint, - })?; +recovered_fingerprint, +)?; let unwrapped = ArtifactValue::BackendKey { backend_name: backend_name.clone(), diff --git a/crates/rite-stdlib/src/crypto/verify_signature.rs b/crates/rite-stdlib/src/crypto/verify_signature.rs index 903c78f..3c29cf4 100644 --- a/crates/rite-stdlib/src/crypto/verify_signature.rs +++ b/crates/rite-stdlib/src/crypto/verify_signature.rs @@ -1,6 +1,6 @@ //! `verify_signature` action: check a signature against a public key. -use rite_model::{ActionType, StepFact}; +use rite_model::ActionType; use rite_runtime::{ Action, ActionError, HandlerContext, Icon, Reporter, StepInfo, StepResult, compute_fingerprint, parse_params, resolve_artifact_bytes, @@ -92,7 +92,7 @@ impl Action for VerifySignatureAction { // The key is already resolved, so the backend decides only who runs the // check, never what is checked. - let (verified, checked_by) = if let Some(backend) = backend { + let verified = if let Some(backend) = backend { let backend_name = backend.name().to_string(); let verifier = backend.as_verify_mut().ok_or_else(|| { ActionError::Failed(format!( @@ -100,12 +100,10 @@ impl Action for VerifySignatureAction { Drop the `backend:` field to check this one in software." )) })?; - let checked = verifier.verify_public_key(&public_key, data, signature, algorithm)?; - (checked, backend_name) + verifier.verify_public_key(&public_key, data, signature, algorithm)? } else { - let checked = crate::signatures::verify(&public_key, data, signature, algorithm) - .map_err(|e| ActionError::Failed(format!("Verification failed to run: {e}")))?; - (checked, "software".to_string()) + crate::signatures::verify(&public_key, data, signature, algorithm) + .map_err(|e| ActionError::Failed(format!("Verification failed to run: {e}")))? }; if !verified { @@ -118,23 +116,21 @@ impl Action for VerifySignatureAction { ))); } - reporter.fact(StepFact::BackendOperation { - step: step.id.clone(), - kind: "verify_signature".to_string(), - inputs: json!({ + reporter.backend_operation( + "verify_signature", + json!({ "key_artifact": key_ref.display_name(), "data_artifact": data_ref.display_name(), "signature_artifact": signature_ref.display_name(), "algorithm": algorithm.to_string(), - "verifier": checked_by, }), - outputs: json!({ + json!({ "verified": true, "public_key_fingerprint": compute_fingerprint(public_key.as_bytes()), "signature_fingerprint": compute_fingerprint(signature), }), - fingerprint: None, - })?; + None, + )?; Ok(StepResult::completed("Signature verified")) } diff --git a/crates/rite-stdlib/src/crypto/wrap_key.rs b/crates/rite-stdlib/src/crypto/wrap_key.rs index c59aa2c..25cf108 100644 --- a/crates/rite-stdlib/src/crypto/wrap_key.rs +++ b/crates/rite-stdlib/src/crypto/wrap_key.rs @@ -69,7 +69,7 @@ impl Action for WrapKeyAction { let target_check_value = key_to_wrap.check_value.map(ToString::to_string); let key_backend = key_to_wrap.backend_name; - let (wrapped_key, backend_fingerprint) = match custody { + let wrapped_key = match custody { Custody::Backend => { let wrapping_key = resolve_backend_key(ctx.artifacts, &wrapping_key_id).map_err(|e| { @@ -85,11 +85,10 @@ impl Action for WrapKeyAction { "Key wrapping requires both keys on same backend (key: '{key_backend}', wrapper: '{wrap_key_backend}')" ))); } - let (transport, _, backend_fp) = + let (transport, _) = crate::crypto::transport_backend(backend, key_backend, "wrap a key")?; reporter.log(Icon::Spinner, "Wrapping key using backend...")?; - let wk = transport.wrap(key_to_wrap.key_id, wrapping_key.key_id, scheme)?; - (wk, backend_fp) + transport.wrap(key_to_wrap.key_id, wrapping_key.key_id, scheme)? } Custody::External => { let recipient = crate::signatures::resolve_public_key( @@ -123,14 +122,13 @@ impl Action for WrapKeyAction { declared: typed.expect_recipient.is_some(), })?; - let (transport, _, backend_fp) = + let (transport, _) = crate::crypto::transport_backend(backend, key_backend, "wrap a key")?; reporter.log( Icon::Spinner, "Wrapping key to external recipient public key...", )?; - let wk = transport.wrap_to_public(key_to_wrap.key_id, &recipient, scheme)?; - (wk, backend_fp) + transport.wrap_to_public(key_to_wrap.key_id, &recipient, scheme)? } }; @@ -138,10 +136,9 @@ impl Action for WrapKeyAction { reporter.log(Icon::Checkmark, "Key wrapped")?; let description = wrapped_key.description(); - reporter.fact(StepFact::BackendOperation { - step: step.id.clone(), - kind: "wrap_key".to_string(), - inputs: json!({ + reporter.backend_operation( + "wrap_key", + json!({ "scheme": wrapped_key.scheme().to_string(), "key_to_wrap": key_to_wrap_ref.display_name(), "wrapping_key": wrapping_key_ref.display_name(), @@ -152,18 +149,16 @@ impl Action for WrapKeyAction { "key_to_wrap_fingerprint": target_fingerprint, "key_to_wrap_check_value": target_check_value, }), - outputs: json!({ + json!({ "wrapped_key_fingerprint": fingerprint, - "backend": key_backend, - "backend_fingerprint": backend_fingerprint, // Read back out of the artifact, not predicted from the // request, and recorded whole. A verifier compares the // description it re-derives from the blob against this one, so // a field added to it is checked without touching either side. "wrap": description, }), - fingerprint: Some(fingerprint), - })?; + Some(fingerprint), + )?; let message = format!("Key wrapped using {}", wrapped_key.scheme()); let wrapped = ArtifactValue::WrappedKey(wrapped_key); diff --git a/crates/rite-stdlib/src/piv/attest.rs b/crates/rite-stdlib/src/piv/attest.rs index f44ff67..a4df5c6 100644 --- a/crates/rite-stdlib/src/piv/attest.rs +++ b/crates/rite-stdlib/src/piv/attest.rs @@ -66,16 +66,15 @@ impl Action for YubikeyAttestSlotAction { ), )?; - reporter.fact(StepFact::BackendOperation { - step: step.id.clone(), - kind: "yubikey_attest_slot".to_string(), - inputs: json!({ "slot": typed.slot }), - outputs: json!({ + reporter.backend_operation( + "yubikey_attest_slot", + json!({ "slot": typed.slot }), + json!({ "cert_fingerprint": cert_fingerprint, "cert_size": cert_der.len(), }), - fingerprint: Some(cert_fingerprint), - })?; + Some(cert_fingerprint), + )?; if let Some(produces) = &step.produces { reporter.log( diff --git a/crates/rite-stdlib/src/piv/read_certificate.rs b/crates/rite-stdlib/src/piv/read_certificate.rs index aacb028..894c5ec 100644 --- a/crates/rite-stdlib/src/piv/read_certificate.rs +++ b/crates/rite-stdlib/src/piv/read_certificate.rs @@ -68,16 +68,15 @@ impl Action for PivReadCertificateAction { ), )?; - reporter.fact(StepFact::BackendOperation { - step: step.id.clone(), - kind: "piv_read_certificate".to_string(), - inputs: json!({ "slot": typed.slot }), - outputs: json!({ + reporter.backend_operation( + "piv_read_certificate", + json!({ "slot": typed.slot }), + json!({ "cert_fingerprint": cert_fingerprint, "cert_size": cert_der.len(), }), - fingerprint: Some(cert_fingerprint), - })?; + Some(cert_fingerprint), + )?; if let Some(produces) = &step.produces { reporter.log( diff --git a/crates/rite-stdlib/src/piv/sign.rs b/crates/rite-stdlib/src/piv/sign.rs index 8b316f1..24f83ad 100644 --- a/crates/rite-stdlib/src/piv/sign.rs +++ b/crates/rite-stdlib/src/piv/sign.rs @@ -67,7 +67,6 @@ impl Action for PivSignAction { let backend = backend .ok_or_else(|| ActionError::Failed("Backend required for PIV signing".into()))?; let backend_name = backend.name().to_string(); - let backend_fingerprint = backend.fingerprint(); verify_pin(backend, &backend_name, reporter)?; @@ -88,21 +87,18 @@ impl Action for PivSignAction { format!("Signature produced ({} bytes)", signature.len()), )?; - reporter.fact(StepFact::BackendOperation { - step: step.id.clone(), - kind: "piv_sign".to_string(), - inputs: json!({ + reporter.backend_operation( + "piv_sign", + json!({ "slot": typed.slot, "algorithm": typed.algorithm, "input_artifact": input_ref.display_name(), }), - outputs: json!({ - "backend": backend_name, - "backend_fingerprint": backend_fingerprint, + json!({ "signature_len": signature.len(), }), - fingerprint: Some(signature_fingerprint), - })?; + Some(signature_fingerprint), + )?; if let Some(produces) = &step.produces { reporter.log( diff --git a/crates/rite-stdlib/src/pki/generate_csr.rs b/crates/rite-stdlib/src/pki/generate_csr.rs index 778ecb3..6732991 100644 --- a/crates/rite-stdlib/src/pki/generate_csr.rs +++ b/crates/rite-stdlib/src/pki/generate_csr.rs @@ -6,7 +6,7 @@ //! leaves the backend, so the CSR must be generated in-ceremony with //! the same backend before [`crate::pki::issue_certificate`] can consume it. -use rite_model::{ActionType, StepFact}; +use rite_model::ActionType; use rite_runtime::{ Action, ActionError, ArtifactValue, HandlerContext, Icon, Reporter, StepInfo, StepResult, parse_params, resolve_backend_key, @@ -118,7 +118,6 @@ impl Action for GenerateCsrAction { ) })?; - let backend_fingerprint = backend.fingerprint(); let backend_name = backend.name().to_string(); if backend_name != signing_key.backend_name { @@ -148,20 +147,16 @@ impl Action for GenerateCsrAction { reporter.log(Icon::Checkmark, "CSR signed")?; - reporter.fact(StepFact::BackendOperation { - step: step.id.clone(), - kind: "generate_csr".to_string(), - inputs: json!({ + reporter.backend_operation( + "generate_csr", + json!({ "algorithm": evidence_algorithm, "signing_key": signing_key_ref.display_name(), "subject": typed.subject, }), - outputs: json!({ - "backend": backend_name, - "backend_fingerprint": backend_fingerprint, - }), - fingerprint: None, - })?; + json!({}), + None, + )?; let artifact = ArtifactValue::Bytes(csr_der); let message = "PKCS#10 CSR generated".to_string(); diff --git a/crates/rite-stdlib/src/pki/issue_certificate.rs b/crates/rite-stdlib/src/pki/issue_certificate.rs index b992d97..e0eaad5 100644 --- a/crates/rite-stdlib/src/pki/issue_certificate.rs +++ b/crates/rite-stdlib/src/pki/issue_certificate.rs @@ -14,7 +14,7 @@ //! `SignBackend` (software, PKCS#11, `YubiKey`) without per-backend //! cert-building code. -use rite_model::{ActionType, CertProfile, StepFact}; +use rite_model::{ActionType, CertProfile}; use rite_runtime::{ Action, ActionError, ArtifactValue, HandlerContext, Icon, Reporter, StepInfo, StepResult, parse_params, resolve_artifact_bytes, resolve_backend_key, @@ -282,7 +282,6 @@ impl Action for IssueCertificateAction { ) })?; - let backend_fingerprint = backend.fingerprint(); let backend_name = backend.name().to_string(); if backend_name != signing_key.backend_name { @@ -312,22 +311,18 @@ impl Action for IssueCertificateAction { reporter.log(Icon::Checkmark, "Certificate signed")?; - reporter.fact(StepFact::BackendOperation { - step: step.id.clone(), - kind: "issue_certificate".to_string(), - inputs: json!({ + reporter.backend_operation( + "issue_certificate", + json!({ "algorithm": evidence_algorithm, "profile": profile_name, "validity_days": validity_days, "signing_key": signing_key_ref.display_name(), "csr": csr_ref.display_name(), }), - outputs: json!({ - "backend": backend_name, - "backend_fingerprint": backend_fingerprint, - }), - fingerprint: None, - })?; + json!({}), + None, + )?; let artifact = ArtifactValue::Certificate(CertificateDer::new(cert_der).map_err(|e| { ActionError::Failed(format!("Issued certificate does not parse back: {e}")) diff --git a/crates/rite-stdlib/src/sharing/combine_shares.rs b/crates/rite-stdlib/src/sharing/combine_shares.rs index fed94cb..2f7706f 100644 --- a/crates/rite-stdlib/src/sharing/combine_shares.rs +++ b/crates/rite-stdlib/src/sharing/combine_shares.rs @@ -1,6 +1,6 @@ //! `combine_shares` action, the secret back from shares `split_secret` made. -use rite_model::{ActionType, StepFact}; +use rite_model::ActionType; use rite_runtime::{ Action, ActionError, ArtifactValue, HandlerContext, Icon, Reporter, StepInfo, StepResult, resolve_share, @@ -94,17 +94,16 @@ impl Action for CombineSharesAction { // The evidence that it is the right secret is whatever the ceremony // checks it against next, not a hash an auditor cannot read the // input space of. - reporter.fact(StepFact::BackendOperation { - step: step.id.clone(), - kind: "combine_shares".to_string(), - inputs: json!({ + reporter.backend_operation( + "combine_shares", + json!({ "scheme": gf256::FORMAT, "threshold": threshold, "shares": given, }), - outputs: json!({}), - fingerprint: None, - })?; + json!({}), + None, + )?; let message = format!("Secret reconstructed from {} shares", given.len()); // Moved out of the arithmetic's wiping buffer, not copied; the empty diff --git a/crates/rite-stdlib/src/sharing/split_secret.rs b/crates/rite-stdlib/src/sharing/split_secret.rs index 2dac1a6..ebe9e58 100644 --- a/crates/rite-stdlib/src/sharing/split_secret.rs +++ b/crates/rite-stdlib/src/sharing/split_secret.rs @@ -1,6 +1,6 @@ //! `split_secret` action, Shamir shares of a secret the ceremony holds. -use rite_model::{ActionType, SharingScheme, StepFact}; +use rite_model::{ActionType, SharingScheme}; use rite_runtime::{ Action, ActionError, ArtifactValue, HandlerContext, Icon, Reporter, Share, ShareSet, StepInfo, StepResult, parse_params, resolve_artifact_bytes, @@ -54,7 +54,6 @@ impl Action for SplitSecretAction { let backend = backend .ok_or_else(|| ActionError::Failed("Backend required to split a secret".into()))?; let backend_name = backend.name().to_string(); - let backend_fingerprint = backend.fingerprint(); let random = backend.as_random_mut().ok_or_else(|| { ActionError::Failed(format!( "Backend '{backend_name}' cannot generate random bytes, which the polynomial \ @@ -100,22 +99,19 @@ impl Action for SplitSecretAction { // is a digest of a secret, and a length is its shape: for a wallet // seed the definition gives it away anyway, for a passphrase it is // the character count. - reporter.fact(StepFact::BackendOperation { - step: step.id.clone(), - kind: "split_secret".to_string(), - inputs: json!({ + reporter.backend_operation( + "split_secret", + json!({ "scheme": scheme.to_string(), "threshold": typed.threshold, "shares": typed.shares, "secret": secret_ref.display_name(), }), - outputs: json!({ - "backend": backend_name, - "backend_fingerprint": backend_fingerprint, + json!({ "subsets_verified": subsets_checked, }), - fingerprint: None, - })?; + None, + )?; let message = format!("{}-of-{} shares made", typed.threshold, typed.shares); if let Some(produces) = &step.produces { diff --git a/crates/rite-stdlib/src/verification/machine_info.rs b/crates/rite-stdlib/src/verification/machine_info.rs index 91d9e19..6e11a3c 100644 --- a/crates/rite-stdlib/src/verification/machine_info.rs +++ b/crates/rite-stdlib/src/verification/machine_info.rs @@ -73,15 +73,12 @@ impl Action for MachineInfoAction { // Serialize the typed, projected snapshot. Optional fields that were // cleared above serialize as JSON null. `arch` is always present. - let outputs = serde_json::to_value(&info) + let info = serde_json::to_value(&info) .map_err(|e| ActionError::Failed(format!("failed to serialize machine info: {e}")))?; - reporter.fact(StepFact::BackendOperation { + reporter.fact(StepFact::MachineInfoRecorded { step: step.id.clone(), - kind: "machine_info".to_string(), - inputs: serde_json::Value::Null, - outputs, - fingerprint: None, + info, })?; Ok(StepResult::completed("Machine info captured")) diff --git a/crates/rite-stdlib/tests/actions.rs b/crates/rite-stdlib/tests/actions.rs index 6de0019..0dcec13 100644 --- a/crates/rite-stdlib/tests/actions.rs +++ b/crates/rite-stdlib/tests/actions.rs @@ -371,11 +371,11 @@ fn machine_info_records_a_snapshot_fact() { result.expect("capturing machine info completes"); assert!( - harness.facts().iter().any(|f| matches!( - f, - StepFact::BackendOperation { kind, .. } if kind == "machine_info" - )), - "machine_info must record a machine_info BackendOperation fact" + harness + .facts() + .iter() + .any(|f| matches!(f, StepFact::MachineInfoRecorded { .. })), + "machine_info must record a MachineInfoRecorded fact" ); } diff --git a/crates/rite-tui/src/model.rs b/crates/rite-tui/src/model.rs index 3034511..ad43071 100644 --- a/crates/rite-tui/src/model.rs +++ b/crates/rite-tui/src/model.rs @@ -1,9 +1,9 @@ //! UI state. Pure data, never reaches for I/O or terminal handles. -use std::collections::VecDeque; +use std::collections::{HashMap, VecDeque}; use chrono::{DateTime, Local, Timelike}; -use rite_model::{Prompt, StepId}; +use rite_model::{Prompt, RoleId, StepId}; use rite_runtime::{Environment, ExecEvent, Icon, MaterialOverview, PromptId, SystemInfo}; /// Maximum length of a deviation note. Anything longer is rejected before @@ -26,6 +26,8 @@ pub struct Model { pub ceremony_materials: Vec, /// Total number of steps in the execution plan, when known. pub ceremony_step_count: Option, + /// Role display names, from the `RoleDeclared` facts at ceremony start. + pub role_names: HashMap, /// Static build/host identity for the System tab. Populated from /// `UiSignal::SystemInfo`, emitted once at ceremony start. pub system_info: Option, @@ -71,6 +73,7 @@ impl Default for Model { ceremony_description: None, ceremony_materials: Vec::new(), ceremony_step_count: None, + role_names: HashMap::new(), system_info: None, environment: None, screen: Screen::Step { diff --git a/crates/rite-tui/src/update.rs b/crates/rite-tui/src/update.rs index c958f30..2c74f0e 100644 --- a/crates/rite-tui/src/update.rs +++ b/crates/rite-tui/src/update.rs @@ -416,12 +416,15 @@ fn handle_fact(model: &mut Model, at: DateTime, fact: &StepFact) -> Vec { + StepFact::RoleDeclared { role, name } => { + model.role_names.insert(role.clone(), name.clone()); + } + StepFact::StepStarted { id, label, role } => { + let role_name = model + .role_names + .get(role) + .cloned() + .unwrap_or_else(|| role.as_str().to_string()); // First StepStarted is the natural boundary between pre-step // overview and live execution: auto-switch to the Ceremony // tab if the operator is still on Overview, so the action @@ -657,13 +660,19 @@ mod tests { #[test] fn step_started_pushes_a_divider_into_the_log_feed() { let mut model = Model::new(); + let _ = apply_exec( + &mut model, + fact_event(StepFact::RoleDeclared { + role: rite_model::RoleId::new("crypto_officer"), + name: "Crypto Officer".to_string(), + }), + ); let _ = apply_exec( &mut model, fact_event(StepFact::StepStarted { id: StepId::new("s1"), label: "2.1".to_string(), role: rite_model::RoleId::new("crypto_officer"), - role_name: "Crypto Officer".to_string(), }), ); assert!(matches!(model.screen, Screen::Step { .. })); @@ -725,7 +734,6 @@ mod tests { id: StepId::new("s1"), label: "1".to_string(), role: rite_model::RoleId::new("op"), - role_name: "Operator".to_string(), }), ); assert!(matches!( @@ -746,7 +754,6 @@ mod tests { id: StepId::new("s1"), label: "1".to_string(), role: rite_model::RoleId::new("op"), - role_name: "Operator".to_string(), }), ); // Operator switches back to Overview to re-read the description. @@ -766,7 +773,6 @@ mod tests { id: StepId::new("s2"), label: "2".to_string(), role: rite_model::RoleId::new("op"), - role_name: "Operator".to_string(), }), ); assert!(matches!( @@ -782,9 +788,7 @@ mod tests { let mut model = Model::new(); let _ = apply_exec( &mut model, - fact_event(StepFact::CeremonyStarted { - name: "Root CA".to_string(), - }), + fact_event(rite_runtime::test_support::ceremony_started("Root CA")), ); assert_eq!(model.ceremony_name.as_deref(), Some("Root CA")); // Description, materials, step count travel via UiSignal::CeremonyOverview, @@ -833,7 +837,6 @@ mod tests { id: StepId::new("s1"), label: "Step One".to_string(), role: rite_model::RoleId::new("op"), - role_name: "Operator".to_string(), }; let _ = apply_exec(&mut model, fact_event(fact)); let s = model.current_step.expect("current step"); diff --git a/crates/rite/src/bundle/create.rs b/crates/rite/src/bundle/create.rs new file mode 100644 index 0000000..1f59213 --- /dev/null +++ b/crates/rite/src/bundle/create.rs @@ -0,0 +1,310 @@ +//! `rite bundle create`: package a run directory as an evidence bundle. + +use std::path::{Path, PathBuf}; + +use clap::Args as ClapArgs; +use rite_model::bundle::{BundleFile, BundleIndex, DEFINITION_FILE, TRANSCRIPT_FILE}; +use rite_model::{Sha256Digest, StepFact}; +use rite_runtime::{LoadedTranscript, read_verified_transcript}; + +use crate::bundle::{copy_artifact, read, with_new_dir, write_index, write_new}; + +#[derive(ClapArgs, Debug)] +pub struct Args { + /// Run output directory produced by `rite run` + pub run_dir: PathBuf, + /// Ceremony YAML the run was resolved from + /// + /// Checked against the template digest the transcript records. A file that + /// does not match is refused, so the bundle always holds the definition + /// that actually ran. + #[arg( + long, + value_name = "FILE", + required_unless_present = "without_definition", + conflicts_with = "without_definition" + )] + pub definition: Option, + /// Leave the ceremony definition out of the bundle + #[arg(long)] + pub without_definition: bool, + /// Bundle directory to create; it must not exist + #[arg(short, long, value_name = "DIR")] + pub output: PathBuf, + /// Accept a transcript with no terminal fact (an interrupted run) + #[arg(long)] + pub allow_truncated: bool, +} + +pub fn run(args: &Args) { + match create(args) { + Ok(created) => { + println!("Bundle written: {}", args.output.display()); + for file in &created.index.files { + println!(" {}", file.path); + } + for note in &created.left_out { + println!(" left out: {note}"); + } + println!(); + println!("Transcript fingerprint: {}", created.index.fingerprint); + } + Err(e) => { + eprintln!("Error: {e}"); + std::process::exit(1); + } + } +} + +/// What creating a bundle produced. +#[derive(Debug)] +struct Created { + index: BundleIndex, + /// Artifacts the transcript records that the bundle does not hold, with why. + left_out: Vec, +} + +/// Build the bundle in a new output directory. +fn create(args: &Args) -> Result { + with_new_dir(&args.output, || create_into(args)) +} + +fn create_into(args: &Args) -> Result { + let out = &args.output; + + // Every check runs on the bytes that go into the bundle, read once: the + // transcript is copied first and the copy is what gets verified. + let transcript = read(&args.run_dir.join(TRANSCRIPT_FILE))?; + write_new(&out.join(TRANSCRIPT_FILE), &transcript)?; + let loaded = read_verified_transcript(&out.join(TRANSCRIPT_FILE)) + .map_err(|e| format!("the transcript does not verify: {e}"))?; + check_complete(&loaded, args.allow_truncated)?; + let mut files = vec![BundleFile::transcript()]; + + if let Some(path) = &args.definition { + let bytes = read(path)?; + check_definition(&loaded, &bytes, path)?; + std::fs::create_dir_all(out.join(DEFINITION_FILE).parent().unwrap_or(out)) + .map_err(|e| format!("cannot create the definition directory: {e}"))?; + write_new(&out.join(DEFINITION_FILE), &bytes)?; + files.push(BundleFile::definition()); + } + + let mut left_out = Vec::new(); + for fact in loaded.facts.iter().map(|t| &t.fact) { + let StepFact::ArtifactWritten { + name, file, digest, .. + } = fact + else { + continue; + }; + let Some(digest) = digest else { + left_out.push(format!("{name}, opened content is never bundled")); + continue; + }; + match copy_artifact(&args.run_dir, out, name, file, digest)? { + Some(copied) => files.push(copied), + None => left_out.push(format!("{name}, not in the run directory")), + } + } + + let index = BundleIndex::complete(loaded.fingerprint, files); + write_index(out, &index)?; + Ok(Created { index, left_out }) +} + +/// A complete bundle needs the whole record: no withheld lines, and a run that +/// reached its end unless the caller accepts an interrupted one. +fn check_complete(loaded: &LoadedTranscript, allow_truncated: bool) -> Result<(), String> { + if !loaded.withheld.is_empty() { + return Err(format!( + "the transcript has {} withheld line(s); a complete bundle needs the complete \ + transcript", + loaded.withheld.len() + )); + } + if !loaded.terminated && !allow_truncated { + return Err( + "the transcript is truncated (no ceremony_completed or ceremony_failed \ + fact at the end); pass --allow-truncated to bundle an interrupted run" + .to_string(), + ); + } + Ok(()) +} + +/// The definition must be the one the run was resolved from. +fn check_definition(loaded: &LoadedTranscript, bytes: &[u8], path: &Path) -> Result<(), String> { + let template = template_digest(loaded) + .ok_or_else(|| "the transcript records no ceremony_started fact".to_string())?; + let actual = Sha256Digest::of(bytes); + if actual == *template { + Ok(()) + } else { + Err(format!( + "{} is not the ceremony this run used: its digest is {actual}, the transcript \ + records {template}", + path.display() + )) + } +} + +/// The template digest `CeremonyStarted` records, if the fact is disclosed. +pub(crate) fn template_digest(loaded: &LoadedTranscript) -> Option<&Sha256Digest> { + loaded.facts.iter().find_map(|t| match &t.fact { + StepFact::CeremonyStarted { template, .. } => Some(template), + _ => None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use rite_model::bundle::{ARTIFACTS_DIR, INDEX_FILE}; + + const CEREMONY: &str = r#" +version: "0.3" +name: "Bundle test" +roles: + operator: {} +sections: + main: + role: ${role.operator} + steps: + confirm: + action: attest + silent: true + with: + statement: "I confirm." +"#; + + /// A run directory holding a real transcript of `CEREMONY`, plus one + /// artifact recorded with `artifact_bytes`, written as `artifact_on_disk`. + fn run_dir(dir: &Path, artifact_bytes: &[u8], artifact_on_disk: Option<&[u8]>) { + let ceremony = rite_resolver::resolve(CEREMONY, None) + .into_result() + .expect("resolve"); + rite_runtime::test_support::write_transcript( + dir, + &[ + StepFact::CeremonyStarted { + name: "Bundle test".to_string(), + template: ceremony.source_digest.clone(), + }, + StepFact::ArtifactWritten { + step: rite_model::StepId::new("confirm"), + name: "cert".to_string(), + file: "cert.pem".to_string(), + digest: Some(Sha256Digest::of(artifact_bytes)), + }, + StepFact::CeremonyCompleted {}, + ], + ) + .expect("transcript"); + if let Some(bytes) = artifact_on_disk { + std::fs::create_dir_all(dir.join(ARTIFACTS_DIR)).expect("artifacts dir"); + std::fs::write(dir.join(ARTIFACTS_DIR).join("cert.pem"), bytes).expect("artifact"); + } + } + + fn args(run: &Path, definition: Option, output: PathBuf) -> Args { + Args { + run_dir: run.to_path_buf(), + without_definition: definition.is_none(), + definition, + output, + allow_truncated: false, + } + } + + fn write_ceremony(dir: &Path, text: &str) -> PathBuf { + let path = dir.join("ceremony.rite.yaml"); + std::fs::write(&path, text).expect("write ceremony"); + path + } + + #[test] + fn bundles_the_transcript_definition_and_artifacts() { + let tmp = tempfile::tempdir().expect("tempdir"); + let run = tmp.path().join("run"); + std::fs::create_dir(&run).expect("run dir"); + run_dir(&run, b"cert", Some(b"cert")); + let definition = write_ceremony(tmp.path(), CEREMONY); + let out = tmp.path().join("bundle"); + + let created = create(&args(&run, Some(definition), out.clone())).expect("create"); + + let paths: Vec<&str> = created + .index + .files + .iter() + .map(|f| f.path.as_str()) + .collect(); + assert_eq!( + paths, + [TRANSCRIPT_FILE, DEFINITION_FILE, "artifacts/cert.pem"] + ); + assert!(created.left_out.is_empty()); + let index: BundleIndex = + serde_json::from_str(&std::fs::read_to_string(out.join(INDEX_FILE)).expect("index")) + .expect("parse index"); + assert_eq!(index, created.index); + assert_eq!( + std::fs::read(out.join(TRANSCRIPT_FILE)).expect("bundled transcript"), + std::fs::read(run.join(TRANSCRIPT_FILE)).expect("run transcript"), + ); + } + + #[test] + fn a_different_definition_is_refused_and_nothing_is_left_behind() { + let tmp = tempfile::tempdir().expect("tempdir"); + let run = tmp.path().join("run"); + std::fs::create_dir(&run).expect("run dir"); + run_dir(&run, b"cert", Some(b"cert")); + let definition = write_ceremony(tmp.path(), &CEREMONY.replace("I confirm.", "I agree.")); + let out = tmp.path().join("bundle"); + + let err = create(&args(&run, Some(definition), out.clone())).expect_err("mismatch"); + assert!(err.contains("not the ceremony this run used"), "{err}"); + assert!(!out.exists(), "a refused bundle leaves no directory behind"); + } + + #[test] + fn a_tampered_artifact_is_refused() { + let tmp = tempfile::tempdir().expect("tempdir"); + let run = tmp.path().join("run"); + std::fs::create_dir(&run).expect("run dir"); + run_dir(&run, b"cert", Some(b"forged")); + let out = tmp.path().join("bundle"); + + let err = create(&args(&run, None, out)).expect_err("tampered artifact"); + assert!(err.contains("does not match"), "{err}"); + } + + #[test] + fn a_missing_artifact_is_left_out_and_said_so() { + let tmp = tempfile::tempdir().expect("tempdir"); + let run = tmp.path().join("run"); + std::fs::create_dir(&run).expect("run dir"); + run_dir(&run, b"cert", None); + let out = tmp.path().join("bundle"); + + let created = create(&args(&run, None, out)).expect("create"); + assert_eq!(created.index.files.len(), 1); + assert_eq!(created.left_out, ["cert, not in the run directory"]); + } + + #[test] + fn an_existing_output_directory_is_refused() { + let tmp = tempfile::tempdir().expect("tempdir"); + let run = tmp.path().join("run"); + std::fs::create_dir(&run).expect("run dir"); + run_dir(&run, b"cert", Some(b"cert")); + let out = tmp.path().join("bundle"); + std::fs::create_dir(&out).expect("pre-existing"); + + let err = create(&args(&run, None, out.clone())).expect_err("exists"); + assert!(err.contains("cannot create"), "{err}"); + assert!(out.exists(), "a directory it did not create is not removed"); + } +} diff --git a/crates/rite/src/bundle/disclose.rs b/crates/rite/src/bundle/disclose.rs new file mode 100644 index 0000000..bf495b6 --- /dev/null +++ b/crates/rite/src/bundle/disclose.rs @@ -0,0 +1,232 @@ +//! `rite bundle disclose`: derive a disclosure from a complete evidence bundle. + +use std::path::PathBuf; + +use clap::Args as ClapArgs; +use rite_model::StepFact; +use rite_model::bundle::{BundleFile, BundleIndex, BundleKind, INDEX_FILE, TRANSCRIPT_FILE}; +use rite_runtime::{disclose_transcript, verify_jsonl}; + +use crate::bundle::{copy_artifact, read, read_index, with_new_dir, write_index, write_new}; + +#[derive(ClapArgs, Debug)] +pub struct Args { + /// Complete bundle produced by `rite bundle create` + pub bundle: PathBuf, + /// Widest audience to disclose to: a level name (`public`, `restricted`, + /// `confidential`) or its number + /// + /// Every fact recorded at this level or below is disclosed; every fact + /// above it is withheld, keeping its commitment. + #[arg(long, value_name = "LEVEL")] + pub level: String, + /// Disclosure directory to create; it must not exist + #[arg(short, long, value_name = "DIR")] + pub output: PathBuf, +} + +pub fn run(args: &Args) { + match with_new_dir(&args.output, || disclose(args)) { + Ok(disclosed) => { + let index = &disclosed.index; + if let BundleKind::Disclosure { threshold } = index.kind { + println!( + "Disclosure written: {} (up to {threshold})", + args.output.display() + ); + } + println!( + " {} fact(s) disclosed, {} withheld", + disclosed.facts, disclosed.withheld + ); + for file in &index.files { + println!(" {}", file.path); + } + println!(" left out: the ceremony definition, which can name people"); + for name in &disclosed.missing { + println!(" left out: {name}, not in the bundle"); + } + println!(); + println!("Transcript fingerprint: {}", index.fingerprint); + } + Err(e) => { + eprintln!("Error: {e}"); + std::process::exit(1); + } + } +} + +/// What a disclosure produced. +#[derive(Debug)] +struct Disclosed { + index: BundleIndex, + facts: usize, + withheld: usize, + /// Disclosed artifacts the source bundle does not hold. + missing: Vec, +} + +fn disclose(args: &Args) -> Result { + let out = &args.output; + let index = read_index(&args.bundle)?; + if index.kind != BundleKind::Complete { + return Err("a disclosure is derived from a complete bundle".to_string()); + } + + // The transcript is read once: the text that is verified is the text + // that is disclosed. + let text = String::from_utf8(read(&args.bundle.join(TRANSCRIPT_FILE))?) + .map_err(|_| "the bundle's transcript is not UTF-8".to_string())?; + let source = + verify_jsonl(&text).map_err(|e| format!("the bundle's transcript does not verify: {e}"))?; + if !source.withheld.is_empty() { + return Err("the bundle's transcript already has withheld lines".to_string()); + } + if index.fingerprint != source.fingerprint { + return Err(format!( + "{INDEX_FILE} names fingerprint {}, but the transcript folds to {}", + index.fingerprint, source.fingerprint + )); + } + let threshold = source + .header + .level(&args.level) + .ok_or_else(|| format!("'{}' is not a level this transcript declares", args.level))?; + + // The withheld copy must verify, and fold to the fingerprint of the + // complete transcript. + let disclosed = disclose_transcript(&text, threshold).map_err(|e| e.to_string())?; + let loaded = verify_jsonl(&disclosed) + .map_err(|e| format!("the disclosed transcript does not verify: {e}"))?; + if loaded.fingerprint != source.fingerprint { + return Err("the disclosed transcript does not keep the fingerprint".to_string()); + } + write_new(&out.join(TRANSCRIPT_FILE), disclosed.as_bytes())?; + let mut files = vec![BundleFile::transcript()]; + let mut missing = Vec::new(); + + // Only artifacts whose facts are disclosed, and only those the bundle + // holds; each is checked against its digest on the way through. + for fact in loaded.facts.iter().map(|t| &t.fact) { + if let StepFact::ArtifactWritten { + name, + file, + digest: Some(digest), + .. + } = fact + { + match copy_artifact(&args.bundle, out, name, file, digest)? { + Some(copied) => files.push(copied), + None => missing.push(name.clone()), + } + } + } + + let index = BundleIndex::disclosure(loaded.fingerprint, threshold, files); + write_index(out, &index)?; + Ok(Disclosed { + index, + facts: loaded.facts.len(), + withheld: loaded.withheld.len(), + missing, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use rite_model::Sha256Digest; + use rite_runtime::test_support::{ceremony_started, write_transcript}; + + /// A complete bundle holding one public and one confidential fact, with + /// `fingerprint` in its index, or the transcript's own when `None`. + fn bundle(dir: &std::path::Path, fingerprint: Option) -> Sha256Digest { + let actual = write_transcript( + dir, + &[ + ceremony_started("Disclose test"), + StepFact::RoleAssigned { + role: rite_model::RoleId::new("operator"), + person: "Alice".to_string(), + }, + StepFact::CeremonyCompleted {}, + ], + ) + .expect("transcript"); + let index = BundleIndex::complete( + fingerprint.unwrap_or_else(|| actual.clone()), + vec![BundleFile::transcript()], + ); + write_index(dir, &index).expect("index"); + actual + } + + fn args(bundle: &std::path::Path, output: PathBuf) -> Args { + Args { + bundle: bundle.to_path_buf(), + level: "public".to_string(), + output, + } + } + + #[test] + fn discloses_under_the_transcript_fingerprint() { + let tmp = tempfile::tempdir().expect("tempdir"); + let source = tmp.path().join("bundle"); + std::fs::create_dir(&source).expect("bundle dir"); + let fingerprint = bundle(&source, None); + + let out = tmp.path().join("out"); + std::fs::create_dir(&out).expect("out dir"); + + let disclosed = disclose(&args(&source, out)).expect("disclose"); + + assert_eq!(disclosed.withheld, 1); + assert_eq!(disclosed.index.fingerprint, fingerprint); + assert!(disclosed.missing.is_empty()); + } + + #[test] + fn a_disclosed_artifact_the_bundle_lacks_is_named() { + let tmp = tempfile::tempdir().expect("tempdir"); + let source = tmp.path().join("bundle"); + std::fs::create_dir(&source).expect("bundle dir"); + let fingerprint = write_transcript( + &source, + &[ + ceremony_started("Disclose test"), + StepFact::ArtifactWritten { + step: rite_model::StepId::new("s1"), + name: "root_cert".to_string(), + file: "root_cert.pem".to_string(), + digest: Some(Sha256Digest::of(b"cert")), + }, + StepFact::CeremonyCompleted {}, + ], + ) + .expect("transcript"); + write_index( + &source, + &BundleIndex::complete(fingerprint, vec![BundleFile::transcript()]), + ) + .expect("index"); + let out = tmp.path().join("out"); + std::fs::create_dir(&out).expect("out dir"); + + let disclosed = disclose(&args(&source, out)).expect("disclose"); + + assert_eq!(disclosed.missing, ["root_cert"]); + } + + #[test] + fn an_index_naming_another_fingerprint_is_refused() { + let tmp = tempfile::tempdir().expect("tempdir"); + let source = tmp.path().join("bundle"); + std::fs::create_dir(&source).expect("bundle dir"); + bundle(&source, Some(Sha256Digest::of(b"another run"))); + + let err = disclose(&args(&source, tmp.path().join("out"))).expect_err("refused"); + + assert!(err.contains("fingerprint"), "{err}"); + } +} diff --git a/crates/rite/src/bundle/mod.rs b/crates/rite/src/bundle/mod.rs new file mode 100644 index 0000000..c4944a5 --- /dev/null +++ b/crates/rite/src/bundle/mod.rs @@ -0,0 +1,113 @@ +//! `rite bundle`: evidence bundles, and the file operations its commands and +//! `rite verify` share. + +pub mod create; +pub mod disclose; + +use std::path::Path; + +use clap::Subcommand; +use rite_model::Sha256Digest; +use rite_model::bundle::{ + ARTIFACTS_DIR, BUNDLE_FORMAT, BundleFile, BundleIndex, INDEX_FILE, artifact_path, +}; + +#[derive(Subcommand)] +pub enum Command { + /// Package a run as an evidence bundle + /// + /// Copies the transcript, the ceremony definition it ran from, and the + /// artifacts it records into a bundle directory with an index. Every file + /// is checked against the transcript on the way in; opened content is + /// never bundled. + Create(create::Args), + /// Derive a disclosure from an evidence bundle + /// + /// Withholds every fact recorded above a level, keeping its commitment, + /// so the disclosed transcript verifies to the same fingerprint as the + /// complete one. Carries only the artifacts whose facts it discloses, and + /// never the ceremony definition. + Disclose(disclose::Args), +} + +pub fn run(command: &Command) { + match command { + Command::Create(args) => create::run(args), + Command::Disclose(args) => disclose::run(args), + } +} + +/// Create `dir`, which must not exist, and run `build` in it, removing the +/// directory again if `build` fails. +pub(crate) fn with_new_dir( + dir: &Path, + build: impl FnOnce() -> Result, +) -> Result { + std::fs::create_dir(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?; + let result = build(); + if result.is_err() { + // The directory was created above, so it holds only what `build` + // wrote into it. + let _ = std::fs::remove_dir_all(dir); + } + result +} + +pub(crate) fn read(path: &Path) -> Result, String> { + std::fs::read(path).map_err(|e| format!("cannot read {}: {e}", path.display())) +} + +/// Write a file that must not exist yet, readable by its owner only: a bundle +/// can hold wrapped keys and other material that is not for everyone. +pub(crate) fn write_new(path: &Path, bytes: &[u8]) -> Result<(), String> { + rite_runtime::write_new_file(path, bytes) + .map_err(|e| format!("cannot write {}: {e}", path.display())) +} + +/// Read a bundle's index, refusing a format this version does not know. +pub(crate) fn read_index(dir: &Path) -> Result { + let path = dir.join(INDEX_FILE); + let index: BundleIndex = + serde_json::from_slice(&read(&path)?).map_err(|e| format!("{}: {e}", path.display()))?; + if index.rite_bundle != BUNDLE_FORMAT { + return Err(format!( + "unknown bundle format {} (this version reads {BUNDLE_FORMAT})", + index.rite_bundle + )); + } + Ok(index) +} + +pub(crate) fn write_index(dir: &Path, index: &BundleIndex) -> Result<(), String> { + let mut json = serde_json::to_string_pretty(index).map_err(|e| e.to_string())?; + json.push('\n'); + write_new(&dir.join(INDEX_FILE), json.as_bytes()) +} + +/// Copy the artifact a transcript records as `name`, stored as `file` with +/// `digest`, from `source` to `out`, checking it against the digest on the +/// way. Returns `None` when `source` does not hold it. +pub(crate) fn copy_artifact( + source: &Path, + out: &Path, + name: &str, + file: &str, + digest: &Sha256Digest, +) -> Result, String> { + let path = artifact_path(file) + .ok_or_else(|| format!("artifact {name} records an unsafe file name '{file}'"))?; + let from = source.join(&path); + if !from.is_file() { + return Ok(None); + } + let bytes = read(&from)?; + if Sha256Digest::of(&bytes) != *digest { + return Err(format!( + "artifact {name} does not match the digest the transcript records" + )); + } + std::fs::create_dir_all(out.join(ARTIFACTS_DIR)) + .map_err(|e| format!("cannot create the artifacts directory: {e}"))?; + write_new(&out.join(&path), &bytes)?; + Ok(Some(BundleFile::artifact(name, &path))) +} diff --git a/crates/rite/src/console.rs b/crates/rite/src/console.rs index df9f20f..f61615d 100644 --- a/crates/rite/src/console.rs +++ b/crates/rite/src/console.rs @@ -238,14 +238,11 @@ mod tests { fn renders_each_fact_kind_without_panicking() { let mut buf: Vec = Vec::new(); let facts = vec![ - StepFact::CeremonyStarted { - name: "T".to_string(), - }, + rite_runtime::test_support::ceremony_started("T"), StepFact::StepStarted { id: StepId::new("a"), label: "Step A".to_string(), role: rite_model::RoleId::new("op"), - role_name: "Operator".to_string(), }, StepFact::PromptAnswered { step: Some(StepId::new("a")), @@ -319,9 +316,9 @@ mod tests { // Send only events the helper sees: let prompt_id = PromptId::new(7); event_tx - .send(fact_event(StepFact::CeremonyStarted { - name: "T".to_string(), - })) + .send(fact_event(rite_runtime::test_support::ceremony_started( + "T", + ))) .expect("send fact"); event_tx .send(ExecEvent::AwaitPrompt { diff --git a/crates/rite/src/container_checks.rs b/crates/rite/src/container_checks.rs index f061bd4..81350ae 100644 --- a/crates/rite/src/container_checks.rs +++ b/crates/rite/src/container_checks.rs @@ -21,7 +21,8 @@ use std::path::Path; use rite_model::StepFact; use rite_sdk::{KeyCheckValue, RecipientInfoKind, WrapDescription, WrapScheme}; -use crate::verify::{artifact_location, digest_hex}; +use crate::verify::digest_hex; +use rite_model::bundle::artifact_path; /// What was concluded about one wrap step. #[derive(Debug)] @@ -241,7 +242,7 @@ struct Index<'a> { /// entry, and the first `declared` would then stand for both. recipients: HashMap<&'a str, (&'a str, bool)>, /// Artifact file names by the digest recorded for their contents. - artifacts: HashMap<&'a str, &'a std::ffi::OsStr>, + artifacts: HashMap<&'a str, &'a str>, } impl<'a> Index<'a> { @@ -284,13 +285,15 @@ impl<'a> Index<'a> { .entry(step.as_str()) .or_insert((fingerprint.as_str(), *declared)); } - StepFact::ArtifactWritten { path, sha256, .. } => { - if let Some(file_name) = path.file_name() { - index - .artifacts - .entry(digest_hex(sha256)) - .or_insert(file_name); - } + StepFact::ArtifactWritten { + file, + digest: Some(digest), + .. + } => { + index + .artifacts + .entry(digest_hex(digest.as_str())) + .or_insert(file.as_str()); } _ => {} } @@ -301,10 +304,10 @@ impl<'a> Index<'a> { /// The bytes of the artifact whose recorded digest is `fingerprint`. /// /// The transcript is untrusted input, so the recorded path is never - /// followed: [`artifact_location`] confines it to the run directory. + /// followed: [`artifact_path`] confines it to the run directory. fn artifact_bytes(&self, dir: &Path, fingerprint: &str) -> Option> { - let file_name = self.artifacts.get(digest_hex(fingerprint))?; - std::fs::read(dir.join(artifact_location(file_name))).ok() + let file = self.artifacts.get(digest_hex(fingerprint))?; + std::fs::read(dir.join(artifact_path(file)?)).ok() } } @@ -497,7 +500,6 @@ mod tests { KeyAlgorithm, KeyPolicy, KeySpec, KeyStoreBackend, KeyTransportBackend, WrapScheme, }; use serde_json::json; - use std::path::PathBuf; use std::sync::LazyLock; /// A real CMS wrap, plus the facts a run would have recorded for it. @@ -570,6 +572,7 @@ mod tests { StepFact::BackendOperation { step: StepId::new("gen"), kind: "generate_key".to_string(), + backend: None, inputs: json!({}), outputs: json!({ "public_key_fingerprint": wrap.target_fingerprint }), fingerprint: None, @@ -583,6 +586,7 @@ mod tests { StepFact::BackendOperation { step: StepId::new("wrap"), kind: "wrap_key".to_string(), + backend: None, inputs: json!({ "scheme": "CMS-AES-256-GCM", "key_to_wrap_fingerprint": wrap.target_fingerprint, @@ -594,8 +598,8 @@ mod tests { StepFact::ArtifactWritten { step: StepId::new("wrap"), name: "wrapped".to_string(), - path: PathBuf::from("/somewhere/else/artifacts/wrapped.p7c"), - sha256: rite_runtime::compute_fingerprint(&wrap.blob), + file: "wrapped.p7c".to_string(), + digest: Some(rite_model::Sha256Digest::of(&wrap.blob)), }, ] } @@ -665,6 +669,7 @@ mod tests { StepFact::BackendOperation { step: StepId::new("gen_kek"), kind: "generate_key".to_string(), + backend: None, inputs: json!({}), outputs: json!({ "key_check_value": sealed.kek_check_value }), fingerprint: None, @@ -672,6 +677,7 @@ mod tests { StepFact::BackendOperation { step: StepId::new("encrypt"), kind: "encrypt_data".to_string(), + backend: None, inputs: json!({ "scheme": "CMS-AES-256-GCM", "encryption_key": "kek", @@ -682,8 +688,8 @@ mod tests { StepFact::ArtifactWritten { step: StepId::new("encrypt"), name: "sealed".to_string(), - path: PathBuf::from("/somewhere/else/artifacts/sealed.p7c"), - sha256: rite_runtime::compute_fingerprint(&sealed.blob), + file: "sealed.p7c".to_string(), + digest: Some(rite_model::Sha256Digest::of(&sealed.blob)), }, ]; @@ -764,6 +770,7 @@ mod tests { facts.push(StepFact::BackendOperation { step: StepId::new("gen_kek"), kind: "generate_key".to_string(), + backend: None, inputs: json!({}), outputs: json!({ "public_key_fingerprint": wrap.recipient_fingerprint }), fingerprint: None, @@ -835,6 +842,7 @@ mod tests { StepFact::BackendOperation { step: StepId::new("gen_target"), kind: "generate_key".to_string(), + backend: None, inputs: json!({}), outputs: json!({ "public_key_fingerprint": target_fingerprint }), fingerprint: None, @@ -842,6 +850,7 @@ mod tests { StepFact::BackendOperation { step: StepId::new("import_kek"), kind: "import_key".to_string(), + backend: None, inputs: json!({}), outputs: json!({ "imported_key_check_value": check_value }), fingerprint: None, @@ -849,6 +858,7 @@ mod tests { StepFact::BackendOperation { step: StepId::new("wrap"), kind: "wrap_key".to_string(), + backend: None, inputs: json!({ "scheme": "CMS-AES-256-GCM", "key_to_wrap_fingerprint": target_fingerprint, @@ -863,8 +873,8 @@ mod tests { StepFact::ArtifactWritten { step: StepId::new("wrap"), name: "wrapped".to_string(), - path: tmp.path().join("artifacts/wrapped.p7c"), - sha256: rite_runtime::compute_fingerprint(wrapped.data()), + file: "wrapped.p7c".to_string(), + digest: Some(rite_model::Sha256Digest::of(wrapped.data())), }, ]; @@ -940,6 +950,7 @@ mod tests { StepFact::BackendOperation { step: StepId::new("gen_target"), kind: "generate_key".to_string(), + backend: None, inputs: json!({}), outputs: json!({ "public_key_fingerprint": target_fingerprint }), fingerprint: None, @@ -947,6 +958,7 @@ mod tests { StepFact::BackendOperation { step: StepId::new("gen_kek"), kind: "generate_key".to_string(), + backend: None, inputs: json!({}), outputs: json!({ "key_check_value": check_value }), fingerprint: None, @@ -954,6 +966,7 @@ mod tests { StepFact::BackendOperation { step: StepId::new("wrap"), kind: "wrap_key".to_string(), + backend: None, inputs: json!({ "scheme": "CMS-AES-256-GCM", "key_to_wrap_fingerprint": target_fingerprint, @@ -968,8 +981,8 @@ mod tests { StepFact::ArtifactWritten { step: StepId::new("wrap"), name: "wrapped".to_string(), - path: tmp.path().join("artifacts/wrapped.p7c"), - sha256: rite_runtime::compute_fingerprint(wrapped.data()), + file: "wrapped.p7c".to_string(), + digest: Some(rite_model::Sha256Digest::of(wrapped.data())), }, ]; @@ -1034,6 +1047,7 @@ mod tests { StepFact::BackendOperation { step: StepId::new("gen_kek"), kind: "generate_key".to_string(), + backend: None, inputs: json!({}), outputs: json!({ "key_check_value": check_value }), fingerprint: None, @@ -1041,6 +1055,7 @@ mod tests { StepFact::BackendOperation { step: StepId::new("wrap"), kind: "wrap_key".to_string(), + backend: None, inputs: json!({ "scheme": "CMS-AES-256-GCM", "key_to_wrap_fingerprint": serde_json::Value::Null, @@ -1056,8 +1071,8 @@ mod tests { StepFact::ArtifactWritten { step: StepId::new("wrap"), name: "wrapped".to_string(), - path: tmp.path().join("artifacts/wrapped.p7c"), - sha256: rite_runtime::compute_fingerprint(wrapped.data()), + file: "wrapped.p7c".to_string(), + digest: Some(rite_model::Sha256Digest::of(wrapped.data())), }, ]; diff --git a/crates/rite/src/headless.rs b/crates/rite/src/headless.rs index bf68b69..1c5fe72 100644 --- a/crates/rite/src/headless.rs +++ b/crates/rite/src/headless.rs @@ -288,9 +288,9 @@ mod tests { // Simulate a runtime: send a couple of facts and a Continue prompt. event_tx - .send(fact_event(StepFact::CeremonyStarted { - name: "T".to_string(), - })) + .send(fact_event(rite_runtime::test_support::ceremony_started( + "T", + ))) .expect("send fact"); event_tx .send(ExecEvent::AwaitPrompt { diff --git a/crates/rite/src/main.rs b/crates/rite/src/main.rs index 3041646..6a1edcd 100644 --- a/crates/rite/src/main.rs +++ b/crates/rite/src/main.rs @@ -2,6 +2,7 @@ #![allow(clippy::print_stdout, clippy::print_stderr)] +mod bundle; mod check; mod common; mod console; @@ -28,6 +29,10 @@ Lifecycle: rite verify # verify integrity rite report # generate audit report +Evidence bundles: + rite bundle create --definition ceremony.rite.yaml -o + rite bundle disclose --level public -o + Exit codes: 0 Success 1 A negative result or bad input (invalid ceremony, failed verification) @@ -41,6 +46,10 @@ Lifecycle: rite run ceremony.rite.yaml # execute with transcript rite verify # verify integrity +Evidence bundles: + rite bundle create --definition ceremony.rite.yaml -o + rite bundle disclose --level public -o + Exit codes: 0 Success 1 A negative result or bad input (invalid ceremony, failed verification) @@ -78,6 +87,14 @@ enum Commands { /// digests and reads each wrapped key back to confirm it was produced the /// way the transcript says. Verify(verify::Args), + /// Create and derive evidence bundles + /// + /// A bundle packages a run's transcript with the ceremony definition and + /// the artifacts, for keeping and for handing to an auditor. + Bundle { + #[command(subcommand)] + command: bundle::Command, + }, /// Render a ceremony as a printable protocol /// /// Produces a self-contained HTML document that participants follow and @@ -105,6 +122,7 @@ fn main() { Commands::Check(args) => check::run(&args), Commands::Run(args) => run::run(args), Commands::Verify(args) => verify::run(&args), + Commands::Bundle { command } => bundle::run(&command), #[cfg(feature = "render")] Commands::Script(args) => script::run(&args), #[cfg(feature = "render")] diff --git a/crates/rite/src/report.rs b/crates/rite/src/report.rs index c323f2c..2ed904e 100644 --- a/crates/rite/src/report.rs +++ b/crates/rite/src/report.rs @@ -4,7 +4,7 @@ use clap::Args as ClapArgs; use std::path::{Path, PathBuf}; use crate::common::{BrandingArgs, ThemeArg, build_branding_or_exit, write_document}; -use rite_render::report::build_report_data; +use rite_render::report::{ReportWithheld, build_report_data}; use rite_runtime::read_verified_transcript; #[derive(ClapArgs, Debug)] @@ -40,10 +40,11 @@ pub fn run(args: &Args) { } }; - let data = build_report_data( + let mut data = build_report_data( loaded.facts.iter().map(|t| (t.at, &t.fact)), loaded.fingerprint.as_str(), ); + data.withheld = withheld(&loaded); let branding = build_branding_or_exit(&args.branding); let html = rite_render::render_report(&data, &branding, args.theme.into()).unwrap_or_else(|e| { @@ -55,6 +56,28 @@ pub fn run(args: &Args) { write_document(&html, args.output.as_deref(), &default); } +/// What a disclosed transcript withholds, by count and by level name, or +/// `None` for a complete one. +fn withheld(loaded: &rite_runtime::LoadedTranscript) -> Option { + if loaded.withheld.is_empty() { + return None; + } + let levels: std::collections::BTreeSet<_> = + loaded.withheld.iter().map(|line| line.level).collect(); + let name = |level: rite_model::Level| { + loaded + .header + .levels + .iter() + .find(|(_, declared)| **declared == level) + .map_or_else(|| level.to_string(), |(name, _)| name.clone()) + }; + Some(ReportWithheld { + facts: loaded.withheld.len(), + levels: levels.into_iter().map(name).collect(), + }) +} + /// Accept either the JSONL file directly or the parent output directory. fn resolve_transcript_path(input: &Path) -> PathBuf { if input.is_dir() { @@ -63,3 +86,39 @@ fn resolve_transcript_path(input: &Path) -> PathBuf { input.to_path_buf() } } + +#[cfg(test)] +mod tests { + use super::*; + use rite_model::{RoleId, StepFact}; + use rite_runtime::test_support::{ceremony_started, write_transcript}; + use rite_runtime::{disclose_transcript, verify_jsonl}; + + #[test] + fn a_disclosure_states_what_it_withholds() { + let tmp = tempfile::tempdir().expect("tempdir"); + write_transcript( + tmp.path(), + &[ + ceremony_started("Report test"), + StepFact::RoleAssigned { + role: RoleId::new("operator"), + person: "Alice".to_string(), + }, + StepFact::CeremonyCompleted {}, + ], + ) + .expect("transcript"); + let complete = std::fs::read_to_string(tmp.path().join("transcript.jsonl")).expect("read"); + assert_eq!(withheld(&verify_jsonl(&complete).expect("verify")), None); + + let public = disclose_transcript(&complete, rite_model::Level::PUBLIC).expect("disclose"); + assert_eq!( + withheld(&verify_jsonl(&public).expect("verify")), + Some(ReportWithheld { + facts: 1, + levels: vec!["confidential".to_string()], + }) + ); + } +} diff --git a/crates/rite/src/run.rs b/crates/rite/src/run.rs index 23fe947..b4c08a4 100644 --- a/crates/rite/src/run.rs +++ b/crates/rite/src/run.rs @@ -189,19 +189,21 @@ pub fn run(args: Args) { eprintln!("Frontend error: {e}"); } - report_outcome(exec_result, &output_dir, args.no_transcript); + report_outcome(exec_result, &output_dir, &args.file, args.no_transcript); } /// Print the terminal summary and exit the process with the matching code. /// -/// On success prints the output directory and transcript fingerprint (exit 0). -/// On failure or operator abort, exits non-zero: an abort is a deliberate stop -/// rather than a failure, but the ceremony did not complete either way. The -/// transcript is recorded up to the stopping point in both cases, so the output -/// directory is reported so the operator can find the evidence. +/// On success prints the output directory, the transcript fingerprint and the +/// commands that check and keep the record (exit 0). On failure or operator +/// abort, exits non-zero: an abort is a deliberate stop rather than a failure, +/// but the ceremony did not complete either way. The transcript is recorded up +/// to the stopping point in both cases, so the output directory and the same +/// commands are printed so the operator can find and keep the evidence. fn report_outcome( exec_result: Result, output_dir: &Path, + ceremony: &Path, no_transcript: bool, ) -> ! { match exec_result { @@ -209,6 +211,8 @@ fn report_outcome( if !no_transcript { println!("Output directory: {}", output_dir.display()); println!("Transcript fingerprint: {}", summary.transcript_fingerprint); + println!(); + println!("{}", next_steps(output_dir, ceremony)); } std::process::exit(0); } @@ -220,12 +224,42 @@ fn report_outcome( } if !no_transcript { eprintln!("Output directory: {}", output_dir.display()); + eprintln!(); + eprintln!("{}", next_steps(output_dir, ceremony)); } std::process::exit(1); } } } +/// The commands that check the run, render its report, and package it with +/// the ceremony it ran from, ready to copy. +fn next_steps(output_dir: &Path, ceremony: &Path) -> String { + let run = shell_arg(&output_dir.display().to_string()); + let bundle = shell_arg(&format!("{}-bundle", output_dir.display())); + let definition = shell_arg(&ceremony.display().to_string()); + format!( + "Next steps:\n \ + rite verify {run}\n \ + rite report {run}\n \ + rite bundle create {run} --definition {definition} -o {bundle}" + ) +} + +/// `arg` as one shell word: unchanged when it holds only characters no shell +/// treats specially, single-quoted otherwise. +fn shell_arg(arg: &str) -> String { + let plain = !arg.is_empty() + && arg + .chars() + .all(|c| c.is_ascii_alphanumeric() || "-_./:@+,=".contains(c)); + if plain { + arg.to_string() + } else { + format!("'{}'", arg.replace('\'', "'\\''")) + } +} + fn run_frontend( frontend: Frontend, cmd_tx: &crossbeam_channel::Sender, @@ -253,3 +287,31 @@ fn is_stdout_tty() -> bool { use std::io::IsTerminal; std::io::stdout().is_terminal() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn next_steps_name_the_run_and_the_ceremony() { + let text = next_steps( + Path::new("./root-ca-20260924T101500"), + Path::new("root.rite.yaml"), + ); + assert_eq!( + text, + "Next steps:\n \ + rite verify ./root-ca-20260924T101500\n \ + rite report ./root-ca-20260924T101500\n \ + rite bundle create ./root-ca-20260924T101500 --definition root.rite.yaml \ + -o ./root-ca-20260924T101500-bundle" + ); + } + + #[test] + fn shell_arguments_are_quoted_when_needed() { + assert_eq!(shell_arg("runs/a-1"), "runs/a-1"); + assert_eq!(shell_arg("my runs/a"), "'my runs/a'"); + assert_eq!(shell_arg("it's"), "'it'\\''s'"); + } +} diff --git a/crates/rite/src/verify.rs b/crates/rite/src/verify.rs index 7e3ddba..a59e4ef 100644 --- a/crates/rite/src/verify.rs +++ b/crates/rite/src/verify.rs @@ -4,14 +4,12 @@ use std::path::{Path, PathBuf}; use crate::container_checks::{ContainerCheck, check_containers}; use clap::Args as ClapArgs; -use rite_model::StepFact; -use rite_runtime::{ - TimedFact, VerifyError, compute_file_fingerprint, read_verified_transcript, verify_entropy, -}; - -/// Entropy-source label the runtime records for a dry run. Such a transcript -/// derives from a fixed, publicly-known sentinel seed and re-derives exactly -/// like a real one, so the verifier must flag it loudly. +use rite_model::{Sha256Digest, StepFact}; +use rite_runtime::{TimedFact, VerifyError, read_verified_transcript, verify_entropy}; + +/// Entropy-source label the runtime records for a dry run. The header says +/// whether the run was a dry run; a seed from the public sentinel means the +/// same thing whatever the header says, so either one marks the transcript. const DRY_RUN_SOURCE: &str = "dry-run"; #[derive(ClapArgs, Debug)] @@ -53,7 +51,19 @@ pub fn run(args: &Args) { let facts: Vec<&StepFact> = loaded.facts.iter().map(|t| &t.fact).collect(); let container_checks = check_containers(source_dir.as_deref(), &facts); - print_counts(loaded.facts.len(), &entropy); + print_counts(&loaded, &entropy); + + // A bundle directory also holds an index and, usually, the definition + // the run was resolved from. + let bundle_failed = source_dir + .as_deref() + .and_then(|dir| check_bundle(dir, &loaded)) + .is_some_and(|check| { + for line in &check.lines { + println!(" {line}"); + } + check.failed + }); let (artifacts_failed, artifacts_missing) = match &artifact_checks { Some(checks) => summarize_artifacts(checks), @@ -76,7 +86,7 @@ pub fn run(args: &Args) { let mut failed = false; - if entropy.source.as_deref() == Some(DRY_RUN_SOURCE) { + if loaded.header.dry_run || entropy.source.as_deref() == Some(DRY_RUN_SOURCE) { eprintln!(); eprintln!( "WARNING: this transcript was produced by a DRY RUN. Its entropy seed is a\n\ @@ -85,6 +95,11 @@ pub fn run(args: &Args) { ); } + if let Some(warning) = unstable_format_warning(&loaded.header) { + eprintln!(); + eprintln!("{warning}"); + } + if let Some(line) = first_timestamp_regression(&loaded.facts) { eprintln!(); eprintln!( @@ -104,6 +119,12 @@ pub fn run(args: &Args) { failed = true; } + if bundle_failed { + eprintln!(); + eprintln!("Verification failed: the bundle does not match its transcript or its index."); + failed = true; + } + if containers_failed { eprintln!(); eprintln!( @@ -134,6 +155,24 @@ pub fn run(args: &Args) { std::process::exit(i32::from(failed)); } +/// Before 1.0 the format changes between releases under the same number, so +/// only the release that wrote a transcript is sure to read it right. +/// +/// Printed only when the checks pass. A transcript whose format changed fails +/// as a chain break or an invalid line, without this hint. The number does not +/// say which releases share a format, so a warning here does not mean the +/// format differs. +fn unstable_format_warning(header: &rite_model::TranscriptHeader) -> Option { + (header.rite_transcript == 0 && header.producer != rite_runtime::PRODUCER).then(|| { + format!( + "Warning: this transcript was written by {}, and this is {}. The transcript\n\ + format is not stable before 1.0; verify it with the release that wrote it.", + header.producer, + rite_runtime::PRODUCER + ) + }) +} + /// Chain-verify the transcript the argument points at, exiting on failure. /// /// The argument may name the transcript itself or the run directory holding @@ -169,9 +208,37 @@ fn load_or_exit(file: &Path) -> (rite_runtime::LoadedTranscript, Option /// Print what the transcript contained and what the entropy re-derivation /// covered. -fn print_counts(facts: usize, entropy: &rite_runtime::EntropyVerified) { - println!("Transcript verified."); - println!(" Facts: {facts}"); +fn print_counts(loaded: &rite_runtime::LoadedTranscript, entropy: &rite_runtime::EntropyVerified) { + let header = &loaded.header; + // A fact of an unknown type may be the one that matters (a refused + // attestation, a deviation), so its presence qualifies the verdict. + if loaded.unknown.is_empty() { + println!("Transcript verified."); + } else { + println!( + "Transcript verified, except {} fact(s) of a type this version cannot read.", + loaded.unknown.len() + ); + } + println!( + " Format: {} (vocabulary {})", + header.rite_transcript, header.vocabulary + ); + println!(" Producer: {}", header.producer); + println!(" Run: {}", header.run_id); + println!(" Facts: {}", loaded.facts.len()); + if !loaded.withheld.is_empty() { + println!( + " Withheld: {} line(s), committed but not disclosed", + loaded.withheld.len() + ); + } + for unknown in &loaded.unknown { + println!( + " Unread: line {}, type '{}', committed but not checked", + unknown.line, unknown.type_name + ); + } if let Some(scheme) = &entropy.derivation { println!( " Entropy: {} value(s) re-derived, {} contribution(s) folded ({scheme})", @@ -183,6 +250,242 @@ fn print_counts(facts: usize, entropy: &rite_runtime::EntropyVerified) { } } +/// What checking a bundle's index and definition found. +struct BundleCheck { + lines: Vec, + failed: bool, +} + +/// Check the index and the definition of a bundle directory, or `None` when +/// `dir` is a plain run directory with no index. +fn check_bundle(dir: &Path, loaded: &rite_runtime::LoadedTranscript) -> Option { + use rite_model::bundle::{BundleKind, DEFINITION_FILE, INDEX_FILE}; + + if !dir.join(INDEX_FILE).is_file() { + return None; + } + let mut check = BundleCheck { + lines: Vec::new(), + failed: false, + }; + let index = match crate::bundle::read_index(dir) { + Ok(index) => index, + Err(e) => { + check.lines.push(format!("Bundle: UNREADABLE: {e}")); + check.failed = true; + return Some(check); + } + }; + let disclosure = matches!(index.kind, BundleKind::Disclosure { .. }); + match index.kind { + BundleKind::Complete => { + check.lines.push(format!( + "Bundle: complete, format {}", + index.rite_bundle + )); + if !loaded.withheld.is_empty() { + check.lines.push(format!( + " FAILED: a complete bundle, but its transcript withholds {} line(s)", + loaded.withheld.len() + )); + check.failed = true; + } + } + BundleKind::Disclosure { threshold } => { + check.lines.push(format!( + "Bundle: disclosure up to {threshold}, format {}", + index.rite_bundle + )); + check_disclosure_policy(loaded, threshold, &mut check); + } + _ => { + check + .lines + .push("Bundle: of a kind this version does not know".to_string()); + check.failed = true; + } + } + if index.fingerprint != loaded.fingerprint { + check.lines.push(format!( + " MISMATCH: the index names fingerprint {}", + index.fingerprint + )); + check.failed = true; + } + check_index_files(dir, &index.files, loaded, &mut check); + + let definition_path = dir.join(DEFINITION_FILE); + let line = match ( + definition_path.is_file(), + crate::bundle::create::template_digest(loaded), + ) { + (false, _) => "Definition: not in the bundle".to_string(), + // The definition can name persons and parameter values, and has no + // level of its own, so no disclosure carries it. + (true, _) if disclosure => { + check.failed = true; + format!("Definition: FAILED: a disclosure holds {DEFINITION_FILE}") + } + (true, None) => { + check.failed = true; + "Definition: present, but the transcript records no template digest".to_string() + } + (true, Some(template)) => match std::fs::read(&definition_path) { + Ok(bytes) if rite_model::Sha256Digest::of(&bytes) == *template => { + format!("Definition: ok ({DEFINITION_FILE})") + } + Ok(_) => { + check.failed = true; + format!("Definition: MISMATCH ({DEFINITION_FILE} is not the template {template})") + } + Err(e) => { + check.failed = true; + format!("Definition: ERROR: {e}") + } + }, + }; + check.lines.push(line); + Some(check) +} + +/// Check the index's file list against the bundle. Every file it lists is +/// there, at the path its role names, and an artifact is one a disclosed fact +/// records under that name. Every file the verifier reads as part of the +/// bundle is listed. Any other file in the directory is bound by nothing: it +/// is named, and fails nothing. +fn check_index_files( + dir: &Path, + files: &[rite_model::bundle::BundleFile], + loaded: &rite_runtime::LoadedTranscript, + check: &mut BundleCheck, +) { + use rite_model::bundle::{ + DEFINITION_FILE, FileRole, INDEX_FILE, TRANSCRIPT_FILE, artifact_path, + }; + use std::collections::{BTreeMap, BTreeSet}; + + // Where each disclosed artifact is stored, and the name it is recorded under. + let recorded: BTreeMap = loaded + .facts + .iter() + .filter_map(|timed| { + let StepFact::ArtifactWritten { name, file, .. } = &timed.fact else { + return None; + }; + artifact_path(file).map(|path| (path, name.as_str())) + }) + .collect(); + + check + .lines + .push(format!("Files: {} listed in the index", files.len())); + let mut fail = |line: String| { + check.lines.push(line); + check.failed = true; + }; + + let mut listed = BTreeSet::new(); + for file in files { + let path = file.path.as_str(); + let problem = if !listed.insert(path) { + Some("is listed twice") + } else if !path.split('/').all(rite_model::is_safe_component) { + Some("is not a relative path inside the bundle") + } else if !dir.join(path).is_file() { + Some("is listed but not in the bundle") + } else { + match file.role { + FileRole::Transcript if path == TRANSCRIPT_FILE => None, + FileRole::Definition if path == DEFINITION_FILE => None, + FileRole::Artifact => match recorded.get(path) { + Some(name) if file.name.as_deref() == Some(*name) => None, + Some(_) => Some("is listed under another artifact's name"), + None => Some("is listed as an artifact no disclosed fact records"), + }, + _ => Some("is listed with a role its path does not have"), + } + }; + if let Some(problem) = problem { + fail(format!(" FAILED: {path} {problem}")); + } + } + + let mut required = vec![TRANSCRIPT_FILE.to_string()]; + if dir.join(DEFINITION_FILE).is_file() { + required.push(DEFINITION_FILE.to_string()); + } + required.extend(recorded.into_keys().filter(|path| dir.join(path).is_file())); + for path in required + .iter() + .filter(|path| !listed.contains(path.as_str())) + { + fail(format!( + " FAILED: {path} is in the bundle but not in the index" + )); + } + + let mut others = Vec::new(); + collect_files(dir, "", &mut others); + others.retain(|path| { + path != INDEX_FILE && !listed.contains(path.as_str()) && !required.contains(path) + }); + if !others.is_empty() { + check.lines.push(format!( + " Note: not in the index, and not checked: {}", + others.join(", ") + )); + } +} + +/// Every file under `dir`, as `/`-separated paths below it, prefixed by +/// `prefix`. Symbolic links are listed, not followed. +fn collect_files(dir: &Path, prefix: &str, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + let mut entries: Vec<_> = entries.filter_map(Result::ok).collect(); + entries.sort_by_key(std::fs::DirEntry::file_name); + for entry in entries { + let path = format!("{prefix}{}", entry.file_name().to_string_lossy()); + if entry.file_type().is_ok_and(|kind| kind.is_dir()) { + collect_files(&entry.path(), &format!("{path}/"), out); + } else { + out.push(path); + } + } +} + +/// A disclosure withholds exactly what sits above its threshold. A line +/// withheld at or below it, or disclosed above it, makes the disclosure +/// misstate what it is, which fails. Facts of an unknown type count by the +/// level on their line. +fn check_disclosure_policy( + loaded: &rite_runtime::LoadedTranscript, + threshold: rite_model::Level, + check: &mut BundleCheck, +) { + for withheld in loaded.withheld.iter().filter(|w| w.level <= threshold) { + check.lines.push(format!( + " FAILED: line {} is withheld at {}, which this disclosure covers", + withheld.line, withheld.level + )); + check.failed = true; + } + let known = loaded.facts.iter().map(|fact| (fact.line, fact.level)); + let unknown = loaded.unknown.iter().map(|fact| (fact.line, fact.level)); + let mut above: Vec<_> = known + .chain(unknown) + .filter(|(_, level)| *level > threshold) + .collect(); + above.sort_unstable(); + for (line, level) in above { + check.lines.push(format!( + " FAILED: line {line} is disclosed at {level}, above this disclosure's threshold" + )); + check.failed = true; + } +} + /// Print the per-container result lines, and report whether any artifact /// contradicts what the transcript says was done to it. /// @@ -215,7 +518,7 @@ fn summarize_artifacts(checks: &[ArtifactCheck]) -> (bool, bool) { for check in checks { println!(" {}", check.describe()); match check.status { - ArtifactStatus::Match => {} + ArtifactStatus::Match | ArtifactStatus::NoDigest => {} ArtifactStatus::Missing => missing = true, ArtifactStatus::Mismatch { .. } | ArtifactStatus::Error { .. } => failed = true, } @@ -245,6 +548,8 @@ enum ArtifactStatus { }, /// No file at the derived location. Missing, + /// Opened content: the transcript records no digest to check against. + NoDigest, /// The artifact could not be checked at all (unusable recorded path, /// read error). Treated as a failure, like a mismatch. Error { @@ -262,6 +567,10 @@ impl ArtifactCheck { self.name, self.location, ), ArtifactStatus::Missing => format!("{}: missing ({})", self.name, self.location), + ArtifactStatus::NoDigest => format!( + "{}: not checked, opened content has no recorded digest ({})", + self.name, self.location, + ), ArtifactStatus::Error { reason } => format!("{}: ERROR: {reason}", self.name), } } @@ -276,12 +585,12 @@ fn check_artifacts<'a>( .into_iter() .filter_map(|fact| { let StepFact::ArtifactWritten { - name, path, sha256, .. + name, file, digest, .. } = fact else { return None; }; - Some(check_one_artifact(dir, name, path, sha256)) + Some(check_one_artifact(dir, name, file, digest.as_ref())) }) .collect() } @@ -289,29 +598,32 @@ fn check_artifacts<'a>( fn check_one_artifact( dir: &Path, name: &str, - recorded_path: &Path, - recorded_sha256: &str, + file: &str, + recorded: Option<&Sha256Digest>, ) -> ArtifactCheck { - let Some(file_name) = recorded_path.file_name() else { + let Some(location) = rite_model::bundle::artifact_path(file) else { return ArtifactCheck { name: name.to_string(), location: String::new(), status: ArtifactStatus::Error { - reason: format!( - "recorded path '{}' has no file name", - recorded_path.display(), - ), + reason: format!("recorded file name '{file}' is not a single path component"), }, }; }; - let location = artifact_location(file_name); + let Some(recorded) = recorded else { + return ArtifactCheck { + name: name.to_string(), + location, + status: ArtifactStatus::NoDigest, + }; + }; let on_disk = dir.join(&location); let status = if on_disk.is_file() { - match compute_file_fingerprint(&on_disk) { - Ok(actual) if digest_hex(&actual) == digest_hex(recorded_sha256) => { - ArtifactStatus::Match - } - Ok(actual) => ArtifactStatus::Mismatch { actual }, + match std::fs::read(&on_disk).map(|bytes| Sha256Digest::of(&bytes)) { + Ok(actual) if actual == *recorded => ArtifactStatus::Match, + Ok(actual) => ArtifactStatus::Mismatch { + actual: actual.to_string(), + }, Err(e) => ArtifactStatus::Error { reason: format!("could not read {}: {e}", on_disk.display()), }, @@ -321,35 +633,24 @@ fn check_one_artifact( }; ArtifactCheck { name: name.to_string(), - location: location.display().to_string(), + location, status, } } -/// Where an artifact recorded under `file_name` is looked for. -/// -/// The transcript is untrusted input, so the recorded path is never followed. -/// Only its final component names the file, anchored under the run -/// directory's `artifacts/` subdirectory; a crafted transcript therefore -/// cannot point the verifier at files outside the directory being verified. -pub(crate) fn artifact_location(file_name: &std::ffi::OsStr) -> PathBuf { - Path::new("artifacts").join(file_name) -} - /// Bare hex digest, tolerant of the `sha256:` prefix the runtime records. pub(crate) fn digest_hex(s: &str) -> &str { s.strip_prefix("sha256:").unwrap_or(s) } -/// 1-based line number of the first fact whose envelope timestamp is earlier -/// than its predecessor's, if any. One fact per transcript line, so a fact's -/// index maps directly to its line number. +/// Line number of the first fact whose time is earlier than the fact before +/// it, if any. fn first_timestamp_regression(facts: &[TimedFact]) -> Option { facts .iter() .zip(facts.iter().skip(1)) - .position(|(prev, next)| next.at < prev.at) - .map(|i| i.saturating_add(2)) + .find(|(prev, next)| next.at < prev.at) + .map(|(_, next)| next.line) } #[cfg(test)] @@ -357,14 +658,13 @@ mod tests { use super::*; use chrono::{DateTime, TimeZone, Utc}; use rite_model::StepId; - use rite_runtime::compute_fingerprint; - fn artifact_fact(path: &str, sha256: String) -> StepFact { + fn artifact_fact(file: &str, bytes: &[u8]) -> StepFact { StepFact::ArtifactWritten { step: StepId::new("s1"), - name: "root.crt".to_string(), - path: PathBuf::from(path), - sha256, + name: "root".to_string(), + file: file.to_string(), + digest: Some(Sha256Digest::of(bytes)), } } @@ -378,10 +678,7 @@ mod tests { fn artifact_with_matching_bytes_passes() { let tmp = tempfile::tempdir().expect("tempdir"); write_artifact(tmp.path(), "root.crt", b"cert bytes"); - let fact = artifact_fact( - "/original/run/artifacts/root.crt", - compute_fingerprint(b"cert bytes"), - ); + let fact = artifact_fact("root.crt", b"cert bytes"); let checks = check_artifacts(tmp.path(), [&fact]); assert_eq!(checks.len(), 1); assert_eq!( @@ -394,10 +691,7 @@ mod tests { fn artifact_with_different_bytes_is_a_mismatch() { let tmp = tempfile::tempdir().expect("tempdir"); write_artifact(tmp.path(), "root.crt", b"tampered bytes"); - let fact = artifact_fact( - "/original/run/artifacts/root.crt", - compute_fingerprint(b"cert bytes"), - ); + let fact = artifact_fact("root.crt", b"cert bytes"); let checks = check_artifacts(tmp.path(), [&fact]); assert!(matches!( checks.first().expect("one check").status, @@ -408,10 +702,7 @@ mod tests { #[test] fn artifact_absent_from_disk_is_reported_missing() { let tmp = tempfile::tempdir().expect("tempdir"); - let fact = artifact_fact( - "/original/run/artifacts/root.crt", - compute_fingerprint(b"cert bytes"), - ); + let fact = artifact_fact("root.crt", b"cert bytes"); let checks = check_artifacts(tmp.path(), [&fact]); assert_eq!( checks.first().expect("one check").status, @@ -420,27 +711,37 @@ mod tests { } #[test] - fn recorded_path_is_never_followed_outside_the_run_directory() { - // A crafted transcript records a traversal path; only the file name - // is used, anchored under artifacts/, so the check looks for - // artifacts/passwd inside the run directory and nothing else. + fn a_recorded_name_that_is_not_one_component_is_an_error() { + // A crafted transcript cannot point the verifier outside artifacts/. let tmp = tempfile::tempdir().expect("tempdir"); - let fact = artifact_fact("../../../../etc/passwd", compute_fingerprint(b"x")); - let checks = check_artifacts(tmp.path(), [&fact]); - let check = checks.first().expect("one check"); - assert_eq!(check.location, "artifacts/passwd"); - assert_eq!(check.status, ArtifactStatus::Missing); + for file in ["../../../../etc/passwd", "/etc/passwd", "a/b", "", ".."] { + let fact = artifact_fact(file, b"x"); + let checks = check_artifacts(tmp.path(), [&fact]); + assert!( + matches!( + checks.first().expect("one check").status, + ArtifactStatus::Error { .. } + ), + "accepted {file:?}" + ); + } } #[test] - fn recorded_path_without_a_file_name_is_an_error() { + fn opened_content_is_reported_unchecked() { let tmp = tempfile::tempdir().expect("tempdir"); - let fact = artifact_fact("/", compute_fingerprint(b"x")); + write_artifact(tmp.path(), "opened.bin", b"plaintext"); + let fact = StepFact::ArtifactWritten { + step: StepId::new("s1"), + name: "opened".to_string(), + file: "opened.bin".to_string(), + digest: None, + }; let checks = check_artifacts(tmp.path(), [&fact]); - assert!(matches!( + assert_eq!( checks.first().expect("one check").status, - ArtifactStatus::Error { .. } - )); + ArtifactStatus::NoDigest + ); } #[test] @@ -452,11 +753,13 @@ mod tests { fn timed(facts_seconds: &[i64]) -> Vec { facts_seconds .iter() - .map(|s| TimedFact { + .enumerate() + .map(|(i, s)| TimedFact { + // Line 1 is the header. + line: i.saturating_add(2), at: ts(*s), - fact: StepFact::CeremonyStarted { - name: "t".to_string(), - }, + level: rite_model::Level::PUBLIC, + fact: rite_runtime::test_support::ceremony_started("t"), }) .collect() } @@ -472,10 +775,10 @@ mod tests { #[test] fn a_backwards_timestamp_is_reported_with_its_line_number() { - // Line 3 (1-based) goes backwards relative to line 2. + // The third fact, on line 4 after the header, goes backwards. assert_eq!( first_timestamp_regression(&timed(&[10, 20, 15, 30])), - Some(3) + Some(4) ); } @@ -483,4 +786,246 @@ mod tests { fn empty_fact_list_raises_no_warning() { assert_eq!(first_timestamp_regression(&[]), None); } + + /// A transcript of one public fact on line 2, one withheld restricted + /// line on line 3, and whatever `edit` adds. + fn disclosure_check(edit: impl FnOnce(&mut rite_runtime::LoadedTranscript)) -> BundleCheck { + use rite_model::Level; + let mut loaded = rite_runtime::LoadedTranscript { + header: rite_model::TranscriptHeader::new("rite test", "0", false), + facts: timed(&[10]), + withheld: vec![rite_runtime::WithheldLine { + line: 3, + level: Level::RESTRICTED, + }], + unknown: Vec::new(), + fingerprint: Sha256Digest::of(b"fingerprint"), + terminated: false, + }; + edit(&mut loaded); + let mut check = BundleCheck { + lines: Vec::new(), + failed: false, + }; + check_disclosure_policy(&loaded, Level::PUBLIC, &mut check); + check + } + + #[test] + fn a_disclosure_that_withholds_what_is_above_its_threshold_passes() { + let check = disclosure_check(|_| {}); + assert!(!check.failed, "{:?}", check.lines); + } + + #[test] + fn a_line_withheld_at_the_threshold_fails() { + let check = disclosure_check(|loaded| { + loaded.withheld.push(rite_runtime::WithheldLine { + line: 4, + level: rite_model::Level::PUBLIC, + }); + }); + assert!(check.failed); + assert_eq!( + check.lines, + [" FAILED: line 4 is withheld at public, which this disclosure covers"] + ); + } + + #[test] + fn a_line_disclosed_above_the_threshold_fails() { + let check = disclosure_check(|loaded| { + let fact = loaded.facts.first_mut().expect("one fact"); + fact.level = rite_model::Level::CONFIDENTIAL; + }); + assert!(check.failed); + assert_eq!( + check.lines, + [" FAILED: line 2 is disclosed at confidential, above this disclosure's threshold"] + ); + } + + #[test] + fn an_unknown_fact_disclosed_above_the_threshold_fails() { + let check = disclosure_check(|loaded| { + loaded.unknown.push(rite_runtime::UnknownFact { + line: 4, + level: rite_model::Level::RESTRICTED, + type_name: "future_fact".to_string(), + }); + }); + assert!(check.failed); + assert_eq!( + check.lines, + [" FAILED: line 4 is disclosed at restricted, above this disclosure's threshold"] + ); + } + + #[test] + fn a_disclosure_holding_the_definition_fails() { + use rite_model::bundle::{BundleFile, BundleIndex, DEFINITION_FILE}; + + let tmp = tempfile::tempdir().expect("tempdir"); + std::fs::write(tmp.path().join("transcript.jsonl"), b"").expect("transcript"); + std::fs::create_dir_all(tmp.path().join("definition")).expect("definition dir"); + std::fs::write(tmp.path().join(DEFINITION_FILE), b"name: demo").expect("definition"); + let fingerprint = Sha256Digest::of(b"fingerprint"); + let index = BundleIndex::disclosure( + fingerprint.clone(), + rite_model::Level::PUBLIC, + vec![BundleFile::transcript(), BundleFile::definition()], + ); + std::fs::write( + tmp.path().join("bundle.json"), + serde_json::to_vec(&index).expect("index json"), + ) + .expect("index"); + let loaded = rite_runtime::LoadedTranscript { + header: rite_model::TranscriptHeader::new("rite test", "0", false), + facts: Vec::new(), + withheld: Vec::new(), + unknown: Vec::new(), + fingerprint, + terminated: true, + }; + + let check = check_bundle(tmp.path(), &loaded).expect("a bundle"); + assert!(check.failed, "{:?}", check.lines); + assert!( + check.lines.contains(&format!( + "Definition: FAILED: a disclosure holds {DEFINITION_FILE}" + )), + "{:?}", + check.lines + ); + } + + /// A bundle directory holding a transcript and the artifact `root.crt`, + /// recorded as `root`, with `files` as its index's list. + fn index_check( + files: &[rite_model::bundle::BundleFile], + extra: &[&str], + ) -> (tempfile::TempDir, BundleCheck) { + let tmp = tempfile::tempdir().expect("tempdir"); + std::fs::write(tmp.path().join("transcript.jsonl"), b"").expect("transcript"); + std::fs::write(tmp.path().join("bundle.json"), b"").expect("index"); + write_artifact(tmp.path(), "root.crt", b"cert"); + for file in extra { + std::fs::write(tmp.path().join(file), b"").expect("extra"); + } + let loaded = rite_runtime::LoadedTranscript { + header: rite_model::TranscriptHeader::new("rite test", "0", false), + facts: vec![TimedFact { + line: 2, + at: ts(10), + level: rite_model::Level::PUBLIC, + fact: StepFact::ArtifactWritten { + step: StepId::new("s1"), + name: "root".to_string(), + file: "root.crt".to_string(), + digest: Some(Sha256Digest::of(b"cert")), + }, + }], + withheld: Vec::new(), + unknown: Vec::new(), + fingerprint: Sha256Digest::of(b"fingerprint"), + terminated: true, + }; + let mut check = BundleCheck { + lines: Vec::new(), + failed: false, + }; + check_index_files(tmp.path(), files, &loaded, &mut check); + (tmp, check) + } + + fn full_list() -> Vec { + use rite_model::bundle::BundleFile; + vec![ + BundleFile::transcript(), + BundleFile::artifact("root", "artifacts/root.crt"), + ] + } + + #[test] + fn an_index_listing_every_file_passes() { + let (_tmp, check) = index_check(&full_list(), &[]); + assert!(!check.failed, "{:?}", check.lines); + } + + #[test] + fn an_empty_file_list_fails_for_every_file_it_leaves_out() { + let (_tmp, check) = index_check(&[], &[]); + assert!(check.failed); + assert_eq!( + check.lines, + [ + "Files: 0 listed in the index", + " FAILED: transcript.jsonl is in the bundle but not in the index", + " FAILED: artifacts/root.crt is in the bundle but not in the index", + ] + ); + } + + #[test] + fn a_listed_file_that_is_not_there_fails() { + let mut files = full_list(); + files.push(rite_model::bundle::BundleFile::definition()); + let (_tmp, check) = index_check(&files, &[]); + assert!(check.failed); + assert!( + check.lines.contains( + &" FAILED: definition/ceremony.rite.yaml is listed but not in the bundle" + .to_string() + ), + "{:?}", + check.lines + ); + } + + #[test] + fn an_artifact_under_another_name_fails() { + use rite_model::bundle::BundleFile; + let files = [ + BundleFile::transcript(), + BundleFile::artifact("other", "artifacts/root.crt"), + ]; + let (_tmp, check) = index_check(&files, &[]); + assert!(check.failed); + assert!(check.lines.contains( + &" FAILED: artifacts/root.crt is listed under another artifact's name".to_string() + )); + } + + #[test] + fn a_path_outside_the_bundle_fails() { + use rite_model::bundle::BundleFile; + let mut files = full_list(); + files.push(BundleFile::artifact("root", "../outside.crt")); + let (_tmp, check) = index_check(&files, &[]); + assert!(check.failed); + assert!(check.lines.contains( + &" FAILED: ../outside.crt is not a relative path inside the bundle".to_string() + )); + } + + #[test] + fn a_file_outside_the_index_is_named_and_fails_nothing() { + let (_tmp, check) = index_check(&full_list(), &[".DS_Store"]); + assert!(!check.failed, "{:?}", check.lines); + assert!( + check + .lines + .contains(&" Note: not in the index, and not checked: .DS_Store".to_string()) + ); + } + + #[test] + fn a_pre_1_0_transcript_from_another_release_is_flagged() { + let mut header = rite_model::TranscriptHeader::new(rite_runtime::PRODUCER, "0", false); + assert_eq!(unstable_format_warning(&header), None); + header.producer = "rite 0.0.1".to_string(); + let warning = unstable_format_warning(&header).expect("a warning"); + assert!(warning.contains("written by rite 0.0.1"), "{warning}"); + } } diff --git a/docs/demo/README.md b/docs/demo/README.md new file mode 100644 index 0000000..a4fb41f --- /dev/null +++ b/docs/demo/README.md @@ -0,0 +1,29 @@ +# Demo outputs + +Sample outputs of the showcase ceremony, [`examples/showcase/demo.rite.yaml`](../../examples/showcase/demo.rite.yaml): +a root signing key generated, certified and exported, with a crypto officer and a witness. + +| File | What it is | +|---|---| +| [`demo-script.html`](demo-script.html) | The printed protocol, from `rite script`, that participants follow and complete by hand. | +| [`demo.gif`](demo.gif) | The ceremony run with `rite run`. | +| [`demo-bundle/`](demo-bundle/) | The evidence bundle of one run, from `rite bundle create`: the complete transcript, the ceremony definition it ran from, and the artifacts. | +| [`demo-report.html`](demo-report.html) | The report of that run, from `rite report`. | +| [`demo-disclosure/`](demo-disclosure/) | The public disclosure of the same run, from `rite bundle disclose --level public`. Every fact above public is withheld, and the definition is left out. | +| [`demo-disclosure-report.html`](demo-disclosure-report.html) | The report of the disclosure: what the public sees, stated as such. | + +The bundle and the disclosure have the same transcript fingerprint. Both verify from a clone: + +```sh +rite verify docs/demo/demo-bundle +rite verify docs/demo/demo-disclosure +``` + +Comparing the two transcripts line by line shows what a disclosure keeps. A withheld line keeps its time, its level +and its two checkpoints, so the chain still folds to the same fingerprint, and drops its salt and its fact. The names of +the crypto officer and the witness are in the bundle, and not in the disclosure. + +## Regenerating + +`render-prints.sh` produces every file here except the GIF, from one run, and `record.sh` records the GIF. See each +script for when to run it. diff --git a/docs/demo/demo-bundle/artifacts/root_cert.pem b/docs/demo/demo-bundle/artifacts/root_cert.pem new file mode 100644 index 0000000..0c7843b --- /dev/null +++ b/docs/demo/demo-bundle/artifacts/root_cert.pem @@ -0,0 +1,30 @@ +-----BEGIN CERTIFICATE----- +MIIFLTCCAxWgAwIBAgIRAM+MDfauYcCnNlr+bfYMLJ0wDQYJKoZIhvcNAQELBQAw +MDEUMBIGA1UECgwLRXhhbXBsZSBPcmcxGDAWBgNVBAMMD0V4YW1wbGUgUm9vdCBD +QTAeFw0yNjA5MjYxMTQzMjBaFw00NjA5MjExMTQzMjBaMDAxFDASBgNVBAoMC0V4 +YW1wbGUgT3JnMRgwFgYDVQQDDA9FeGFtcGxlIFJvb3QgQ0EwggIiMA0GCSqGSIb3 +DQEBAQUAA4ICDwAwggIKAoICAQC6YWxe5njr3+ETYy1DE6I8NXcMgNzaN970fOCy +ERfrFQiIoRCQtFAKPU6RUyPVQTVeEZtaTRbxRxujT/6XdBpwtKsHGLr5sNcckmc1 +FKpfKEV26MrkLNKpSx/IeRKBEKguCbwthuK3oYg+Up3GrVTgB1/oD20RwKRNJCWv +siPqMLozlLE+Qk3s5wGOK92oXTD/S6qvDPnJA+MYkucAMbS9g4oeiVIFDv/npjsZ +KvY6BTBiGdQ8b03DZzqSGwNPQ2U3Qp5dJtu4voC2uSSZEJqdMHAL4r9FkcUS54HW +85IpsIaOB9P8UsW820HBqAdanFlEOzE1QltP7FRggNfavI+BP44PEPWeJqy1I0Sg +9/QPJ44VYD2w4OytoiVatyLs7oVGgiM8R7sIHDs87XYN0GjFDGM6T/fvsBaZEBc1 +o2RuoyBlyv69ndLPReSnPoVwrYZdMpH+4LQlgb8Yycf1kMhx48ZX6smWSSl8+9hm +ghAJiItMRY/QZUEzj7g4+7mZqcMOpFBEET9b4kaOXUMRwlcneEAFsoOJhqSgbcX0 +9MaSGgpBuw0ZK0k6Dt3T4zahgcYp2qNhJbwNEGKDhSQEasgU9Cvel9rCRqL6U1Yy +hy7l86LyAv/Ozi2FiMG+Y+GiMQcJ+jytXRBg2MIjWDKu5JrR891OkMu2PzcS4Eb0 +qU6QlQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAd +BgNVHQ4EFgQUUlZ6sZj4Hw3coF2GyXXTmPaWx1IwDQYJKoZIhvcNAQELBQADggIB +AA9riwnJlUY61Zamx6afvJiv/K4nDo9mP1RiUNb0JpB0RHHTMSRzi2Dd68xjQiOD +4fqiUjfP3udxHigWLDpsQAtxx8Y80Tta0KdzivpDR5tjGt8bLVDkVvfZrr3ApwSz +E+54yu6291SbfRppy3P+OdZllkfpKoA3S4SQF2JpZSmJdrxMAVSo1WN204dzZExz +c2sMBGbkTMVO4gim+SPpgSNqPeJvTwJTYtkVaaknViI35qFv6RIj7zyCGjUwbp89 +VB4qX+X64U6RhTWje6AUeOE4XeOXcSbRXR3+RsLZ0ZGmTOdBOOzldBZIMbNggawc +6jJVgzW1Ty6b23JgaoqOk2OVXBtWPlmXymzVGRduOEh1Tjb0ruS18c6pmwWW3tBq +l/7am8mmRHF1FeZLZFxH2V96etSCmzjVK//By/FGM0jAdXw1qIYn1/czSHn+YcBP +vkruBB2LtM6r+w+LfJEjZOM+pbzXyQQDCAwRMhrgoE40Dbq8rnCcRCyTRI4i908c +mDoF7v73GAGshL4xwyn4sV7vMEG/nr6yI5icQIwJo+tXTYhT35/ykrPQR1N8JdLd +6najYD2byX5bCQpEYMWWYhfoL3EjqkKhF4y+OXM1ohgNCCAyDsck5xXklqt9x49o +WRgDKPpKdSb5ogEBD1BS/x0IbtSUyC0j0KQ5pVg//5uH +-----END CERTIFICATE----- \ No newline at end of file diff --git a/docs/demo/demo-bundle/artifacts/root_public_key.pem b/docs/demo/demo-bundle/artifacts/root_public_key.pem new file mode 100644 index 0000000..d987b5a --- /dev/null +++ b/docs/demo/demo-bundle/artifacts/root_public_key.pem @@ -0,0 +1,14 @@ +-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAumFsXuZ469/hE2MtQxOi +PDV3DIDc2jfe9HzgshEX6xUIiKEQkLRQCj1OkVMj1UE1XhGbWk0W8Ucbo0/+l3Qa +cLSrBxi6+bDXHJJnNRSqXyhFdujK5CzSqUsfyHkSgRCoLgm8LYbit6GIPlKdxq1U +4Adf6A9tEcCkTSQlr7Ij6jC6M5SxPkJN7OcBjivdqF0w/0uqrwz5yQPjGJLnADG0 +vYOKHolSBQ7/56Y7GSr2OgUwYhnUPG9Nw2c6khsDT0NlN0KeXSbbuL6AtrkkmRCa +nTBwC+K/RZHFEueB1vOSKbCGjgfT/FLFvNtBwagHWpxZRDsxNUJbT+xUYIDX2ryP +gT+ODxD1niastSNEoPf0DyeOFWA9sODsraIlWrci7O6FRoIjPEe7CBw7PO12DdBo +xQxjOk/377AWmRAXNaNkbqMgZcr+vZ3Sz0Xkpz6FcK2GXTKR/uC0JYG/GMnH9ZDI +cePGV+rJlkkpfPvYZoIQCYiLTEWP0GVBM4+4OPu5manDDqRQRBE/W+JGjl1DEcJX +J3hABbKDiYakoG3F9PTGkhoKQbsNGStJOg7d0+M2oYHGKdqjYSW8DRBig4UkBGrI +FPQr3pfawkai+lNWMocu5fOi8gL/zs4thYjBvmPhojEHCfo8rV0QYNjCI1gyruSa +0fPdTpDLtj83EuBG9KlOkJUCAwEAAQ== +-----END PUBLIC KEY----- \ No newline at end of file diff --git a/docs/demo/demo-bundle/bundle.json b/docs/demo/demo-bundle/bundle.json new file mode 100644 index 0000000..45b49b7 --- /dev/null +++ b/docs/demo/demo-bundle/bundle.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://ritely.io/schemas/0.6.0/bundle.schema.json", + "rite_bundle": 0, + "kind": "complete", + "fingerprint": "sha256:d8777b67f67b60ca085ac5b8fcf77115925b22414830ec99682aef9c05dff3af", + "files": [ + { + "path": "transcript.jsonl", + "role": "transcript" + }, + { + "path": "definition/ceremony.rite.yaml", + "role": "definition" + }, + { + "path": "artifacts/root_cert.pem", + "role": "artifact", + "name": "root_cert" + }, + { + "path": "artifacts/root_public_key.pem", + "role": "artifact", + "name": "root_public_key" + } + ] +} diff --git a/docs/demo/demo-bundle/definition/ceremony.rite.yaml b/docs/demo/demo-bundle/definition/ceremony.rite.yaml new file mode 100644 index 0000000..cbdab97 --- /dev/null +++ b/docs/demo/demo-bundle/definition/ceremony.rite.yaml @@ -0,0 +1,98 @@ +version: "0.3" +name: "Demo: Root Signing Key Ceremony" +description: | + Generate an offline root signing key on an air-gapped workstation, issue its + self-signed certificate, and export the public half for distribution. Two + roles perform and witness the ceremony, and each attests to what they saw. + +backends: + openssl: + provider: openssl + +output: + root_cert: + type: certificate + description: "Self-signed root certificate." + root_public_key: + type: public_key + description: "Public half of the root key, safe to distribute as a trust anchor." + +roles: + crypto_officer: + name: "Crypto Officer" + person: "Alice Rivera" + witness: + name: "Witness" + person: "Bob Tanaka" + +prerequisites: + - "Air-gapped workstation is powered on with all network interfaces physically disconnected." + - "Both participants have verified each other's identity against photo ID." + +sections: + environment: + name: "Environment Verification" + role: ${role.crypto_officer} + steps: + verify_air_gap: + action: confirm + with: + message: > + Confirm the workstation is isolated from all networks before any + key material exists: all wired interfaces are physically + disconnected, Wi-Fi and Bluetooth radios are disabled, and no + storage is attached except the ceremony media. Verify each item + aloud and have the witness confirm. + description: "Confirm and witness the air-gap before any key material exists." + + keygen: + name: "Key Generation" + role: ${role.crypto_officer} + steps: + generate_root_key: + action: generate_key + backend: openssl + with: + algorithm: RSA-4096 + creates: root_keypair + description: "Generate the root RSA-4096 keypair inside the air-gapped workstation." + generate_root_csr: + action: generate_csr + backend: openssl + reads: + signing_key: ${artifact.root_keypair} + with: + subject: "CN=Example Root CA,O=Example Org" + creates: root_csr + description: "Build the certificate signing request for the root key." + issue_root_cert: + action: issue_certificate + backend: openssl + reads: + signing_key: ${artifact.root_keypair} + csr: ${artifact.root_csr} + with: + profile: root_ca + validity_days: 7300 + creates: root_cert + description: "Issue the self-signed root certificate, valid for 20 years." + export_public_key: + action: export_public + backend: openssl + reads: ${artifact.root_keypair} + creates: root_public_key + description: "Export the public key for distribution as a trust anchor." + + attestation: + name: "Witness Attestation" + steps: + witness_attest: + action: attest + role: ${role.witness} + with: + statement: "I witnessed the generation of the root signing key and the issuance of its certificate." + officer_attest: + action: attest + role: ${role.crypto_officer} + with: + statement: "I generated the root signing key, issued its self-signed certificate, and exported the public key." diff --git a/docs/demo/demo-bundle/transcript.jsonl b/docs/demo/demo-bundle/transcript.jsonl new file mode 100644 index 0000000..003fda9 --- /dev/null +++ b/docs/demo/demo-bundle/transcript.jsonl @@ -0,0 +1,43 @@ +{"$schema":"https://ritely.io/schemas/0.6.0/transcript.schema.json","header":{"dry_run":false,"levels":{"confidential":30,"public":10,"restricted":20},"producer":"rite 0.6.0","rite_transcript":0,"run_id":"0ac4d127ee9b2b05447c9d241c3b5b0c","vocabulary":0},"chain":"sha256:2f958766370d69e8daca670d1a1710f15a0953cdd6627d5ad0be5c090f633504"} +{"at":"2026-09-26T11:43:19.160128Z","level":10,"leaf":"sha256:e3eea9391f728b7121b8dfa1c07e758ba1903020998b627dc0d03778045e83b0","chain":"sha256:a1040b92e53b21903db1b82f400b196ba57564614933e0ed6205487a105b602b","salt":"208d1570c79045bc85d00d30a49fae51","fact":{"name":"Demo: Root Signing Key Ceremony","template":"sha256:c248837e50ceda3ae08e8fbd1f86c8bacd5595f67c2fb265fbc8feb5ba5d4280","type":"ceremony_started"}} +{"at":"2026-09-26T11:43:19.166985Z","level":10,"leaf":"sha256:cd177f24fdd7a8082c21919087aa1f6472ddeb620976ca364f6e7e507c3db478","chain":"sha256:c4c17dc5b0f2c27426a77a9ac1cc6859f01e60e5b69b838c79e24bf3f255e6a3","salt":"4dd8c08a6922a1ce667188b2399c5ab2","fact":{"derivation":"rite-kdf/v1","m":"9ce76959fc1a86de21466dff971cdaff79c9f3c46120321e38964d1b379ee6a6","source":"os","type":"entropy_seeded"}} +{"at":"2026-09-26T11:43:19.175112Z","level":10,"leaf":"sha256:c50bc4d0f1a407f01e1addb53339e44e7a6e0299da8aedddabe2a0d38a0a5efb","chain":"sha256:0f72f90551705074ebe70b5b90853517f62c1412ea71bded2abbe637a53c1c63","salt":"9d5dce2dcae09d8c09dc163a07f3ecb4","fact":{"name":"Crypto Officer","role":"crypto_officer","type":"role_declared"}} +{"at":"2026-09-26T11:43:19.181164Z","level":10,"leaf":"sha256:de7594aba1f2a15ce77c998472566bf1b30e7bafcddf0b078dd65e22f512f55c","chain":"sha256:c067ab2ff93bc2c7c82543e1bb688ef5bee8d44760dfb4d008bd9fc1033f93a5","salt":"3e9801ea4aba2060ec9c9f5e59c77013","fact":{"name":"Witness","role":"witness","type":"role_declared"}} +{"at":"2026-09-26T11:43:19.186273Z","level":30,"leaf":"sha256:42e6d612fedb02edbb45550fce85d233c2b8b4f4db8fde94b6d614e2cf89a483","chain":"sha256:c6ea83df60a130acdd5e0caddf418ab59f4698f5cba83bcb468e40c3f6d597b6","salt":"ae914b6e6b4305ad17bb62ac90c22bfd","fact":{"person":"Alice Rivera","role":"crypto_officer","type":"role_assigned"}} +{"at":"2026-09-26T11:43:19.191351Z","level":30,"leaf":"sha256:0165fef0f4f40caeeb897a178885591c0f1d70f311feecb0591e8ae79c032a1d","chain":"sha256:ff9b5e8de3b54e51e3aca26e835fcbd490f1fa9e40a073196dc66fa54bb762ac","salt":"ecb68ada508b2a066093d7560f524573","fact":{"person":"Bob Tanaka","role":"witness","type":"role_assigned"}} +{"at":"2026-09-26T11:43:19.196483Z","level":20,"leaf":"sha256:cfc7066caa5c90f57d26e4e52a29cc3192cadb12814d001e37990d0f20c768b9","chain":"sha256:48ff3082eec9d81923386198e405b787bb6891f6b50bba2140c2bd1a8e0bf9cc","salt":"02ed5e04967aa586bfe02fb3f13f6669","fact":{"prompt":{"hint":"Press Enter to start the ceremony","type":"continue"},"response":{"type":"acknowledged"},"type":"prompt_answered"}} +{"at":"2026-09-26T11:43:19.200494Z","level":10,"leaf":"sha256:34b357498cd0bc2f58f6385a18c53b3db2e97255c27a09af4840bb62078b6cda","chain":"sha256:72581751acc46934082dc330da97def19e18833e3d861bb9c88f1c2810abe0c4","salt":"52925ebb026a74b0c62e4d34e59276d8","fact":{"id":"verify_air_gap","label":"1","role":"crypto_officer","type":"step_started"}} +{"at":"2026-09-26T11:43:19.204877Z","level":20,"leaf":"sha256:248756c4d972c64ebd2a60a5efb68a2ed70a386aa47bf973af7b2cc61b395b9d","chain":"sha256:37910cf5d3692527cfe09bc2e26eb62ab1630fef89ab553d5953d642ddb3562e","salt":"1de7ad05e849802c9bcc6f24065556a5","fact":{"prompt":{"hint":"Press Enter to start step 1","type":"continue"},"response":{"type":"acknowledged"},"step":"verify_air_gap","type":"prompt_answered"}} +{"at":"2026-09-26T11:43:19.208913Z","level":20,"leaf":"sha256:56b23d241da3b9f26c4bb864473d1d87cc6da4b44b5a918e525565f05ec5ad64","chain":"sha256:c543eb4e62453add98928ff5321ea0e3371495e07a7223ed6da9adb57b1d28aa","salt":"f67488726fbb74a9a9b65ab6b6fbd83f","fact":{"prompt":{"default":null,"question":"Confirm?","type":"confirm"},"response":{"type":"bool","value":true},"step":"verify_air_gap","type":"prompt_answered"}} +{"at":"2026-09-26T11:43:19.213043Z","level":10,"leaf":"sha256:96d645992e1c4f5f23850ddce7f50ec204725fcf09003869e6deab681e8764df","chain":"sha256:a79d33a9bf8a374263e9ab34955e3fc138a6627af07be118c6ec7a673b06ca40","salt":"5c0ff8cfbd0b62c5d8da56c3e2a13e14","fact":{"id":"verify_air_gap","outcome":{"message":"Verification confirmed","status":"completed"},"type":"step_completed"}} +{"at":"2026-09-26T11:43:19.218110Z","level":10,"leaf":"sha256:89a3d02456bbc8ef3f59f59759cb8b2179b485b6b8d6d2bc97029a901b7fa330","chain":"sha256:4357526eedba5af696a92dfbed61be8ef5b3d3f979459e483e2102d300fa247c","salt":"007e3209f1046bbbffdc8b952531c77f","fact":{"id":"generate_root_key","label":"2","role":"crypto_officer","type":"step_started"}} +{"at":"2026-09-26T11:43:19.223255Z","level":20,"leaf":"sha256:e0f57525a07e8ecba1005d839e1eeab47d265dd3f1fa8777a9d7af144b4fb29f","chain":"sha256:1f976328ee950199e822e5976f5a3c82a7fcf51e0b645521055da1fc4b142178","salt":"9dc2dc2ecb553ac927365baa0a5cb03b","fact":{"prompt":{"hint":"Press Enter to start step 2","type":"continue"},"response":{"type":"acknowledged"},"step":"generate_root_key","type":"prompt_answered"}} +{"at":"2026-09-26T11:43:19.228399Z","level":20,"leaf":"sha256:714bfa3493199b1f343b6d6a586dee823661f453486cc5b1ff3099f4619261be","chain":"sha256:98ff9d98addd57f2ce5b11d46e143760a9d8ff85c6cc0b57cfa4cab69246f6f5","salt":"a6ba526845cdbb30cdbdebca88d2255a","fact":{"identity":"openssl-backend=openssl+openssl=3.6.4","name":"openssl","provider":"openssl","type":"backend_bound"}} +{"at":"2026-09-26T11:43:20.941173Z","level":10,"leaf":"sha256:728438da6a4997b2ca71b2bf4e213a71fb3685be5dd78a976056e2bd9861fac0","chain":"sha256:9c0c45763e1171b586617fe13bd1a07b5c4a22f00509892106553641cb300931","salt":"3e16e914be7a250067def9df967ecd72","fact":{"backend":"openssl","fingerprint":"sha256:3c5f3b9f90f36de895baf8164c0f8149e06c58e0587b07470980c470bce362c0","inputs":{"algorithm":"RSA-4096","policy":{"extractable":false,"persistent":true,"sensitive":true,"usages":["sign","verify"],"wrap_with_trusted_only":false}},"kind":"generate_key","outputs":{"key_id":"b01aaf7756c6df946f1bb3cdc56736da","public_key_fingerprint":"sha256:3c5f3b9f90f36de895baf8164c0f8149e06c58e0587b07470980c470bce362c0"},"step":"generate_root_key","type":"backend_operation"}} +{"at":"2026-09-26T11:43:20.945790Z","level":10,"leaf":"sha256:91ba5a8e6f72120fda1e9ef64d84c7dc1d44a7d1f44cddeaaf18f58eb6c67c5e","chain":"sha256:639ac410d3c585fa78ddf216db3ed84aa159e85a88d6e1999f7a513f3dea9d1d","salt":"8ee9522518341d32cb3411f73e4151ec","fact":{"id":"generate_root_key","outcome":{"message":"RSA-4096 keypair generated","status":"completed"},"type":"step_completed"}} +{"at":"2026-09-26T11:43:20.950067Z","level":10,"leaf":"sha256:95020f458038c992a57909a980401e79fa4421c1b9a6ca28bdfcf6e420f746c9","chain":"sha256:f0c3e5e06d11f43393cc3a82c2ff0f6240244686cde60b5c75e37701f0e039ff","salt":"b5931b5c9b63014bd5e2eeccb0b3636c","fact":{"id":"generate_root_csr","label":"3","role":"crypto_officer","type":"step_started"}} +{"at":"2026-09-26T11:43:20.954036Z","level":20,"leaf":"sha256:c073a80b640324f56ba0b5ab08df24022b2fc55dc3c8e32af4a19a16f8c10765","chain":"sha256:e8c5d08013d71c2d185b3afce2c043fa6667db0c6fd2ac5d59682de27c32b7af","salt":"24c1fedbcd993dde7746e8580820f53b","fact":{"prompt":{"hint":"Press Enter to start step 3","type":"continue"},"response":{"type":"acknowledged"},"step":"generate_root_csr","type":"prompt_answered"}} +{"at":"2026-09-26T11:43:20.963587Z","level":10,"leaf":"sha256:cc8b5c0c755e462f81df66d2cf6c93bfd739308303b264f093fa1164a6203368","chain":"sha256:6dbc4c05bda321ae7d1fea46177c9a967fe17b5d05ad00d2ee3b02acfc27301d","salt":"036241c06d37ff832e195721ecffd61c","fact":{"backend":"openssl","fingerprint":null,"inputs":{"algorithm":"sha256WithRSAEncryption","signing_key":"root_keypair","subject":"CN=Example Root CA,O=Example Org"},"kind":"generate_csr","outputs":{},"step":"generate_root_csr","type":"backend_operation"}} +{"at":"2026-09-26T11:43:20.968312Z","level":10,"leaf":"sha256:cb96873db7da53ae6f7906ad696d97edb31ca916869986426b5dc605e58735d5","chain":"sha256:aed162c2b98d37c92e5f963d69a3e029d6574bbc6f189f29857040ba7e468c2a","salt":"50eb76e7dd5245129cb38d6b734521d8","fact":{"id":"generate_root_csr","outcome":{"message":"PKCS#10 CSR generated","status":"completed"},"type":"step_completed"}} +{"at":"2026-09-26T11:43:20.973504Z","level":10,"leaf":"sha256:8fd75a86be4039216f4074354e2241db7e9efca82bd1ae0f2f6d5ca6eea1379f","chain":"sha256:200375ef0d38bf8d468945b26dae130f2dd76d78a2a1ee8a5af60a78d963b7ac","salt":"3888b99248ff3be655ddc795f2829d30","fact":{"id":"issue_root_cert","label":"4","role":"crypto_officer","type":"step_started"}} +{"at":"2026-09-26T11:43:20.978494Z","level":20,"leaf":"sha256:a465bc8199b556d5fcb2ad1caa94b5cd536ec9a6b07fece339d97065aaf2802f","chain":"sha256:8df6ea36d1f90542f57a3c9f4120d9aeb563ed204da552d7206406f15576482b","salt":"bcceec8c8f64f56f0b7f65518191b966","fact":{"prompt":{"hint":"Press Enter to start step 4","type":"continue"},"response":{"type":"acknowledged"},"step":"issue_root_cert","type":"prompt_answered"}} +{"at":"2026-09-26T11:43:20.984127Z","level":10,"leaf":"sha256:f0e1fe390fcfc6178736b5aec38b17995eb31aec7e40dbf82277b093923b45e1","chain":"sha256:1620e2b0811d533ea6de89e5b87cd7b85997fc6f07ebe0792c1cb3f2d476fedc","salt":"341aa2d39e21785073a3230ab294899f","fact":{"path":"0/issue_root_cert/cert-serial","step":"issue_root_cert","type":"entropy_drawn","value":"cf8c0df6ae61c0a7365afe6df60c2c9d"}} +{"at":"2026-09-26T11:43:20.991500Z","level":10,"leaf":"sha256:520808e72ea1fc16e2db084060056b0f6870311aeb4d35e6449b5e15a3d85abf","chain":"sha256:d9f061b615979e6c7e3228e59fa117abae0b18e0118132e2903f89078c89b10f","salt":"9a8991387820d4284b80c1926f0c7a07","fact":{"backend":"openssl","fingerprint":null,"inputs":{"algorithm":"sha256WithRSAEncryption","csr":"root_csr","profile":"root_ca","signing_key":"root_keypair","validity_days":7300},"kind":"issue_certificate","outputs":{},"step":"issue_root_cert","type":"backend_operation"}} +{"at":"2026-09-26T11:43:20.996120Z","level":10,"leaf":"sha256:839410d059c232f1dda8eecbda31e59e4d309314fd101ec8d6c921b9c3934ca0","chain":"sha256:9352d1dbbd37bf5a71f70688d2c085eb819d1227ee9bc92b9abee0cc1f4746cf","salt":"529993f868aa426d7eb2501a119a875b","fact":{"id":"issue_root_cert","outcome":{"message":"X.509 certificate issued from CSR","status":"completed"},"type":"step_completed"}} +{"at":"2026-09-26T11:43:21.005284Z","level":10,"leaf":"sha256:f10a002dc4fddf5a1edc5dc0115b251662ba287964d234746e13a6b823bb08f0","chain":"sha256:74b7bccde8893f5aeebadbca4523d93a178e34cdab1cb8e91b15538dc6917fd2","salt":"e2276a5cd05b3eb5a36aefe737acd71b","fact":{"digest":"sha256:e5738701c1081736bc3cfb26f5b3269da6509fe438b42e61208cd989b8686393","file":"root_cert.pem","name":"root_cert","step":"issue_root_cert","type":"artifact_written"}} +{"at":"2026-09-26T11:43:21.009276Z","level":10,"leaf":"sha256:7d8dd8b0ae72029c4e534ff50710040bb3ec0e89120b145bc7552093e736e2c0","chain":"sha256:f4cfe165dc8d91a857b520170a633b35e2e042226a5c8c4ca6fa5edb575217b8","salt":"87eaae2f58e7f024064d5231253f303e","fact":{"id":"export_public_key","label":"5","role":"crypto_officer","type":"step_started"}} +{"at":"2026-09-26T11:43:21.013461Z","level":20,"leaf":"sha256:b5fc520a0d35ddfebe9f718275ae74090eee53a391c305b68f4f28b15d3e68dd","chain":"sha256:a909ef16d96f4645174539cad3fcdb2a984a1509f423d82b70cf0c690f8fd324","salt":"fd95b117cc4b4f626059a0ab92a273c0","fact":{"prompt":{"hint":"Press Enter to start step 5","type":"continue"},"response":{"type":"acknowledged"},"step":"export_public_key","type":"prompt_answered"}} +{"at":"2026-09-26T11:43:21.018476Z","level":10,"leaf":"sha256:96f3938762053bf1269de3187c793674d6769f312db05c44cfc7d4dac1f935e5","chain":"sha256:58add227fd3dc4c632188a9bec5b71fc6631e49006e90fe5a219ad91731af47b","salt":"82c0768636b202913d5e397a8c766564","fact":{"backend":"openssl","fingerprint":"sha256:3c5f3b9f90f36de895baf8164c0f8149e06c58e0587b07470980c470bce362c0","inputs":{"source_artifact":"root_keypair"},"kind":"export_public","outputs":{"exported_key_fingerprint":"sha256:3c5f3b9f90f36de895baf8164c0f8149e06c58e0587b07470980c470bce362c0"},"step":"export_public_key","type":"backend_operation"}} +{"at":"2026-09-26T11:43:21.023636Z","level":10,"leaf":"sha256:6eb4f3d5153aae4dcb428c43cae123944989b53f6ed1f9b838b6e10ff6e62ccc","chain":"sha256:608f6b2c2ba37a010e957a7d0edf937d47085c641a5497c6b8a23c765d4a441a","salt":"a994326eb0c63fd7b4b795b18569ab34","fact":{"id":"export_public_key","outcome":{"message":"Public key exported","status":"completed"},"type":"step_completed"}} +{"at":"2026-09-26T11:43:21.032141Z","level":10,"leaf":"sha256:e03d074c415811b3df6d403ff5eeb842506db432ce02033fb4d655f51a55c535","chain":"sha256:4ddef2fcc3e84af98a6eb7976a988f90da0a9eab28a9f1c794cad4ac8397aee7","salt":"80d5ffb57d2e99fc1d17f37447efdc3c","fact":{"digest":"sha256:59abb8d15f19bfcc1eb0012c04afc53cda277e588c41bd679571274fa02ccb6e","file":"root_public_key.pem","name":"root_public_key","step":"export_public_key","type":"artifact_written"}} +{"at":"2026-09-26T11:43:21.037120Z","level":10,"leaf":"sha256:3996017dc0226d764ea5c84dec71a71fd63815bf1341a2503fef9231ddeed280","chain":"sha256:671ab50e3017d5f5f2ad3499e5edc7e6aaec152ef7af4f5052a9009245b36ee9","salt":"f38b8ca3a7eedb7fd4083860162abc23","fact":{"id":"witness_attest","label":"6","role":"witness","type":"step_started"}} +{"at":"2026-09-26T11:43:21.041168Z","level":20,"leaf":"sha256:6b6af75021f5f1e9bdab8e25db97338b8c704e0bcf809b985f1ba96113d3eebd","chain":"sha256:d7d25596a898f565090b936fe9227a0dbdc2bfba9d73521b7913da90282c2f61","salt":"1de0688f55471fdccbab8ed5b872591b","fact":{"prompt":{"hint":"Press Enter to start step 6","type":"continue"},"response":{"type":"acknowledged"},"step":"witness_attest","type":"prompt_answered"}} +{"at":"2026-09-26T11:43:21.045348Z","level":20,"leaf":"sha256:28a9da16e8c2d88d4af2b7a3708937cc84dcd783d7d6f6bd698848df247e94e5","chain":"sha256:9f9b2cc05a1bef50b47d5c130f73a37c669c94c024ec7d427fedcd8463886ad5","salt":"93f1b9e563e4a4938a1fa807509a9ca5","fact":{"prompt":{"expected":"attest","label":"Type 'attest' to confirm","type":"literal"},"response":{"type":"text","value":"attest"},"step":"witness_attest","type":"prompt_answered"}} +{"at":"2026-09-26T11:43:21.050437Z","level":10,"leaf":"sha256:25daaf3c5a2bdbd6e1f45f0fcbcdb2116e492b6d74c7ada78a2ef58bc3aae04d","chain":"sha256:90d41d78175bd474085a68882a003bff77d50b76cb1d7bb1bf5aa46043d548cd","salt":"c68a5c5ffca32d49308c6f0c06bd6fa9","fact":{"role":"witness","statement":"I witnessed the generation of the root signing key and the issuance of its certificate.","step":"witness_attest","type":"attestation_recorded"}} +{"at":"2026-09-26T11:43:21.055524Z","level":10,"leaf":"sha256:506ecc0213faa7555137ff5644c4879b18ba30ca5ad1d9925734cccc7cbf9001","chain":"sha256:8a0246e65bf2b2bead0b6c7fdbaabdec4b4f4c605573b963aa78012a23fd0dc1","salt":"a07f091b94d23f39cc76c302d5ba0990","fact":{"id":"witness_attest","outcome":{"message":"Attestation recorded for Witness","status":"completed"},"type":"step_completed"}} +{"at":"2026-09-26T11:43:21.060616Z","level":10,"leaf":"sha256:9e7a1109e2951540a34e3575cba67dee1cc440172085f25545f3dc9ee06d56ac","chain":"sha256:b6c9c2be5f726ba33e8f64a38728a44653acc53c7dbe9eeaa14ad279b5de4879","salt":"12155da3ea35fa746498078c491e77fc","fact":{"id":"officer_attest","label":"7","role":"crypto_officer","type":"step_started"}} +{"at":"2026-09-26T11:43:21.064709Z","level":20,"leaf":"sha256:1f5982dfd4b73416e49c257ab77e367e28987018997e9d55e49704c363f9866e","chain":"sha256:4681624dec3c7697ef58a3bf0bc4aea0eec2f2df5a69a1a836c3714b8f948558","salt":"3072776d64aa31ccdb48d1e3f6f3dd42","fact":{"prompt":{"hint":"Press Enter to start step 7","type":"continue"},"response":{"type":"acknowledged"},"step":"officer_attest","type":"prompt_answered"}} +{"at":"2026-09-26T11:43:21.069049Z","level":20,"leaf":"sha256:9fb92957f5c0ebf922fcedb547c153b66443b7069410552c589b4b29f26dc3ff","chain":"sha256:8dbaeec2f02e07c0ec137943498946e68af8026dd138a621adbc2ed56cb02cb4","salt":"aebfc60b2eef8e335d2f75ca134aeb49","fact":{"prompt":{"expected":"attest","label":"Type 'attest' to confirm","type":"literal"},"response":{"type":"text","value":"attest"},"step":"officer_attest","type":"prompt_answered"}} +{"at":"2026-09-26T11:43:21.073003Z","level":10,"leaf":"sha256:529ecbcbb9667d1fd33d28cf829914b37e387fb19b13ca35fd905dca05b3c6b6","chain":"sha256:b9ab0d0aed336105bfeaaed6a974c0ed9ff97dad4c770299b16cedfa7739d114","salt":"f362f6d796bbd1fc55ef7edd59e367fb","fact":{"role":"crypto_officer","statement":"I generated the root signing key, issued its self-signed certificate, and exported the public key.","step":"officer_attest","type":"attestation_recorded"}} +{"at":"2026-09-26T11:43:21.077213Z","level":10,"leaf":"sha256:5343f8e1b167a8187c098b427712e456a92d0cafc01fc4453feca95c5b758904","chain":"sha256:aff1f09f3929bcf6b5d52171d6a188fd1d0196ae0d12b7f80f1e00837e6b1907","salt":"6afe2ba0f0cfc67b2de93c9358b32321","fact":{"id":"officer_attest","outcome":{"message":"Attestation recorded for Crypto Officer","status":"completed"},"type":"step_completed"}} +{"at":"2026-09-26T11:43:21.081274Z","level":10,"leaf":"sha256:90ad90a9b51c73e3a1f4c6f92b1eaf1c7f4621ed0dcc7f286dcce0468d4c0fcf","chain":"sha256:d8777b67f67b60ca085ac5b8fcf77115925b22414830ec99682aef9c05dff3af","salt":"e39db07b84fe0dfb4ea7d4416583d25b","fact":{"type":"ceremony_completed"}} diff --git a/docs/demo/demo-disclosure-report.html b/docs/demo/demo-disclosure-report.html new file mode 100644 index 0000000..f6c745e --- /dev/null +++ b/docs/demo/demo-disclosure-report.html @@ -0,0 +1,571 @@ + + + + + Demo: Root Signing Key Ceremony · Ceremony Report + + + +
+ +

Rite-ly

+

Ceremony Report

+

Demo: Root Signing Key Ceremony

+
+ +
+

Status: Completed

+

Started: 2026-09-26 11:43:19 UTC

+

Completed: 2026-09-26 11:43:21 UTC

+

Duration: 1s

+

Transcript fingerprint: sha256:d8777b67f67b60ca085ac5b8fcf77115925b22414830ec99682aef9c05dff3af

+

Disclosure: 14 fact(s) withheld, recorded at restricted, confidential. This report shows the disclosed part of the transcript; the fingerprint covers the complete record.

+
+

Artifacts

+ + + + + + +
NameStepFileFingerprint
root_certissue_root_certroot_cert.pemsha256:e5738701c1081736bc3cfb26f5b3269da6509fe438b42e61208cd989b8686393
root_public_keyexport_public_keyroot_public_key.pemsha256:59abb8d15f19bfcc1eb0012c04afc53cda277e588c41bd679571274fa02ccb6e
+

Roles

+
    +
  • CO Crypto Officer
  • +
  • Wi Witness
  • +
+

Execution Log

+ + + + + + + + + + + +
LabelStepRoleStartedCompletedOutcome
1verify_air_gapCO2026-09-26 11:43:19 UTC2026-09-26 11:43:19 UTCcompleted, Verification confirmed
2generate_root_keyCO2026-09-26 11:43:19 UTC2026-09-26 11:43:20 UTCcompleted, RSA-4096 keypair generated
3generate_root_csrCO2026-09-26 11:43:20 UTC2026-09-26 11:43:20 UTCcompleted, PKCS#10 CSR generated
4issue_root_certCO2026-09-26 11:43:20 UTC2026-09-26 11:43:20 UTCcompleted, X.509 certificate issued from CSR
5export_public_keyCO2026-09-26 11:43:21 UTC2026-09-26 11:43:21 UTCcompleted, Public key exported
6witness_attestWi2026-09-26 11:43:21 UTC2026-09-26 11:43:21 UTCcompleted, Attestation recorded for Witness
7officer_attestCO2026-09-26 11:43:21 UTC2026-09-26 11:43:21 UTCcompleted, Attestation recorded for Crypto Officer
+ + + + \ No newline at end of file diff --git a/docs/demo/demo-disclosure/artifacts/root_cert.pem b/docs/demo/demo-disclosure/artifacts/root_cert.pem new file mode 100644 index 0000000..0c7843b --- /dev/null +++ b/docs/demo/demo-disclosure/artifacts/root_cert.pem @@ -0,0 +1,30 @@ +-----BEGIN CERTIFICATE----- +MIIFLTCCAxWgAwIBAgIRAM+MDfauYcCnNlr+bfYMLJ0wDQYJKoZIhvcNAQELBQAw +MDEUMBIGA1UECgwLRXhhbXBsZSBPcmcxGDAWBgNVBAMMD0V4YW1wbGUgUm9vdCBD +QTAeFw0yNjA5MjYxMTQzMjBaFw00NjA5MjExMTQzMjBaMDAxFDASBgNVBAoMC0V4 +YW1wbGUgT3JnMRgwFgYDVQQDDA9FeGFtcGxlIFJvb3QgQ0EwggIiMA0GCSqGSIb3 +DQEBAQUAA4ICDwAwggIKAoICAQC6YWxe5njr3+ETYy1DE6I8NXcMgNzaN970fOCy +ERfrFQiIoRCQtFAKPU6RUyPVQTVeEZtaTRbxRxujT/6XdBpwtKsHGLr5sNcckmc1 +FKpfKEV26MrkLNKpSx/IeRKBEKguCbwthuK3oYg+Up3GrVTgB1/oD20RwKRNJCWv +siPqMLozlLE+Qk3s5wGOK92oXTD/S6qvDPnJA+MYkucAMbS9g4oeiVIFDv/npjsZ +KvY6BTBiGdQ8b03DZzqSGwNPQ2U3Qp5dJtu4voC2uSSZEJqdMHAL4r9FkcUS54HW +85IpsIaOB9P8UsW820HBqAdanFlEOzE1QltP7FRggNfavI+BP44PEPWeJqy1I0Sg +9/QPJ44VYD2w4OytoiVatyLs7oVGgiM8R7sIHDs87XYN0GjFDGM6T/fvsBaZEBc1 +o2RuoyBlyv69ndLPReSnPoVwrYZdMpH+4LQlgb8Yycf1kMhx48ZX6smWSSl8+9hm +ghAJiItMRY/QZUEzj7g4+7mZqcMOpFBEET9b4kaOXUMRwlcneEAFsoOJhqSgbcX0 +9MaSGgpBuw0ZK0k6Dt3T4zahgcYp2qNhJbwNEGKDhSQEasgU9Cvel9rCRqL6U1Yy +hy7l86LyAv/Ozi2FiMG+Y+GiMQcJ+jytXRBg2MIjWDKu5JrR891OkMu2PzcS4Eb0 +qU6QlQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAd +BgNVHQ4EFgQUUlZ6sZj4Hw3coF2GyXXTmPaWx1IwDQYJKoZIhvcNAQELBQADggIB +AA9riwnJlUY61Zamx6afvJiv/K4nDo9mP1RiUNb0JpB0RHHTMSRzi2Dd68xjQiOD +4fqiUjfP3udxHigWLDpsQAtxx8Y80Tta0KdzivpDR5tjGt8bLVDkVvfZrr3ApwSz +E+54yu6291SbfRppy3P+OdZllkfpKoA3S4SQF2JpZSmJdrxMAVSo1WN204dzZExz +c2sMBGbkTMVO4gim+SPpgSNqPeJvTwJTYtkVaaknViI35qFv6RIj7zyCGjUwbp89 +VB4qX+X64U6RhTWje6AUeOE4XeOXcSbRXR3+RsLZ0ZGmTOdBOOzldBZIMbNggawc +6jJVgzW1Ty6b23JgaoqOk2OVXBtWPlmXymzVGRduOEh1Tjb0ruS18c6pmwWW3tBq +l/7am8mmRHF1FeZLZFxH2V96etSCmzjVK//By/FGM0jAdXw1qIYn1/czSHn+YcBP +vkruBB2LtM6r+w+LfJEjZOM+pbzXyQQDCAwRMhrgoE40Dbq8rnCcRCyTRI4i908c +mDoF7v73GAGshL4xwyn4sV7vMEG/nr6yI5icQIwJo+tXTYhT35/ykrPQR1N8JdLd +6najYD2byX5bCQpEYMWWYhfoL3EjqkKhF4y+OXM1ohgNCCAyDsck5xXklqt9x49o +WRgDKPpKdSb5ogEBD1BS/x0IbtSUyC0j0KQ5pVg//5uH +-----END CERTIFICATE----- \ No newline at end of file diff --git a/docs/demo/demo-disclosure/artifacts/root_public_key.pem b/docs/demo/demo-disclosure/artifacts/root_public_key.pem new file mode 100644 index 0000000..d987b5a --- /dev/null +++ b/docs/demo/demo-disclosure/artifacts/root_public_key.pem @@ -0,0 +1,14 @@ +-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAumFsXuZ469/hE2MtQxOi +PDV3DIDc2jfe9HzgshEX6xUIiKEQkLRQCj1OkVMj1UE1XhGbWk0W8Ucbo0/+l3Qa +cLSrBxi6+bDXHJJnNRSqXyhFdujK5CzSqUsfyHkSgRCoLgm8LYbit6GIPlKdxq1U +4Adf6A9tEcCkTSQlr7Ij6jC6M5SxPkJN7OcBjivdqF0w/0uqrwz5yQPjGJLnADG0 +vYOKHolSBQ7/56Y7GSr2OgUwYhnUPG9Nw2c6khsDT0NlN0KeXSbbuL6AtrkkmRCa +nTBwC+K/RZHFEueB1vOSKbCGjgfT/FLFvNtBwagHWpxZRDsxNUJbT+xUYIDX2ryP +gT+ODxD1niastSNEoPf0DyeOFWA9sODsraIlWrci7O6FRoIjPEe7CBw7PO12DdBo +xQxjOk/377AWmRAXNaNkbqMgZcr+vZ3Sz0Xkpz6FcK2GXTKR/uC0JYG/GMnH9ZDI +cePGV+rJlkkpfPvYZoIQCYiLTEWP0GVBM4+4OPu5manDDqRQRBE/W+JGjl1DEcJX +J3hABbKDiYakoG3F9PTGkhoKQbsNGStJOg7d0+M2oYHGKdqjYSW8DRBig4UkBGrI +FPQr3pfawkai+lNWMocu5fOi8gL/zs4thYjBvmPhojEHCfo8rV0QYNjCI1gyruSa +0fPdTpDLtj83EuBG9KlOkJUCAwEAAQ== +-----END PUBLIC KEY----- \ No newline at end of file diff --git a/docs/demo/demo-disclosure/bundle.json b/docs/demo/demo-disclosure/bundle.json new file mode 100644 index 0000000..d266bc2 --- /dev/null +++ b/docs/demo/demo-disclosure/bundle.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://ritely.io/schemas/0.6.0/bundle.schema.json", + "rite_bundle": 0, + "kind": "disclosure", + "threshold": 10, + "fingerprint": "sha256:d8777b67f67b60ca085ac5b8fcf77115925b22414830ec99682aef9c05dff3af", + "files": [ + { + "path": "transcript.jsonl", + "role": "transcript" + }, + { + "path": "artifacts/root_cert.pem", + "role": "artifact", + "name": "root_cert" + }, + { + "path": "artifacts/root_public_key.pem", + "role": "artifact", + "name": "root_public_key" + } + ] +} diff --git a/docs/demo/demo-disclosure/transcript.jsonl b/docs/demo/demo-disclosure/transcript.jsonl new file mode 100644 index 0000000..eef7c2c --- /dev/null +++ b/docs/demo/demo-disclosure/transcript.jsonl @@ -0,0 +1,43 @@ +{"$schema":"https://ritely.io/schemas/0.6.0/transcript.schema.json","header":{"dry_run":false,"levels":{"confidential":30,"public":10,"restricted":20},"producer":"rite 0.6.0","rite_transcript":0,"run_id":"0ac4d127ee9b2b05447c9d241c3b5b0c","vocabulary":0},"chain":"sha256:2f958766370d69e8daca670d1a1710f15a0953cdd6627d5ad0be5c090f633504"} +{"at":"2026-09-26T11:43:19.160128Z","level":10,"leaf":"sha256:e3eea9391f728b7121b8dfa1c07e758ba1903020998b627dc0d03778045e83b0","chain":"sha256:a1040b92e53b21903db1b82f400b196ba57564614933e0ed6205487a105b602b","salt":"208d1570c79045bc85d00d30a49fae51","fact":{"name":"Demo: Root Signing Key Ceremony","template":"sha256:c248837e50ceda3ae08e8fbd1f86c8bacd5595f67c2fb265fbc8feb5ba5d4280","type":"ceremony_started"}} +{"at":"2026-09-26T11:43:19.166985Z","level":10,"leaf":"sha256:cd177f24fdd7a8082c21919087aa1f6472ddeb620976ca364f6e7e507c3db478","chain":"sha256:c4c17dc5b0f2c27426a77a9ac1cc6859f01e60e5b69b838c79e24bf3f255e6a3","salt":"4dd8c08a6922a1ce667188b2399c5ab2","fact":{"derivation":"rite-kdf/v1","m":"9ce76959fc1a86de21466dff971cdaff79c9f3c46120321e38964d1b379ee6a6","source":"os","type":"entropy_seeded"}} +{"at":"2026-09-26T11:43:19.175112Z","level":10,"leaf":"sha256:c50bc4d0f1a407f01e1addb53339e44e7a6e0299da8aedddabe2a0d38a0a5efb","chain":"sha256:0f72f90551705074ebe70b5b90853517f62c1412ea71bded2abbe637a53c1c63","salt":"9d5dce2dcae09d8c09dc163a07f3ecb4","fact":{"name":"Crypto Officer","role":"crypto_officer","type":"role_declared"}} +{"at":"2026-09-26T11:43:19.181164Z","level":10,"leaf":"sha256:de7594aba1f2a15ce77c998472566bf1b30e7bafcddf0b078dd65e22f512f55c","chain":"sha256:c067ab2ff93bc2c7c82543e1bb688ef5bee8d44760dfb4d008bd9fc1033f93a5","salt":"3e9801ea4aba2060ec9c9f5e59c77013","fact":{"name":"Witness","role":"witness","type":"role_declared"}} +{"at":"2026-09-26T11:43:19.186273Z","level":30,"leaf":"sha256:42e6d612fedb02edbb45550fce85d233c2b8b4f4db8fde94b6d614e2cf89a483","chain":"sha256:c6ea83df60a130acdd5e0caddf418ab59f4698f5cba83bcb468e40c3f6d597b6"} +{"at":"2026-09-26T11:43:19.191351Z","level":30,"leaf":"sha256:0165fef0f4f40caeeb897a178885591c0f1d70f311feecb0591e8ae79c032a1d","chain":"sha256:ff9b5e8de3b54e51e3aca26e835fcbd490f1fa9e40a073196dc66fa54bb762ac"} +{"at":"2026-09-26T11:43:19.196483Z","level":20,"leaf":"sha256:cfc7066caa5c90f57d26e4e52a29cc3192cadb12814d001e37990d0f20c768b9","chain":"sha256:48ff3082eec9d81923386198e405b787bb6891f6b50bba2140c2bd1a8e0bf9cc"} +{"at":"2026-09-26T11:43:19.200494Z","level":10,"leaf":"sha256:34b357498cd0bc2f58f6385a18c53b3db2e97255c27a09af4840bb62078b6cda","chain":"sha256:72581751acc46934082dc330da97def19e18833e3d861bb9c88f1c2810abe0c4","salt":"52925ebb026a74b0c62e4d34e59276d8","fact":{"id":"verify_air_gap","label":"1","role":"crypto_officer","type":"step_started"}} +{"at":"2026-09-26T11:43:19.204877Z","level":20,"leaf":"sha256:248756c4d972c64ebd2a60a5efb68a2ed70a386aa47bf973af7b2cc61b395b9d","chain":"sha256:37910cf5d3692527cfe09bc2e26eb62ab1630fef89ab553d5953d642ddb3562e"} +{"at":"2026-09-26T11:43:19.208913Z","level":20,"leaf":"sha256:56b23d241da3b9f26c4bb864473d1d87cc6da4b44b5a918e525565f05ec5ad64","chain":"sha256:c543eb4e62453add98928ff5321ea0e3371495e07a7223ed6da9adb57b1d28aa"} +{"at":"2026-09-26T11:43:19.213043Z","level":10,"leaf":"sha256:96d645992e1c4f5f23850ddce7f50ec204725fcf09003869e6deab681e8764df","chain":"sha256:a79d33a9bf8a374263e9ab34955e3fc138a6627af07be118c6ec7a673b06ca40","salt":"5c0ff8cfbd0b62c5d8da56c3e2a13e14","fact":{"id":"verify_air_gap","outcome":{"message":"Verification confirmed","status":"completed"},"type":"step_completed"}} +{"at":"2026-09-26T11:43:19.218110Z","level":10,"leaf":"sha256:89a3d02456bbc8ef3f59f59759cb8b2179b485b6b8d6d2bc97029a901b7fa330","chain":"sha256:4357526eedba5af696a92dfbed61be8ef5b3d3f979459e483e2102d300fa247c","salt":"007e3209f1046bbbffdc8b952531c77f","fact":{"id":"generate_root_key","label":"2","role":"crypto_officer","type":"step_started"}} +{"at":"2026-09-26T11:43:19.223255Z","level":20,"leaf":"sha256:e0f57525a07e8ecba1005d839e1eeab47d265dd3f1fa8777a9d7af144b4fb29f","chain":"sha256:1f976328ee950199e822e5976f5a3c82a7fcf51e0b645521055da1fc4b142178"} +{"at":"2026-09-26T11:43:19.228399Z","level":20,"leaf":"sha256:714bfa3493199b1f343b6d6a586dee823661f453486cc5b1ff3099f4619261be","chain":"sha256:98ff9d98addd57f2ce5b11d46e143760a9d8ff85c6cc0b57cfa4cab69246f6f5"} +{"at":"2026-09-26T11:43:20.941173Z","level":10,"leaf":"sha256:728438da6a4997b2ca71b2bf4e213a71fb3685be5dd78a976056e2bd9861fac0","chain":"sha256:9c0c45763e1171b586617fe13bd1a07b5c4a22f00509892106553641cb300931","salt":"3e16e914be7a250067def9df967ecd72","fact":{"backend":"openssl","fingerprint":"sha256:3c5f3b9f90f36de895baf8164c0f8149e06c58e0587b07470980c470bce362c0","inputs":{"algorithm":"RSA-4096","policy":{"extractable":false,"persistent":true,"sensitive":true,"usages":["sign","verify"],"wrap_with_trusted_only":false}},"kind":"generate_key","outputs":{"key_id":"b01aaf7756c6df946f1bb3cdc56736da","public_key_fingerprint":"sha256:3c5f3b9f90f36de895baf8164c0f8149e06c58e0587b07470980c470bce362c0"},"step":"generate_root_key","type":"backend_operation"}} +{"at":"2026-09-26T11:43:20.945790Z","level":10,"leaf":"sha256:91ba5a8e6f72120fda1e9ef64d84c7dc1d44a7d1f44cddeaaf18f58eb6c67c5e","chain":"sha256:639ac410d3c585fa78ddf216db3ed84aa159e85a88d6e1999f7a513f3dea9d1d","salt":"8ee9522518341d32cb3411f73e4151ec","fact":{"id":"generate_root_key","outcome":{"message":"RSA-4096 keypair generated","status":"completed"},"type":"step_completed"}} +{"at":"2026-09-26T11:43:20.950067Z","level":10,"leaf":"sha256:95020f458038c992a57909a980401e79fa4421c1b9a6ca28bdfcf6e420f746c9","chain":"sha256:f0c3e5e06d11f43393cc3a82c2ff0f6240244686cde60b5c75e37701f0e039ff","salt":"b5931b5c9b63014bd5e2eeccb0b3636c","fact":{"id":"generate_root_csr","label":"3","role":"crypto_officer","type":"step_started"}} +{"at":"2026-09-26T11:43:20.954036Z","level":20,"leaf":"sha256:c073a80b640324f56ba0b5ab08df24022b2fc55dc3c8e32af4a19a16f8c10765","chain":"sha256:e8c5d08013d71c2d185b3afce2c043fa6667db0c6fd2ac5d59682de27c32b7af"} +{"at":"2026-09-26T11:43:20.963587Z","level":10,"leaf":"sha256:cc8b5c0c755e462f81df66d2cf6c93bfd739308303b264f093fa1164a6203368","chain":"sha256:6dbc4c05bda321ae7d1fea46177c9a967fe17b5d05ad00d2ee3b02acfc27301d","salt":"036241c06d37ff832e195721ecffd61c","fact":{"backend":"openssl","fingerprint":null,"inputs":{"algorithm":"sha256WithRSAEncryption","signing_key":"root_keypair","subject":"CN=Example Root CA,O=Example Org"},"kind":"generate_csr","outputs":{},"step":"generate_root_csr","type":"backend_operation"}} +{"at":"2026-09-26T11:43:20.968312Z","level":10,"leaf":"sha256:cb96873db7da53ae6f7906ad696d97edb31ca916869986426b5dc605e58735d5","chain":"sha256:aed162c2b98d37c92e5f963d69a3e029d6574bbc6f189f29857040ba7e468c2a","salt":"50eb76e7dd5245129cb38d6b734521d8","fact":{"id":"generate_root_csr","outcome":{"message":"PKCS#10 CSR generated","status":"completed"},"type":"step_completed"}} +{"at":"2026-09-26T11:43:20.973504Z","level":10,"leaf":"sha256:8fd75a86be4039216f4074354e2241db7e9efca82bd1ae0f2f6d5ca6eea1379f","chain":"sha256:200375ef0d38bf8d468945b26dae130f2dd76d78a2a1ee8a5af60a78d963b7ac","salt":"3888b99248ff3be655ddc795f2829d30","fact":{"id":"issue_root_cert","label":"4","role":"crypto_officer","type":"step_started"}} +{"at":"2026-09-26T11:43:20.978494Z","level":20,"leaf":"sha256:a465bc8199b556d5fcb2ad1caa94b5cd536ec9a6b07fece339d97065aaf2802f","chain":"sha256:8df6ea36d1f90542f57a3c9f4120d9aeb563ed204da552d7206406f15576482b"} +{"at":"2026-09-26T11:43:20.984127Z","level":10,"leaf":"sha256:f0e1fe390fcfc6178736b5aec38b17995eb31aec7e40dbf82277b093923b45e1","chain":"sha256:1620e2b0811d533ea6de89e5b87cd7b85997fc6f07ebe0792c1cb3f2d476fedc","salt":"341aa2d39e21785073a3230ab294899f","fact":{"path":"0/issue_root_cert/cert-serial","step":"issue_root_cert","type":"entropy_drawn","value":"cf8c0df6ae61c0a7365afe6df60c2c9d"}} +{"at":"2026-09-26T11:43:20.991500Z","level":10,"leaf":"sha256:520808e72ea1fc16e2db084060056b0f6870311aeb4d35e6449b5e15a3d85abf","chain":"sha256:d9f061b615979e6c7e3228e59fa117abae0b18e0118132e2903f89078c89b10f","salt":"9a8991387820d4284b80c1926f0c7a07","fact":{"backend":"openssl","fingerprint":null,"inputs":{"algorithm":"sha256WithRSAEncryption","csr":"root_csr","profile":"root_ca","signing_key":"root_keypair","validity_days":7300},"kind":"issue_certificate","outputs":{},"step":"issue_root_cert","type":"backend_operation"}} +{"at":"2026-09-26T11:43:20.996120Z","level":10,"leaf":"sha256:839410d059c232f1dda8eecbda31e59e4d309314fd101ec8d6c921b9c3934ca0","chain":"sha256:9352d1dbbd37bf5a71f70688d2c085eb819d1227ee9bc92b9abee0cc1f4746cf","salt":"529993f868aa426d7eb2501a119a875b","fact":{"id":"issue_root_cert","outcome":{"message":"X.509 certificate issued from CSR","status":"completed"},"type":"step_completed"}} +{"at":"2026-09-26T11:43:21.005284Z","level":10,"leaf":"sha256:f10a002dc4fddf5a1edc5dc0115b251662ba287964d234746e13a6b823bb08f0","chain":"sha256:74b7bccde8893f5aeebadbca4523d93a178e34cdab1cb8e91b15538dc6917fd2","salt":"e2276a5cd05b3eb5a36aefe737acd71b","fact":{"digest":"sha256:e5738701c1081736bc3cfb26f5b3269da6509fe438b42e61208cd989b8686393","file":"root_cert.pem","name":"root_cert","step":"issue_root_cert","type":"artifact_written"}} +{"at":"2026-09-26T11:43:21.009276Z","level":10,"leaf":"sha256:7d8dd8b0ae72029c4e534ff50710040bb3ec0e89120b145bc7552093e736e2c0","chain":"sha256:f4cfe165dc8d91a857b520170a633b35e2e042226a5c8c4ca6fa5edb575217b8","salt":"87eaae2f58e7f024064d5231253f303e","fact":{"id":"export_public_key","label":"5","role":"crypto_officer","type":"step_started"}} +{"at":"2026-09-26T11:43:21.013461Z","level":20,"leaf":"sha256:b5fc520a0d35ddfebe9f718275ae74090eee53a391c305b68f4f28b15d3e68dd","chain":"sha256:a909ef16d96f4645174539cad3fcdb2a984a1509f423d82b70cf0c690f8fd324"} +{"at":"2026-09-26T11:43:21.018476Z","level":10,"leaf":"sha256:96f3938762053bf1269de3187c793674d6769f312db05c44cfc7d4dac1f935e5","chain":"sha256:58add227fd3dc4c632188a9bec5b71fc6631e49006e90fe5a219ad91731af47b","salt":"82c0768636b202913d5e397a8c766564","fact":{"backend":"openssl","fingerprint":"sha256:3c5f3b9f90f36de895baf8164c0f8149e06c58e0587b07470980c470bce362c0","inputs":{"source_artifact":"root_keypair"},"kind":"export_public","outputs":{"exported_key_fingerprint":"sha256:3c5f3b9f90f36de895baf8164c0f8149e06c58e0587b07470980c470bce362c0"},"step":"export_public_key","type":"backend_operation"}} +{"at":"2026-09-26T11:43:21.023636Z","level":10,"leaf":"sha256:6eb4f3d5153aae4dcb428c43cae123944989b53f6ed1f9b838b6e10ff6e62ccc","chain":"sha256:608f6b2c2ba37a010e957a7d0edf937d47085c641a5497c6b8a23c765d4a441a","salt":"a994326eb0c63fd7b4b795b18569ab34","fact":{"id":"export_public_key","outcome":{"message":"Public key exported","status":"completed"},"type":"step_completed"}} +{"at":"2026-09-26T11:43:21.032141Z","level":10,"leaf":"sha256:e03d074c415811b3df6d403ff5eeb842506db432ce02033fb4d655f51a55c535","chain":"sha256:4ddef2fcc3e84af98a6eb7976a988f90da0a9eab28a9f1c794cad4ac8397aee7","salt":"80d5ffb57d2e99fc1d17f37447efdc3c","fact":{"digest":"sha256:59abb8d15f19bfcc1eb0012c04afc53cda277e588c41bd679571274fa02ccb6e","file":"root_public_key.pem","name":"root_public_key","step":"export_public_key","type":"artifact_written"}} +{"at":"2026-09-26T11:43:21.037120Z","level":10,"leaf":"sha256:3996017dc0226d764ea5c84dec71a71fd63815bf1341a2503fef9231ddeed280","chain":"sha256:671ab50e3017d5f5f2ad3499e5edc7e6aaec152ef7af4f5052a9009245b36ee9","salt":"f38b8ca3a7eedb7fd4083860162abc23","fact":{"id":"witness_attest","label":"6","role":"witness","type":"step_started"}} +{"at":"2026-09-26T11:43:21.041168Z","level":20,"leaf":"sha256:6b6af75021f5f1e9bdab8e25db97338b8c704e0bcf809b985f1ba96113d3eebd","chain":"sha256:d7d25596a898f565090b936fe9227a0dbdc2bfba9d73521b7913da90282c2f61"} +{"at":"2026-09-26T11:43:21.045348Z","level":20,"leaf":"sha256:28a9da16e8c2d88d4af2b7a3708937cc84dcd783d7d6f6bd698848df247e94e5","chain":"sha256:9f9b2cc05a1bef50b47d5c130f73a37c669c94c024ec7d427fedcd8463886ad5"} +{"at":"2026-09-26T11:43:21.050437Z","level":10,"leaf":"sha256:25daaf3c5a2bdbd6e1f45f0fcbcdb2116e492b6d74c7ada78a2ef58bc3aae04d","chain":"sha256:90d41d78175bd474085a68882a003bff77d50b76cb1d7bb1bf5aa46043d548cd","salt":"c68a5c5ffca32d49308c6f0c06bd6fa9","fact":{"role":"witness","statement":"I witnessed the generation of the root signing key and the issuance of its certificate.","step":"witness_attest","type":"attestation_recorded"}} +{"at":"2026-09-26T11:43:21.055524Z","level":10,"leaf":"sha256:506ecc0213faa7555137ff5644c4879b18ba30ca5ad1d9925734cccc7cbf9001","chain":"sha256:8a0246e65bf2b2bead0b6c7fdbaabdec4b4f4c605573b963aa78012a23fd0dc1","salt":"a07f091b94d23f39cc76c302d5ba0990","fact":{"id":"witness_attest","outcome":{"message":"Attestation recorded for Witness","status":"completed"},"type":"step_completed"}} +{"at":"2026-09-26T11:43:21.060616Z","level":10,"leaf":"sha256:9e7a1109e2951540a34e3575cba67dee1cc440172085f25545f3dc9ee06d56ac","chain":"sha256:b6c9c2be5f726ba33e8f64a38728a44653acc53c7dbe9eeaa14ad279b5de4879","salt":"12155da3ea35fa746498078c491e77fc","fact":{"id":"officer_attest","label":"7","role":"crypto_officer","type":"step_started"}} +{"at":"2026-09-26T11:43:21.064709Z","level":20,"leaf":"sha256:1f5982dfd4b73416e49c257ab77e367e28987018997e9d55e49704c363f9866e","chain":"sha256:4681624dec3c7697ef58a3bf0bc4aea0eec2f2df5a69a1a836c3714b8f948558"} +{"at":"2026-09-26T11:43:21.069049Z","level":20,"leaf":"sha256:9fb92957f5c0ebf922fcedb547c153b66443b7069410552c589b4b29f26dc3ff","chain":"sha256:8dbaeec2f02e07c0ec137943498946e68af8026dd138a621adbc2ed56cb02cb4"} +{"at":"2026-09-26T11:43:21.073003Z","level":10,"leaf":"sha256:529ecbcbb9667d1fd33d28cf829914b37e387fb19b13ca35fd905dca05b3c6b6","chain":"sha256:b9ab0d0aed336105bfeaaed6a974c0ed9ff97dad4c770299b16cedfa7739d114","salt":"f362f6d796bbd1fc55ef7edd59e367fb","fact":{"role":"crypto_officer","statement":"I generated the root signing key, issued its self-signed certificate, and exported the public key.","step":"officer_attest","type":"attestation_recorded"}} +{"at":"2026-09-26T11:43:21.077213Z","level":10,"leaf":"sha256:5343f8e1b167a8187c098b427712e456a92d0cafc01fc4453feca95c5b758904","chain":"sha256:aff1f09f3929bcf6b5d52171d6a188fd1d0196ae0d12b7f80f1e00837e6b1907","salt":"6afe2ba0f0cfc67b2de93c9358b32321","fact":{"id":"officer_attest","outcome":{"message":"Attestation recorded for Crypto Officer","status":"completed"},"type":"step_completed"}} +{"at":"2026-09-26T11:43:21.081274Z","level":10,"leaf":"sha256:90ad90a9b51c73e3a1f4c6f92b1eaf1c7f4621ed0dcc7f286dcce0468d4c0fcf","chain":"sha256:d8777b67f67b60ca085ac5b8fcf77115925b22414830ec99682aef9c05dff3af","salt":"e39db07b84fe0dfb4ea7d4416583d25b","fact":{"type":"ceremony_completed"}} diff --git a/docs/demo/demo-report.html b/docs/demo/demo-report.html index 0adfcde..252ca39 100644 --- a/docs/demo/demo-report.html +++ b/docs/demo/demo-report.html @@ -533,17 +533,17 @@

Demo: Root Signing Key Ceremony

Status: Completed

-

Started: 2026-07-23 16:09:32 UTC

-

Completed: 2026-07-23 16:09:33 UTC

-

Duration: 0s

-

Transcript fingerprint: sha256:dc1b8eec6e91ac01d0d870142b641c7ba303c2fec78b1763cd388fee92d30d0a

+

Started: 2026-09-26 11:43:19 UTC

+

Completed: 2026-09-26 11:43:21 UTC

+

Duration: 1s

+

Transcript fingerprint: sha256:d8777b67f67b60ca085ac5b8fcf77115925b22414830ec99682aef9c05dff3af

Artifacts

- + - - + +
NameStepPathFingerprint
NameStepFileFingerprint
root_certissue_root_certartifacts/root_cert.pemsha256:6138e4d87a2eeea14b40f5de8f520143e42f9ed47d074d38019103faedf6f32d
root_public_keyexport_public_keyartifacts/root_public_key.pemsha256:a589d40b33a937c3f477ff0550516b7ff2ee12210d3ae6a6381f60f207cdb256
root_certissue_root_certroot_cert.pemsha256:e5738701c1081736bc3cfb26f5b3269da6509fe438b42e61208cd989b8686393
root_public_keyexport_public_keyroot_public_key.pemsha256:59abb8d15f19bfcc1eb0012c04afc53cda277e588c41bd679571274fa02ccb6e

Roles

@@ -555,16 +555,16 @@

Execution Log

- - - - - - - + + + + + + +
LabelStepRoleStartedCompletedOutcome
1verify_air_gapCO2026-07-23 16:09:32 UTC2026-07-23 16:09:32 UTCcompleted, Verification confirmed
2generate_root_keyCO2026-07-23 16:09:32 UTC2026-07-23 16:09:32 UTCcompleted, RSA-4096 keypair generated
3generate_root_csrCO2026-07-23 16:09:32 UTC2026-07-23 16:09:32 UTCcompleted, PKCS#10 CSR generated
4issue_root_certCO2026-07-23 16:09:32 UTC2026-07-23 16:09:33 UTCcompleted, X.509 certificate issued from CSR
5export_public_keyCO2026-07-23 16:09:33 UTC2026-07-23 16:09:33 UTCcompleted, Public key exported
6witness_attestWi2026-07-23 16:09:33 UTC2026-07-23 16:09:33 UTCcompleted, Attestation recorded for Witness
7officer_attestCO2026-07-23 16:09:33 UTC2026-07-23 16:09:33 UTCcompleted, Attestation recorded for Crypto Officer
1verify_air_gapCO2026-09-26 11:43:19 UTC2026-09-26 11:43:19 UTCcompleted, Verification confirmed
2generate_root_keyCO2026-09-26 11:43:19 UTC2026-09-26 11:43:20 UTCcompleted, RSA-4096 keypair generated
3generate_root_csrCO2026-09-26 11:43:20 UTC2026-09-26 11:43:20 UTCcompleted, PKCS#10 CSR generated
4issue_root_certCO2026-09-26 11:43:20 UTC2026-09-26 11:43:20 UTCcompleted, X.509 certificate issued from CSR
5export_public_keyCO2026-09-26 11:43:21 UTC2026-09-26 11:43:21 UTCcompleted, Public key exported
6witness_attestWi2026-09-26 11:43:21 UTC2026-09-26 11:43:21 UTCcompleted, Attestation recorded for Witness
7officer_attestCO2026-09-26 11:43:21 UTC2026-09-26 11:43:21 UTCcompleted, Attestation recorded for Crypto Officer
- + \ No newline at end of file diff --git a/docs/demo/demo-transcript.jsonl b/docs/demo/demo-transcript.jsonl deleted file mode 100644 index 8ff2d64..0000000 --- a/docs/demo/demo-transcript.jsonl +++ /dev/null @@ -1,37 +0,0 @@ -{"prev_hash":"sha256:0000000000000000000000000000000000000000000000000000000000000000","at":"2026-07-23T16:09:32.687467Z","fact":{"type":"ceremony_started","name":"Demo: Root Signing Key Ceremony"}} -{"prev_hash":"sha256:96e4c68c0d9c3583e635bdde9e501d544065f518589e4a1fa65f6de4bbc40337","at":"2026-07-23T16:09:32.703369Z","fact":{"type":"entropy_seeded","m":"9ec297a6fdad8148462a929ee6f0fb30fea3843e65303f8e2fd5029c3339ad14","source":"os","derivation":"rite-kdf/v1"}} -{"prev_hash":"sha256:279ee7758f69b2afbf1f0dfbc74dfe6e92f908b4895b9718238148794cca2051","at":"2026-07-23T16:09:32.737446Z","fact":{"type":"prompt_answered","prompt":{"type":"continue","hint":"Press Enter to start the ceremony"},"response":{"type":"acknowledged"}}} -{"prev_hash":"sha256:1a4b8d69b9607f1aeaeb2f8f19af08878ff2de7c3872c3e1aa998fe2d60fc21d","at":"2026-07-23T16:09:32.775540Z","fact":{"type":"step_started","id":"verify_air_gap","label":"1","role":"crypto_officer","role_name":"Crypto Officer"}} -{"prev_hash":"sha256:0ca109459f1dfe21f89e0d87bb708497aac6c6d9bbbf1fd018aacbaac5aa29ec","at":"2026-07-23T16:09:32.788697Z","fact":{"type":"prompt_answered","step":"verify_air_gap","prompt":{"type":"continue","hint":"Press Enter to start step 1"},"response":{"type":"acknowledged"}}} -{"prev_hash":"sha256:633050c61e6be27d15d282f43c21b86e685e042b5f0c0e38b9e18536b8f860b1","at":"2026-07-23T16:09:32.793859Z","fact":{"type":"prompt_answered","step":"verify_air_gap","prompt":{"type":"confirm","question":"Confirm?","default":null},"response":{"type":"bool","value":true}}} -{"prev_hash":"sha256:1a8a8741ecccddd6f1479c3425e90ce4e47eda68e9f9935576886ff75f185153","at":"2026-07-23T16:09:32.798843Z","fact":{"type":"step_completed","id":"verify_air_gap","outcome":{"status":"completed","message":"Verification confirmed"}}} -{"prev_hash":"sha256:8fe4b63a59b520faa51b2d551176a48fe80303db25bf8c7f620a4b4902e5d1db","at":"2026-07-23T16:09:32.802779Z","fact":{"type":"step_started","id":"generate_root_key","label":"2","role":"crypto_officer","role_name":"Crypto Officer"}} -{"prev_hash":"sha256:f22a2a10d0c1676612c35798c55b79f4373a4e047c4c43f2b066f17ead870b8b","at":"2026-07-23T16:09:32.807159Z","fact":{"type":"prompt_answered","step":"generate_root_key","prompt":{"type":"continue","hint":"Press Enter to start step 2"},"response":{"type":"acknowledged"}}} -{"prev_hash":"sha256:df9d5a41218f67b8a2f6e00a3536dd79a03d70eda70fe729bb5fbf779ca26e8f","at":"2026-07-23T16:09:32.953190Z","fact":{"type":"backend_operation","step":"generate_root_key","kind":"generate_keypair","inputs":{"algorithm":"RSA-4096"},"outputs":{"backend":"openssl","backend_fingerprint":"openssl-backend=openssl+openssl=3.6.3","key_id":"caed84533cbb267e19955a1228e02d9a","public_key_fingerprint":"sha256:90bfaf3bb68d7941d37677ca949137ef01f9023aa33bc4e440ab8f2483b57305"},"fingerprint":"sha256:90bfaf3bb68d7941d37677ca949137ef01f9023aa33bc4e440ab8f2483b57305"}} -{"prev_hash":"sha256:d753aab9f3354fc8d0254ddbcb6a4a44a5a02fe4c8f389b6c5550a6b472e690a","at":"2026-07-23T16:09:32.957609Z","fact":{"type":"step_completed","id":"generate_root_key","outcome":{"status":"completed","message":"RSA-4096 keypair generated"}}} -{"prev_hash":"sha256:9a1e1f91319e5322b2d4cc65f0750e1d02179612edc768cc9e3443ebb8cdd0a6","at":"2026-07-23T16:09:32.962574Z","fact":{"type":"step_started","id":"generate_root_csr","label":"3","role":"crypto_officer","role_name":"Crypto Officer"}} -{"prev_hash":"sha256:77937a9a12fef7a0332037f49472be0f83745893df58918049375ead39a44c90","at":"2026-07-23T16:09:32.967770Z","fact":{"type":"prompt_answered","step":"generate_root_csr","prompt":{"type":"continue","hint":"Press Enter to start step 3"},"response":{"type":"acknowledged"}}} -{"prev_hash":"sha256:db2ff4f55e27ca37b2bf36088b74e5d250bd3fca5f0bbe0b4f8b002fbb1f4dde","at":"2026-07-23T16:09:32.982052Z","fact":{"type":"backend_operation","step":"generate_root_csr","kind":"generate_csr","inputs":{"algorithm":"sha256WithRSAEncryption","signing_key":"root_keypair","subject":"CN=Example Root CA,O=Example Org"},"outputs":{"backend":"openssl","backend_fingerprint":"openssl-backend=openssl+openssl=3.6.3"},"fingerprint":null}} -{"prev_hash":"sha256:264b343dff7b58824c39a260a721d980d154860e83c94ffc31a1a4dcbdb07297","at":"2026-07-23T16:09:32.987946Z","fact":{"type":"step_completed","id":"generate_root_csr","outcome":{"status":"completed","message":"PKCS#10 CSR generated"}}} -{"prev_hash":"sha256:c8a71576ef0e586b22e0e40eef71be7c3b902b88839f65f58656f789828c117a","at":"2026-07-23T16:09:32.991928Z","fact":{"type":"step_started","id":"issue_root_cert","label":"4","role":"crypto_officer","role_name":"Crypto Officer"}} -{"prev_hash":"sha256:113b973baf040484e6c11adde2d3ada5b7ab47fa6f284854dd9cfacc8889f849","at":"2026-07-23T16:09:32.996327Z","fact":{"type":"prompt_answered","step":"issue_root_cert","prompt":{"type":"continue","hint":"Press Enter to start step 4"},"response":{"type":"acknowledged"}}} -{"prev_hash":"sha256:620a56d2a4af7f73b0d40e188655c9f2776c54293852d05449cf3e2785a80514","at":"2026-07-23T16:09:33.019577Z","fact":{"type":"entropy_drawn","step":"issue_root_cert","path":"0/issue_root_cert/cert-serial","value":"b0869c9f027b2e2dad5ffc0b53222f3b"}} -{"prev_hash":"sha256:36e56444c92898ac025c59d0772e5fc0b79f19ba63562ba453ac6f3be16ee775","at":"2026-07-23T16:09:33.030475Z","fact":{"type":"backend_operation","step":"issue_root_cert","kind":"issue_certificate","inputs":{"algorithm":"sha256WithRSAEncryption","csr":"root_csr","profile":"root_ca","signing_key":"root_keypair","validity_days":7300},"outputs":{"backend":"openssl","backend_fingerprint":"openssl-backend=openssl+openssl=3.6.3"},"fingerprint":null}} -{"prev_hash":"sha256:63c56141bdb01c4cca0e69a4cf380360eeb84fef90200c6e6fe2ccb34e64bc90","at":"2026-07-23T16:09:33.035512Z","fact":{"type":"step_completed","id":"issue_root_cert","outcome":{"status":"completed","message":"X.509 certificate issued from CSR"}}} -{"prev_hash":"sha256:f5338696f3ee53d5916de41099f96528431cae8a736ddfb28c7264c7d0573bb2","at":"2026-07-23T16:09:33.046946Z","fact":{"type":"artifact_written","step":"issue_root_cert","name":"root_cert","path":"artifacts/root_cert.pem","sha256":"sha256:6138e4d87a2eeea14b40f5de8f520143e42f9ed47d074d38019103faedf6f32d"}} -{"prev_hash":"sha256:d5b2d286ee127fd354f2c9c2f88c042b9725bf7554cc80c1f552b4b6940a2dbd","at":"2026-07-23T16:09:33.051926Z","fact":{"type":"step_started","id":"export_public_key","label":"5","role":"crypto_officer","role_name":"Crypto Officer"}} -{"prev_hash":"sha256:3ec17bb8bf7e14a0d20c6d6cad4825aea0377a8297417b8eb414f5c35cbbdb82","at":"2026-07-23T16:09:33.055998Z","fact":{"type":"prompt_answered","step":"export_public_key","prompt":{"type":"continue","hint":"Press Enter to start step 5"},"response":{"type":"acknowledged"}}} -{"prev_hash":"sha256:ea66bcb6a2478fb1a90d28d540e8f2f455300a0c9984a15b66f81bcbaecb0379","at":"2026-07-23T16:09:33.061316Z","fact":{"type":"backend_operation","step":"export_public_key","kind":"export_public","inputs":{"source_artifact":"root_keypair"},"outputs":{"exported_key_fingerprint":"sha256:90bfaf3bb68d7941d37677ca949137ef01f9023aa33bc4e440ab8f2483b57305"},"fingerprint":"sha256:90bfaf3bb68d7941d37677ca949137ef01f9023aa33bc4e440ab8f2483b57305"}} -{"prev_hash":"sha256:81ff51590ad62db4196a216285aa51c9fc51289c554783e378a6b5cfac04db19","at":"2026-07-23T16:09:33.066389Z","fact":{"type":"step_completed","id":"export_public_key","outcome":{"status":"completed","message":"Public key exported"}}} -{"prev_hash":"sha256:220badd6bb62e5056df271f6f49dac649583c6757dfee5139539908d0a3d3528","at":"2026-07-23T16:09:33.076641Z","fact":{"type":"artifact_written","step":"export_public_key","name":"root_public_key","path":"artifacts/root_public_key.pem","sha256":"sha256:a589d40b33a937c3f477ff0550516b7ff2ee12210d3ae6a6381f60f207cdb256"}} -{"prev_hash":"sha256:fa9e70cef33b7377ef3062d2b37bd74b7709fb0d6c6a497419c0e3aa1173a5ab","at":"2026-07-23T16:09:33.081773Z","fact":{"type":"step_started","id":"witness_attest","label":"6","role":"witness","role_name":"Witness"}} -{"prev_hash":"sha256:a7bb13fe31e8475911e789299ef83d46b7a3eae3d86020edbf4cad51f5b1c6d1","at":"2026-07-23T16:09:33.086829Z","fact":{"type":"prompt_answered","step":"witness_attest","prompt":{"type":"continue","hint":"Press Enter to start step 6"},"response":{"type":"acknowledged"}}} -{"prev_hash":"sha256:04df057233695cb2a289bd0af8e5c6fb3b327ae2a49e886131b606cf001c254a","at":"2026-07-23T16:09:33.093031Z","fact":{"type":"prompt_answered","step":"witness_attest","prompt":{"type":"literal","label":"Type 'attest' to confirm","expected":"attest"},"response":{"type":"text","value":"attest"}}} -{"prev_hash":"sha256:7662f146f73b429190a1585190f8a4ef704c2459657425d3aded10c4fa73ecf4","at":"2026-07-23T16:09:33.096905Z","fact":{"type":"attestation_recorded","step":"witness_attest","role":"witness","statement":"I witnessed the generation of the root signing key and the issuance of its certificate."}} -{"prev_hash":"sha256:494b89ea91d3a46722243722b7b82f4e601c8c5621f95ceba4e3868d62afc06b","at":"2026-07-23T16:09:33.101305Z","fact":{"type":"step_completed","id":"witness_attest","outcome":{"status":"completed","message":"Attestation recorded for Witness"}}} -{"prev_hash":"sha256:912369480fb65b298b02ef6412907a3cc977c9dc2d6756acb531586ff25c4cf1","at":"2026-07-23T16:09:33.106379Z","fact":{"type":"step_started","id":"officer_attest","label":"7","role":"crypto_officer","role_name":"Crypto Officer"}} -{"prev_hash":"sha256:ead435ee364877bbd5d72719b0cf44515c0783d7c13c2324c72b806d3c60de91","at":"2026-07-23T16:09:33.111453Z","fact":{"type":"prompt_answered","step":"officer_attest","prompt":{"type":"continue","hint":"Press Enter to start step 7"},"response":{"type":"acknowledged"}}} -{"prev_hash":"sha256:b29eca7fe87271a6a917df094f82341ef6a587e818f7af39f36336f162eb052d","at":"2026-07-23T16:09:33.116614Z","fact":{"type":"prompt_answered","step":"officer_attest","prompt":{"type":"literal","label":"Type 'attest' to confirm","expected":"attest"},"response":{"type":"text","value":"attest"}}} -{"prev_hash":"sha256:384544a5e2609de636083b3b40d507047760100f56efa1ff3e7cb106bafeff6d","at":"2026-07-23T16:09:33.121791Z","fact":{"type":"attestation_recorded","step":"officer_attest","role":"crypto_officer","statement":"I generated the root signing key, issued its self-signed certificate, and exported the public key."}} -{"prev_hash":"sha256:ed3f5bf1c2e7eb66ca7b46e4e61b8ff27ae76c2d8e51f54ab25be2d8463f6d4c","at":"2026-07-23T16:09:33.126863Z","fact":{"type":"step_completed","id":"officer_attest","outcome":{"status":"completed","message":"Attestation recorded for Crypto Officer"}}} -{"prev_hash":"sha256:3600b6554df92d66dd7f1e69f365249ae431eaa998af8ac9300d70196445e158","at":"2026-07-23T16:09:33.131984Z","fact":{"type":"ceremony_completed"}} diff --git a/docs/demo/render-prints.sh b/docs/demo/render-prints.sh index cb1e895..8701c8b 100755 --- a/docs/demo/render-prints.sh +++ b/docs/demo/render-prints.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash # -# Render the showcase demo ceremony's printed script, transcript, and -# post-ceremony report to docs/demo/, committed alongside the GIF as viewable -# sample outputs. The ritely.io website copies these static files as needed. +# Render the showcase demo ceremony's sample outputs to docs/demo/, committed +# alongside the GIF: the printed script, the evidence bundle of one run, its +# public disclosure, and the report of each. docs/demo/README.md describes +# them. The ritely.io website copies these static files as needed. # Kept separate from record.sh so the prints (cheap, no tmux) can be refreshed # without re-recording the GIF. Run it from anywhere: # @@ -10,14 +11,14 @@ # # Requires the debug binary at target/debug/rite, so build it first: # cargo build -p rite. Regenerate whenever the showcase ceremony, the -# script/report templates, or the vendored logo change. +# script/report templates, the transcript format, or the vendored logo change. # -# The script and report are branded with the project name and the logo +# The script and reports are branded with the project name and the logo # vendored at docs/demo/logo.svg (see BRAND_NAME / LOGO below). # -# The transcript and report come from a single headless run, so they agree on -# the same fingerprint. Both embed that run's wall-clock timestamps, so each -# regeneration produces a fresh diff. +# The bundle, the disclosure and both reports come from a single headless run, +# so they agree on the same fingerprint. They embed that run's wall-clock +# timestamps and random values, so each regeneration produces a fresh diff. set -euo pipefail @@ -49,14 +50,26 @@ trap 'rm -rf "$WORK"' EXIT -o "$SCRIPT_DIR/demo-script.html" echo "wrote $SCRIPT_DIR/demo-script.html" -# A headless run produces the transcript; keep it and the report rendered from -# it so the two agree on the same fingerprint. +# One headless run, packed with the ceremony it ran from. "$RITE" run --frontend headless -o "$WORK" "$CEREMONY" >/dev/null OUT=$(ls -d "$WORK"/*/ | head -1) -cp "$OUT/transcript.jsonl" "$SCRIPT_DIR/demo-transcript.jsonl" -echo "wrote $SCRIPT_DIR/demo-transcript.jsonl" -"$RITE" report "$OUT" \ +rm -rf "$SCRIPT_DIR/demo-bundle" "$SCRIPT_DIR/demo-disclosure" +"$RITE" bundle create "$OUT" --definition "$CEREMONY" -o "$SCRIPT_DIR/demo-bundle" >/dev/null +echo "wrote $SCRIPT_DIR/demo-bundle/" + +# Its public disclosure: every fact above public withheld, same fingerprint. +"$RITE" bundle disclose "$SCRIPT_DIR/demo-bundle" --level public \ + -o "$SCRIPT_DIR/demo-disclosure" >/dev/null +echo "wrote $SCRIPT_DIR/demo-disclosure/" + +# A report of each: the complete record, and what the public sees. +"$RITE" report "$SCRIPT_DIR/demo-bundle" \ --brand-name "$BRAND_NAME" \ --logo "$LOGO" \ -o "$SCRIPT_DIR/demo-report.html" echo "wrote $SCRIPT_DIR/demo-report.html" +"$RITE" report "$SCRIPT_DIR/demo-disclosure" \ + --brand-name "$BRAND_NAME" \ + --logo "$LOGO" \ + -o "$SCRIPT_DIR/demo-disclosure-report.html" +echo "wrote $SCRIPT_DIR/demo-disclosure-report.html" diff --git a/docs/development/runtime-and-frontend.md b/docs/development/runtime-and-frontend.md index 8599ad3..b7ed47d 100644 --- a/docs/development/runtime-and-frontend.md +++ b/docs/development/runtime-and-frontend.md @@ -79,19 +79,17 @@ snapshot at ceremony start, `CeremonyOverview` and `SystemInfo`), and ### `StepFact` Every variant of `StepFact` is what would convince a future auditor that -the step happened the way the transcript claims. The kinds: +the step happened the way the transcript claims. The kinds, their levels and their wire form are +in [the transcript format](../transcript-format.md#facts); each fact carries one kind of +information, so it has one level. -- `CeremonyStarted` / `CeremonyCompleted` / `CeremonyFailed` -- `ActStarted` / `StepStarted` / `StepCompleted` -- `PromptAnswered` (the prompt itself plus a redacted response) -- `BackendOperation` (`kind` + structured `inputs` / `outputs` JSON) -- `AttestationRecorded` -- `ArtifactWritten` -- `DeviationRecorded` +Action handlers emit the facts about their own work (`BackendOperation`, `AttestationRecorded`, +`MachineInfoRecorded`, `WrapRecipientRecorded`, `EntropyContributed`, `EntropyDrawn`). The +executor emits the rest at the corresponding lifecycle boundary. -Action handlers emit `BackendOperation` and `AttestationRecorded` -directly. The executor emits the rest automatically at the corresponding -lifecycle boundary. +The wire form of every fact is published as a JSON Schema in [`docs/schema/`](../schema/README.md), +generated from these types by the tests of `rite-model`. A change to a fact changes the +committed schema, and the tests say how to regenerate it. ### `UiCommand` @@ -107,7 +105,8 @@ Action handlers do not touch the channels or the transcript directly. They receive a `&mut Reporter<'_>` and call: ```rust -reporter.fact(StepFact::BackendOperation { … })?; // durable +reporter.backend_operation("sign_data", inputs, outputs, fingerprint)?; // durable +reporter.fact(StepFact::AttestationRecorded { … })?; // durable reporter.log(Icon::Spinner, "signing…")?; // UI-only reporter.progress("verifying", Some(0.42))?; // UI-only let response = reporter.prompt(&Prompt::Confirm { … })?; @@ -127,30 +126,28 @@ trait is small: ```rust pub trait TranscriptSink: Send { - fn record(&mut self, fact: &StepFact) -> io::Result<()>; + fn begin(&mut self, header: &TranscriptHeader) -> io::Result<()>; + fn record(&mut self, at: DateTime, level: Level, fact: &StepFact) -> io::Result<()>; fn finalize(&mut self) -> io::Result; } ``` The default implementation, `JsonlFileSink`, writes one line per fact and `fsync`s before returning, so a fact the executor has moved past -cannot be lost to a subsequent crash or power loss. Each line: +cannot be lost to a subsequent crash or power loss. -```jsonc -{"prev_hash": "sha256:…", "at": "2026-06-01T20:34:51Z", "fact": { "type": "step_started", … }} -``` +`begin` writes the header line, `record` one fact line at the level the executor chose, and +`finalize` returns the `chain` of the last line, the fingerprint. The line layout, the levels and +the commitment chain are specified in [the transcript format](../transcript-format.md); the +construction is in `rite_model::commitment`. Two properties of the sink: + +- The chain advances only after a line is persisted, so a failed write leaves the in-memory chain + where the file is. +- `record` refuses a level the header does not declare, since a reader would refuse the line. -`at` is the wall-clock time the sink stamped when it wrote the line, the -single uniform timestamp for every event. Individual facts carry no -timestamp of their own; a timestamp that is *data* rather than record time -(a future `clock_check` observed time, an RFC 3161 token) would be a field -on the fact. Because `at` is part of the line, it is covered by the hash -chain like the rest of the envelope. - -Each line's SHA-256 is the next line's `prev_hash`. The hash of the -final line *is* the transcript fingerprint; the JSONL is self-identifying -and no sidecar file is written. `rite verify` walks the chain and -returns that fingerprint. +`at` is the wall-clock time the executor supplied, the single timestamp of every fact. A +timestamp that is data rather than record time (an RFC 3161 token, an observed clock) is a field +on its fact. ## Frontend architecture (TEA) diff --git a/docs/evidence-bundles.md b/docs/evidence-bundles.md new file mode 100644 index 0000000..7711971 --- /dev/null +++ b/docs/evidence-bundles.md @@ -0,0 +1,96 @@ +# Evidence bundles + +A run leaves a directory: the transcript and the artifacts the ceremony wrote. An evidence +bundle packages that record for keeping or for handing to someone else, with the ceremony +definition it ran from. A disclosure is a bundle with part of the record withheld, for a wider +audience. + +A bundle and every disclosure made from it verify to the same transcript fingerprint, the one +written on paper at the end of the ceremony. + +## Creating a bundle + +```sh +rite bundle create root-ca-20260926T101500 --definition root-ca.rite.yaml -o root-ca-bundle +``` + +`rite bundle create` copies the transcript, the definition and the artifacts into a new +directory, and checks each file on the way in: + +- the transcript must verify, withhold nothing, and end with `ceremony_completed` or + `ceremony_failed` (`--allow-truncated` accepts an interrupted run); +- the definition must match the digest `ceremony_started` records; `--without-definition` leaves + it out on purpose; +- each artifact must match the digest its `artifact_written` fact records. + +Opened content (decrypted data, recovered secrets) is never bundled. An artifact missing from the +run directory is left out. The output names both. If a check fails, the command removes the +partial bundle. Only the file owner can read the files it writes. + +## Layout + +```text +root-ca-bundle/ + bundle.json the index + transcript.jsonl + definition/ceremony.rite.yaml + artifacts/root_cert.pem + artifacts/root_public_key.pem +``` + +A bundle is a directory. To send one, archive it with any tool, and verify the extracted +directory. + +`bundle.json` lists each file with its role and names the transcript fingerprint: + +```json +{ + "$schema": "https://ritely.io/schemas/0.6.0/bundle.schema.json", + "rite_bundle": 0, + "kind": "complete", + "fingerprint": "sha256:…", + "files": [ + { "path": "transcript.jsonl", "role": "transcript" }, + { "path": "definition/ceremony.rite.yaml", "role": "definition" }, + { "path": "artifacts/root_cert.pem", "role": "artifact", "name": "root_cert" } + ] +} +``` + +The index holds no digests. The transcript already records a digest for every file the index +lists, so the index needs no protection of its own. `rite verify` checks it against the files and +the transcript. + +## Disclosing part of it + +```sh +rite bundle disclose root-ca-bundle --level public -o root-ca-public +``` + +Every fact is recorded at a confidentiality level (see +[the transcript format](transcript-format.md#confidentiality-levels)). `rite bundle disclose` +keeps the facts at or below the level you give, by name or by number, and withholds the rest. A +withheld line keeps its commitment, so the disclosure verifies to the same fingerprint as the +complete bundle. + +A disclosure carries an artifact only when the fact that recorded it is disclosed. It never +carries the ceremony definition, which can name people and parameter values. Its index says +`"kind": "disclosure"` and gives the `threshold`. + +## Verifying + +```sh +rite verify root-ca-bundle +rite verify root-ca-public +``` + +On a bundle, `rite verify` checks the transcript as it does for a run. Then it checks the index: +the fingerprint it names, every file it lists, the definition against its digest, and each +artifact against its digest. A file the index does not list is named but not checked. + +For a disclosure, `rite verify` also checks the threshold. Verification fails if a line at or +below the threshold is withheld, if a line above it is disclosed, or if the bundle holds the +definition. + +Then compare the printed fingerprint with the one written on paper (see +[what `rite verify` proves](transcript-format.md#what-rite-verify-proves)). diff --git a/docs/schema/README.md b/docs/schema/README.md new file mode 100644 index 0000000..5764383 --- /dev/null +++ b/docs/schema/README.md @@ -0,0 +1,51 @@ +# JSON Schemas + +JSON Schemas (draft 2020-12) for the files `rite` writes, so other tools can check them without Rust: + +- [`transcript.schema.json`](transcript.schema.json): one line of `transcript.jsonl`. The first line is the header; every + other line is a fact, complete or withheld. +- [`bundle.schema.json`](bundle.schema.json): the `bundle.json` index of an evidence bundle. + +What the files mean, and how the chain is computed, is in [the transcript format](../transcript-format.md) and +[evidence bundles](../evidence-bundles.md). + +Each schema describes one format version, pinned in the header's `rite_transcript` and `vocabulary`, and in the index's +`rite_bundle`. Integers are bounded to `±(2^53 - 1)`, the range the transcript's canonical form writes. + +## Versions and URLs + +Before 1.0 the three version numbers are 0 and the formats change between releases without a new number. Each release +publishes its schemas at a URL of its own, which is each schema's `$id`: + +```text +https://ritely.io/schemas//transcript.schema.json +https://ritely.io/schemas//bundle.schema.json +``` + +A published schema never changes, so a transcript is checked against the schema of the release that wrote it. `rite` +writes that URL as `$schema` at the top of `bundle.json`, which editors read to validate the index, and on the first line +of `transcript.jsonl`, outside the committed header. Editors do not apply a schema to each line of a JSON Lines file, so +for the transcript the URL is for tools that read it. + +A schema checks the shape of a file, not its integrity. It does not recompute a line's `leaf` or `chain`, check that a +fact is in canonical form, check that each level is declared in the header, or check a disclosure against its threshold. +`rite verify` checks those. + +The transcript schema lists the fact types of its vocabulary and refuses any other. A reader of the transcript format +accepts a fact type from a newer vocabulary and counts it as unknown; a tool that wants the same behaviour checks the +header's `vocabulary` before validating the facts. + +## Regenerating + +The schemas are generated from the Rust types that read and write the files, by the tests of `rite-model`, and a test +fails when the committed files differ. + +The descriptions are not the Rust doc comments. They are written for readers of the files, as a +`schemars(description = "...")` attribute on each type, variant and field, so editing the code's documentation never +changes a schema. An item without one falls back to its doc comment, so a new fact or field needs one. + +After a change to those types or descriptions: + +```sh +RITE_UPDATE_SCHEMA=1 cargo test -p rite-model schema +``` diff --git a/docs/schema/bundle.schema.json b/docs/schema/bundle.schema.json new file mode 100644 index 0000000..c349086 --- /dev/null +++ b/docs/schema/bundle.schema.json @@ -0,0 +1,131 @@ +{ + "$id": "https://ritely.io/schemas/0.6.0/bundle.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Rite bundle index (format 0)", + "description": "The bundle.json index of an evidence bundle: what kind of bundle it is and which files it holds. It repeats no digest: every file other than the transcript is bound by a digest the transcript records, so a verifier checks the files against the transcript.", + "type": "object", + "properties": { + "$schema": { + "description": "The URL of the JSON Schema this index was written against, for editors. A reader ignores it.", + "type": [ + "string", + "null" + ], + "format": "uri" + }, + "files": { + "description": "Every file in the bundle other than the index.", + "type": "array", + "items": { + "$ref": "#/$defs/BundleFile" + } + }, + "fingerprint": { + "description": "The fingerprint of the bundle's transcript, for indexing. A verifier computes it from the transcript and compares.", + "$ref": "#/$defs/Sha256Digest" + }, + "rite_bundle": { + "description": "Version of the bundle format.", + "type": "integer", + "const": 0, + "maximum": 4294967295, + "minimum": 0 + } + }, + "oneOf": [ + { + "description": "The complete record: the transcript with every fact, the ceremony definition if it was included, and the artifacts the run kept.", + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "complete" + } + }, + "required": [ + "kind" + ] + }, + { + "description": "A disclosure derived from a complete bundle: every line above the threshold is withheld, and only the artifacts whose facts are disclosed are included. The ceremony definition is never included.", + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "disclosure" + }, + "threshold": { + "description": "The widest level disclosed: every line at or below it is disclosed, every line above it withheld.", + "$ref": "#/$defs/Level" + } + }, + "required": [ + "kind", + "threshold" + ] + } + ], + "required": [ + "rite_bundle", + "fingerprint", + "files" + ], + "$defs": { + "BundleFile": { + "description": "One file in the bundle.", + "type": "object", + "properties": { + "name": { + "description": "For an artifact, its name as declared in the ceremony.", + "type": [ + "string", + "null" + ] + }, + "path": { + "description": "The file's path from the bundle root, with `/` between components.", + "type": "string" + }, + "role": { + "description": "What the file is.", + "$ref": "#/$defs/FileRole" + } + }, + "required": [ + "path", + "role" + ] + }, + "FileRole": { + "description": "What a file is, which also says what binds it to the transcript.", + "oneOf": [ + { + "description": "The transcript, which the fingerprint identifies.", + "type": "string", + "const": "transcript" + }, + { + "description": "The ceremony definition, bound by the template digest on ceremony_started.", + "type": "string", + "const": "definition" + }, + { + "description": "An artifact, bound by the digest on its artifact_written fact.", + "type": "string", + "const": "artifact" + } + ] + }, + "Level": { + "description": "The confidentiality level of a line: who may see its fact. Levels are integers ordered from the widest audience to the narrowest, so a disclosure up to a level withholds every line above it. Three levels are built in at fixed values: public (10, anyone), restricted (20, auditors under agreement) and confidential (30, the ceremony's own organisation). Every level a transcript uses is declared in its header.", + "type": "integer", + "maximum": 4294967295, + "minimum": 0 + }, + "Sha256Digest": { + "description": "A SHA-256 digest: `sha256:` followed by 64 lowercase hex digits.", + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + } + } +} diff --git a/docs/schema/transcript.schema.json b/docs/schema/transcript.schema.json new file mode 100644 index 0000000..1a0c5ed --- /dev/null +++ b/docs/schema/transcript.schema.json @@ -0,0 +1,1204 @@ +{ + "$id": "https://ritely.io/schemas/0.6.0/transcript.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Rite transcript line (format 0, vocabulary 0)", + "description": "One line of a transcript.jsonl file. The first line is the header; every other line records one fact, either complete or withheld. Each line is a JSON object on its own line, and the lines are chained: each line's chain value commits to the line and to every line before it.", + "anyOf": [ + { + "$ref": "#/$defs/HeaderLine" + }, + { + "$ref": "#/$defs/CompleteLine" + }, + { + "$ref": "#/$defs/WithheldLine" + } + ], + "$defs": { + "ActId": { + "description": "An act's identifier, as written in the ceremony.", + "type": "string" + }, + "At": { + "description": "When the fact was recorded: an RFC 3339 time in UTC with six fractional digits. The chain commits to this exact string.", + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{6}Z$" + }, + "CompleteLine": { + "description": "A fact line with its content: the fact, the salt that hides it, and the values that commit to both.", + "type": "object", + "properties": { + "at": { + "$ref": "#/$defs/At" + }, + "chain": { + "description": "The chain value after this line: SHA-256 over the byte 0x01, the previous chain value, the length of `at` as an 8-byte big-endian integer, `at` in UTF-8, the level as an 8-byte big-endian integer, and the leaf. The chain value of the last line is the transcript's fingerprint.", + "$ref": "#/$defs/Sha256Digest" + }, + "fact": { + "$ref": "#/$defs/StepFact" + }, + "leaf": { + "description": "The commitment to the fact: SHA-256 over the byte 0x00, the 16 salt bytes, and the fact in RFC 8785 canonical JSON.", + "$ref": "#/$defs/Sha256Digest" + }, + "level": { + "$ref": "#/$defs/Level" + }, + "salt": { + "description": "16 random bytes, written as 32 lowercase hex digits. The leaf hashes the decoded bytes. The salt keeps a withheld fact from being guessed from its leaf.", + "type": "string", + "pattern": "^[0-9a-f]{32}$" + } + }, + "additionalProperties": false, + "required": [ + "at", + "level", + "leaf", + "chain", + "salt", + "fact" + ] + }, + "ErrorClass": { + "description": "What kind of bad outcome this was.", + "oneOf": [ + { + "description": "Something around the ceremony was not ready, such as a device not connected, and the step's work did not happen.", + "type": "string", + "const": "environmental" + }, + { + "description": "The ceremony's own logic concluded badly, such as a verification that did not match or a refused attestation.", + "type": "string", + "const": "procedural" + }, + { + "description": "The run itself cannot be trusted to continue, or the ceremony definition is broken.", + "type": "string", + "const": "integrity" + }, + { + "description": "An operator chose to stop the ceremony.", + "type": "string", + "const": "abort" + } + ] + }, + "ErrorRecord": { + "description": "What went wrong: a class for audit, a stable kind, and a message for people.", + "type": "object", + "properties": { + "class": { + "description": "The kind of bad outcome, for audit.", + "$ref": "#/$defs/ErrorClass" + }, + "kind": { + "description": "A stable label for the error, such as aborted or step_failed.", + "type": "string" + }, + "message": { + "description": "A description of the error for people.", + "type": "string" + } + }, + "required": [ + "class", + "kind", + "message" + ] + }, + "Format": { + "description": "What kind of value a person types.", + "oneOf": [ + { + "description": "Anything the person can type.", + "type": "string", + "const": "text" + }, + { + "description": "Decimal digits only.", + "type": "string", + "const": "digits" + }, + { + "description": "ASCII letters and digits.", + "type": "string", + "const": "alphanumeric" + }, + { + "description": "Bytes as hexadecimal, in either case, two digits per byte.", + "type": "string", + "const": "hex" + }, + { + "description": "Bytes as standard base64 with padding.", + "type": "string", + "const": "base64" + } + ] + }, + "HeaderLine": { + "description": "The first line of a transcript: what the file is, and the start of the chain.", + "type": "object", + "properties": { + "$schema": { + "description": "The URL of the JSON Schema of the release that wrote the transcript, for editors and other tools. The chain does not commit to it, and a reader ignores it.", + "type": [ + "string", + "null" + ], + "format": "uri" + }, + "chain": { + "description": "The first chain value: SHA-256 over the byte 0x02 followed by the header in RFC 8785 canonical JSON.", + "$ref": "#/$defs/Sha256Digest" + }, + "header": { + "$ref": "#/$defs/TranscriptHeader" + } + }, + "additionalProperties": false, + "required": [ + "header", + "chain" + ] + }, + "Level": { + "description": "The confidentiality level of a line: who may see its fact. Levels are integers ordered from the widest audience to the narrowest, so a disclosure up to a level withholds every line above it. Three levels are built in at fixed values: public (10, anyone), restricted (20, auditors under agreement) and confidential (30, the ceremony's own organisation). Every level a transcript uses is declared in its header.", + "type": "integer", + "maximum": 4294967295, + "minimum": 0 + }, + "MaterialId": { + "description": "A material's identifier, as written in the ceremony.", + "type": "string" + }, + "ParamId": { + "description": "A parameter's identifier, as written in the ceremony.", + "type": "string" + }, + "Prompt": { + "description": "A prompt as it was shown.", + "oneOf": [ + { + "description": "A yes or no question.", + "type": "object", + "properties": { + "default": { + "description": "The answer given when the person confirms without choosing, if any.", + "type": [ + "boolean", + "null" + ] + }, + "question": { + "description": "The question.", + "type": "string" + }, + "type": { + "type": "string", + "const": "confirm" + } + }, + "required": [ + "type", + "question" + ] + }, + { + "description": "A request for free text, checked before it is accepted.", + "type": "object", + "properties": { + "label": { + "description": "The label shown.", + "type": "string" + }, + "type": { + "type": "string", + "const": "text" + }, + "validator": { + "description": "The check the answer had to pass.", + "$ref": "#/$defs/ValidatorSpec" + } + }, + "required": [ + "type", + "label", + "validator" + ] + }, + { + "description": "A request for a secret, such as a PIN. The answer is never recorded.", + "type": "object", + "properties": { + "label": { + "description": "The label shown.", + "type": "string" + }, + "type": { + "type": "string", + "const": "secret" + }, + "validator": { + "description": "The check the answer had to pass.", + "$ref": "#/$defs/ValidatorSpec" + } + }, + "required": [ + "type", + "label", + "validator" + ] + }, + { + "description": "A request to type a given text exactly, such as a confirmation phrase.", + "type": "object", + "properties": { + "expected": { + "description": "The text that had to be typed.", + "type": "string" + }, + "label": { + "description": "The label shown.", + "type": "string" + }, + "type": { + "type": "string", + "const": "literal" + } + }, + "required": [ + "type", + "label", + "expected" + ] + }, + { + "description": "A pause until the person is ready to continue.", + "type": "object", + "properties": { + "hint": { + "description": "The hint shown, if any.", + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "const": "continue" + } + }, + "required": [ + "type" + ] + } + ] + }, + "ResponseRecord": { + "description": "The answer to a prompt, as recorded. A secret answer is never recorded, not even as a digest.", + "oneOf": [ + { + "description": "A yes or no answer.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "bool" + }, + "value": { + "description": "The answer.", + "type": "boolean" + } + }, + "required": [ + "type", + "value" + ] + }, + { + "description": "A free-text answer.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + }, + "value": { + "description": "The answer, as typed.", + "type": "string" + } + }, + "required": [ + "type", + "value" + ] + }, + { + "description": "A secret was entered. Only the fact that it was entered is recorded.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "secret_redacted" + } + }, + "required": [ + "type" + ] + }, + { + "description": "The person continued past a pause.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "acknowledged" + } + }, + "required": [ + "type" + ] + } + ] + }, + "RoleId": { + "description": "A role's identifier, as written in the ceremony.", + "type": "string" + }, + "Sha256Digest": { + "description": "A SHA-256 digest: `sha256:` followed by 64 lowercase hex digits.", + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "StepFact": { + "description": "One recorded fact, identified by its `type`. Each fact type has a fixed set of fields in a given vocabulary.", + "oneOf": [ + { + "description": "The ceremony started.", + "type": "object", + "properties": { + "name": { + "description": "The ceremony's name.", + "type": "string" + }, + "template": { + "description": "Digest of the ceremony definition file the run was resolved from. The inputs of the run are recorded as their own facts, so this digest covers the definition only.", + "$ref": "#/$defs/Sha256Digest" + }, + "type": { + "type": "string", + "const": "ceremony_started" + } + }, + "required": [ + "type", + "name", + "template" + ] + }, + { + "description": "A role the ceremony defines. Recorded once per role at the start, whether or not anyone is assigned to it; later facts name the role by its identifier.", + "type": "object", + "properties": { + "name": { + "description": "The role's display name.", + "type": "string" + }, + "role": { + "description": "The role.", + "$ref": "#/$defs/RoleId" + }, + "type": { + "type": "string", + "const": "role_declared" + } + }, + "required": [ + "type", + "role", + "name" + ] + }, + { + "description": "A person was assigned to a role for this run. One fact per assignment, so each can be withheld on its own.", + "type": "object", + "properties": { + "person": { + "description": "The person, as supplied when the run started. Recorded as given: nothing in the transcript proves who the person is.", + "type": "string" + }, + "role": { + "description": "The role.", + "$ref": "#/$defs/RoleId" + }, + "type": { + "type": "string", + "const": "role_assigned" + } + }, + "required": [ + "type", + "role", + "person" + ] + }, + { + "description": "A parameter took its value for this run, supplied or defaulted.", + "type": "object", + "properties": { + "name": { + "description": "The parameter.", + "$ref": "#/$defs/ParamId" + }, + "type": { + "type": "string", + "const": "parameter_bound" + }, + "value": { + "description": "The value, as the ceremony used it." + } + }, + "required": [ + "type", + "name", + "value" + ] + }, + { + "description": "A material was loaded at the start of the run. The digest of a digital material is a separate fact, material_digest, so the two can be disclosed to different audiences.", + "type": "object", + "properties": { + "identifier": { + "description": "The identifier of a physical or pre-provisioned material, such as a serial number, when one was supplied.", + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "The material.", + "$ref": "#/$defs/MaterialId" + }, + "type": { + "type": "string", + "const": "material_loaded" + } + }, + "required": [ + "type", + "name" + ] + }, + { + "description": "The digest of a digital material's bytes, as loaded.", + "type": "object", + "properties": { + "digest": { + "description": "The digest of the material's bytes.", + "$ref": "#/$defs/Sha256Digest" + }, + "name": { + "description": "The material.", + "$ref": "#/$defs/MaterialId" + }, + "type": { + "type": "string", + "const": "material_digest" + } + }, + "required": [ + "type", + "name", + "digest" + ] + }, + { + "description": "A backend was used for the first time in this run. Recorded once per backend, before its first operation; operations name the backend and do not repeat its identity.", + "type": "object", + "properties": { + "identity": { + "description": "The identity the backend reports for itself, such as a device serial number and firmware version.", + "type": "string" + }, + "name": { + "description": "The backend's name in the ceremony.", + "type": "string" + }, + "provider": { + "description": "The kind of backend, such as openssl, yubikey or pkcs11.", + "type": "string" + }, + "type": { + "type": "string", + "const": "backend_bound" + } + }, + "required": [ + "type", + "name", + "provider", + "identity" + ] + }, + { + "description": "A description of the machine the ceremony ran on.", + "type": "object", + "properties": { + "info": { + "description": "The parts of the machine description the step was asked to record." + }, + "step": { + "description": "The step that recorded it.", + "$ref": "#/$defs/StepId" + }, + "type": { + "type": "string", + "const": "machine_info_recorded" + } + }, + "required": [ + "type", + "step", + "info" + ] + }, + { + "description": "An act, a named part of the ceremony, started.", + "type": "object", + "properties": { + "id": { + "description": "The act.", + "$ref": "#/$defs/ActId" + }, + "label": { + "description": "The act's label, as written in the ceremony.", + "type": "string" + }, + "type": { + "type": "string", + "const": "act_started" + } + }, + "required": [ + "type", + "id", + "label" + ] + }, + { + "description": "A step started.", + "type": "object", + "properties": { + "id": { + "description": "The step.", + "$ref": "#/$defs/StepId" + }, + "label": { + "description": "The step's label, as written in the ceremony.", + "type": "string" + }, + "role": { + "description": "The role responsible for the step. Its display name is on the role's role_declared fact.", + "$ref": "#/$defs/RoleId" + }, + "type": { + "type": "string", + "const": "step_started" + } + }, + "required": [ + "type", + "id", + "label", + "role" + ] + }, + { + "description": "Someone answered a prompt, and the answer was accepted.", + "type": "object", + "properties": { + "prompt": { + "description": "The prompt, as shown.", + "$ref": "#/$defs/Prompt" + }, + "response": { + "description": "The answer, as recorded.", + "$ref": "#/$defs/ResponseRecord" + }, + "step": { + "description": "The step that asked, or absent for a prompt asked outside any step.", + "anyOf": [ + { + "$ref": "#/$defs/StepId" + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "const": "prompt_answered" + } + }, + "required": [ + "type", + "prompt", + "response" + ] + }, + { + "description": "A backend performed an operation. The inputs and outputs are specific to the kind of operation.", + "type": "object", + "properties": { + "backend": { + "description": "The backend the step ran with, by the name the ceremony gives it. Its identity is on that backend's backend_bound fact. Absent for an operation done in software.", + "type": [ + "string", + "null" + ] + }, + "fingerprint": { + "description": "A fingerprint of the material the operation produced, when it has one.", + "type": [ + "string", + "null" + ] + }, + "inputs": { + "description": "What the operation was given: parameters and references to artifacts or materials." + }, + "kind": { + "description": "The kind of operation, such as generate_key or sign_data.", + "type": "string" + }, + "outputs": { + "description": "What the operation produced: artifact names and digests." + }, + "step": { + "description": "The step the operation ran in.", + "$ref": "#/$defs/StepId" + }, + "type": { + "type": "string", + "const": "backend_operation" + } + }, + "required": [ + "type", + "step", + "kind", + "inputs", + "outputs" + ] + }, + { + "description": "A key held outside the ceremony received a wrapped key. A separate fact from the operation, so the method of a wrap can be published while the recipient stays withheld.", + "type": "object", + "properties": { + "declared": { + "description": "Whether the ceremony named this fingerprint in advance. When it did, the key received was checked against it; otherwise the fingerprint records whatever key arrived.", + "type": "boolean" + }, + "fingerprint": { + "description": "The digest of the recipient's public key, as DER-encoded SubjectPublicKeyInfo.", + "$ref": "#/$defs/Sha256Digest" + }, + "source": { + "description": "The artifact or material the recipient's key came from, by its name in the ceremony.", + "type": "string" + }, + "step": { + "description": "The step that wrapped the key.", + "$ref": "#/$defs/StepId" + }, + "type": { + "type": "string", + "const": "wrap_recipient_recorded" + } + }, + "required": [ + "type", + "step", + "source", + "fingerprint", + "declared" + ] + }, + { + "description": "A person made an attestation.", + "type": "object", + "properties": { + "role": { + "description": "The role that made the attestation.", + "$ref": "#/$defs/RoleId" + }, + "statement": { + "description": "The statement, word for word.", + "type": "string" + }, + "step": { + "description": "The step the attestation was made in.", + "$ref": "#/$defs/StepId" + }, + "type": { + "type": "string", + "const": "attestation_recorded" + } + }, + "required": [ + "type", + "step", + "role", + "statement" + ] + }, + { + "description": "An artifact was written to the run's artifacts directory.", + "type": "object", + "properties": { + "digest": { + "description": "The digest of the file's bytes. Absent for content a step opened, since a digest of a secret can be tested against guesses.", + "anyOf": [ + { + "$ref": "#/$defs/Sha256Digest" + }, + { + "type": "null" + } + ] + }, + "file": { + "description": "The file name in the artifacts directory: a single path component.", + "type": "string" + }, + "name": { + "description": "The artifact's name, as declared in the ceremony.", + "type": "string" + }, + "step": { + "description": "The step that produced the artifact.", + "$ref": "#/$defs/StepId" + }, + "type": { + "type": "string", + "const": "artifact_written" + } + }, + "required": [ + "type", + "step", + "name", + "file" + ] + }, + { + "description": "An operator recorded a deviation.", + "type": "object", + "properties": { + "step": { + "description": "The step during which the deviation was recorded, or absent outside any step.", + "anyOf": [ + { + "$ref": "#/$defs/StepId" + }, + { + "type": "null" + } + ] + }, + "text": { + "description": "The deviation, word for word.", + "type": "string" + }, + "type": { + "type": "string", + "const": "deviation_recorded" + } + }, + "required": [ + "type", + "text" + ] + }, + { + "description": "One attempt at a step failed. Recorded per attempt: a step that is retried and then succeeds has this fact for each failed attempt, then step_completed.", + "type": "object", + "properties": { + "attempt": { + "description": "The attempt's number within the step, starting at 1.", + "type": "integer", + "maximum": 4294967295, + "minimum": 0 + }, + "error": { + "description": "What went wrong in this attempt.", + "$ref": "#/$defs/ErrorRecord" + }, + "step": { + "description": "The step.", + "$ref": "#/$defs/StepId" + }, + "type": { + "type": "string", + "const": "step_attempt_failed" + } + }, + "required": [ + "type", + "step", + "attempt", + "error" + ] + }, + { + "description": "A step finished.", + "type": "object", + "properties": { + "id": { + "description": "The step.", + "$ref": "#/$defs/StepId" + }, + "outcome": { + "description": "How the step ended.", + "$ref": "#/$defs/StepOutcome" + }, + "type": { + "type": "string", + "const": "step_completed" + } + }, + "required": [ + "type", + "id", + "outcome" + ] + }, + { + "description": "The ceremony finished. It is the last line of a transcript whose run completed.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "ceremony_completed" + } + }, + "required": [ + "type" + ] + }, + { + "description": "The ceremony failed or was aborted. It is the last line of a transcript whose run did not complete.", + "type": "object", + "properties": { + "error": { + "description": "What went wrong.", + "$ref": "#/$defs/ErrorRecord" + }, + "type": { + "type": "string", + "const": "ceremony_failed" + } + }, + "required": [ + "type", + "error" + ] + }, + { + "description": "The ceremony's entropy source was seeded with machine randomness. Recorded once, at the start, so every value drawn from the source can be derived again from the transcript.", + "type": "object", + "properties": { + "derivation": { + "description": "The derivation scheme, such as rite-kdf/v1. A verifier refuses a scheme it does not know.", + "type": "string" + }, + "m": { + "description": "The machine randomness, as lowercase hex.", + "type": "string", + "pattern": "^(?:[0-9a-f]{2})+$" + }, + "source": { + "description": "Where the machine randomness came from, such as os.", + "type": "string" + }, + "type": { + "type": "string", + "const": "entropy_seeded" + } + }, + "required": [ + "type", + "m", + "source", + "derivation" + ] + }, + { + "description": "A person added their own randomness to the entropy source, starting a new epoch.", + "type": "object", + "properties": { + "contribution": { + "description": "The contribution, word for word, mixed into the source as UTF-8.", + "type": "string" + }, + "epoch": { + "description": "The epoch this contribution starts, counting from 1.", + "type": "integer", + "maximum": 4294967295, + "minimum": 0 + }, + "step": { + "description": "The step the contribution was made in.", + "$ref": "#/$defs/StepId" + }, + "type": { + "type": "string", + "const": "entropy_contributed" + } + }, + "required": [ + "type", + "step", + "epoch", + "contribution" + ] + }, + { + "description": "A value was drawn from the entropy source, such as a nonce, a certificate serial number or a challenge. A verifier derives it again from the seed, the contributions and the path.", + "type": "object", + "properties": { + "path": { + "description": "The derivation path: `//`.", + "type": "string" + }, + "step": { + "description": "The step that drew the value.", + "$ref": "#/$defs/StepId" + }, + "type": { + "type": "string", + "const": "entropy_drawn" + }, + "value": { + "description": "The value, as lowercase hex. Its length is the number of bytes drawn.", + "type": "string", + "pattern": "^(?:[0-9a-f]{2})*$" + } + }, + "required": [ + "type", + "step", + "path", + "value" + ] + } + ] + }, + "StepId": { + "description": "A step's identifier, as written in the ceremony.", + "type": "string" + }, + "StepOutcome": { + "description": "How a step ended.", + "oneOf": [ + { + "description": "The step did its work.", + "type": "object", + "properties": { + "message": { + "description": "A short description of what the step did.", + "type": "string" + }, + "status": { + "type": "string", + "const": "completed" + } + }, + "required": [ + "status", + "message" + ] + } + ] + }, + "TranscriptHeader": { + "description": "What the file is. A reader checks `rite_transcript` before reading anything else, and refuses a version it does not know.", + "type": "object", + "properties": { + "dry_run": { + "description": "Whether the run was a dry run. A dry-run transcript is a rehearsal record, never evidence of a ceremony.", + "type": "boolean" + }, + "levels": { + "description": "Every level the transcript uses, by name. It holds public, restricted and confidential at their fixed values, and any other level the ceremony declares. No two names share a value.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/Level" + } + }, + "producer": { + "description": "The program and version that wrote the transcript, as it reports itself. Informational: a program cannot vouch for its own identity.", + "type": "string" + }, + "rite_transcript": { + "description": "Version of the line format and the chain rule.", + "type": "integer", + "const": 0, + "maximum": 4294967295, + "minimum": 0 + }, + "run_id": { + "description": "A random identifier of this run: 32 lowercase hex digits.", + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + "vocabulary": { + "description": "Version of the fact vocabulary: which fact types and fields the transcript uses.", + "type": "integer", + "const": 0, + "maximum": 4294967295, + "minimum": 0 + } + }, + "required": [ + "rite_transcript", + "vocabulary", + "producer", + "run_id", + "dry_run", + "levels" + ] + }, + "ValidatorSpec": { + "description": "The check an answer had to pass.", + "oneOf": [ + { + "description": "The answer is not empty or blank.", + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "non_empty" + } + }, + "required": [ + "kind" + ] + }, + { + "description": "The answer matches a regular expression in full.", + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "regex" + }, + "value": { + "description": "The regular expression.", + "type": "string" + } + }, + "required": [ + "kind", + "value" + ] + }, + { + "description": "The answer passes a check the program defines, by name.", + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "predefined" + }, + "value": { + "description": "The name of the check, such as serial_number.", + "type": "string" + } + }, + "required": [ + "kind", + "value" + ] + }, + { + "description": "The answer is a value of one format, with a length within bounds, counted in characters for text and in bytes for an encoding.", + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "format" + }, + "value": { + "type": "object", + "properties": { + "format": { + "description": "What kind of value the answer is.", + "$ref": "#/$defs/Format" + }, + "max_length": { + "description": "The most units accepted, if bounded.", + "type": [ + "integer", + "null" + ], + "maximum": 9007199254740991, + "minimum": 0 + }, + "min_length": { + "description": "The fewest units accepted, if bounded.", + "type": [ + "integer", + "null" + ], + "maximum": 9007199254740991, + "minimum": 0 + } + }, + "required": [ + "format" + ] + } + }, + "required": [ + "kind", + "value" + ] + } + ] + }, + "WithheldLine": { + "description": "A fact line whose fact is withheld from a disclosure. It keeps the time, the level, the leaf and the chain value, so it folds into the chain exactly as the complete line does, and the transcript keeps its fingerprint.", + "type": "object", + "properties": { + "at": { + "$ref": "#/$defs/At" + }, + "chain": { + "description": "The chain value after this line.", + "$ref": "#/$defs/Sha256Digest" + }, + "leaf": { + "description": "The commitment to the withheld fact.", + "$ref": "#/$defs/Sha256Digest" + }, + "level": { + "$ref": "#/$defs/Level" + } + }, + "additionalProperties": false, + "required": [ + "at", + "level", + "leaf", + "chain" + ] + } + } +} diff --git a/docs/transcript-format.md b/docs/transcript-format.md new file mode 100644 index 0000000..8fe482c --- /dev/null +++ b/docs/transcript-format.md @@ -0,0 +1,174 @@ +# Transcript format + +`rite run` records every ceremony in `transcript.jsonl`: one JSON object per line, the header +first, then one line per fact in the order the facts happened. The file is append-only. Each +line is written and synced to storage before the run moves on, so a crash or a power loss leaves +every line recorded until then. + +The last `chain` value is the transcript fingerprint. Participants write it on paper at the end +of the ceremony, and anyone can recompute it from the file later. This page describes the format +for anyone writing their own verifier. The JSON Schemas in [`schema/`](schema/README.md) describe +the same lines field by field. + +## The header line + +```json +{"$schema":"https://ritely.io/schemas/0.6.0/transcript.schema.json","header":{"dry_run":false,"levels":{"confidential":30,"public":10,"restricted":20},"producer":"rite 0.6.0","rite_transcript":0,"run_id":"0ac4d127ee9b2b05447c9d241c3b5b0c","vocabulary":0},"chain":"sha256:…"} +``` + +| Member | Meaning | +|---|---| +| `header.rite_transcript` | Format version: the line layout and the chain rule. A reader refuses a version it does not know, before reading anything else. | +| `header.vocabulary` | Version of the fact vocabulary. | +| `header.producer` | The program and release that wrote the file, for information only. | +| `header.run_id` | 16 random bytes as hex, to tell runs apart before the fingerprint exists. | +| `header.dry_run` | Whether the run was a rehearsal. A dry run uses a fixed, publicly known entropy seed, so its transcript is not evidence of a real ceremony. | +| `header.levels` | Every confidentiality level the transcript uses, by name. | +| `chain` | `node_0`, the commitment to the header. | +| `$schema` | The JSON Schema of the release that wrote the file, for editors and other tools. The chain does not commit to it, and a reader ignores it. | + +## Fact lines + +```json +{"at":"2026-09-26T10:40:29.057123Z","level":10,"leaf":"sha256:…","chain":"sha256:…","salt":"<32 hex digits>","fact":{"type":"step_started",…}} +``` + +| Member | Meaning | +|---|---| +| `at` | When the fact was recorded: RFC 3339, UTC, microseconds. Committed as the exact string on the line. | +| `level` | The fact's confidentiality level, an integer the header names. | +| `leaf` | The commitment to the fact under its salt. | +| `chain` | The chain value after this line. | +| `salt` | 16 random bytes as 32 lowercase hex digits. | +| `fact` | The fact, an object whose `type` names its kind. | + +A **withheld** line, in a disclosure, has only `at`, `level`, `leaf` and `chain`. These are enough +to compute the chain, so a transcript with withheld lines has the same fingerprint as the complete +one. + +Members may appear in any order; a reader finds them by name. A line has either both `salt` and +`fact` or neither. + +## The commitment chain + +```text +node_0 = SHA-256(0x02 ‖ JCS(header)) +leaf_i = SHA-256(0x00 ‖ salt_i ‖ JCS(fact_i)) +node_i = SHA-256(0x01 ‖ node_{i-1} ‖ len(at_i) ‖ at_i ‖ level_i ‖ leaf_i) +fingerprint = node_n +``` + +- `JCS` is the RFC 8785 canonical form of the JSON value: members sorted by their UTF-16 code + units, no whitespace. Any reader that parses a fact computes the same bytes, whatever the + member order on the line. +- Facts hold integers only, within `±(2^53 - 1)`, and no other numbers. `rite check` refuses any + other number in a ceremony before it runs. +- `salt_i` is the 16 decoded bytes, not the hex text. +- `at_i` is the UTF-8 string on the line. `len(at_i)` and `level_i` are 8-byte big-endian + unsigned integers. +- Each hash input starts with its own byte (`0x00` leaf, `0x01` node, `0x02` header), so no leaf, + node or header can hash the same input as another. +- `leaf` and `chain` can be recomputed. They are stored so a verifier can name the first line + where its result differs. A `leaf` mismatch means the fact or its canonical form differs. A + `chain` mismatch with a matching `leaf` means the time, the level or an earlier line differs. + Someone who forges a transcript recomputes them too, so they do not detect forgery. +- The `chain` of the last line is the fingerprint. No other file is needed to know it. + +Test vectors are in `rite_model::commitment` (the module example, and `construction_vectors`). + +Each fact gets a new salt from the operating system's random source. The salt hides a withheld +fact: without it, anyone with the leaf could test guesses of the fact against it. The salt never +comes from the ceremony's entropy source, because that source is public so that `rite verify` can +re-derive it. + +## Confidentiality levels + +A level is an integer; a higher number is a narrower audience. Three are built in, at fixed +values in every transcript: + +| Level | Value | Audience | +|---|---|---| +| `public` | 10 | anyone | +| `restricted` | 20 | auditors, under agreement | +| `confidential` | 30 | the organisation that ran the ceremony | + +The header's `levels` table names every level the transcript uses. The built-ins must appear at +their values, no two names share a value, and a reader refuses a line at a level the header does +not name. + +The chain includes each line's level, so a disclosure cannot move a fact to another level. Each +fact type has a default level: + +| Level | Facts | +|---|---| +| public | `ceremony_started`, `role_declared`, `act_started`, `step_started`, `backend_operation`, `attestation_recorded`, `step_completed`, `ceremony_completed`, `ceremony_failed`, and the entropy facts, which `rite verify` needs to re-derive the entropy | +| restricted | `parameter_bound`, `material_loaded`, `backend_bound`, `prompt_answered`, `wrap_recipient_recorded`, `step_attempt_failed`, `deviation_recorded` | +| confidential | `role_assigned`, `material_digest`, `machine_info_recorded` | + +`artifact_written` takes its level from the artifact: certificates, public keys, CSRs and +signatures are public; wrapped keys, ciphertext and other content are restricted; opened content +is confidential. + +A secret value (a private key, a PIN, a passphrase, opened plaintext) is never recorded, at any +level. + +## Facts + +Each fact records one kind of information. Where two values may go to different audiences, such +as a material's name and the digest of its content, they are separate facts. The kinds: + +- `ceremony_started` (with the digest of the ceremony definition), `ceremony_completed`, + `ceremony_failed` +- `role_declared`: each role's id and name +- `role_assigned`, `parameter_bound`, `material_loaded`, `material_digest`: the run's inputs, + one fact each +- `backend_bound`: a backend's identity, recorded the first time the backend is used +- `act_started`, `step_started`, `step_attempt_failed`, `step_completed` +- `prompt_answered`: the prompt, and the answer unless it is a secret +- `backend_operation`: the kind of operation, the backend the step ran with, and what went in and + came out +- `attestation_recorded`, `machine_info_recorded`, `wrap_recipient_recorded` +- `entropy_seeded`, `entropy_contributed`, `entropy_drawn` +- `artifact_written`: the file name under `artifacts/`, and its digest unless it is opened + content +- `deviation_recorded` + +The schema lists every field. Digests are written `sha256:` followed by 64 lowercase hex digits. + +## What a disclosure shows + +A withheld line keeps its time and its level. So a reader of a disclosure sees how many facts +were withheld at each level, and when each one was recorded. For example, four confidential lines +at the start of a run are four people assigned to roles, and the time between two lines shows how long an +answer took. A withheld line's step is known from its position: it sits between that step's +public `step_started` and `step_completed`. + +A disclosure shows when the ceremony took place. The public artifacts usually show it too, since +a certificate's validity starts when it was issued. + +## Versions + +Before 1.0, `rite_transcript` and `vocabulary` are 0, and the format can change between releases +without a new number. `producer` names the release that wrote a transcript. Verify a transcript +with that release. `rite verify` prints a warning when another release wrote the transcript. At +1.0 the numbers become 1 and the format stops changing. + +When a reader does not know a fact's type, it still checks the fact against its leaf. It counts +the fact instead of reading it, and `rite verify` reports the count next to its result. + +## What `rite verify` proves + +`rite verify` accepts a run directory, a transcript file or an evidence bundle. It checks: + +- the header, and that every line's `leaf` and `chain` recompute, up to the fingerprint it prints; +- the entropy: every drawn value re-derives from the recorded seed and contributions; +- each artifact present against its recorded digest, and each wrapped key against what the + transcript says produced it; +- for a bundle, the index, the definition against the digest `ceremony_started` records, and for + a disclosure, that every line at or below its threshold is disclosed and every line above it + withheld. + +These checks show that the transcript is consistent with itself and with the files next to it. +They do not show that it is the transcript written during the ceremony, because someone who +rewrites the whole file can recompute every value. To check that, compare the fingerprint +`rite verify` prints with the one the participants wrote on paper at the end of the ceremony. From bcc25c7ef73370fdbec2bd37c406cbf0f06df37c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lomig=20Me=CC=81gard?= Date: Sat, 26 Sep 2026 15:21:01 +0200 Subject: [PATCH 2/3] fix: the PIV actions no longer import StepFact They record through Reporter::backend_operation; the imports only compile under the piv and yubikey features, which the workspace lint does not enable. --- crates/rite-stdlib/src/piv/attest.rs | 2 +- crates/rite-stdlib/src/piv/read_certificate.rs | 2 +- crates/rite-stdlib/src/piv/sign.rs | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/rite-stdlib/src/piv/attest.rs b/crates/rite-stdlib/src/piv/attest.rs index a4df5c6..f4f6ab1 100644 --- a/crates/rite-stdlib/src/piv/attest.rs +++ b/crates/rite-stdlib/src/piv/attest.rs @@ -1,6 +1,6 @@ //! `yubikey_attest_slot` action: generate a `YubiKey` attestation certificate. -use rite_model::{ActionType, StepFact}; +use rite_model::ActionType; use rite_runtime::{ Action, ActionError, ArtifactValue, HandlerContext, Icon, Reporter, StepInfo, StepResult, compute_fingerprint, parse_params, diff --git a/crates/rite-stdlib/src/piv/read_certificate.rs b/crates/rite-stdlib/src/piv/read_certificate.rs index 894c5ec..3ee828b 100644 --- a/crates/rite-stdlib/src/piv/read_certificate.rs +++ b/crates/rite-stdlib/src/piv/read_certificate.rs @@ -1,6 +1,6 @@ //! `piv_read_certificate` action: read an X.509 certificate from a PIV slot. -use rite_model::{ActionType, StepFact}; +use rite_model::ActionType; use rite_runtime::{ Action, ActionError, ArtifactValue, HandlerContext, Icon, Reporter, StepInfo, StepResult, compute_fingerprint, parse_params, diff --git a/crates/rite-stdlib/src/piv/sign.rs b/crates/rite-stdlib/src/piv/sign.rs index 24f83ad..b6bf624 100644 --- a/crates/rite-stdlib/src/piv/sign.rs +++ b/crates/rite-stdlib/src/piv/sign.rs @@ -1,6 +1,6 @@ //! `piv_sign` action: sign data with a PIV smart card on-device key. -use rite_model::{ActionType, Format, Prompt, StepFact, StepInputs, ValidatorSpec}; +use rite_model::{ActionType, Format, Prompt, StepInputs, ValidatorSpec}; use rite_runtime::{ Action, ActionError, ArtifactValue, HandlerContext, Icon, Reporter, Response, StepInfo, StepResult, compute_fingerprint, parse_params, resolve_artifact_bytes, @@ -198,6 +198,7 @@ fn parse_sign_algorithm(s: &str) -> Result { #[cfg(test)] mod tests { use super::*; + use rite_model::StepFact; use rite_sdk::{BackendError, KeyId, PivBackend, PivDeviceInfo, PivSlotInfo, SignBackend}; #[test] From ba0057250d623378a0a3bd28965ca0b41238c47d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lomig=20Me=CC=81gard?= Date: Sat, 26 Sep 2026 15:41:40 +0200 Subject: [PATCH 3/3] refactor: random bytes come from one helper per crate rite-runtime draws from the operating system through os_random, and rite-openssl from OpenSSL through random_bytes. Each is the only place its crate starts a buffer as zeros and fills it. Transcript salts are decoded straight into their array. The local checks in CONTRIBUTING.md are the CI lint and test commands, including the PIV and YubiKey features. --- CONTRIBUTING.md | 15 ++++++++-- crates/rite-openssl/src/backend.rs | 34 ++++++++++------------ crates/rite-openssl/src/content.rs | 5 ++-- crates/rite-runtime/src/lib.rs | 1 + crates/rite-runtime/src/os_random.rs | 19 ++++++++++++ crates/rite-runtime/src/runner.rs | 18 ++++-------- crates/rite-runtime/src/transcript_sink.rs | 9 ++---- 7 files changed, 58 insertions(+), 43 deletions(-) create mode 100644 crates/rite-runtime/src/os_random.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0dec180..728e6d5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,16 +52,27 @@ cargo fmt --all **Clippy**: ```sh cargo clippy --workspace --all-targets -- -D warnings +cargo clippy -p rite-stdlib --features piv,yubikey --all-targets -- -D warnings +cargo clippy -p rite-pkcs11 --all-targets -- -D warnings ``` +The PIV and YubiKey actions compile only with their features, so the workspace command does not +check them. On Linux they need `libpcsclite-dev`. + **Tests**: ```sh cargo test --workspace +cargo test -p rite-stdlib -p rite --features piv,yubikey ``` -**All at once**: +**All at once**, the same commands as the CI lint and test jobs: ```sh -cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace +cargo fmt --all -- --check \ + && cargo clippy --workspace --all-targets -- -D warnings \ + && cargo clippy -p rite-stdlib --features piv,yubikey --all-targets -- -D warnings \ + && cargo clippy -p rite-pkcs11 --all-targets -- -D warnings \ + && cargo test --workspace \ + && cargo test -p rite-stdlib -p rite --features piv,yubikey ``` ## Testing diff --git a/crates/rite-openssl/src/backend.rs b/crates/rite-openssl/src/backend.rs index 1cbde47..f3608dc 100644 --- a/crates/rite-openssl/src/backend.rs +++ b/crates/rite-openssl/src/backend.rs @@ -209,8 +209,7 @@ impl OpenSslBackend { material: KeyMaterial, policy: KeyPolicy, ) -> Result { - let mut id_bytes = [0u8; 16]; - openssl::rand::rand_bytes(&mut id_bytes).map_err(|e| ossl_err("Generate key ID", &e))?; + let id_bytes = random_bytes(16)?; let key_id = KeyId::new(base16ct::lower::encode_string(&id_bytes)); self.keys.insert( key_id.clone(), @@ -302,6 +301,13 @@ pub(crate) fn ossl_err(context: &str, e: &openssl::error::ErrorStack) -> Backend BackendError::Other(format!("{context}: {e}")) } +/// `len` bytes from OpenSSL's random generator, wiped when dropped. +pub(crate) fn random_bytes(len: usize) -> Result>, BackendError> { + let mut bytes = Zeroizing::new(vec![0u8; len]); + openssl::rand::rand_bytes(&mut bytes).map_err(|e| ossl_err("Generate random bytes", &e))?; + Ok(bytes) +} + /// Reject a signature request whose algorithm does not match the stored key. /// /// Runs once at the top of `sign` and `verify`, so the per-key-type arms below @@ -408,8 +414,7 @@ fn generate_ml_kem(algorithm: KeyAlgorithm) -> Result, BackendErro "{algorithm} is not an ML-KEM parameter set" )) })?; - let mut seed = Zeroizing::new(vec![0u8; ML_KEM_SEED_LEN]); - openssl::rand::rand_bytes(&mut seed).map_err(|e| ossl_err("ML-KEM seed generation", &e))?; + let seed = random_bytes(ML_KEM_SEED_LEN)?; PKey::private_key_from_seed(None, key_type, None, &seed) .map_err(|e| ossl_err("ML-KEM key generation", &e)) } @@ -438,8 +443,7 @@ fn generate_ml_dsa(algorithm: KeyAlgorithm) -> Result, BackendErro "{algorithm} is not an ML-DSA parameter set" )) })?; - let mut seed = Zeroizing::new(vec![0u8; ML_DSA_SEED_LEN]); - openssl::rand::rand_bytes(&mut seed).map_err(|e| ossl_err("ML-DSA seed generation", &e))?; + let seed = random_bytes(ML_DSA_SEED_LEN)?; PKey::private_key_from_seed(None, key_type, None, &seed) .map_err(|e| ossl_err("ML-DSA key generation", &e)) } @@ -792,9 +796,7 @@ impl KeyStoreBackend for OpenSslBackend { // symmetric variants here, so a symmetric algorithm added to the SDK // is generated by this backend without an edit. if let Some(length) = spec.algorithm.key_bytes() { - let mut secret = Zeroizing::new(vec![0u8; length]); - openssl::rand::rand_bytes(&mut secret) - .map_err(|e| ossl_err("Generate symmetric key", &e))?; + let secret = random_bytes(length)?; return self.store_secret(spec.algorithm, spec.label, secret, spec.policy); } let pkey = match spec.algorithm { @@ -940,9 +942,7 @@ impl VerifyBackend for OpenSslBackend { impl RandomBackend for OpenSslBackend { fn generate_random(&mut self, len: usize) -> Result, BackendError> { - let mut buf = vec![0u8; len]; - openssl::rand::rand_bytes(&mut buf).map_err(|e| ossl_err("Generate random bytes", &e))?; - Ok(buf) + Ok(random_bytes(len)?.to_vec()) } } @@ -1396,8 +1396,7 @@ fn cms_seal_to_kek( ) -> Result, BackendError> { // A fresh content-encryption key per message, as RFC 5652 requires: the // KEK encrypts this, and this encrypts the content. - let mut cek = Zeroizing::new(vec![0u8; 32]); - openssl::rand::rand_bytes(&mut cek).map_err(|e| ossl_err("Generate CEK", &e))?; + let cek = random_bytes(32)?; let sealed = crate::content::seal_content(&cek, payload)?; let envelope = cms::KekEnvelope { @@ -1559,9 +1558,7 @@ fn raw_wrap( // payload under AES-KWP, concatenated with the OAEP part first. // A recipient splits them by the modulus size, so no framing is // written and none may be. - let mut ephemeral = Zeroizing::new(vec![0u8; 32]); - openssl::rand::rand_bytes(&mut ephemeral) - .map_err(|e| ossl_err("Generate ephemeral AES key", &e))?; + let ephemeral = random_bytes(32)?; let mut data = rsa_oaep_encrypt(recipient, &ephemeral)?; data.extend_from_slice(&aes_key_wrap(&ephemeral, payload, KeyWrap::Padded, true)?); data @@ -1783,8 +1780,7 @@ impl KeyTransportBackend for OpenSslBackend { kek.policy.require(KeyUsages::WRAP, "protect a data key")?; let (secret, _) = kek.secret()?; - let mut plaintext = Zeroizing::new(vec![0u8; size]); - openssl::rand::rand_bytes(&mut plaintext).map_err(|e| ossl_err("Generate data key", &e))?; + let plaintext = random_bytes(size)?; let wrapped = aes_key_wrap(secret, &plaintext, KeyWrap::Plain, true)?; Ok(DataKey::new(plaintext, wrapped, KeyProtection::AesKeyWrap)) diff --git a/crates/rite-openssl/src/content.rs b/crates/rite-openssl/src/content.rs index 5348124..44e979c 100644 --- a/crates/rite-openssl/src/content.rs +++ b/crates/rite-openssl/src/content.rs @@ -11,7 +11,7 @@ use rite_sdk::BackendError; use zeroize::Zeroizing; -use crate::backend::{aes_256_gcm_open, aes_256_gcm_seal, ossl_err}; +use crate::backend::{aes_256_gcm_open, aes_256_gcm_seal, random_bytes}; /// Content under AES-256-GCM, with the values that have to travel beside it. /// @@ -38,8 +38,7 @@ pub struct SealedContent { /// /// Returns [`BackendError`] if `cek` is not a 256-bit key or the cipher fails. pub fn seal_content(cek: &[u8], payload: &[u8]) -> Result { - let mut nonce = vec![0u8; 12]; - openssl::rand::rand_bytes(&mut nonce).map_err(|e| ossl_err("Generate GCM nonce", &e))?; + let nonce = random_bytes(12)?.to_vec(); let (ciphertext, tag) = aes_256_gcm_seal(cek, &nonce, payload)?; Ok(SealedContent { nonce, diff --git a/crates/rite-runtime/src/lib.rs b/crates/rite-runtime/src/lib.rs index bfa74e6..db7daba 100644 --- a/crates/rite-runtime/src/lib.rs +++ b/crates/rite-runtime/src/lib.rs @@ -28,6 +28,7 @@ mod display; mod entropy; mod executor; mod expressions; +mod os_random; mod output_config; mod protocol; mod reporter; diff --git a/crates/rite-runtime/src/os_random.rs b/crates/rite-runtime/src/os_random.rs new file mode 100644 index 0000000..217b898 --- /dev/null +++ b/crates/rite-runtime/src/os_random.rs @@ -0,0 +1,19 @@ +//! Random bytes from the operating system. + +use std::io; + +use rand::TryRng; +use rand::rngs::SysRng; + +/// `N` bytes from the operating system's random generator. +/// +/// # Errors +/// +/// Returns the generator's error if the operating system cannot supply bytes. +pub(crate) fn os_random() -> io::Result<[u8; N]> { + let mut bytes = [0u8; N]; + SysRng + .try_fill_bytes(&mut bytes) + .map_err(io::Error::other)?; + Ok(bytes) +} diff --git a/crates/rite-runtime/src/runner.rs b/crates/rite-runtime/src/runner.rs index 24b07c5..dfa5e10 100644 --- a/crates/rite-runtime/src/runner.rs +++ b/crates/rite-runtime/src/runner.rs @@ -35,8 +35,6 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; use crossbeam_channel::{Receiver, Sender}; -use rand::TryRng; -use rand::rngs::SysRng; use rite_model::{ ActId, ActionType, ArtifactId, Ceremony, Level, MaterialId, MaterialKind, MaterialSource, OutputId, ParamId, RoleId, Sha256Digest, Step, TranscriptHeader, @@ -63,36 +61,32 @@ use rite_model::{ErrorClass, ErrorRecord, Prompt, RetryPolicy, StepFact, StepOut /// Gather the machine entropy `m` that seeds the ceremony entropy source. /// -/// Sourced directly from the host OS RNG ([`SysRng`]), independent of any +/// Sourced directly from the host OS RNG, independent of any /// ceremony backend, so a device the ceremony later challenges cannot /// influence its own challenge nonce. A dry run instead returns a fixed, /// clearly-labelled sentinel so a re-derived value can never be mistaken for /// one produced under real entropy. fn gather_machine_entropy(dry_run: bool) -> Result<([u8; 32], String), ExecutionError> { - let mut m = [0u8; 32]; if dry_run { + let mut m = [0u8; 32]; for (slot, byte) in m.iter_mut().zip(b"rite-dry-run-not-real-entropy") { *slot = *byte; } return Ok((m, "dry-run".to_string())); } - SysRng - .try_fill_bytes(&mut m) - .map_err(|e| ExecutionError::EntropyError(e.to_string()))?; + let m = + crate::os_random::os_random().map_err(|e| ExecutionError::EntropyError(e.to_string()))?; Ok((m, "os".to_string())) } -/// Program and version recorded in the transcript header. /// Name and version of the program writing transcripts, as the header /// records it. pub const PRODUCER: &str = concat!("rite ", env!("CARGO_PKG_VERSION")); /// A fresh run identifier: 16 bytes from the OS RNG, as lowercase hex. fn new_run_id() -> Result { - let mut id = [0u8; 16]; - SysRng - .try_fill_bytes(&mut id) - .map_err(|e| ExecutionError::EntropyError(e.to_string()))?; + let id: [u8; 16] = + crate::os_random::os_random().map_err(|e| ExecutionError::EntropyError(e.to_string()))?; Ok(base16ct::lower::encode_string(&id)) } diff --git a/crates/rite-runtime/src/transcript_sink.rs b/crates/rite-runtime/src/transcript_sink.rs index 17c5635..75bd203 100644 --- a/crates/rite-runtime/src/transcript_sink.rs +++ b/crates/rite-runtime/src/transcript_sink.rs @@ -56,8 +56,6 @@ use std::io::{self, BufRead, BufReader, BufWriter, Write}; use std::path::{Path, PathBuf}; use chrono::{DateTime, SecondsFormat, Utc}; -use rand::TryRng; -use rand::rngs::SysRng; use serde::Deserialize; use thiserror::Error; @@ -210,8 +208,7 @@ impl Chain { } let value = serde_json::to_value(fact).map_err(io::Error::other)?; let canonical = canonical_json(&value).map_err(io::Error::other)?; - let mut salt = [0u8; SALT_LEN]; - SysRng.try_fill_bytes(&mut salt).map_err(io::Error::other)?; + let salt: [u8; SALT_LEN] = crate::os_random::os_random()?; let leaf = fact_leaf(&salt, &canonical); let at = at.to_rfc3339_opts(SecondsFormat::Micros, true); let node = chain_node(&previous, &at, level, &leaf); @@ -878,9 +875,7 @@ fn add_fact( } fn decode_salt(hex: &str) -> Option<[u8; SALT_LEN]> { - let mut salt = [0u8; SALT_LEN]; - let decoded = base16ct::lower::decode(hex, &mut salt).ok()?; - (decoded.len() == SALT_LEN).then_some(salt) + base16ct::lower::decode_vec(hex).ok()?.try_into().ok() } /// Compare a computed value against the checkpoint recorded on a line.