diff --git a/.github/workflows/haskell-build.yml b/.github/workflows/haskell-build.yml index 4de54ff20..19d57cd7f 100644 --- a/.github/workflows/haskell-build.yml +++ b/.github/workflows/haskell-build.yml @@ -124,6 +124,9 @@ jobs: uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 - name: Install NASM (aws-lc-sys assembly) uses: ilammy/setup-nasm@72793074d3c8cdda771dba85f6deafe00623038b # v1.5.2 + # Build the MSVC CLI before entering the MSYS2 build environment. + - name: Build CLI for the test-suite + run: cargo build -p secretspec # MINGW64 (msvcrt-based) matches the CRT the x86_64-pc-windows-gnu Rust # target links, so every object in the archive agrees on one C runtime. - name: Install MSYS2 MinGW toolchain (compiles the staticlib's C deps) @@ -146,9 +149,8 @@ jobs: # members that objcopy below refuses to process. cargo rustc -p libsecretspec --release --target x86_64-pc-windows-gnu \ --crate-type staticlib - # The CLI (for the test-suite's end-to-end codegen test) builds for - # the default MSVC host target, same as the released binaries. - cargo build -p secretspec + # The CLI for the test-suite's end-to-end codegen test was built for + # the default MSVC host before entering MSYS2. export SECRETSPEC_BIN="$(cygpath -w "$PWD/target/debug/secretspec.exe")" native_libs="$(cargo rustc -q -p libsecretspec --release \ --target x86_64-pc-windows-gnu --crate-type staticlib -- \ diff --git a/CHANGELOG.md b/CHANGELOG.md index bf5d12e8b..205ec4787 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Generate passphrases, BIP-39 mnemonics, WireGuard private keys, signing JWKs, + age identities, and self-signed P-256 X.509 identities. Derive keys, + certificates, chains, and password-protected PKCS#12 archives from a declared + identity with `from`, `format`, and `credentials`, or select JSON/INI fields + from another declared secret with `from` and `extract`. Dependencies resolve + in order and scopes include hidden inputs without exposing their values. + - Provider reads now retry recognized transient failures with bounded exponential backoff, including CLI providers such as BWS. Set `[defaults.retry].max_attempts` in user configuration or use `Secrets::with_retry_policy` in Rust to control @@ -20,6 +27,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Imported X.509 identities accept self-issued certificates signed by a distinct + issuer with the same name and preserve supplementary Unicode in certificate + subjects for both leaf and issuer certificates when exporting PKCS#12, + including in builds using published dependencies. + Scoped explanations redact hidden derivation + source names. + +- X.509 identity generation, validation, and PKCS#12 conversion no longer + build or require OpenSSL. PKCS#12 passwords currently support the Unicode Basic + Multilingual Plane; unsupported characters produce a clear credential error. + +- Stored X.509 identities and their derived secrets retain credential expiry + and refresh deadlines so SDK consumers refresh them when their inputs change. + - Secret declarations now warn about ignored unknown fields, with a suggestion to use `composed` when `compose` is misspelled and a note that the field may be supported in a newer version. Unknown fields remain accepted for diff --git a/Cargo.lock b/Cargo.lock index e73e1e252..6700ab2c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,6 +28,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + [[package]] name = "aes" version = "0.8.4" @@ -56,14 +66,28 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ - "aead", + "aead 0.5.2", "aes 0.8.4", "cipher 0.4.4", - "ctr", - "ghash", + "ctr 0.9.2", + "ghash 0.5.1", "subtle", ] +[[package]] +name = "aes-gcm" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" +dependencies = [ + "aead 0.6.1", + "aes 0.9.1", + "cipher 0.5.2", + "ctr 0.10.1", + "ctutils", + "ghash 0.6.0", +] + [[package]] name = "aes-kw" version = "0.2.1" @@ -80,16 +104,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd290633c2482479f70f6d1d96ae0e9f52c6a26cd5859edd47ee1fe33fc89f26" dependencies = [ "aes 0.8.4", - "aes-gcm", + "aes-gcm 0.10.3", "age-core", - "base64", + "base64 0.22.1", "bcrypt-pbkdf", "bech32", "cbc 0.1.2", "chacha20poly1305", "cipher 0.4.4", "cookie-factory", - "ctr", + "ctr 0.9.2", "curve25519-dalek", "hkdf", "hmac 0.12.1", @@ -105,7 +129,7 @@ dependencies = [ "rand 0.8.8", "rsa", "rust-embed", - "scrypt", + "scrypt 0.11.0", "sha2 0.10.9", "sha3", "subtle", @@ -121,7 +145,7 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01d4375964d1501e5f1b32aef2ead573913893ff238448d4e9fdf1522d828656" dependencies = [ - "base64", + "base64 0.22.1", "bech32", "chacha20poly1305", "cookie-factory", @@ -291,6 +315,45 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -496,24 +559,26 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.16.2" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", + "untrusted 0.7.1", "zeroize", ] [[package]] name = "aws-lc-sys" -version = "0.39.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -995,6 +1060,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64-simd" version = "0.8.0" @@ -1037,13 +1108,23 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" +[[package]] +name = "bip39" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" +dependencies = [ + "bitcoin_hashes", + "zeroize", +] + [[package]] name = "bit-set" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", ] [[package]] @@ -1052,6 +1133,24 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" +dependencies = [ + "hex-conservative", +] + [[package]] name = "bitfields" version = "1.0.3" @@ -1361,9 +1460,9 @@ dependencies = [ [[package]] name = "chacha20" -version = "0.10.0" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cipher 0.5.2", @@ -1377,7 +1476,7 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ - "aead", + "aead 0.5.2", "chacha20 0.9.1", "cipher 0.4.4", "poly1305", @@ -1519,6 +1618,18 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de0758edba32d61d1fd9f4d69491b47604b91ee2f7e6b33de7e54ca4ebe55dc3" +[[package]] +name = "cms" +version = "0.3.0-pre.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758d5272932ff167e96ec9641a04d96ab1901cccc5ea9f47c15b4cbaa6f9ea53" +dependencies = [ + "const-oid 0.10.2", + "der 0.8.1", + "spki 0.8.0", + "x509-cert", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -1796,6 +1907,15 @@ dependencies = [ "cipher 0.4.4", ] +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher 0.5.2", +] + [[package]] name = "ctutils" version = "0.4.0" @@ -1840,7 +1960,7 @@ checksum = "b4c0cf476284b03eb6c10e78787b21c7abb7d7d43cb2f02532ba6b831ed892fa" dependencies = [ "crypto-bigint", "elliptic-curve", - "pkcs8", + "pkcs8 0.10.2", "rand_core 0.6.4", "serdect 0.3.0", "sha3", @@ -1956,10 +2076,38 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" dependencies = [ + "const-oid 0.10.2", + "der_derive", + "flagset", "pem-rfc7468 1.0.0", "zeroize", ] +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "der_derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59600e2c2d636fde9b65e99cc6445ac770c63d3628195ff39932b8d6d7409903" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "deranged" version = "0.5.8" @@ -2033,6 +2181,15 @@ dependencies = [ "cipher 0.4.4", ] +[[package]] +name = "des" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a94e407b54f9034d71dd748234cd1e516ced6284009906ae246f177eafe5a" +dependencies = [ + "cipher 0.5.2", +] + [[package]] name = "detect-coding-agent" version = "0.1.0" @@ -2108,7 +2265,7 @@ dependencies = [ "digest 0.10.7", "num-bigint-dig", "num-traits", - "pkcs8", + "pkcs8 0.10.2", "rfc6979", "sha2 0.10.9", "signature", @@ -2133,10 +2290,10 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9954fabd903b82b9d7a68f65f97dc96dd9ad368e40ccc907a7c19d53e6bfac28" dependencies = [ - "aead", + "aead 0.5.2", "cipher 0.4.4", "cmac", - "ctr", + "ctr 0.9.2", "subtle", ] @@ -2151,7 +2308,7 @@ dependencies = [ "elliptic-curve", "rfc6979", "signature", - "spki", + "spki 0.7.3", ] [[package]] @@ -2160,7 +2317,7 @@ version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "pkcs8", + "pkcs8 0.10.2", "signature", ] @@ -2200,7 +2357,7 @@ dependencies = [ "group", "hkdf", "pem-rfc7468 0.7.0", - "pkcs8", + "pkcs8 0.10.2", "rand_core 0.6.4", "sec1", "serde_json", @@ -2440,6 +2597,12 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "flate2" version = "1.1.9" @@ -2733,7 +2896,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" dependencies = [ "opaque-debug", - "polyval", + "polyval 0.6.2", +] + +[[package]] +name = "ghash" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval 0.7.3", ] [[package]] @@ -2755,7 +2927,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f83bc5c208df4a6b38ad2a8d2b01c0d377811f9efe9b0733171f28dd74db9c3" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "bytes", "chrono", "google-cloud-gax", @@ -2781,7 +2953,7 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "188909653b7c484e43695325c0324804b5645d568f8d2e4c8a6f520231d50956" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures", "google-cloud-rpc", @@ -2913,7 +3085,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0ade65b0e4fa9cb4b6f147c8e726803bff453e3190910a53cbd3b0c019f5c2a" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "serde", "serde_json", @@ -3038,6 +3210,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + [[package]] name = "hex-literal" version = "1.1.0" @@ -3077,8 +3258,8 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4917627a14198c3603282c5158b815ad5534795451d3c074b53cf3cee0960b11" dependencies = [ - "aead", - "aes-gcm", + "aead 0.5.2", + "aes-gcm 0.10.3", "chacha20poly1305", "digest 0.10.7", "generic-array", @@ -3274,7 +3455,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -3879,7 +4060,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c6922f6afe80418dd6019818af5d0d34584c371780ff09b9752370c25b4abb" dependencies = [ - "base64", + "base64 0.22.1", "jiff", "serde", "serde_json", @@ -3901,11 +4082,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2765eb6c9966956f5b421f44657140856accd7a769151dd47811d81ff2d8e8df" dependencies = [ "aes 0.9.1", - "base64", + "base64 0.22.1", "block-modes", "byteorder", "cbc 0.2.1", - "chacha20 0.10.0", + "chacha20 0.10.2", "chrono", "cipher 0.5.2", "flate2", @@ -3935,8 +4116,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adf807a8d4a1ea057446b4ac9535df3ec0ffb6b1c58c7884f231e0fe0eaea092" dependencies = [ "aes 0.8.4", - "aes-gcm", - "base64", + "aes-gcm 0.10.3", + "base64 0.22.1", "block-padding 0.3.3", "chrono", "cipher 0.4.4", @@ -4010,7 +4191,7 @@ version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31e940a73033a7c5c7918b5ece7851d84571b58be25e937198da37fa13f116d3" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "either", "futures", @@ -4025,7 +4206,7 @@ dependencies = [ "jsonpath-rust", "k8s-openapi", "kube-core", - "pem", + "pem 3.0.6", "rustls 0.23.37", "rustls-platform-verifier", "secrecy", @@ -4553,12 +4734,21 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c196e0276c471c843dd5777e7543a36a298a4be942a2a688d8111cd43390dedb" dependencies = [ - "aead", + "aead 0.5.2", "cipher 0.4.4", - "ctr", + "ctr 0.9.2", "subtle", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -4677,6 +4867,29 @@ version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" +[[package]] +name = "p12-keystore" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2ed4fa0ec7ec58b657d4184680c57da9e203e97771d29a566028869a5193e0" +dependencies = [ + "cbc 0.2.1", + "cms", + "der 0.8.1", + "des 0.9.0", + "hex", + "hmac 0.13.0", + "pkcs12", + "pkcs5", + "pkcs8 0.11.0", + "rand 0.10.2", + "rc2", + "sha1 0.11.0", + "sha2 0.11.0", + "thiserror 2.0.18", + "x509-parser", +] + [[package]] name = "p256" version = "0.13.2" @@ -4781,7 +4994,17 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120" +dependencies = [ + "base64 0.23.1", "serde_core", ] @@ -4857,12 +5080,12 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1cfa4743b28656065ff4c0ba09e46b357a65e8c00fc2341e89084b82f87cbdf1" dependencies = [ - "aead", + "aead 0.5.2", "aes 0.8.4", - "aes-gcm", + "aes-gcm 0.10.3", "aes-kw", "argon2", - "base64", + "base64 0.22.1", "bitfields", "block-padding 0.3.3", "blowfish", @@ -4879,7 +5102,7 @@ dependencies = [ "cx448", "derive_builder", "derive_more", - "des", + "des 0.8.1", "digest 0.10.7", "dsa", "eax", @@ -4970,8 +5193,39 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" dependencies = [ "der 0.7.10", - "pkcs8", - "spki", + "pkcs8 0.10.2", + "spki 0.7.3", +] + +[[package]] +name = "pkcs12" +version = "0.2.0-pre.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8059bd79345d1d1c125656025814704bab5cdd204b1cefd5fab59b0fdd7476d2" +dependencies = [ + "cms", + "const-oid 0.10.2", + "der 0.8.1", + "digest 0.11.2", + "spki 0.8.0", + "x509-cert", + "zeroize", +] + +[[package]] +name = "pkcs5" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63d440a804ec8d6fafbb6b84471e013286658d373248927692ab3366686220ca" +dependencies = [ + "aes 0.9.1", + "aes-gcm 0.11.1", + "cbc 0.2.1", + "der 0.8.1", + "pbkdf2 0.13.0", + "scrypt 0.12.0", + "sha2 0.11.0", + "spki 0.8.0", ] [[package]] @@ -4981,7 +5235,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ "der 0.7.10", - "spki", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der 0.8.1", + "spki 0.8.0", ] [[package]] @@ -5012,7 +5276,7 @@ checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ "cpufeatures 0.2.17", "opaque-debug", - "universal-hash", + "universal-hash 0.5.1", ] [[package]] @@ -5024,7 +5288,18 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "opaque-debug", - "universal-hash", + "universal-hash 0.5.1", +] + +[[package]] +name = "polyval" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" +dependencies = [ + "cpubits", + "cpufeatures 0.3.0", + "universal-hash 0.6.1", ] [[package]] @@ -5138,7 +5413,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set", - "bit-vec", + "bit-vec 0.8.0", "bitflags", "num-traits", "rand 0.9.2", @@ -5344,7 +5619,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20 0.10.0", + "chacha20 0.10.2", "getrandom 0.4.2", "rand_core 0.10.0", ] @@ -5402,6 +5677,29 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rc2" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ceda21af1ae61033b63175653a1af86cae399d79cd03ca80ba347eb3a6c4a7fe" +dependencies = [ + "cipher 0.5.2", +] + +[[package]] +name = "rcgen" +version = "0.14.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8" +dependencies = [ + "aws-lc-rs", + "pem 4.0.0", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -5495,7 +5793,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "http 1.4.0", @@ -5533,7 +5831,7 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-channel", @@ -5593,7 +5891,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -5618,11 +5916,11 @@ dependencies = [ "num-integer", "num-traits", "pkcs1", - "pkcs8", + "pkcs8 0.10.2", "rand_core 0.6.4", "sha2 0.10.9", "signature", - "spki", + "spki 0.7.3", "subtle", "zeroize", ] @@ -5633,7 +5931,7 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ae76b7506744d254fd0eb2c0ff5c5d108201ccbb083111ac04a44eeda105680" dependencies = [ - "base64", + "base64 0.22.1", "blake2b_simd", "constant_time_eq", "crossbeam-utils", @@ -5705,6 +6003,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "rustix" version = "1.1.4" @@ -5802,7 +6109,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" dependencies = [ "ring", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -5814,7 +6121,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -5932,6 +6239,18 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "scrypt" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87af57419b594aa23fa95f09f0e06d80d84ba01c26148c43844cad6ff4485f0" +dependencies = [ + "cfg-if", + "pbkdf2 0.13.0", + "salsa20 0.11.0", + "sha2 0.11.0", +] + [[package]] name = "sct" version = "0.7.1" @@ -5939,7 +6258,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" dependencies = [ "ring", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -5951,7 +6270,7 @@ dependencies = [ "base16ct", "der 0.7.10", "generic-array", - "pkcs8", + "pkcs8 0.10.2", "serdect 0.2.0", "subtle", "zeroize", @@ -5993,12 +6312,15 @@ dependencies = [ "age", "async-trait", "aws-config", + "aws-lc-rs", "aws-sdk-secretsmanager", "aws-sdk-ssm", "azure_core", "azure_identity", "azure_security_keyvault_secrets", - "base64", + "base64 0.22.1", + "bech32", + "bip39", "clap", "clap_complete", "clap_complete_nushell", @@ -6007,6 +6329,7 @@ dependencies = [ "detect-coding-agent", "dotenv-ng", "dunce", + "ed25519-dalek", "etcetera", "google-cloud-gax", "google-cloud-secretmanager-v1", @@ -6024,11 +6347,15 @@ dependencies = [ "libc", "linkme", "miette", + "p12-keystore", + "p256", + "pem 3.0.6", "percent-encoding", "pgp", "proptest", "rand 0.10.2", "rand 0.8.8", + "rcgen", "reqwest 0.12.28", "rsa", "rust-ini", @@ -6045,6 +6372,7 @@ dependencies = [ "ssh-key", "tempfile", "thiserror 2.0.18", + "time", "tokio", "toml 1.1.4+spec-1.1.0", "toml_edit", @@ -6053,6 +6381,8 @@ dependencies = [ "wait-timeout", "whoami", "windows-sys 0.61.2", + "x25519-dalek", + "x509-parser", ] [[package]] @@ -6187,7 +6517,7 @@ checksum = "7bd22781911de0ca6debda95f073c8f18bec65d1a94f1fa9573f3102e514cea4" dependencies = [ "ahash", "annotate-snippets", - "base64", + "base64 0.22.1", "encoding_rs_io", "getrandom 0.3.4", "granit-parser", @@ -6291,7 +6621,7 @@ version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" dependencies = [ - "base64", + "base64 0.22.1", "chrono", "hex", "indexmap 1.9.3", @@ -6555,6 +6885,16 @@ dependencies = [ "der 0.7.10", ] +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der 0.8.1", +] + [[package]] name = "ssh-cipher" version = "0.2.0" @@ -7048,7 +7388,7 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "http 1.4.0", "http-body 1.0.1", @@ -7095,7 +7435,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "async-compression", - "base64", + "base64 0.22.1", "bitflags", "bytes", "futures-core", @@ -7224,7 +7564,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "753a2fe021e407d4fc9ee6f4f0a33403cc306d5c54c4e4ebe1b8cbde0ca052b9" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures", "serde", @@ -7239,7 +7579,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0373af0f9d4f580b3a1a9d9639cedaabe015ed262b35bfbe13941bfb14fe1ea6" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "bytes", "dyn-clone", "futures", @@ -7369,6 +7709,22 @@ dependencies = [ "subtle", ] +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -7381,7 +7737,7 @@ version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" dependencies = [ - "base64", + "base64 0.22.1", "der 0.8.1", "flate2", "log", @@ -7399,7 +7755,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" dependencies = [ - "base64", + "base64 0.22.1", "http 1.4.0", "httparse", "log", @@ -8117,12 +8473,51 @@ dependencies = [ "zeroize", ] +[[package]] +name = "x509-cert" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "105ef4642d9cb137ef83d623d0e4bf08b8adf69e9918ca904a174adb6d3d038b" +dependencies = [ + "const-oid 0.10.2", + "der 0.8.1", + "spki 0.8.0", +] + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "aws-lc-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + [[package]] name = "xmlparser" version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec 0.9.1", + "time", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 13ab2dc4b..977abdab8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,7 +79,12 @@ pgp = { version = "0.20", default-features = false } smallvec = "1" uuid = { version = "1", features = ["serde", "v4"] } data-encoding = "2" -sha2 = "0.10" +bip39 = { version = "2.2.2", default-features = false, features = ["zeroize"] } +bech32 = "0.11" +ed25519-dalek = { version = "2.2", features = ["rand_core"] } +p256 = { version = "0.13", default-features = false, features = ["arithmetic"] } +x25519-dalek = { version = "2", features = ["static_secrets"] } +sha2 = { version = "0.10", features = ["oid"] } detect-coding-agent = "0.1" age = { version = "0.12", features = ["armor", "plugin", "ssh"] } ssh-key = { version = "0.6.7", features = ["crypto"] } @@ -90,6 +95,13 @@ async-trait = "0.1" tokio-util = { version = "0.7", features = ["rt"] } zeroize = { version = "1.8", features = ["derive"] } secretspec-ipc = { version = "0.21.1", path = "./secretspec-ipc" } +aws-lc-rs = { version = "1.18", default-features = false, features = ["aws-lc-sys"] } +rcgen = { version = "0.14.10", default-features = false, features = ["crypto", "pem", "aws_lc_rs"] } +x509-parser = { version = "0.18", features = ["verify-aws"] } +pem = "3" +time = "0.3" +# 0.4.1 preserves Unicode certificate subjects when rewrapping archives. +p12-keystore = "0.4.1" # The profile that 'dist' will build with [profile.dist] diff --git a/RELEASE.md b/RELEASE.md index e1399cdd2..54d507b25 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -33,6 +33,30 @@ Before tagging 0.20: The detailed compatibility matrix is maintained in [`libsecretspec/README.md`](libsecretspec/README.md). +## SecretSpec 0.22 X.509 dependency release + +The X.509 backend uses `p12-keystore` 0.4.1 from crates.io, which includes the +[Unicode certificate-label fix](https://github.com/ancwrd1/p12-keystore/pull/14) +and the bag-preserving archive API. The minimum dependency version is 0.4.1, +so packaged crates retain both fixes without a Git override. +Before publishing 0.22: + +1. Align the workspace `secretspec-ipc` dependency with the release version + and publish that crate before `secretspec`, as `publish.yml` already does. + Current `main` uses `secretspec_ipc::MAX_JSON_INTEGER`, which the published + 0.21.1 IPC crate does not provide. Package verification against that older + registry crate fails even though the archive API is available. +2. Run the Rust tests, SDK CI, Unicode-subject interoperability checks, and + `cargo package -p secretspec` with the published dependencies. Verify the + packaged manifest resolves the fixed registry version without a Git override. +3. Keep the documented BMP password restriction until + [RustCrypto/formats#2477](https://github.com/RustCrypto/formats/pull/2477) + ships and the `pkcs12` dependency resolves to that release. Then add + supplementary-Unicode interoperability coverage before lifting the check. + +The OpenSSL-generated test archives live in `secretspec/tests/fixtures/x509`. +They allow the Rust test suite to check imports without compiling OpenSSL. + ## After every release Once the release artifacts are available, update the `secretspec` package in diff --git a/docs/public/schema/secretspec.schema.json b/docs/public/schema/secretspec.schema.json index 448001c82..527642a99 100644 --- a/docs/public/schema/secretspec.schema.json +++ b/docs/public/schema/secretspec.schema.json @@ -4,6 +4,10 @@ "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, "definitions": { + "CredentialBinding": { + "description": "How a typed secret obtains one credential it needs (SecretSpec 0.22+).\n\nWritten in a secret's `credentials` table keyed by the role its type\ndefines, with the bare name of another declared secret as the value:\n\n```toml\nTLS_PFX = { type = \"pkcs12\", from = \"TLS_IDENTITY\", credentials = { password = \"TLS_PFX_PASSWORD\" } }\n```\n\nThe named secret is an ordinary declaration: it resolves through its own\nproviders, default, prompt, or generator, joins the dependency graph, and is\nstored with `secretspec set` like any other value. Table values are rejected\nand reserved for a future provider-pinned source, so adding one later is\nadditive.", + "type": "string" + }, "CredentialSource": { "anyOf": [ { @@ -70,17 +74,19 @@ "description": "Type-specific options for secret generation.", "properties": { "algorithm": { - "description": "OpenPGP or SSH key algorithm (`ed25519` or `rsa`, default `ed25519`; 0.21+).", + "description": "OpenPGP or SSH algorithm (0.21+), or JWK or mnemonic algorithm (0.22+).", "enum": [ "ed25519", - "rsa" + "rsa", + "p256", + "bip39" ], "type": [ "string" ] }, "bits": { - "description": "RSA key size in bits (`rsa_private_key` defaults to 2048;\nOpenPGP and SSH RSA in 0.21+ default to 3072).", + "description": "RSA key size in bits (`rsa_private_key` defaults to 2048; OpenPGP, SSH, and JWK RSA default to 3072).", "format": "uint", "minimum": 0, "type": [ @@ -131,6 +137,24 @@ "string" ] }, + "issuer": { + "description": "X.509 issuer mode (currently `self_signed`; 0.22+).", + "type": [ + "string" + ] + }, + "kid": { + "description": "Optional JSON Web Key identifier (`kid`; 0.22+).", + "type": [ + "string" + ] + }, + "language": { + "description": "Word-list language for a generated mnemonic (currently `english`; 0.22+).", + "type": [ + "string" + ] + }, "length": { "description": "Length of generated password (for `password` type)", "format": "uint", @@ -139,11 +163,49 @@ "integer" ] }, + "san": { + "description": "X.509 subject alternative names, written as `dns:name` or `ip:address` (0.22+).", + "items": { + "type": "string" + }, + "type": [ + "array" + ] + }, + "separator": { + "description": "Separator placed between generated passphrase words (default `-`; 0.22+).", + "type": [ + "string" + ] + }, + "usages": { + "description": "X.509 extended key usages (`server_auth` and/or `client_auth`; 0.22+).", + "items": { + "type": "string" + }, + "type": [ + "array" + ] + }, "user_id": { "description": "OpenPGP User ID bound to a generated certificate (0.21+).", "type": [ "string" ] + }, + "valid_for": { + "description": "X.509 certificate lifetime as whole days, for example `30d` (0.22+).", + "type": [ + "string" + ] + }, + "words": { + "description": "Number of words in a generated passphrase or mnemonic (defaults 7 or 24; 0.22+).", + "format": "uint", + "minimum": 0, + "type": [ + "integer" + ] } }, "type": "object" @@ -557,6 +619,15 @@ "string" ] }, + "credentials": { + "additionalProperties": { + "$ref": "#/definitions/CredentialBinding" + }, + "description": "Declared secrets bound to typed credential roles (0.22+).", + "type": [ + "object" + ] + }, "default": { "description": "Fallback value when no provider supplies the secret.", "type": [ @@ -585,6 +656,18 @@ ], "description": "Select a logical value from structured provider data (0.19+)." }, + "format": { + "description": "Typed output serialization, such as pem or der (0.22+).", + "type": [ + "string" + ] + }, + "from": { + "description": "Declared source secret for a conversion or extraction (0.22+).", + "type": [ + "string" + ] + }, "generate": { "anyOf": [ { @@ -634,16 +717,27 @@ "description": "Whether this secret is required, or named presence groups (0.17+)." }, "type": { - "description": "Secret type for validation and generation. OpenPGP and SSH private keys require 0.21+.", + "description": "Secret type for validation and generation. OpenPGP and SSH private keys require 0.21+.\nPassphrases, mnemonics, WireGuard, JWK, age, and X.509 types require 0.22+.", "enum": [ "password", + "passphrase", + "mnemonic", "hex", "base64", "uuid", "command", "rsa_private_key", "openpgp_private_key", - "ssh_private_key" + "ssh_private_key", + "wireguard_private_key", + "jwk_private_key", + "age_identity", + "x509_identity", + "pkcs12", + "pkcs8_private_key", + "x509_certificate", + "x509_certificate_chain", + "x509_issuer_chain" ], "type": [ "string" @@ -674,7 +768,7 @@ }, "SecretExtract": { "additionalProperties": false, - "description": "Selects one logical secret from a structured stored value.\n\nExtraction is applied after [`SecretEncoding`] is decoded and only to\nvalues read from providers or caches. Defaults and composed values are\nalready logical and are not extracted.\n\nAvailable since SecretSpec 0.19.", + "description": "Selects one logical secret from a structured text document.\n\nOn a provider-backed secret, extraction is applied after [`SecretEncoding`]\nis decoded and only to values read from providers or caches. On a secret\nwith [`from`](Secret::from) (0.22+), the named declared secret is resolved\nfirst and its logical text becomes the extraction input.\n\nAvailable since SecretSpec 0.19.", "properties": { "format": { "allOf": [ @@ -682,7 +776,7 @@ "$ref": "#/definitions/ExtractFormat" } ], - "description": "The structured-data format of the stored value." + "description": "The structured-data format of the document." }, "pointer": { "description": "Slash-delimited pointer selecting the logical value. JSON accepts a\ncomplete RFC 6901 JSON Pointer; INI accepts `/key` or `/section/key`\nwith RFC 6901 escaping for each segment.", @@ -690,8 +784,7 @@ } }, "required": [ - "format", - "pointer" + "format" ], "type": "object" } diff --git a/docs/src/content/docs/concepts/declarative.md b/docs/src/content/docs/concepts/declarative.md index 1d4236ebf..4976214ed 100644 --- a/docs/src/content/docs/concepts/declarative.md +++ b/docs/src/content/docs/concepts/declarative.md @@ -36,10 +36,19 @@ SECRET_NAME = { - `required`: Whether the secret must be provided (default: `true`) - `default`: Fallback value for optional secrets - `composed` (0.16+): Derive a read-only value from other declared secrets (see [Composed Secrets](/concepts/composed-secrets/) for the strict template and dependency semantics) -- `type`: Secret type for auto-generation (`password`, `hex`, `base64`, `uuid`, `command`, `rsa_private_key`, `openpgp_private_key` (0.21+), and `ssh_private_key` (0.21+)) +- `type`: Secret type for auto-generation (`password`, `hex`, `base64`, `uuid`, `command`, `rsa_private_key`, `openpgp_private_key` (0.21+), `ssh_private_key` (0.21+); `passphrase`, `mnemonic`, `wireguard_private_key`, `jwk_private_key`, `age_identity`, and `x509_identity` require 0.22+) - `generate`: Enable auto-generation when the secret is missing (`true` or a table with options) - `prompt` (0.19+): Securely ask for a missing value during `secretspec run` and let the selected provider decide whether to save the answer +- `from` (0.22+): Derive a read-only value from another declared secret, either + by selecting a field with `extract` or by converting an `x509_identity` into + a `pkcs12`, `pkcs8_private_key`, `x509_certificate`, + `x509_certificate_chain`, or `x509_issuer_chain` (see the + [configuration reference](/reference/configuration/#derived-typed-secrets-022)) +- `format` (0.22+): Choose `pem` (default) or `der` for a converted key or + certificate +- `credentials` (0.22+): Bind the `password` that opens a stored + `x509_identity` or protects a derived `pkcs12` to another declared secret ## Related Concepts diff --git a/docs/src/content/docs/concepts/generation.mdx b/docs/src/content/docs/concepts/generation.mdx index 853e48c77..ad9a756a7 100644 --- a/docs/src/content/docs/concepts/generation.mdx +++ b/docs/src/content/docs/concepts/generation.mdx @@ -24,6 +24,8 @@ REQUEST_ID = { description = "Request ID prefix", type = "uuid", generate = true | Type | Default Output | Options | |------|---------------|---------| | `password` | 32 alphanumeric chars | `length` (int), `charset` (`"alphanumeric"` or `"ascii"`) | +| `passphrase` (0.22+) | Seven BIP-39 English words joined with `-` | `words` (6–32), `separator` (non-empty string) | +| `mnemonic` (0.22+) | 24-word English BIP-39 mnemonic | `algorithm` (`"bip39"`), `words` (12, 15, 18, 21, or 24), `language` (`"english"`) | | `hex` | 64 hex chars (32 bytes) | `bytes` (int) | | `base64` | 44 chars (32 bytes) | `bytes` (int) | | `uuid` | UUID v4 (36 chars) | none | @@ -31,6 +33,10 @@ REQUEST_ID = { description = "Request ID prefix", type = "uuid", generate = true | `rsa_private_key` | 2048-bit RSA private key (PKCS1 PEM) | `bits` (int) | | `openpgp_private_key` (0.21+) | ASCII-armored OpenPGP transferable secret key | `user_id` (required), `algorithm` (`"ed25519"` or `"rsa"`), `bits` (RSA only), `capabilities` (`["sign"]`, `["encrypt"]`, or both) | | `ssh_private_key` (0.21+) | Unencrypted OpenSSH Ed25519 private key | `algorithm` (`"ed25519"` or `"rsa"`), `bits` (RSA only), `comment` (string) | +| `wireguard_private_key` (0.22+) | Base64-encoded WireGuard private key | none | +| `jwk_private_key` (0.22+) | Ed25519 private signing JWK | `algorithm` (`"ed25519"`, `"p256"`, or `"rsa"`), `bits` (RSA only), `kid` (string) | +| `age_identity` (0.22+) | Native X25519 age identity | none | +| `x509_identity` (0.22+) | PKCS#12 archive (Base64 at rest) containing a P-256 key and self-signed certificate | `san` (required), `usages`, `valid_for` (1–200 days); `issuer = "self_signed"`, `algorithm = "p256"` | ### Command type @@ -107,6 +113,73 @@ keys are not passphrase-encrypted, so store them in an encrypted provider. Set `as_path = true` when a command needs the key in a temporary file rather than in an environment variable. +### Passphrases and infrastructure keys {/* #additional-generation-types-022 */} + + + +`passphrase` independently selects seven words from the 2,048-word BIP-39 +English list by default, providing 77 bits of entropy. The output is a +human-readable passphrase, not a BIP-39 mnemonic: SecretSpec adds no checksum +and does not derive a wallet seed. Set `words` from 6 through 32 and choose any +non-empty, control-character-free `separator`: + +```toml +RECOVERY_CODE = { description = "Operator recovery code", type = "passphrase", generate = { words = 8, separator = "." } } +``` + +`mnemonic` (0.22+) generates a checksum-valid BIP-39 recovery mnemonic. It +defaults to 24 English words, encoding 256 bits of OS-provided entropy plus the +BIP-39 checksum. Supported word counts are 12, 15, 18, 21, and 24; +`algorithm = "bip39"` and `language = "english"` are currently the only +supported subtype values: + +```toml +WALLET_RECOVERY = { description = "Wallet recovery mnemonic", type = "mnemonic", generate = { algorithm = "bip39", words = 24, language = "english" } } +``` + +The generated value is the mnemonic itself. SecretSpec does not derive a +BIP-32 seed or wallet keys and does not add BIP-39's optional mnemonic +passphrase. Store it only in a provider appropriate for high-value recovery +material. + +The other 0.22+ infrastructure formats are generated entirely in process: + +```toml +WIREGUARD_KEY = { description = "WireGuard peer private key", type = "wireguard_private_key", generate = true } +JWT_KEY = { description = "JWT signing key", type = "jwk_private_key", generate = { algorithm = "p256", kid = "release-2026" } } +AGE_KEY = { description = "Backup encryption identity", type = "age_identity", generate = true } +TLS_IDENTITY = { description = "Development TLS identity", type = "x509_identity", generate = { san = ["dns:localhost", "ip:127.0.0.1"], valid_for = "30d" } } +TLS_KEY = { description = "PKCS#8 private key", type = "pkcs8_private_key", from = "TLS_IDENTITY", as_path = true } +``` + +`wireguard_private_key` produces the standard Base64-encoded, clamped 32-byte +private key accepted by WireGuard. `age_identity` produces a native X25519 +`AGE-SECRET-KEY-1...` identity. Neither accepts generation options. + +The X25519 age identity maximizes compatibility with the current Rust age +provider. The age reference implementation now recommends hybrid +ML-KEM-768+X25519 identities for data that needs post-quantum confidentiality, +but the Rust age library does not yet parse that identity format. Provision a +hybrid `AGE-SECRET-KEY-PQ-1...` identity externally when that threat model +applies. + +`jwk_private_key` emits one compact private JWK including its public parameters, +`use = "sig"`, and `key_ops = ["sign"]`. Ed25519 with RFC 9864's fully +specified `Ed25519` JOSE algorithm identifier is the default; +`algorithm = "p256"` selects P-256/`ES256`, while `"rsa"` selects +RSA/`RS256`, defaults to 3072 bits, and accepts 2048 through 8192. `kid` is +optional. Protect all three private-key values with an encrypted provider. + +`x509_identity` (0.22+) generates a self-signed P-256 certificate whose SANs +must be explicit. The canonical value is an empty-password PKCS#12 archive, +stored as Base64 by default; the provider is its security boundary. Generation +uses ECDSA/SHA-256, a 30-day default lifetime, and a 200-day maximum. Declare +further secrets with `from = "TLS_IDENTITY"` and a typed conversion target to +obtain PKCS#8 PEM or DER private keys, certificate PEM or DER, ordered +certificate chains, or password protected PFX files. See the +[configuration reference](/reference/configuration/#derived-typed-secrets-022) +for the complete example and validation rules. + ## How it works - Generation only triggers when a secret is **missing**. Existing secrets are never overwritten. @@ -114,7 +187,9 @@ an environment variable. - Subsequent runs find the stored value and skip generation (idempotent). - The `null` provider (0.19+) instead returns a fresh generated value for only the current resolution. - `generate` and `default` cannot both be set on the same secret. -- Setting `type` without `generate` is informational only and does not trigger auto-generation. +- Setting `type` without `generate` does not trigger auto-generation. In 0.22+, + registered X.509 identity and conversion types still validate and interpret + the value; other types remain informational. ## Ephemeral generation with null {/* #ephemeral-generation-with-null-019 */} @@ -159,6 +234,18 @@ RELEASE_KEY = { description = "Release signing key", type = "openpgp_private_key # OpenSSH Ed25519 private key (requires SecretSpec 0.21+) DEPLOY_KEY = { description = "Deployment SSH key", type = "ssh_private_key", generate = true } +# Human-readable passphrase (requires SecretSpec 0.22+) +RECOVERY_CODE = { description = "Recovery code", type = "passphrase", generate = true } + +# Checksum-protected BIP-39 mnemonic (requires SecretSpec 0.22+) +WALLET_RECOVERY = { description = "Wallet recovery mnemonic", type = "mnemonic", generate = true } + +# Private P-256 JWK (requires SecretSpec 0.22+) +JWT_JWK = { description = "JWT signing JWK", type = "jwk_private_key", generate = { algorithm = "p256" } } + +# Self-signed P-256 X.509 identity (requires SecretSpec 0.22+) +TLS_IDENTITY = { description = "Development TLS identity", type = "x509_identity", generate = { san = ["dns:localhost", "ip:127.0.0.1"] } } + # Informational type only, no generation EXTERNAL_API_KEY = { description = "Provided by vendor", type = "password" } ``` diff --git a/docs/src/content/docs/reference/cli.mdx b/docs/src/content/docs/reference/cli.mdx index 7e7c2426a..9ad4d3e01 100644 --- a/docs/src/content/docs/reference/cli.mdx +++ b/docs/src/content/docs/reference/cli.mdx @@ -515,6 +515,13 @@ Writing secret 'API_KEY' to keyring (profile: production) #### Resolution report (`--json` / `--explain`) + +Derived secrets declared with `from` appear as `converted from SOURCE` or +`selected from SOURCE` in `--explain`. Source names outside the selected scope +are redacted. Reports check their dependencies without +generating identities, converting certificates, or creating temporary files. + + `--json` and `--explain` report how every declared secret resolved for the active profile without prompting and without ever printing a secret value. Both exit non-zero when a required secret is missing, so they work as a CI gate. diff --git a/docs/src/content/docs/reference/configuration.mdx b/docs/src/content/docs/reference/configuration.mdx index cb0dde465..2e4f1ea94 100644 --- a/docs/src/content/docs/reference/configuration.mdx +++ b/docs/src/content/docs/reference/configuration.mdx @@ -251,8 +251,11 @@ Each secret variable is defined as a table with the following fields: | `refs` (0.19+) | table | No | Provider-alias-scoped coordinates, keyed by leaf alias (e.g. `refs = { source = { item = "old" }, target = { item = "new" } }`); mutually exclusive with `ref` | | `as_path` | boolean | No | Write secret to temp file and return file path (default: false) | | `encoding` (0.19+) | `"base64"`, `"base64url"`, or `"hex"` | No | Encode logical values before storage writes and decode stored values after reads | -| `extract` (0.19+) | table | No | Select one logical value from stored JSON (0.19+) or INI (0.20+) data with a pointer | -| `type` | string | No | Secret type for generation: `password`, `hex`, `base64`, `uuid`, `command`, `rsa_private_key`, `openpgp_private_key` (0.21+), `ssh_private_key` (0.21+) | +| `extract` (0.19+) | table | No | Select one field from stored JSON/INI text, or from the text of the secret named by `from` (0.22+) | +| `type` | string | No | Secret type for generation: `password`, `hex`, `base64`, `uuid`, `command`, `rsa_private_key`, `passphrase` (0.22+), `mnemonic` (0.22+), `openpgp_private_key` (0.21+), `ssh_private_key` (0.21+), `wireguard_private_key` (0.22+), `jwk_private_key` (0.22+), `age_identity` (0.22+), `x509_identity` (0.22+). Typed conversion targets (0.22+): `pkcs12`, `pkcs8_private_key`, `x509_certificate`, `x509_certificate_chain`, `x509_issuer_chain` | +| `format` (0.22+) | `"pem"` or `"der"` | No | Serialization of a `pkcs8_private_key` or `x509_certificate`; defaults to `pem` | +| `from` (0.22+) | string | No | Declared secret this value is converted from (with a typed conversion target) or selected from (with `extract`) | +| `credentials` (0.22+) | table | No | Declared secrets bound to the roles a type defines, e.g. `credentials = { password = "PFX_PASSWORD" }` for `x509_identity` and `pkcs12` | | `generate` | boolean or table | No | Enable auto-generation when secret is missing | | `prompt` (0.19+) | boolean | No | Securely prompt for a missing value during `secretspec run`; the selected provider controls persistence | @@ -272,6 +275,17 @@ Field notes: combined with `default`, enabled `generate`, `extract`, or `composed`. - `extract` (0.19+) is read-only and cannot be combined with enabled `generate`. +- `from` (0.22+) makes a secret read-only and derived: it needs either + `extract` or a typed conversion target as its `type`, and cannot declare + `default`, `providers`, `ref`, `refs`, enabled `generate`, `prompt = true`, + or `composed`. See [Derived typed secrets](#derived-typed-secrets-022). +- `format` (0.22+) is valid only for types with more than one serialization + (`pkcs8_private_key` and `x509_certificate`). +- `credentials` (0.22+) is valid only for types that define roles: + `x509_identity` and `pkcs12` accept `password`. Each value names a declared + text secret. The `{ provider, ref }` table form is reserved. +- `type = "x509_identity"` (0.22+) is a binary type: it cannot be combined + with `default` or `prompt = true`, and `encoding` defaults to `base64`. #### Composed Secrets @@ -301,7 +315,9 @@ and composed secrets may reference other composed secrets. SecretSpec rejects unknown references, cycles, malformed references, and source conflicts while loading the manifest. A composed secret is read-only and cannot also set `default`, `providers`, `ref`, `refs` (0.19+), `type`, enabled `generate`, -`encoding` (0.19+), or `extract` (0.19+). +`encoding` (0.19+), `extract` (0.19+), `format` (0.22+), `from` (0.22+), or +`credentials` (0.22+). A composed template may reference a derived secret, for +example to embed the path of a certificate file. Composition intentionally does **not** implement dotenv or shell expansion: @@ -342,7 +358,8 @@ Scopes name membership-only subsets of a profile's secrets, so a single service or task resolves only what it declares instead of the entire profile. They are **orthogonal to profiles**: a profile decides how each secret resolves (`required`, `default`, providers, references, generation, prompts (0.19+), `as_path`, -`encoding` (0.19+), `extract` (0.19+), and the storage namespace); a scope only +`encoding` (0.19+), `extract` (0.19+), `from` derivation (0.22+), and the +storage namespace); a scope only decides *which* secrets take part in a given resolution. ```toml @@ -829,6 +846,16 @@ arbitrary stdout bytes in 0.21+. SecretSpec encodes logical bytes before writing to a provider. The `secretspec import` command copies the stored representation verbatim, avoiding double encoding. + + +Binary typed values (`x509_identity` and the `pkcs12` and `der` conversion +targets) default `encoding` to `base64` and use it wherever their bytes must be +text: in the provider, in the cache, and inline when `as_path` is not set. With +`as_path = true` the file holds the raw bytes. `secretspec set` accepts such a +value in its encoded form. See [Derived typed secrets](#derived-typed-secrets-022). + + + ### Structured Extraction {/* #structured-extraction-019 */} @@ -843,6 +870,10 @@ Structured extraction now supports INI documents with `format = "ini"`. Extraction rejects non-UTF-8 decoded documents before parsing. +`extract` can now select from the text of another declared secret named by +`from`. X.509 conversions use `type` and `from` rather than extraction formats; +see [Derived typed secrets](#derived-typed-secrets-022). + `extract` (0.19+) selects one logical secret from structured text read from a @@ -902,22 +933,202 @@ always remain strings, and literal backslashes are preserved. Empty pointers, pointers deeper than `/section/key`, malformed INI, and unmatched pointers are decoding errors. +In 0.22+, `from` names another declared secret as the document to select from. +The source is resolved first and may itself come from a provider, default, +generator, or another derived declaration. `from` dependencies share composed +secrets' static graph: declaration order does not matter, scoped resolutions +fetch hidden dependencies without exposing them, and unknown names or cycles +are rejected while loading the manifest. JSON and INI continue to use +`pointer`, and the source must be text (a binary typed source is rejected): + +```toml +[profiles.default] +# from requires SecretSpec 0.22+ +GENERATED_DOCUMENT = { description = "Generated JSON", type = "command", generate = { command = "generate-config" } } +DATABASE_PASSWORD = { description = "Database password", from = "GENERATED_DOCUMENT", extract = { format = "json", pointer = "/database/password" } } +``` + Stored-value transforms run in this order: ```text provider or cache → encoding decode → structured extraction → as_path +from source → source resolution → structured extraction → as_path ``` This makes a Base64-encoded JSON document valid input when a declaration sets both `encoding = "base64"` (0.19+) and `extract` (0.19+). A provider-native `ref.field` is also resolved first, so a field whose contents are JSON can be -selected further. Defaults and composed values are already logical and are not -extracted. +selected further. Without `from`, defaults and composed values are already +logical and are not extracted. Extracted secrets are read-only. `set`, `delete`, interactive prompting, generation, and `import` reject them rather than replacing or removing the containing document and its sibling values. Update the document through its -owning system instead. +owning system instead. A `from` selection reports its source as the value to +change. + +### Derived typed secrets {/* #derived-typed-secrets-022 */} + + + +A secret with `from` and a typed conversion target as its `type` converts +another declared secret. In 0.22 the source is always an `x509_identity`: a +PKCS#12 archive holding one private key, its leaf certificate, and an optional +issuer chain, whether generated by SecretSpec or imported from a provider. Every +target is an ordinary flat secret with its own name, so SDKs and `run` see it +like any other value. + +| Type (0.22+) | Output | `format` | Binary | +|--------------|--------|----------|--------| +| `pkcs12` | Complete PKCS#12/PFX archive, protected by the `password` credential when one is bound | fixed | yes | +| `pkcs8_private_key` | Unencrypted PKCS#8 private key | `pem` (default) or `der` | `der` only | +| `x509_certificate` | Leaf certificate | `pem` (default) or `der` | `der` only | +| `x509_certificate_chain` | Leaf followed by its ordered issuer certificates | `pem` | no | +| `x509_issuer_chain` | Ordered issuer certificates without the leaf | `pem` | no | + +No target is itself an accepted source, so conversions are one level deep. A +composed template may still reference a target. + +#### Generated identity and password protected PFX + +```toml +[profiles.development] +# type = "x509_identity", from, format, and credentials require SecretSpec 0.22+ +TLS_IDENTITY = { + description = "Canonical development TLS identity", + type = "x509_identity", + generate = { san = ["dns:localhost", "ip:127.0.0.1"], usages = ["server_auth"], valid_for = "30d" } +} + +TLS_PFX_PASSWORD = { + description = "Password protecting the exported PFX", + type = "passphrase", + generate = true +} + +TLS_PFX = { + description = "Password protected Windows TLS identity", + type = "pkcs12", + from = "TLS_IDENTITY", + credentials = { password = "TLS_PFX_PASSWORD" }, + as_path = true +} + +TLS_KEY = { + description = "PKCS#8 private key", + type = "pkcs8_private_key", + from = "TLS_IDENTITY", + as_path = true +} + +TLS_CERTIFICATE_DER = { + description = "Leaf certificate as DER", + type = "x509_certificate", + format = "der", + from = "TLS_IDENTITY", + as_path = true +} + +TLS_CHAIN = { + description = "Leaf followed by its issuer chain", + type = "x509_certificate_chain", + from = "TLS_IDENTITY" +} +``` + +`TLS_IDENTITY` does not spell `encoding`: binary types default to Base64. One +resolve generates or reads the identity and the password, then materializes +every target. The consumer receives `TLS_PFX` as a `.pfx` path and +`TLS_PFX_PASSWORD` as a value, which is what it needs to load the archive. + +#### Importing a protected PFX + +The password that opens a stored archive belongs to the source declaration. +Projections do not repeat it: + +```toml +[profiles.production] +SERVICE_IDENTITY = { + description = "Imported service identity", + type = "x509_identity", + providers = ["identity_store"], + credentials = { password = "SERVICE_IDENTITY_PASSWORD" } +} + +SERVICE_IDENTITY_PASSWORD = { + description = "Password protecting the imported PFX", + providers = ["onepassword"], + ref = { vault = "Infrastructure", item = "Production TLS", field = "PFX password" } +} + +SERVICE_KEY = { description = "Service private key", type = "pkcs8_private_key", from = "SERVICE_IDENTITY", as_path = true } +SERVICE_CERTIFICATE = { description = "Service certificate chain", type = "x509_certificate_chain", from = "SERVICE_IDENTITY", as_path = true } +``` + +The password is an ordinary declared secret with provider routing, native +coordinates, `secretspec set`, `import`, audit, and scope handling. If it is +missing, the identity and every projection are reported missing under their own +`required` policy. A wrong password, or a protected archive with no password +bound, is a decoding error; SecretSpec never falls back to guessing. + +To rewrap an archive under a new password, derive a `pkcs12` from the imported +identity with a different `credentials.password`. Omitting the credential on a +`pkcs12` target produces an empty-password archive, an interchange container +rather than a security boundary. + +#### Delivery + +- `as_path = true` writes the raw bytes to an owner-only file with the type's + suffix: `.pfx`, `.pem`, or `.der`. +- Without `as_path`, a binary target is exposed inline as text in its + `encoding` (`base64` by default; `base64url` and `hex` are accepted). This + suits CI systems that take a Base64 PFX in an environment variable. A text + target is exposed as is, and `encoding` is rejected on it. +- Stored identities support RSA, P-256, P-384, P-521, and Ed25519 private keys. + Certificate subjects may contain supplementary Unicode characters even + though passwords currently require BMP characters. A self-issued leaf with + matching subject and issuer names may be signed by a distinct issuer key + when that issuer's certificate is included in its chain. + They are validated once: the archive must be between 1 byte and + 10 MiB, hold one private key and one matching leaf, carry at most 16 issuer + certificates that form one unambiguous leaf-to-root chain with verified + signatures, and be within every validity period. Unordered certificate bags + are put in order; unrelated, duplicate, or ambiguous bags are rejected. +- Generated `pkcs12` output uses PBES2/PBKDF2 with HMAC-SHA-256, AES-256-CBC + for key and certificate privacy, and HMAC-SHA-256 integrity, each with + 100,000 iterations. RC2, 3DES, and SHA-1 are never emitted. +- A bound password must be non-empty, contain no NUL, and be at most 1024 + bytes; an empty value is an error rather than "no password". Passwords + currently support characters in the Unicode Basic Multilingual Plane, + including accented letters. Supplementary characters, such as most emoji, + are rejected for both imported identities and derived PKCS#12 archives. + +#### Rules + +- `from` requires either `extract` (selection) or a conversion target `type` + (conversion), never both, and cannot set `default`, `providers`, `ref`, + `refs`, enabled `generate`, `prompt = true`, or `composed`. +- The source's type must be accepted by the target: every target derives from + `x509_identity`. `x509_identity` itself is stored or generated and cannot be + derived. +- `format` is accepted only by `pkcs8_private_key` and `x509_certificate`. +- `credentials` roles are defined per type: `x509_identity` and `pkcs12` accept + `password`; other targets accept none. A credential must name a declared + text secret, so neither an `as_path` secret nor a binary typed secret + qualifies. A generated `x509_identity` cannot bind a password; it is stored + with an empty password and the provider is its security boundary. +- `from` and `credentials` add edges to the same dependency graph as + `composed`: unknown names, self references, and cycles are rejected when the + manifest loads, and scopes fetch hidden sources and credentials without + exposing them. +- Derived secrets are read-only. `set` and `delete` name the source to change + instead; `import` and generation skip them. +- Profile inheritance merges `from`, `format`, and `credentials` field by + field like every other field, then validates the merged secret. An override + may clear inherited credentials with `credentials = {}`. +- `check --explain` labels a conversion `converted from ` and a + selection `selected from `; resolve payloads report both with the + existing `composed` provenance. ### Secret References @@ -1139,6 +1350,15 @@ RELEASE_KEY = { description = "Release signing key", type = "openpgp_private_key # OpenSSH Ed25519 private key (0.21+) DEPLOY_KEY = { description = "Deployment SSH key", type = "ssh_private_key", generate = true } +# Private P-256 JSON Web Key (0.22+) +JWT_JWK = { description = "JWT signing JWK", type = "jwk_private_key", generate = { algorithm = "p256", kid = "release-2026" } } + +# Native age X25519 identity (0.22+) +BACKUP_IDENTITY = { description = "Backup encryption identity", type = "age_identity", generate = true } + +# Self-signed P-256 X.509 identity stored as a Base64 PKCS#12 archive (0.22+) +LOCAL_TLS = { description = "Local TLS identity", type = "x509_identity", generate = { san = ["dns:localhost", "ip:127.0.0.1"] } } + # Type without generate: informational only, no auto-generation MANUAL_SECRET = { description = "Manually managed", type = "password" } ``` @@ -1155,6 +1375,8 @@ for binary output. | Type | Default Output | Options | |------|---------------|---------| | `password` | 32 alphanumeric chars | `length` (int), `charset` (`"alphanumeric"` or `"ascii"`) | +| `passphrase` (0.22+) | Seven BIP-39 English words joined with `-` | `words` (6–32), `separator` (non-empty string) | +| `mnemonic` (0.22+) | 24-word English BIP-39 mnemonic | `algorithm` (`"bip39"`), `words` (12, 15, 18, 21, or 24), `language` (`"english"`) | | `hex` | 64 hex chars (32 bytes) | `bytes` (int) | | `base64` | 44 chars (32 bytes) | `bytes` (int) | | `uuid` | UUID v4 (36 chars) | none | @@ -1162,6 +1384,10 @@ for binary output. | `rsa_private_key` | 2048-bit RSA private key (PKCS1 PEM) | `bits` (int) | | `openpgp_private_key` (0.21+) | ASCII-armored OpenPGP v4 transferable secret key | `user_id` (required), `algorithm` (`"ed25519"` or `"rsa"`), `bits` (RSA only), `capabilities` (`["sign"]`, `["encrypt"]`, or both) | | `ssh_private_key` (0.21+) | Unencrypted OpenSSH Ed25519 private key | `algorithm` (`"ed25519"` or `"rsa"`), `bits` (RSA only), `comment` (string) | +| `wireguard_private_key` (0.22+) | Base64-encoded WireGuard private key | none | +| `jwk_private_key` (0.22+) | Ed25519 private signing JWK | `algorithm` (`"ed25519"`, `"p256"`, or `"rsa"`), `bits` (RSA only), `kid` (string) | +| `age_identity` (0.22+) | Native X25519 age identity | none | +| `x509_identity` (0.22+) | PKCS#12 archive (Base64 at rest) containing a P-256 key and self-signed certificate | `issuer` (`"self_signed"`), `algorithm` (`"p256"`), `san` (required DNS/IP names), `usages` (`"server_auth"` and/or `"client_auth"`), `valid_for` (1–200 days) | #### OpenPGP private-key generation {/* #openpgp-private-key-generation-021 */} @@ -1193,6 +1419,62 @@ compatibility; RSA defaults to 3072 bits and accepts 2048 through 8192. `bits` is invalid with Ed25519. An optional `comment` is embedded in the key and must not contain control characters. +#### Additional credential generation {/* #additional-credential-generation-022 */} + + + +- `passphrase` independently selects seven BIP-39 English words by default + (77 bits of entropy) and joins them with `-`. It is not a BIP-39 mnemonic. + `words` accepts 6 through 32; `separator` must be non-empty and contain no + control characters. +- `mnemonic` (0.22+) emits a checksum-valid BIP-39 mnemonic. It defaults to 24 + English words (256 bits of entropy plus checksum); `words` accepts 12, 15, + 18, 21, or 24. The extensible subtype fields currently accept only + `algorithm = "bip39"` and `language = "english"`. SecretSpec returns the + mnemonic itself and does not derive a BIP-32 seed, wallet keys, or BIP-39's + optional mnemonic passphrase. +- `wireguard_private_key` emits the standard Base64-encoded, clamped 32-byte + scalar accepted by WireGuard and accepts no options. +- `jwk_private_key` emits a compact private signing JWK with public parameters, + `use = "sig"`, and `key_ops = ["sign"]`. It defaults to Ed25519 with RFC + 9864's fully specified `Ed25519` JOSE algorithm identifier; + `algorithm = "p256"` selects P-256/`ES256`, and `"rsa"` selects + RSA/`RS256`. RSA defaults to 3072 bits and accepts 2048 through 8192. + `kid` adds optional key-identification metadata. +- `age_identity` emits a native X25519 `AGE-SECRET-KEY-1...` identity and + accepts no options. This is the interoperable classic format supported by + the Rust age provider. For post-quantum confidentiality, provision an + externally generated hybrid ML-KEM-768+X25519 `AGE-SECRET-KEY-PQ-1...` + identity until the Rust age library supports that format. + +#### X.509 identity generation {/* #x509-identity-generation-022 */} + + + +`x509_identity` generates a P-256 key and a self-signed X.509 v3 certificate. +`generate.san` is required and accepts `dns:name` and `ip:address` entries; +hostname verification therefore never depends on the deprecated Common Name. +The certificate uses ECDSA with SHA-256, a random 128-bit serial, critical +CA-false basic constraints and digital-signature key usage, plus `server_auth` +extended usage by default. `client_auth` may be selected or combined with it. + +Validity defaults to `30d` and accepts `1d` through `200d`, matching the current +CA/Browser Forum maximum for publicly trusted subscriber certificates even +though SecretSpec's self-signed development identities are outside that policy. +The start time is backdated five minutes for peer clock skew without extending +the requested total lifetime. + +The canonical value is an empty-password PKCS#12 archive. It explicitly uses +AES-256-CBC for key and certificate protection and HMAC-SHA-256 for integrity, +avoiding legacy RC2, 3DES, and SHA-1 profiles. Because the password is empty, +the archive is an interoperable container rather than an independent security +boundary: store it in a provider that protects secrets at rest. `encoding` +defaults to `base64`, so inline exposure is Base64 and `as_path = true` writes +the PFX bytes to an owner-only `.pfx` file. Derive PEM, DER, key, certificate, +chain, and password protected PFX values with `from`; see +[Derived typed secrets](#derived-typed-secrets-022). A provider-backed +`x509_identity` may also be an imported archive, protected or not. + #### Behavior - Generation only triggers when a secret is **missing** — existing secrets are never overwritten @@ -1206,6 +1488,8 @@ not contain control characters. signing and encryption, respectively - `type = "ssh_private_key"` (0.21+) defaults to Ed25519; RSA generation is available with `generate = { algorithm = "rsa", bits = 4096 }` +- `passphrase`, `mnemonic`, `wireguard_private_key`, `jwk_private_key`, `age_identity`, and `x509_identity` + generation require SecretSpec 0.22+ - The value-free preflights — [`check --json` / `check --explain`](/reference/cli/#resolution-report---json----explain) and the SDKs' report/no-values resolutions — never mint a value. Since SecretSpec 0.20 a **required** generatable secret that no provider holds is diff --git a/secretspec/Cargo.toml b/secretspec/Cargo.toml index 6941892f9..bab0153fb 100644 --- a/secretspec/Cargo.toml +++ b/secretspec/Cargo.toml @@ -77,6 +77,11 @@ pgp.workspace = true smallvec.workspace = true uuid.workspace = true data-encoding.workspace = true +bip39.workspace = true +bech32.workspace = true +ed25519-dalek.workspace = true +p256.workspace = true +x25519-dalek.workspace = true sha2.workspace = true ssh-key.workspace = true detect-coding-agent.workspace = true @@ -84,6 +89,12 @@ age = { workspace = true, optional = true } kube = { workspace = true, optional = true } k8s-openapi = { workspace = true, optional = true } json-patch = { workspace = true, optional = true } +aws-lc-rs.workspace = true +rcgen.workspace = true +x509-parser.workspace = true +p12-keystore.workspace = true +pem.workspace = true +time.workspace = true [target.'cfg(unix)'.dependencies] libc.workspace = true diff --git a/secretspec/README.md b/secretspec/README.md index 4e84da676..dcb6ef639 100644 --- a/secretspec/README.md +++ b/secretspec/README.md @@ -61,7 +61,7 @@ SecretSpec fixes this by separating secret **declaration** from secret **storage - [Kubernetes](https://secretspec.dev/providers/kubernetes) (0.20+) - **[Type-Safe Rust SDK](https://secretspec.dev/sdk/rust/)**: Generate strongly-typed structs from your `secretspec.toml` for compile-time safety - **[Profile Support](https://secretspec.dev/concepts/profiles/)**: Override secret requirements and defaults per profile (development, production, etc.) -- **[Secret Generation](https://secretspec.dev/concepts/generation/)**: Auto-generate passwords, tokens, UUIDs, and more when secrets are missing — including pure-Rust OpenPGP and OpenSSH private keys in 0.21+ +- **[Secret Generation](https://secretspec.dev/concepts/generation/)**: Auto-generate passwords, tokens, UUIDs, and more when secrets are missing, including OpenPGP and OpenSSH private keys (0.21+), plus passphrases, BIP-39 mnemonics, WireGuard, JWK, age credentials, and self-signed X.509 identities with key and certificate conversions (0.22+) - **Run prompts (0.19+)**: Set `prompt = true` to request a hidden missing value; writable providers save it, while `null` keeps it invocation-only - **Composed Secrets (0.16+)**: Derive read-only values such as DSNs from declared secrets with strict, order-independent `${UPPERCASE_NAME}` references - **[Configuration Inheritance](https://secretspec.dev/concepts/inheritance/)**: Extend and override shared configurations using the `extends` feature diff --git a/secretspec/src/compiled_spec.rs b/secretspec/src/compiled_spec.rs index 4bed4cad1..54ccf092f 100644 --- a/secretspec/src/compiled_spec.rs +++ b/secretspec/src/compiled_spec.rs @@ -79,6 +79,26 @@ impl CompiledSecret { composition, } } + + /// Every declared secret this one must resolve first: the references of a + /// composed template, the `from` source, and each credential binding, in + /// that order. One definition feeds graph validation, scope closure, + /// prompting, the SSH agent's dependency walk, and the executor, so none + /// of them can disagree about what a derived or typed secret needs. + pub(crate) fn dependencies(&self) -> Vec<&str> { + let mut dependencies: Vec<&str> = self + .composition + .as_ref() + .map(|template| template.dependencies().iter().map(String::as_str).collect()) + .unwrap_or_default(); + dependencies.extend(self.config.from.as_deref()); + dependencies.extend( + self.config + .credential_secrets() + .map(|(_, credential)| credential), + ); + dependencies + } } #[cfg(test)] diff --git a/secretspec/src/config.rs b/secretspec/src/config.rs index 174684dba..90d0ff977 100644 --- a/secretspec/src/config.rs +++ b/secretspec/src/config.rs @@ -975,27 +975,79 @@ fn validate_composition_graph( profile_name: &str, profile: &crate::compiled_spec::CompiledProfile, ) -> Result<(), ParseError> { - // Templates were parsed during manifest compilation; a malformed one was - // already rejected by `validate_semantics` before this runs. - let mut graph: BTreeMap<&str, &[String]> = BTreeMap::new(); + // Templates, `from`, and credential shapes were validated per secret + // before this runs. They share one dependency graph so a cycle mixing + // composed, converted, and credential edges cannot evade any resolver pass. + let mut graph: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); for (name, secret) in &profile.secrets { - let Some(template) = &secret.composition else { - continue; + let fail = |message: String| { + ParseError::Validation(format!( + "Profile '{profile_name}': Secret '{name}': {message}" + )) }; - for dependency in template.dependencies() { - if !profile.secrets.contains_key(dependency) { - return Err(ParseError::Validation(format!( - "Profile '{}': Secret '{}': composed reference `${{{}}}` does not name a declared secret", - profile_name, name, dependency + let dependencies = secret.dependencies(); + for dependency in &dependencies { + if *dependency == name.as_str() { + return Err(fail("cannot depend on itself".to_string())); + } + if !profile.secrets.contains_key(*dependency) { + return Err(fail(format!( + "dependency '{dependency}' does not name a declared secret" + ))); + } + } + + let config = &secret.config; + if let Some(source) = config.from.as_deref() { + let source_config = &profile.secrets[source].config; + if let Some(contract) = config.typed_contract() { + if !contract.accepts_source(source_config.secret_type.as_deref()) { + let declared = source_config.secret_type.as_deref().map_or_else( + || "no `type`".to_string(), + |source_type| format!("type = \"{source_type}\""), + ); + return Err(fail(format!( + "`from` source '{source}' has {declared}, but type = \"{}\" derives from {}", + contract.name, + contract + .sources + .iter() + .map(|source| format!("type = \"{source}\"")) + .collect::>() + .join(" or ") + ))); + } + } else if source_config.is_binary_value() { + return Err(fail(format!( + "`extract` cannot select from '{source}' because its type = \"{}\" is binary", + source_config.secret_type.as_deref().unwrap_or_default() ))); } } - graph.insert(name.as_str(), template.dependencies()); + + for (role, credential) in config.credential_secrets() { + let credential_config = &profile.secrets[credential].config; + if credential_config.as_path == Some(true) { + return Err(fail(format!( + "credential `{role}` names '{credential}', which is delivered as a path; credentials must be text values" + ))); + } + if credential_config.is_binary_value() { + return Err(fail(format!( + "credential `{role}` names '{credential}', whose type = \"{}\" is binary; credentials must be text values", + credential_config.secret_type.as_deref().unwrap_or_default() + ))); + } + } + + if !dependencies.is_empty() { + graph.insert(name.as_str(), dependencies); + } } fn visit<'a>( name: &'a str, - graph: &BTreeMap<&'a str, &'a [String]>, + graph: &BTreeMap<&'a str, Vec<&'a str>>, state: &mut HashMap<&'a str, u8>, stack: &mut Vec<&'a str>, ) -> Result<(), Vec> { @@ -1012,8 +1064,8 @@ fn validate_composition_graph( state.insert(name, 1); stack.push(name); if let Some(dependencies) = graph.get(name) { - for dependency in *dependencies { - if graph.contains_key(dependency.as_str()) { + for dependency in dependencies { + if graph.contains_key(dependency) { visit(dependency, graph, state, stack)?; } } @@ -1027,7 +1079,7 @@ fn validate_composition_graph( for name in graph.keys() { if let Err(cycle) = visit(name, &graph, &mut state, &mut Vec::new()) { return Err(ParseError::Validation(format!( - "Profile '{}': composed secret cycle: {}", + "Profile '{}': derived secret cycle: {}", profile_name, cycle.join(" -> ") ))); @@ -1604,6 +1656,8 @@ impl IntoIterator for Profile { /// type-specific options (`generate = { length = 64 }`). #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(untagged)] +// Keep the options variant unboxed to preserve the public Rust API. +#[allow(clippy::large_enum_variant)] pub enum GenerateConfig { /// Simple boolean flag to enable/disable generation with defaults Bool(bool), @@ -1637,13 +1691,12 @@ pub struct GenerateOptions { /// Shell command to run (for `command` type) #[serde(skip_serializing_if = "Option::is_none")] pub command: Option, - /// RSA key size in bits (`rsa_private_key` defaults to 2048; - /// OpenPGP and SSH RSA in 0.21+ default to 3072). + /// RSA key size in bits (`rsa_private_key` defaults to 2048; OpenPGP, SSH, and JWK RSA default to 3072). #[serde(skip_serializing_if = "Option::is_none")] pub bits: Option, - /// OpenPGP or SSH key algorithm (`ed25519` or `rsa`, default `ed25519`; 0.21+). + /// OpenPGP or SSH algorithm (0.21+), or JWK or mnemonic algorithm (0.22+). #[serde(skip_serializing_if = "Option::is_none")] - #[schemars(extend("enum" = ["ed25519", "rsa"]))] + #[schemars(extend("enum" = ["ed25519", "rsa", "p256", "bip39"]))] pub algorithm: Option, /// OpenPGP User ID bound to a generated certificate (0.21+). #[serde(skip_serializing_if = "Option::is_none")] @@ -1655,6 +1708,30 @@ pub struct GenerateOptions { /// Comment embedded in a generated OpenSSH private key (0.21+). #[serde(skip_serializing_if = "Option::is_none")] pub comment: Option, + /// Number of words in a generated passphrase or mnemonic (defaults 7 or 24; 0.22+). + #[serde(skip_serializing_if = "Option::is_none")] + pub words: Option, + /// Separator placed between generated passphrase words (default `-`; 0.22+). + #[serde(skip_serializing_if = "Option::is_none")] + pub separator: Option, + /// Word-list language for a generated mnemonic (currently `english`; 0.22+). + #[serde(skip_serializing_if = "Option::is_none")] + pub language: Option, + /// Optional JSON Web Key identifier (`kid`; 0.22+). + #[serde(skip_serializing_if = "Option::is_none")] + pub kid: Option, + /// X.509 issuer mode (currently `self_signed`; 0.22+). + #[serde(skip_serializing_if = "Option::is_none")] + pub issuer: Option, + /// X.509 subject alternative names, written as `dns:name` or `ip:address` (0.22+). + #[serde(skip_serializing_if = "Option::is_none")] + pub san: Option>, + /// X.509 extended key usages (`server_auth` and/or `client_auth`; 0.22+). + #[serde(skip_serializing_if = "Option::is_none")] + pub usages: Option>, + /// X.509 certificate lifetime as whole days, for example `30d` (0.22+). + #[serde(skip_serializing_if = "Option::is_none")] + pub valid_for: Option, } pub(crate) const OPENPGP_RSA_DEFAULT_BITS: usize = 3072; @@ -1663,6 +1740,14 @@ pub(crate) const OPENPGP_RSA_MAX_BITS: usize = 8192; pub(crate) const SSH_RSA_DEFAULT_BITS: usize = 3072; pub(crate) const SSH_RSA_MIN_BITS: usize = 2048; pub(crate) const SSH_RSA_MAX_BITS: usize = 8192; +pub(crate) const JWK_RSA_DEFAULT_BITS: usize = 3072; +pub(crate) const JWK_RSA_MIN_BITS: usize = 2048; +pub(crate) const JWK_RSA_MAX_BITS: usize = 8192; +pub(crate) const PASSPHRASE_DEFAULT_WORDS: usize = 7; +pub(crate) const PASSPHRASE_MIN_WORDS: usize = 6; +pub(crate) const PASSPHRASE_MAX_WORDS: usize = 32; +pub(crate) const MNEMONIC_DEFAULT_WORDS: usize = 24; +pub(crate) const MNEMONIC_WORD_COUNTS: &[usize] = &[12, 15, 18, 21, 24]; /// Native coordinates of one externally managed secret: the value of a /// secret's `ref` field. @@ -2117,9 +2202,19 @@ struct SecretSerde { #[serde(skip_serializing_if = "Option::is_none")] extract: Option, /// Secret type for validation and generation. OpenPGP and SSH private keys require 0.21+. + /// Passphrases, mnemonics, WireGuard, JWK, age, and X.509 types require 0.22+. #[serde(rename = "type", skip_serializing_if = "Option::is_none")] - #[schemars(extend("enum" = ["password", "hex", "base64", "uuid", "command", "rsa_private_key", "openpgp_private_key", "ssh_private_key"]))] + #[schemars(extend("enum" = ["password", "passphrase", "mnemonic", "hex", "base64", "uuid", "command", "rsa_private_key", "openpgp_private_key", "ssh_private_key", "wireguard_private_key", "jwk_private_key", "age_identity", "x509_identity", "pkcs12", "pkcs8_private_key", "x509_certificate", "x509_certificate_chain", "x509_issuer_chain"]))] secret_type: Option, + /// Typed output serialization, such as pem or der (0.22+). + #[serde(skip_serializing_if = "Option::is_none")] + format: Option, + /// Declared source secret for a conversion or extraction (0.22+). + #[serde(skip_serializing_if = "Option::is_none")] + from: Option, + /// Declared secrets bound to typed credential roles (0.22+). + #[serde(skip_serializing_if = "Option::is_none")] + credentials: Option>, /// Generate a missing secret with defaults or type-specific options. #[serde(skip_serializing_if = "Option::is_none")] generate: Option, @@ -2184,21 +2279,23 @@ impl ExtractFormat { } } -/// Selects one logical secret from a structured stored value. +/// Selects one logical secret from a structured text document. /// -/// Extraction is applied after [`SecretEncoding`] is decoded and only to -/// values read from providers or caches. Defaults and composed values are -/// already logical and are not extracted. +/// On a provider-backed secret, extraction is applied after [`SecretEncoding`] +/// is decoded and only to values read from providers or caches. On a secret +/// with [`from`](Secret::from) (0.22+), the named declared secret is resolved +/// first and its logical text becomes the extraction input. /// /// Available since SecretSpec 0.19. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct SecretExtract { - /// The structured-data format of the stored value. + /// The structured-data format of the document. pub format: ExtractFormat, /// Slash-delimited pointer selecting the logical value. JSON accepts a /// complete RFC 6901 JSON Pointer; INI accepts `/key` or `/section/key` /// with RFC 6901 escaping for each segment. + #[serde(default, skip_serializing_if = "String::is_empty")] pub pointer: String, } @@ -2211,6 +2308,74 @@ impl SecretExtract { } } +/// How a typed secret obtains one credential it needs (SecretSpec 0.22+). +/// +/// Written in a secret's `credentials` table keyed by the role its type +/// defines, with the bare name of another declared secret as the value: +/// +/// ```toml +/// TLS_PFX = { type = "pkcs12", from = "TLS_IDENTITY", credentials = { password = "TLS_PFX_PASSWORD" } } +/// ``` +/// +/// The named secret is an ordinary declaration: it resolves through its own +/// providers, default, prompt, or generator, joins the dependency graph, and is +/// stored with `secretspec set` like any other value. Table values are rejected +/// and reserved for a future provider-pinned source, so adding one later is +/// additive. +#[derive(Debug, Clone, PartialEq, Eq, JsonSchema)] +#[schemars(with = "String")] +#[non_exhaustive] +pub enum CredentialBinding { + /// A declared secret in the same profile. + Secret(String), +} + +impl CredentialBinding { + /// The declared secret this binding resolves through. + pub fn secret_name(&self) -> &str { + match self { + Self::Secret(name) => name, + } + } +} + +impl Serialize for CredentialBinding { + fn serialize(&self, serializer: S) -> Result { + match self { + Self::Secret(name) => serializer.serialize_str(name), + } + } +} + +impl<'de> Deserialize<'de> for CredentialBinding { + fn deserialize>(deserializer: D) -> Result { + struct Visitor; + + impl<'de> serde::de::Visitor<'de> for Visitor { + type Value = CredentialBinding; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("the name of a declared secret") + } + + fn visit_str(self, name: &str) -> Result { + Ok(CredentialBinding::Secret(name.to_string())) + } + + fn visit_map>( + self, + _map: M, + ) -> Result { + Err(serde::de::Error::custom( + "credential bindings name a declared secret, for example `password = \"PFX_PASSWORD\"`; the `{ provider, ref }` table form is reserved", + )) + } + } + + deserializer.deserialize_any(Visitor) + } +} + /// Validate the JSON Pointer grammar independently of any particular document. /// An empty pointer selects the whole document; every non-empty pointer starts /// with `/`, and `~` escapes only `~0` and `~1`. @@ -2305,18 +2470,47 @@ pub struct Secret { /// /// Available since SecretSpec 0.19. pub encoding: Option, - /// Structured stored-value extraction applied after optional decoding. - /// JSON extraction uses an RFC 6901 pointer. INI extraction (0.20+) uses - /// `/key` for an unsectioned key or `/section/key` for a named section, - /// with RFC 6901 segment escaping. Extracted secrets are read-only because - /// a selected value cannot reconstruct its containing document for a - /// storage write. + /// Structured extraction applied after optional decoding, or to the + /// logical text of the declared secret named by [`from`](Self::from) + /// (0.22+). JSON extraction uses an RFC 6901 pointer. INI extraction + /// (0.20+) uses `/key` for an unsectioned key or `/section/key` for a named + /// section, with RFC 6901 segment escaping. Extracted secrets are read-only + /// because a selected value cannot reconstruct its containing document for + /// a storage write. /// /// Available since SecretSpec 0.19. pub extract: Option, - /// The type of secret, used for generation. OpenPGP and OpenSSH private-key - /// generation are available in SecretSpec 0.21+. + /// The semantic secret type. OpenPGP and SSH private keys require 0.21+; + /// `passphrase`, `mnemonic`, `wireguard_private_key`, `jwk_private_key`, + /// `age_identity`, and `x509_identity` require SecretSpec 0.22+. + /// For the typed contracts in [`crate::typed`] (`x509_identity` and the + /// `pkcs12`, `pkcs8_private_key`, `x509_certificate`, + /// `x509_certificate_chain`, and `x509_issuer_chain` targets, 0.22+) the + /// type is authoritative: it decides decoding, validation, conversion, + /// accepted `credentials` roles, accepted `format` values, and the default + /// `encoding` of a binary value. pub secret_type: Option, + /// Serialization of a typed value whose type offers more than one, for + /// example `pem` or `der` for a `pkcs8_private_key`. Every such type has a + /// default, so this is normally written only to select DER. + /// + /// Available since SecretSpec 0.22. + pub format: Option, + /// Another declared secret whose logical value this secret derives from. + /// Combined with a derivable `type` it converts the source (an + /// `x509_identity` to a `pkcs12`, key, or certificate); combined with + /// `extract` it selects one field from the source's text. Derived secrets + /// are read-only and route to no provider. + /// + /// Available since SecretSpec 0.22. + pub from: Option, + /// Declared secrets that unlock or protect this typed value, keyed by the + /// role the type defines: the `password` that opens a stored + /// `x509_identity` archive, or the `password` that protects a derived + /// `pkcs12`. + /// + /// Available since SecretSpec 0.22. + pub credentials: Option>, /// Auto-generation configuration. Either `true` for defaults or a table with options. pub generate: Option, /// Prompt securely when the value is missing during `secretspec run`. @@ -2367,6 +2561,9 @@ impl TryFrom for Secret { encoding: value.encoding, extract: value.extract, secret_type: value.secret_type, + format: value.format, + from: value.from, + credentials: value.credentials, generate: value.generate, prompt: value.prompt, }) @@ -2396,6 +2593,9 @@ impl From for SecretSerde { encoding: value.encoding, extract: value.extract, secret_type: value.secret_type, + format: value.format, + from: value.from, + credentials: value.credentials, generate: value.generate, prompt: value.prompt, unknown_fields: BTreeMap::new(), @@ -2449,6 +2649,211 @@ impl Secret { self.generate.as_ref().is_some_and(|g| g.is_enabled()) } + /// The registry contract behind `type`, when the type is one the registry + /// governs. `None` for informational types and untyped secrets. + pub(crate) fn typed_contract(&self) -> Option<&'static crate::typed::TypeContract> { + self.secret_type.as_deref().and_then(crate::typed::contract) + } + + /// The effective serialization of a registry-typed value: the declared + /// `format` or the type's default. `None` when the type is not a registry + /// type, has a fixed representation, or the declared format is invalid + /// (which [`Self::validate`] rejects separately). + pub(crate) fn typed_format(&self) -> Option { + self.typed_contract() + .and_then(|contract| contract.format(self.format.as_deref()).ok()) + .flatten() + } + + /// Whether the logical value is bytes rather than text. Only registry + /// types are binary; an informational type with `encoding` still stores + /// text that happens to be encoded at rest. + pub(crate) fn is_binary_value(&self) -> bool { + self.typed_contract() + .is_some_and(|contract| contract.is_binary(self.typed_format())) + } + + /// The codec used wherever this value must be text: the declared + /// `encoding`, or Base64 for a binary registry type that declares none. + pub(crate) fn effective_encoding(&self) -> Option { + self.encoding.or_else(|| { + self.typed_contract() + .and_then(|contract| contract.default_encoding(self.typed_format())) + }) + } + + /// Preferred file suffix for `as_path` delivery, from the type registry. + pub(crate) fn file_suffix(&self) -> Option<&'static str> { + self.typed_contract() + .map(|contract| contract.suffix(self.typed_format())) + } + + /// The `(role, declared secret)` pairs bound through `credentials`. + pub(crate) fn credential_secrets(&self) -> impl Iterator { + self.credentials + .iter() + .flat_map(|bindings| bindings.iter()) + .map(|(role, binding)| (role.as_str(), binding.secret_name())) + } + + /// Whether the value is derived from other declared secrets rather than + /// stored: a `composed` template or a `from` source. + pub(crate) fn is_derived(&self) -> bool { + self.composed.is_some() || self.from.is_some() + } + + /// Rules for `from`, `format`, `credentials`, and the registry types + /// (0.22+). Every rule reads the type registry rather than matching type + /// names, so adding a contract cannot leave a rule behind. + fn validate_typed(&self) -> Result<(), String> { + let contract = self.typed_contract(); + + let format = match contract { + Some(contract) => contract.format(self.format.as_deref())?, + None => { + if self.format.is_some() { + return Err(match self.secret_type.as_deref() { + Some(name) => format!( + "`format` is not valid for type = \"{name}\"; only `pkcs8_private_key` and `x509_certificate` accept `pem` or `der`" + ), + None => "`format` requires a `type` that accepts formats: `pkcs8_private_key` or `x509_certificate`".into(), + }); + } + None + } + }; + + if let Some(bindings) = &self.credentials + && !bindings.is_empty() + { + let Some(contract) = contract else { + return Err(match self.secret_type.as_deref() { + Some(name) => format!( + "`credentials` is not valid for type = \"{name}\"; only `x509_identity` and `pkcs12` accept a `password` credential" + ), + None => "`credentials` requires a `type` that accepts credentials: `x509_identity` or `pkcs12`".into(), + }); + }; + for (role, binding) in bindings { + if !contract.accepts_role(role) { + return Err(if contract.roles.is_empty() { + format!( + "type = \"{}\" accepts no credentials; unknown role `{role}`", + contract.name + ) + } else { + format!( + "unknown credential role `{role}` for type = \"{}\"; expected {}", + contract.name, + contract + .roles + .iter() + .map(|role| format!("`{role}`")) + .collect::>() + .join(", ") + ) + }); + } + if !is_valid_identifier(binding.secret_name()) { + return Err(format!( + "credential `{role}` must name a declared secret using a valid identifier" + )); + } + } + if contract.is_stored() && self.would_generate() { + return Err(format!( + "`credentials` cannot be combined with enabled `generate` for type = \"{}\"; a generated identity is stored with an empty password and the provider is its boundary", + contract.name + )); + } + } + + match (&self.from, contract) { + (Some(source), _) => { + if !is_valid_identifier(source) { + return Err( + "`from` must name a declared secret using a valid identifier".into(), + ); + } + match (contract, &self.extract) { + (Some(contract), None) if !contract.is_stored() => {} + (Some(contract), None) => { + return Err(format!( + "type = \"{}\" is stored by a provider and cannot be derived with `from`", + contract.name + )); + } + (Some(_), Some(_)) => { + return Err( + "`from` either selects with `extract` or converts with a derivable `type`, not both" + .into(), + ); + } + (None, Some(_)) => { + if self.secret_type.is_some() { + return Err("`from` with `extract` cannot also set `type`".into()); + } + } + (None, None) => { + return Err(match self.secret_type.as_deref() { + Some(name) => format!( + "type = \"{name}\" cannot be derived with `from`; derivable types are {}", + crate::typed::derivable_type_names() + .map(|name| format!("`{name}`")) + .collect::>() + .join(", ") + ), + None => "`from` requires `extract` to select from the source or a derivable `type` to convert it".into(), + }); + } + } + if self.default.is_some() + || self.providers.is_some() + || self.reference.is_some() + || self.refs.is_some() + || self.would_generate() + || self.prompt == Some(true) + { + return Err( + "`from` secrets cannot also set `default`, `providers`, `ref`, `refs`, enabled `generate`, or `prompt = true`; the source declaration owns storage" + .into(), + ); + } + if self.encoding.is_some() + && !contract.is_some_and(|contract| contract.is_binary(format)) + { + return Err( + "`encoding` on a `from` secret is valid only when the result is binary: a `pkcs12` or a `der` format" + .into(), + ); + } + } + (None, Some(contract)) => { + if !contract.is_stored() { + return Err(format!( + "type = \"{}\" requires `from`; it is derived from a declared `x509_identity`", + contract.name + )); + } + if self.extract.is_some() { + return Err(format!( + "`extract` is not valid for stored type = \"{}\"; its value is a binary archive", + contract.name + )); + } + if self.default.is_some() || self.prompt == Some(true) { + return Err(format!( + "`type = \"{}\"` cannot be combined with `default` or `prompt = true`; its value is a binary archive", + contract.name + )); + } + } + (None, None) => {} + } + + Ok(()) + } + /// Whether this declaration supplies an individual or grouped requiredness /// policy. The three Rust fields serialize as one TOML field and therefore /// inherit as one unit. @@ -2503,16 +2908,20 @@ impl Secret { || self.encoding.is_some() || self.extract.is_some() || self.secret_type.is_some() + || self.format.is_some() + || self.from.is_some() + || self.credentials.is_some() || self.would_generate() || self.prompt == Some(true) { return Err( - "`composed` secrets cannot also set `default`, `providers`, `ref`, `refs`, `encoding`, `extract`, `type`, enabled `generate`, or `prompt = true`" + "`composed` secrets cannot also set `default`, `providers`, `ref`, `refs`, `encoding`, `extract`, `type`, `format`, `from`, `credentials`, enabled `generate`, or `prompt = true`" .into(), ); } } + self.validate_typed()?; if self.prompt == Some(true) { if self.required == Some(false) || self.at_least_one.is_some() @@ -2595,6 +3004,19 @@ impl Secret { return Err("'generate' and 'default' cannot both be set".into()); } + if let GenerateConfig::Options(opts) = gen_config + && self.secret_type.as_deref() != Some("x509_identity") + && (opts.issuer.is_some() + || opts.san.is_some() + || opts.usages.is_some() + || opts.valid_for.is_some()) + { + return Err( + "generate.issuer, generate.san, generate.usages, and generate.valid_for are only valid for type = \"x509_identity\"" + .into(), + ); + } + // type = "command" requires generate = { command = "..." } if self.secret_type.as_deref() == Some("command") { match gen_config { @@ -2634,9 +3056,19 @@ impl Secret { if user_id.chars().any(char::is_control) { return Err("generate.user_id cannot contain control characters".into()); } - if opts.comment.is_some() { + if opts.length.is_some() + || opts.bytes.is_some() + || opts.charset.is_some() + || opts.command.is_some() + || opts.comment.is_some() + || opts.words.is_some() + || opts.separator.is_some() + || opts.language.is_some() + || opts.kid.is_some() + { return Err( - "generate.comment is only valid for type = \"ssh_private_key\"".into(), + "openpgp_private_key generation accepts only `algorithm`, `bits`, `user_id`, and `capabilities` options" + .into(), ); } @@ -2695,9 +3127,19 @@ impl Secret { } }; if let Some(opts) = opts { - if opts.user_id.is_some() || opts.capabilities.is_some() { + if opts.length.is_some() + || opts.bytes.is_some() + || opts.charset.is_some() + || opts.command.is_some() + || opts.user_id.is_some() + || opts.capabilities.is_some() + || opts.words.is_some() + || opts.separator.is_some() + || opts.language.is_some() + || opts.kid.is_some() + { return Err( - "generate.user_id and generate.capabilities are only valid for type = \"openpgp_private_key\"" + "ssh_private_key generation accepts only `algorithm`, `bits`, and `comment` options" .into(), ); } @@ -2734,6 +3176,247 @@ impl Secret { } } + if self.secret_type.as_deref() == Some("passphrase") { + let opts = match gen_config { + GenerateConfig::Bool(true) => None, + GenerateConfig::Options(opts) => Some(opts), + GenerateConfig::Bool(false) => { + unreachable!("disabled generation handled above") + } + }; + if let Some(opts) = opts { + let words = opts.words.unwrap_or(PASSPHRASE_DEFAULT_WORDS); + if !(PASSPHRASE_MIN_WORDS..=PASSPHRASE_MAX_WORDS).contains(&words) { + return Err(format!( + "passphrase generate.words must be between {PASSPHRASE_MIN_WORDS} and {PASSPHRASE_MAX_WORDS}" + )); + } + if opts.separator.as_deref().is_some_and(|separator| { + separator.is_empty() || separator.chars().any(char::is_control) + }) { + return Err( + "passphrase generate.separator must be non-empty and contain no control characters" + .into(), + ); + } + if opts.length.is_some() + || opts.bytes.is_some() + || opts.charset.is_some() + || opts.command.is_some() + || opts.algorithm.is_some() + || opts.bits.is_some() + || opts.user_id.is_some() + || opts.capabilities.is_some() + || opts.comment.is_some() + || opts.kid.is_some() + || opts.language.is_some() + { + return Err( + "passphrase generation accepts only `words` and `separator` options" + .into(), + ); + } + } + } + + if self.secret_type.as_deref() == Some("mnemonic") { + let opts = match gen_config { + GenerateConfig::Bool(true) => None, + GenerateConfig::Options(opts) => Some(opts), + GenerateConfig::Bool(false) => { + unreachable!("disabled generation handled above") + } + }; + if let Some(opts) = opts { + let words = opts.words.unwrap_or(MNEMONIC_DEFAULT_WORDS); + if !MNEMONIC_WORD_COUNTS.contains(&words) { + return Err( + "mnemonic generate.words must be one of 12, 15, 18, 21, or 24".into(), + ); + } + if opts.algorithm.as_deref().unwrap_or("bip39") != "bip39" { + return Err("unknown mnemonic algorithm; expected `bip39`".into()); + } + if opts.language.as_deref().unwrap_or("english") != "english" { + return Err("unknown mnemonic language; expected `english`".into()); + } + if opts.length.is_some() + || opts.bytes.is_some() + || opts.charset.is_some() + || opts.command.is_some() + || opts.bits.is_some() + || opts.user_id.is_some() + || opts.capabilities.is_some() + || opts.comment.is_some() + || opts.separator.is_some() + || opts.kid.is_some() + { + return Err( + "mnemonic generation accepts only `algorithm`, `words`, and `language` options" + .into(), + ); + } + } + } + + if self.secret_type.as_deref() == Some("jwk_private_key") { + let opts = match gen_config { + GenerateConfig::Bool(true) => None, + GenerateConfig::Options(opts) => Some(opts), + GenerateConfig::Bool(false) => { + unreachable!("disabled generation handled above") + } + }; + if let Some(opts) = opts { + if opts.kid.as_deref().is_some_and(|kid| { + kid.trim().is_empty() || kid.chars().any(char::is_control) + }) { + return Err( + "JWK generate.kid must be non-empty and contain no control characters" + .into(), + ); + } + match opts.algorithm.as_deref().unwrap_or("ed25519") { + "ed25519" | "p256" => { + if opts.bits.is_some() { + return Err( + "generate.bits is only valid when generate.algorithm = \"rsa\"" + .into(), + ); + } + } + "rsa" => { + let bits = opts.bits.unwrap_or(JWK_RSA_DEFAULT_BITS); + if !(JWK_RSA_MIN_BITS..=JWK_RSA_MAX_BITS).contains(&bits) { + return Err( + "JWK RSA generate.bits must be between 2048 and 8192".into() + ); + } + } + algorithm => { + return Err(format!( + "unknown JWK algorithm '{algorithm}'; expected `ed25519`, `p256`, or `rsa`" + )); + } + } + if opts.length.is_some() + || opts.bytes.is_some() + || opts.charset.is_some() + || opts.command.is_some() + || opts.user_id.is_some() + || opts.capabilities.is_some() + || opts.comment.is_some() + || opts.words.is_some() + || opts.separator.is_some() + || opts.language.is_some() + { + return Err( + "JWK generation accepts only `algorithm`, `bits`, and `kid` options" + .into(), + ); + } + } + } + + if self.secret_type.as_deref() == Some("x509_identity") { + let opts = match gen_config { + GenerateConfig::Options(opts) => opts, + GenerateConfig::Bool(true) => { + return Err( + "type = \"x509_identity\" requires generate = { san = [\"dns:localhost\"] }" + .into(), + ); + } + GenerateConfig::Bool(false) => { + unreachable!("disabled generation handled above") + } + }; + if opts.issuer.as_deref().unwrap_or("self_signed") != "self_signed" { + return Err("unknown X.509 issuer; expected `self_signed`".into()); + } + if opts.algorithm.as_deref().unwrap_or("p256") != "p256" { + return Err("unknown X.509 algorithm; expected `p256`".into()); + } + let sans = opts + .san + .as_deref() + .ok_or_else(|| "type = \"x509_identity\" requires generate.san".to_string())?; + if sans.is_empty() || sans.len() > 100 { + return Err("X.509 generate.san must contain between 1 and 100 names".into()); + } + let mut seen = HashSet::new(); + for san in sans { + crate::x509_identity::validate_san(san)?; + if !seen.insert(san) { + return Err(format!("generate.san contains duplicate name '{san}'")); + } + } + if let Some(usages) = &opts.usages { + if usages.is_empty() { + return Err( + "X.509 generate.usages must contain `server_auth`, `client_auth`, or both" + .into(), + ); + } + let mut seen = HashSet::new(); + for usage in usages { + if !matches!(usage.as_str(), "server_auth" | "client_auth") { + return Err(format!( + "unknown X.509 usage '{usage}'; expected `server_auth` or `client_auth`" + )); + } + if !seen.insert(usage) { + return Err(format!( + "generate.usages contains duplicate usage '{usage}'" + )); + } + } + } + crate::x509_identity::parse_valid_days(opts.valid_for.as_deref())?; + if opts.length.is_some() + || opts.bytes.is_some() + || opts.charset.is_some() + || opts.command.is_some() + || opts.bits.is_some() + || opts.user_id.is_some() + || opts.capabilities.is_some() + || opts.comment.is_some() + || opts.words.is_some() + || opts.separator.is_some() + || opts.language.is_some() + || opts.kid.is_some() + { + return Err( + "X.509 generation accepts only `issuer`, `algorithm`, `san`, `usages`, and `valid_for` options" + .into(), + ); + } + } + + if matches!( + self.secret_type.as_deref(), + Some("wireguard_private_key" | "age_identity") + ) && let GenerateConfig::Options(opts) = gen_config + && (opts.length.is_some() + || opts.bytes.is_some() + || opts.charset.is_some() + || opts.command.is_some() + || opts.bits.is_some() + || opts.algorithm.is_some() + || opts.user_id.is_some() + || opts.capabilities.is_some() + || opts.comment.is_some() + || opts.words.is_some() + || opts.separator.is_some() + || opts.language.is_some() + || opts.kid.is_some()) + { + return Err(format!( + "{} generation does not accept options", + self.secret_type.as_deref().expect("matched type") + )); + } + // Validate known types if let Some(ref t) = self.secret_type { match t.as_str() { @@ -2744,7 +3427,14 @@ impl Secret { | "command" | "rsa_private_key" | "openpgp_private_key" - | "ssh_private_key" => {} + | "ssh_private_key" + | "passphrase" + | "mnemonic" + | "wireguard_private_key" + | "jwk_private_key" + | "age_identity" + | "x509_identity" => {} + typed if crate::typed::contract(typed).is_some() => {} unknown => { return Err(format!("unknown secret type '{}'", unknown)); } @@ -2765,7 +3455,14 @@ impl Secret { | "command" | "rsa_private_key" | "openpgp_private_key" - | "ssh_private_key" => {} + | "ssh_private_key" + | "passphrase" + | "mnemonic" + | "wireguard_private_key" + | "jwk_private_key" + | "age_identity" + | "x509_identity" => {} + typed if crate::typed::contract(typed).is_some() => {} unknown => { return Err(format!("unknown secret type '{}'", unknown)); } @@ -2821,13 +3518,15 @@ impl Secret { } else { (defaults.and_then(|d| d.required), None, None) }; - // A composed secret's source is its dependency graph, so neither the - // profile's storage defaults nor project default providers apply. - let storage_defaults = if composed.is_some() { None } else { defaults }; - let project_providers = if composed.is_none() { - project_defaults.map(|defaults| defaults.providers.clone()) - } else { + let extract = inherit(current, default, |s| s.extract.clone()); + let from = inherit(current, default, |s| s.from.clone()); + // Derived values do not inherit storage routes or defaults. + let derived = composed.is_some() || from.is_some(); + let storage_defaults = if derived { None } else { defaults }; + let project_providers = if derived { None + } else { + project_defaults.map(|defaults| defaults.providers.clone()) }; // `ref` and `refs` are two serialized forms of one address-model // setting. Select the pair from one profile entry so an explicit switch @@ -2841,6 +3540,14 @@ impl Secret { let (reference, refs) = reference_source.map_or((None, None), |secret| { (secret.reference.clone(), secret.refs.clone()) }); + // An explicitly empty table clears a binding inherited from the + // default profile. Keep this selection separate from `inherit`: using + // `or_else` would mistake the empty table for an absent override. + let credentials = match current.and_then(|secret| secret.credentials.as_ref()) { + Some(bindings) if bindings.is_empty() => None, + Some(bindings) => Some(bindings.clone()), + None => default.and_then(|secret| secret.credentials.clone()), + }; Some(Secret { description: inherit(current, default, |s| s.description.clone()), required, @@ -2856,8 +3563,11 @@ impl Secret { refs, as_path: inherit(current, default, |s| s.as_path), encoding: inherit(current, default, |s| s.encoding), - extract: inherit(current, default, |s| s.extract.clone()), + extract, secret_type: inherit(current, default, |s| s.secret_type.clone()), + format: inherit(current, default, |s| s.format.clone()), + from, + credentials, generate: inherit(current, default, |s| s.generate.clone()), prompt: inherit(current, default, |s| s.prompt), }) @@ -3844,6 +4554,107 @@ C = { description = "c", composed = "${A}" } assert!(cycle.contains("A -> B -> C -> A"), "{cycle}"); } + #[test] + fn from_sources_share_the_derived_dependency_graph() { + let parse = |body: &str| { + toml::from_str::(&format!( + r#" +[project] +name = "derived" +revision = "1.0" + +[profiles.default] +{body} +"# + )) + .unwrap() + }; + + parse( + r#" +TLS_IDENTITY = { description = "identity", type = "x509_identity" } +TLS_KEY = { description = "key", type = "pkcs8_private_key", from = "TLS_IDENTITY" } +TLS_CERTIFICATE = { description = "certificate", type = "x509_certificate", format = "der", from = "TLS_IDENTITY", as_path = true } +DOCUMENT = { description = "document" } +FIELD = { description = "field", from = "DOCUMENT", extract = { format = "json", pointer = "/a" } } +"#, + ) + .validate() + .unwrap(); + + let wrong_type = parse( + r#" +DOCUMENT = { description = "document" } +TLS_KEY = { description = "key", type = "pkcs8_private_key", from = "DOCUMENT" } +"#, + ) + .validate() + .unwrap_err() + .to_string(); + assert!( + wrong_type.contains("has no `type`, but type = \"pkcs8_private_key\" derives from type = \"x509_identity\""), + "{wrong_type}" + ); + + let cycle = parse( + r#" +A = { description = "a", from = "B", extract = { format = "json", pointer = "" } } +B = { description = "b", from = "A", extract = { format = "json", pointer = "" } } +"#, + ) + .validate() + .unwrap_err() + .to_string(); + assert!(cycle.contains("A -> B -> A"), "{cycle}"); + + let credential_cycle = parse( + r#" +TLS_IDENTITY = { description = "identity", type = "x509_identity" } +TLS_PFX = { description = "pfx", type = "pkcs12", from = "TLS_IDENTITY", credentials = { password = "PFX_PASSWORD" } } +PFX_PASSWORD = { description = "password", composed = "${TLS_PFX}" } +"#, + ) + .validate() + .unwrap_err() + .to_string(); + assert!( + credential_cycle.contains("derived secret cycle"), + "{credential_cycle}" + ); + assert!( + credential_cycle.contains("TLS_PFX -> PFX_PASSWORD") + || credential_cycle.contains("PFX_PASSWORD -> TLS_PFX"), + "{credential_cycle}" + ); + + let self_reference = parse( + r#" +A = { description = "a", from = "A", extract = { format = "json", pointer = "" } } +"#, + ) + .validate() + .unwrap_err() + .to_string(); + assert!( + self_reference.contains("cannot depend on itself"), + "{self_reference}" + ); + + let unknown = parse( + r#" +TLS_IDENTITY = { description = "identity", type = "x509_identity" } +TLS_PFX = { description = "pfx", type = "pkcs12", from = "TLS_IDENTITY", credentials = { password = "MISSING" } } +"#, + ) + .validate() + .unwrap_err() + .to_string(); + assert!( + unknown.contains("dependency 'MISSING' does not name a declared secret"), + "{unknown}" + ); + } + #[test] fn composed_rejects_operators_and_storage_sources() { let invalid: Config = toml::from_str( @@ -4159,6 +4970,30 @@ TOKEN = { generate = true } config.validate().unwrap(); } + #[test] + fn empty_credentials_override_clears_inherited_binding() { + let config: Config = toml::from_str( + r#" +[project] +name = "tmp" +revision = "1.0" + +[profiles.default] +PFX_PASSWORD = { description = "password" } +TLS_IDENTITY = { description = "identity", type = "x509_identity", credentials = { password = "PFX_PASSWORD" } } + +[profiles.production] +TLS_IDENTITY = { credentials = {}, generate = { san = ["dns:localhost"] } } +"#, + ) + .unwrap(); + + let compiled = config.validate_and_compile().unwrap(); + let identity = &compiled.profile("production").unwrap().secrets["TLS_IDENTITY"].config; + assert!(identity.credentials.is_none()); + assert!(identity.would_generate()); + } + #[test] fn config_validate_blames_default_profile_for_empty_inherited_description() { // The empty description lives in the default profile; the error must diff --git a/secretspec/src/error.rs b/secretspec/src/error.rs index f09cbf1aa..115229b1a 100644 --- a/secretspec/src/error.rs +++ b/secretspec/src/error.rs @@ -89,6 +89,14 @@ pub enum SecretSpecError { "Secret '{0}' uses `extract` and is read-only; update its containing document in the source provider instead" )] ExtractedSecretReadOnly(String), + #[error("Secret '{name}' is derived from '{from}' and is read-only; change '{from}' instead")] + DerivedSecretReadOnly { name: String, from: String }, + #[error("Secret '{name}': credential `{role}` {reason}")] + CredentialInvalid { + name: String, + role: String, + reason: String, + }, #[error("Failed to compose secret: {0}")] CompositionFailed(String), #[error("No secretspec.toml found in current or any parent directory")] @@ -184,6 +192,8 @@ impl SecretSpecError { SecretSpecError::ProducedValueWriteRefused(_) => "produced_value_write_refused", SecretSpecError::ComposedSecretReadOnly(_) => "composed_secret_read_only", SecretSpecError::ExtractedSecretReadOnly(_) => "extracted_secret_read_only", + SecretSpecError::DerivedSecretReadOnly { .. } => "derived_secret_read_only", + SecretSpecError::CredentialInvalid { .. } => "credential_invalid", SecretSpecError::CompositionFailed(_) => "composition_failed", SecretSpecError::NoManifest => "no_manifest", SecretSpecError::ExtendedConfigNotFound(_) => "extended_config_not_found", @@ -328,6 +338,13 @@ mod tests { SecretSpecError::ExtractedSecretReadOnly("X".into()), "extracted_secret_read_only", ), + ( + SecretSpecError::DerivedSecretReadOnly { + name: "X".into(), + from: "Y".into(), + }, + "derived_secret_read_only", + ), ( SecretSpecError::CompositionFailed("too large".into()), "composition_failed", diff --git a/secretspec/src/generator.rs b/secretspec/src/generator.rs index f3c06ba3b..a57b85943 100644 --- a/secretspec/src/generator.rs +++ b/secretspec/src/generator.rs @@ -1,25 +1,34 @@ //! Secret value generation //! //! This module provides generation of secret values based on type and configuration. -//! Supported types: password, hex, base64, uuid, command, rsa_private_key, -//! openpgp_private_key, ssh_private_key. +//! Supported types: password, passphrase, mnemonic, hex, base64, uuid, command, +//! rsa_private_key, openpgp_private_key, ssh_private_key, +//! wireguard_private_key, jwk_private_key, age_identity, and (through the +//! binary identity generator) x509_identity. use crate::SecretBytes; use crate::SecretSpecError; use crate::config::{ - GenerateConfig, OPENPGP_RSA_DEFAULT_BITS, OPENPGP_RSA_MAX_BITS, OPENPGP_RSA_MIN_BITS, + GenerateConfig, JWK_RSA_DEFAULT_BITS, JWK_RSA_MAX_BITS, JWK_RSA_MIN_BITS, + MNEMONIC_DEFAULT_WORDS, MNEMONIC_WORD_COUNTS, OPENPGP_RSA_DEFAULT_BITS, OPENPGP_RSA_MAX_BITS, + OPENPGP_RSA_MIN_BITS, PASSPHRASE_DEFAULT_WORDS, PASSPHRASE_MAX_WORDS, PASSPHRASE_MIN_WORDS, SSH_RSA_DEFAULT_BITS, SSH_RSA_MAX_BITS, SSH_RSA_MIN_BITS, }; -use data_encoding::{BASE64, HEXLOWER}; +use bip39::{Language, Mnemonic}; +use data_encoding::{BASE64, BASE64URL_NOPAD, HEXLOWER}; +use p256::elliptic_curve::sec1::ToEncodedPoint; use pgp::composed::{ ArmorOptions, EncryptionCaps, KeyType, SecretKeyParamsBuilder, SubkeyParamsBuilder, }; use pgp::crypto::{ecc_curve::ECCCurve, hash::HashAlgorithm, sym::SymmetricKeyAlgorithm}; use pgp::types::{CompressionAlgorithm, KeyVersion}; use rand::RngExt; -use rand_08::rngs::OsRng as OpenPgpOsRng; +use rand_08::RngCore as _; +use rand_08::rngs::OsRng as OsRng08; use rsa::RsaPrivateKey; use rsa::pkcs1::EncodeRsaPrivateKey; +use rsa::traits::{PrivateKeyParts, PublicKeyParts}; +use secrecy::zeroize::Zeroizing; use smallvec::smallvec; use ssh_key::private::{KeypairData as SshKeypairData, RsaKeypair as SshRsaKeypair}; use ssh_key::{ @@ -30,6 +39,8 @@ use ssh_key::{ pub fn generate(secret_type: &str, config: &GenerateConfig) -> crate::Result { match secret_type { "password" => generate_password(config), + "passphrase" => generate_passphrase(config), + "mnemonic" => generate_mnemonic(config), "hex" => generate_hex(config), "base64" => generate_base64(config), "uuid" => generate_uuid(), @@ -37,6 +48,9 @@ pub fn generate(secret_type: &str, config: &GenerateConfig) -> crate::Result generate_rsa(config), "openpgp_private_key" => generate_openpgp(config), "ssh_private_key" => generate_ssh(config), + "wireguard_private_key" => generate_wireguard(), + "jwk_private_key" => generate_jwk(config), + "age_identity" => generate_age_identity(), unknown => Err(SecretSpecError::GenerationFailed(format!( "unknown secret type '{}'", unknown @@ -44,6 +58,96 @@ pub fn generate(secret_type: &str, config: &GenerateConfig) -> crate::Result crate::Result<()> { + OsRng08.try_fill_bytes(bytes).map_err(|error| { + SecretSpecError::GenerationFailed(format!( + "operating-system randomness failed while generating {purpose}: {error}" + )) + }) +} + +fn protect_generated_string(value: String) -> SecretBytes { + // Copy into an exactly sized protected allocation, then wipe the temporary + // String. This avoids leaving a serialized private credential in a normal + // String allocation after returning it to the caller. + let value = Zeroizing::new(value); + SecretBytes::from_utf8(value.as_str()) +} + +fn generate_passphrase(config: &GenerateConfig) -> crate::Result { + let (words, separator) = match config { + GenerateConfig::Bool(_) => (PASSPHRASE_DEFAULT_WORDS, "-"), + GenerateConfig::Options(opts) => ( + opts.words.unwrap_or(PASSPHRASE_DEFAULT_WORDS), + opts.separator.as_deref().unwrap_or("-"), + ), + }; + if !(PASSPHRASE_MIN_WORDS..=PASSPHRASE_MAX_WORDS).contains(&words) { + return Err(SecretSpecError::GenerationFailed(format!( + "passphrase generate.words must be between {PASSPHRASE_MIN_WORDS} and {PASSPHRASE_MAX_WORDS}" + ))); + } + if separator.is_empty() || separator.chars().any(char::is_control) { + return Err(SecretSpecError::GenerationFailed( + "passphrase generate.separator must be non-empty and contain no control characters" + .to_string(), + )); + } + + // Seven independently selected BIP-39 English words provide 77 bits of + // entropy by default. This is a passphrase, not a BIP-39 mnemonic: there + // is deliberately no checksum or wallet-seed interpretation. + let wordlist = Language::English.word_list(); + let mut entropy = Zeroizing::new(vec![0_u8; words * 2]); + fill_os_random(&mut entropy, "passphrase")?; + let value = entropy + .chunks_exact(2) + // The word list has 2048 entries, which divides the 16-bit input + // domain exactly, so this reduction introduces no modulo bias. + .map(|bytes| { + let index = usize::from(u16::from_le_bytes([bytes[0], bytes[1]])) % wordlist.len(); + wordlist[index] + }) + .collect::>() + .join(separator); + Ok(protect_generated_string(value)) +} + +fn generate_mnemonic(config: &GenerateConfig) -> crate::Result { + let (algorithm, words, language) = match config { + GenerateConfig::Bool(_) => ("bip39", MNEMONIC_DEFAULT_WORDS, "english"), + GenerateConfig::Options(opts) => ( + opts.algorithm.as_deref().unwrap_or("bip39"), + opts.words.unwrap_or(MNEMONIC_DEFAULT_WORDS), + opts.language.as_deref().unwrap_or("english"), + ), + }; + if algorithm != "bip39" { + return Err(SecretSpecError::GenerationFailed(format!( + "unknown mnemonic algorithm '{algorithm}'; expected 'bip39'" + ))); + } + if language != "english" { + return Err(SecretSpecError::GenerationFailed(format!( + "unknown mnemonic language '{language}'; expected 'english'" + ))); + } + if !MNEMONIC_WORD_COUNTS.contains(&words) { + return Err(SecretSpecError::GenerationFailed( + "mnemonic generate.words must be one of 12, 15, 18, 21, or 24".to_string(), + )); + } + + // BIP-39 maps these word counts to 128, 160, 192, 224, or 256 bits of + // entropy respectively, then appends the checksum encoded by Mnemonic. + let mut entropy = Zeroizing::new(vec![0_u8; (words / 3) * 4]); + fill_os_random(&mut entropy, "BIP-39 mnemonic")?; + let mnemonic = Mnemonic::from_entropy_in(Language::English, &entropy).map_err(|error| { + SecretSpecError::GenerationFailed(format!("failed to encode BIP-39 mnemonic: {error}")) + })?; + Ok(protect_generated_string(mnemonic.to_string())) +} + fn generate_password(config: &GenerateConfig) -> crate::Result { let (length, charset_name) = match config { GenerateConfig::Bool(_) => (32, "alphanumeric"), @@ -137,6 +241,243 @@ fn generate_rsa(config: &GenerateConfig) -> crate::Result { Ok(SecretBytes::from_slice(pem.as_bytes())) } +/// Generates the Base64-encoded, clamped Curve25519 scalar accepted by +/// `wg(8)` as a WireGuard private key. +fn generate_wireguard() -> crate::Result { + let mut key = Zeroizing::new([0_u8; 32]); + fill_os_random(&mut key[..], "WireGuard private key")?; + key[0] &= 248; + key[31] &= 127; + key[31] |= 64; + Ok(protect_generated_string(BASE64.encode(&*key))) +} + +fn jwk_base64(bytes: &[u8]) -> String { + BASE64URL_NOPAD.encode(bytes) +} + +fn jwk_integer(bytes: &[u8]) -> String { + let first_nonzero = bytes + .iter() + .position(|byte| *byte != 0) + .unwrap_or(bytes.len()); + if first_nonzero == bytes.len() { + jwk_base64(&[0]) + } else { + jwk_base64(&bytes[first_nonzero..]) + } +} + +#[derive(serde::Serialize)] +struct OkpPrivateJwk<'a> { + kty: &'static str, + crv: &'static str, + alg: &'static str, + #[serde(rename = "use")] + usage: &'static str, + key_ops: [&'static str; 1], + x: String, + d: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + kid: Option<&'a str>, +} + +#[derive(serde::Serialize)] +struct EcPrivateJwk<'a> { + kty: &'static str, + crv: &'static str, + alg: &'static str, + #[serde(rename = "use")] + usage: &'static str, + key_ops: [&'static str; 1], + x: String, + y: String, + d: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + kid: Option<&'a str>, +} + +#[derive(serde::Serialize)] +struct RsaPrivateJwk<'a> { + kty: &'static str, + alg: &'static str, + #[serde(rename = "use")] + usage: &'static str, + key_ops: [&'static str; 1], + n: String, + e: String, + d: &'a str, + p: &'a str, + q: &'a str, + dp: &'a str, + dq: &'a str, + qi: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + kid: Option<&'a str>, +} + +/// Generates a private signing JWK. The output includes its public parameters +/// so it can be consumed directly by ordinary JOSE libraries. +fn generate_jwk(config: &GenerateConfig) -> crate::Result { + let opts = match config { + GenerateConfig::Bool(_) => None, + GenerateConfig::Options(opts) => Some(opts), + }; + let algorithm = opts + .and_then(|options| options.algorithm.as_deref()) + .unwrap_or("ed25519"); + let kid = opts.and_then(|options| options.kid.as_deref()); + if kid.is_some_and(|kid| kid.trim().is_empty() || kid.chars().any(char::is_control)) { + return Err(SecretSpecError::GenerationFailed( + "JWK generate.kid must be non-empty and contain no control characters".to_string(), + )); + } + + let encoded = match algorithm { + "ed25519" => { + if opts.is_some_and(|options| options.bits.is_some()) { + return Err(SecretSpecError::GenerationFailed( + "generate.bits is only valid when generate.algorithm = \"rsa\"".to_string(), + )); + } + let signing = ed25519_dalek::SigningKey::generate(&mut OsRng08); + let private = Zeroizing::new(jwk_base64(&signing.to_bytes())); + serde_json::to_string(&OkpPrivateJwk { + kty: "OKP", + crv: "Ed25519", + // RFC 9864 deprecates the polymorphic `EdDSA` identifier. + alg: "Ed25519", + usage: "sig", + key_ops: ["sign"], + x: jwk_base64(signing.verifying_key().as_bytes()), + d: private.as_str(), + kid, + }) + } + "p256" => { + if opts.is_some_and(|options| options.bits.is_some()) { + return Err(SecretSpecError::GenerationFailed( + "generate.bits is only valid when generate.algorithm = \"rsa\"".to_string(), + )); + } + let secret = p256::SecretKey::random(&mut OsRng08); + let point = secret.public_key().to_encoded_point(false); + let (x, y) = point.x().zip(point.y()).ok_or_else(|| { + SecretSpecError::GenerationFailed( + "generated P-256 JSON Web Key has no affine coordinates".to_string(), + ) + })?; + let private = Zeroizing::new(jwk_base64(&secret.to_bytes())); + serde_json::to_string(&EcPrivateJwk { + kty: "EC", + crv: "P-256", + alg: "ES256", + usage: "sig", + key_ops: ["sign"], + x: jwk_base64(x), + y: jwk_base64(y), + d: private.as_str(), + kid, + }) + } + "rsa" => { + let bits = opts + .and_then(|options| options.bits) + .unwrap_or(JWK_RSA_DEFAULT_BITS); + if !(JWK_RSA_MIN_BITS..=JWK_RSA_MAX_BITS).contains(&bits) { + return Err(SecretSpecError::GenerationFailed( + "JWK RSA generate.bits must be between 2048 and 8192".to_string(), + )); + } + let mut key = + RsaPrivateKey::new(&mut rsa::rand_core::OsRng, bits).map_err(|error| { + SecretSpecError::GenerationFailed(format!( + "failed to generate RSA JSON Web Key: {error}" + )) + })?; + key.precompute().map_err(|error| { + SecretSpecError::GenerationFailed(format!( + "failed to compute RSA JSON Web Key parameters: {error}" + )) + })?; + let primes = key.primes(); + let (p, q) = primes.first().zip(primes.get(1)).ok_or_else(|| { + SecretSpecError::GenerationFailed( + "generated RSA JSON Web Key has fewer than two primes".to_string(), + ) + })?; + let dp = key.dp().ok_or_else(|| { + SecretSpecError::GenerationFailed( + "generated RSA JSON Web Key has no first CRT exponent".to_string(), + ) + })?; + let dq = key.dq().ok_or_else(|| { + SecretSpecError::GenerationFailed( + "generated RSA JSON Web Key has no second CRT exponent".to_string(), + ) + })?; + let qi = key + .qinv() + .ok_or_else(|| { + SecretSpecError::GenerationFailed( + "generated RSA JSON Web Key has no CRT coefficient".to_string(), + ) + })? + .to_biguint() + .ok_or_else(|| { + SecretSpecError::GenerationFailed( + "failed to encode RSA JSON Web Key q inverse".to_string(), + ) + })?; + let d = Zeroizing::new(jwk_integer(&key.d().to_bytes_be())); + let p = Zeroizing::new(jwk_integer(&p.to_bytes_be())); + let q = Zeroizing::new(jwk_integer(&q.to_bytes_be())); + let dp = Zeroizing::new(jwk_integer(&dp.to_bytes_be())); + let dq = Zeroizing::new(jwk_integer(&dq.to_bytes_be())); + let qi = Zeroizing::new(jwk_integer(&qi.to_bytes_be())); + serde_json::to_string(&RsaPrivateJwk { + kty: "RSA", + alg: "RS256", + usage: "sig", + key_ops: ["sign"], + n: jwk_integer(&key.n().to_bytes_be()), + e: jwk_integer(&key.e().to_bytes_be()), + d: d.as_str(), + p: p.as_str(), + q: q.as_str(), + dp: dp.as_str(), + dq: dq.as_str(), + qi: qi.as_str(), + kid, + }) + } + algorithm => { + return Err(SecretSpecError::GenerationFailed(format!( + "unknown JWK algorithm '{algorithm}'; expected `ed25519`, `p256`, or `rsa`" + ))); + } + } + .map_err(|error| { + SecretSpecError::GenerationFailed(format!("failed to encode JSON Web Key: {error}")) + })?; + Ok(protect_generated_string(encoded)) +} + +fn generate_age_identity() -> crate::Result { + let mut scalar = Zeroizing::new([0_u8; 32]); + fill_os_random(&mut scalar[..], "age identity")?; + let secret = x25519_dalek::StaticSecret::from(*scalar); + let hrp = bech32::Hrp::parse("AGE-SECRET-KEY-").map_err(|error| { + SecretSpecError::GenerationFailed(format!("failed to configure age identity: {error}")) + })?; + let encoded = bech32::encode::(hrp, secret.as_bytes()) + .map_err(|error| { + SecretSpecError::GenerationFailed(format!("failed to encode age identity: {error}")) + })? + .to_uppercase(); + Ok(protect_generated_string(encoded)) +} + /// Generates a broadly interoperable OpenPGP v4 transferable secret key. /// /// The certification-only primary key is Ed25519. Requested signing and @@ -290,7 +631,7 @@ fn generate_openpgp(config: &GenerateConfig) -> crate::Result { "failed to configure OpenPGP private key: {error}" )) })? - .generate(OpenPgpOsRng) + .generate(OsRng08) .map_err(|error| { SecretSpecError::GenerationFailed(format!( "failed to generate OpenPGP private key: {error}" @@ -331,7 +672,7 @@ fn generate_ssh(config: &GenerateConfig) -> crate::Result { )); } - let mut rng = OpenPgpOsRng; + let mut rng = OsRng08; let mut key = match algorithm { "ed25519" => { if opts.is_some_and(|options| options.bits.is_some()) { @@ -496,6 +837,100 @@ mod tests { assert_eq!(value.try_as_utf8().unwrap().len(), 10000); } + #[test] + fn test_generate_passphrase_default_and_custom_separator() { + let value = generate("passphrase", &GenerateConfig::Bool(true)).unwrap(); + let words = value.try_as_utf8().unwrap().split('-').collect::>(); + assert_eq!(words.len(), PASSPHRASE_DEFAULT_WORDS); + assert!( + words + .iter() + .all(|word| Language::English.word_list().contains(word)) + ); + + let custom = generate( + "passphrase", + &GenerateConfig::Options(GenerateOptions { + words: Some(8), + separator: Some(".".to_string()), + ..Default::default() + }), + ) + .unwrap(); + assert_eq!(custom.try_as_utf8().unwrap().split('.').count(), 8); + } + + #[test] + fn test_generate_passphrase_rejects_unsafe_options() { + for config in [ + GenerateOptions { + words: Some(5), + ..Default::default() + }, + GenerateOptions { + separator: Some(String::new()), + ..Default::default() + }, + GenerateOptions { + separator: Some("\n".to_string()), + ..Default::default() + }, + ] { + assert!(generate("passphrase", &GenerateConfig::Options(config)).is_err()); + } + } + + #[test] + fn test_generate_mnemonic_default_is_valid_bip39() { + let first = generate("mnemonic", &GenerateConfig::Bool(true)).unwrap(); + let second = generate("mnemonic", &GenerateConfig::Bool(true)).unwrap(); + assert_ne!(first.expose_secret(), second.expose_secret()); + + let parsed = Mnemonic::parse_in_normalized(Language::English, first.try_as_utf8().unwrap()) + .expect("generated mnemonic must have a valid BIP-39 checksum"); + assert_eq!(parsed.word_count(), MNEMONIC_DEFAULT_WORDS); + } + + #[test] + fn test_generate_mnemonic_supports_every_bip39_word_count() { + for &words in MNEMONIC_WORD_COUNTS { + let value = generate( + "mnemonic", + &GenerateConfig::Options(GenerateOptions { + algorithm: Some("bip39".to_string()), + words: Some(words), + language: Some("english".to_string()), + ..Default::default() + }), + ) + .unwrap(); + let parsed = + Mnemonic::parse_in_normalized(Language::English, value.try_as_utf8().unwrap()) + .expect("generated mnemonic must have a valid BIP-39 checksum"); + assert_eq!(parsed.word_count(), words); + } + } + + #[test] + fn test_generate_mnemonic_rejects_invalid_options() { + for config in [ + GenerateOptions { + words: Some(13), + ..Default::default() + }, + GenerateOptions { + algorithm: Some("electrum".to_string()), + ..Default::default() + }, + GenerateOptions { + language: Some("spanish".to_string()), + ..Default::default() + }, + ] { + assert!(generate("mnemonic", &GenerateConfig::Options(config)).is_err()); + } + } + #[test] fn test_generate_hex_default() { let value = generate("hex", &GenerateConfig::Bool(true)).unwrap(); @@ -682,6 +1117,144 @@ mod tests { assert_ne!(v1.try_as_utf8().unwrap(), v2.try_as_utf8().unwrap()); } + #[test] + fn test_generate_wireguard_private_key() { + let first = generate("wireguard_private_key", &GenerateConfig::Bool(true)).unwrap(); + let second = generate("wireguard_private_key", &GenerateConfig::Bool(true)).unwrap(); + let decoded = BASE64.decode(first.expose_secret()).unwrap(); + assert_eq!(decoded.len(), 32); + assert_eq!(decoded[0] & 7, 0); + assert_eq!(decoded[31] & 0x80, 0); + assert_eq!(decoded[31] & 0x40, 0x40); + assert_ne!(first.expose_secret(), second.expose_secret()); + } + + fn parse_jwk(config: &GenerateConfig) -> serde_json::Value { + let value = generate("jwk_private_key", config).unwrap(); + serde_json::from_slice(value.expose_secret()).unwrap() + } + + fn decode_jwk_field(jwk: &serde_json::Value, field: &str) -> Vec { + BASE64URL_NOPAD + .decode(jwk[field].as_str().unwrap().as_bytes()) + .unwrap() + } + + #[test] + fn test_generate_ed25519_private_jwk() { + let jwk = parse_jwk(&GenerateConfig::Options(GenerateOptions { + kid: Some("release-2026".to_string()), + ..Default::default() + })); + assert_eq!(jwk["kty"], "OKP"); + assert_eq!(jwk["crv"], "Ed25519"); + assert_eq!(jwk["alg"], "Ed25519"); + assert_eq!(jwk["use"], "sig"); + assert_eq!(jwk["key_ops"], serde_json::json!(["sign"])); + assert_eq!(jwk["kid"], "release-2026"); + + let secret: [u8; 32] = decode_jwk_field(&jwk, "d").try_into().unwrap(); + let signing = ed25519_dalek::SigningKey::from_bytes(&secret); + assert_eq!( + signing.verifying_key().as_bytes(), + decode_jwk_field(&jwk, "x").as_slice() + ); + } + + #[test] + fn test_generate_p256_private_jwk() { + let jwk = parse_jwk(&GenerateConfig::Options(GenerateOptions { + algorithm: Some("p256".to_string()), + ..Default::default() + })); + assert_eq!(jwk["kty"], "EC"); + assert_eq!(jwk["crv"], "P-256"); + assert_eq!(jwk["alg"], "ES256"); + + let secret = p256::SecretKey::from_slice(&decode_jwk_field(&jwk, "d")).unwrap(); + let public = secret.public_key().to_encoded_point(false); + assert_eq!(public.x().unwrap().as_slice(), decode_jwk_field(&jwk, "x")); + assert_eq!(public.y().unwrap().as_slice(), decode_jwk_field(&jwk, "y")); + } + + #[test] + fn test_generate_rsa_private_jwk() { + let jwk = parse_jwk(&GenerateConfig::Options(GenerateOptions { + algorithm: Some("rsa".to_string()), + bits: Some(2048), + ..Default::default() + })); + assert_eq!(jwk["kty"], "RSA"); + assert_eq!(jwk["alg"], "RS256"); + for field in ["n", "e", "d", "p", "q", "dp", "dq", "qi"] { + assert!(!decode_jwk_field(&jwk, field).is_empty(), "{field}"); + } + let mut key = RsaPrivateKey::from_components( + rsa::BigUint::from_bytes_be(&decode_jwk_field(&jwk, "n")), + rsa::BigUint::from_bytes_be(&decode_jwk_field(&jwk, "e")), + rsa::BigUint::from_bytes_be(&decode_jwk_field(&jwk, "d")), + vec![ + rsa::BigUint::from_bytes_be(&decode_jwk_field(&jwk, "p")), + rsa::BigUint::from_bytes_be(&decode_jwk_field(&jwk, "q")), + ], + ) + .unwrap(); + key.validate().unwrap(); + key.precompute().unwrap(); + assert_eq!(jwk["dp"], jwk_integer(&key.dp().unwrap().to_bytes_be())); + assert_eq!(jwk["dq"], jwk_integer(&key.dq().unwrap().to_bytes_be())); + assert_eq!( + jwk["qi"], + jwk_integer(&key.qinv().unwrap().to_biguint().unwrap().to_bytes_be()) + ); + } + + #[test] + fn test_generate_jwk_rejects_invalid_profiles() { + for config in [ + GenerateOptions { + algorithm: Some("ed25519".to_string()), + bits: Some(3072), + ..Default::default() + }, + GenerateOptions { + algorithm: Some("rsa".to_string()), + bits: Some(1024), + ..Default::default() + }, + GenerateOptions { + algorithm: Some("secp256k1".to_string()), + ..Default::default() + }, + GenerateOptions { + kid: Some(" ".to_string()), + ..Default::default() + }, + ] { + assert!(generate("jwk_private_key", &GenerateConfig::Options(config)).is_err()); + } + } + + #[test] + fn test_generate_age_identity() { + let first = generate("age_identity", &GenerateConfig::Bool(true)).unwrap(); + let second = generate("age_identity", &GenerateConfig::Bool(true)).unwrap(); + assert!(first.expose_secret().starts_with(b"AGE-SECRET-KEY-1")); + let (hrp, bytes) = bech32::decode(first.try_as_utf8().unwrap()).unwrap(); + assert_eq!(hrp, bech32::Hrp::parse("AGE-SECRET-KEY-").unwrap()); + let secret = x25519_dalek::StaticSecret::from(<[u8; 32]>::try_from(bytes).unwrap()); + assert_ne!(x25519_dalek::PublicKey::from(&secret).as_bytes(), &[0; 32]); + #[cfg(feature = "age")] + assert!( + first + .try_as_utf8() + .unwrap() + .parse::() + .is_ok() + ); + assert_ne!(first.expose_secret(), second.expose_secret()); + } + fn openpgp_config( algorithm: Option<&str>, bits: Option, diff --git a/secretspec/src/lib.rs b/secretspec/src/lib.rs index 2b88831f4..46cf1ed7f 100644 --- a/secretspec/src/lib.rs +++ b/secretspec/src/lib.rs @@ -66,12 +66,14 @@ mod secrets; mod serve; mod spec; mod spec_edit; +pub(crate) mod typed; mod validation; /// Windows ACL helpers. Public only so the IPC conformance harness can build a /// directory the endpoint trust checks accept; not part of the stable API. #[cfg(windows)] #[doc(hidden)] pub mod windows_security; +mod x509_identity; pub(crate) mod provider; @@ -112,8 +114,8 @@ pub mod __private { // Public API exports pub use config::{ - CredentialSource, ExtractFormat, NativeAddress, NativeAddressTemplate, ProviderAlias, - ProviderCache, RequireReason, SecretEncoding, SecretExtract, + CredentialBinding, CredentialSource, ExtractFormat, NativeAddress, NativeAddressTemplate, + ProviderAlias, ProviderCache, RequireReason, SecretEncoding, SecretExtract, }; pub use error::{Result, SecretSpecError}; pub use native::{ @@ -131,7 +133,8 @@ pub use provider::{ providers, }; pub use report::{ - RESOLUTION_REPORT_SCHEMA_VERSION, ResolutionReport, ResolutionStatus, SecretResolution, + Derivation, RESOLUTION_REPORT_SCHEMA_VERSION, ResolutionReport, ResolutionStatus, + SecretResolution, }; pub use resolve::{ NamedResolution, RESOLVE_SCHEMA_VERSION, ResolveResponse, ResolvedSecret, ResolvedSource, @@ -141,10 +144,14 @@ pub use secrets::ExportFormat; pub use secrets::Secrets; pub use secretspec_ipc::Revision; pub use spec::{ - Generation, OpenPgpAlgorithm, OpenPgpCapability, PasswordCharset, Profile, Secret, Spec, - SpecBuilder, SshKeyAlgorithm, + Generation, JwkKeyAlgorithm, MnemonicAlgorithm, MnemonicLanguage, OpenPgpAlgorithm, + OpenPgpCapability, PasswordCharset, Profile, Secret, Spec, SpecBuilder, SshKeyAlgorithm, + X509Usage, }; +pub use typed::Format; pub use validation::{ConstraintKind, ConstraintViolation, ValidatedSecrets, ValidationErrors}; #[cfg(test)] mod tests; +#[cfg(test)] +mod typed_tests; diff --git a/secretspec/src/native.rs b/secretspec/src/native.rs index b30fec292..53e9bfc7c 100644 --- a/secretspec/src/native.rs +++ b/secretspec/src/native.rs @@ -195,6 +195,12 @@ struct InlineSecret { #[serde(default, rename = "type")] secret_type: Option, #[serde(default)] + format: Option, + #[serde(default)] + from: Option, + #[serde(default)] + credentials: Option>, + #[serde(default)] generate: Option, #[serde(default)] prompt: Option, @@ -241,7 +247,7 @@ where #[serde(untagged)] enum InlineGenerate { Bool(bool), - Options(InlineGenerateOptions), + Options(Box), } #[derive(Debug, Deserialize)] @@ -265,6 +271,22 @@ struct InlineGenerateOptions { capabilities: Option>, #[serde(default)] comment: Option, + #[serde(default)] + words: Option, + #[serde(default)] + separator: Option, + #[serde(default)] + language: Option, + #[serde(default)] + kid: Option, + #[serde(default)] + issuer: Option, + #[serde(default)] + san: Option>, + #[serde(default)] + usages: Option>, + #[serde(default)] + valid_for: Option, } impl InlineSpec { @@ -346,6 +368,9 @@ impl InlineSecret { encoding: self.encoding, extract: self.extract, secret_type: self.secret_type, + format: self.format, + from: self.from, + credentials: self.credentials, generate: self.generate.map(|generate| match generate { InlineGenerate::Bool(enabled) => GenerateConfig::Bool(enabled), InlineGenerate::Options(options) => GenerateConfig::Options(GenerateOptions { @@ -358,6 +383,14 @@ impl InlineSecret { user_id: options.user_id, capabilities: options.capabilities, comment: options.comment, + words: options.words, + separator: options.separator, + language: options.language, + kid: options.kid, + issuer: options.issuer, + san: options.san, + usages: options.usages, + valid_for: options.valid_for, }), }), prompt: self.prompt, @@ -674,4 +707,118 @@ mod tests { ); } } + + #[test] + fn inline_declaration_preserves_new_generation_options() { + let passphrase: InlineSecret = serde_json::from_str( + r#"{ + "description": "Recovery phrase", + "type": "passphrase", + "generate": { "words": 8, "separator": "." } + }"#, + ) + .unwrap(); + let Some(GenerateConfig::Options(options)) = passphrase.into_config().unwrap().generate + else { + panic!("expected passphrase generation options"); + }; + assert_eq!(options.words, Some(8)); + assert_eq!(options.separator.as_deref(), Some(".")); + + let mnemonic: InlineSecret = serde_json::from_str( + r#"{ + "description": "Wallet recovery mnemonic", + "type": "mnemonic", + "generate": { "algorithm": "bip39", "words": 24, "language": "english" } + }"#, + ) + .unwrap(); + let Some(GenerateConfig::Options(options)) = mnemonic.into_config().unwrap().generate + else { + panic!("expected mnemonic generation options"); + }; + assert_eq!(options.algorithm.as_deref(), Some("bip39")); + assert_eq!(options.words, Some(24)); + assert_eq!(options.language.as_deref(), Some("english")); + + let jwk: InlineSecret = serde_json::from_str( + r#"{ + "description": "Signing key", + "type": "jwk_private_key", + "generate": { "algorithm": "rsa", "bits": 4096, "kid": "release-2026" } + }"#, + ) + .unwrap(); + let Some(GenerateConfig::Options(options)) = jwk.into_config().unwrap().generate else { + panic!("expected JWK generation options"); + }; + assert_eq!(options.algorithm.as_deref(), Some("rsa")); + assert_eq!(options.bits, Some(4096)); + assert_eq!(options.kid.as_deref(), Some("release-2026")); + } + + #[test] + fn inline_spec_resolves_scoped_x509_conversions_with_hidden_credentials() { + use crate::x509_identity::test_support::{cert_from_pem, open_pfx}; + use data_encoding::BASE64; + use p12_keystore::Pkcs12Archive; + + let _env = crate::tests::scrub_resolution_env(); + let dir = tempfile::TempDir::new().unwrap(); + let mut request: serde_json::Value = serde_json::from_str(&inline_request( + INLINE_SPEC_SCHEMA_VERSION, + dir.path(), + serde_json::json!({ + "project": { "name": "inline-x509" }, + "defaults": { "providers": ["ephemeral"] }, + "providers": { "ephemeral": "null://" }, + "profiles": { "default": { "secrets": { + "IDENTITY": { + "description": "hidden identity", + "type": "x509_identity", + "generate": { "san": ["dns:localhost"] } + }, + "PASSWORD": { + "description": "hidden archive password", + "required": false, + "default": "archive-password" + }, + "CERT": { + "description": "certificate", + "type": "x509_certificate", + "format": "pem", + "from": "IDENTITY" + }, + "PFX": { + "description": "protected archive", + "type": "pkcs12", + "from": "IDENTITY", + "credentials": { "password": "PASSWORD" } + }, + "UNRELATED": { "description": "outside the scope" } + }}}, + "scopes": { "tls": { "secrets": ["CERT", "PFX"] } } + }), + )) + .unwrap(); + request["options"]["scope"] = serde_json::json!("tls"); + + let response = call(&request.to_string()); + assert_eq!(response["ok"], true, "envelope: {response}"); + let secrets = response["response"]["secrets"].as_object().unwrap(); + assert_eq!(secrets.len(), 2); + let certificate = + cert_from_pem(secrets["CERT"]["value"].as_str().unwrap().as_bytes()).unwrap(); + let archive = BASE64 + .decode(secrets["PFX"]["value"].as_str().unwrap().as_bytes()) + .unwrap(); + assert!(Pkcs12Archive::from_pkcs12(&archive, "").is_err()); + let (key, leaf) = open_pfx(&archive, "archive-password"); + assert_eq!(certificate.as_der(), leaf.as_der()); + let (_, parsed) = x509_parser::parse_x509_certificate(certificate.as_der()).unwrap(); + assert_eq!( + key.public_key_raw(), + parsed.public_key().subject_public_key.data.as_ref() + ); + } } diff --git a/secretspec/src/plan.rs b/secretspec/src/plan.rs index 07324d402..145ba0bdf 100644 --- a/secretspec/src/plan.rs +++ b/secretspec/src/plan.rs @@ -201,12 +201,16 @@ impl PlannedSecret { self.secret.config.as_path.unwrap_or(false) } - /// Optional encoding applied at the storage boundary. + /// The codec used wherever this value must be text: the declared + /// `encoding`, or the type registry's default for a binary value. For a + /// provider-backed secret that is the storage and cache boundary; for a + /// binary value it is also the inline exposure when `as_path` is off. pub(crate) fn encoding(&self) -> Option { - self.secret.config.encoding + self.secret.config.effective_encoding() } - /// Optional structured extraction applied at the storage boundary. + /// Optional structured extraction applied at the storage boundary or to + /// the `from` source's text. pub(crate) fn extract(&self) -> Option<&SecretExtract> { self.secret.config.extract.as_ref() } @@ -215,6 +219,43 @@ impl PlannedSecret { self.secret.composition.is_some() } + /// The declared secret this value is converted or selected from. + pub(crate) fn from(&self) -> Option<&str> { + self.secret.config.from.as_deref() + } + + /// Whether the value is produced by the executor from other declared + /// secrets rather than read from a store. + pub(crate) fn is_derived(&self) -> bool { + self.is_composed() || self.from().is_some() + } + + /// Whether this is a provider-backed registry type whose stored value is + /// decoded and validated by the type contract, possibly with credentials, + /// once every dependency has resolved. + pub(crate) fn is_typed_stored(&self) -> bool { + self.from().is_none() + && self + .secret + .config + .typed_contract() + .is_some_and(|contract| contract.is_stored()) + } + + /// Every declared secret that must resolve before this one. + pub(crate) fn dependencies(&self) -> Vec<&str> { + self.secret.dependencies() + } + + /// The declared secret bound to a credential role, if any. + pub(crate) fn credential(&self, role: &str) -> Option<&str> { + self.secret + .config + .credential_secrets() + .find(|(bound_role, _)| *bound_role == role) + .map(|(_, credential)| credential) + } + /// The parsed derived-value template, for composed secrets. pub(crate) fn composition(&self) -> Option<&Template> { self.secret.composition.as_ref() @@ -461,9 +502,9 @@ impl Secrets { override_spec: &Option, ) -> Result { self.validate_scoped_refs(&name, &secret.config)?; - // A composed secret's value is derived by the executor, so it routes + // A derived secret's value is produced by the executor, so it routes // to no store, including an explicit `--provider` override. - let route = if secret.composition.is_some() { + let route = if secret.composition.is_some() || secret.config.from.is_some() { None } else { Some(self.route_for(&secret.config, override_spec)?) diff --git a/secretspec/src/report.rs b/secretspec/src/report.rs index 5deb6ab49..638e6350e 100644 --- a/secretspec/src/report.rs +++ b/secretspec/src/report.rs @@ -61,15 +61,34 @@ pub struct SecretResolution { /// retains a generated value. A required secret backed by a store that keeps /// what it mints is reported `missing_required` until a pass provisions it. pub generated: bool, - /// Whether the value was derived from other declared secrets. + /// Whether the value was derived from other declared secrets. The field's + /// historical internal name covers templates, conversions, and selections. /// Internal provenance used by human-readable output and the value-carrying /// resolve response; omitted from the report v1 wire format. #[serde(skip)] pub composed: bool, + /// How a derived value was produced, for human-readable output. `None` + /// when the value is not derived. Omitted from the report wire format. + #[serde(skip)] + pub derivation: Option, /// Whether the value is materialized to a temp file and exposed as a path. pub as_path: bool, } +/// How a derived secret's value is produced from other declared secrets. +/// +/// Available since SecretSpec 0.22. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum Derivation { + /// Rendered from a `composed` template. + Composed, + /// Converted from the named `from` source by its `type`. + ConvertedFrom(String), + /// Selected from the named `from` source's text with `extract`. + SelectedFrom(String), +} + /// A complete, value-free snapshot of one resolution pass over a profile. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ResolutionReport { @@ -155,7 +174,15 @@ impl ResolutionReport { } else if s.default_applied { "ok default value".to_string() } else if s.composed { - "ok composed".to_string() + match &s.derivation { + Some(Derivation::ConvertedFrom(source)) => { + format!("ok converted from {source}") + } + Some(Derivation::SelectedFrom(source)) => { + format!("ok selected from {source}") + } + Some(Derivation::Composed) | None => "ok composed".to_string(), + } } else if let Some(uri) = &s.source_provider { format!("ok source {}", uri) } else { @@ -199,6 +226,7 @@ mod tests { default_applied: false, generated: false, composed: false, + derivation: None, as_path: false, }, SecretResolution { @@ -209,6 +237,7 @@ mod tests { default_applied: false, generated: false, composed: false, + derivation: None, as_path: false, }, SecretResolution { @@ -219,6 +248,7 @@ mod tests { default_applied: false, generated: true, composed: false, + derivation: None, as_path: false, }, SecretResolution { @@ -229,6 +259,7 @@ mod tests { default_applied: true, generated: false, composed: false, + derivation: None, as_path: false, }, SecretResolution { @@ -239,6 +270,7 @@ mod tests { default_applied: false, generated: false, composed: false, + derivation: None, as_path: false, }, ], @@ -300,6 +332,7 @@ mod tests { default_applied: false, generated: false, composed: false, + derivation: None, as_path: true, }], ); diff --git a/secretspec/src/resolve.rs b/secretspec/src/resolve.rs index b503b2975..4a3ed4099 100644 --- a/secretspec/src/resolve.rs +++ b/secretspec/src/resolve.rs @@ -38,9 +38,12 @@ pub enum ResolvedSource { Generated, /// The manifest's committed `default` value. Default, - /// Derived from other declared secrets using a strict template. + /// Derived from other declared secrets: a strict `composed` template, or a + /// `from` conversion or selection. The serialized name remains `composed` + /// for wire compatibility. /// - /// Available since SecretSpec 0.16. + /// Template composition is available since SecretSpec 0.16; `from` + /// derivation is available since SecretSpec 0.22. Composed, } diff --git a/secretspec/src/secrets.rs b/secretspec/src/secrets.rs index 4ac2596b2..05aeafa69 100644 --- a/secretspec/src/secrets.rs +++ b/secretspec/src/secrets.rs @@ -13,7 +13,7 @@ use crate::provider::{ Address, OwnedAddress, ProducedValuePersistence, Provider as ProviderTrait, ProviderCredentials, ProviderValue, same_storage_container, }; -use crate::report::{ResolutionReport, ResolutionStatus, SecretResolution}; +use crate::report::{Derivation, ResolutionReport, ResolutionStatus, SecretResolution}; use crate::resolve::{ NamedResolution, RESOLVE_SCHEMA_VERSION, ResolveResponse, ResolvedSecret, ResolvedSource, }; @@ -24,7 +24,8 @@ use colored::Colorize; use data_encoding::{ BASE64, BASE64_NOPAD, BASE64URL, BASE64URL_NOPAD, Encoding, HEXLOWER, HEXLOWER_PERMISSIVE, }; -use secrecy::ExposeSecret; +use secrecy::zeroize::Zeroizing; +use secrecy::{ExposeSecret, SecretSlice, SecretString}; #[cfg(unix)] use signal_hook::consts::signal::{SIGHUP, SIGINT, SIGTERM}; #[cfg(unix)] @@ -175,9 +176,9 @@ fn resolved_source(entry: &SecretResolution) -> ResolvedSource { /// Stands in for a secret's name in diagnostics when the active scope hides it. /// /// Every accessed-but-not-visible secret is by construction a dependency of a -/// visible composed secret, so this describes what it is without disclosing +/// visible derived secret, so this describes what it is without disclosing /// which secret it is. -pub(crate) const HIDDEN_SECRET_LABEL: &str = "a hidden composition input"; +pub(crate) const HIDDEN_SECRET_LABEL: &str = "a hidden derived-secret input"; /// Emits a warning when a provider in a fallback chain fails so the user /// can see why a particular link was skipped, without aborting the chain. @@ -1407,6 +1408,13 @@ struct ResolutionExecution<'secrets, 'plan, 'filter, 'addresses> { failed_primary_uris: HashMap, SecretSpecError>, cached_uris: HashMap, fallback_results: HashMap, + /// Registry-typed provider values parked by the provider pass until the + /// derived pass, where their credentials have resolved and the type + /// contract decodes them exactly once. + pending_typed: HashMap, + /// Decoded identities, keyed by secret name, for the projections that + /// derive from them in the same pass. + identities: HashMap, } impl<'secrets, 'plan, 'filter, 'addresses> @@ -1439,6 +1447,58 @@ impl<'secrets, 'plan, 'filter, 'addresses> failed_primary_uris: HashMap::new(), cached_uris: HashMap::new(), fallback_results: HashMap::new(), + pending_typed: HashMap::new(), + identities: HashMap::new(), + } + } + + /// Insert a value that crossed a storage boundary. A registry-typed stored + /// value is parked instead: its contract decodes it in the derived pass, + /// once the credentials it may need have resolved. + fn insert_stored( + &mut self, + planned: &PlannedSecret, + diagnostic_name: &str, + value: SecretBytes, + ) -> Result<()> { + if planned.is_typed_stored() { + self.pending_typed + .insert(planned.name.clone(), PendingTyped::Stored(value)); + return Ok(()); + } + self.manager.insert_resolved( + &mut self.values, + &mut self.temp_files, + planned, + diagnostic_name, + value, + ResolvedRepresentation::Stored, + ) + } + + /// Insert a freshly generated value. Text is already logical; generated + /// binary identities are parked like stored ones so one code path decodes, + /// validates, and delivers every typed value. + fn insert_generated( + &mut self, + planned: &PlannedSecret, + diagnostic_name: &str, + value: GeneratedValue, + ) -> Result<()> { + match value { + GeneratedValue::Text(value) => self.manager.insert_resolved( + &mut self.values, + &mut self.temp_files, + planned, + diagnostic_name, + value, + ResolvedRepresentation::Logical, + ), + GeneratedValue::Binary(bytes) => { + self.pending_typed + .insert(planned.name.clone(), PendingTyped::Logical(bytes)); + Ok(()) + } } } @@ -1460,7 +1520,7 @@ impl<'secrets, 'plan, 'filter, 'addresses> self.fetch_primary_values()?; self.fetch_fallback_values(); self.resolve_provider_backed_values()?; - self.resolve_composed_values()?; + self.resolve_derived_values()?; self.finish() } @@ -1658,14 +1718,7 @@ impl<'secrets, 'plan, 'filter, 'addresses> manager.write_cached_secret(planned, route, profile, &value); } if materialize.values() { - manager.insert_resolved( - &mut self.values, - &mut self.temp_files, - planned, - diagnostic_name, - value.value, - ResolvedRepresentation::Stored, - )?; + self.insert_stored(planned, diagnostic_name, value.value)?; } status = ResolutionStatus::Resolved; } @@ -1727,14 +1780,7 @@ impl<'secrets, 'plan, 'filter, 'addresses> source_provider = fallback_uri; if materialize.values() { manager.write_cached_secret(planned, route, profile, &value); - manager.insert_resolved( - &mut self.values, - &mut self.temp_files, - planned, - diagnostic_name, - value.value, - ResolvedRepresentation::Stored, - )?; + self.insert_stored(planned, diagnostic_name, value.value)?; } status = ResolutionStatus::Resolved; } else { @@ -1767,13 +1813,10 @@ impl<'secrets, 'plan, 'filter, 'addresses> let generated_value = manager .try_generate_secret(planned, profile)? .expect("compiled Generate policy has a generator"); - manager.insert_resolved( - &mut self.values, - &mut self.temp_files, + self.insert_generated( planned, diagnostic_name, generated_value, - ResolvedRepresentation::Logical, )?; status = ResolutionStatus::Resolved; } else if required @@ -1828,49 +1871,48 @@ impl<'secrets, 'plan, 'filter, 'addresses> default_applied, generated, composed: false, + derivation: None, as_path: planned.as_path(), }); } Ok(()) } - fn resolve_composed_values(&mut self) -> Result<()> { - fn composition_order<'a>( + /// Resolve every value the executor produces itself, in one topological + /// pass: composed templates, `from` conversions and selections, and the + /// typed decoding of provider-backed registry values whose credentials had + /// to resolve first. A node runs after its dependencies, so declaration + /// order never matters and a typed value is decoded exactly once. + fn resolve_derived_values(&mut self) -> Result<()> { + fn order<'a>( planned: &'a PlannedSecret, - composed: &HashMap<&str, &'a PlannedSecret>, + nodes: &HashMap<&str, &'a PlannedSecret>, visited: &mut HashSet<&'a str>, ordered: &mut Vec<&'a PlannedSecret>, ) { if !visited.insert(planned.name.as_str()) { return; } - let template = planned - .composition() - .expect("only composed nodes are ordered"); - for dependency in template.dependencies() { - if let Some(dependency) = composed.get(dependency.as_str()) { - composition_order(dependency, composed, visited, ordered); + for dependency in planned.dependencies() { + if let Some(dependency) = nodes.get(dependency) { + order(dependency, nodes, visited, ordered); } } ordered.push(planned); } - let composed: HashMap<&str, &PlannedSecret> = self + let is_node = |secret: &PlannedSecret| secret.is_derived() || secret.is_typed_stored(); + let nodes: HashMap<&str, &PlannedSecret> = self .plan .secrets .iter() - .filter(|secret| secret.is_composed()) + .filter(|secret| is_node(secret)) .map(|secret| (secret.name.as_str(), secret)) .collect(); - let mut ordered = Vec::with_capacity(composed.len()); + let mut ordered = Vec::with_capacity(nodes.len()); let mut visited = HashSet::new(); - for planned in self - .plan - .secrets - .iter() - .filter(|secret| secret.is_composed()) - { - composition_order(planned, &composed, &mut visited, &mut ordered); + for planned in self.plan.secrets.iter().filter(|secret| is_node(secret)) { + order(planned, &nodes, &mut visited, &mut ordered); } if ordered.is_empty() { @@ -1883,55 +1925,94 @@ impl<'secrets, 'plan, 'filter, 'addresses> .map(|entry| (entry.name.clone(), entry.status.clone())) .collect(); for planned in ordered { - let template = planned - .composition() - .expect("only composed nodes are ordered"); - let dependencies_resolved = template + let dependencies_resolved = planned .dependencies() .iter() - .all(|dependency| statuses.get(dependency) == Some(&ResolutionStatus::Resolved)); - let status = if dependencies_resolved { - if let Some(expiry) = template - .dependencies() - .iter() - .filter_map(|name| self.secret_expiries.get(name)) - .copied() - .min() - { - self.secret_expiries.insert(planned.name.clone(), expiry); + .all(|dependency| statuses.get(*dependency) == Some(&ResolutionStatus::Resolved)); + + if dependencies_resolved { + // Stored typed values depend on credentials just as derived + // values depend on their inputs. Preserve the earlier of the + // value's own deadline and every dependency's deadline. + for deadlines in [&mut self.secret_expiries, &mut self.refreshes] { + if let Some(deadline) = planned + .dependencies() + .iter() + .filter_map(|name| deadlines.get(*name)) + .copied() + .min() + { + deadlines + .entry(planned.name.clone()) + .and_modify(|existing| *existing = (*existing).min(deadline)) + .or_insert(deadline); + } } - if let Some(refresh) = template - .dependencies() - .iter() - .filter_map(|name| self.refreshes.get(name)) - .copied() - .min() - { - self.refreshes.insert(planned.name.clone(), refresh); + } + + if planned.is_typed_stored() { + // The provider pass already recorded this secret. It stays + // resolved only while its credentials did: an archive whose + // password is missing cannot be opened, so the secret is + // missing under its own policy rather than exposed unusable. + let fetched = + statuses.get(planned.name.as_str()) == Some(&ResolutionStatus::Resolved); + if fetched && dependencies_resolved { + if self.materialize.values() { + self.finalize_typed_stored(planned)?; + } + } else if fetched { + self.pending_typed.remove(planned.name.as_str()); + let status = match planned.secret.missing { + MissingPolicy::Omit => { + self.missing_optional.push(planned.name.clone()); + ResolutionStatus::MissingOptional + } + _ => { + self.missing_required.push(planned.name.clone()); + ResolutionStatus::MissingRequired + } + }; + if let Some(entry) = self + .resolution + .iter_mut() + .find(|entry| entry.name == planned.name) + { + entry.status = status.clone(); + } + statuses.insert(planned.name.clone(), status); } + continue; + } + + let status = if dependencies_resolved { if self.materialize.values() { - let inputs = template - .dependencies() - .iter() - .filter_map(|name| self.values.get(name).map(|value| (name, value))) - .map(|(name, value)| { - let diagnostic_name = - Secrets::diagnostic_secret_name(name, self.output_filter); - Self::text_input(diagnostic_name, value) - .map(|text| (name.as_str(), text)) - }) - .collect::>>()?; - let rendered = template - .render(|dependency| inputs.get(dependency).copied()) - .map_err(SecretSpecError::CompositionFailed)?; - self.manager.insert_resolved( - &mut self.values, - &mut self.temp_files, - planned, - Secrets::diagnostic_secret_name(&planned.name, self.output_filter), - SecretBytes::from_utf8(rendered), - ResolvedRepresentation::Logical, - )?; + if let Some(template) = planned.composition() { + let inputs = template + .dependencies() + .iter() + .filter_map(|name| self.values.get(name).map(|value| (name, value))) + .map(|(name, value)| { + let diagnostic_name = + Secrets::diagnostic_secret_name(name, self.output_filter); + Self::text_input(diagnostic_name, value) + .map(|text| (name.as_str(), text)) + }) + .collect::>>()?; + let rendered = template + .render(|dependency| inputs.get(dependency).copied()) + .map_err(SecretSpecError::CompositionFailed)?; + self.manager.insert_resolved( + &mut self.values, + &mut self.temp_files, + planned, + Secrets::diagnostic_secret_name(&planned.name, self.output_filter), + SecretBytes::from_utf8(rendered), + ResolvedRepresentation::Logical, + )?; + } else { + self.resolve_from(planned)?; + } } ResolutionStatus::Resolved } else { @@ -1945,11 +2026,23 @@ impl<'secrets, 'plan, 'filter, 'addresses> ResolutionStatus::MissingOptional } MissingPolicy::Generate | MissingPolicy::UseDefault | MissingPolicy::Prompt => { - unreachable!("composed source conflicts are rejected at load time") + unreachable!("derived source conflicts are rejected at load time") } } }; + let derivation = if planned.is_composed() { + Derivation::Composed + } else if let Some(source) = planned.from() { + let source = Secrets::diagnostic_secret_name(source, self.output_filter); + if planned.extract().is_some() { + Derivation::SelectedFrom(source.to_string()) + } else { + Derivation::ConvertedFrom(source.to_string()) + } + } else { + unreachable!("derived nodes are composed or converted") + }; statuses.insert(planned.name.clone(), status.clone()); self.resolution.push(SecretResolution { name: planned.name.clone(), @@ -1959,6 +2052,7 @@ impl<'secrets, 'plan, 'filter, 'addresses> default_applied: false, generated: false, composed: true, + derivation: Some(derivation), as_path: planned.as_path(), }); } @@ -1973,6 +2067,184 @@ impl<'secrets, 'plan, 'filter, 'addresses> }) } + /// The value bound to one credential role of a typed secret, validated + /// against the bounds every credential shares. `None` when the role is + /// unbound, which the type contract treats as an empty credential. + fn credential_value( + &self, + planned: &PlannedSecret, + role: &str, + ) -> Result> { + let Some(credential) = planned.credential(role) else { + return Ok(None); + }; + let diagnostic_name = Secrets::diagnostic_secret_name(&planned.name, self.output_filter); + let value = + self.values + .get(credential) + .ok_or_else(|| SecretSpecError::CredentialInvalid { + name: diagnostic_name.to_string(), + role: role.to_string(), + reason: format!( + "depends on '{}', which did not resolve", + Secrets::diagnostic_secret_name(credential, self.output_filter) + ), + })?; + crate::typed::validate_credential_value( + role, + value + .try_as_utf8() + .map_err(|_| SecretSpecError::CredentialInvalid { + name: diagnostic_name.to_string(), + role: role.to_string(), + reason: "credential is not valid UTF-8".to_string(), + })?, + ) + .map_err(|reason| SecretSpecError::CredentialInvalid { + name: diagnostic_name.to_string(), + role: role.to_string(), + reason, + })?; + Ok(Some(SecretString::new( + value.try_as_utf8().expect("validated credential").into(), + ))) + } + + /// Deliver a binary logical value: a file holding the raw bytes with the + /// type's suffix when `as_path`, otherwise inline text in the value's + /// encoding. This is the delivery contract for registry-typed binary + /// values, stored or derived. + fn deliver_bytes(&mut self, planned: &PlannedSecret, bytes: &[u8]) -> Result<()> { + if planned.as_path() { + let (owner, path) = self + .manager + .write_secret_to_temp_file(bytes, planned.config().file_suffix())?; + self.temp_files.push(owner); + self.values + .insert(planned.name.clone(), SecretBytes::from_utf8(path)); + } else { + let encoding = planned + .encoding() + .expect("binary values always carry an encoding"); + self.values.insert( + planned.name.clone(), + Secrets::encode_logical_bytes(encoding, bytes), + ); + } + Ok(()) + } + + /// Decode and validate a parked registry-typed value with the credentials + /// bound to it, deliver its canonical bytes, and keep the decoded identity + /// for the projections that derive from it. + fn finalize_typed_stored(&mut self, planned: &PlannedSecret) -> Result<()> { + let diagnostic_name = Secrets::diagnostic_secret_name(&planned.name, self.output_filter); + let pending = self + .pending_typed + .remove(planned.name.as_str()) + .expect("a resolved typed stored secret parked its value"); + let bytes: SecretSlice = match pending { + PendingTyped::Stored(value) => { + let encoding = planned + .encoding() + .expect("binary stored types always carry an encoding"); + SecretSlice::from( + Secrets::decode_stored_value(encoding, diagnostic_name, &value)?.to_vec(), + ) + } + PendingTyped::Logical(bytes) => bytes, + }; + let password = self.credential_value(planned, "password")?; + let identity = crate::x509_identity::Identity::decode( + bytes.expose_secret(), + password.as_ref().map(|password| password.expose_secret()), + diagnostic_name, + )?; + self.deliver_bytes(planned, bytes.expose_secret())?; + self.identities.insert(planned.name.clone(), identity); + Ok(()) + } + + /// Produce a `from` secret: select a field from the source's text with + /// `extract`, or convert the source identity into the target type. + fn resolve_from(&mut self, planned: &PlannedSecret) -> Result<()> { + let source_name = planned.from().expect("a `from` secret names its source"); + let diagnostic_name = Secrets::diagnostic_secret_name(&planned.name, self.output_filter); + + if let Some(extract) = planned.extract() { + let source_planned = self + .plan + .secrets + .iter() + .find(|candidate| candidate.name == source_name) + .expect("validated `from` source is in the resolution plan"); + let source = self + .values + .get(source_name) + .expect("a resolved `from` source has a value"); + let not_utf8 = |error: std::str::Utf8Error| SecretSpecError::DecodeFailed { + name: diagnostic_name.to_string(), + encoding: extract.format.as_str(), + reason: format!("source value is not valid UTF-8: {error}"), + }; + let text = if source_planned.as_path() { + let bytes = Zeroizing::new(std::fs::read( + source.try_as_utf8().expect("materialized path is UTF-8"), + )?); + SecretBytes::from_utf8(std::str::from_utf8(&bytes).map_err(not_utf8)?) + } else { + source.clone() + }; + let value = Secrets::extract_stored_value( + extract, + diagnostic_name, + std::str::from_utf8(text.expose_secret()).map_err(not_utf8)?, + )?; + return self.manager.insert_resolved( + &mut self.values, + &mut self.temp_files, + planned, + diagnostic_name, + value, + ResolvedRepresentation::Logical, + ); + } + + let contract = planned + .config() + .typed_contract() + .expect("a converting `from` secret has a registry type"); + let projection = contract + .projection + .expect("derivable registry types define a projection"); + let password = self.credential_value(planned, "password")?; + let projected = { + let identity = self + .identities + .get(source_name) + .expect("an x509_identity source is decoded before its projections"); + identity.project( + projection, + planned.config().typed_format(), + password.as_ref().map(|password| password.expose_secret()), + diagnostic_name, + )? + }; + match projected { + crate::x509_identity::ProjectedValue::Text(value) => self.manager.insert_resolved( + &mut self.values, + &mut self.temp_files, + planned, + diagnostic_name, + SecretBytes::from_utf8(value.expose_secret()), + ResolvedRepresentation::Logical, + ), + crate::x509_identity::ProjectedValue::Binary(bytes) => { + self.deliver_bytes(planned, bytes.expose_secret()) + } + } + } + fn apply_output_filter(&mut self) { let Some(filter) = self.output_filter else { return; @@ -2219,6 +2491,20 @@ fn keep_owned_files(files: Vec) -> Result<()> { Ok(()) } +/// A freshly generated value before the provider's textual storage boundary. +enum GeneratedValue { + Text(SecretBytes), + Binary(SecretSlice), +} + +/// A registry-typed value waiting for the derived pass to decode it: either +/// the text read from a store, still in its storage encoding, or the logical +/// bytes a generator just produced. +enum PendingTyped { + Stored(SecretBytes), + Logical(SecretSlice), +} + /// Whether a resolved string came from a storage boundary and is eligible for /// decoding, or is already the logical value produced inside SecretSpec. #[derive(Clone, Copy)] @@ -3855,7 +4141,10 @@ impl Secrets { /// Encode logical bytes into the canonical stored representation. fn encode_logical_value(encoding: SecretEncoding, value: &SecretBytes) -> SecretBytes { - let bytes = value.expose_secret(); + Self::encode_logical_bytes(encoding, value.expose_secret()) + } + + fn encode_logical_bytes(encoding: SecretEncoding, bytes: &[u8]) -> SecretBytes { let encoded = match encoding { SecretEncoding::Base64 => BASE64.encode(bytes), SecretEncoding::Base64Url => BASE64URL_NOPAD.encode(bytes), @@ -3872,6 +4161,32 @@ impl Secrets { .map(|encoding| Self::encode_logical_value(encoding, value)) } + /// Convert operator input into the provider representation. Binary typed + /// values are entered in their encoded form, so decode them before + /// validating and re-encoding canonically. Text values are logical input + /// and only need their configured storage encoding applied. + fn prompted_value_for_storage( + planned: &PlannedSecret, + diagnostic_name: &str, + value: &SecretBytes, + ) -> Result> { + if !planned.is_typed_stored() { + return Ok(Self::encoded_for_storage(planned, value)); + } + + let encoding = planned + .encoding() + .expect("binary stored types always carry an encoding"); + let bytes = Self::decode_stored_value(encoding, diagnostic_name, value)?; + if planned.credential("password").is_none() { + crate::x509_identity::validate(bytes.expose_secret(), diagnostic_name)?; + } + Ok(Some(Self::encode_logical_bytes( + encoding, + bytes.expose_secret(), + ))) + } + /// Validate a stored value before import copies it into another provider. /// Import moves the stored representation verbatim, so an invalid encoded /// source must fail before any target write (and especially before source @@ -3882,9 +4197,20 @@ impl Secrets { diagnostic_name: &str, value: &SecretBytes, ) -> Result<()> { - if let Some(encoding) = planned.encoding() { - Self::decode_stored_value(encoding, diagnostic_name, value)?; + let Some(encoding) = planned.encoding() else { + return Ok(()); + }; + let decoded = Self::decode_stored_value(encoding, diagnostic_name, value)?; + if planned.is_typed_stored() { + // Import copies stored bytes without resolving the profile, so an + // archive that a bound credential unlocks can only be checked for + // its encoding here; an unprotected one is validated in full. + if planned.credential("password").is_none() { + crate::x509_identity::validate(decoded.expose_secret(), diagnostic_name)?; + } + return Ok(()); } + {} Ok(()) } @@ -3985,7 +4311,8 @@ impl Secrets { .map(|value| value.expose_secret()) .or_else(|| decoded.as_ref().map(|(_, decoded)| decoded.expose_secret())) .unwrap_or_else(|| value.expose_secret()); - let (owner, path) = self.write_secret_to_temp_file(bytes)?; + let (owner, path) = + self.write_secret_to_temp_file(bytes, planned.config().file_suffix())?; Ok(PreparedSecret::File { owner, path }) } else { Ok(PreparedSecret::Inline( @@ -4286,10 +4613,8 @@ impl Secrets { if !acc.insert(name.to_string()) { return; } - if let Some(secret) = profile.secrets.get(name) - && let Some(template) = &secret.composition - { - for dependency in template.dependencies() { + if let Some(secret) = profile.secrets.get(name) { + for dependency in secret.dependencies() { visit(dependency, profile, acc); } } @@ -5217,7 +5542,7 @@ impl Secrets { let Some(route) = &planned.route else { if named { return Err(SecretSpecError::ProviderOperationFailed(format!( - "secret '{name}' is composed and has no provider cache" + "secret '{name}' is derived and has no provider cache" ))); } continue; @@ -5284,7 +5609,7 @@ impl Secrets { /// Stores a UTF-8 secret value. Available starting with SecretSpec 0.21. pub fn set_text(&self, name: &str, value: &str) -> Result<()> { - self.set(name, SecretBytes::from_utf8(value)) + self.set_with_input_format(name, |_| Ok(SecretBytes::from_utf8(value)), true) } /// Prompts for a textual secret value and stores it. Available starting @@ -5295,30 +5620,35 @@ impl Secrets { /// stores `value`. Use [`Self::set`] or `secretspec set --from-file` to /// store exact bytes. pub fn prompt_and_set(&self, name: &str) -> Result<()> { - self.set_with_input(name, |profile_name| { - if io::stdin().is_terminal() { - let secret = inquire::Password::new(&format!( - "Enter value for {name} (profile: {profile_name}):" - )) - .without_confirmation() - .prompt()?; - Ok(SecretBytes::from_utf8(secret)) - } else { - // Read from stdin when input is piped - let mut buffer = Vec::new(); - io::stdin().read_to_end(&mut buffer)?; - let text = SecretBytes::from_vec(buffer); - let text = - text.try_as_utf8_for(name) - .map_err(|_| SecretSpecError::SecretNotText { - name: name.to_string(), - reason: "piped input is not valid UTF-8; use `--from-file -` to store \ + self.set_with_input_format( + name, + |profile_name| { + if io::stdin().is_terminal() { + let secret = inquire::Password::new(&format!( + "Enter value for {name} (profile: {profile_name}):" + )) + .without_confirmation() + .prompt()?; + Ok(SecretBytes::from_utf8(secret)) + } else { + // Read from stdin when input is piped + let mut buffer = Vec::new(); + io::stdin().read_to_end(&mut buffer)?; + let text = SecretBytes::from_vec(buffer); + let text = + text.try_as_utf8_for(name) + .map_err(|_| SecretSpecError::SecretNotText { + name: name.to_string(), + reason: + "piped input is not valid UTF-8; use `--from-file -` to store \ exact bytes" - .to_string(), - })?; - Ok(SecretBytes::from_utf8(text.trim())) - } - }) + .to_string(), + })?; + Ok(SecretBytes::from_utf8(text.trim())) + } + }, + true, + ) } /// Requests the value only after validating and reporting the write destination. @@ -5327,7 +5657,16 @@ impl Secrets { name: &str, input: impl FnOnce(&str) -> Result, ) -> Result<()> { - let stored = self.store_secret_with_input(name, input)?; + self.set_with_input_format(name, input, false) + } + + fn set_with_input_format( + &self, + name: &str, + input: impl FnOnce(&str) -> Result, + encoded_typed_input: bool, + ) -> Result<()> { + let stored = self.store_secret_with_input(name, input, encoded_typed_input)?; eprintln!( "{} Secret '{}' saved to {} (profile: {})", "✓".green(), @@ -5342,6 +5681,7 @@ impl Secrets { &self, name: &str, input: impl FnOnce(&str) -> Result, + encoded_typed_input: bool, ) -> Result { self.ensure_reason_for(AuditAction::Set, Some(name))?; // Check if the secret exists in the spec @@ -5379,27 +5719,30 @@ impl Secrets { } }; - // A composed secret plans no route: its value is derived, so a write - // has nowhere to go. - let Some(route) = &planned.route else { - let err = SecretSpecError::ComposedSecretReadOnly(name.to_string()); - self.record_key_error(AuditAction::Set, &profile_name, name, None, None, &err); - return Err(err); - }; - - if planned.extract().is_some() { + if planned.extract().is_some() && planned.from().is_none() { let err = SecretSpecError::ExtractedSecretReadOnly(name.to_string()); self.record_key_error( AuditAction::Set, &profile_name, name, - route.primary().map(str::to_string), + planned + .route + .as_ref() + .and_then(|route| route.primary().map(str::to_string)), planned.reference(), &err, ); return Err(err); } + // A derived secret plans no route: its value is produced from other + // secrets, so a write has nowhere to go. + let Some(route) = &planned.route else { + let err = Self::derived_read_only(&planned); + self.record_key_error(AuditAction::Set, &profile_name, name, None, None, &err); + return Err(err); + }; + let backend = match self.write_provider_for_route(route, Some(&profile_name)) { Ok(backend) => backend, Err(err) => { @@ -5464,7 +5807,32 @@ impl Secrets { return Err(err); } - let encoded_value = Self::encoded_for_storage(&planned, &value); + // Terminal input represents typed binary values in their declared + // encoding. Byte APIs and --from-file already supply logical bytes. + let prepared = if encoded_typed_input { + Self::prompted_value_for_storage(&planned, name, &value) + } else { + let valid = if planned.is_typed_stored() && planned.credential("password").is_none() { + crate::x509_identity::validate(value.expose_secret(), name) + } else { + Ok(()) + }; + valid.map(|()| Self::encoded_for_storage(&planned, &value)) + }; + let encoded_value = match prepared { + Ok(value) => value, + Err(err) => { + self.record_key_error( + AuditAction::Set, + &profile_name, + name, + Some(backend.uri()), + None, + &err, + ); + return Err(err); + } + }; let stored_value = encoded_value.as_ref().unwrap_or(&value); let result = backend.set(addr, stored_value); self.audit_write_result( @@ -5521,23 +5889,26 @@ impl Secrets { } }; - let Some(route) = &planned.route else { - let error = SecretSpecError::ComposedSecretReadOnly(name.to_string()); - self.record_key_error(AuditAction::Delete, &profile_name, name, None, None, &error); - return Err(error); - }; - if planned.extract().is_some() { + if planned.extract().is_some() && planned.from().is_none() { let error = SecretSpecError::ExtractedSecretReadOnly(name.to_string()); self.record_key_error( AuditAction::Delete, &profile_name, name, - route.primary().map(str::to_string), + planned + .route + .as_ref() + .and_then(|route| route.primary().map(str::to_string)), planned.reference(), &error, ); return Err(error); } + let Some(route) = &planned.route else { + let error = Self::derived_read_only(&planned); + self.record_key_error(AuditAction::Delete, &profile_name, name, None, None, &error); + return Err(error); + }; let backend = match self.write_provider_for_route(route, Some(&profile_name)) { Ok(backend) => backend, Err(error) => { @@ -5770,7 +6141,24 @@ impl Secrets { let value = SecretBytes::from_utf8(prompt.prompt()?); - let encoded_value = Self::encoded_for_storage(&planned, &value); + let encoded_value = match Self::prompted_value_for_storage( + &planned, + secret_name, + &value, + ) { + Ok(value) => value, + Err(error) => { + self.record_key_error( + AuditAction::Set, + &profile_display, + secret_name, + Some(backend.uri()), + planned.reference(), + &error, + ); + return Err(error); + } + }; let stored_value = encoded_value.as_ref().unwrap_or(&value); let address = self.address_for_spec( &planned, @@ -6286,7 +6674,7 @@ impl Secrets { &self, planned: &PlannedSecret, profile_name: &str, - ) -> Result> { + ) -> Result> { let name = planned.name.as_str(); let gen_config = match &planned.config().generate { Some(config) if config.is_enabled() => config, @@ -6308,7 +6696,11 @@ impl Secrets { } }; - let value = crate::generator::generate(secret_type, gen_config)?; + let value = if secret_type == "x509_identity" { + GeneratedValue::Binary(crate::x509_identity::generate(gen_config)?) + } else { + GeneratedValue::Text(crate::generator::generate(secret_type, gen_config)?) + }; // Store the generated value at the plan's address, through the plan's // write route: the same decisions every other write path executes. @@ -6345,8 +6737,22 @@ impl Secrets { // The provider states why a write is refused; wrapping it here would // only nest a second "Provider operation failed" prefix. backend.check_writable(addr)?; - let encoded_value = Self::encoded_for_storage(planned, &value); - let stored_value = encoded_value.as_ref().unwrap_or(&value); + let encoded_value = match &value { + GeneratedValue::Text(value) => Self::encoded_for_storage(planned, value), + GeneratedValue::Binary(value) => Some(Self::encode_logical_bytes( + planned + .encoding() + .expect("binary generators require a validated encoding"), + value.expose_secret(), + )), + }; + let stored_value = match (&value, encoded_value.as_ref()) { + (_, Some(value)) => value, + (GeneratedValue::Text(value), None) => value, + (GeneratedValue::Binary(_), None) => { + unreachable!("binary generators require a stored encoding") + } + }; let set_result = backend.set(addr, stored_value); // Generating a secret writes a brand-new value to the provider; record it // like any other write so the audit log captures every stored secret. @@ -6492,6 +6898,18 @@ impl Secrets { Ok(value) } + /// The read-only error for a secret that plans no route: a `from` secret + /// names the source to change, a composed one has no single source. + fn derived_read_only(planned: &PlannedSecret) -> SecretSpecError { + match planned.from() { + Some(source) => SecretSpecError::DerivedSecretReadOnly { + name: planned.name.clone(), + from: source.to_string(), + }, + None => SecretSpecError::ComposedSecretReadOnly(planned.name.clone()), + } + } + /// Writes secret bytes to a temporary file and returns the file handle and path /// /// # Arguments @@ -6508,10 +6926,14 @@ impl Secrets { fn write_secret_to_temp_file( &self, secret: &[u8], + suffix: Option<&str>, ) -> Result<(tempfile::NamedTempFile, String)> { use std::io::Write; - let mut temp_file = tempfile::NamedTempFile::new().map_err(SecretSpecError::Io)?; + let mut temp_file = tempfile::Builder::new() + .suffix(suffix.unwrap_or("")) + .tempfile() + .map_err(SecretSpecError::Io)?; temp_file.write_all(secret).map_err(SecretSpecError::Io)?; @@ -6779,7 +7201,7 @@ impl Secrets { ) -> Result { with_ipc_audit_purpose(purpose, || { self.require_ipc_surface(AuditAction::Set, name)?; - self.store_secret_with_input(name, |_| Ok(SecretBytes::from_utf8(value))) + self.store_secret_with_input(name, |_| Ok(SecretBytes::from_utf8(value)), false) }) } @@ -7295,10 +7717,8 @@ impl Secrets { if !names.insert(name.to_string()) { return; } - if let Some(template) = &profile.secrets[name].composition { - for dependency in template.dependencies() { - visit(dependency, profile, names); - } + for dependency in profile.secrets[name].dependencies() { + visit(dependency, profile, names); } } @@ -7326,6 +7746,12 @@ impl Secrets { .iter() .map(|entry| (entry.name.as_str(), &entry.status)) .collect(); + let fetched_from_provider: HashSet<&str> = errors + .resolution + .iter() + .filter(|entry| entry.source_provider.is_some()) + .map(|entry| entry.name.as_str()) + .collect(); let profile = self .manifest .profile(profile_name) @@ -7335,22 +7761,59 @@ impl Secrets { name: &str, profile: &crate::compiled_spec::CompiledProfile, statuses: &HashMap<&str, &ResolutionStatus>, + fetched_from_provider: &HashSet<&str>, promptable: &mut HashSet, ) { - let Some(template) = &profile.secrets[name].composition else { + let secret = &profile.secrets[name]; + // A stored typed value can be fetched successfully but remain + // unresolved because a credential is missing. It is a dependency + // node in that state, not a missing leaf: prompting for it would + // overwrite the archive that is already present. + if secret + .config + .typed_contract() + .is_some_and(|contract| contract.is_stored()) + && fetched_from_provider.contains(name) + { + for (_, credential) in secret.config.credential_secrets() { + if statuses.get(credential).copied() != Some(&ResolutionStatus::Resolved) { + visit( + credential, + profile, + statuses, + fetched_from_provider, + promptable, + ); + } + } + return; + } + if !secret.config.is_derived() { promptable.insert(name.to_string()); return; - }; - for dependency in template.dependencies() { - if statuses.get(dependency.as_str()).copied() != Some(&ResolutionStatus::Resolved) { - visit(dependency, profile, statuses, promptable); + } + for dependency in secret.dependencies() { + if statuses.get(dependency).copied() != Some(&ResolutionStatus::Resolved) { + visit( + dependency, + profile, + statuses, + fetched_from_provider, + promptable, + ); } } } let mut promptable = HashSet::new(); for name in &errors.missing_required { - visit(name, profile, &statuses, &mut promptable); + visit( + name, + profile, + &statuses, + &fetched_from_provider, + &mut promptable, + ); } let mut promptable: Vec = promptable.into_iter().collect(); promptable.sort(); @@ -8933,6 +9396,38 @@ mod encoding_tests { assert_eq!(stored.expose_secret(), expected.as_bytes()); } } + + #[test] + fn prompted_binary_value_is_decoded_before_canonical_storage() { + let generated = crate::x509_identity::generate(&crate::config::GenerateConfig::Options( + crate::config::GenerateOptions { + san: Some(vec!["dns:localhost".to_string()]), + ..Default::default() + }, + )) + .unwrap(); + let canonical = BASE64.encode(generated.expose_secret()); + let entered = SecretBytes::from_utf8(format!("{canonical}\n")); + + let config = crate::tests::resolve_test_config(HashMap::from([( + "TLS_IDENTITY".to_string(), + crate::config::Secret { + description: Some("identity".to_string()), + secret_type: Some("x509_identity".to_string()), + ..Default::default() + }, + )])); + let spec = Secrets::new(config, None, None, None); + let planned = spec + .plan_secret("TLS_IDENTITY", "default", None) + .unwrap() + .unwrap(); + + let stored = Secrets::prompted_value_for_storage(&planned, "TLS_IDENTITY", &entered) + .unwrap() + .unwrap(); + assert_eq!(stored.expose_secret(), canonical.as_bytes()); + } } #[cfg(test)] @@ -9331,3 +9826,69 @@ mod retry_policy_tests { assert_eq!(provider.0.unwrap(), crate::RetryPolicy::default()); } } + +#[cfg(test)] +mod resolution_deadline_tests { + use super::*; + + #[test] + fn typed_identity_and_projections_use_the_earliest_input_deadlines() { + let _env = crate::tests::scrub_resolution_env(); + let config: Config = toml::from_str( + r#" +[project] +name = "identity-deadlines" +revision = "1.0" +[profiles.default] +ID = { description = "identity", type = "x509_identity", credentials = { password = "PW" } } +PW = { description = "password" } +CERT = { description = "certificate", type = "x509_certificate", from = "ID" } +"#, + ) + .unwrap(); + let secrets = Secrets::new(config, None, Some("null://".into()), None); + let plan = secrets.build_plan(Some("default")).unwrap(); + + // Cover missing deadlines, either input expiring first, and expiry + // and refresh being governed by different inputs. The value-free + // pass uses the same provider metadata and dependency graph as reads. + for (identity_expiry, password_expiry, identity_refresh, password_refresh) in [ + (None, Some(200), None, Some(400)), + (Some(100), Some(200), Some(300), Some(400)), + (Some(200), Some(100), Some(400), Some(300)), + (Some(100), Some(200), Some(400), Some(300)), + (Some(100), None, Some(300), None), + (None, None, None, None), + ] { + let mut execution = + ResolutionExecution::new(&secrets, &plan, Materialize::None, None, None); + execution.fetched_values = HashMap::from([ + ( + "ID".into(), + ProviderValue::new(SecretBytes::from_utf8("unused"), identity_expiry), + ), + ( + "PW".into(), + ProviderValue::new(SecretBytes::from_utf8("password"), password_expiry), + ), + ]); + for (name, refresh) in [("ID", identity_refresh), ("PW", password_refresh)] { + if let Some(refresh) = refresh { + execution.refreshes.insert(name.into(), refresh); + } + } + execution.resolve_provider_backed_values().unwrap(); + execution.resolve_derived_values().unwrap(); + + let expected_expiry = identity_expiry.into_iter().chain(password_expiry).min(); + let expected_refresh = identity_refresh.into_iter().chain(password_refresh).min(); + for name in ["ID", "CERT"] { + assert_eq!( + execution.secret_expiries.get(name).copied(), + expected_expiry + ); + assert_eq!(execution.refreshes.get(name).copied(), expected_refresh); + } + } + } +} diff --git a/secretspec/src/spec.rs b/secretspec/src/spec.rs index a0ef1941f..18e358bb5 100644 --- a/secretspec/src/spec.rs +++ b/secretspec/src/spec.rs @@ -7,11 +7,12 @@ use crate::compiled_spec::CompiledSpec; use crate::config::{ - Config, GenerateConfig, GenerateOptions, NativeAddress, Profile as ConfigProfile, - ProfileDefaults, Project, ProjectDefaults, ProviderAlias, RequireReason, Scope, - Secret as ConfigSecret, SecretEncoding, SecretExtract, + Config, CredentialBinding, GenerateConfig, GenerateOptions, NativeAddress, + Profile as ConfigProfile, ProfileDefaults, Project, ProjectDefaults, ProviderAlias, + RequireReason, Scope, Secret as ConfigSecret, SecretEncoding, SecretExtract, }; use crate::error::{Result, SecretSpecError}; +use std::collections::BTreeMap; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::str::FromStr; @@ -793,12 +794,46 @@ impl Secret { self } - /// Extract a value from a structured provider result. + /// Extract a value from a structured provider result, or from the text of + /// the secret named by [`from`](Self::from). pub fn extract(mut self, extract: SecretExtract) -> Self { self.config.extract = Some(extract); self } + /// Set the semantic type. For the typed contracts (`x509_identity` and the + /// `pkcs12`, `pkcs8_private_key`, `x509_certificate`, + /// `x509_certificate_chain`, and `x509_issuer_chain` targets, 0.22+) the + /// type governs decoding, conversion, credentials, and delivery. + pub fn secret_type(mut self, secret_type: impl Into) -> Self { + self.config.secret_type = Some(secret_type.into()); + self + } + + /// Choose the serialization of a typed value that offers more than one, + /// such as DER instead of the default PEM (0.22+). + pub fn format(mut self, format: crate::typed::Format) -> Self { + self.config.format = Some(format.as_str().to_string()); + self + } + + /// Derive this value from another declared secret: convert it with a + /// derivable type or select from it with `extract` (0.22+). + pub fn from(mut self, source: impl Into) -> Self { + self.config.from = Some(source.into()); + self + } + + /// Bind a credential role of this typed value, such as the `password` + /// that opens or protects a PKCS#12 archive, to a declared secret (0.22+). + pub fn credential(mut self, role: impl Into, secret: impl Into) -> Self { + self.config + .credentials + .get_or_insert_with(BTreeMap::new) + .insert(role.into(), CredentialBinding::Secret(secret.into())); + self + } + /// Generate the value when it is absent. pub fn generate(mut self, generation: Generation) -> Self { let (secret_type, config) = generation.into_config(); @@ -839,6 +874,22 @@ pub enum Generation { /// Characters from which the password is drawn. charset: PasswordCharset, }, + /// A human-readable passphrase composed from independently selected words (0.22+). + Passphrase { + /// Word count; `None` uses SecretSpec's seven-word default. + words: Option, + /// Text placed between words. + separator: String, + }, + /// A checksum-protected recovery mnemonic (0.22+). + Mnemonic { + /// Mnemonic encoding algorithm. + algorithm: MnemonicAlgorithm, + /// Word count; `None` uses SecretSpec's 24-word default. + words: Option, + /// Word-list language. + language: MnemonicLanguage, + }, /// Random bytes rendered as hexadecimal. Hex { /// Byte count; `None` uses SecretSpec's default. @@ -874,6 +925,26 @@ pub enum Generation { /// Optional comment embedded in the OpenSSH key. comment: Option, }, + /// A Base64-encoded WireGuard private key (0.22+). + WireguardPrivateKey, + /// A private signing key serialized as a JSON Web Key (0.22+). + JwkPrivateKey { + /// JOSE signing algorithm and optional RSA key size. + algorithm: JwkKeyAlgorithm, + /// Optional JWK `kid` metadata. + kid: Option, + }, + /// A native X25519 age identity (0.22+). + AgeIdentity, + /// A self-signed P-256 X.509 identity stored as PKCS#12 (0.22+). + X509Identity { + /// DNS and IP subject alternative names (`dns:name` or `ip:address`). + subject_alt_names: Vec, + /// Extended key usages placed on the certificate. + usages: Vec, + /// Validity in days; `None` uses SecretSpec's 30-day default. + valid_for_days: Option, + }, } impl Generation { @@ -885,6 +956,23 @@ impl Generation { } } + /// A seven-word passphrase separated with hyphens (0.22+). + pub fn passphrase() -> Self { + Self::Passphrase { + words: None, + separator: "-".to_string(), + } + } + + /// A 24-word English BIP-39 mnemonic (0.22+). + pub fn mnemonic() -> Self { + Self::Mnemonic { + algorithm: MnemonicAlgorithm::Bip39, + words: None, + language: MnemonicLanguage::English, + } + } + /// Hexadecimal output with SecretSpec's default random-byte count. pub fn hex() -> Self { Self::Hex { bytes: None } @@ -927,6 +1015,33 @@ impl Generation { } } + /// A Base64-encoded WireGuard private key (0.22+). + pub fn wireguard_private_key() -> Self { + Self::WireguardPrivateKey + } + + /// An Ed25519 private signing JWK (0.22+). + pub fn jwk_private_key() -> Self { + Self::JwkPrivateKey { + algorithm: JwkKeyAlgorithm::Ed25519, + kid: None, + } + } + + /// A native X25519 age identity (0.22+). + pub fn age_identity() -> Self { + Self::AgeIdentity + } + + /// A self-signed P-256 X.509 server identity with a 30-day validity (0.22+). + pub fn x509_identity(subject_alt_names: impl IntoIterator>) -> Self { + Self::X509Identity { + subject_alt_names: subject_alt_names.into_iter().map(Into::into).collect(), + usages: vec![X509Usage::ServerAuth], + valid_for_days: None, + } + } + fn into_config(self) -> (&'static str, GenerateConfig) { match self { Self::Password { length, charset } => ( @@ -937,6 +1052,27 @@ impl Generation { ..GenerateOptions::default() }), ), + Self::Passphrase { words, separator } => ( + "passphrase", + GenerateConfig::Options(GenerateOptions { + words, + separator: Some(separator), + ..GenerateOptions::default() + }), + ), + Self::Mnemonic { + algorithm, + words, + language, + } => ( + "mnemonic", + GenerateConfig::Options(GenerateOptions { + algorithm: Some(algorithm.as_str().to_string()), + words, + language: Some(language.as_str().to_string()), + ..GenerateOptions::default() + }), + ), Self::Hex { bytes } => ( "hex", GenerateConfig::Options(GenerateOptions { @@ -994,6 +1130,125 @@ impl Generation { ..GenerateOptions::default() }), ), + Self::WireguardPrivateKey => ("wireguard_private_key", GenerateConfig::Bool(true)), + Self::JwkPrivateKey { algorithm, kid } => ( + "jwk_private_key", + GenerateConfig::Options(GenerateOptions { + algorithm: Some(algorithm.as_str().to_string()), + bits: algorithm.bits(), + kid, + ..GenerateOptions::default() + }), + ), + Self::AgeIdentity => ("age_identity", GenerateConfig::Bool(true)), + Self::X509Identity { + subject_alt_names, + usages, + valid_for_days, + } => ( + "x509_identity", + GenerateConfig::Options(GenerateOptions { + issuer: Some("self_signed".to_string()), + algorithm: Some("p256".to_string()), + san: Some(subject_alt_names), + usages: Some( + usages + .into_iter() + .map(|usage| usage.as_str().to_string()) + .collect(), + ), + valid_for: valid_for_days.map(|days| format!("{days}d")), + ..GenerateOptions::default() + }), + ), + } + } +} + +/// Extended key usage for a generated X.509 identity. +/// +/// Available starting with SecretSpec 0.22. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum X509Usage { + /// TLS server authentication. + ServerAuth, + /// TLS client authentication. + ClientAuth, +} + +impl X509Usage { + fn as_str(self) -> &'static str { + match self { + Self::ServerAuth => "server_auth", + Self::ClientAuth => "client_auth", + } + } +} + +/// The encoding algorithm for a generated recovery mnemonic. +/// +/// Available starting with SecretSpec 0.22. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum MnemonicAlgorithm { + /// BIP-39 entropy and checksum encoding. + Bip39, +} + +impl MnemonicAlgorithm { + fn as_str(self) -> &'static str { + match self { + Self::Bip39 => "bip39", + } + } +} + +/// The word-list language for a generated recovery mnemonic. +/// +/// Available starting with SecretSpec 0.22. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum MnemonicLanguage { + /// The BIP-39 English word list. + English, +} + +impl MnemonicLanguage { + fn as_str(self) -> &'static str { + match self { + Self::English => "english", + } + } +} + +/// The algorithm for a generated private JSON Web Key. +/// +/// Available starting with SecretSpec 0.22. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum JwkKeyAlgorithm { + /// Ed25519 with the fully specified JOSE algorithm `Ed25519` (RFC 9864). + Ed25519, + /// NIST P-256 with JOSE algorithm `ES256`. + P256, + /// RSA with JOSE algorithm `RS256`. `None` uses the 3072-bit default. + Rsa { bits: Option }, +} + +impl JwkKeyAlgorithm { + fn as_str(self) -> &'static str { + match self { + Self::Ed25519 => "ed25519", + Self::P256 => "p256", + Self::Rsa { .. } => "rsa", + } + } + + fn bits(self) -> Option { + match self { + Self::Rsa { bits } => bits, + Self::Ed25519 | Self::P256 => None, } } } @@ -1229,6 +1484,78 @@ mod tests { assert_eq!(rsa.comment.as_deref(), Some("deploy@example.com")); } + #[test] + fn additional_credential_generation_builders_map_to_document_options() { + let (secret_type, GenerateConfig::Options(passphrase)) = Generation::Passphrase { + words: Some(8), + separator: " ".to_string(), + } + .into_config() else { + panic!("passphrase generation must use options"); + }; + assert_eq!(secret_type, "passphrase"); + assert_eq!(passphrase.words, Some(8)); + assert_eq!(passphrase.separator.as_deref(), Some(" ")); + + let (secret_type, GenerateConfig::Options(mnemonic)) = Generation::Mnemonic { + algorithm: MnemonicAlgorithm::Bip39, + words: Some(12), + language: MnemonicLanguage::English, + } + .into_config() else { + panic!("mnemonic generation must use options"); + }; + assert_eq!(secret_type, "mnemonic"); + assert_eq!(mnemonic.algorithm.as_deref(), Some("bip39")); + assert_eq!(mnemonic.words, Some(12)); + assert_eq!(mnemonic.language.as_deref(), Some("english")); + + let (secret_type, GenerateConfig::Options(default_mnemonic)) = + Generation::mnemonic().into_config() + else { + panic!("mnemonic generation must use options"); + }; + assert_eq!(secret_type, "mnemonic"); + assert_eq!(default_mnemonic.words, None); + + let (secret_type, GenerateConfig::Options(jwk)) = Generation::JwkPrivateKey { + algorithm: JwkKeyAlgorithm::Rsa { bits: Some(4096) }, + kid: Some("release-2026".to_string()), + } + .into_config() else { + panic!("JWK generation must use options"); + }; + assert_eq!(secret_type, "jwk_private_key"); + assert_eq!(jwk.algorithm.as_deref(), Some("rsa")); + assert_eq!(jwk.bits, Some(4096)); + assert_eq!(jwk.kid.as_deref(), Some("release-2026")); + + assert!(matches!( + Generation::wireguard_private_key().into_config(), + ("wireguard_private_key", GenerateConfig::Bool(true)) + )); + assert!(matches!( + Generation::age_identity().into_config(), + ("age_identity", GenerateConfig::Bool(true)) + )); + + let (secret_type, GenerateConfig::Options(x509)) = + Generation::x509_identity(["dns:localhost", "ip:127.0.0.1"]).into_config() + else { + panic!("X.509 generation must use options"); + }; + assert_eq!(secret_type, "x509_identity"); + assert_eq!(x509.algorithm.as_deref(), Some("p256")); + assert_eq!( + x509.san, + Some(vec![ + "dns:localhost".to_string(), + "ip:127.0.0.1".to_string() + ]) + ); + assert_eq!(x509.usages, Some(vec!["server_auth".to_string()])); + } + #[test] fn builder_edits_a_copy_without_mutating_the_original_spec() { let original = Spec::builder("embedded") diff --git a/secretspec/src/tests.rs b/secretspec/src/tests.rs index 5c4394e31..0ceb9b899 100644 --- a/secretspec/src/tests.rs +++ b/secretspec/src/tests.rs @@ -6383,6 +6383,10 @@ fn test_config_rejects_invalid_openpgp_generation_options() { r#"KEY = { description = "Key", type = "openpgp_private_key", generate = { user_id = "Bot", algorithm = "ed25519", bits = 3072 } }"#, r#"KEY = { description = "Key", type = "openpgp_private_key", generate = { user_id = "Bot", algorithm = "rsa", bits = 1024 } }"#, r#"KEY = { description = "Key", type = "openpgp_private_key", generate = { user_id = "Bot", algorithm = "rsa", bits = 16384 } }"#, + r#"KEY = { description = "Key", type = "openpgp_private_key", generate = { user_id = "Bot", length = 1 } }"#, + r#"KEY = { description = "Key", type = "openpgp_private_key", generate = { user_id = "Bot", bytes = 1 } }"#, + r#"KEY = { description = "Key", type = "openpgp_private_key", generate = { user_id = "Bot", charset = "ascii" } }"#, + r#"KEY = { description = "Key", type = "openpgp_private_key", generate = { user_id = "Bot", command = "echo ignored" } }"#, ] { let toml_content = format!( "[project]\nname = \"test-gen\"\nrevision = \"1.0\"\n\n[profiles.default]\n{declaration}\n" @@ -6429,6 +6433,10 @@ fn test_config_rejects_invalid_ssh_generation_options() { r#"KEY = { description = "Key", type = "ssh_private_key", generate = { algorithm = "rsa", bits = 16384 } }"#, r#"KEY = { description = "Key", type = "ssh_private_key", generate = { comment = "bad\ncomment" } }"#, r#"KEY = { description = "Key", type = "ssh_private_key", generate = { user_id = "Bot" } }"#, + r#"KEY = { description = "Key", type = "ssh_private_key", generate = { length = 1 } }"#, + r#"KEY = { description = "Key", type = "ssh_private_key", generate = { bytes = 1 } }"#, + r#"KEY = { description = "Key", type = "ssh_private_key", generate = { charset = "ascii" } }"#, + r#"KEY = { description = "Key", type = "ssh_private_key", generate = { command = "echo ignored" } }"#, r#"KEY = { description = "Key", type = "openpgp_private_key", generate = { user_id = "Bot", comment = "ssh-only" } }"#, ] { let toml_content = format!( @@ -6438,6 +6446,79 @@ fn test_config_rejects_invalid_ssh_generation_options() { } } +#[test] +fn test_config_parses_additional_credential_generators() { + let toml_content = r#" +[project] +name = "test-gen" +revision = "1.0" + +[profiles.default] +RECOVERY = { description = "Recovery phrase", type = "passphrase", generate = { words = 8, separator = "." } } +WALLET_SEED = { description = "Wallet recovery mnemonic", type = "mnemonic", generate = { algorithm = "bip39", words = 24, language = "english" } } +WG_KEY = { description = "WireGuard key", type = "wireguard_private_key", generate = true } +JWT_KEY = { description = "JWT key", type = "jwk_private_key", generate = { algorithm = "p256", kid = "release-2026" } } +AGE_KEY = { description = "age identity", type = "age_identity", generate = true } +"#; + let config = parse_spec_from_str(toml_content, None).unwrap(); + let profile = &config.profiles["default"]; + + let Some(GenerateConfig::Options(passphrase)) = &profile.secrets["RECOVERY"].generate else { + panic!("expected passphrase options"); + }; + assert_eq!(passphrase.words, Some(8)); + assert_eq!(passphrase.separator.as_deref(), Some(".")); + + let Some(GenerateConfig::Options(mnemonic)) = &profile.secrets["WALLET_SEED"].generate else { + panic!("expected mnemonic options"); + }; + assert_eq!(mnemonic.algorithm.as_deref(), Some("bip39")); + assert_eq!(mnemonic.words, Some(24)); + assert_eq!(mnemonic.language.as_deref(), Some("english")); + + let Some(GenerateConfig::Options(jwk)) = &profile.secrets["JWT_KEY"].generate else { + panic!("expected JWK options"); + }; + assert_eq!(jwk.algorithm.as_deref(), Some("p256")); + assert_eq!(jwk.kid.as_deref(), Some("release-2026")); + + for name in ["WG_KEY", "AGE_KEY"] { + assert!(matches!( + profile.secrets[name].generate, + Some(GenerateConfig::Bool(true)) + )); + } +} + +#[test] +fn test_config_rejects_invalid_additional_credential_generation_options() { + for declaration in [ + r#"KEY = { description = "Key", type = "passphrase", generate = { words = 5 } }"#, + r#"KEY = { description = "Key", type = "passphrase", generate = { separator = "" } }"#, + r#"KEY = { description = "Key", type = "passphrase", generate = { algorithm = "rsa" } }"#, + r#"KEY = { description = "Key", type = "mnemonic", generate = { words = 11 } }"#, + r#"KEY = { description = "Key", type = "mnemonic", generate = { words = 13 } }"#, + r#"KEY = { description = "Key", type = "mnemonic", generate = { words = 25 } }"#, + r#"KEY = { description = "Key", type = "mnemonic", generate = { algorithm = "electrum" } }"#, + r#"KEY = { description = "Key", type = "mnemonic", generate = { language = "spanish" } }"#, + r#"KEY = { description = "Key", type = "mnemonic", generate = { separator = "-" } }"#, + r#"KEY = { description = "Key", type = "jwk_private_key", generate = { algorithm = "ed25519", bits = 3072 } }"#, + r#"KEY = { description = "Key", type = "jwk_private_key", generate = { algorithm = "rsa", bits = 1024 } }"#, + r#"KEY = { description = "Key", type = "jwk_private_key", generate = { algorithm = "secp256k1" } }"#, + r#"KEY = { description = "Key", type = "jwk_private_key", generate = { kid = " " } }"#, + r#"KEY = { description = "Key", type = "wireguard_private_key", generate = { bytes = 32 } }"#, + r#"KEY = { description = "Key", type = "age_identity", generate = { algorithm = "x25519" } }"#, + ] { + let toml_content = format!( + "[project]\nname = \"test-gen\"\nrevision = \"1.0\"\n\n[profiles.default]\n{declaration}\n" + ); + assert!( + parse_spec_from_str(&toml_content, None).is_err(), + "accepted invalid declaration: {declaration}" + ); + } +} + #[test] fn test_config_type_without_generate_is_valid() { let toml_content = r#" @@ -6823,6 +6904,7 @@ DB_PASSWORD = { description = "Password", type = "password", generate = true } API_TOKEN = { description = "Token", type = "hex", generate = { bytes = 16 } } SESSION_KEY = { description = "Session", type = "base64", generate = { bytes = 24 } } REQUEST_ID = { description = "ID", type = "uuid", generate = true } +WALLET_RECOVERY = { description = "Wallet recovery mnemonic", type = "mnemonic", generate = true } "#; fs::write(&config_file, toml_content).unwrap(); @@ -6845,6 +6927,7 @@ REQUEST_ID = { description = "ID", type = "uuid", generate = true } assert!(validated.resolved.secrets.contains_key("API_TOKEN")); assert!(validated.resolved.secrets.contains_key("SESSION_KEY")); assert!(validated.resolved.secrets.contains_key("REQUEST_ID")); + assert!(validated.resolved.secrets.contains_key("WALLET_RECOVERY")); // Verify types let pw = validated @@ -6874,6 +6957,17 @@ REQUEST_ID = { description = "ID", type = "uuid", generate = true } .unwrap(); assert_eq!(uuid.len(), 36); assert!(uuid.contains('-')); + + let mnemonic = validated + .resolved + .secrets + .get("WALLET_RECOVERY") + .unwrap() + .try_as_utf8() + .unwrap(); + let parsed = bip39::Mnemonic::parse_in_normalized(bip39::Language::English, mnemonic) + .expect("resolved mnemonic must have a valid BIP-39 checksum"); + assert_eq!(parsed.word_count(), 24); } #[test] @@ -6948,6 +7042,9 @@ fn test_resolve_secret_config_merges_type_and_generate() { encoding: None, extract: None, secret_type: Some("password".to_string()), + format: None, + from: None, + credentials: None, generate: Some(crate::config::GenerateConfig::Bool(true)), prompt: None, }, @@ -10373,6 +10470,42 @@ secrets = ["DATABASE_URL"] ); } + #[test] + fn prompting_fetched_identity_descends_to_missing_password() { + let temp = TempDir::new().unwrap(); + let env_path = temp.path().join(".env"); + // The bytes need not form a PFX yet: resolution parks a fetched typed + // value until its credential resolves, so it must not inspect or offer + // to replace the existing archive in this pass. + fs::write(&env_path, "TLS_IDENTITY=Zm9v\n").unwrap(); + let manifest = r#" +[project] +name = "typed-prompt" +revision = "1.0" + +[profiles.default] +PFX_PASSWORD = { description = "PFX password" } +TLS_IDENTITY = { description = "identity", type = "x509_identity", credentials = { password = "PFX_PASSWORD" } } +"#; + let config = config(manifest); + config.validate().unwrap(); + let spec = Secrets::new( + config, + None, + Some(format!("dotenv://{}", env_path.display())), + None, + ); + let errors = match spec.validate().unwrap() { + Ok(_) => panic!("the missing password must keep the identity unresolved"), + Err(errors) => errors, + }; + + assert_eq!( + spec.scoped_promptable_missing(&errors, "default").unwrap(), + vec!["PFX_PASSWORD".to_string()] + ); + } + /// `run --scope` scrubs the raw dependencies of an in-scope composed secret /// from the child — via the separate `scope_excluded_names` path, not the /// resolution output filter — even when the parent shell exported them. diff --git a/secretspec/src/typed.rs b/secretspec/src/typed.rs new file mode 100644 index 000000000..7c95a0a48 --- /dev/null +++ b/secretspec/src/typed.rs @@ -0,0 +1,340 @@ +//! Registry of typed secret contracts (SecretSpec 0.22+). +//! +//! `type` is informational for most secrets. For the types registered here it +//! is authoritative: it decides how a stored value is decoded and validated, +//! which `credentials` roles may unlock or protect it, which declared secrets a +//! `from` target may derive from, how the result is serialized, and how the +//! result is delivered. Every string match on these type names lives here so +//! configuration, planning, and resolution cannot drift. + +use crate::config::SecretEncoding; + +/// The stored or generated canonical identity type. The only registry type a +/// provider may hold in 0.22. +pub(crate) const X509_IDENTITY: &str = "x509_identity"; + +/// Longest credential value accepted for a typed secret, in bytes. PKCS#12 +/// passwords are re-encoded as BMPString; a bound keeps that allocation and +/// the KDF input small without constraining any realistic password. +pub(crate) const MAX_CREDENTIAL_BYTES: usize = 1024; + +/// A serialization for a typed value whose type offers more than one, written +/// as the secret's `format`. +/// +/// Available since SecretSpec 0.22. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum Format { + /// Textual PEM encoding. + Pem, + /// Binary DER encoding. + Der, +} + +impl Format { + pub(crate) fn parse(value: &str) -> Option { + match value { + "pem" => Some(Self::Pem), + "der" => Some(Self::Der), + _ => None, + } + } + + /// The manifest spelling of this format. + pub const fn as_str(self) -> &'static str { + match self { + Self::Pem => "pem", + Self::Der => "der", + } + } + + pub(crate) const fn is_binary(self) -> bool { + matches!(self, Self::Der) + } + + pub(crate) const fn suffix(self) -> &'static str { + match self { + Self::Pem => ".pem", + Self::Der => ".der", + } + } +} + +/// What a derived registry type produces from its `from` source. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Projection { + /// The complete identity as a PKCS#12/PFX archive, optionally protected by + /// the `password` credential. + Pkcs12, + /// The unencrypted PKCS#8 private key. + Pkcs8PrivateKey, + /// The leaf certificate. + Certificate, + /// The leaf followed by its ordered issuer chain. + CertificateChain, + /// The ordered issuer chain without the leaf. + IssuerChain, +} + +/// One registered type's contract. +#[derive(Debug)] +pub(crate) struct TypeContract { + /// The manifest spelling. + pub(crate) name: &'static str, + /// Accepted `format` values, default first. Empty when the representation + /// is fixed, in which case `format` is rejected. + pub(crate) formats: &'static [Format], + /// Types a `from` source may declare. Empty for stored types. + pub(crate) sources: &'static [&'static str], + /// Accepted `credentials` roles. + pub(crate) roles: &'static [&'static str], + /// The projection a `from` target produces, or `None` for a stored type. + pub(crate) projection: Option, + /// Whether a fixed representation is binary. + fixed_binary: bool, + /// Preferred file suffix of a fixed representation. + fixed_suffix: &'static str, +} + +impl TypeContract { + /// Whether providers may hold this type. Every other registry type exists + /// only as a `from` target. + pub(crate) fn is_stored(&self) -> bool { + self.projection.is_none() + } + + /// Resolve the effective format: the requested one when the type accepts + /// it, the type's default when none is requested, or `None` for a fixed + /// representation. + pub(crate) fn format(&self, requested: Option<&str>) -> Result, String> { + match (requested, self.formats.first()) { + (None, default) => Ok(default.copied()), + (Some(_), None) => Err(format!( + "`format` is not valid for type = \"{}\"; its representation is fixed", + self.name + )), + (Some(requested), Some(_)) => Format::parse(requested) + .filter(|format| self.formats.contains(format)) + .map(Some) + .ok_or_else(|| { + format!( + "unknown format '{}' for type = \"{}\"; expected {}", + requested, + self.name, + self.formats + .iter() + .map(|format| format!("`{}`", format.as_str())) + .collect::>() + .join(" or ") + ) + }), + } + } + + /// Whether the logical value is bytes rather than UTF-8 text. + pub(crate) fn is_binary(&self, format: Option) -> bool { + format.map_or(self.fixed_binary, Format::is_binary) + } + + /// Preferred file suffix for `as_path` delivery. + pub(crate) fn suffix(&self, format: Option) -> &'static str { + format.map_or(self.fixed_suffix, Format::suffix) + } + + /// The codec a binary value uses wherever it must be text, when the + /// declaration does not choose one. + pub(crate) fn default_encoding(&self, format: Option) -> Option { + self.is_binary(format).then_some(SecretEncoding::Base64) + } + + pub(crate) fn accepts_source(&self, source_type: Option<&str>) -> bool { + source_type.is_some_and(|source_type| self.sources.contains(&source_type)) + } + + pub(crate) fn accepts_role(&self, role: &str) -> bool { + self.roles.contains(&role) + } +} + +const CONTRACTS: &[TypeContract] = &[ + TypeContract { + name: X509_IDENTITY, + formats: &[], + sources: &[], + roles: &["password"], + projection: None, + fixed_binary: true, + fixed_suffix: ".pfx", + }, + TypeContract { + name: "pkcs12", + formats: &[], + sources: &[X509_IDENTITY], + roles: &["password"], + projection: Some(Projection::Pkcs12), + fixed_binary: true, + fixed_suffix: ".pfx", + }, + TypeContract { + name: "pkcs8_private_key", + formats: &[Format::Pem, Format::Der], + sources: &[X509_IDENTITY], + roles: &[], + projection: Some(Projection::Pkcs8PrivateKey), + fixed_binary: false, + fixed_suffix: ".pem", + }, + TypeContract { + name: "x509_certificate", + formats: &[Format::Pem, Format::Der], + sources: &[X509_IDENTITY], + roles: &[], + projection: Some(Projection::Certificate), + fixed_binary: false, + fixed_suffix: ".pem", + }, + TypeContract { + name: "x509_certificate_chain", + formats: &[Format::Pem], + sources: &[X509_IDENTITY], + roles: &[], + projection: Some(Projection::CertificateChain), + fixed_binary: false, + fixed_suffix: ".pem", + }, + TypeContract { + name: "x509_issuer_chain", + formats: &[Format::Pem], + sources: &[X509_IDENTITY], + roles: &[], + projection: Some(Projection::IssuerChain), + fixed_binary: false, + fixed_suffix: ".pem", + }, +]; + +/// Look up a registry type. `None` for every informational type such as +/// `password` or `ssh_private_key`. +pub(crate) fn contract(type_name: &str) -> Option<&'static TypeContract> { + CONTRACTS.iter().find(|contract| contract.name == type_name) +} + +/// Types a `from` declaration may convert to, for diagnostics. +pub(crate) fn derivable_type_names() -> impl Iterator { + CONTRACTS + .iter() + .filter(|contract| !contract.is_stored()) + .map(|contract| contract.name) +} + +/// Reject a credential value that would silently change meaning or abuse the +/// crypto backend. An empty value is an error rather than "no credential": the +/// declaration bound one and it must be present. NUL is rejected because +/// PKCS#12 consumers disagree about its interpretation. +pub(crate) fn validate_credential_value(role: &str, value: &str) -> Result<(), String> { + if value.is_empty() { + return Err(format!( + "credential `{role}` resolved to an empty value; omit the binding for an empty credential" + )); + } + if value.contains('\0') { + return Err(format!("credential `{role}` must not contain NUL")); + } + if value.len() > MAX_CREDENTIAL_BYTES { + return Err(format!( + "credential `{role}` exceeds {MAX_CREDENTIAL_BYTES} bytes" + )); + } + if value.chars().any(|character| u32::from(character) > 0xffff) { + return Err(format!( + "credential `{role}` contains a supplementary Unicode character; PKCS#12 passwords currently support only the Unicode Basic Multilingual Plane" + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stored_and_derived_contracts_are_distinct() { + let identity = contract(X509_IDENTITY).unwrap(); + assert!(identity.is_stored()); + assert!(identity.sources.is_empty()); + assert!(identity.accepts_role("password")); + assert!(identity.is_binary(None)); + assert_eq!(identity.suffix(None), ".pfx"); + assert_eq!( + identity.default_encoding(None), + Some(SecretEncoding::Base64) + ); + + for name in derivable_type_names() { + let target = contract(name).unwrap(); + assert!(!target.is_stored(), "{name}"); + assert!(target.accepts_source(Some(X509_IDENTITY)), "{name}"); + assert!(!target.accepts_source(Some("pkcs12")), "{name} chains"); + assert!(!target.accepts_source(None), "{name} untyped"); + } + assert!(contract("password").is_none()); + assert_eq!( + derivable_type_names().collect::>(), + [ + "pkcs12", + "pkcs8_private_key", + "x509_certificate", + "x509_certificate_chain", + "x509_issuer_chain", + ] + ); + } + + #[test] + fn formats_default_per_type_and_reject_fixed_representations() { + let key = contract("pkcs8_private_key").unwrap(); + assert_eq!(key.format(None).unwrap(), Some(Format::Pem)); + assert_eq!(key.format(Some("der")).unwrap(), Some(Format::Der)); + assert!(!key.is_binary(Some(Format::Pem))); + assert!(key.is_binary(Some(Format::Der))); + assert_eq!(key.suffix(Some(Format::Der)), ".der"); + assert_eq!(key.default_encoding(Some(Format::Pem)), None); + assert_eq!( + key.default_encoding(Some(Format::Der)), + Some(SecretEncoding::Base64) + ); + let unknown = key.format(Some("p12")).unwrap_err(); + assert!(unknown.contains("expected `pem` or `der`"), "{unknown}"); + + let chain = contract("x509_certificate_chain").unwrap(); + assert_eq!(chain.format(None).unwrap(), Some(Format::Pem)); + let der = chain.format(Some("der")).unwrap_err(); + assert!(der.contains("expected `pem`"), "{der}"); + + let pfx = contract("pkcs12").unwrap(); + assert_eq!(pfx.format(None).unwrap(), None); + let fixed = pfx.format(Some("der")).unwrap_err(); + assert!(fixed.contains("representation is fixed"), "{fixed}"); + assert!(pfx.is_binary(None)); + assert_eq!(pfx.suffix(None), ".pfx"); + } + + #[test] + fn credential_values_are_bounded() { + assert!(validate_credential_value("password", "correct horse").is_ok()); + assert!(validate_credential_value("password", "päss漢字").is_ok()); + assert!( + validate_credential_value("password", "päss🔑") + .unwrap_err() + .contains("supplementary Unicode") + ); + let empty = validate_credential_value("password", "").unwrap_err(); + assert!(empty.contains("empty"), "{empty}"); + let nul = validate_credential_value("password", "a\0b").unwrap_err(); + assert!(nul.contains("NUL"), "{nul}"); + let long = validate_credential_value("password", &"x".repeat(MAX_CREDENTIAL_BYTES + 1)) + .unwrap_err(); + assert!(long.contains("exceeds"), "{long}"); + assert!(validate_credential_value("password", &"x".repeat(MAX_CREDENTIAL_BYTES)).is_ok()); + } +} diff --git a/secretspec/src/typed_tests.rs b/secretspec/src/typed_tests.rs new file mode 100644 index 000000000..d1a0f3b7d --- /dev/null +++ b/secretspec/src/typed_tests.rs @@ -0,0 +1,1656 @@ +//! End-to-end coverage for typed, converted, and password protected secrets +//! (0.22+): `type`, `format`, `from`, and `credentials` across configuration +//! validation, resolution, scopes, reports, and the read-only lifecycle. + +use crate::config::{Config, GenerateConfig, GenerateOptions}; +use crate::report::{Derivation, ResolutionStatus}; +use crate::resolve::ResolvedSource; +use crate::tests::scrub_resolution_env; +use crate::x509_identity::Identity; +use crate::x509_identity::test_support::{cert_from_pem, key_from_pem, open_pfx}; +use crate::{Format, SecretSpecError, Secrets}; +use data_encoding::{BASE64, HEXLOWER}; +use p12_keystore::Pkcs12Archive; +use rcgen::KeyPair; +use secrecy::ExposeSecret; +use std::fs; +use tempfile::TempDir; + +fn manifest(body: &str) -> Config { + toml::from_str(&format!( + "[project]\nname = \"typed\"\nrevision = \"1.0\"\n\n{body}" + )) + .expect("manifest parses") +} + +fn validation_error(body: &str) -> String { + manifest(body).validate().unwrap_err().to_string() +} + +fn assert_valid(body: &str) { + manifest(body).validate().unwrap_or_else(|error| { + panic!("expected a valid manifest, got: {error}\n{body}"); + }); +} + +fn generated_identity() -> Vec { + let config = GenerateConfig::Options(GenerateOptions { + san: Some(vec!["dns:localhost".to_string()]), + ..Default::default() + }); + crate::x509_identity::generate(&config) + .unwrap() + .expose_secret() + .to_vec() +} + +fn protected_identity(password: &str) -> Vec { + let bytes = generated_identity(); + Identity::decode(&bytes, None, "ID") + .unwrap() + .to_pkcs12(Some(password), "ID") + .unwrap() + .expose_secret() + .to_vec() +} + +/// A spec whose every stored secret lives in one dotenv file. +fn dotenv_spec(body: &str, env: &str) -> (TempDir, Secrets) { + let dir = TempDir::new().unwrap(); + let env_path = dir.path().join(".env"); + fs::write(&env_path, env).unwrap(); + let provider = format!("dotenv://{}", env_path.display()); + ( + dir, + Secrets::new(manifest(body), None, Some(provider), None), + ) +} + +fn null_spec(body: &str) -> Secrets { + Secrets::new(manifest(body), None, Some("null://".to_string()), None) +} + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +#[test] +fn every_target_type_and_format_validates_against_an_identity() { + assert_valid( + r#" +[profiles.default] +TLS_IDENTITY = { description = "identity", type = "x509_identity" } +TLS_IDENTITY_HEX = { description = "hex at rest", type = "x509_identity", encoding = "hex" } +TLS_PFX = { description = "pfx", type = "pkcs12", from = "TLS_IDENTITY", as_path = true } +TLS_PFX_B64 = { description = "inline pfx", type = "pkcs12", from = "TLS_IDENTITY" } +TLS_PFX_HEX = { description = "inline hex pfx", type = "pkcs12", from = "TLS_IDENTITY", encoding = "hex" } +TLS_KEY = { description = "key", type = "pkcs8_private_key", from = "TLS_IDENTITY" } +TLS_KEY_PEM = { description = "key", type = "pkcs8_private_key", format = "pem", from = "TLS_IDENTITY", as_path = true } +TLS_KEY_DER = { description = "key", type = "pkcs8_private_key", format = "der", from = "TLS_IDENTITY" } +TLS_CERT = { description = "cert", type = "x509_certificate", from = "TLS_IDENTITY" } +TLS_CERT_DER = { description = "cert", type = "x509_certificate", format = "der", from = "TLS_IDENTITY", as_path = true } +TLS_CHAIN = { description = "chain", type = "x509_certificate_chain", from = "TLS_IDENTITY" } +TLS_CHAIN_PEM = { description = "chain", type = "x509_certificate_chain", format = "pem", from = "TLS_IDENTITY" } +TLS_ISSUERS = { description = "issuers", type = "x509_issuer_chain", from = "TLS_IDENTITY", required = false } +"#, + ); +} + +#[test] +fn from_requires_extract_or_a_derivable_type() { + let bare = validation_error( + r#" +[profiles.default] +A = { description = "a" } +B = { description = "b", from = "A" } +"#, + ); + assert!( + bare.contains("`from` requires `extract` to select from the source or a derivable `type`"), + "{bare}" + ); + + let informational = validation_error( + r#" +[profiles.default] +A = { description = "a" } +B = { description = "b", type = "password", from = "A" } +"#, + ); + assert!( + informational.contains("type = \"password\" cannot be derived with `from`"), + "{informational}" + ); + assert!(informational.contains("`pkcs12`"), "{informational}"); + assert!( + informational.contains("`x509_issuer_chain`"), + "{informational}" + ); + + let stored = validation_error( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity" } +B = { description = "b", type = "x509_identity", from = "A" } +"#, + ); + assert!( + stored.contains("type = \"x509_identity\" is stored by a provider and cannot be derived"), + "{stored}" + ); + + let both = validation_error( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity" } +B = { description = "b", type = "pkcs12", from = "A", extract = { format = "json", pointer = "/a" } } +"#, + ); + assert!( + both.contains( + "either selects with `extract` or converts with a derivable `type`, not both" + ), + "{both}" + ); + + let typed_selection = validation_error( + r#" +[profiles.default] +A = { description = "a" } +B = { description = "b", type = "password", from = "A", extract = { format = "json", pointer = "/a" } } +"#, + ); + assert!( + typed_selection.contains("`from` with `extract` cannot also set `type`"), + "{typed_selection}" + ); + + let bad_name = validation_error( + r#" +[profiles.default] +A = { description = "a" } +B = { description = "b", from = "not a name", extract = { format = "json", pointer = "/a" } } +"#, + ); + assert!( + bad_name.contains("`from` must name a declared secret using a valid identifier"), + "{bad_name}" + ); +} + +#[test] +fn stored_x509_identity_rejects_extract() { + let error = validation_error( + r#" +[profiles.default] +TLS_IDENTITY = { description = "identity", type = "x509_identity", extract = { format = "json", pointer = "/certificate" } } +"#, + ); + assert!( + error.contains("`extract` is not valid for stored type = \"x509_identity\""), + "{error}" + ); +} + +#[test] +fn from_secrets_reject_storage_and_generation_fields() { + for field in [ + r#"default = "x""#, + r#"providers = ["keyring"]"#, + r#"ref = { item = "x" }"#, + r#"refs = { keyring = { item = "x" } }"#, + r#"generate = { san = ["dns:localhost"] }"#, + r#"prompt = true"#, + ] { + let error = validation_error(&format!( + r#" +[profiles.default] +TLS_IDENTITY = {{ description = "identity", type = "x509_identity" }} +TLS_KEY = {{ description = "key", type = "pkcs8_private_key", from = "TLS_IDENTITY", {field} }} +"# + )); + assert!( + error.contains("`from` secrets cannot also set") + || error.contains("`generate` and `default`") + || error.contains("'generate' requires 'type'"), + "{field}: {error}" + ); + } + + let composed = validation_error( + r#" +[profiles.default] +A = { description = "a" } +B = { description = "b", composed = "${A}", from = "A", extract = { format = "json", pointer = "/a" } } +"#, + ); + assert!( + composed.contains("`composed` secrets cannot also set"), + "{composed}" + ); + assert!(composed.contains("`from`"), "{composed}"); +} + +#[test] +fn format_is_validated_against_the_type() { + let fixed = validation_error( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity" } +B = { description = "b", type = "pkcs12", format = "der", from = "A" } +"#, + ); + assert!( + fixed.contains("`format` is not valid for type = \"pkcs12\""), + "{fixed}" + ); + + let chain_der = validation_error( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity" } +B = { description = "b", type = "x509_certificate_chain", format = "der", from = "A" } +"#, + ); + assert!( + chain_der + .contains("unknown format 'der' for type = \"x509_certificate_chain\"; expected `pem`"), + "{chain_der}" + ); + + let unknown = validation_error( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity" } +B = { description = "b", type = "x509_certificate", format = "p7b", from = "A" } +"#, + ); + assert!( + unknown.contains( + "unknown format 'p7b' for type = \"x509_certificate\"; expected `pem` or `der`" + ), + "{unknown}" + ); + + let untyped = validation_error( + r#" +[profiles.default] +A = { description = "a", format = "pem" } +"#, + ); + assert!( + untyped.contains("`format` requires a `type` that accepts formats"), + "{untyped}" + ); + + let informational = validation_error( + r#" +[profiles.default] +A = { description = "a", type = "password", format = "pem" } +"#, + ); + assert!( + informational.contains("`format` is not valid for type = \"password\""), + "{informational}" + ); + + let identity = validation_error( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity", format = "der" } +"#, + ); + assert!(identity.contains("representation is fixed"), "{identity}"); +} + +#[test] +fn credential_roles_and_bindings_are_validated() { + let unknown_role = validation_error( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity", credentials = { passphrase = "P" } } +P = { description = "p" } +"#, + ); + assert!( + unknown_role.contains( + "unknown credential role `passphrase` for type = \"x509_identity\"; expected `password`" + ), + "{unknown_role}" + ); + + let no_roles = validation_error( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity" } +K = { description = "k", type = "pkcs8_private_key", from = "A", credentials = { password = "P" } } +P = { description = "p" } +"#, + ); + assert!( + no_roles.contains("type = \"pkcs8_private_key\" accepts no credentials"), + "{no_roles}" + ); + + let untyped = validation_error( + r#" +[profiles.default] +A = { description = "a", credentials = { password = "P" } } +P = { description = "p" } +"#, + ); + assert!( + untyped.contains("`credentials` requires a `type` that accepts credentials"), + "{untyped}" + ); + + let informational = validation_error( + r#" +[profiles.default] +A = { description = "a", type = "ssh_private_key", credentials = { password = "P" } } +P = { description = "p" } +"#, + ); + assert!( + informational.contains("`credentials` is not valid for type = \"ssh_private_key\""), + "{informational}" + ); + + let table = toml::from_str::( + r#" +[project] +name = "typed" +revision = "1.0" + +[profiles.default] +A = { description = "a", type = "x509_identity", credentials = { password = { provider = "keyring" } } } +"#, + ) + .unwrap_err() + .to_string(); + assert!( + table.contains("the `{ provider, ref }` table form is reserved"), + "{table}" + ); + + let bad_name = validation_error( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity", credentials = { password = "not a name" } } +"#, + ); + assert!( + bad_name + .contains("credential `password` must name a declared secret using a valid identifier"), + "{bad_name}" + ); + + let generated = validation_error( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity", generate = { san = ["dns:localhost"] }, credentials = { password = "P" } } +P = { description = "p" } +"#, + ); + assert!( + generated.contains( + "`credentials` cannot be combined with enabled `generate` for type = \"x509_identity\"" + ), + "{generated}" + ); + + // A stored identity and a derived archive both accept `password`. + assert_valid( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity", credentials = { password = "P" } } +B = { description = "b", type = "pkcs12", from = "A", credentials = { password = "Q" } } +P = { description = "p" } +Q = { description = "q" } +"#, + ); +} + +#[test] +fn credentials_must_name_text_secrets() { + let as_path = validation_error( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity", credentials = { password = "P" } } +P = { description = "p", as_path = true } +"#, + ); + assert!( + as_path.contains("credential `password` names 'P', which is delivered as a path"), + "{as_path}" + ); + + let binary = validation_error( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity" } +B = { description = "b", type = "pkcs12", from = "A", credentials = { password = "A" } } +"#, + ); + assert!( + binary + .contains("credential `password` names 'A', whose type = \"x509_identity\" is binary"), + "{binary}" + ); + + // A composed or selected text value is an acceptable credential. + assert_valid( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity" } +B = { description = "b", type = "pkcs12", from = "A", credentials = { password = "P" } } +DOC = { description = "doc", default = "{\"pw\":\"x\"}" } +P = { description = "p", from = "DOC", extract = { format = "json", pointer = "/pw" } } +"#, + ); +} + +#[test] +fn encoding_on_from_secrets_requires_a_binary_result() { + let pem = validation_error( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity" } +K = { description = "k", type = "pkcs8_private_key", from = "A", encoding = "base64" } +"#, + ); + assert!( + pem.contains("`encoding` on a `from` secret is valid only when the result is binary"), + "{pem}" + ); + + let selection = validation_error( + r#" +[profiles.default] +DOC = { description = "doc" } +F = { description = "f", from = "DOC", extract = { format = "json", pointer = "/a" }, encoding = "base64" } +"#, + ); + assert!( + selection.contains("`encoding` on a `from` secret is valid only when the result is binary"), + "{selection}" + ); + + assert_valid( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity" } +K = { description = "k", type = "pkcs8_private_key", format = "der", from = "A", encoding = "base64url" } +P = { description = "p", type = "pkcs12", from = "A", encoding = "hex" } +"#, + ); +} + +#[test] +fn target_types_require_from_and_identities_reject_defaults() { + let stored_target = validation_error( + r#" +[profiles.default] +P = { description = "p", type = "pkcs12", providers = ["keyring"] } +"#, + ); + assert!( + stored_target.contains("type = \"pkcs12\" requires `from`"), + "{stored_target}" + ); + + let generated_target = validation_error( + r#" +[profiles.default] +K = { description = "k", type = "pkcs8_private_key" } +"#, + ); + assert!( + generated_target.contains("type = \"pkcs8_private_key\" requires `from`"), + "{generated_target}" + ); + + let defaulted = validation_error( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity", default = "x" } +"#, + ); + assert!( + defaulted.contains( + "`type = \"x509_identity\"` cannot be combined with `default` or `prompt = true`" + ), + "{defaulted}" + ); + + let prompted = validation_error( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity", prompt = true } +"#, + ); + assert!( + prompted.contains( + "`type = \"x509_identity\"` cannot be combined with `default` or `prompt = true`" + ), + "{prompted}" + ); +} + +#[test] +fn extract_cannot_select_from_a_binary_source_and_targets_are_not_sources() { + let identity = validation_error( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity" } +F = { description = "f", from = "A", extract = { format = "json", pointer = "/a" } } +"#, + ); + assert!( + identity.contains( + "`extract` cannot select from 'A' because its type = \"x509_identity\" is binary" + ), + "{identity}" + ); + + let chained = validation_error( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity" } +P = { description = "p", type = "pkcs12", from = "A" } +K = { description = "k", type = "pkcs8_private_key", from = "P" } +"#, + ); + assert!( + chained.contains("`from` source 'P' has type = \"pkcs12\", but type = \"pkcs8_private_key\" derives from type = \"x509_identity\""), + "{chained}" + ); + + let der_selection = validation_error( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity" } +C = { description = "c", type = "x509_certificate", format = "der", from = "A" } +F = { description = "f", from = "C", extract = { format = "ini", pointer = "/a" } } +"#, + ); + assert!( + der_selection.contains( + "`extract` cannot select from 'C' because its type = \"x509_certificate\" is binary" + ), + "{der_selection}" + ); + + // A PEM target is text, so a selection from it is type-valid. + assert_valid( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity" } +C = { description = "c", type = "x509_certificate", from = "A" } +F = { description = "f", from = "C", extract = { format = "ini", pointer = "/a" } } +"#, + ); +} + +#[test] +fn inheritance_merges_from_format_and_credentials_then_validates() { + let config = manifest( + r#" +[profiles.default] +TLS_IDENTITY = { description = "identity", type = "x509_identity" } +TLS_PFX_PASSWORD = { description = "password" } +TLS_PFX = { description = "pfx", type = "pkcs12", from = "TLS_IDENTITY", credentials = { password = "TLS_PFX_PASSWORD" } } +TLS_KEY = { description = "key", type = "pkcs8_private_key", format = "der", from = "TLS_IDENTITY", as_path = true } + +[profiles.production] +TLS_PFX = { description = "production pfx" } +TLS_KEY = { format = "pem" } +"#, + ); + config.validate().unwrap(); + let spec = Secrets::new(config, None, None, Some("production".to_string())); + let pfx = spec + .resolve_secret_config("TLS_PFX", Some("production")) + .unwrap(); + assert_eq!(pfx.description.as_deref(), Some("production pfx")); + assert_eq!(pfx.from.as_deref(), Some("TLS_IDENTITY")); + assert_eq!( + pfx.credential_secrets().collect::>(), + vec![("password", "TLS_PFX_PASSWORD")] + ); + let key = spec + .resolve_secret_config("TLS_KEY", Some("production")) + .unwrap(); + assert_eq!(key.format.as_deref(), Some("pem")); + assert_eq!(key.from.as_deref(), Some("TLS_IDENTITY")); + assert_eq!(key.as_path, Some(true)); + + // The merged view is what gets validated: an override that adds storage + // to an inherited `from` declaration fails like it would inline. + let error = validation_error( + r#" +[profiles.default] +TLS_IDENTITY = { description = "identity", type = "x509_identity" } +TLS_KEY = { description = "key", type = "pkcs8_private_key", from = "TLS_IDENTITY" } + +[profiles.production] +TLS_KEY = { providers = ["keyring"] } +"#, + ); + assert!(error.contains("`from` secrets cannot also set"), "{error}"); + + // An override may clear inherited credentials with an empty table. + let cleared = manifest( + r#" +[profiles.default] +TLS_IDENTITY = { description = "identity", type = "x509_identity" } +P = { description = "p" } +TLS_PFX = { description = "pfx", type = "pkcs12", from = "TLS_IDENTITY", credentials = { password = "P" } } + +[profiles.production] +TLS_PFX = { credentials = {} } +"#, + ); + cleared.validate().unwrap(); + let spec = Secrets::new(cleared, None, None, Some("production".to_string())); + let pfx = spec + .resolve_secret_config("TLS_PFX", Some("production")) + .unwrap(); + assert_eq!(pfx.credential_secrets().count(), 0); +} + +#[test] +fn typed_fields_round_trip_through_toml_and_the_builder() { + use crate::config::Secret; + + let secret: Secret = toml::from_str( + r#"description = "pfx" +type = "pkcs12" +from = "TLS_IDENTITY" +credentials = { password = "TLS_PFX_PASSWORD" } +as_path = true"#, + ) + .unwrap(); + assert_eq!(secret.from.as_deref(), Some("TLS_IDENTITY")); + assert_eq!( + secret.credential_secrets().collect::>(), + vec![("password", "TLS_PFX_PASSWORD")] + ); + assert!(secret.is_binary_value()); + assert_eq!(secret.file_suffix(), Some(".pfx")); + assert_eq!( + secret.effective_encoding(), + Some(crate::config::SecretEncoding::Base64) + ); + let rendered = toml::to_string(&secret).unwrap(); + assert!(rendered.contains("from = \"TLS_IDENTITY\""), "{rendered}"); + assert!( + rendered.contains("password = \"TLS_PFX_PASSWORD\""), + "{rendered}" + ); + assert!(!rendered.contains("format"), "{rendered}"); + let reparsed: Secret = toml::from_str(&rendered).unwrap(); + assert_eq!(reparsed.credentials, secret.credentials); + + let key: Secret = toml::from_str( + r#"description = "key" +type = "pkcs8_private_key" +format = "der" +from = "TLS_IDENTITY""#, + ) + .unwrap(); + assert_eq!(key.typed_format(), Some(Format::Der)); + assert!(key.is_binary_value()); + assert_eq!(key.file_suffix(), Some(".der")); + let pem: Secret = toml::from_str( + r#"description = "key" +type = "pkcs8_private_key" +from = "TLS_IDENTITY""#, + ) + .unwrap(); + assert_eq!(pem.typed_format(), Some(Format::Pem)); + assert!(!pem.is_binary_value()); + assert_eq!(pem.effective_encoding(), None); + assert_eq!(pem.file_suffix(), Some(".pem")); + + let built = crate::spec::Secret::new("Password protected Windows TLS identity") + .secret_type("pkcs12") + .from("TLS_IDENTITY") + .credential("password", "TLS_PFX_PASSWORD") + .as_path(true) + .into_config(); + assert_eq!(built.secret_type.as_deref(), Some("pkcs12")); + assert_eq!(built.from.as_deref(), Some("TLS_IDENTITY")); + assert_eq!( + built.credential_secrets().collect::>(), + vec![("password", "TLS_PFX_PASSWORD")] + ); + let der = crate::spec::Secret::new("Leaf certificate as DER") + .secret_type("x509_certificate") + .format(Format::Der) + .from("TLS_IDENTITY") + .into_config(); + assert_eq!(der.format.as_deref(), Some("der")); + der.validate().unwrap(); + + // The builder no longer has to spell the identity's encoding. + let identity = crate::spec::Secret::new("identity") + .generate(crate::spec::Generation::x509_identity(["dns:localhost"])) + .into_config(); + assert_eq!(identity.encoding, None); + assert_eq!( + identity.effective_encoding(), + Some(crate::config::SecretEncoding::Base64) + ); + identity.validate().unwrap(); +} + +// --------------------------------------------------------------------------- +// Resolution +// --------------------------------------------------------------------------- + +const GENERATED_PROFILE: &str = r#" +[profiles.default] +TLS_IDENTITY = { description = "identity", type = "x509_identity", generate = { san = ["dns:localhost", "ip:127.0.0.1"], usages = ["server_auth", "client_auth"], valid_for = "7d" } } +TLS_PFX_PASSWORD = { description = "pfx password", type = "passphrase", generate = true } +TLS_PFX = { description = "protected pfx", type = "pkcs12", from = "TLS_IDENTITY", credentials = { password = "TLS_PFX_PASSWORD" }, as_path = true } +TLS_PFX_B64 = { description = "inline pfx", type = "pkcs12", from = "TLS_IDENTITY", credentials = { password = "TLS_PFX_PASSWORD" } } +TLS_PFX_HEX = { description = "inline hex pfx", type = "pkcs12", from = "TLS_IDENTITY", encoding = "hex" } +TLS_KEY = { description = "key", type = "pkcs8_private_key", from = "TLS_IDENTITY" } +TLS_KEY_DER = { description = "key der", type = "pkcs8_private_key", format = "der", from = "TLS_IDENTITY", as_path = true } +TLS_KEY_DER_B64 = { description = "key der inline", type = "pkcs8_private_key", format = "der", from = "TLS_IDENTITY" } +TLS_CERTIFICATE = { description = "cert", type = "x509_certificate", from = "TLS_IDENTITY", as_path = true } +TLS_CERTIFICATE_DER = { description = "cert der", type = "x509_certificate", format = "der", from = "TLS_IDENTITY", as_path = true } +TLS_CHAIN = { description = "chain", type = "x509_certificate_chain", from = "TLS_IDENTITY" } +TLS_ISSUERS = { description = "issuers", type = "x509_issuer_chain", from = "TLS_IDENTITY" } +TLS_CONFIG = { description = "config", composed = "cert=${TLS_CERTIFICATE};key=${TLS_KEY}" } +"#; + +#[test] +fn generated_identity_derives_every_target_in_one_resolve() { + let _env = scrub_resolution_env(); + let spec = null_spec(GENERATED_PROFILE); + let response = spec.resolve().unwrap(); + assert!(response.is_ok(), "{:?}", response.missing_required); + + let secrets = &response.secrets; + // The canonical identity is inline Base64 of an empty-password archive. + let identity_bytes = BASE64 + .decode(secrets["TLS_IDENTITY"].value.as_deref().unwrap().as_bytes()) + .unwrap(); + let identity = open_pfx(&identity_bytes, ""); + let key = &identity.0; + let certificate = &identity.1; + assert_eq!(secrets["TLS_IDENTITY"].source, ResolvedSource::Generated); + + // The protected archive opens only with the generated password and holds + // the same key and leaf. + let password = secrets["TLS_PFX_PASSWORD"].value.as_deref().unwrap(); + assert!( + password.split(|c: char| !c.is_alphanumeric()).count() >= 6, + "passphrase: {password}" + ); + let pfx_path = secrets["TLS_PFX"].path.as_deref().unwrap(); + assert!(pfx_path.ends_with(".pfx"), "{pfx_path}"); + assert!(secrets["TLS_PFX"].as_path); + let pfx_bytes = fs::read(pfx_path).unwrap(); + let protected = open_pfx(&pfx_bytes, password); + assert!(protected.0.public_key_raw() == key.public_key_raw()); + assert_eq!(protected.1.as_der().to_vec(), certificate.as_der().to_vec()); + assert!( + Pkcs12Archive::from_pkcs12(&pfx_bytes, "").is_err(), + "protected archive must not open with an empty password" + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = fs::metadata(pfx_path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o400, "owner-only temp file"); + } + + // Binary targets without `as_path` are inline text in their encoding. + let inline_pfx = BASE64 + .decode(secrets["TLS_PFX_B64"].value.as_deref().unwrap().as_bytes()) + .unwrap(); + assert!(open_pfx(&inline_pfx, password).0.public_key_raw() == key.public_key_raw()); + let hex_pfx = HEXLOWER + .decode(secrets["TLS_PFX_HEX"].value.as_deref().unwrap().as_bytes()) + .unwrap(); + assert!(open_pfx(&hex_pfx, "").0.public_key_raw() == key.public_key_raw()); + assert!(!secrets["TLS_PFX_B64"].as_path); + + // Key projections. + let pem_key = secrets["TLS_KEY"].value.as_deref().unwrap(); + assert!(pem_key.starts_with("-----BEGIN PRIVATE KEY-----")); + assert!(key_from_pem(pem_key.as_bytes()).unwrap().public_key_raw() == key.public_key_raw()); + let der_path = secrets["TLS_KEY_DER"].path.as_deref().unwrap(); + assert!(der_path.ends_with(".der"), "{der_path}"); + let der_key = KeyPair::try_from(fs::read(der_path).unwrap().as_slice()).unwrap(); + assert!(der_key.public_key_raw() == key.public_key_raw()); + let inline_der = BASE64 + .decode( + secrets["TLS_KEY_DER_B64"] + .value + .as_deref() + .unwrap() + .as_bytes(), + ) + .unwrap(); + assert!( + KeyPair::try_from(inline_der.as_slice()) + .unwrap() + .public_key_raw() + == key.public_key_raw() + ); + + // Certificate projections. + let cert_path = secrets["TLS_CERTIFICATE"].path.as_deref().unwrap(); + assert!(cert_path.ends_with(".pem"), "{cert_path}"); + let cert_pem = fs::read(cert_path).unwrap(); + assert_eq!( + cert_from_pem(&cert_pem).unwrap().as_der().to_vec(), + certificate.as_der().to_vec() + ); + let cert_der_path = secrets["TLS_CERTIFICATE_DER"].path.as_deref().unwrap(); + assert!(cert_der_path.ends_with(".der"), "{cert_der_path}"); + assert_eq!( + fs::read(cert_der_path).unwrap(), + certificate.as_der().to_vec() + ); + assert_eq!( + secrets["TLS_CHAIN"].value.as_deref().unwrap().as_bytes(), + cert_pem.as_slice(), + "a self-signed identity's chain is its leaf" + ); + assert_eq!(secrets["TLS_ISSUERS"].value.as_deref(), Some("")); + + // Derived values feed compositions like any other secret. + let config = secrets["TLS_CONFIG"].value.as_deref().unwrap(); + assert!(config.starts_with(&format!("cert={cert_path};key=-----BEGIN PRIVATE KEY-----"))); + + for name in [ + "TLS_PFX", + "TLS_PFX_B64", + "TLS_KEY", + "TLS_KEY_DER", + "TLS_CERTIFICATE", + "TLS_CHAIN", + "TLS_ISSUERS", + ] { + assert_eq!(secrets[name].source, ResolvedSource::Composed, "{name}"); + assert_eq!(secrets[name].source_provider, None, "{name}"); + } + let names: Vec<&str> = secrets.keys().map(String::as_str).collect(); + assert_eq!( + names.len(), + 13, + "every declared name stays a flat field: {names:?}" + ); +} + +#[test] +fn a_stored_protected_identity_opens_with_its_declared_password() { + let _env = scrub_resolution_env(); + let archive = protected_identity("correct horse battery staple"); + let (_dir, spec) = dotenv_spec( + r#" +[profiles.default] +SERVICE_IDENTITY = { description = "identity", type = "x509_identity", credentials = { password = "SERVICE_IDENTITY_PASSWORD" }, as_path = true } +SERVICE_IDENTITY_PASSWORD = { description = "password" } +SERVICE_KEY = { description = "key", type = "pkcs8_private_key", from = "SERVICE_IDENTITY" } +SERVICE_CERTIFICATE = { description = "cert", type = "x509_certificate_chain", from = "SERVICE_IDENTITY" } +"#, + &format!( + "SERVICE_IDENTITY={}\nSERVICE_IDENTITY_PASSWORD=correct horse battery staple\n", + BASE64.encode(&archive) + ), + ); + let response = spec.resolve().unwrap(); + assert!(response.is_ok(), "{:?}", response.missing_required); + let secrets = &response.secrets; + + // The identity is delivered as its stored, still protected, archive. + let path = secrets["SERVICE_IDENTITY"].path.as_deref().unwrap(); + assert!(path.ends_with(".pfx"), "{path}"); + assert_eq!(fs::read(path).unwrap(), archive); + assert_eq!(secrets["SERVICE_IDENTITY"].source, ResolvedSource::Provider); + + let expected = open_pfx(&archive, "correct horse battery staple"); + let key = key_from_pem(secrets["SERVICE_KEY"].value.as_deref().unwrap().as_bytes()).unwrap(); + assert!(expected.0.public_key_raw() == key.public_key_raw()); + let chain = secrets["SERVICE_CERTIFICATE"].value.as_deref().unwrap(); + assert_eq!( + cert_from_pem(chain.as_bytes()).unwrap().as_der().to_vec(), + expected.1.as_der().to_vec() + ); + assert_eq!(secrets["SERVICE_KEY"].source, ResolvedSource::Composed); +} + +#[test] +fn a_wrong_or_unbound_password_never_falls_back_to_guessing() { + let _env = scrub_resolution_env(); + let archive = BASE64.encode(&protected_identity("right")); + + let (_dir, spec) = dotenv_spec( + r#" +[profiles.default] +SERVICE_IDENTITY = { description = "identity", type = "x509_identity", credentials = { password = "PW" } } +PW = { description = "password" } +SERVICE_KEY = { description = "key", type = "pkcs8_private_key", from = "SERVICE_IDENTITY" } +"#, + &format!("SERVICE_IDENTITY={archive}\nPW=wrong\n"), + ); + let error = spec.resolve().unwrap_err(); + assert!( + matches!(&error, SecretSpecError::DecodeFailed { name, encoding, .. } if name == "SERVICE_IDENTITY" && *encoding == "pkcs12"), + "{error}" + ); + let text = error.to_string(); + assert!(text.contains("configured password"), "{text}"); + assert!( + !text.contains("wrong"), + "must not echo the password: {text}" + ); + + let (_dir, spec) = dotenv_spec( + r#" +[profiles.default] +SERVICE_IDENTITY = { description = "identity", type = "x509_identity" } +"#, + &format!("SERVICE_IDENTITY={archive}\n"), + ); + let text = spec.resolve().unwrap_err().to_string(); + assert!(text.contains("empty password"), "{text}"); + assert!(text.contains("credentials"), "{text}"); + + // A bound password on an unprotected archive is equally wrong. + let unprotected = BASE64.encode(&generated_identity()); + let (_dir, spec) = dotenv_spec( + r#" +[profiles.default] +SERVICE_IDENTITY = { description = "identity", type = "x509_identity", credentials = { password = "PW" } } +PW = { description = "password" } +"#, + &format!("SERVICE_IDENTITY={unprotected}\nPW=surprise\n"), + ); + let text = spec.resolve().unwrap_err().to_string(); + assert!(text.contains("configured password"), "{text}"); +} + +#[test] +fn a_missing_password_secret_makes_the_identity_and_its_projections_missing() { + let _env = scrub_resolution_env(); + let archive = BASE64.encode(&protected_identity("pw")); + + let (_dir, spec) = dotenv_spec( + r#" +[profiles.default] +SERVICE_IDENTITY = { description = "identity", type = "x509_identity", credentials = { password = "PW" } } +PW = { description = "password" } +SERVICE_KEY = { description = "key", type = "pkcs8_private_key", from = "SERVICE_IDENTITY" } +OTHER = { description = "unrelated" } +"#, + &format!("SERVICE_IDENTITY={archive}\nOTHER=fine\n"), + ); + let response = spec.resolve().unwrap(); + assert!(!response.is_ok()); + assert_eq!( + response.missing_required, + vec![ + "PW".to_string(), + "SERVICE_IDENTITY".to_string(), + "SERVICE_KEY".to_string() + ] + ); + + // The value-free report agrees, so a CI gate sees the gap. + let report = spec.report().unwrap(); + let status = |name: &str| { + report + .secrets + .iter() + .find(|entry| entry.name == name) + .unwrap() + .status + .clone() + }; + assert_eq!( + status("SERVICE_IDENTITY"), + ResolutionStatus::MissingRequired + ); + assert_eq!(status("SERVICE_KEY"), ResolutionStatus::MissingRequired); + assert_eq!(status("OTHER"), ResolutionStatus::Resolved); + + // Optional declarations degrade to missing optional instead. + let (_dir, spec) = dotenv_spec( + r#" +[profiles.default] +SERVICE_IDENTITY = { description = "identity", type = "x509_identity", credentials = { password = "PW" }, required = false } +PW = { description = "password", required = false } +SERVICE_KEY = { description = "key", type = "pkcs8_private_key", from = "SERVICE_IDENTITY", required = false } +"#, + &format!("SERVICE_IDENTITY={archive}\n"), + ); + let response = spec.resolve().unwrap(); + assert!(response.is_ok()); + assert!(response.secrets.is_empty(), "{:?}", response.secrets.keys()); + assert_eq!( + response.missing_optional, + vec![ + "PW".to_string(), + "SERVICE_IDENTITY".to_string(), + "SERVICE_KEY".to_string() + ] + ); +} + +#[test] +fn empty_nul_and_oversized_credential_values_are_rejected() { + let _env = scrub_resolution_env(); + let cases = [ + ("", "empty"), + ("with\u{0}nul", "NUL"), + ("with🔑emoji", "supplementary Unicode"), + (&"x".repeat(1025), "exceeds 1024 bytes"), + ]; + for (password, expected) in cases { + let escaped = password.replace('\u{0}', "\\u0000"); + let spec = null_spec(&format!( + r#" +[profiles.default] +TLS_IDENTITY = {{ description = "identity", type = "x509_identity", generate = {{ san = ["dns:localhost"] }} }} +PW = {{ description = "password", default = "{escaped}" }} +TLS_PFX = {{ description = "pfx", type = "pkcs12", from = "TLS_IDENTITY", credentials = {{ password = "PW" }} }} +"# + )); + let error = spec.resolve().unwrap_err(); + assert!( + matches!(&error, SecretSpecError::CredentialInvalid { name, role, .. } if name == "TLS_PFX" && role == "password"), + "{expected}: {error}" + ); + assert!(error.to_string().contains(expected), "{error}"); + assert_eq!(error.kind(), "credential_invalid"); + } + + // An omitted binding is the empty-password archive, not an error. + let spec = null_spec( + r#" +[profiles.default] +TLS_IDENTITY = { description = "identity", type = "x509_identity", generate = { san = ["dns:localhost"] } } +TLS_PFX = { description = "pfx", type = "pkcs12", from = "TLS_IDENTITY" } +"#, + ); + let response = spec.resolve().unwrap(); + let pfx = BASE64 + .decode( + response.secrets["TLS_PFX"] + .value + .as_deref() + .unwrap() + .as_bytes(), + ) + .unwrap(); + open_pfx(&pfx, ""); +} + +#[test] +fn scopes_fetch_hidden_sources_and_credentials_without_exposing_them() { + let _env = scrub_resolution_env(); + let body = format!( + "{GENERATED_PROFILE}\n[scopes.windows]\nsecrets = [\"TLS_PFX\", \"TLS_PFX_PASSWORD\"]\n\n[scopes.key_only]\nsecrets = [\"TLS_KEY\"]\n" + ); + + let mut spec = null_spec(&body); + spec.set_scope("windows"); + let response = spec.resolve().unwrap(); + assert!(response.is_ok(), "{:?}", response.missing_required); + assert_eq!( + response.secrets.keys().collect::>(), + vec!["TLS_PFX", "TLS_PFX_PASSWORD"], + "the hidden identity is fetched but not exposed" + ); + let password = response.secrets["TLS_PFX_PASSWORD"] + .value + .as_deref() + .unwrap(); + let pfx = fs::read(response.secrets["TLS_PFX"].path.as_deref().unwrap()).unwrap(); + open_pfx(&pfx, password); + assert_eq!(response.scope.as_deref(), Some("windows")); + + let mut spec = null_spec(&body); + spec.set_scope("key_only"); + let response = spec.resolve().unwrap(); + assert!(response.is_ok()); + assert_eq!(response.secrets.keys().collect::>(), vec!["TLS_KEY"]); + key_from_pem( + response.secrets["TLS_KEY"] + .value + .as_deref() + .unwrap() + .as_bytes(), + ) + .unwrap(); +} + +#[test] +fn declaration_order_does_not_affect_derived_resolution() { + let _env = scrub_resolution_env(); + // Targets before their source, credentials after their consumer. + let spec = null_spec( + r#" +[profiles.default] +TLS_KEY = { description = "key", type = "pkcs8_private_key", from = "TLS_IDENTITY" } +TLS_PFX = { description = "pfx", type = "pkcs12", from = "TLS_IDENTITY", credentials = { password = "PW" } } +TLS_IDENTITY = { description = "identity", type = "x509_identity", generate = { san = ["dns:localhost"] } } +PW = { description = "password", composed = "${PART_A}-${PART_B}" } +PART_B = { description = "b", default = "beta" } +PART_A = { description = "a", default = "alpha" } +"#, + ); + let response = spec.resolve().unwrap(); + assert!(response.is_ok(), "{:?}", response.missing_required); + let pfx = BASE64 + .decode( + response.secrets["TLS_PFX"] + .value + .as_deref() + .unwrap() + .as_bytes(), + ) + .unwrap(); + let opened = open_pfx(&pfx, "alpha-beta"); + let key = key_from_pem( + response.secrets["TLS_KEY"] + .value + .as_deref() + .unwrap() + .as_bytes(), + ) + .unwrap(); + assert!(opened.0.public_key_raw() == key.public_key_raw()); +} + +#[test] +fn json_and_ini_selection_from_declared_secrets() { + let _env = scrub_resolution_env(); + let spec = null_spec( + r#" +[profiles.default] +DOCUMENT = { description = "json", default = "{\"database\":{\"password\":\"from-json\",\"port\":5432}}" } +DATABASE_PASSWORD = { description = "password", from = "DOCUMENT", extract = { format = "json", pointer = "/database/password" } } +DATABASE_PORT = { description = "port", from = "DOCUMENT", extract = { format = "json", pointer = "/database/port" }, as_path = true } +INI_DOCUMENT = { description = "ini", default = "[database]\npassword = from-ini\n", as_path = true } +INI_PASSWORD = { description = "ini password", from = "INI_DOCUMENT", extract = { format = "ini", pointer = "/database/password" } } +MISSING_FIELD = { description = "absent", from = "DOCUMENT", extract = { format = "json", pointer = "/database/nope" }, required = false } +"#, + ); + let response = spec.resolve(); + // A pointer that does not match is a hard error, not a miss: the document + // resolved and the declaration is wrong. + let error = response.unwrap_err().to_string(); + assert!(error.contains("MISSING_FIELD"), "{error}"); + assert!(error.contains("/database/nope"), "{error}"); + + let spec = null_spec( + r#" +[profiles.default] +DOCUMENT = { description = "json", default = "{\"database\":{\"password\":\"from-json\",\"port\":5432}}" } +DATABASE_PASSWORD = { description = "password", from = "DOCUMENT", extract = { format = "json", pointer = "/database/password" } } +DATABASE_PORT = { description = "port", from = "DOCUMENT", extract = { format = "json", pointer = "/database/port" }, as_path = true } +INI_DOCUMENT = { description = "ini", default = "[database]\npassword = from-ini\n", as_path = true } +INI_PASSWORD = { description = "ini password", from = "INI_DOCUMENT", extract = { format = "ini", pointer = "/database/password" } } +"#, + ); + let response = spec.resolve().unwrap(); + assert!(response.is_ok(), "{:?}", response.missing_required); + let secrets = &response.secrets; + assert_eq!( + secrets["DATABASE_PASSWORD"].value.as_deref(), + Some("from-json") + ); + assert_eq!( + fs::read_to_string(secrets["DATABASE_PORT"].path.as_deref().unwrap()).unwrap(), + "5432" + ); + assert_eq!( + secrets["INI_PASSWORD"].value.as_deref(), + Some("from-ini"), + "a selection reads an `as_path` source's file" + ); + assert_eq!( + secrets["DATABASE_PASSWORD"].source, + ResolvedSource::Composed + ); +} + +#[test] +fn rewrapping_a_stored_identity_under_a_new_password() { + let _env = scrub_resolution_env(); + let legacy = protected_identity("legacy"); + let (_dir, spec) = dotenv_spec( + r#" +[profiles.default] +LEGACY_IDENTITY = { description = "legacy", type = "x509_identity", credentials = { password = "LEGACY_PASSWORD" } } +LEGACY_PASSWORD = { description = "legacy password" } +NEW_PASSWORD = { description = "new password", default = "fresh" } +NEW_PFX = { description = "rewrapped", type = "pkcs12", from = "LEGACY_IDENTITY", credentials = { password = "NEW_PASSWORD" }, as_path = true } +"#, + &format!( + "LEGACY_IDENTITY={}\nLEGACY_PASSWORD=legacy\n", + BASE64.encode(&legacy) + ), + ); + let response = spec.resolve().unwrap(); + assert!(response.is_ok(), "{:?}", response.missing_required); + let rewrapped = fs::read(response.secrets["NEW_PFX"].path.as_deref().unwrap()).unwrap(); + assert!(Pkcs12Archive::from_pkcs12(&rewrapped, "legacy").is_err()); + let opened = open_pfx(&rewrapped, "fresh"); + let original = open_pfx(&legacy, "legacy"); + assert!(opened.0.public_key_raw() == original.0.public_key_raw()); + assert_ne!(rewrapped, legacy, "a rewrapped archive is re-encrypted"); +} + +#[test] +fn an_optional_identity_that_is_absent_omits_its_projections() { + let _env = scrub_resolution_env(); + let (_dir, spec) = dotenv_spec( + r#" +[profiles.default] +SERVICE_IDENTITY = { description = "identity", type = "x509_identity", required = false } +SERVICE_KEY = { description = "key", type = "pkcs8_private_key", from = "SERVICE_IDENTITY", required = false } +SERVICE_PFX = { description = "pfx", type = "pkcs12", from = "SERVICE_IDENTITY" } +"#, + "", + ); + let response = spec.resolve().unwrap(); + assert!(!response.is_ok()); + assert_eq!(response.missing_required, vec!["SERVICE_PFX".to_string()]); + assert_eq!( + response.missing_optional, + vec!["SERVICE_IDENTITY".to_string(), "SERVICE_KEY".to_string()] + ); +} + +#[test] +fn a_stored_identity_with_an_invalid_encoding_or_archive_fails_cleanly() { + let _env = scrub_resolution_env(); + let (_dir, spec) = dotenv_spec( + r#" +[profiles.default] +SERVICE_IDENTITY = { description = "identity", type = "x509_identity" } +"#, + "SERVICE_IDENTITY=not*base64!\n", + ); + let error = spec.resolve().unwrap_err(); + assert!( + matches!(&error, SecretSpecError::DecodeFailed { encoding, .. } if *encoding == "base64"), + "{error}" + ); + + let (_dir, spec) = dotenv_spec( + r#" +[profiles.default] +SERVICE_IDENTITY = { description = "identity", type = "x509_identity", encoding = "hex" } +"#, + &format!("SERVICE_IDENTITY={}\n", HEXLOWER.encode(b"not an archive")), + ); + let text = spec.resolve().unwrap_err().to_string(); + assert!(text.contains("invalid PKCS#12 identity"), "{text}"); +} + +// --------------------------------------------------------------------------- +// Reports and provenance +// --------------------------------------------------------------------------- + +#[test] +fn value_free_reports_predict_derivation_without_materializing() { + let _env = scrub_resolution_env(); + let archive = BASE64.encode(&protected_identity("pw")); + let (dir, spec) = dotenv_spec( + r#" +[profiles.default] +SERVICE_IDENTITY = { description = "identity", type = "x509_identity", credentials = { password = "PW" }, as_path = true } +PW = { description = "password" } +SERVICE_KEY = { description = "key", type = "pkcs8_private_key", from = "SERVICE_IDENTITY", as_path = true } +DOCUMENT = { description = "doc", default = "{\"a\":1}" } +FIELD = { description = "field", from = "DOCUMENT", extract = { format = "json", pointer = "/a" } } +DSN = { description = "dsn", composed = "x=${FIELD}" } +"#, + &format!("SERVICE_IDENTITY={archive}\nPW=pw\n"), + ); + + let before: Vec<_> = fs::read_dir(std::env::temp_dir()) + .unwrap() + .filter_map(|entry| entry.ok()) + .filter(|entry| { + let name = entry.file_name().to_string_lossy().to_string(); + name.ends_with(".pfx") || name.ends_with(".pem") + }) + .map(|entry| entry.path()) + .collect(); + + let report = spec.report().unwrap(); + let entry = |name: &str| { + report + .secrets + .iter() + .find(|entry| entry.name == name) + .unwrap_or_else(|| panic!("missing {name}")) + }; + assert_eq!(entry("SERVICE_IDENTITY").status, ResolutionStatus::Resolved); + assert!(entry("SERVICE_IDENTITY").derivation.is_none()); + assert_eq!( + entry("SERVICE_KEY").derivation, + Some(Derivation::ConvertedFrom("SERVICE_IDENTITY".to_string())) + ); + assert_eq!( + entry("FIELD").derivation, + Some(Derivation::SelectedFrom("DOCUMENT".to_string())) + ); + assert_eq!(entry("DSN").derivation, Some(Derivation::Composed)); + assert!(entry("SERVICE_KEY").composed); + assert!(entry("SERVICE_KEY").as_path); + let explained = report.to_explain_string(); + assert!( + explained.contains("converted from SERVICE_IDENTITY"), + "{explained}" + ); + assert!(explained.contains("selected from DOCUMENT"), "{explained}"); + assert!(explained.contains("ok composed"), "{explained}"); + + let after: Vec<_> = fs::read_dir(std::env::temp_dir()) + .unwrap() + .filter_map(|entry| entry.ok()) + .filter(|entry| { + let name = entry.file_name().to_string_lossy().to_string(); + name.ends_with(".pfx") || name.ends_with(".pem") + }) + .map(|entry| entry.path()) + .filter(|path| !before.contains(path)) + .collect(); + assert!( + after.is_empty(), + "report must not materialize files: {after:?}" + ); + + let stripped = spec.resolve_without_values().unwrap(); + assert!(stripped.is_ok()); + assert_eq!(stripped.secrets["SERVICE_KEY"].value, None); + assert_eq!(stripped.secrets["SERVICE_KEY"].path, None); + assert_eq!( + stripped.secrets["SERVICE_KEY"].source, + ResolvedSource::Composed + ); + drop(dir); +} + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +#[test] +fn derived_secrets_are_read_only_and_name_their_source() { + let _env = scrub_resolution_env(); + let (_dir, spec) = dotenv_spec( + r#" +[profiles.default] +SERVICE_IDENTITY = { description = "identity", type = "x509_identity" } +SERVICE_KEY = { description = "key", type = "pkcs8_private_key", from = "SERVICE_IDENTITY" } +DOCUMENT = { description = "doc" } +FIELD = { description = "field", from = "DOCUMENT", extract = { format = "json", pointer = "/a" } } +STORED_FIELD = { description = "stored field", extract = { format = "json", pointer = "/a" }, required = false } +DSN = { description = "dsn", composed = "x=${FIELD}" } +"#, + "", + ); + + let error = spec + .set("SERVICE_KEY", crate::SecretBytes::from_utf8("value")) + .unwrap_err(); + assert!( + matches!(&error, SecretSpecError::DerivedSecretReadOnly { name, from } if name == "SERVICE_KEY" && from == "SERVICE_IDENTITY"), + "{error}" + ); + assert_eq!(error.kind(), "derived_secret_read_only"); + assert!( + error + .to_string() + .contains("change 'SERVICE_IDENTITY' instead") + ); + + let error = spec + .set("FIELD", crate::SecretBytes::from_utf8("value")) + .unwrap_err(); + assert!( + matches!(&error, SecretSpecError::DerivedSecretReadOnly { from, .. } if from == "DOCUMENT"), + "a selection names its source, not the extract rule: {error}" + ); + let error = spec.delete("SERVICE_KEY").unwrap_err(); + assert!( + matches!(&error, SecretSpecError::DerivedSecretReadOnly { from, .. } if from == "SERVICE_IDENTITY"), + "{error}" + ); + let error = spec.delete("FIELD").unwrap_err(); + assert!( + matches!(&error, SecretSpecError::DerivedSecretReadOnly { .. }), + "{error}" + ); + + // Existing read-only classes are unchanged. + assert!(matches!( + spec.set("STORED_FIELD", crate::SecretBytes::from_utf8("v")) + .unwrap_err(), + SecretSpecError::ExtractedSecretReadOnly(_) + )); + assert!(matches!( + spec.set("DSN", crate::SecretBytes::from_utf8("v")) + .unwrap_err(), + SecretSpecError::ComposedSecretReadOnly(_) + )); + + // The source itself is writable, and the identity gets its stored form. + spec.set("DOCUMENT", crate::SecretBytes::from_utf8(r#"{"a":"one"}"#)) + .unwrap(); + spec.set( + "SERVICE_IDENTITY", + crate::SecretBytes::from_vec(generated_identity()), + ) + .unwrap(); + let response = spec.resolve().unwrap(); + assert!(response.is_ok(), "{:?}", response.missing_required); + assert_eq!(response.secrets["FIELD"].value.as_deref(), Some("one")); + assert!( + response.secrets["SERVICE_KEY"] + .value + .as_deref() + .unwrap() + .starts_with("-----BEGIN PRIVATE KEY-----") + ); +} + +#[test] +fn binary_and_text_setters_store_the_same_identity_representation() { + let _env = scrub_resolution_env(); + let bytes = generated_identity(); + let (_dir, spec) = dotenv_spec( + r#" +[profiles.default] +RAW = { description = "raw input", type = "x509_identity", as_path = true } +TEXT = { description = "encoded input", type = "x509_identity", as_path = true } +"#, + "", + ); + spec.set("RAW", crate::SecretBytes::from_slice(&bytes)) + .unwrap(); + spec.set_text("TEXT", &BASE64.encode(&bytes)).unwrap(); + let result = spec.resolve().unwrap(); + for name in ["RAW", "TEXT"] { + let path = result.secrets[name].path.as_ref().unwrap(); + assert_eq!(fs::read(path).unwrap(), bytes); + fs::remove_file(path).unwrap(); + } +} + +#[cfg(feature = "cli")] +#[test] +fn ipc_derived_identity_files_are_leased_and_persistent_generation_is_read_only() { + use crate::secrets::{IpcAuditPurpose, OwnedNamedResolution}; + let _env = scrub_resolution_env(); + let body = r#" +[profiles.default] +IDENTITY = { description = "identity", type = "x509_identity", generate = { san = ["dns:localhost"] } } +CERT = { description = "certificate", type = "x509_certificate", from = "IDENTITY", as_path = true } +UNRELATED = { description = "must not be resolved" } +"#; + let purpose = || IpcAuditPurpose { + consumer: "typed-test".into(), + operation: "resolve".into(), + host: None, + path: None, + }; + let mut ephemeral = null_spec(body); + ephemeral.refuse_produced_writes(); + let OwnedNamedResolution::File { file, .. } = ephemeral + .resolve_named_owned_for_ipc("CERT", purpose(), false) + .unwrap() + else { + panic!("expected an owned certificate file") + }; + let path = file.path().to_owned(); + cert_from_pem(&fs::read(&path).unwrap()).unwrap(); + assert_eq!(path.extension().unwrap(), "pem"); + drop(file); + assert!(!path.exists()); + + let (dir, mut persistent) = dotenv_spec(body, ""); + persistent.refuse_produced_writes(); + let error = persistent + .resolve_named_owned_for_ipc("CERT", purpose(), false) + .err() + .unwrap(); + assert!( + matches!(error, SecretSpecError::ProducedValueWriteRefused(_)), + "{error}" + ); + assert_eq!(fs::read_to_string(dir.path().join(".env")).unwrap(), ""); +} + +#[test] +fn derived_sources_do_not_inherit_project_storage_defaults() { + let _env = scrub_resolution_env(); + let config = manifest( + r#" +[defaults] +providers = ["null"] +[profiles.default] +IDENTITY = { description = "identity", type = "x509_identity", generate = { san = ["dns:localhost"] } } +CERT = { description = "certificate", type = "x509_certificate", from = "IDENTITY" } +"#, + ); + config.validate().unwrap(); + let result = Secrets::new(config, None, None, None).resolve().unwrap(); + cert_from_pem(result.secrets["CERT"].value.as_ref().unwrap().as_bytes()).unwrap(); +} + +#[cfg(feature = "cli")] +#[test] +fn named_identity_and_certificate_inherit_password_refresh_deadline() { + let _env = scrub_resolution_env(); + let dir = TempDir::new().unwrap(); + let source = dir.path().join("source.env"); + let cache = dir.path().join("cache.env"); + fs::write( + &source, + format!( + "ID={}\nPW=secret-password\n", + BASE64.encode(&protected_identity("secret-password")) + ), + ) + .unwrap(); + let config = manifest(&format!( + r#" +[providers] +source = {} +cache = {} +password = {{ fallback = ["source"], cache = {{ provider = "cache", max_age = "1h" }} }} +[profiles.default] +ID = {{ description = "identity", type = "x509_identity", credentials = {{ password = "PW" }}, providers = ["source"] }} +PW = {{ description = "password", providers = ["password"] }} +CERT = {{ description = "cert", type = "x509_certificate", from = "ID" }} +"#, + toml::Value::String(format!("dotenv://{}", source.display())), + toml::Value::String(format!("dotenv://{}", cache.display())) + )); + config.validate().unwrap(); + let secrets = Secrets::new(config, None, None, None); + secrets.resolve_named_owned("CERT").unwrap(); + let crate::secrets::OwnedNamedResolution::Value { + refresh_at_unix_ms: password_refresh, + .. + } = secrets.resolve_named_owned("PW").unwrap() + else { + panic!("the cached password must resolve to an inline value"); + }; + assert!(password_refresh.is_some()); + for name in ["ID", "CERT"] { + let crate::secrets::OwnedNamedResolution::Value { + refresh_at_unix_ms, .. + } = secrets.resolve_named_owned(name).unwrap() + else { + panic!("{name} must resolve to an inline value"); + }; + assert_eq!( + refresh_at_unix_ms, password_refresh, + "{name} must preserve its hidden password refresh deadline" + ); + } +} + +#[test] +fn scoped_reports_redact_hidden_conversion_and_selection_sources() { + let _env = scrub_resolution_env(); + let config = manifest( + r#" +[profiles.default] +HIDDEN_ID = { description = "identity", type = "x509_identity", generate = { san = ["dns:localhost"] } } +CERT = { description = "certificate", type = "x509_certificate", from = "HIDDEN_ID" } +HIDDEN_DOCUMENT = { description = "document", default = '{"field":"value"}' } +FIELD = { description = "field", from = "HIDDEN_DOCUMENT", extract = { format = "json", pointer = "/field" } } +[scopes.visible] +secrets = ["CERT", "FIELD"] +[scopes.all] +secrets = ["HIDDEN_ID", "CERT", "HIDDEN_DOCUMENT", "FIELD"] +"#, + ); + config.validate().unwrap(); + for scope in [Some("visible"), Some("all"), None] { + let mut secrets = Secrets::new(config.clone(), None, Some("null://".to_string()), None); + if let Some(scope) = scope { + secrets.set_scope(scope); + } + let report = secrets.report().unwrap(); + let explanation = report.to_explain_string(); + if scope == Some("visible") { + assert!(!explanation.contains("HIDDEN_ID"), "{explanation}"); + assert!(!explanation.contains("HIDDEN_DOCUMENT"), "{explanation}"); + assert_eq!(report.secrets.len(), 2); + assert!(explanation.contains(&format!( + "converted from {}", + crate::secrets::HIDDEN_SECRET_LABEL + ))); + assert!(explanation.contains(&format!( + "selected from {}", + crate::secrets::HIDDEN_SECRET_LABEL + ))); + } else { + assert!( + explanation.contains("converted from HIDDEN_ID"), + "{explanation}" + ); + assert!( + explanation.contains("selected from HIDDEN_DOCUMENT"), + "{explanation}" + ); + } + } +} diff --git a/secretspec/src/x509_identity.rs b/secretspec/src/x509_identity.rs new file mode 100644 index 000000000..729c4ccd9 --- /dev/null +++ b/secretspec/src/x509_identity.rs @@ -0,0 +1,1185 @@ +//! X.509 identity generation, bag-preserving PKCS#12 decoding, and projections. +//! Rust certificate and archive APIs share the application's AWS-LC backend. + +use crate::SecretSpecError; +use crate::config::{GenerateConfig, GenerateOptions}; +use crate::typed::{Format, Projection}; +use p12_keystore::{ + Certificate, EncryptionAlgorithm, KeyStore, KeyStoreEntry, MacAlgorithm, Pkcs12Archive, + PrivateKey, PrivateKeyChain, +}; +use rcgen::{ + CertificateParams, DistinguishedName, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair, + KeyUsagePurpose, PublicKeyData, SanType, +}; +use secrecy::zeroize::Zeroizing; +use secrecy::{ExposeSecret, SecretSlice, SecretString}; +use std::net::IpAddr; +use time::{Duration, OffsetDateTime}; +use x509_parser::prelude::{FromDer, X509Certificate}; + +const MAX_IDENTITY_BYTES: usize = 10 * 1024 * 1024; +const MAX_CHAIN_CERTIFICATES: usize = 16; +const MAX_VALID_DAYS: u32 = 200; +const PFX_ITERATIONS: u32 = 100_000; +const FRIENDLY_NAME: &str = "SecretSpec X.509 identity"; + +pub(crate) enum ProjectedValue { + Text(SecretString), + Binary(SecretSlice), +} + +fn generation_failed(context: &str, error: impl std::fmt::Display) -> SecretSpecError { + SecretSpecError::GenerationFailed(format!("{context}: {error}")) +} + +fn decode_failed(name: &str, error: impl std::fmt::Display) -> SecretSpecError { + SecretSpecError::DecodeFailed { + name: name.to_string(), + encoding: "pkcs12", + reason: error.to_string(), + } +} + +pub(crate) fn parse_valid_days(value: Option<&str>) -> Result { + let value = value.unwrap_or("30d"); + let days = value + .strip_suffix('d') + .ok_or_else(|| { + "generate.valid_for must be a whole number of days such as `30d`".to_string() + })? + .parse::() + .map_err(|_| { + "generate.valid_for must be a whole number of days such as `30d`".to_string() + })?; + if !(1..=MAX_VALID_DAYS).contains(&days) { + return Err(format!( + "X.509 generate.valid_for must be between 1d and {MAX_VALID_DAYS}d" + )); + } + Ok(days) +} + +pub(crate) fn validate_san(value: &str) -> Result<(), String> { + if let Some(dns) = value.strip_prefix("dns:") { + validate_dns_name(dns) + } else if let Some(ip) = value.strip_prefix("ip:") { + ip.parse::() + .map(|_| ()) + .map_err(|_| format!("invalid X.509 IP SAN `{value}`")) + } else { + Err(format!( + "invalid X.509 SAN `{value}`; expected `dns:name` or `ip:address`" + )) + } +} + +fn validate_dns_name(name: &str) -> Result<(), String> { + let ordinary = name.strip_prefix("*.").unwrap_or(name); + if ordinary.is_empty() + || ordinary.len() > 253 + || !ordinary.is_ascii() + || ordinary.split('.').any(|label| { + label.is_empty() + || label.len() > 63 + || label.starts_with('-') + || label.ends_with('-') + || !label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + }) + { + return Err(format!("invalid X.509 DNS SAN `dns:{name}`")); + } + Ok(()) +} + +fn options(config: &GenerateConfig) -> Result<&GenerateOptions, SecretSpecError> { + match config { + GenerateConfig::Options(options) => Ok(options), + GenerateConfig::Bool(_) => Err(SecretSpecError::GenerationFailed( + "type = \"x509_identity\" requires generate = { san = [\"dns:localhost\"] }" + .to_string(), + )), + } +} + +/// Emit the same modern encryption and integrity profile on every platform. +fn build_archive( + key: &[u8], + certificate: &Certificate, + chain: &[Certificate], + password: &str, +) -> Result, p12_keystore::error::Error> { + let mut store = KeyStore::new(); + store.add_entry( + FRIENDLY_NAME, + KeyStoreEntry::PrivateKeyChain(PrivateKeyChain::new( + "identity", + PrivateKey::from_der(key)?, + std::iter::once(certificate.clone()).chain(chain.iter().cloned()), + )), + ); + let der = Zeroizing::new( + store + .writer(password) + .encryption_algorithm(EncryptionAlgorithm::PbeWithHmacSha256AndAes256) + .encryption_iterations(PFX_ITERATIONS) + .mac_algorithm(MacAlgorithm::HmacSha256) + .mac_iterations(PFX_ITERATIONS) + .write()?, + ); + Ok(der.as_slice().to_vec().into()) +} + +pub(crate) fn generate(config: &GenerateConfig) -> crate::Result> { + let options = options(config)?; + let valid_days = parse_valid_days(options.valid_for.as_deref()) + .map_err(SecretSpecError::GenerationFailed)?; + let sans = options + .san + .as_deref() + .filter(|sans| !sans.is_empty()) + .ok_or_else(|| { + SecretSpecError::GenerationFailed("X.509 generation requires generate.san".into()) + })?; + let mut params = CertificateParams::default(); + for san in sans { + validate_san(san).map_err(SecretSpecError::GenerationFailed)?; + params + .subject_alt_names + .push(if let Some(dns) = san.strip_prefix("dns:") { + SanType::DnsName( + dns.try_into() + .map_err(|error| generation_failed("invalid DNS SAN", error))?, + ) + } else { + SanType::IpAddress( + san.strip_prefix("ip:") + .expect("validated IP SAN") + .parse() + .expect("validated IP address"), + ) + }); + } + let common_name = sans + .iter() + .find_map(|san| san.strip_prefix("dns:")) + .filter(|name| name.len() <= 64) + .unwrap_or("SecretSpec generated identity"); + params.distinguished_name = DistinguishedName::new(); + params + .distinguished_name + .push(DnType::CommonName, common_name); + params.is_ca = IsCa::ExplicitNoCa; + params.key_usages = vec![KeyUsagePurpose::DigitalSignature]; + let default_usages = ["server_auth".to_string()]; + for usage in options.usages.as_deref().unwrap_or(&default_usages) { + params.extended_key_usages.push(match usage.as_str() { + "server_auth" => ExtendedKeyUsagePurpose::ServerAuth, + "client_auth" => ExtendedKeyUsagePurpose::ClientAuth, + _ => { + return Err(SecretSpecError::GenerationFailed( + "invalid X.509 extended key usage".into(), + )); + } + }); + } + let mut serial = [0u8; 16]; + use rand_08::RngCore; + rand_08::rngs::OsRng + .try_fill_bytes(&mut serial) + .map_err(|error| generation_failed("failed to generate X.509 serial", error))?; + serial[0] |= 0x80; + params.serial_number = Some(rcgen::SerialNumber::from_slice(&serial)); + params.not_before = OffsetDateTime::now_utc() - Duration::seconds(300); + params.not_after = params.not_before + Duration::days(i64::from(valid_days)); + let key = KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256) + .map_err(|error| generation_failed("failed to generate P-256 private key", error))?; + let generated = params + .self_signed(&key) + .map_err(|error| generation_failed("failed to sign X.509 certificate", error))?; + let certificate = Certificate::from_der(generated.der()) + .map_err(|error| generation_failed("failed to encode X.509 certificate", error))?; + // Empty-password storage relies on the provider's protection. Consumers + // derive a separately protected PKCS#12 archive when they need one. + build_archive(key.serialized_der(), &certificate, &[], "") + .map_err(|error| generation_failed("failed to build PKCS#12 identity", error)) +} + +pub(crate) struct Identity { + key: SecretSlice, + certificate: Certificate, + chain: Vec, +} + +impl std::fmt::Debug for Identity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Identity") + .field("chain_len", &self.chain.len()) + .finish_non_exhaustive() + } +} + +/// x509-parser's dispatcher omits P-521 and some SHA-512 combinations. +/// Verify those with the same AWS-LC backend, without accepting a failed +/// signature under a different algorithm or ignoring its named curve. +fn signature_valid( + certificate: &X509Certificate<'_>, + issuer: Option<&x509_parser::x509::SubjectPublicKeyInfo<'_>>, +) -> bool { + use aws_lc_rs::signature; + if certificate.signature_algorithm != certificate.tbs_certificate.signature { + return false; + } + match certificate.verify_signature(issuer) { + Ok(()) => return true, + Err(x509_parser::error::X509Error::SignatureUnsupportedAlgorithm) => {} + Err(_) => return false, + } + let public_key = issuer.unwrap_or_else(|| certificate.public_key()); + if public_key.algorithm.algorithm.to_id_string() != "1.2.840.10045.2.1" { + return false; + } + let Some(curve) = public_key + .algorithm + .parameters + .as_ref() + .and_then(|value| value.as_oid().ok()) + else { + return false; + }; + let algorithm: &dyn signature::VerificationAlgorithm = match ( + curve.to_id_string().as_str(), + certificate + .signature_algorithm + .algorithm + .to_id_string() + .as_str(), + ) { + ("1.2.840.10045.3.1.7", "1.2.840.10045.4.1") => &signature::ECDSA_P256_SHA1_ASN1, + ("1.2.840.10045.3.1.7", "1.2.840.10045.4.3.4") => &signature::ECDSA_P256_SHA512_ASN1, + ("1.3.132.0.34", "1.2.840.10045.4.3.4") => &signature::ECDSA_P384_SHA512_ASN1, + ("1.3.132.0.35", "1.2.840.10045.4.1") => &signature::ECDSA_P521_SHA1_ASN1, + ("1.3.132.0.35", "1.2.840.10045.4.3.1") => &signature::ECDSA_P521_SHA224_ASN1, + ("1.3.132.0.35", "1.2.840.10045.4.3.2") => &signature::ECDSA_P521_SHA256_ASN1, + ("1.3.132.0.35", "1.2.840.10045.4.3.3") => &signature::ECDSA_P521_SHA384_ASN1, + ("1.3.132.0.35", "1.2.840.10045.4.3.4") => &signature::ECDSA_P521_SHA512_ASN1, + _ => return false, + }; + signature::UnparsedPublicKey::new(algorithm, public_key.subject_public_key.data.as_ref()) + .verify( + certificate.tbs_certificate.as_ref(), + certificate.signature_value.data.as_ref(), + ) + .is_ok() +} + +fn parsed_certificate<'a>(der: &'a [u8], name: &str) -> crate::Result> { + let (remaining, certificate) = X509Certificate::from_der(der) + .map_err(|_| decode_failed(name, "invalid X.509 certificate"))?; + if !remaining.is_empty() { + return Err(decode_failed(name, "trailing data after X.509 certificate")); + } + Ok(certificate) +} + +fn password_supported(password: Option<&str>, name: &str) -> crate::Result<()> { + if let Some(password) = password { + crate::typed::validate_credential_value("password", password).map_err(|reason| { + SecretSpecError::CredentialInvalid { + name: name.into(), + role: "password".into(), + reason, + } + })?; + } + Ok(()) +} + +impl Identity { + /// MAC verification and decryption precede application key and chain checks. + /// No alternate password, linking policy, or certificate filtering is used. + pub(crate) fn decode(bytes: &[u8], password: Option<&str>, name: &str) -> crate::Result { + if bytes.is_empty() || bytes.len() > MAX_IDENTITY_BYTES { + return Err(decode_failed( + name, + format!("PKCS#12 identity must be between 1 and {MAX_IDENTITY_BYTES} bytes"), + )); + } + password_supported(password, name)?; + let mut archive = Pkcs12Archive::from_pkcs12(bytes, password.unwrap_or("")) + .map_err(|error| decode_failed(name, match error { + p12_keystore::error::Error::DerError(_) | p12_keystore::error::Error::InvalidVersion => + "invalid PKCS#12 identity", + _ if password.is_some() => "PKCS#12 identity could not be opened with the configured password", + _ => "PKCS#12 identity could not be opened with an empty password; bind its password with `credentials = { password = \"...\" }` if the archive is protected", + }))?; + if archive.keys.len() != 1 { + return Err(decode_failed( + name, + "PKCS#12 identity must hold exactly one private key", + )); + } + if archive.certs.len() > MAX_CHAIN_CERTIFICATES + 1 { + return Err(decode_failed( + name, + format!( + "PKCS#12 identity has more than {MAX_CHAIN_CERTIFICATES} chain certificates" + ), + )); + } + let bag = archive.keys.pop().expect("exactly one private key"); + let key: SecretSlice = bag.key.as_der().to_vec().into(); + let public_key = KeyPair::try_from(key.expose_secret()) + .map_err(|_| decode_failed(name, "unsupported or invalid PKCS#8 private key; supported keys are RSA, P-256, P-384, P-521, and Ed25519"))?; + let public_der = public_key.subject_public_key_info(); + let (_, expected_public) = + x509_parser::x509::SubjectPublicKeyInfo::from_der(&public_der) + .map_err(|_| decode_failed(name, "invalid private key public component"))?; + let mut certificate_ders = std::collections::HashSet::new(); + for certificate in &archive.certs { + if !certificate_ders.insert(certificate.cert.as_der()) { + return Err(decode_failed( + name, + "duplicate certificate bags do not form one unambiguous, coherent chain", + )); + } + } + let mut matching = Vec::new(); + for (index, certificate) in archive.certs.iter().enumerate() { + let parsed = parsed_certificate(certificate.cert.as_der(), name)?; + if parsed.public_key().algorithm == expected_public.algorithm + && parsed.public_key().subject_public_key == expected_public.subject_public_key + { + matching.push(index); + } + } + // A CA and its leaf may reuse the same public key. The local key ID + // identifies the leaf in that case, but never substitutes for matching + // its actual public key or validating every remaining certificate bag. + if matching.len() > 1 + && let Some(key_id) = &bag.local_key_id + { + matching.retain(|index| { + archive.certs[*index].local_key_id.as_deref() == Some(key_id.as_ref()) + }); + } + if matching.len() != 1 { + return Err(decode_failed( + name, + "PKCS#12 identity must hold one leaf certificate that matches the private key", + )); + } + let certificate = archive.certs.remove(matching[0]).cert; + let mut remaining: Vec = + archive.certs.into_iter().map(|bag| bag.cert).collect(); + let leaf = parsed_certificate(certificate.as_der(), name)?; + let now = x509_parser::time::ASN1Time::now(); + if leaf.validity().not_before > now { + return Err(decode_failed(name, "leaf certificate is not valid yet")); + } + if leaf.validity().not_after < now { + return Err(decode_failed(name, "leaf certificate has expired")); + } + for issuer in &remaining { + if !parsed_certificate(issuer.as_der(), name)? + .validity() + .is_valid_at(now) + { + return Err(decode_failed( + name, + "PKCS#12 certificate chain contains a certificate outside its validity period", + )); + } + } + let mut chain: Vec = Vec::with_capacity(remaining.len()); + while !remaining.is_empty() { + let child = parsed_certificate(chain.last().unwrap_or(&certificate).as_der(), name)?; + let mut matching = Vec::new(); + for (index, candidate) in remaining.iter().enumerate() { + let issuer = parsed_certificate(candidate.as_der(), name)?; + if issuer.subject() == child.issuer() + && signature_valid(&child, Some(issuer.public_key())) + { + matching.push(index); + } + } + if matching.len() != 1 { + return Err(decode_failed( + name, + "PKCS#12 certificate bags do not form one unambiguous, coherent chain", + )); + } + chain.push(remaining.remove(matching[0])); + } + // Matching names make a certificate self-issued, not necessarily + // self-signed. A supplied issuer chain has already verified its + // signature with the issuer's key, which may differ from the leaf's. + if chain.is_empty() && leaf.issuer() == leaf.subject() && !signature_valid(&leaf, None) { + return Err(decode_failed( + name, + "self-signed leaf certificate has an invalid signature", + )); + } + Ok(Self { + key, + certificate, + chain, + }) + } + + #[cfg(test)] + pub(crate) fn chain_len(&self) -> usize { + self.chain.len() + } + + pub(crate) fn to_pkcs12( + &self, + password: Option<&str>, + name: &str, + ) -> crate::Result> { + password_supported(password, name)?; + build_archive( + self.key.expose_secret(), + &self.certificate, + &self.chain, + password.unwrap_or(""), + ) + .map_err(|_| decode_failed(name, "failed to build PKCS#12 archive")) + } + + pub(crate) fn project( + &self, + projection: Projection, + format: Option, + password: Option<&str>, + name: &str, + ) -> crate::Result { + let format = format.unwrap_or(Format::Pem); + match projection { + Projection::Pkcs12 => self.to_pkcs12(password, name).map(ProjectedValue::Binary), + Projection::Pkcs8PrivateKey => match format { + Format::Der => Ok(ProjectedValue::Binary( + self.key.expose_secret().to_vec().into(), + )), + Format::Pem => Ok(ProjectedValue::Text(secret_pem( + "PRIVATE KEY", + self.key.expose_secret(), + ))), + }, + Projection::Certificate => match format { + Format::Der => Ok(ProjectedValue::Binary( + self.certificate.as_der().to_vec().into(), + )), + Format::Pem => Ok(ProjectedValue::Text(secret_pem( + "CERTIFICATE", + self.certificate.as_der(), + ))), + }, + Projection::CertificateChain | Projection::IssuerChain => { + let mut output = Zeroizing::new(String::new()); + if projection == Projection::CertificateChain { + output.push_str( + secret_pem("CERTIFICATE", self.certificate.as_der()).expose_secret(), + ); + } + for issuer in &self.chain { + output.push_str(secret_pem("CERTIFICATE", issuer.as_der()).expose_secret()); + } + Ok(ProjectedValue::Text(SecretString::new( + output.as_str().into(), + ))) + } + } + } +} + +pub(crate) fn validate(bytes: &[u8], name: &str) -> crate::Result<()> { + Identity::decode(bytes, None, name).map(|_| ()) +} + +fn secret_pem(label: &str, der: &[u8]) -> SecretString { + let encoded = Zeroizing::new(data_encoding::BASE64.encode(der)); + let mut output = Zeroizing::new(format!("-----BEGIN {label}-----\n")); + for line in encoded.as_bytes().chunks(64) { + output.push_str(std::str::from_utf8(line).expect("Base64 is ASCII")); + output.push('\n'); + } + output.push_str(&format!("-----END {label}-----\n")); + SecretString::new(output.as_str().into()) +} + +#[cfg(test)] +pub(crate) mod test_support { + use super::*; + + pub(crate) fn key_from_pem(bytes: &[u8]) -> Result { + KeyPair::from_pem(std::str::from_utf8(bytes).unwrap()) + } + pub(crate) fn cert_from_pem(bytes: &[u8]) -> Result { + let pem = pem::parse(bytes).unwrap(); + Certificate::from_der(pem.contents()) + } + pub(crate) fn pem_text(certificate: &Certificate) -> String { + secret_pem("CERTIFICATE", certificate.as_der()) + .expose_secret() + .to_owned() + } + pub(crate) fn open_pfx(bytes: &[u8], password: &str) -> (KeyPair, Certificate) { + let mut archive = Pkcs12Archive::from_pkcs12(bytes, password).unwrap(); + assert_eq!(archive.keys.len(), 1); + let key = KeyPair::try_from(archive.keys.remove(0).key.as_der()).unwrap(); + let leaf = archive + .certs + .into_iter() + .find(|bag| { + parsed_certificate(bag.cert.as_der(), "fixture") + .unwrap() + .public_key() + .subject_public_key + .data + .as_ref() + == key.public_key_raw() + }) + .unwrap() + .cert; + (key, leaf) + } +} +#[cfg(test)] +mod tests { + use super::test_support::*; + use super::*; + use crate::config::GenerateOptions; + use secrecy::ExposeSecret; + + fn config() -> GenerateConfig { + GenerateConfig::Options(GenerateOptions { + algorithm: Some("p256".to_string()), + san: Some(vec![ + "dns:localhost".to_string(), + "ip:127.0.0.1".to_string(), + ]), + usages: Some(vec!["server_auth".to_string()]), + valid_for: Some("30d".to_string()), + ..Default::default() + }) + } + + fn p256() -> KeyPair { + KeyPair::generate().unwrap() + } + + fn certificate( + subject: &str, + key: &KeyPair, + issuer: Option<(&KeyPair, &Certificate)>, + ca: bool, + not_before: i64, + not_after: i64, + ) -> Certificate { + let mut params = CertificateParams::default(); + params.distinguished_name = DistinguishedName::new(); + params.distinguished_name.push(DnType::CommonName, subject); + params.is_ca = if ca { + IsCa::Ca(rcgen::BasicConstraints::Unconstrained) + } else { + IsCa::ExplicitNoCa + }; + let now = OffsetDateTime::now_utc(); + params.not_before = now + Duration::seconds(not_before); + params.not_after = now + Duration::seconds(not_after); + let generated = if let Some((issuer_key, issuer_cert)) = issuer { + let parsed = parsed_certificate(issuer_cert.as_der(), "fixture").unwrap(); + let common_name = parsed + .subject() + .iter_common_name() + .next() + .unwrap() + .as_str() + .unwrap(); + let mut issuer_params = CertificateParams::default(); + issuer_params.distinguished_name = DistinguishedName::new(); + issuer_params + .distinguished_name + .push(DnType::CommonName, common_name); + params + .signed_by(key, &rcgen::Issuer::new(issuer_params, issuer_key)) + .unwrap() + } else { + params.self_signed(key).unwrap() + }; + Certificate::from_der(generated.der()).unwrap() + } + + const DAY: i64 = 86_400; + + /// Root -> intermediate -> leaf, all currently valid. + struct Chain { + root: Certificate, + intermediate: Certificate, + leaf_key: KeyPair, + leaf: Certificate, + } + + fn chain() -> Chain { + let root_key = p256(); + let root = certificate("Test Root", &root_key, None, true, -DAY, 365 * DAY); + let intermediate_key = p256(); + let intermediate = certificate( + "Test Intermediate", + &intermediate_key, + Some((&root_key, &root)), + true, + -DAY, + 180 * DAY, + ); + let leaf_key = p256(); + let leaf = certificate( + "leaf.example", + &leaf_key, + Some((&intermediate_key, &intermediate)), + false, + -DAY, + 30 * DAY, + ); + Chain { + root, + intermediate, + leaf_key, + leaf, + } + } + + fn archive(key: &KeyPair, leaf: &Certificate, bags: &[Certificate], password: &str) -> Vec { + build_archive(key.serialized_der(), leaf, bags, password) + .unwrap() + .expose_secret() + .to_vec() + } + + #[test] + fn generates_and_projects_a_matching_identity() { + let identity = generate(&config()).unwrap(); + validate(identity.expose_secret(), "TLS_IDENTITY").unwrap(); + let identity = Identity::decode(identity.expose_secret(), None, "TLS_IDENTITY").unwrap(); + assert_eq!(identity.chain_len(), 0); + + let ProjectedValue::Text(key) = identity + .project(Projection::Pkcs8PrivateKey, None, None, "TLS_KEY") + .unwrap() + else { + panic!("expected PEM text") + }; + assert!( + key.expose_secret() + .starts_with("-----BEGIN PRIVATE KEY-----") + ); + let ProjectedValue::Binary(key_der) = identity + .project( + Projection::Pkcs8PrivateKey, + Some(Format::Der), + None, + "TLS_KEY_DER", + ) + .unwrap() + else { + panic!("expected DER bytes") + }; + let from_der = KeyPair::try_from(key_der.expose_secret()).unwrap(); + assert_eq!( + from_der.public_key_raw(), + KeyPair::try_from(identity.key.expose_secret()) + .unwrap() + .public_key_raw() + ); + + let ProjectedValue::Text(certificate) = identity + .project(Projection::Certificate, None, None, "TLS_CERTIFICATE") + .unwrap() + else { + panic!("expected PEM text") + }; + assert!( + certificate + .expose_secret() + .starts_with("-----BEGIN CERTIFICATE-----") + ); + let ProjectedValue::Text(chain) = identity + .project(Projection::CertificateChain, None, None, "TLS_CHAIN") + .unwrap() + else { + panic!("expected PEM text") + }; + assert_eq!( + chain.expose_secret(), + certificate.expose_secret(), + "a self-signed identity's chain is just its leaf" + ); + let ProjectedValue::Text(issuers) = identity + .project(Projection::IssuerChain, None, None, "TLS_ISSUERS") + .unwrap() + else { + panic!("expected PEM text") + }; + assert!(issuers.expose_secret().is_empty()); + } + + #[test] + fn self_issued_leaf_uses_its_distinct_issuers_key() { + let root_key = p256(); + let root = certificate("same-name", &root_key, None, true, -DAY, 365 * DAY); + let leaf_key = p256(); + let leaf = certificate( + "same-name", + &leaf_key, + Some((&root_key, &root)), + false, + -DAY, + 30 * DAY, + ); + let parsed = parsed_certificate(leaf.as_der(), "LEAF").unwrap(); + assert_eq!(parsed.subject(), parsed.issuer()); + assert!(!signature_valid(&parsed, None)); + assert!(signature_valid( + &parsed, + Some( + parsed_certificate(root.as_der(), "ROOT") + .unwrap() + .public_key() + ) + )); + let encoded = archive(&leaf_key, &leaf, std::slice::from_ref(&root), "pw"); + let decoded = Identity::decode(&encoded, Some("pw"), "ID").unwrap(); + assert_eq!(decoded.certificate.as_der(), leaf.as_der()); + assert_eq!(decoded.chain_len(), 1); + assert_eq!(decoded.chain[0].as_der(), root.as_der()); + } + + #[test] + fn unicode_subjects_survive_rewrapping_in_every_chain_certificate() { + let root_key = p256(); + let root = certificate("Root🚀", &root_key, None, true, -DAY, 365 * DAY); + let leaf_key = p256(); + let leaf = certificate( + "Leaf🔑", + &leaf_key, + Some((&root_key, &root)), + false, + -DAY, + 30 * DAY, + ); + let encoded = archive(&leaf_key, &leaf, std::slice::from_ref(&root), "old"); + let decoded = Identity::decode(&encoded, Some("old"), "ID").unwrap(); + let rewrapped = decoded.to_pkcs12(Some("new"), "PFX").unwrap(); + let reopened = Identity::decode(rewrapped.expose_secret(), Some("new"), "PFX").unwrap(); + assert_eq!(reopened.key.expose_secret(), decoded.key.expose_secret()); + assert_eq!(reopened.certificate.as_der(), leaf.as_der()); + assert_eq!(reopened.chain_len(), 1); + assert_eq!(reopened.chain[0].as_der(), root.as_der()); + let bags = Pkcs12Archive::from_pkcs12(rewrapped.expose_secret(), "new").unwrap(); + assert!(bags.certs.iter().all(|bag| bag.friendly_name.is_none())); + assert_eq!( + bags.keys[0].local_key_id.as_ref().map(|id| id.as_ref()), + bags.certs[0].local_key_id.as_deref(), + ); + } + + #[test] + fn generation_uses_fallback_cn_for_long_valid_dns_san() { + let long_dns = format!("{}.example.internal", "a".repeat(63)); + assert!(long_dns.len() > 64); + let generated = generate(&GenerateConfig::Options(GenerateOptions { + san: Some(vec![format!("dns:{long_dns}")]), + ..Default::default() + })) + .unwrap(); + let identity = Identity::decode(generated.expose_secret(), None, "TLS_IDENTITY").unwrap(); + let parsed = parsed_certificate(identity.certificate.as_der(), "ID").unwrap(); + let common_name = parsed + .subject() + .iter_common_name() + .next() + .unwrap() + .as_str() + .unwrap(); + assert_eq!(common_name, "SecretSpec generated identity"); + } + + #[test] + fn protected_archives_open_only_with_their_password() { + let generated = generate(&config()).unwrap(); + let identity = Identity::decode(generated.expose_secret(), None, "ID").unwrap(); + let protected = identity + .to_pkcs12(Some("correct horse battery staple"), "PFX") + .unwrap(); + + // The configured password opens it and the identity is intact. + let reopened = Identity::decode( + protected.expose_secret(), + Some("correct horse battery staple"), + "PFX", + ) + .unwrap(); + assert_eq!( + KeyPair::try_from(reopened.key.expose_secret()) + .unwrap() + .public_key_raw(), + KeyPair::try_from(identity.key.expose_secret()) + .unwrap() + .public_key_raw() + ); + assert_eq!( + reopened.certificate.as_der().to_vec(), + identity.certificate.as_der().to_vec() + ); + + // Neither a wrong password nor a missing one falls back to guessing. + let wrong = Identity::decode(protected.expose_secret(), Some("wrong"), "PFX") + .unwrap_err() + .to_string(); + assert!(wrong.contains("configured password"), "{wrong}"); + assert!( + !wrong.contains("wrong"), + "must not echo the password: {wrong}" + ); + let missing = Identity::decode(protected.expose_secret(), None, "PFX") + .unwrap_err() + .to_string(); + assert!(missing.contains("empty password"), "{missing}"); + assert!(missing.contains("credentials"), "{missing}"); + + // And a password is not accepted for an archive that has none. + let unprotected = Identity::decode(generated.expose_secret(), Some("x"), "ID") + .unwrap_err() + .to_string(); + assert!(unprotected.contains("configured password"), "{unprotected}"); + } + + #[test] + fn rewrapping_replaces_the_password_and_keeps_the_chain() { + let chain = chain(); + let legacy = archive( + &chain.leaf_key, + &chain.leaf, + &[chain.intermediate.clone(), chain.root.clone()], + "legacy", + ); + let identity = Identity::decode(&legacy, Some("legacy"), "LEGACY").unwrap(); + assert_eq!(identity.chain_len(), 2); + + let rewrapped = identity.to_pkcs12(Some("fresh"), "NEW").unwrap(); + assert!(Identity::decode(rewrapped.expose_secret(), Some("legacy"), "NEW").is_err()); + let reopened = Identity::decode(rewrapped.expose_secret(), Some("fresh"), "NEW").unwrap(); + assert_eq!(reopened.chain_len(), 2); + assert_eq!( + reopened.chain[0].as_der().to_vec(), + chain.intermediate.as_der().to_vec() + ); + assert_eq!( + reopened.chain[1].as_der().to_vec(), + chain.root.as_der().to_vec() + ); + } + + #[test] + fn unordered_bags_are_reconstructed_into_one_leaf_to_root_chain() { + let chain = chain(); + // Root before intermediate: the bag order is not the chain order. + let bytes = archive( + &chain.leaf_key, + &chain.leaf, + &[chain.root.clone(), chain.intermediate.clone()], + "", + ); + let identity = Identity::decode(&bytes, None, "ID").unwrap(); + assert_eq!( + identity + .chain + .iter() + .map(|cert| cert.as_der().to_vec()) + .collect::>(), + vec![ + chain.intermediate.as_der().to_vec(), + chain.root.as_der().to_vec() + ] + ); + + let ProjectedValue::Text(full) = identity + .project(Projection::CertificateChain, None, None, "CHAIN") + .unwrap() + else { + panic!("expected PEM text") + }; + let ProjectedValue::Text(issuers) = identity + .project(Projection::IssuerChain, None, None, "ISSUERS") + .unwrap() + else { + panic!("expected PEM text") + }; + let leaf_pem = pem_text(&chain.leaf); + let intermediate_pem = pem_text(&chain.intermediate); + let root_pem = pem_text(&chain.root); + assert_eq!( + full.expose_secret(), + format!("{leaf_pem}{intermediate_pem}{root_pem}") + ); + assert_eq!( + issuers.expose_secret(), + format!("{intermediate_pem}{root_pem}") + ); + assert_eq!(full.expose_secret().matches("BEGIN CERTIFICATE").count(), 3); + } + + #[test] + fn unrelated_duplicate_and_expired_chain_bags_are_rejected() { + let chain = chain(); + + let stranger_key = p256(); + let stranger = certificate("Stranger", &stranger_key, None, true, -DAY, DAY); + let unrelated = archive( + &chain.leaf_key, + &chain.leaf, + &[chain.intermediate.clone(), chain.root.clone(), stranger], + "", + ); + let error = Identity::decode(&unrelated, None, "ID") + .unwrap_err() + .to_string(); + assert!(error.contains("unambiguous, coherent chain"), "{error}"); + + let duplicated = archive( + &chain.leaf_key, + &chain.leaf, + &[ + chain.intermediate.clone(), + chain.intermediate.clone(), + chain.root.clone(), + ], + "", + ); + let error = Identity::decode(&duplicated, None, "ID") + .unwrap_err() + .to_string(); + assert!(error.contains("unambiguous, coherent chain"), "{error}"); + + let root_key = p256(); + let expired_root = certificate("Old Root", &root_key, None, true, -3 * DAY, -DAY); + let leaf_key = p256(); + let leaf = certificate( + "leaf.example", + &leaf_key, + Some((&root_key, &expired_root)), + false, + -DAY, + DAY, + ); + let expired_issuer = archive(&leaf_key, &leaf, &[expired_root], ""); + let error = Identity::decode(&expired_issuer, None, "ID") + .unwrap_err() + .to_string(); + assert!(error.contains("outside its validity period"), "{error}"); + } + + #[test] + fn leaf_validity_and_key_match_are_enforced() { + let key = p256(); + let expired = certificate("expired.example", &key, None, false, -3 * DAY, -DAY); + let error = Identity::decode(&archive(&key, &expired, &[], ""), None, "ID") + .unwrap_err() + .to_string(); + assert!(error.contains("has expired"), "{error}"); + + let future = certificate("future.example", &key, None, false, DAY, 3 * DAY); + let error = Identity::decode(&archive(&key, &future, &[], ""), None, "ID") + .unwrap_err() + .to_string(); + assert!(error.contains("not valid yet"), "{error}"); + + let other_key = p256(); + let mismatched = certificate("other.example", &other_key, None, false, -DAY, DAY); + let bytes = archive(&key, &mismatched, &[], ""); + let error = Identity::decode(&bytes, None, "ID") + .unwrap_err() + .to_string(); + assert!(error.contains("matches the private key"), "{error}"); + } + + #[test] + fn oversized_empty_and_overlong_chain_archives_are_rejected() { + let empty = Identity::decode(&[], None, "ID").unwrap_err().to_string(); + assert!(empty.contains("between 1 and"), "{empty}"); + let huge = vec![0u8; MAX_IDENTITY_BYTES + 1]; + let oversized = Identity::decode(&huge, None, "ID").unwrap_err().to_string(); + assert!(oversized.contains("between 1 and"), "{oversized}"); + let garbage = Identity::decode(b"not a pfx", None, "ID") + .unwrap_err() + .to_string(); + assert!(garbage.contains("invalid PKCS#12 identity"), "{garbage}"); + + // Seventeen issuer bags exceed the chain cap before any chain walk. + let mut issuers = Vec::new(); + let mut parent: Option<(KeyPair, Certificate)> = None; + for index in 0..=MAX_CHAIN_CERTIFICATES { + let key = p256(); + let cert = certificate( + &format!("CA {index}"), + &key, + parent.as_ref().map(|(key, cert)| (key, cert)), + true, + -DAY, + DAY, + ); + issuers.push(cert.clone()); + parent = Some((key, cert)); + } + let (issuer_key, issuer) = parent.unwrap(); + let leaf_key = p256(); + let leaf = certificate( + "leaf.example", + &leaf_key, + Some((&issuer_key, &issuer)), + false, + -DAY, + DAY, + ); + let error = Identity::decode(&archive(&leaf_key, &leaf, &issuers, ""), None, "ID") + .unwrap_err() + .to_string(); + assert!(error.contains("more than 16 chain certificates"), "{error}"); + } + + #[test] + fn local_key_id_identifies_a_leaf_that_reuses_its_issuers_key() { + let key = p256(); + let root = certificate("Shared-key root", &key, None, true, -DAY, DAY); + let leaf = certificate("leaf.example", &key, Some((&key, &root)), false, -DAY, DAY); + let bytes = archive(&key, &leaf, &[root], ""); + let identity = Identity::decode(&bytes, None, "ID").unwrap(); + assert_eq!(identity.certificate.as_der(), leaf.as_der()); + assert_eq!(identity.chain_len(), 1); + } + + #[test] + fn validates_san_and_validity_bounds() { + assert!(validate_san("dns:*.example.com").is_ok()); + assert!(validate_san("ip:::1").is_ok()); + assert!(validate_san("example.com").is_err()); + assert!(validate_san("dns:bad_name").is_err()); + assert!(parse_valid_days(Some("200d")).is_ok()); + assert!(parse_valid_days(Some("201d")).is_err()); + } +} + +#[cfg(test)] +mod interoperability_tests { + use super::*; + + #[test] + fn imports_openssl_archives_and_preserves_every_supported_key_type() { + for (archive, expected_certificate) in [ + ( + include_bytes!("../tests/fixtures/x509/p256.pfx").as_slice(), + include_bytes!("../tests/fixtures/x509/p256.der").as_slice(), + ), + ( + include_bytes!("../tests/fixtures/x509/p384.pfx").as_slice(), + include_bytes!("../tests/fixtures/x509/p384.der").as_slice(), + ), + ( + include_bytes!("../tests/fixtures/x509/p521.pfx").as_slice(), + include_bytes!("../tests/fixtures/x509/p521.der").as_slice(), + ), + ( + include_bytes!("../tests/fixtures/x509/rsa.pfx").as_slice(), + include_bytes!("../tests/fixtures/x509/rsa.der").as_slice(), + ), + ( + include_bytes!("../tests/fixtures/x509/ed25519.pfx").as_slice(), + include_bytes!("../tests/fixtures/x509/ed25519.der").as_slice(), + ), + ( + include_bytes!("../tests/fixtures/x509/legacy-3des.pfx").as_slice(), + include_bytes!("../tests/fixtures/x509/p256.der").as_slice(), + ), + ] { + let identity = Identity::decode(archive, Some("fixture-password"), "ID").unwrap(); + assert_eq!(identity.certificate.as_der(), expected_certificate); + let rewrapped = identity.to_pkcs12(Some("päss漢字"), "PFX").unwrap(); + let reopened = + Identity::decode(rewrapped.expose_secret(), Some("päss漢字"), "PFX").unwrap(); + assert_eq!(reopened.certificate.as_der(), expected_certificate); + assert_eq!(reopened.key.expose_secret(), identity.key.expose_secret()); + } + } + + #[test] + fn bmp_passwords_interoperate_and_supplementary_passwords_fail_explicitly() { + Identity::decode( + include_bytes!("../tests/fixtures/x509/bmp-password.pfx"), + Some("päss漢字"), + "ID", + ) + .unwrap(); + let error = Identity::decode( + include_bytes!("../tests/fixtures/x509/supplementary-password.pfx"), + Some("päss🔑"), + "ID", + ) + .unwrap_err(); + assert!( + matches!(&error, SecretSpecError::CredentialInvalid { name, role, .. } if name == "ID" && role == "password") + ); + assert!(error.to_string().contains("supplementary Unicode")); + assert!(!error.to_string().contains("päss")); + let identity = Identity::decode( + include_bytes!("../tests/fixtures/x509/p256.pfx"), + Some("fixture-password"), + "ID", + ) + .unwrap(); + assert!(matches!( + identity.to_pkcs12(Some("päss🔑"), "PFX"), + Err(SecretSpecError::CredentialInvalid { .. }) + )); + } + + #[test] + fn rejects_multiple_keys_and_invalid_self_signatures_without_filtering_bags() { + let archive = Pkcs12Archive::from_pkcs12( + include_bytes!("../tests/fixtures/x509/p256.pfx"), + "fixture-password", + ) + .unwrap(); + let key = archive.keys[0].key.clone(); + let certificate = archive.certs[0].cert.clone(); + let mut store = KeyStore::new(); + store.add_entry( + "identity", + KeyStoreEntry::PrivateKeyChain(PrivateKeyChain::new( + "identity", + key.clone(), + [certificate.clone()], + )), + ); + store.add_entry( + "extra key", + KeyStoreEntry::PrivateKeyChain(PrivateKeyChain::new("extra", key.clone(), [])), + ); + let bytes = store.writer("").write().unwrap(); + let error = Identity::decode(&bytes, None, "ID") + .unwrap_err() + .to_string(); + assert!(error.contains("exactly one private key"), "{error}"); + + let mut corrupt = certificate.as_der().to_vec(); + *corrupt.last_mut().unwrap() ^= 1; + let corrupt = Certificate::from_der(&corrupt).unwrap(); + let bytes = build_archive(key.as_der(), &corrupt, &[], "").unwrap(); + let error = Identity::decode(bytes.expose_secret(), None, "ID") + .unwrap_err() + .to_string(); + assert!(error.contains("invalid signature"), "{error}"); + } +} diff --git a/secretspec/tests/config_schemas.rs b/secretspec/tests/config_schemas.rs index aa8bfaf51..af519514d 100644 --- a/secretspec/tests/config_schemas.rs +++ b/secretspec/tests/config_schemas.rs @@ -70,6 +70,15 @@ FILE = { as_path = true, encoding = "base64", ref = { item = "file", field = "va EXTRACTED = { refs = { vault = { item = "data" } }, extract = { format = "json", pointer = "/token" } } PGP = { type = "openpgp_private_key", generate = { user_id = "Test ", algorithm = "rsa", bits = 3072, capabilities = ["sign"] } } SSH = { type = "ssh_private_key", generate = { comment = "test" } } +PASSPHRASE = { type = "passphrase", generate = { words = 8, separator = "." } } +MNEMONIC = { type = "mnemonic", generate = { algorithm = "bip39", language = "english", words = 24 } } +WG = { type = "wireguard_private_key", generate = true } +JWK = { type = "jwk_private_key", generate = { algorithm = "p256", kid = "service" } } +AGE = { type = "age_identity", generate = true } +IDENTITY = { type = "x509_identity", generate = { san = ["dns:localhost"], issuer = "self_signed", usages = ["server_auth"], valid_for = "30d" } } +CERT = { type = "x509_certificate", from = "IDENTITY", format = "der", as_path = true } +PFX = { type = "pkcs12", from = "IDENTITY", credentials = { password = "TOKEN" } } + [profiles.production.defaults] inherit = false default = "production" diff --git a/secretspec/tests/fixtures/x509/README.md b/secretspec/tests/fixtures/x509/README.md new file mode 100644 index 000000000..0c82c36bb --- /dev/null +++ b/secretspec/tests/fixtures/x509/README.md @@ -0,0 +1,16 @@ +# OpenSSL interoperability fixtures + +These archives contain disposable test keys and self-signed certificates for +`fixture.example`. Their passwords are public test data. Generated with +OpenSSL 3.6.4 using `python3 generate.py`; OpenSSL is needed only to regenerate +fixtures, not to build or run the Rust tests. + +The RSA, P-256, P-384, P-521, and Ed25519 archives use password +`fixture-password`, AES-256-CBC with PBKDF2/HMAC-SHA-256, a SHA-256 MAC, and +100,000 iterations. The legacy archive uses 3DES/SHA-1. The Unicode archives +use `päss漢字` and `päss🔑`. The `.der` files record the expected leaf certificate. +Certificates expire in 2126 so fixture lifetime does not drive routine updates. + +Regeneration replaces every fixture with newly generated keys and certificates. +The tests compare each archive with its corresponding certificate rather than +assuming a particular random key or serial number. diff --git a/secretspec/tests/fixtures/x509/bmp-password.pfx b/secretspec/tests/fixtures/x509/bmp-password.pfx new file mode 100644 index 000000000..3430851ff Binary files /dev/null and b/secretspec/tests/fixtures/x509/bmp-password.pfx differ diff --git a/secretspec/tests/fixtures/x509/ed25519.der b/secretspec/tests/fixtures/x509/ed25519.der new file mode 100644 index 000000000..172c26282 Binary files /dev/null and b/secretspec/tests/fixtures/x509/ed25519.der differ diff --git a/secretspec/tests/fixtures/x509/ed25519.pfx b/secretspec/tests/fixtures/x509/ed25519.pfx new file mode 100644 index 000000000..9af6f4f6c Binary files /dev/null and b/secretspec/tests/fixtures/x509/ed25519.pfx differ diff --git a/secretspec/tests/fixtures/x509/generate.py b/secretspec/tests/fixtures/x509/generate.py new file mode 100644 index 000000000..407d4e8b8 --- /dev/null +++ b/secretspec/tests/fixtures/x509/generate.py @@ -0,0 +1,52 @@ +"""Regenerate public interoperability fixtures with the OpenSSL CLI.""" + +from pathlib import Path +import subprocess +import tempfile + +OUTPUT = Path(__file__).resolve().parent + + +def run(*args): + subprocess.run( + ["openssl", *map(str, args)], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + + +with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for name, kind, options in [ + ("p256", "EC", ["-pkeyopt", "ec_paramgen_curve:P-256"]), + ("p384", "EC", ["-pkeyopt", "ec_paramgen_curve:P-384"]), + ("p521", "EC", ["-pkeyopt", "ec_paramgen_curve:P-521"]), + ("rsa", "RSA", ["-pkeyopt", "rsa_keygen_bits:2048"]), + ("ed25519", "ED25519", []), + ]: + key = root / f"{name}.key" + certificate = root / f"{name}.pem" + run("genpkey", "-algorithm", kind, *options, "-out", key) + run( + "req", "-new", "-x509", "-key", key, "-out", certificate, + "-subj", "/CN=fixture.example", "-days", "36500", + "-addext", "subjectAltName=DNS:fixture.example", + ) + run("x509", "-in", certificate, "-outform", "DER", "-out", OUTPUT / f"{name}.der") + export = [ + "pkcs12", "-export", "-inkey", key, "-in", certificate, + "-name", "fixture", "-iter", "100000", + "-keypbe", "AES-256-CBC", "-certpbe", "AES-256-CBC", "-macalg", "sha256", + ] + run(*export, "-passout", "pass:fixture-password", "-out", OUTPUT / f"{name}.pfx") + if name == "p256": + run(*export, "-passout", "pass:päss漢字", "-out", OUTPUT / "bmp-password.pfx") + run(*export, "-passout", "pass:päss🔑", "-out", OUTPUT / "supplementary-password.pfx") + run( + *export, "-keypbe", "PBE-SHA1-3DES", "-certpbe", "PBE-SHA1-3DES", + "-macalg", "sha1", "-passout", "pass:fixture-password", + "-out", OUTPUT / "legacy-3des.pfx", + ) + +print("Generated OpenSSL interoperability fixtures, including legacy 3DES and Unicode passwords.") diff --git a/secretspec/tests/fixtures/x509/legacy-3des.pfx b/secretspec/tests/fixtures/x509/legacy-3des.pfx new file mode 100644 index 000000000..c873ba9c6 Binary files /dev/null and b/secretspec/tests/fixtures/x509/legacy-3des.pfx differ diff --git a/secretspec/tests/fixtures/x509/p256.der b/secretspec/tests/fixtures/x509/p256.der new file mode 100644 index 000000000..0dd0e61ce Binary files /dev/null and b/secretspec/tests/fixtures/x509/p256.der differ diff --git a/secretspec/tests/fixtures/x509/p256.pfx b/secretspec/tests/fixtures/x509/p256.pfx new file mode 100644 index 000000000..895c8b5c1 Binary files /dev/null and b/secretspec/tests/fixtures/x509/p256.pfx differ diff --git a/secretspec/tests/fixtures/x509/p384.der b/secretspec/tests/fixtures/x509/p384.der new file mode 100644 index 000000000..d45114f97 Binary files /dev/null and b/secretspec/tests/fixtures/x509/p384.der differ diff --git a/secretspec/tests/fixtures/x509/p384.pfx b/secretspec/tests/fixtures/x509/p384.pfx new file mode 100644 index 000000000..d04c651e9 Binary files /dev/null and b/secretspec/tests/fixtures/x509/p384.pfx differ diff --git a/secretspec/tests/fixtures/x509/p521.der b/secretspec/tests/fixtures/x509/p521.der new file mode 100644 index 000000000..040887d5f Binary files /dev/null and b/secretspec/tests/fixtures/x509/p521.der differ diff --git a/secretspec/tests/fixtures/x509/p521.pfx b/secretspec/tests/fixtures/x509/p521.pfx new file mode 100644 index 000000000..511eb618a Binary files /dev/null and b/secretspec/tests/fixtures/x509/p521.pfx differ diff --git a/secretspec/tests/fixtures/x509/rsa.der b/secretspec/tests/fixtures/x509/rsa.der new file mode 100644 index 000000000..a7ff8e4b9 Binary files /dev/null and b/secretspec/tests/fixtures/x509/rsa.der differ diff --git a/secretspec/tests/fixtures/x509/rsa.pfx b/secretspec/tests/fixtures/x509/rsa.pfx new file mode 100644 index 000000000..d9979c076 Binary files /dev/null and b/secretspec/tests/fixtures/x509/rsa.pfx differ diff --git a/secretspec/tests/fixtures/x509/supplementary-password.pfx b/secretspec/tests/fixtures/x509/supplementary-password.pfx new file mode 100644 index 000000000..086e09179 Binary files /dev/null and b/secretspec/tests/fixtures/x509/supplementary-password.pfx differ