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/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-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-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..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; @@ -40,7 +41,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 +80,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/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/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..dfa5e10 100644 --- a/crates/rite-runtime/src/runner.rs +++ b/crates/rite-runtime/src/runner.rs @@ -31,18 +31,18 @@ //! 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 +51,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; @@ -61,25 +61,35 @@ 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())) } +/// 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 id: [u8; 16] = + crate::os_random::os_random().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 +395,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 +444,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 +466,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 +490,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 +505,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 +563,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 +629,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 +667,7 @@ impl Executor { &ctx, ¶ms, reporter, - &mut self.backend_registry, + &mut backends, )?; // StepCompleted @@ -643,29 +704,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 +752,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 +825,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 +835,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 +1018,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 +1179,8 @@ sections: vec![ "ceremony_started", "entropy_seeded", + "role_declared", + "role_assigned", "step_started", "step_completed", "ceremony_completed", @@ -1170,13 +1303,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 +1618,7 @@ sections: fn execute( &self, - step: &StepInfo, + _step: &StepInfo, _ctx: &HandlerContext, _params: &serde_json::Value, reporter: &mut Reporter<'_>, @@ -1491,13 +1628,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 +1973,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 +2017,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..75bd203 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,19 @@ 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 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 +76,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 +108,147 @@ 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 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); + 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 +258,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 +266,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 +281,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 +356,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 +468,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 +586,319 @@ 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]> { + base16ct::lower::decode_vec(hex).ok()?.try_into().ok() +} - 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 +1046,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 +1069,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 +1097,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 +1108,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 +1174,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 +1190,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 +1403,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..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, @@ -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..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, @@ -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..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, @@ -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( @@ -202,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] 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.