diff --git a/.gitignore b/.gitignore index 32b935d..aaf278e 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ lib/bs/ # ── Rust / Gossamer ────────────────────────────────────── /target/ +/crates/provenance/target/ src-gossamer/target/ src-tauri/target/ src-tauri/WixTools/ diff --git a/.well-known/keys/README.adoc b/.well-known/keys/README.adoc new file mode 100644 index 0000000..fc02933 --- /dev/null +++ b/.well-known/keys/README.adoc @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 += Contributor public-key registry + +Only public JSON key documents belong here. Never commit a private key. +Key documents are reviewed identity bindings, not self-authenticating claims. +Approve the fingerprint with the contributor over an independent trusted +channel before merging it. A verifier must use its own approved checkout of +this directory, never a registry provided by an untrusted distribution. + +The format and rotation policy are in `docs/PROVENANCE-EXHIBIT-B.adoc`. +No production identities or private keys are manufactured by the implementation. diff --git a/Cargo.lock b/Cargo.lock index 24bf971..46ed869 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,12 +17,37 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bit-set" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +dependencies = [ + "borsh", + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -35,6 +60,39 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -63,6 +121,32 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -89,6 +173,50 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dirs" version = "6.0.0" @@ -118,7 +246,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -155,7 +283,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -180,6 +308,17 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fips204" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a823e139c2d539b1b735d31cdce8b0b356671585eac3d54dec79d1d15052093b" +dependencies = [ + "rand_core 0.6.4", + "sha3", + "zeroize", +] + [[package]] name = "fnv" version = "1.0.7" @@ -275,7 +414,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -307,6 +446,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -327,6 +476,7 @@ dependencies = [ "cfg-if", "libc", "r-efi", + "rand_core 0.10.1", ] [[package]] @@ -644,6 +794,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + [[package]] name = "kqueue" version = "1.2.0" @@ -789,6 +948,15 @@ dependencies = [ "serde", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -817,7 +985,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -856,6 +1024,7 @@ dependencies = [ "notify", "notify-debouncer-mini", "once_cell", + "panll-provenance", "regex", "reqwest", "serde", @@ -866,6 +1035,20 @@ dependencies = [ "which", ] +[[package]] +name = "panll-provenance" +version = "0.1.0" +dependencies = [ + "base64", + "chrono", + "fips204", + "proptest", + "serde", + "serde_jcs", + "serde_json", + "sha3", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -893,6 +1076,15 @@ dependencies = [ "zerovec", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -902,6 +1094,32 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags 2.13.0", + "chacha20", + "core_detect", + "num-traits", + "rand", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.45" @@ -917,6 +1135,40 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_xorshift" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -1025,7 +1277,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1067,12 +1319,30 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "ryu-js" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6518fc26bced4d53678a22d6e423e9d8716377def84545fe328236e3af070e7f" + [[package]] name = "same-file" version = "1.0.6" @@ -1141,7 +1411,18 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", +] + +[[package]] +name = "serde_jcs" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3a60f3fda61525e439ef6d67422118f11e986566997d9021c56867ad814a0aa" +dependencies = [ + "ryu-js", + "serde", + "serde_json", ] [[package]] @@ -1169,6 +1450,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest", + "keccak", + "zeroize", +] + [[package]] name = "shlex" version = "2.0.1" @@ -1220,6 +1512,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -1237,7 +1540,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1271,7 +1574,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1291,7 +1594,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1327,7 +1630,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1363,6 +1666,36 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.18+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec6e78bd70db9420d8d212fa9dc97f116448a1ceff5d00eabb991d407e21396" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + [[package]] name = "tower" version = "0.5.3" @@ -1433,6 +1766,18 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1480,6 +1825,21 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -1547,7 +1907,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.118", "wasm-bindgen-shared", ] @@ -1601,7 +1961,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1721,6 +2081,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3b93199df0ab874cdf0c6a94f0a0dc3423fe41f1cafb62b8bd7b1deb277aef" +dependencies = [ + "memchr", +] + [[package]] name = "winsafe" version = "0.0.19" @@ -1752,7 +2121,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", "synstructure", ] @@ -1773,7 +2142,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", "synstructure", ] @@ -1782,6 +2151,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] [[package]] name = "zerotrie" @@ -1813,7 +2196,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index db21f00..456068d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,11 +39,17 @@ required-features = ["gossamer"] # To build against a local gossamer checkout instead of the pinned commit, put # a [patch."https://github.com/metadatastician/gossamer"] entry in your own # .cargo/config.toml (not committed). +[workspace] +members = ["crates/provenance"] +default-members = [".", "crates/provenance"] +resolver = "2" + [features] default = [] gossamer = ["dep:gossamer-rs"] [dependencies] +panll-provenance = { path = "crates/provenance" } # Gossamer webview shell — replaces tauri. Pinned by commit: a path dependency # on a sibling checkout cannot resolve in CI or for anyone else. gossamer-rs = { git = "https://github.com/metadatastician/gossamer", rev = "c234fea6d8e41a6c9080b3dea97a1885fca1f9a7", optional = true } diff --git a/PROOF-NEEDS.adoc b/PROOF-NEEDS.adoc index 15564b4..9521720 100644 --- a/PROOF-NEEDS.adoc +++ b/PROOF-NEEDS.adoc @@ -163,9 +163,12 @@ a parent hash. It is serialised as canonical JSON, signed with ML-DSA-65 and witnessed, and verifies against keys published under `.well-known/keys/`. Today's engine (git blame plus Co-Authored-By trailers) is forgeable, so its output counts only as "claimed". -|Not started. The signed record format and verifier come first; the -proof obligation is that verification rejects any record whose fields, -hash or chain were altered. +|Implemented in `crates/provenance`: JCS records, ML-DSA-65 signing, +approved public-key registry, independent witnesses, timestamp/content/chain +verification and mutation/property tests. Symbolic binding model: +`docs/models/Provenance.tla`; protocol and limits: +`docs/PROVENANCE-EXHIBIT-B.adoc`. This does not prove every action is recorded, +prevent tail truncation without a checkpoint, or prove cryptographic hardness. |#173 |Cartridge schema validation diff --git a/crates/provenance/Cargo.toml b/crates/provenance/Cargo.toml new file mode 100644 index 0000000..a73c586 --- /dev/null +++ b/crates/provenance/Cargo.toml @@ -0,0 +1,19 @@ +# SPDX-License-Identifier: MPL-2.0 +[package] +name = "panll-provenance" +version = "0.1.0" +edition = "2021" +license = "MPL-2.0" +rust-version = "1.85" + +[dependencies] +base64 = "0.22" +chrono = { version = "0.4", default-features = false, features = ["std"] } +fips204 = { version = "=0.5.0", default-features = false, features = ["ml-dsa-65", "default-rng"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_jcs = "0.2" +sha3 = "0.10" + +[dev-dependencies] +proptest = "1" diff --git a/crates/provenance/examples/record.rs b/crates/provenance/examples/record.rs new file mode 100644 index 0000000..5d2cf80 --- /dev/null +++ b/crates/provenance/examples/record.rs @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MPL-2.0 +//! Offline signing helper. Secret keys never pass through desktop IPC. +use base64::{engine::general_purpose::STANDARD, Engine}; +use fips204::{ + ml_dsa_65, + traits::{KeyGen, SerDes}, +}; +use panll_provenance::*; +use std::{fs, io::Write, path::Path}; + +fn read(path: &str) -> Result { + serde_json::from_slice(&fs::read(path).map_err(|e| e.to_string())?).map_err(|e| e.to_string()) +} + +fn write_new(path: &str, bytes: &[u8], secret: bool) -> Result<(), String> { + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(if secret { 0o600 } else { 0o644 }); + } + #[cfg(not(unix))] + let _ = secret; // On Windows, use a directory with a private inherited ACL. + options + .open(path) + .and_then(|mut f| f.write_all(bytes)) + .map_err(|e| e.to_string()) +} + +fn secret(path: &str) -> Result { + let encoded = fs::read_to_string(path).map_err(|e| e.to_string())?; + let bytes: [u8; ml_dsa_65::SK_LEN] = STANDARD + .decode(encoded.trim()) + .map_err(|e| e.to_string())? + .try_into() + .map_err(|_| "wrong private key length")?; + ml_dsa_65::PrivateKey::try_from_bytes(bytes).map_err(str::to_owned) +} + +fn run(args: &[String]) -> Result<(), String> { + match args { + [command, identity, from, until, public, private] if command == "keygen" => { + // Refuse a private key inside any Git working tree. + let directory = Path::new(private).parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new(".")); + if std::process::Command::new("git").args(["rev-parse", "--is-inside-work-tree"]) + .current_dir(directory).output().map_err(|e| e.to_string())?.status.success() { + return Err("store the private key outside a Git working tree".into()); + } + let (pk, sk) = ml_dsa_65::KG::try_keygen().map_err(str::to_owned)?; + let bytes = pk.into_bytes(); + let key = PublicKey { key_id: hash(&bytes), author_identity: identity.clone(), algorithm: ALGORITHM.into(), + public_key: STANDARD.encode(bytes), valid_from: from.clone(), valid_until: until.clone(), revoked: false }; + Registry::new(vec![key.clone()])?; + write_new(private, STANDARD.encode(sk.into_bytes()).as_bytes(), true)?; + write_new(public, &serde_json::to_vec_pretty(&key).map_err(|e| e.to_string())?, false) + }, + [command, record, private, output] if command == "sign" => { + let record: Record = read(record)?; + let signature = Some(sign(&record, &secret(private)?)?); + let envelope = Envelope { record, signature, attestations: vec![] }; + write_new(output, &serde_json::to_vec_pretty(&envelope).map_err(|e| e.to_string())?, false) + }, + [command, input, public, private, output] if command == "witness" => { + let mut envelope: Envelope = read(input)?; + let key: PublicKey = read(public)?; + Registry::new(vec![key.clone()])?; + envelope.attestations.push(attest(&envelope.record, key.key_id, &secret(private)?)?); + write_new(output, &serde_json::to_vec_pretty(&envelope).map_err(|e| e.to_string())?, false) + }, + _ => Err("usage: record keygen IDENTITY FROM UNTIL PUBLIC.json PRIVATE.key | sign RECORD.json PRIVATE.key OUT.json | witness ENVELOPE.json PUBLIC.json PRIVATE.key OUT.json".into()), + } +} + +fn main() { + if let Err(error) = run(&std::env::args().skip(1).collect::>()) { + eprintln!("{error}"); + std::process::exit(1); + } +} diff --git a/crates/provenance/src/lib.rs b/crates/provenance/src/lib.rs new file mode 100644 index 0000000..25a0081 --- /dev/null +++ b/crates/provenance/src/lib.rs @@ -0,0 +1,330 @@ +// SPDX-License-Identifier: MPL-2.0 +//! Exhibit B v1. Trust comes from an independently approved key registry, +//! never from a key shipped inside a record. Git attribution stays claimed. +use base64::{engine::general_purpose::STANDARD, Engine}; +use chrono::{DateTime, FixedOffset}; +use fips204::{ + ml_dsa_65, + traits::{SerDes, Signer, Verifier}, +}; +use serde::{Deserialize, Serialize}; +use sha3::{Digest, Sha3_256}; +use std::collections::{HashMap, HashSet}; + +pub const ALGORITHM: &str = "ML-DSA-65"; +pub fn utc_timestamp(seconds: u64) -> Result { + let seconds = i64::try_from(seconds).map_err(|_| "clock overflow")?; + chrono::DateTime::from_timestamp(seconds, 0) + .map(|t| t.to_rfc3339()) + .ok_or_else(|| "timestamp outside supported range".into()) +} +// Domain separation prevents signatures being reused by another protocol. +const CONTEXT: &[u8] = b"PanLL/Exhibit-B/v1"; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct Record { + pub version: u8, + pub algorithm: String, + pub key_id: String, + pub author_identity: String, + pub timestamp: String, + pub content_hash: String, + #[serde(deserialize_with = "present_option")] + pub parent_hash: Option, + #[serde(deserialize_with = "present_option")] + pub platform: Option, + /// Required witness key fingerprints, covered by the author's signature. + pub witnesses: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct Attestation { + pub key_id: String, + pub signature: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct Envelope { + pub record: Record, + #[serde(deserialize_with = "present_option")] + pub signature: Option, + pub attestations: Vec, +} + +// `Option` normally accepts an absent JSON field. The v1 payload requires an +// explicit null so deleting a field cannot be silently normalised away. +fn present_option<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, + T: Deserialize<'de>, +{ + Option::::deserialize(deserializer) +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct PublicKey { + pub key_id: String, + pub author_identity: String, + pub algorithm: String, + pub public_key: String, + pub valid_from: String, + pub valid_until: String, + pub revoked: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct Contribution { + pub envelope: Envelope, + /// Actual bytes, not a hash reported by the distribution. + pub content_base64: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Status { + Verified, + Claimed, + Invalid, +} + +#[derive(Debug, Serialize)] +pub struct Verdict { + pub status: Status, + pub record_hash: String, + pub reasons: Vec, +} + +pub fn hash(bytes: &[u8]) -> String { + Sha3_256::digest(bytes) + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} + +pub fn canonical(record: &Record) -> Result, String> { + serde_jcs::to_vec(record).map_err(|e| e.to_string()) +} + +pub fn record_hash(record: &Record) -> Result { + canonical(record).map(|bytes| hash(&bytes)) +} + +pub fn sign(record: &Record, key: &ml_dsa_65::PrivateKey) -> Result { + key.try_sign(&canonical(record)?, CONTEXT) + .map(|sig| STANDARD.encode(sig)) + .map_err(str::to_owned) +} + +pub fn attest( + record: &Record, + key_id: String, + key: &ml_dsa_65::PrivateKey, +) -> Result { + if !record.witnesses.contains(&key_id) || record.key_id == key_id { + return Err("witness must be declared and distinct from author".into()); + } + Ok(Attestation { + key_id, + signature: sign(record, key)?, + }) +} + +fn timestamp(value: &str) -> Result, String> { + DateTime::parse_from_rfc3339(value).map_err(|_| "timestamp must be RFC 3339 with a zone".into()) +} + +fn digest_format(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) +} + +/// Registry construction fails closed on duplicate identities/fingerprints, +/// malformed keys or validity periods longer than the annual rotation limit. +pub struct Registry(HashMap); +impl Registry { + pub fn new(keys: Vec) -> Result { + let mut registry = HashMap::new(); + for key in keys { + let bytes = STANDARD + .decode(&key.public_key) + .map_err(|e| e.to_string())?; + let from = timestamp(&key.valid_from)?; + let until = timestamp(&key.valid_until)?; + let duration = until.signed_duration_since(from); + if key.algorithm != ALGORITHM + || key.author_identity.trim().is_empty() + || key.key_id != hash(&bytes) + || duration.num_seconds() <= 0 + || duration.num_seconds() > 366 * 86400 + { + return Err("invalid public key metadata or rotation window".into()); + } + let raw: [u8; ml_dsa_65::PK_LEN] = + bytes.try_into().map_err(|_| "wrong public key length")?; + ml_dsa_65::PublicKey::try_from_bytes(raw).map_err(str::to_owned)?; + if registry.insert(key.key_id.clone(), key).is_some() { + return Err("duplicate public key fingerprint".into()); + } + } + Ok(Self(registry)) + } + + fn verify_signature( + &self, + id: &str, + identity: Option<&str>, + time: DateTime, + message: &[u8], + signature: &str, + ) -> Result { + // Reject malformed signatures even if their key is not trusted. + let signature: [u8; ml_dsa_65::SIG_LEN] = STANDARD + .decode(signature) + .map_err(|_| "invalid signature encoding")? + .try_into() + .map_err(|_| "wrong signature length")?; + let Some(key) = self.0.get(id) else { + return Ok(false); + }; + if key.revoked + || identity.is_some_and(|identity| identity != key.author_identity) + || time < timestamp(&key.valid_from)? + || time > timestamp(&key.valid_until)? + { + return Err("revoked, expired, not-yet-valid or identity-mismatched key".into()); + } + let raw: [u8; ml_dsa_65::PK_LEN] = STANDARD + .decode(&key.public_key) + .map_err(|e| e.to_string())? + .try_into() + .map_err(|_| "wrong public key length")?; + let public = ml_dsa_65::PublicKey::try_from_bytes(raw).map_err(str::to_owned)?; + if !public.verify(message, &signature, CONTEXT) { + return Err("signature mismatch".into()); + } + Ok(true) + } +} + +/// Verify a complete, ordered chain from its adoption root. The caller supplies +/// a trusted time window; no record is permitted to select its own clock. +/// A verified child cannot promote an unverified ancestor to verified. +pub fn verify_chain( + entries: &[Contribution], + keys: &Registry, + earliest: &str, + latest: &str, +) -> Result, String> { + let earliest = timestamp(earliest)?; + let latest = timestamp(latest)?; + if earliest > latest { + return Err("inverted timestamp window".into()); + } + let mut results = Vec::new(); + let mut parent: Option = None; + let mut parent_status = Status::Verified; + let mut parent_time = None; + let mut seen = HashSet::new(); + for entry in entries { + let record = &entry.envelope.record; + let id = record_hash(record)?; + let mut reasons = Vec::new(); + let checked = (|| -> Result { + if record.version != 1 + || record.algorithm != ALGORITHM + || record.author_identity.trim().is_empty() + || !digest_format(&record.key_id) + || !digest_format(&record.content_hash) + { + return Err("unsupported or malformed record".into()); + } + let time = timestamp(&record.timestamp)?; + if time < earliest || time > latest || parent_time.is_some_and(|p| time < p) { + return Err("timestamp outside policy window or before parent".into()); + } + if record.parent_hash != parent || !seen.insert(id.clone()) { + return Err("missing, reordered, duplicate or spliced chain link".into()); + } + let content = STANDARD + .decode(&entry.content_base64) + .map_err(|_| "invalid content encoding")?; + if hash(&content) != record.content_hash { + return Err("content hash mismatch".into()); + } + let message = canonical(record)?; + let mut complete = match &entry.envelope.signature { + Some(sig) => keys.verify_signature( + &record.key_id, + Some(&record.author_identity), + time, + &message, + sig, + )?, + None => false, + }; + let declared: HashSet<_> = record.witnesses.iter().collect(); + if declared.len() != record.witnesses.len() + || declared.contains(&record.key_id) + || record.witnesses.iter().any(|id| !digest_format(id)) + { + return Err("duplicate, malformed or self witness".into()); + } + let mut supplied = HashSet::new(); + for attestation in &entry.envelope.attestations { + if !declared.contains(&attestation.key_id) + || !supplied.insert(attestation.key_id.clone()) + { + return Err("undeclared or duplicate witness attestation".into()); + } + if let (Some(author), Some(witness)) = + (keys.0.get(&record.key_id), keys.0.get(&attestation.key_id)) + { + if author.author_identity == witness.author_identity { + return Err("author cannot witness own record".into()); + } + } + complete &= keys.verify_signature( + &attestation.key_id, + None, + time, + &message, + &attestation.signature, + )?; + } + complete &= !declared.is_empty() && supplied.len() == declared.len(); + parent_time = Some(time); + Ok(complete) + })(); + let status = match checked { + Err(reason) => { + reasons.push(reason); + Status::Invalid + } + Ok(_) if parent_status == Status::Invalid => { + reasons.push("invalid ancestor".into()); + Status::Invalid + } + Ok(true) if parent_status == Status::Verified => Status::Verified, + Ok(_) => { + reasons + .push("unsigned, unknown key, incomplete witnesses or claimed ancestor".into()); + Status::Claimed + } + }; + parent = Some(id.clone()); + parent_status = status; + results.push(Verdict { + status, + record_hash: id, + reasons, + }); + } + Ok(results) +} diff --git a/crates/provenance/tests/tamper.rs b/crates/provenance/tests/tamper.rs new file mode 100644 index 0000000..bad91b1 --- /dev/null +++ b/crates/provenance/tests/tamper.rs @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: MPL-2.0 +use base64::{engine::general_purpose::STANDARD, Engine}; +use fips204::{ + ml_dsa_65, + traits::{KeyGen, SerDes}, +}; +use panll_provenance::*; +use proptest::prelude::*; + +fn fixture() -> (Contribution, Vec) { + let (author, secret) = ml_dsa_65::KG::keygen_from_seed(&[1; 32]); + let (witness, witness_secret) = ml_dsa_65::KG::keygen_from_seed(&[2; 32]); + let keys: Vec<_> = [ + (author, "Author "), + (witness, "Witness "), + ] + .into_iter() + .map(|(key, identity)| { + let bytes = key.into_bytes(); + PublicKey { + key_id: hash(&bytes), + author_identity: identity.into(), + algorithm: ALGORITHM.into(), + public_key: STANDARD.encode(bytes), + valid_from: "2026-11-05T00:00:00Z".into(), + valid_until: "2027-11-05T00:00:00Z".into(), + revoked: false, + } + }) + .collect(); + let record = Record { + version: 1, + algorithm: ALGORITHM.into(), + key_id: keys[0].key_id.clone(), + author_identity: keys[0].author_identity.clone(), + timestamp: "2026-11-06T12:00:00Z".into(), + content_hash: hash(b"contribution"), + parent_hash: None, + platform: Some("test".into()), + witnesses: vec![keys[1].key_id.clone()], + }; + let signature = Some(sign(&record, &secret).unwrap()); + let attestations = vec![attest(&record, keys[1].key_id.clone(), &witness_secret).unwrap()]; + ( + Contribution { + envelope: Envelope { + record, + signature, + attestations, + }, + content_base64: STANDARD.encode(b"contribution"), + }, + keys, + ) +} + +fn verify(entries: &[Contribution], keys: Vec) -> Vec { + verify_chain( + entries, + &Registry::new(keys).unwrap(), + "2026-11-05T00:00:00Z", + "2026-11-07T00:00:00Z", + ) + .unwrap() +} + +#[test] +fn signed_witnessed_round_trip_and_json_order() { + let (entry, keys) = fixture(); + assert_eq!( + verify(std::slice::from_ref(&entry), keys)[0].status, + Status::Verified + ); + let bytes = canonical(&entry.envelope.record).unwrap(); + let parsed: Record = serde_json::from_slice(&bytes).unwrap(); + assert_eq!( + record_hash(&parsed).unwrap(), + record_hash(&entry.envelope.record).unwrap() + ); + let duplicate = r#"{"version":1,"version":2}"#; + assert!(serde_json::from_str::(duplicate).is_err()); + for field in ["parent-hash", "platform"] { + let mut payload = serde_json::to_value(&entry.envelope.record).unwrap(); + payload.as_object_mut().unwrap().remove(field); + assert!( + serde_json::from_value::(payload).is_err(), + "missing {field}" + ); + } +} + +#[test] +fn every_signed_field_is_bound() { + let (entry, keys) = fixture(); + for field in [ + "version", + "algorithm", + "key-id", + "author-identity", + "timestamp", + "content-hash", + "parent-hash", + "platform", + "witnesses", + ] { + let mut json = serde_json::to_value(&entry).unwrap(); + let record = &mut json["envelope"]["record"]; + record[field] = match field { + "version" => serde_json::json!(2), + "witnesses" => serde_json::json!([]), + "content-hash" | "key-id" | "parent-hash" => serde_json::json!("a".repeat(64)), + "timestamp" => serde_json::json!("2026-11-06T13:00:00Z"), + _ => serde_json::json!("changed"), + }; + let tampered: Contribution = serde_json::from_value(json).unwrap(); + // An unknown author key is claimed, never verified. + assert_ne!( + verify(&[tampered], keys.clone())[0].status, + Status::Verified, + "{field}" + ); + } +} + +#[test] +fn trust_expiry_revocation_missing_witnesses_and_bad_signatures() { + let (entry, keys) = fixture(); + assert_eq!( + verify(std::slice::from_ref(&entry), vec![])[0].status, + Status::Claimed + ); + let mut revoked = keys.clone(); + revoked[0].revoked = true; + assert_eq!( + verify(std::slice::from_ref(&entry), revoked)[0].status, + Status::Invalid + ); + let mut expired = keys.clone(); + expired[0].valid_until = "2026-11-06T11:00:00Z".into(); + assert_eq!( + verify(std::slice::from_ref(&entry), expired)[0].status, + Status::Invalid + ); + let mut unsigned = entry.clone(); + unsigned.envelope.signature = None; + assert_eq!(verify(&[unsigned], keys.clone())[0].status, Status::Claimed); + let mut missing = entry.clone(); + missing.envelope.attestations.clear(); + assert_eq!(verify(&[missing], keys.clone())[0].status, Status::Claimed); + let mut bad = entry.clone(); + bad.envelope.signature = Some(STANDARD.encode([0; ml_dsa_65::SIG_LEN])); + assert_eq!(verify(&[bad], keys.clone())[0].status, Status::Invalid); + let mut bad = entry.clone(); + bad.envelope.attestations[0].signature = "bad".into(); + assert_eq!(verify(&[bad], keys.clone())[0].status, Status::Invalid); + assert!(Registry::new(vec![keys[0].clone(), keys[0].clone()]).is_err()); + let mut same_identity = keys.clone(); + same_identity[1].author_identity = same_identity[0].author_identity.clone(); + assert_eq!(verify(&[entry], same_identity)[0].status, Status::Invalid); +} + +fn child(parent: &Contribution, keys: &[PublicKey]) -> Contribution { + let mut entry = parent.clone(); + entry.envelope.record.parent_hash = Some(record_hash(&parent.envelope.record).unwrap()); + entry.envelope.record.timestamp = "2026-11-06T13:00:00Z".into(); + let (_, author) = ml_dsa_65::KG::keygen_from_seed(&[1; 32]); + let (_, witness) = ml_dsa_65::KG::keygen_from_seed(&[2; 32]); + entry.envelope.signature = Some(sign(&entry.envelope.record, &author).unwrap()); + entry.envelope.attestations = + vec![attest(&entry.envelope.record, keys[1].key_id.clone(), &witness).unwrap()]; + entry +} + +#[test] +fn chain_removal_splice_reorder_and_untrusted_ancestors() { + let (root, keys) = fixture(); + let next = child(&root, &keys); + assert!(verify(&[root.clone(), next.clone()], keys.clone()) + .iter() + .all(|v| v.status == Status::Verified)); + assert_eq!( + verify(std::slice::from_ref(&next), keys.clone())[0].status, + Status::Invalid + ); + assert_eq!( + verify(&[next.clone(), root.clone()], keys.clone())[1].status, + Status::Invalid + ); + assert_eq!( + verify(&[root.clone(), root.clone()], keys.clone())[1].status, + Status::Invalid + ); + let mut claimed = root.clone(); + claimed.envelope.signature = None; + assert_eq!( + verify(&[claimed, next.clone()], keys.clone())[1].status, + Status::Claimed + ); + let mut invalid = root; + invalid.content_base64 = STANDARD.encode(b"changed"); + assert_eq!(verify(&[invalid, next], keys)[1].status, Status::Invalid); +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(48))] + #[test] + fn arbitrary_content_mutations_are_invalid(bytes in proptest::collection::vec(any::(), 0..256)) { + let (mut entry, keys) = fixture(); + prop_assume!(bytes != b"contribution"); + entry.content_base64 = STANDARD.encode(bytes); + prop_assert_eq!(verify(&[entry], keys)[0].status, Status::Invalid); + } + #[test] + fn arbitrary_metadata_mutations_never_verify(suffix in "[a-zA-Z0-9]{1,40}") { + let (mut entry, keys) = fixture(); + entry.envelope.record.platform = Some(format!("test{suffix}")); + prop_assert_eq!(verify(&[entry], keys)[0].status, Status::Invalid); + } + #[test] + fn arbitrary_chain_splices_never_verify(bytes in any::<[u8; 32]>()) { + let (root, keys) = fixture(); + let mut next = child(&root, &keys); + let spliced = hash(&bytes); + prop_assume!(Some(&spliced) != next.envelope.record.parent_hash.as_ref()); + next.envelope.record.parent_hash = Some(spliced); + prop_assert_eq!(verify(&[root, next], keys)[1].status, Status::Invalid); + } +} diff --git a/docs/PROVENANCE-EXHIBIT-B.adoc b/docs/PROVENANCE-EXHIBIT-B.adoc new file mode 100644 index 0000000..6ca172a --- /dev/null +++ b/docs/PROVENANCE-EXHIBIT-B.adoc @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 += Exhibit B signed provenance, version 1 + +== Wire format and signing + +`crates/provenance` implements the Exhibit B protocol independently of the +desktop shell. `Record` uses kebab-case JSON fields: version (1), algorithm +(`ML-DSA-65`), key-id, author-identity, timestamp, content-hash, parent-hash, +platform and witnesses. Nullable fields must be present. Unknown and duplicate +fields are rejected. Timestamps use RFC 3339, including an explicit zone. +Hashes and key fingerprints are lowercase SHA3-256 hex; keys and signatures +use standard base64. Content hashes cover the exact contribution bytes. + +RFC 8785 JCS (`serde_jcs`) canonicalises the entire Record. Pure ML-DSA-65 +signs those bytes with context `PanLL/Exhibit-B/v1`. The envelope separates +the author signature and witness attestations to avoid recursive signatures. +`witnesses` names the required witness key fingerprints and is itself signed; +each attestation signs the identical canonical Record with its own key. +At least one independent witness is required for verified status. A key with +the same approved identity as the author cannot witness that author's record. + +`parent-hash` hashes the parent's canonical Record (not its mutable signature +envelope). Supply the complete ordered chain from its adoption root. Genesis +has a null parent. Removing, duplicating, reordering or splicing interior +links fails; an invalid/claimed ancestor propagates to descendants. This +does not prove an append-only log is complete: detecting tail truncation +requires a separately trusted head/checkpoint. It does not prove every user +action was recorded, establish a civil identity without key approval, or +prove a signer told the truth about authorship or time. + +== Implementation decision + +`fips204` is pinned to 0.5.0 with only ML-DSA-65 and OS randomness enabled. +It implements FIPS 204, exposes typed fixed-size key/signature decoding and +uses zeroisation for its private-key types. This is a FIPS-aligned algorithm +implementation, not a claim of FIPS 140 validation or an independent audit. +See its upstream source at https://github.com/integritychain/fips204 and +NIST FIPS 204. SHA3-256 is provided by RustCrypto `sha3`. + +== Key policy + +The trust root is the verifier's independently approved `.well-known/keys/` +directory, containing one `PublicKey` JSON document per fingerprint. Required +fields: key-id (SHA3-256 of the raw public key), author-identity, algorithm, +public-key (base64), valid-from, valid-until and revoked (boolean). +Duplicate fingerprints or malformed registry entries abort verification. +Different keys may bind the same identity during planned rotation. + +Each validity interval is positive and no longer than 366 days. Publish and +independently approve a new key before expiry; retain old public keys for +historical verification. Verification checks validity at the record time. +Rotate at least annually, on algorithm-policy changes, or immediately after +suspected compromise. Mark compromised keys revoked; this rejects historical +signatures too, since an attacker can backdate a record. Ordinary expiry +does not revoke earlier records. Keys supplied by a distribution are never +automatically enrolled. Secret keys stay outside Git and outside desktop IPC. + +== Offline adoption workflow + +Build the helper with `cargo build --manifest-path crates/provenance/Cargo.toml +--example record`. Run the resulting executable directly: + +[source,text] +---- +record keygen "Name " 2026-11-05T00:00:00Z 2027-11-05T00:00:00Z public.json /private/location/author.key +record sign record.json /private/location/author.key signed.json +record witness signed.json witness-public.json /private/location/witness.key witnessed.json +---- + +The helper uses create-new writes, refuses private keys in a Git working tree, +and creates private files mode 0600 on Unix. On Windows use a private directory +with a restricted inherited ACL. Approve the public fingerprint separately, +then copy only its public JSON document into the registry. A witness must +independently inspect the actual content and record before signing; the helper +does not replace that human decision. The signed witness list must be chosen +before author signing. Generate no keys for another person's identity. + +== Desktop verification + +`provenance_verify_records(repo_path, records_json)` takes a JSON array of +`{envelope, content-base64}` contributions. It returns per-record status, +record_hash and reasons. It uses adoption time 2026-11-05T00:00:00Z through +the verifier's current clock plus five minutes. Older git history is claimed. +The API `verify_chain` also supports an explicitly trusted time window for +offline callers. Clock validation constrains a claim; it is not an independent +timestamp authority. + +* verified: valid author signature, matching bytes, valid complete chain, + approved identity binding, valid key dates, and all independent witnesses. +* claimed: unsigned record, unknown keys, absent witnesses, or claimed ancestor. +* invalid: malformed/tampered signature, content or chain; revoked/mismatched + key; invalid timestamp; duplicate or undeclared witness; invalid ancestor. + +Malformed JSON and a malformed key registry fail the whole request. Empty +input returns an empty array, never a batch-level success certification. +`provenance_analyse_file` continues returning git blame/trailers as claimed. + +== Evidence and limits + +`cargo test --manifest-path crates/provenance/Cargo.toml --locked` exercises +real ML-DSA signatures, every metadata field, content mutations, witness +tampering, identity binding, validity/revocation and chain deletion/splicing. +Property tests generate arbitrary content, metadata and parent-hash mutations. +`docs/models/Provenance.tla` models signature binding and chain verification +symbolically. Its invariant is not a cryptographic proof of ML-DSA or SHA3, +nor a proof of all implementation paths. Production adoption requires an +approved contributor key and independent witness; historical records are +never silently re-signed. + +Validation receipt, 2026-11-06: the Linux workspace suite passed 295 backend +tests and seven signature/property tests (two existing documentation examples +are ignored). The protocol crate also passed Windows tests and Clippy with +warnings denied. TLC 2.19 from tlaplus/tlaplus release v1.7.4 checked +`Provenance.cfg` with `Values = {a, b}`: 18,045,441 generated states, 68,096 +distinct states, no invariant violation. SHA-256 of the checked model: +`c379ece2393e2cacfffccd6bb0360184ad179c77ae72dc875948a022d123444a`. +The model assumes signature/hash binding; it is not an implementation +correspondence proof or a cryptographic hardness proof. diff --git a/docs/models/Provenance.cfg b/docs/models/Provenance.cfg new file mode 100644 index 0000000..a67c646 --- /dev/null +++ b/docs/models/Provenance.cfg @@ -0,0 +1,3 @@ +SPECIFICATION Spec +CONSTANT Values = {a, b} +INVARIANT Binding diff --git a/docs/models/Provenance.tla b/docs/models/Provenance.tla new file mode 100644 index 0000000..6ac5f4e --- /dev/null +++ b/docs/models/Provenance.tla @@ -0,0 +1,31 @@ +--------------------------- MODULE Provenance --------------------------- +EXTENDS Naturals, Sequences, FiniteSets +\* SPDX-License-Identifier: MPL-2.0 +\* Symbolic unforgeability: an attacker may change fields/content/links or +\* remove signatures, but cannot add a signature for a payload not signed. +CONSTANT Values +VARIABLES chain, authorSigned, witnessSigned, content, verified +vars == <> +Payloads == [field: Values, digest: Values, parent: Values] +Init == /\ chain = <<>> /\ authorSigned = {} /\ witnessSigned = {} + /\ content = <<>> /\ verified = FALSE +Sign == \E p \in Payloads: + /\ authorSigned' = authorSigned \cup {p} + /\ witnessSigned' = witnessSigned \cup {p} + /\ UNCHANGED <> +\* Any ordered pair, including a splice or substituted contribution. +Supply == \E a,b \in Payloads, x,y \in Values: + /\ chain' = <> /\ content' = <> + /\ verified' = FALSE + /\ UNCHANGED <> +Valid == /\ Len(chain) = 2 + /\ \A i \in 1..2: /\ chain[i] \in authorSigned + /\ chain[i] \in witnessSigned + /\ chain[i].digest = content[i] + /\ chain[2].parent = chain[1].field +Check == /\ verified' = Valid + /\ UNCHANGED <> +Next == Sign \/ Supply \/ Check +Spec == Init /\ [][Next]_vars +Binding == verified => Valid +============================================================================= diff --git a/src-gossamer/src/ipc.rs b/src-gossamer/src/ipc.rs index bc0eee0..82c7c8e 100644 --- a/src-gossamer/src/ipc.rs +++ b/src-gossamer/src/ipc.rs @@ -745,6 +745,16 @@ fn plaza(t: &mut Table) { fn provenance(t: &mut Table) { use crate::provenance::commands as c; + t.add( + "provenance_verify_records", + &["repo_path", "records_json"], + |p| { + block_on(c::provenance_verify_records( + arg(p, "repo_path")?, + arg(p, "records_json")?, + )) + }, + ); t.add( "provenance_analyse_file", &["repo_path", "file_path"], diff --git a/src-gossamer/src/provenance/commands.rs b/src-gossamer/src/provenance/commands.rs index 83a486e..1d7e9a6 100644 --- a/src-gossamer/src/provenance/commands.rs +++ b/src-gossamer/src/provenance/commands.rs @@ -92,6 +92,7 @@ pub async fn provenance_analyse_file( let result = json!({ "authors": author_list, "co_authors": co_authors, + "status": "claimed", "total_lines": total_lines, "file": file_path, "repo": repo_path, @@ -100,6 +101,59 @@ pub async fn provenance_analyse_file( Ok(result.to_string()) } +/// Verify Exhibit B records against this repository's independently approved +/// `.well-known/keys/*.json` registry. No secret keys enter the IPC channel. +pub async fn provenance_verify_records( + repo_path: String, + records_json: String, +) -> Result { + use panll_provenance::{Contribution, PublicKey, Registry}; + if records_json.len() > 16 * 1024 * 1024 { + return Err("record batch exceeds 16 MiB".into()); + } + let entries: Vec = + serde_json::from_str(&records_json).map_err(|e| e.to_string())?; + if entries.len() > 1024 { + return Err("record batch exceeds 1024 entries".into()); + } + let directory = std::path::Path::new(&repo_path).join(".well-known/keys"); + let mut keys: Vec = Vec::new(); + match fs::read_dir(directory) { + Ok(files) => { + for file in files { + let file = file.map_err(|e| e.to_string())?; + if file.path().extension().and_then(|s| s.to_str()) == Some("json") { + if file.metadata().map_err(|e| e.to_string())?.len() > 65536 { + return Err("public key file exceeds 64 KiB".into()); + } + keys.push( + serde_json::from_slice(&fs::read(file.path()).map_err(|e| e.to_string())?) + .map_err(|e| format!("invalid key registry: {e}"))?, + ); + } + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e.to_string()), + } + let registry = Registry::new(keys)?; + // Adoption, not retrospective re-signing. Allow five minutes of clock skew. + let latest = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| e.to_string())? + .as_secs() + .checked_add(300) + .ok_or("clock overflow")?; + let latest = panll_provenance::utc_timestamp(latest)?; + serde_json::to_string(&panll_provenance::verify_chain( + &entries, + ®istry, + "2026-11-05T00:00:00Z", + &latest, + )?) + .map_err(|e| e.to_string()) +} + /// Extract Co-Authored-By trailers from commits that touch a given file. fn extract_co_authors(repo_path: &str, file_path: &str) -> Vec { let output = Command::new("git") @@ -205,6 +259,37 @@ mod tests { use super::*; use std::io::Write; + #[tokio::test] + async fn test_signed_record_ipc_fails_closed_and_keeps_unsigned_claimed() { + let root = std::env::temp_dir().join(format!("panll-provenance-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&root).unwrap(); + let repo = root.to_string_lossy().to_string(); + let records = json!([{ + "envelope": { + "record": {"version": 1, "algorithm": "ML-DSA-65", "key-id": "a".repeat(64), + "author-identity": "Claimed author", "timestamp": "2026-11-05T12:00:00Z", + "content-hash": panll_provenance::hash(b""), "parent-hash": null, + "platform": null, "witnesses": []}, + "signature": null, "attestations": [] + }, + "content-base64": "" + }]) + .to_string(); + let result = provenance_verify_records(repo.clone(), records.clone()) + .await + .unwrap(); + let result: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(result[0]["status"], "claimed"); + assert!(provenance_verify_records(repo.clone(), "not JSON".into()) + .await + .is_err()); + let keys = root.join(".well-known/keys"); + fs::create_dir_all(&keys).unwrap(); + fs::write(keys.join("broken.json"), b"{}").unwrap(); + assert!(provenance_verify_records(repo, records).await.is_err()); + fs::remove_dir_all(root).unwrap(); + } + #[tokio::test] async fn test_provenance_scan_unsound_finds_patterns() { // Create a temporary file with known unsound patterns.