myc

Self-custodial remote signer for Radroots apps
git clone https://radroots.dev/git/myc.git
Log | Files | Refs | README | LICENSE

commit 875099514f23d10593f7c36969237be44afeda69
parent 34c71f38970de687e1da749c6a88d59ee7cbbd60
Author: triesap <tyson@radroots.org>
Date:   Fri, 21 Aug 2026 11:34:02 +0000

contracts: freeze Myc v1 configuration

- bind the complete strict schema, safety defaults, and semantic relationships\n- validate canonical production and repo-local examples plus fail-closed vectors\n- keep Nix and OCI qualification deferred through RCLD-RSHR-170

Diffstat:
MAGENTS.md | 9+++++----
MCargo.lock | 430+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
MCargo.toml | 2++
MREADME | 14++++++++++++++
Acontracts/services_hardening/config.v1.example.toml | 156+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acontracts/services_hardening/config.v1.schema.json | 508+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mradroots.lib.source-lock.v1.toml | 2+-
Atests/services_hardening_config_contract.rs | 760+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 1854 insertions(+), 27 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -181,10 +181,11 @@ all-target checking and testing, warnings-denied all-target Clippy, rustdoc with warnings denied, and diff hygiene. Run any gate not yet covered by the current release script explicitly; do not describe the script as sufficient until it enforces the complete contract. Run additional SQLx freshness, source-lock, -independently produced OCI, systemd, package, SBOM, checksum, notice, and -fresh-install gates when their surfaces change. Checked-in Nix material remains -deferred source data through RCLD-RSHR-170 and is not a verification gate. Use -narrower checked-in commands only for iteration, and never claim a command +systemd, package, SBOM, checksum, notice, and fresh-install gates when their +surfaces change. Checked-in Nix material remains deferred source data through +RCLD-RSHR-170 and is not a verification gate. Every OCI production or +qualification path is likewise deferred and unclaimed through RCLD-RSHR-170. +Use narrower checked-in commands only for iteration, and never claim a command passed unless it ran successfully. ## 10. Commits and irreversible actions diff --git a/Cargo.lock b/Cargo.lock @@ -24,6 +24,20 @@ dependencies = [ ] [[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.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -242,6 +256,21 @@ dependencies = [ ] [[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 = "bitcoin-io" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -283,12 +312,24 @@ dependencies = [ ] [[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + +[[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -391,7 +432,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -584,7 +625,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -620,6 +661,15 @@ dependencies = [ ] [[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + +[[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -647,6 +697,17 @@ dependencies = [ ] [[package]] +name = "fancy-regex" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] name = "fastrand" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -669,6 +730,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + +[[package]] name = "flume" version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -716,6 +788,16 @@ dependencies = [ ] [[package]] +name = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + +[[package]] name = "futures" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -781,7 +863,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -844,9 +926,11 @@ 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]] @@ -906,6 +990,17 @@ dependencies = [ ] [[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] name = "hashlink" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1208,6 +1303,58 @@ dependencies = [ ] [[package]] +name = "jsonschema" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05a6cff806294a7699d48761d4ae2bdebab7fa2eef05e4a0b83320f0ff46901" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom 0.3.4", + "idna 1.1.0", + "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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84708dd3f6c5327478a96f37378adb27deed6a626cf84d19de780d9e8ae84b17" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "jsonschema-value" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd8c04ab4ce60e42c4e302d3bd9d184652bb56f212495d2eea23573bcbf72556" +dependencies = [ + "ahash", + "bytecount", + "fraction", + "num-cmp", + "num-traits", + "serde_json", +] + +[[package]] name = "k256" version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1344,6 +1491,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + +[[package]] name = "mime" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1370,6 +1523,7 @@ dependencies = [ "futures-executor", "futures-util", "hex", + "jsonschema", "keyring", "nostr", "nostr-sdk", @@ -1389,6 +1543,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-tungstenite", + "toml", "tracing", "tracing-appender", "tracing-subscriber", @@ -1492,12 +1647,81 @@ dependencies = [ ] [[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-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.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] name = "num-traits" version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1547,7 +1771,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1573,6 +1797,12 @@ dependencies = [ ] [[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] name = "parking" version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1682,7 +1912,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -1915,6 +2145,55 @@ dependencies = [ ] [[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "referencing" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "980e77f5cfffc592c0e7dfb6d20770a0b96a6ab94d403de7454ed9799505e4ba" +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.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] name = "regex-automata" version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2139,7 +2418,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2167,6 +2446,15 @@ dependencies = [ ] [[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] name = "serial_test" version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2188,7 +2476,7 @@ checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2321,7 +2609,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn", + "syn 2.0.117", ] [[package]] @@ -2342,7 +2630,7 @@ dependencies = [ "sha2", "sqlx-core", "sqlx-sqlite", - "syn", + "syn 2.0.117", "url", ] @@ -2383,6 +2671,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2400,6 +2709,17 @@ dependencies = [ ] [[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2413,7 +2733,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2455,7 +2775,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2466,7 +2786,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2558,7 +2878,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2600,6 +2920,47 @@ dependencies = [ ] [[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] name = "tower" version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2658,7 +3019,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2830,6 +3191,16 @@ dependencies = [ ] [[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 = "valuable" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2848,6 +3219,12 @@ 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 = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2914,7 +3291,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -3161,6 +3538,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3190,7 +3576,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -3206,7 +3592,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -3273,7 +3659,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -3294,7 +3680,7 @@ checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3314,7 +3700,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -3335,7 +3721,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3368,7 +3754,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml @@ -61,5 +61,7 @@ zeroize = "1.8" [dev-dependencies] futures-util = "0.3.32" +jsonschema = { version = "0.48.1", default-features = false } serial_test = "3" tokio-tungstenite = "0.26.2" +toml = "0.8" diff --git a/README b/README @@ -3,6 +3,20 @@ This is the README for `myc` which provides a Nostr remote signer for standalone and application-embedded clients. +## Hardened v1 configuration contract + +The target service configuration is frozen by +`contracts/services_hardening/config.v1.schema.json` and the canonical +non-secret `config.v1.example.toml`. It is one strict, immutable TOML document +with explicit identity, relay, authorization, rate, and discovery authority. +Only reviewed bounded operational leaves have defaults. Bootstrap profile, +instance, repo-local root, and config-path selection are CLI concerns and are +not document fields. + +The current runtime loader remains transitional until its ordered replacement +steps are complete. Existing environment variables and `.env.example` are +prototype evidence, not authority to change or weaken the v1 contract. + ## NIP-46 runtime contract Myc listens for encrypted kind-24133 requests on the ordered relay set in diff --git a/contracts/services_hardening/config.v1.example.toml b/contracts/services_hardening/config.v1.example.toml @@ -0,0 +1,156 @@ +schema = "radroots.myc.config" +schema_version = 1 + +[service] +shutdown_grace_ms = 30000 + +[logging] +level = "info" +format = "json" + +[operations] +enabled = false + +[database] +busy_timeout_ms = 5000 +max_connections = 8 +minimum_free_bytes = 268435456 + +[identity.transport] +provider = "encrypted_file" +envelope_path = "/var/lib/radroots/services/myc/primary/secrets/transport.identity.ncrypt" +credential_reference = "transport_wrapping_key" +expected_public_key = "1111111111111111111111111111111111111111111111111111111111111111" + +[identity.user] +provider = "local_signer" +socket_path = "/run/radroots/services/myc/primary/user-signer.sock" +request_deadline_ms = 15000 +request_max_bytes = 65536 +response_max_bytes = 1048576 +concurrency = 32 +expected_public_key = "2222222222222222222222222222222222222222222222222222222222222222" + +[identity.discovery] +enabled = true + +[identity.discovery.binding] +provider = "encrypted_file" +envelope_path = "/var/lib/radroots/services/myc/primary/secrets/discovery.identity.ncrypt" +credential_reference = "discovery_wrapping_key" +expected_public_key = "3333333333333333333333333333333333333333333333333333333333333333" + +[[relays]] +id = "primary" +url = "wss://relay-primary.example.test/" +read = true +write = true +required = true +authentication = "required" + +[[relays]] +id = "secondary" +url = "wss://relay-secondary.example.test/" +read = true +write = true +required = true +authentication = "required" + +[transport] +connect_deadline_ms = 10000 + +[transport.delivery_policy] +mode = "all_required" + +[transport.publish_retry] +max_attempts = 5 +initial_backoff_ms = 250 +maximum_backoff_ms = 30000 +attempt_deadline_ms = 15000 + +[policy] +connection_approval = "explicit_user" +trusted_clients = ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"] +denied_clients = ["bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"] +permission_ceiling = ["nip04_decrypt", "nip04_encrypt", "nip44_decrypt", "nip44_encrypt", "sign_event:kind:1"] +allowed_sign_event_kinds = [1] + +[policy.challenges] +enabled = true +url = "https://myc.example.test/auth/challenge" +pending_lifetime_ms = 900000 +authorized_lifetime_ms = 3600000 + +[policy.retention] +terminal_connections_ms = 604800000 +terminal_challenges_ms = 86400000 +request_dedup_ms = 604800000 +audit_ms = 2592000000 +completed_outbox_ms = 604800000 + +[rate_limits.connection_admission] +scope = "global_and_relay" +window_ms = 60000 +max_attempts = 10 +retention_ms = 3600000 +maximum_tracked_subjects = 4096 + +[rate_limits.challenge_creation] +scope = "connection" +window_ms = 120000 +max_attempts = 5 +retention_ms = 86400000 +maximum_tracked_subjects = 16384 + +[rate_limits.challenge_authorization] +scope = "connection" +window_ms = 120000 +max_attempts = 5 +retention_ms = 86400000 +maximum_tracked_subjects = 16384 + +[resource_limits.admin] +header_count = 32 +header_bytes = 16384 +request_body_utf8_bytes = 65536 +response_body_utf8_bytes = 1048576 +concurrent_connections = 32 +request_deadline_ms = 15000 +idle_timeout_ms = 30000 +query_items = 100 + +[resource_limits.events] +wire_bytes = 262144 +content_bytes = 131072 +tag_count = 1024 +tag_total_elements = 4096 +tag_element_bytes = 4096 +tag_total_bytes = 131072 +decrypted_plaintext_bytes = 262144 + +[resource_limits.queues] +ingress = 1024 +provider = 64 +outbox = 4096 +discovery = 64 + +[resource_limits.metrics] +descriptors = 64 +samples = 512 +labels_per_sample = 8 +render_utf8_bytes = 1048576 + +[discovery] +enabled = true +domain = "myc.example.test" +handler_identifier = "myc" +public_relay_ids = ["primary", "secondary"] +publish_relay_ids = ["primary", "secondary"] +nostrconnect_url_template = "https://myc.example.test/connect?uri=<nostrconnect>" + +[discovery.metadata] +name = "myc" +display_name = "Radroots Myc" +about = "NIP-46 signer" +website = "https://myc.example.test/" +picture = "https://myc.example.test/myc.png" diff --git a/contracts/services_hardening/config.v1.schema.json b/contracts/services_hardening/config.v1.schema.json @@ -0,0 +1,508 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/radrootslabs/myc/contracts/services_hardening/config.v1.schema.json", + "title": "Radroots Myc configuration v1", + "description": "Strict, immutable, non-secret Myc service configuration.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "schema_version", + "service", + "logging", + "operations", + "database", + "identity", + "relays", + "transport", + "policy", + "rate_limits", + "resource_limits", + "discovery" + ], + "properties": { + "schema": { "const": "radroots.myc.config" }, + "schema_version": { "const": 1 }, + "service": { "$ref": "#/$defs/service" }, + "logging": { "$ref": "#/$defs/logging" }, + "operations": { "$ref": "#/$defs/operations" }, + "database": { "$ref": "#/$defs/database" }, + "identity": { "$ref": "#/$defs/identity" }, + "relays": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "items": { "$ref": "#/$defs/relay" } + }, + "transport": { "$ref": "#/$defs/transport" }, + "policy": { "$ref": "#/$defs/policy" }, + "rate_limits": { "$ref": "#/$defs/rate_limits" }, + "resource_limits": { "$ref": "#/$defs/resource_limits" }, + "discovery": { "$ref": "#/$defs/discovery" } + }, + "$defs": { + "absolute_path": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "x-radroots-maximum-utf8-bytes": 4096, + "pattern": "^/" + }, + "bounded_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "x-radroots-maximum-utf8-bytes": 128, + "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$" + }, + "relay_id": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "x-radroots-maximum-utf8-bytes": 64, + "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$" + }, + "credential_reference": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "x-radroots-maximum-utf8-bytes": 128, + "pattern": "^[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?$" + }, + "public_key": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "relay_url": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "x-radroots-maximum-utf8-bytes": 2048, + "pattern": "^(wss://|ws://(localhost|127\\.0\\.0\\.1|\\[::1\\])(?::[1-9][0-9]{0,4})?(?:/|$))" + }, + "https_url": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "x-radroots-maximum-utf8-bytes": 2048, + "pattern": "^https://" + }, + "nostrconnect_url_template": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "x-radroots-maximum-utf8-bytes": 2048, + "pattern": "^https://[^ ]*<nostrconnect>[^ ]*$" + }, + "service": { + "type": "object", + "additionalProperties": false, + "properties": { + "shutdown_grace_ms": { + "type": "integer", + "minimum": 1, + "maximum": 300000, + "default": 30000, + "x-radroots-default-source": "engineering_safety" + } + } + }, + "logging": { + "type": "object", + "additionalProperties": false, + "properties": { + "level": { + "type": "string", + "enum": ["trace", "debug", "info", "warn", "error"], + "default": "info", + "x-radroots-default-source": "engineering_safety" + }, + "format": { + "const": "json", + "default": "json", + "x-radroots-default-source": "accepted_service_authority" + } + } + }, + "operations_limits": { + "type": "object", + "additionalProperties": false, + "properties": { + "header_count": { "type": "integer", "minimum": 1, "maximum": 64, "default": 32, "x-radroots-default-source": "radroots_service_host" }, + "header_bytes": { "type": "integer", "minimum": 1, "maximum": 32768, "default": 16384, "x-radroots-default-source": "radroots_service_host" }, + "response_body_utf8_bytes": { "type": "integer", "minimum": 1, "maximum": 1048576, "default": 1048576, "x-radroots-default-source": "radroots_service_host" }, + "concurrent_connections": { "type": "integer", "minimum": 1, "maximum": 64, "default": 32, "x-radroots-default-source": "radroots_service_host" }, + "request_deadline_ms": { "type": "integer", "minimum": 1, "maximum": 30000, "default": 15000, "x-radroots-default-source": "radroots_service_host" }, + "idle_timeout_ms": { "type": "integer", "minimum": 1, "maximum": 60000, "default": 30000, "x-radroots-default-source": "radroots_service_host" } + } + }, + "operations": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["enabled"], + "properties": { "enabled": { "const": false } } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "listen", "bind_policy"], + "properties": { + "enabled": { "const": true }, + "listen": { "type": "string", "minLength": 1, "maxLength": 128, "x-radroots-maximum-utf8-bytes": 128 }, + "bind_policy": { "type": "string", "enum": ["loopback_only", "public"] }, + "limits": { "$ref": "#/$defs/operations_limits" } + } + } + ] + }, + "database": { + "type": "object", + "additionalProperties": false, + "required": ["minimum_free_bytes"], + "properties": { + "busy_timeout_ms": { "type": "integer", "minimum": 1, "maximum": 60000, "default": 5000, "x-radroots-default-source": "radroots_service_sqlite" }, + "max_connections": { "type": "integer", "minimum": 1, "maximum": 8, "default": 8, "x-radroots-default-source": "radroots_service_sqlite" }, + "minimum_free_bytes": { "type": "integer", "minimum": 1, "maximum": 9223372036854775807 } + } + }, + "encrypted_file_provider": { + "type": "object", + "additionalProperties": false, + "required": ["provider", "envelope_path", "credential_reference", "expected_public_key"], + "properties": { + "provider": { "const": "encrypted_file" }, + "envelope_path": { "$ref": "#/$defs/absolute_path" }, + "credential_reference": { "$ref": "#/$defs/credential_reference" }, + "expected_public_key": { "$ref": "#/$defs/public_key" } + } + }, + "local_signer_provider": { + "type": "object", + "additionalProperties": false, + "required": ["provider", "socket_path", "request_deadline_ms", "request_max_bytes", "response_max_bytes", "concurrency", "expected_public_key"], + "properties": { + "provider": { "const": "local_signer" }, + "socket_path": { "$ref": "#/$defs/absolute_path" }, + "request_deadline_ms": { "type": "integer", "minimum": 1, "maximum": 30000 }, + "request_max_bytes": { "type": "integer", "minimum": 1, "maximum": 65536 }, + "response_max_bytes": { "type": "integer", "minimum": 1, "maximum": 1048576 }, + "concurrency": { "type": "integer", "minimum": 1, "maximum": 64 }, + "expected_public_key": { "$ref": "#/$defs/public_key" } + } + }, + "provider": { + "oneOf": [ + { "$ref": "#/$defs/encrypted_file_provider" }, + { "$ref": "#/$defs/local_signer_provider" } + ] + }, + "discovery_identity": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["enabled"], + "properties": { "enabled": { "const": false } } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "binding"], + "properties": { + "enabled": { "const": true }, + "binding": { "$ref": "#/$defs/provider" } + } + } + ] + }, + "identity": { + "type": "object", + "additionalProperties": false, + "required": ["transport", "user", "discovery"], + "properties": { + "transport": { "$ref": "#/$defs/provider" }, + "user": { "$ref": "#/$defs/provider" }, + "discovery": { "$ref": "#/$defs/discovery_identity" } + } + }, + "relay": { + "type": "object", + "additionalProperties": false, + "required": ["id", "url", "read", "write", "required", "authentication"], + "properties": { + "id": { "$ref": "#/$defs/relay_id" }, + "url": { "$ref": "#/$defs/relay_url" }, + "read": { "type": "boolean" }, + "write": { "type": "boolean" }, + "required": { "type": "boolean" }, + "authentication": { "type": "string", "enum": ["disabled", "required"] } + } + }, + "delivery_policy": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["mode"], + "properties": { "mode": { "enum": ["all_required", "at_least_one_required"] } } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["mode", "required_acknowledgements"], + "properties": { + "mode": { "const": "required_quorum" }, + "required_acknowledgements": { "type": "integer", "minimum": 1, "maximum": 32 } + } + } + ] + }, + "publish_retry": { + "type": "object", + "additionalProperties": false, + "properties": { + "max_attempts": { "type": "integer", "minimum": 1, "maximum": 32, "default": 5, "x-radroots-default-source": "engineering_safety" }, + "initial_backoff_ms": { "type": "integer", "minimum": 1, "maximum": 30000, "default": 250, "x-radroots-default-source": "engineering_safety" }, + "maximum_backoff_ms": { "type": "integer", "minimum": 1, "maximum": 300000, "default": 30000, "x-radroots-default-source": "engineering_safety" }, + "attempt_deadline_ms": { "type": "integer", "minimum": 1, "maximum": 30000, "default": 15000, "x-radroots-default-source": "radroots_service_host" } + } + }, + "transport": { + "type": "object", + "additionalProperties": false, + "required": ["delivery_policy"], + "properties": { + "connect_deadline_ms": { "type": "integer", "minimum": 1, "maximum": 30000, "default": 10000, "x-radroots-default-source": "engineering_safety" }, + "delivery_policy": { "$ref": "#/$defs/delivery_policy" }, + "publish_retry": { "$ref": "#/$defs/publish_retry" } + } + }, + "permission": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "x-radroots-maximum-utf8-bytes": 64, + "pattern": "^(nip04_decrypt|nip04_encrypt|nip44_decrypt|nip44_encrypt|switch_relays|sign_event:kind:(0|[1-9][0-9]{0,9}))$" + }, + "challenges": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["enabled"], + "properties": { "enabled": { "const": false } } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "url", "pending_lifetime_ms", "authorized_lifetime_ms"], + "properties": { + "enabled": { "const": true }, + "url": { "$ref": "#/$defs/https_url" }, + "pending_lifetime_ms": { "type": "integer", "minimum": 1000, "maximum": 86400000 }, + "authorized_lifetime_ms": { "type": "integer", "minimum": 1000, "maximum": 2592000000 } + } + } + ] + }, + "retention": { + "type": "object", + "additionalProperties": false, + "required": ["terminal_connections_ms", "terminal_challenges_ms", "request_dedup_ms", "audit_ms", "completed_outbox_ms"], + "properties": { + "terminal_connections_ms": { "type": "integer", "minimum": 1, "maximum": 31536000000 }, + "terminal_challenges_ms": { "type": "integer", "minimum": 1, "maximum": 31536000000 }, + "request_dedup_ms": { "type": "integer", "minimum": 1, "maximum": 31536000000 }, + "audit_ms": { "type": "integer", "minimum": 1, "maximum": 31536000000 }, + "completed_outbox_ms": { "type": "integer", "minimum": 1, "maximum": 31536000000 } + } + }, + "policy": { + "type": "object", + "additionalProperties": false, + "required": ["connection_approval", "trusted_clients", "denied_clients", "permission_ceiling", "allowed_sign_event_kinds", "challenges", "retention"], + "properties": { + "connection_approval": { "const": "explicit_user" }, + "trusted_clients": { "type": "array", "maxItems": 1024, "uniqueItems": true, "items": { "$ref": "#/$defs/public_key" } }, + "denied_clients": { "type": "array", "maxItems": 1024, "uniqueItems": true, "items": { "$ref": "#/$defs/public_key" } }, + "permission_ceiling": { "type": "array", "minItems": 1, "maxItems": 64, "uniqueItems": true, "items": { "$ref": "#/$defs/permission" } }, + "allowed_sign_event_kinds": { "type": "array", "minItems": 1, "maxItems": 64, "uniqueItems": true, "items": { "type": "integer", "minimum": 0, "maximum": 4294967295 } }, + "challenges": { "$ref": "#/$defs/challenges" }, + "retention": { "$ref": "#/$defs/retention" } + } + }, + "rate_limit": { + "type": "object", + "additionalProperties": false, + "required": ["scope", "window_ms", "max_attempts", "retention_ms", "maximum_tracked_subjects"], + "properties": { + "scope": { "type": "string", "enum": ["global_and_relay", "connection"] }, + "window_ms": { "type": "integer", "minimum": 1, "maximum": 86400000 }, + "max_attempts": { "type": "integer", "minimum": 1, "maximum": 10000 }, + "retention_ms": { "type": "integer", "minimum": 1, "maximum": 2592000000 }, + "maximum_tracked_subjects": { "type": "integer", "minimum": 1, "maximum": 65536 } + } + }, + "rate_limits": { + "type": "object", + "additionalProperties": false, + "required": ["connection_admission", "challenge_creation", "challenge_authorization"], + "properties": { + "connection_admission": { "$ref": "#/$defs/rate_limit" }, + "challenge_creation": { "$ref": "#/$defs/rate_limit" }, + "challenge_authorization": { "$ref": "#/$defs/rate_limit" } + } + }, + "admin_limits": { + "type": "object", + "additionalProperties": false, + "properties": { + "header_count": { "type": "integer", "minimum": 1, "maximum": 64, "default": 32, "x-radroots-default-source": "radroots_service_host" }, + "header_bytes": { "type": "integer", "minimum": 1, "maximum": 32768, "default": 16384, "x-radroots-default-source": "radroots_service_host" }, + "request_body_utf8_bytes": { "type": "integer", "minimum": 1, "maximum": 65536, "default": 65536, "x-radroots-default-source": "radroots_service_host" }, + "response_body_utf8_bytes": { "type": "integer", "minimum": 1, "maximum": 1048576, "default": 1048576, "x-radroots-default-source": "radroots_service_host" }, + "concurrent_connections": { "type": "integer", "minimum": 1, "maximum": 64, "default": 32, "x-radroots-default-source": "radroots_service_host" }, + "request_deadline_ms": { "type": "integer", "minimum": 1, "maximum": 30000, "default": 15000, "x-radroots-default-source": "radroots_service_host" }, + "idle_timeout_ms": { "type": "integer", "minimum": 1, "maximum": 60000, "default": 30000, "x-radroots-default-source": "radroots_service_host" }, + "query_items": { "type": "integer", "minimum": 1, "maximum": 200, "default": 100, "x-radroots-default-source": "radroots_service_host" } + } + }, + "event_limits": { + "type": "object", + "additionalProperties": false, + "properties": { + "wire_bytes": { "type": "integer", "minimum": 1, "maximum": 524288, "default": 262144, "x-radroots-default-source": "radroots_event" }, + "content_bytes": { "type": "integer", "minimum": 1, "maximum": 131072, "default": 131072, "x-radroots-default-source": "radroots_event" }, + "tag_count": { "type": "integer", "minimum": 1, "maximum": 1024, "default": 1024, "x-radroots-default-source": "radroots_event" }, + "tag_total_elements": { "type": "integer", "minimum": 1, "maximum": 4096, "default": 4096, "x-radroots-default-source": "radroots_event" }, + "tag_element_bytes": { "type": "integer", "minimum": 1, "maximum": 4096, "default": 4096, "x-radroots-default-source": "radroots_event" }, + "tag_total_bytes": { "type": "integer", "minimum": 1, "maximum": 131072, "default": 131072, "x-radroots-default-source": "radroots_event" }, + "decrypted_plaintext_bytes": { "type": "integer", "minimum": 1, "maximum": 262144, "default": 262144, "x-radroots-default-source": "radroots_nostr_connect" } + } + }, + "queue_limits": { + "type": "object", + "additionalProperties": false, + "properties": { + "ingress": { "type": "integer", "minimum": 1, "maximum": 65536, "default": 1024, "x-radroots-default-source": "engineering_safety" }, + "provider": { "type": "integer", "minimum": 1, "maximum": 1024, "default": 64, "x-radroots-default-source": "engineering_safety" }, + "outbox": { "type": "integer", "minimum": 1, "maximum": 65536, "default": 4096, "x-radroots-default-source": "engineering_safety" }, + "discovery": { "type": "integer", "minimum": 1, "maximum": 1024, "default": 64, "x-radroots-default-source": "engineering_safety" } + } + }, + "metrics_limits": { + "type": "object", + "additionalProperties": false, + "properties": { + "descriptors": { "type": "integer", "minimum": 1, "maximum": 64, "default": 64, "x-radroots-default-source": "radroots_service_host" }, + "samples": { "type": "integer", "minimum": 1, "maximum": 512, "default": 512, "x-radroots-default-source": "radroots_service_host" }, + "labels_per_sample": { "type": "integer", "minimum": 1, "maximum": 8, "default": 8, "x-radroots-default-source": "radroots_service_host" }, + "render_utf8_bytes": { "type": "integer", "minimum": 1, "maximum": 1048576, "default": 1048576, "x-radroots-default-source": "radroots_service_host" } + } + }, + "resource_limits": { + "type": "object", + "additionalProperties": false, + "properties": { + "admin": { "$ref": "#/$defs/admin_limits" }, + "events": { "$ref": "#/$defs/event_limits" }, + "queues": { "$ref": "#/$defs/queue_limits" }, + "metrics": { "$ref": "#/$defs/metrics_limits" } + } + }, + "discovery_metadata": { + "type": "object", + "additionalProperties": false, + "required": ["name", "display_name", "about", "website", "picture"], + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 128, "x-radroots-maximum-utf8-bytes": 128 }, + "display_name": { "type": "string", "minLength": 1, "maxLength": 128, "x-radroots-maximum-utf8-bytes": 128 }, + "about": { "type": "string", "maxLength": 1024, "x-radroots-maximum-utf8-bytes": 1024 }, + "website": { "$ref": "#/$defs/https_url" }, + "picture": { "$ref": "#/$defs/https_url" } + } + }, + "discovery": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["enabled"], + "properties": { "enabled": { "const": false } } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "domain", "handler_identifier", "public_relay_ids", "publish_relay_ids", "nostrconnect_url_template", "metadata"], + "properties": { + "enabled": { "const": true }, + "domain": { "type": "string", "minLength": 1, "maxLength": 253, "x-radroots-maximum-utf8-bytes": 253, "pattern": "^[a-z0-9.-]+$" }, + "handler_identifier": { "$ref": "#/$defs/bounded_id" }, + "public_relay_ids": { "type": "array", "minItems": 1, "maxItems": 32, "uniqueItems": true, "items": { "$ref": "#/$defs/relay_id" } }, + "publish_relay_ids": { "type": "array", "minItems": 1, "maxItems": 32, "uniqueItems": true, "items": { "$ref": "#/$defs/relay_id" } }, + "nostrconnect_url_template": { "$ref": "#/$defs/nostrconnect_url_template" }, + "metadata": { "$ref": "#/$defs/discovery_metadata" } + } + } + ] + } + }, + "x-radroots-contract": { + "schema": "radroots.myc.configuration-contract.v1", + "contract_version": 1, + "document_format": "toml_utf8", + "document_max_utf8_bytes": 1048576, + "unknown_fields": "reject_at_every_object_boundary", + "duplicate_keys": "reject_on_original_wire", + "null_values": "reject_on_original_wire", + "environment_overlay": "forbidden", + "protected_material": "forbidden", + "effective_output": "deterministic_redacted_with_exact_provenance", + "bootstrap_only": ["profile", "instance", "repo_local_root", "config_path"], + "safe_default_sources": ["radroots_service_host", "radroots_service_sqlite", "radroots_event", "radroots_nostr_connect", "accepted_service_authority", "engineering_safety"], + "semantic_relationships": [ + "relay_ids_unique", + "relay_urls_canonical_unique", + "relay_read_or_write", + "required_relay_read_or_write", + "production_relays_wss_only", + "repo_local_plaintext_relays_loopback_only", + "at_least_one_read_relay", + "at_least_one_write_relay", + "at_least_one_required_read_relay", + "at_least_one_required_write_relay", + "enabled_role_expected_public_keys_unique", + "trusted_and_denied_clients_disjoint", + "permission_sign_event_kinds_equal_allowed_sign_event_kinds", + "challenge_rate_limits_present_even_when_challenges_disabled", + "rate_retention_not_less_than_window", + "challenge_authorized_lifetime_not_less_than_pending_lifetime", + "delivery_quorum_not_greater_than_required_write_relays", + "publish_initial_backoff_not_greater_than_maximum_backoff", + "discovery_enabled_iff_discovery_identity_enabled", + "discovery_public_relay_ids_reference_read_relays", + "discovery_publish_relay_ids_reference_write_relays", + "operations_public_bind_requires_explicit_public_policy" + ], + "forbidden_prototype_fields": [ + "paths", + "custody", + "persistence", + "audit", + "observability", + "env_file", + "worker", + "signer_state_backend", + "runtime_audit_backend", + "output_dir", + "stdout", + "external_command", + "host_vault", + "keyring", + "managed_account", + "plaintext_file" + ] + } +} diff --git a/radroots.lib.source-lock.v1.toml b/radroots.lib.source-lock.v1.toml @@ -6,4 +6,4 @@ workspace_catalog_sha256 = "deca0c080deae187ff8186c0708903e42f41ea57f77c5f91581e version = "0.1.0-alpha" source_archive_sha256 = "975474804e6358b9228981add0a23181dbdd1afddf5ae12579c82220876bc379" lockfile = "Cargo.lock" -lockfile_sha256 = "395d6703fdd37ba43558475ac739d6878d27d305c479ac1c07b19afe4c5d5867" +lockfile_sha256 = "06df285eb4b6ae0e88a70e4bf55710744f8bbf320fa2d1873c241138208823b5" diff --git a/tests/services_hardening_config_contract.rs b/tests/services_hardening_config_contract.rs @@ -0,0 +1,760 @@ +#![forbid(unsafe_code)] + +use std::collections::{BTreeMap, BTreeSet}; +use std::net::SocketAddr; + +use serde_json::{Map, Value, json}; + +const CONFIG_SCHEMA: &str = include_str!("../contracts/services_hardening/config.v1.schema.json"); +const CONFIG_EXAMPLE: &str = include_str!("../contracts/services_hardening/config.v1.example.toml"); + +#[derive(Clone, Copy)] +enum Profile { + Production, + RepoLocal, +} + +fn schema() -> Value { + serde_json::from_str(CONFIG_SCHEMA).expect("configuration schema must be valid JSON") +} + +fn example() -> Value { + let value = toml::from_str::<toml::Value>(CONFIG_EXAMPLE) + .expect("configuration example must be valid TOML"); + serde_json::to_value(value).expect("TOML value must convert to JSON") +} + +fn schema_valid(value: &Value) -> bool { + jsonschema::validator_for(&schema()) + .expect("configuration schema must compile") + .is_valid(value) +} + +fn string_array<'a>(value: &'a Value, pointer: &str) -> Vec<&'a str> { + value + .pointer(pointer) + .and_then(Value::as_array) + .expect("string array") + .iter() + .map(|value| value.as_str().expect("string item")) + .collect() +} + +fn semantic_valid(value: &Value, profile: Profile) -> bool { + if !schema_valid(value) { + return false; + } + + let relays = value["relays"].as_array().expect("relays"); + let relay_ids = relays + .iter() + .map(|relay| relay["id"].as_str().expect("relay id")) + .collect::<Vec<_>>(); + let relay_urls = relays + .iter() + .map(|relay| relay["url"].as_str().expect("relay URL")) + .collect::<Vec<_>>(); + let parsed_relay_urls = relay_urls + .iter() + .map(|raw| { + url::Url::parse(raw) + .ok() + .filter(|parsed| parsed.as_str() == *raw) + }) + .collect::<Vec<_>>(); + if relay_ids.iter().collect::<BTreeSet<_>>().len() != relay_ids.len() + || relay_urls.iter().collect::<BTreeSet<_>>().len() != relay_urls.len() + || parsed_relay_urls.iter().any(Option::is_none) + || parsed_relay_urls.iter().flatten().any(|url| { + !url.username().is_empty() || url.password().is_some() || url.fragment().is_some() + }) + || relays.iter().any(|relay| { + let read = relay["read"].as_bool().expect("read"); + let write = relay["write"].as_bool().expect("write"); + (!read && !write) || (relay["required"].as_bool().expect("required") && !read && !write) + }) + || !relays + .iter() + .any(|relay| relay["read"].as_bool() == Some(true)) + || !relays + .iter() + .any(|relay| relay["write"].as_bool() == Some(true)) + || !relays.iter().any(|relay| { + relay["required"].as_bool() == Some(true) && relay["read"].as_bool() == Some(true) + }) + || !relays.iter().any(|relay| { + relay["required"].as_bool() == Some(true) && relay["write"].as_bool() == Some(true) + }) + { + return false; + } + if parsed_relay_urls.iter().flatten().any(|url| match profile { + Profile::Production => url.scheme() != "wss", + Profile::RepoLocal => match url.scheme() { + "wss" => false, + "ws" => !matches!( + url.host_str(), + Some("127.0.0.1" | "localhost" | "[::1]" | "::1") + ), + _ => true, + }, + }) { + return false; + } + + let trusted = string_array(value, "/policy/trusted_clients") + .into_iter() + .collect::<BTreeSet<_>>(); + let denied = string_array(value, "/policy/denied_clients") + .into_iter() + .collect::<BTreeSet<_>>(); + if !trusted.is_disjoint(&denied) { + return false; + } + + let mut expected_role_keys = vec![ + value["identity"]["transport"]["expected_public_key"] + .as_str() + .expect("transport key"), + value["identity"]["user"]["expected_public_key"] + .as_str() + .expect("user key"), + ]; + if value["identity"]["discovery"]["enabled"] == true { + expected_role_keys.push( + value["identity"]["discovery"]["binding"]["expected_public_key"] + .as_str() + .expect("discovery key"), + ); + } + if expected_role_keys.iter().collect::<BTreeSet<_>>().len() != expected_role_keys.len() { + return false; + } + + let permitted_kinds = string_array(value, "/policy/permission_ceiling") + .into_iter() + .filter_map(|permission| permission.strip_prefix("sign_event:kind:")) + .filter_map(|kind| kind.parse::<u64>().ok()) + .collect::<BTreeSet<_>>(); + let allowed_kinds = value["policy"]["allowed_sign_event_kinds"] + .as_array() + .expect("allowed kinds") + .iter() + .map(|kind| kind.as_u64().expect("event kind")) + .collect::<BTreeSet<_>>(); + if permitted_kinds != allowed_kinds { + return false; + } + + let challenges = &value["policy"]["challenges"]; + if challenges["enabled"] == true + && challenges["authorized_lifetime_ms"].as_u64() + < challenges["pending_lifetime_ms"].as_u64() + { + return false; + } + for (name, expected_scope) in [ + ("connection_admission", "global_and_relay"), + ("challenge_creation", "connection"), + ("challenge_authorization", "connection"), + ] { + let limit = &value["rate_limits"][name]; + if limit["scope"] != expected_scope + || limit["retention_ms"].as_u64() < limit["window_ms"].as_u64() + { + return false; + } + } + + let retry = &value["transport"]["publish_retry"]; + if !retry.is_null() + && retry["initial_backoff_ms"].as_u64() > retry["maximum_backoff_ms"].as_u64() + { + return false; + } + let required_writers = relays + .iter() + .filter(|relay| { + relay["required"].as_bool() == Some(true) && relay["write"].as_bool() == Some(true) + }) + .count() as u64; + let delivery = &value["transport"]["delivery_policy"]; + if delivery["mode"] == "required_quorum" + && delivery["required_acknowledgements"].as_u64() > Some(required_writers) + { + return false; + } + + let discovery_enabled = value["discovery"]["enabled"].as_bool(); + if discovery_enabled != value["identity"]["discovery"]["enabled"].as_bool() { + return false; + } + if discovery_enabled == Some(true) { + let by_id = relays + .iter() + .map(|relay| (relay["id"].as_str().unwrap(), relay)) + .collect::<BTreeMap<_, _>>(); + if string_array(value, "/discovery/public_relay_ids") + .iter() + .any(|id| by_id.get(id).and_then(|relay| relay["read"].as_bool()) != Some(true)) + || string_array(value, "/discovery/publish_relay_ids") + .iter() + .any(|id| by_id.get(id).and_then(|relay| relay["write"].as_bool()) != Some(true)) + { + return false; + } + } + + let operations = &value["operations"]; + if operations["enabled"] == true { + let Ok(listen) = operations["listen"] + .as_str() + .unwrap_or_default() + .parse::<SocketAddr>() + else { + return false; + }; + if listen.port() == 0 + || (operations["bind_policy"] == "loopback_only" && !listen.ip().is_loopback()) + { + return false; + } + } + true +} + +fn assert_rejected(value: &Value, profile: Profile) { + assert!( + !semantic_valid(value, profile), + "negative configuration vector unexpectedly passed" + ); +} + +fn insert(value: &mut Value, pointer: &str, key: &str, replacement: Value) { + value + .pointer_mut(pointer) + .and_then(Value::as_object_mut) + .expect("object pointer") + .insert(key.to_owned(), replacement); +} + +fn defaults(value: &Value, pointer: &str, output: &mut BTreeMap<String, Value>) { + match value { + Value::Object(object) => { + if let Some(default) = object.get("default") { + assert!( + object.contains_key("x-radroots-default-source"), + "default without provenance at {pointer}" + ); + output.insert(pointer.to_owned(), default.clone()); + } + for (key, child) in object { + defaults(child, &format!("{pointer}/{key}"), output); + } + } + Value::Array(values) => { + for (index, child) in values.iter().enumerate() { + defaults(child, &format!("{pointer}/{index}"), output); + } + } + _ => {} + } +} + +fn assert_integer_bounds(value: &Value, pointer: &str) { + match value { + Value::Object(object) => { + if object.get("type") == Some(&Value::String("integer".to_owned())) { + assert!( + object.contains_key("minimum"), + "missing minimum at {pointer}" + ); + assert!( + object.contains_key("maximum"), + "missing maximum at {pointer}" + ); + } + for (key, child) in object { + assert_integer_bounds(child, &format!("{pointer}/{key}")); + } + } + Value::Array(values) => { + for (index, child) in values.iter().enumerate() { + assert_integer_bounds(child, &format!("{pointer}/{index}")); + } + } + _ => {} + } +} + +#[test] +fn schema_identity_structure_and_machine_policy_are_exact() { + let schema = schema(); + assert_eq!( + schema["$schema"], + "https://json-schema.org/draft/2020-12/schema" + ); + assert_eq!(schema["type"], "object"); + assert_eq!(schema["additionalProperties"], false); + assert_eq!( + schema["required"], + json!([ + "schema", + "schema_version", + "service", + "logging", + "operations", + "database", + "identity", + "relays", + "transport", + "policy", + "rate_limits", + "resource_limits", + "discovery" + ]) + ); + assert_eq!( + schema["properties"]["schema"]["const"], + "radroots.myc.config" + ); + assert_eq!(schema["properties"]["schema_version"]["const"], 1); + assert_eq!( + schema["x-radroots-contract"]["bootstrap_only"], + json!(["profile", "instance", "repo_local_root", "config_path"]) + ); + assert_eq!( + schema["x-radroots-contract"]["document_max_utf8_bytes"], + 1_048_576 + ); + assert_eq!( + schema["x-radroots-contract"]["duplicate_keys"], + "reject_on_original_wire" + ); + assert_eq!( + schema["x-radroots-contract"]["null_values"], + "reject_on_original_wire" + ); + assert_eq!( + schema["x-radroots-contract"]["environment_overlay"], + "forbidden" + ); + assert_eq!( + schema["x-radroots-contract"]["protected_material"], + "forbidden" + ); + assert_eq!( + schema["x-radroots-contract"]["effective_output"], + "deterministic_redacted_with_exact_provenance" + ); + assert_integer_bounds(&schema, ""); + + let mut found_defaults = BTreeMap::new(); + defaults(&schema, "", &mut found_defaults); + assert_eq!(found_defaults.len(), 39); + for source in found_defaults.keys().map(|pointer| { + schema.pointer(pointer).unwrap()["x-radroots-default-source"] + .as_str() + .unwrap() + }) { + assert!( + schema["x-radroots-contract"]["safe_default_sources"] + .as_array() + .unwrap() + .iter() + .any(|allowed| allowed == source) + ); + } +} + +#[test] +fn lib_derived_limits_and_defaults_are_literal_frozen() { + let schema = schema(); + let exact = [ + ("/$defs/operations_limits/properties/header_count", 64, 32), + ( + "/$defs/operations_limits/properties/header_bytes", + 32_768, + 16_384, + ), + ( + "/$defs/operations_limits/properties/response_body_utf8_bytes", + 1_048_576, + 1_048_576, + ), + ( + "/$defs/operations_limits/properties/concurrent_connections", + 64, + 32, + ), + ( + "/$defs/operations_limits/properties/request_deadline_ms", + 30_000, + 15_000, + ), + ( + "/$defs/operations_limits/properties/idle_timeout_ms", + 60_000, + 30_000, + ), + ("/$defs/database/properties/busy_timeout_ms", 60_000, 5_000), + ("/$defs/database/properties/max_connections", 8, 8), + ("/$defs/admin_limits/properties/header_count", 64, 32), + ( + "/$defs/admin_limits/properties/header_bytes", + 32_768, + 16_384, + ), + ( + "/$defs/admin_limits/properties/request_body_utf8_bytes", + 65_536, + 65_536, + ), + ( + "/$defs/admin_limits/properties/response_body_utf8_bytes", + 1_048_576, + 1_048_576, + ), + ( + "/$defs/admin_limits/properties/concurrent_connections", + 64, + 32, + ), + ( + "/$defs/admin_limits/properties/request_deadline_ms", + 30_000, + 15_000, + ), + ( + "/$defs/admin_limits/properties/idle_timeout_ms", + 60_000, + 30_000, + ), + ("/$defs/admin_limits/properties/query_items", 200, 100), + ("/$defs/metrics_limits/properties/descriptors", 64, 64), + ("/$defs/metrics_limits/properties/samples", 512, 512), + ("/$defs/metrics_limits/properties/labels_per_sample", 8, 8), + ( + "/$defs/metrics_limits/properties/render_utf8_bytes", + 1_048_576, + 1_048_576, + ), + ( + "/$defs/event_limits/properties/wire_bytes", + 524_288, + 262_144, + ), + ( + "/$defs/event_limits/properties/content_bytes", + 131_072, + 131_072, + ), + ("/$defs/event_limits/properties/tag_count", 1_024, 1_024), + ( + "/$defs/event_limits/properties/tag_total_elements", + 4_096, + 4_096, + ), + ( + "/$defs/event_limits/properties/tag_element_bytes", + 4_096, + 4_096, + ), + ( + "/$defs/event_limits/properties/tag_total_bytes", + 131_072, + 131_072, + ), + ( + "/$defs/event_limits/properties/decrypted_plaintext_bytes", + 262_144, + 262_144, + ), + ]; + for (pointer, maximum, default) in exact { + assert_eq!( + schema.pointer(pointer).unwrap()["maximum"], + maximum, + "{pointer}" + ); + assert_eq!( + schema.pointer(pointer).unwrap()["default"], + default, + "{pointer}" + ); + } +} + +#[test] +fn canonical_example_and_required_positive_variants_pass() { + let value = example(); + assert!(semantic_valid(&value, Profile::Production)); + assert_eq!(value["identity"]["transport"]["provider"], "encrypted_file"); + assert_eq!(value["identity"]["user"]["provider"], "local_signer"); + assert_eq!( + value["identity"]["discovery"]["binding"]["provider"], + "encrypted_file" + ); + + let mut disabled_discovery = value.clone(); + disabled_discovery["identity"]["discovery"] = json!({"enabled": false}); + disabled_discovery["discovery"] = json!({"enabled": false}); + assert!(semantic_valid(&disabled_discovery, Profile::Production)); + + let mut repo_local = disabled_discovery.clone(); + repo_local["relays"][0]["url"] = json!("ws://127.0.0.1:8080/"); + repo_local["relays"][1]["url"] = json!("ws://localhost:8081/"); + assert!(semantic_valid(&repo_local, Profile::RepoLocal)); + assert_rejected(&repo_local, Profile::Production); + + let mut enabled_operations = value; + enabled_operations["operations"] = json!({ + "enabled": true, + "listen": "127.0.0.1:9460", + "bind_policy": "loopback_only", + "limits": {} + }); + assert!(semantic_valid(&enabled_operations, Profile::Production)); +} + +#[test] +fn structural_unknown_legacy_and_original_wire_vectors_fail() { + let value = example(); + for (pointer, key) in [ + ("", "unknown"), + ("/service", "shutdowm_grace_ms"), + ("/identity/transport", "executable"), + ("/resource_limits/admin", "body_bytes"), + ("/policy/challenges", "redirect_from_client"), + ] { + let mut invalid = value.clone(); + insert(&mut invalid, pointer, key, json!(true)); + assert_rejected(&invalid, Profile::Production); + } + for field in schema()["x-radroots-contract"]["forbidden_prototype_fields"] + .as_array() + .unwrap() + { + let mut invalid = value.clone(); + invalid.as_object_mut().unwrap().insert( + field.as_str().unwrap().to_owned(), + Value::Object(Map::new()), + ); + assert_rejected(&invalid, Profile::Production); + } + + assert!(toml::from_str::<toml::Value>("schema='a'\nschema='b'\n").is_err()); + assert!(toml::from_str::<toml::Value>("schema = null\n").is_err()); +} + +#[test] +fn identity_relay_and_network_authority_fail_closed() { + let value = example(); + for pointer in [ + "/identity/transport/envelope_path", + "/identity/user/socket_path", + ] { + let mut invalid = value.clone(); + *invalid.pointer_mut(pointer).unwrap() = json!("relative/path"); + assert_rejected(&invalid, Profile::Production); + } + for provider in [ + "plaintext_file", + "external_command", + "host_vault", + "keyring", + "managed_account", + ] { + let mut invalid = value.clone(); + invalid["identity"]["transport"]["provider"] = json!(provider); + assert_rejected(&invalid, Profile::Production); + } + + let mut duplicate_id = value.clone(); + duplicate_id["relays"][1]["id"] = duplicate_id["relays"][0]["id"].clone(); + assert_rejected(&duplicate_id, Profile::Production); + let mut duplicate_url = value.clone(); + duplicate_url["relays"][1]["url"] = duplicate_url["relays"][0]["url"].clone(); + assert_rejected(&duplicate_url, Profile::Production); + let mut inactive = value.clone(); + inactive["relays"][0]["read"] = json!(false); + inactive["relays"][0]["write"] = json!(false); + assert_rejected(&inactive, Profile::Production); + let mut insecure = value; + insecure["relays"][0]["url"] = json!("ws://127.0.0.1:8080/"); + assert_rejected(&insecure, Profile::Production); + + let mut collapsed_roles = example(); + collapsed_roles["identity"]["user"]["expected_public_key"] = + collapsed_roles["identity"]["transport"]["expected_public_key"].clone(); + assert_rejected(&collapsed_roles, Profile::Production); + + let mut noncanonical_url = example(); + noncanonical_url["relays"][0]["url"] = json!("WSS://relay-primary.example.test"); + assert_rejected(&noncanonical_url, Profile::Production); + let mut credential_name = example(); + credential_name["identity"]["transport"]["credential_reference"] = + json!("transport.identity-key"); + assert!(semantic_valid(&credential_name, Profile::Production)); + credential_name["identity"]["transport"]["credential_reference"] = + json!(format!("a{}z", "x".repeat(126))); + assert!(semantic_valid(&credential_name, Profile::Production)); + credential_name["identity"]["transport"]["credential_reference"] = + json!(format!("a{}z", "x".repeat(127))); + assert_rejected(&credential_name, Profile::Production); +} + +#[test] +fn bounds_relationships_and_conditional_authority_fail_closed() { + let value = example(); + for (pointer, exact, over) in [ + ("/database/busy_timeout_ms", 60_000_u64, 60_001_u64), + ("/database/max_connections", 8, 9), + ("/resource_limits/admin/header_count", 64, 65), + ("/resource_limits/admin/query_items", 200, 201), + ("/resource_limits/events/wire_bytes", 524_288, 524_289), + ("/resource_limits/metrics/samples", 512, 513), + ("/identity/user/request_deadline_ms", 30_000, 30_001), + ("/identity/user/concurrency", 64, 65), + ] { + let mut minimum = value.clone(); + *minimum.pointer_mut(pointer).unwrap() = json!(1); + assert!( + semantic_valid(&minimum, Profile::Production), + "minimum {pointer}" + ); + let mut maximum = value.clone(); + *maximum.pointer_mut(pointer).unwrap() = json!(exact); + assert!( + semantic_valid(&maximum, Profile::Production), + "maximum {pointer}" + ); + let mut zero = value.clone(); + *zero.pointer_mut(pointer).unwrap() = json!(0); + assert_rejected(&zero, Profile::Production); + let mut excessive = value.clone(); + *excessive.pointer_mut(pointer).unwrap() = json!(over); + assert_rejected(&excessive, Profile::Production); + } + + let mut overlap = value.clone(); + overlap["policy"]["denied_clients"] = overlap["policy"]["trusted_clients"].clone(); + assert_rejected(&overlap, Profile::Production); + let mut permission_mismatch = value.clone(); + permission_mismatch["policy"]["allowed_sign_event_kinds"] = json!([1, 7]); + assert_rejected(&permission_mismatch, Profile::Production); + let mut challenge_lifetime = value.clone(); + challenge_lifetime["policy"]["challenges"]["authorized_lifetime_ms"] = json!(1000); + assert_rejected(&challenge_lifetime, Profile::Production); + let mut challenge_missing = value.clone(); + challenge_missing["policy"]["challenges"] + .as_object_mut() + .unwrap() + .remove("url"); + assert_rejected(&challenge_missing, Profile::Production); + let mut challenge_disabled_leak = value.clone(); + challenge_disabled_leak["policy"]["challenges"]["enabled"] = json!(false); + assert_rejected(&challenge_disabled_leak, Profile::Production); + let mut rate_retention = value.clone(); + rate_retention["rate_limits"]["challenge_creation"]["retention_ms"] = json!(1); + assert_rejected(&rate_retention, Profile::Production); + let mut backoff = value.clone(); + backoff["transport"]["publish_retry"]["initial_backoff_ms"] = json!(30_000); + backoff["transport"]["publish_retry"]["maximum_backoff_ms"] = json!(1); + assert_rejected(&backoff, Profile::Production); + let mut quorum = value.clone(); + quorum["transport"]["delivery_policy"] = + json!({"mode": "required_quorum", "required_acknowledgements": 3}); + assert_rejected(&quorum, Profile::Production); + let mut discovery_mismatch = value.clone(); + discovery_mismatch["identity"]["discovery"] = json!({"enabled": false}); + assert_rejected(&discovery_mismatch, Profile::Production); + let mut missing_relay = value.clone(); + missing_relay["discovery"]["publish_relay_ids"] = json!(["missing"]); + assert_rejected(&missing_relay, Profile::Production); + let mut invalid_template = value; + invalid_template["discovery"]["nostrconnect_url_template"] = + json!("https://myc.example.test/connect"); + assert_rejected(&invalid_template, Profile::Production); +} + +#[test] +fn schema_and_version_are_closed_and_defaults_are_only_safe_leaves() { + let value = example(); + for replacement in [json!("radroots.myc.config.v2"), json!("myc")] { + let mut invalid = value.clone(); + invalid["schema"] = replacement; + assert_rejected(&invalid, Profile::Production); + } + for replacement in [json!(0), json!(2), json!("1")] { + let mut invalid = value.clone(); + invalid["schema_version"] = replacement; + assert_rejected(&invalid, Profile::Production); + } + + let mut safe_defaults_omitted = value; + safe_defaults_omitted["service"] = json!({}); + safe_defaults_omitted["logging"] = json!({}); + safe_defaults_omitted["database"] + .as_object_mut() + .unwrap() + .remove("busy_timeout_ms"); + safe_defaults_omitted["database"] + .as_object_mut() + .unwrap() + .remove("max_connections"); + safe_defaults_omitted["transport"] + .as_object_mut() + .unwrap() + .remove("connect_deadline_ms"); + safe_defaults_omitted["transport"] + .as_object_mut() + .unwrap() + .remove("publish_retry"); + safe_defaults_omitted["resource_limits"] = json!({}); + assert!(semantic_valid(&safe_defaults_omitted, Profile::Production)); + + for required in [ + "minimum_free_bytes", + "transport", + "user", + "relays", + "connection_approval", + "trusted_clients", + "denied_clients", + "permission_ceiling", + "allowed_sign_event_kinds", + "challenges", + "rate_limits", + "delivery_policy", + "discovery", + ] { + assert!( + CONFIG_SCHEMA.contains(required), + "missing authority {required}" + ); + } +} + +#[test] +fn canonical_example_contains_no_protected_material_or_legacy_selector() { + let lowercase = CONFIG_EXAMPLE.to_ascii_lowercase(); + for forbidden in [ + "private_key", + "secret_key", + "mnemonic", + "nsec1", + "wrapping_key =", + "myc_", + "env_file", + "external_command", + "plaintext_file", + "keyring", + "managed_account", + ] { + assert!( + !lowercase.contains(forbidden), + "canonical example contains forbidden material or selector {forbidden}" + ); + } +}