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