From 320919ce3c8470b187235f9e30b9d48263ab90b8 Mon Sep 17 00:00:00 2001 From: Anish Pallati Date: Sun, 19 Jul 2026 19:29:10 -0400 Subject: [PATCH 1/3] feat: add age provider Signed-off-by: Anish Pallati Assisted-by: Claude Opus 4.8 --- Cargo.lock | 646 ++++++++++++++++++++++++++++++++- Cargo.toml | 1 + secretspec/Cargo.toml | 4 +- secretspec/src/provider/age.rs | 514 ++++++++++++++++++++++++++ secretspec/src/provider/mod.rs | 2 + 5 files changed, 1154 insertions(+), 13 deletions(-) create mode 100644 secretspec/src/provider/age.rs diff --git a/Cargo.lock b/Cargo.lock index ead7f400..324f9929 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -39,6 +39,82 @@ dependencies = [ "zeroize", ] +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "age" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd290633c2482479f70f6d1d96ae0e9f52c6a26cd5859edd47ee1fe33fc89f26" +dependencies = [ + "aes", + "aes-gcm", + "age-core", + "base64", + "bcrypt-pbkdf", + "bech32", + "cbc", + "chacha20poly1305", + "cipher", + "cookie-factory", + "ctr", + "curve25519-dalek", + "hkdf", + "hmac", + "hpke", + "i18n-embed", + "i18n-embed-fl", + "lazy_static", + "ml-kem", + "nom 8.0.0", + "num-traits", + "p256", + "pin-project", + "rand 0.8.5", + "rsa", + "rust-embed", + "scrypt", + "sha2 0.10.9", + "sha3", + "subtle", + "which 8.0.5", + "wsl", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "age-core" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d4375964d1501e5f1b32aef2ead573913893ff238448d4e9fdf1522d828656" +dependencies = [ + "base64", + "bech32", + "chacha20poly1305", + "cookie-factory", + "hkdf", + "hpke", + "io_tee", + "nom 8.0.0", + "rand 0.8.5", + "secrecy", + "sha2 0.10.9", + "tempfile", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -113,6 +189,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + [[package]] name = "argon2" version = "0.6.0-rc.2" @@ -375,7 +460,7 @@ dependencies = [ "http 0.2.12", "http 1.4.0", "percent-encoding", - "sha2", + "sha2 0.10.9", "time", "tracing", ] @@ -653,6 +738,12 @@ dependencies = [ "backtrace", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" @@ -675,6 +766,32 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bcrypt-pbkdf" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" +dependencies = [ + "blowfish", + "pbkdf2 0.12.2", + "sha2 0.10.9", +] + +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + [[package]] name = "bindgen" version = "0.68.1" @@ -695,7 +812,7 @@ dependencies = [ "rustc-hash 1.1.0", "shlex", "syn 2.0.117", - "which", + "which 4.4.2", ] [[package]] @@ -826,7 +943,7 @@ dependencies = [ "serde_bytes", "serde_repr", "sha1", - "sha2", + "sha2 0.10.9", "subtle", "thiserror 2.0.18", "tracing", @@ -981,7 +1098,7 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" dependencies = [ - "hybrid-array", + "hybrid-array 0.4.8", ] [[package]] @@ -993,6 +1110,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -1114,7 +1241,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -1351,6 +1478,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "constant_time_eq" version = "0.1.5" @@ -1375,6 +1508,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" +dependencies = [ + "futures", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -1496,6 +1638,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -1513,7 +1667,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" dependencies = [ - "hybrid-array", + "hybrid-array 0.4.8", ] [[package]] @@ -1526,6 +1680,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "ctutils" version = "0.4.0" @@ -1720,7 +1883,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "pem-rfc7468", "zeroize", ] @@ -1801,7 +1964,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", - "const-oid", + "const-oid 0.9.6", "crypto-common 0.1.7", "subtle", ] @@ -1813,6 +1976,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" dependencies = [ "block-buffer 0.12.0", + "const-oid 0.10.2", "crypto-common 0.2.1", "ctutils", ] @@ -1875,7 +2039,7 @@ dependencies = [ "ed25519", "rand_core 0.6.4", "serde", - "sha2", + "sha2 0.10.9", "subtle", "zeroize", ] @@ -1886,6 +2050,25 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encode_unicode" version = "1.0.0" @@ -2012,12 +2195,31 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "find-crate" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a98bbaacea1c0eb6a0876280051b892eb73594fd90cf3b20e9c817029c57d2" +dependencies = [ + "toml 0.5.11", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -2034,6 +2236,51 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fluent" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8137a6d5a2c50d6b0ebfcb9aaa91a28154e0a70605f112d30cb0cd4a78670477" +dependencies = [ + "fluent-bundle", + "unic-langid", +] + +[[package]] +name = "fluent-bundle" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01203cb8918f5711e73891b347816d932046f95f54207710bda99beaeb423bf4" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash 2.1.1", + "self_cell", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54f0d287c53ffd184d04d8677f590f4ac5379785529e5e08b1c8083acdd5c198" +dependencies = [ + "memchr", + "thiserror 2.0.18", +] + [[package]] name = "fnv" version = "1.0.7" @@ -2225,6 +2472,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gimli" version = "0.32.3" @@ -2257,7 +2514,7 @@ dependencies = [ "rustls-pki-types", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "thiserror 2.0.18", "time", "tokio", @@ -2412,6 +2669,17 @@ dependencies = [ "url", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.3.27" @@ -2530,6 +2798,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "hpke" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4917627a14198c3603282c5158b815ad5534795451d3c074b53cf3cee0960b11" +dependencies = [ + "aead", + "aes-gcm", + "chacha20poly1305", + "digest 0.10.7", + "generic-array", + "hkdf", + "hmac", + "p256", + "rand_core 0.6.4", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "http" version = "0.2.12" @@ -2597,6 +2885,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2d35805454dc9f8662a98d6d61886ffe26bd465f5960e0e55345c70d5c0d2a9" +dependencies = [ + "typenum", +] + [[package]] name = "hybrid-array" version = "0.4.8" @@ -2720,6 +3017,72 @@ dependencies = [ "tracing", ] +[[package]] +name = "i18n-config" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e06b90c8a0d252e203c94344b21e35a30f3a3a85dc7db5af8f8df9f3e0c63ef" +dependencies = [ + "basic-toml", + "log", + "serde", + "serde_derive", + "thiserror 1.0.69", + "unic-langid", +] + +[[package]] +name = "i18n-embed" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a217bbb075dcaefb292efa78897fc0678245ca67f265d12c351e42268fcb0305" +dependencies = [ + "arc-swap", + "fluent", + "fluent-langneg", + "fluent-syntax", + "i18n-embed-impl", + "intl-memoizer", + "log", + "parking_lot", + "rust-embed", + "thiserror 1.0.69", + "unic-langid", + "walkdir", +] + +[[package]] +name = "i18n-embed-fl" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e598ed73b67db92f61e04672e599eef2991a262a40e1666735b8a86d2e7e9f30" +dependencies = [ + "find-crate", + "fluent", + "fluent-syntax", + "i18n-config", + "i18n-embed", + "proc-macro-error2", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.117", + "unic-langid", +] + +[[package]] +name = "i18n-embed-impl" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2cc0e0523d1fe6fc2c6f66e5038624ea8091b3e7748b5e8e0c84b1698db6c2" +dependencies = [ + "find-crate", + "i18n-config", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -2931,6 +3294,31 @@ dependencies = [ "tempfile", ] +[[package]] +name = "intl-memoizer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "io_tee" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b3f7cef34251886990511df1c61443aa928499d598a9473929ab5a90a527304" + [[package]] name = "ipnet" version = "2.12.0" @@ -3079,6 +3467,25 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + +[[package]] +name = "kem" +version = "0.3.0-pre.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b8645470337db67b01a7f966decf7d0bafedbae74147d33e641c67a91df239f" +dependencies = [ + "rand_core 0.6.4", + "zeroize", +] + [[package]] name = "keyring" version = "3.6.3" @@ -3313,6 +3720,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ml-kem" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de49b3df74c35498c0232031bb7e85f9389f913e2796169c8ab47a53993a18f" +dependencies = [ + "hybrid-array 0.2.3", + "kem", + "rand_core 0.6.4", + "sha3", +] + [[package]] name = "napi" version = "2.16.17" @@ -3397,6 +3816,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -3585,6 +4013,16 @@ version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "elliptic-curve", + "primeorder", +] + [[package]] name = "parking" version = "2.2.1" @@ -3643,7 +4081,7 @@ dependencies = [ "digest 0.10.7", "hmac", "password-hash 0.4.2", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -3653,6 +4091,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest 0.10.7", + "hmac", ] [[package]] @@ -3756,6 +4195,18 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -3805,6 +4256,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -4243,7 +4703,7 @@ version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "const-oid", + "const-oid 0.9.6", "digest 0.10.7", "num-bigint-dig", "num-integer", @@ -4271,6 +4731,41 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rust-embed" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097" +dependencies = [ + "mime_guess", + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.117", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0" +dependencies = [ + "sha2 0.11.0", + "walkdir", +] + [[package]] name = "rustc-demangle" version = "0.1.27" @@ -4435,6 +4930,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + [[package]] name = "same-file" version = "1.0.6" @@ -4504,6 +5008,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "pbkdf2 0.12.2", + "salsa20", + "sha2 0.10.9", +] + [[package]] name = "sct" version = "0.7.1" @@ -4514,6 +5029,19 @@ dependencies = [ "untrusted", ] +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "subtle", + "zeroize", +] + [[package]] name = "secrecy" version = "0.10.3" @@ -4528,6 +5056,7 @@ dependencies = [ name = "secretspec" version = "0.16.0" dependencies = [ + "age", "aws-config", "aws-sdk-secretsmanager", "azure_core", @@ -4663,6 +5192,12 @@ dependencies = [ "libc", ] +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + [[package]] name = "semver" version = "1.0.27" @@ -4844,6 +5379,27 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.2", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak", +] + [[package]] name = "shlex" version = "1.3.0" @@ -5194,6 +5750,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -5283,6 +5840,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + [[package]] name = "toml" version = "0.9.12+spec-1.1.0" @@ -5516,6 +6082,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.1", +] + [[package]] name = "typenum" version = "1.18.0" @@ -5573,6 +6148,25 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "serde", + "tinystr", +] + [[package]] name = "unicase" version = "2.9.0" @@ -5943,6 +6537,15 @@ dependencies = [ "rustix 0.38.44", ] +[[package]] +name = "which" +version = "8.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f3ef584124b911bcc3875c2f1472e80f24361ceb789bd1c62b3e9a3df9ff43c" +dependencies = [ + "libc", +] + [[package]] name = "whoami" version = "2.1.1" @@ -6386,6 +6989,24 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "wsl" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dab7ac864710bdea6594becbea5b5050333cf34fefb0dc319567eb347950d4" + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + [[package]] name = "xmlparser" version = "0.13.6" @@ -6499,6 +7120,7 @@ version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", diff --git a/Cargo.toml b/Cargo.toml index ad787dbe..cb29460c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,7 @@ rsa = { version = "0.9", features = ["pem"] } uuid = { version = "1", features = ["v4"] } data-encoding = "2" detect-coding-agent = "0.1" +age = { version = "0.12", features = ["armor", "plugin", "ssh"] } # The profile that 'dist' will build with [profile.dist] diff --git a/secretspec/Cargo.toml b/secretspec/Cargo.toml index 64b472fb..7d328e7d 100644 --- a/secretspec/Cargo.toml +++ b/secretspec/Cargo.toml @@ -50,9 +50,10 @@ rsa.workspace = true uuid.workspace = true data-encoding.workspace = true detect-coding-agent.workspace = true +age = { workspace = true, optional = true } [features] -default = ["cli", "keyring", "gcsm", "awssm", "vault", "bws", "akv", "infisical"] +default = ["cli", "keyring", "gcsm", "awssm", "vault", "bws", "akv", "infisical", "age"] cli = ["dep:toml_edit"] keyring = ["dep:keyring", "dep:whoami"] # Compile libdbus from source into the binary instead of linking the system @@ -68,3 +69,4 @@ vault = ["dep:reqwest"] infisical = ["dep:reqwest", "tokio/sync"] bws = ["dep:bitwarden", "dep:rustls"] akv = ["dep:azure_core", "dep:azure_identity", "dep:azure_security_keyvault_secrets"] +age = ["dep:age"] diff --git a/secretspec/src/provider/age.rs b/secretspec/src/provider/age.rs new file mode 100644 index 00000000..9c8259b1 --- /dev/null +++ b/secretspec/src/provider/age.rs @@ -0,0 +1,514 @@ +//! Provider that stores secrets in a single [age](https://age-encryption.org) +//! encrypted file. +//! +//! The file is an encrypted dotenv blob whose plaintext is `KEY=value` lines +//! encrypted to one or more age recipients. A read decrypts the whole blob with +//! the configured identity. A write decrypts it, updates one key, and +//! re-encrypts the whole blob to the current recipients. +//! +//! # URI format +//! +//! - `age://` -- the encrypted blob file +//! - `?identity=` -- identity file, one of the identity sources +//! - `?recipients-file=` -- roster of recipient public keys; absent means +//! solo mode (encrypt to your own identity) +//! - `?armor=false` -- write a binary blob instead of the default ASCII armor +//! +//! # Identity +//! +//! The private key is resolved from the `identity` provider credential, then +//! the `AGE_IDENTITY` environment variable, then `?identity=`. +//! Recipient parsing and identity resolution both go through age's plugin +//! system, so plugin keys such as the ML-KEM/X25519 (X-Wing) `age1pq1...` +//! recipients work when their `age-plugin-*` binary is on `PATH`. + +use super::{Address, Provider, ProviderCredentials, ProviderUrl, credential_or_env, flat_item}; +use crate::config::{NativeAddress, Secret}; +use crate::{Result, SecretSpecError}; +use age::armor::{ArmoredReader, ArmoredWriter, Format}; +use age::{Decryptor, Encryptor, Identity, IdentityFile, NoCallbacks, Recipient}; +use secrecy::{ExposeSecret, SecretString}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; + +/// Semantic credential name for the age identity +const IDENTITY: &str = "identity"; +/// Environment variable fallback for the age identity material +const AGE_IDENTITY_ENV: &str = "AGE_IDENTITY"; + +fn provider_err(msg: impl Into) -> SecretSpecError { + SecretSpecError::ProviderOperationFailed(msg.into()) +} + +/// Configuration for the age provider. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgeConfig { + /// Path to the encrypted blob file + pub path: PathBuf, + /// Identity file path, used when no credential or env identity is set + #[serde(skip_serializing_if = "Option::is_none")] + pub identity_path: Option, + /// Roster of recipient public keys for team mode + #[serde(skip_serializing_if = "Option::is_none")] + pub recipients_file: Option, + /// Whether to write ASCII-armored output + pub armor: bool, +} + +impl Default for AgeConfig { + fn default() -> Self { + Self { + path: PathBuf::from("secrets.age"), + identity_path: None, + recipients_file: None, + armor: true, + } + } +} + +impl TryFrom<&ProviderUrl> for AgeConfig { + type Error = SecretSpecError; + + fn try_from(url: &ProviderUrl) -> std::result::Result { + if url.scheme() != "age" { + return Err(provider_err(format!( + "Invalid scheme '{}' for age provider", + url.scheme() + ))); + } + + // Accept both `age://path` (authority) and `age:///abs/path` forms + let path_str = url.path(); + let path = if !path_str.is_empty() && path_str != "/" { + match url.host() { + Some(host) => format!("{}{}", host, path_str), + None => path_str, + } + } else if let Some(host) = url.host() { + host + } else { + "secrets.age".to_string() + }; + + Ok(Self { + path: PathBuf::from(path), + identity_path: url.query_value("identity").map(PathBuf::from), + recipients_file: url.query_value("recipients-file").map(PathBuf::from), + armor: url + .query_value("armor") + .map(|v| v != "false") + .unwrap_or(true), + }) + } +} + +/// Provider that reads and writes an age-encrypted dotenv blob. +pub struct AgeProvider { + config: AgeConfig, + credentials: ProviderCredentials, +} + +crate::register_provider! { + struct: AgeProvider, + config: AgeConfig, + name: "age", + description: "age-encrypted file", + schemes: ["age"], + examples: ["age://secrets.age", "age://secrets.age?recipients-file=secrets.age.recipients"], + credential_names: ["identity"], +} + +impl AgeProvider { + pub fn new(config: AgeConfig) -> Self { + Self { + config, + credentials: ProviderCredentials::new(), + } + } + + /// Parses the configured identity file from credential, env, or path + fn identity_file(&self) -> Result> { + if let Some(material) = credential_or_env(&self.credentials, IDENTITY, AGE_IDENTITY_ENV) { + return IdentityFile::from_buffer(std::io::Cursor::new(material.into_bytes())) + .map(|f| f.with_callbacks(NoCallbacks)) + .map_err(|e| provider_err(format!("Failed to parse age identity: {}", e))); + } + if let Some(path) = &self.config.identity_path { + return IdentityFile::from_file(path.display().to_string()) + .map(|f| f.with_callbacks(NoCallbacks)) + .map_err(|e| { + provider_err(format!( + "Failed to read age identity file {}: {}", + path.display(), + e + )) + }); + } + Err(provider_err( + "No age identity configured. Set the `identity` credential, the \ + AGE_IDENTITY environment variable, or ?identity= in the URI.", + )) + } + + /// Resolves recipients from the roster file, or the identity in solo mode + fn recipients(&self) -> Result>> { + match &self.config.recipients_file { + Some(path) => parse_recipients_file(path), + None => self.identity_file()?.to_recipients().map_err(|e| { + provider_err(format!("Failed to derive recipient from identity: {}", e)) + }), + } + } + + /// Reads and decrypts the blob into a flat key/value map + fn load(&self) -> Result> { + if !self.config.path.exists() { + return Ok(HashMap::new()); + } + let ciphertext = std::fs::read(&self.config.path)?; + let identities = self + .identity_file()? + .into_identities() + .map_err(|e| provider_err(format!("Failed to load age identities: {}", e)))?; + + let reader = ArmoredReader::new(&ciphertext[..]); + let decryptor = Decryptor::new(reader) + .map_err(|e| provider_err(format!("Failed to read age file: {}", e)))?; + let mut plaintext = Vec::new(); + decryptor + .decrypt(identities.iter().map(|i| i.as_ref() as &dyn Identity)) + .map_err(|e| provider_err(format!("Failed to decrypt age file: {}", e)))? + .read_to_end(&mut plaintext)?; + + parse_dotenv(&plaintext) + } + + /// Re-encrypts a full key/value map to the current recipients + fn store(&self, vars: &HashMap) -> Result<()> { + let plaintext = super::dotenv::serialize_dotenv(vars); + let recipients = self.recipients()?; + let encryptor = + Encryptor::with_recipients(recipients.iter().map(|r| r.as_ref() as &dyn Recipient)) + .map_err(|e| provider_err(format!("No age recipients available: {}", e)))?; + + let mut out = Vec::new(); + let format = if self.config.armor { + Format::AsciiArmor + } else { + Format::Binary + }; + let armored = ArmoredWriter::wrap_output(&mut out, format)?; + let mut writer = encryptor + .wrap_output(armored) + .map_err(|e| provider_err(format!("Failed to encrypt age file: {}", e)))?; + writer.write_all(plaintext.as_bytes())?; + writer + .finish() + .map_err(|e| provider_err(format!("Failed to finish age stream: {}", e)))? + .finish()?; + + std::fs::write(&self.config.path, out)?; + Ok(()) + } +} + +/// Parses decrypted dotenv content into a flat map +fn parse_dotenv(plaintext: &[u8]) -> Result> { + let mut vars = HashMap::new(); + for item in dotenvy::from_read_iter(plaintext) { + let (key, value) = + item.map_err(|e| provider_err(format!("Failed to parse decrypted content: {}", e)))?; + vars.insert(key, value); + } + Ok(vars) +} + +/// Reads a roster file into recipients, skipping comments and blank lines +fn parse_recipients_file(path: &Path) -> Result>> { + let content = std::fs::read_to_string(path).map_err(|e| { + provider_err(format!( + "Failed to read recipients file {}: {}", + path.display(), + e + )) + })?; + + let mut recipients: Vec> = Vec::new(); + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + recipients.push(parse_recipient(line)?); + } + + if recipients.is_empty() { + return Err(provider_err(format!( + "Recipients file {} contains no recipients", + path.display() + ))); + } + Ok(recipients) +} + +/// Parses one recipient string as x25519, ssh, or a plugin recipient +fn parse_recipient(s: &str) -> Result> { + if let Ok(r) = s.parse::() { + return Ok(Box::new(r)); + } + if let Ok(r) = s.parse::() { + return Ok(Box::new(r)); + } + if let Ok(r) = s.parse::() { + let plugin_name = r.plugin().to_string(); + let plugin = age::plugin::RecipientPluginV1::new(&plugin_name, &[r], &[], NoCallbacks) + .map_err(|e| { + provider_err(format!("age plugin '{}' unavailable: {:?}", plugin_name, e)) + })?; + return Ok(Box::new(plugin)); + } + Err(provider_err(format!("Unrecognized age recipient: {}", s))) +} + +impl Provider for AgeProvider { + /// Convention names map straight to the key inside the encrypted blob + fn convention_address( + &self, + _project: &str, + _profile: &str, + key: &str, + ) -> Result { + Ok(NativeAddress { + item: key.to_string(), + ..Default::default() + }) + } + + fn name(&self) -> &'static str { + Self::PROVIDER_NAME + } + + fn uri(&self) -> String { + format!("age:{}", self.config.path.display()) + } + + fn with_credentials(&mut self, credentials: ProviderCredentials) { + self.credentials = credentials; + } + + /// Resolves relative paths against the project root, like the dotenv provider + fn with_base_dir(&mut self, base_dir: &Path) { + if self.config.path.is_relative() { + self.config.path = base_dir.join(&self.config.path); + } + if let Some(path) = &self.config.identity_path + && path.is_relative() + { + self.config.identity_path = Some(base_dir.join(path)); + } + if let Some(path) = &self.config.recipients_file + && path.is_relative() + { + self.config.recipients_file = Some(base_dir.join(path)); + } + } + + fn get(&self, addr: Address<'_>) -> Result> { + let key = flat_item(self, addr)?; + Ok(self + .load()? + .get(&*key) + .map(|v| SecretString::new(v.clone().into()))) + } + + fn set(&self, addr: Address<'_>, value: &SecretString) -> Result<()> { + let key = flat_item(self, addr)?.into_owned(); + let mut vars = self.load()?; + vars.insert(key, value.expose_secret().to_string()); + self.store(&vars) + } + + /// Decrypts the blob once and serves every requested key from it + fn get_many(&self, requests: &[(&str, Address<'_>)]) -> Result> { + let vars = self.load()?; + let mut out = HashMap::new(); + for (name, addr) in requests { + let key = flat_item(self, *addr)?; + if let Some(value) = vars.get(&*key) { + out.insert(name.to_string(), SecretString::new(value.clone().into())); + } + } + Ok(out) + } + + fn reflect(&self) -> Result> { + Ok(self + .load()? + .into_keys() + .map(|key| { + let secret = Secret { + description: Some(format!("{} secret", key)), + required: Some(true), + ..Default::default() + }; + (key, secret) + }) + .collect()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use url::Url; + + // Throwaway x25519 identity used only by these tests + const TEST_IDENTITY: &str = concat!( + "# public key: age1rcq2v5ckqn2r538m8qxz0xhx2am83zhxr60yfvmlsugkt6tygpcss829at\n", + "AGE-SECRET-KEY-15SFU79V44S2N3G4HKMG578KN5VXWM4GNLZUWVLY2Z8ENUPUCNWXQPQ5X33\n", + ); + const TEST_RECIPIENT: &str = "age1rcq2v5ckqn2r538m8qxz0xhx2am83zhxr60yfvmlsugkt6tygpcss829at"; + + fn config_from(uri: &str) -> AgeConfig { + let url = ProviderUrl::new(Url::parse(uri).unwrap()); + (&url).try_into().unwrap() + } + + #[test] + fn parses_uri_forms() { + let c = config_from("age://secrets.age"); + assert_eq!(c.path.to_str().unwrap(), "secrets.age"); + assert!(c.identity_path.is_none()); + assert!(c.recipients_file.is_none()); + assert!(c.armor); + + let c = config_from("age:///abs/secrets.age"); + assert_eq!(c.path.to_str().unwrap(), "/abs/secrets.age"); + + let c = + config_from("age://dir/secrets.age?identity=k.txt&recipients-file=r.txt&armor=false"); + assert_eq!(c.path.to_str().unwrap(), "dir/secrets.age"); + assert_eq!(c.identity_path.unwrap().to_str().unwrap(), "k.txt"); + assert_eq!(c.recipients_file.unwrap().to_str().unwrap(), "r.txt"); + assert!(!c.armor); + } + + #[test] + fn base_dir_rebases_relative_paths() { + let base = Path::new("/project/root"); + let mut provider = AgeProvider::new(AgeConfig { + path: PathBuf::from("secrets.age"), + identity_path: Some(PathBuf::from("key.txt")), + recipients_file: Some(PathBuf::from("roster.recipients")), + armor: true, + }); + provider.with_base_dir(base); + assert_eq!(provider.config.path, base.join("secrets.age")); + assert_eq!(provider.config.identity_path.unwrap(), base.join("key.txt")); + assert_eq!( + provider.config.recipients_file.unwrap(), + base.join("roster.recipients") + ); + } + + #[test] + fn parse_recipient_rejects_garbage() { + assert!(parse_recipient("not-an-age-recipient").is_err()); + } + + fn write_identity(dir: &Path) -> PathBuf { + let key = dir.join("key.txt"); + std::fs::write(&key, TEST_IDENTITY).unwrap(); + key + } + + #[test] + fn solo_round_trip_preserves_other_keys() { + let dir = tempfile::tempdir().unwrap(); + let key = write_identity(dir.path()); + let provider = AgeProvider::new(AgeConfig { + path: dir.path().join("secrets.age"), + identity_path: Some(key), + recipients_file: None, + armor: true, + }); + + let addr = |k| Address::convention("proj", "default", k); + provider + .set( + addr("API_KEY"), + &SecretString::new("sekret".to_string().into()), + ) + .unwrap(); + + let bytes = std::fs::read(&provider.config.path).unwrap(); + assert!(bytes.starts_with(b"-----BEGIN AGE ENCRYPTED FILE-----")); + + provider + .set(addr("OTHER"), &SecretString::new("two".to_string().into())) + .unwrap(); + + assert_eq!( + provider + .get(addr("API_KEY")) + .unwrap() + .unwrap() + .expose_secret(), + "sekret" + ); + assert_eq!( + provider + .get(addr("OTHER")) + .unwrap() + .unwrap() + .expose_secret(), + "two" + ); + assert!(provider.get(addr("MISSING")).unwrap().is_none()); + } + + #[test] + fn armor_false_writes_binary_and_reads_back() { + let dir = tempfile::tempdir().unwrap(); + let key = write_identity(dir.path()); + let provider = AgeProvider::new(AgeConfig { + path: dir.path().join("secrets.age"), + identity_path: Some(key), + recipients_file: None, + armor: false, + }); + let addr = Address::convention("proj", "default", "API_KEY"); + provider + .set(addr, &SecretString::new("bin".to_string().into())) + .unwrap(); + + let bytes = std::fs::read(&provider.config.path).unwrap(); + assert!(!bytes.starts_with(b"-----BEGIN")); + assert_eq!(provider.get(addr).unwrap().unwrap().expose_secret(), "bin"); + } + + #[test] + fn team_recipients_file_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let key = write_identity(dir.path()); + let roster = dir.path().join("roster.recipients"); + std::fs::write(&roster, format!("# team\n{}\n", TEST_RECIPIENT)).unwrap(); + + let provider = AgeProvider::new(AgeConfig { + path: dir.path().join("team.age"), + identity_path: Some(key), + recipients_file: Some(roster), + armor: true, + }); + let addr = Address::convention("proj", "default", "API_KEY"); + provider + .set(addr, &SecretString::new("teamsecret".to_string().into())) + .unwrap(); + assert_eq!( + provider.get(addr).unwrap().unwrap().expose_secret(), + "teamsecret" + ); + } +} diff --git a/secretspec/src/provider/mod.rs b/secretspec/src/provider/mod.rs index 4865ca5e..8405942d 100644 --- a/secretspec/src/provider/mod.rs +++ b/secretspec/src/provider/mod.rs @@ -229,6 +229,8 @@ pub(crate) fn block_on(future: F) -> F::Output { } } +#[cfg(feature = "age")] +pub mod age; #[cfg(feature = "akv")] pub mod akv; #[cfg(feature = "awssm")] From 207e7404654c6fa78165c2c7d77b57ce10c99115 Mon Sep 17 00:00:00 2001 From: Anish Pallati Date: Sun, 19 Jul 2026 19:29:30 -0400 Subject: [PATCH 2/3] docs: document the age provider Signed-off-by: Anish Pallati Assisted-by: Claude Opus 4.8 --- docs/astro.config.ts | 7 +- docs/src/content/docs/concepts/providers.md | 1 + docs/src/content/docs/providers/age.md | 131 +++++++++++++++++++ docs/src/content/docs/quick-start.mdx | 1 + docs/src/content/docs/reference/providers.md | 16 +++ docs/src/pages/index.astro | 1 + secretspec/README.md | 3 + 7 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 docs/src/content/docs/providers/age.md diff --git a/docs/astro.config.ts b/docs/astro.config.ts index c2b7adeb..5dcf3c90 100644 --- a/docs/astro.config.ts +++ b/docs/astro.config.ts @@ -119,7 +119,7 @@ $ secretspec import dotenv://.env.production ## Providers -Secrets can be stored in: keyring (default), dotenv files, environment variables, 1Password, Gopass (0.15+), LastPass, Pass, Proton Pass, Google Cloud Secret Manager, AWS Secrets Manager, HashiCorp Vault / OpenBao, Bitwarden Secrets Manager, Azure Key Vault, or Infisical (0.16+).`, +Secrets can be stored in: keyring (default), dotenv files, environment variables, 1Password, Gopass (0.15+), LastPass, Pass, Proton Pass, Google Cloud Secret Manager, AWS Secrets Manager, HashiCorp Vault / OpenBao, Bitwarden Secrets Manager, Azure Key Vault, Infisical (0.16+), or age (0.17+).`, }), ], title: "SecretSpec", @@ -228,6 +228,11 @@ Secrets can be stored in: keyring (default), dotenv files, environment variables slug: "providers/infisical", badge: { text: "0.16+", variant: "note" }, }, + { + label: "age", + slug: "providers/age", + badge: { text: "0.17+", variant: "note" }, + }, ], }, { diff --git a/docs/src/content/docs/concepts/providers.md b/docs/src/content/docs/concepts/providers.md index ecde828b..e2e15d02 100644 --- a/docs/src/content/docs/concepts/providers.md +++ b/docs/src/content/docs/concepts/providers.md @@ -53,6 +53,7 @@ DATABASE_URL = { description = "Production database", providers = ["prod_vault"] | [bws](/providers/bws/) | Bitwarden Secrets Manager (requires the `bws` build feature) | ✓ | ✓ | ✓ | | [akv](/providers/akv/) | Azure Key Vault (requires the `akv` build feature) | ✓ | ✓ | ✓ | | [infisical](/providers/infisical/) (0.16+) | Infisical (requires the `infisical` build feature) | ✓ | ✓ | ✓ | +| [age](/providers/age/) (0.17+) | An age-encrypted file (requires the `age` build feature) | ✓ | ✓ | ✓ | Each provider page starts with a minimal working example, then covers setup, project configuration, storage conventions, existing provider-native secrets, diff --git a/docs/src/content/docs/providers/age.md b/docs/src/content/docs/providers/age.md new file mode 100644 index 00000000..0f1da348 --- /dev/null +++ b/docs/src/content/docs/providers/age.md @@ -0,0 +1,131 @@ +--- +title: age Provider +description: Store secrets in an age-encrypted file committed alongside code +--- + +:::note[Version compatibility] +The age provider is an upcoming SecretSpec 0.17 feature and is not available in SecretSpec 0.16. +::: + +The age provider keeps secrets in a single [age](https://age-encryption.org)-encrypted file that you can commit to your repository. The plaintext inside is a dotenv-style `KEY=value` blob that SecretSpec encrypts to one or more age recipients and decrypts with your age identity. A read decrypts the blob; a write decrypts it, updates one key, and re-encrypts the whole blob to the current recipients. + +## At a glance + +| | | +| --- | --- | +| Provider | `age` | +| URI | `age://[?options]` | +| Access | Read and write | +| Best for | Encrypted secrets committed alongside code | +| Authentication | An age identity (private key) | +| Build feature | `age` | +| Default storage | A dotenv blob at the configured path, keyed by the secret name | + +## Quick start + +```bash +$ secretspec set DATABASE_URL --provider "age://secrets.age?identity=~/.config/age/keys.txt" +Enter value for DATABASE_URL: postgresql://localhost/mydb +✓ Secret 'DATABASE_URL' saved to age (profile: default) + +$ secretspec get DATABASE_URL --provider "age://secrets.age?identity=~/.config/age/keys.txt" +``` + +With no recipients configured the blob is encrypted to your own identity, so the same key that reads it also writes it. + +## Setup + +### Prerequisites + +- An age identity, created with `age-keygen` +- Build with `--features age` + +### Identity + +The private key is resolved from the first of these sources: the `identity` provider credential, the `AGE_IDENTITY` environment variable holding the key material, or `?identity=` naming an identity file. The credential and environment forms carry the key material directly; the URI form names a file on disk. Routing the identity through the credential system lets the age key itself be a managed secret, for example one stored in the system keyring. + +### Recipients + +Recipients are age public keys and are never secret, so they are configured rather than supplied as credentials. With no `?recipients-file=`, the blob is encrypted to the public key derived from your own identity. To share the file, point `?recipients-file=` at a roster file listing every recipient. + +A roster is a plain text file in age's recipients format: one recipient per line, `#` for comments, blank lines ignored. Recipients may be `age1...` keys or `ssh-ed25519`/`ssh-rsa` keys. + +```text title="secrets.age.recipients" +# alice +age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p +# a deploy host +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... +``` + +Because an age file does not record its recipients, every write re-encrypts to whatever `?recipients-file=` names at that moment. Keep that file complete and committed so a write never drops a reader. When the roster changes, run a write against each secret to re-encrypt it to the new set. + +### Plugins and post-quantum keys + +Identity and recipient parsing both go through age's plugin protocol, so keys handled by an `age-plugin-*` binary work when that binary is on `PATH`. + +Post-quantum keys need one extra step. SecretSpec is built on the Rust age library, which does not read the native `AGE-SECRET-KEY-PQ-1` identity form that `age-keygen -pq` writes. Convert it to the plugin form once with `age-plugin-pq`: + +```bash +$ age-plugin-pq -identity < keys.txt > plugin-identity.txt +$ secretspec get DATABASE_URL --provider "age://secrets.age?identity=plugin-identity.txt" +``` + +## Configuration + +### URI format + +```text +age://[?key=value&...] +``` + +- `path`: the encrypted blob file, resolved against the project root when relative +- `?identity=`: identity file, used when no credential or `AGE_IDENTITY` is set +- `?recipients-file=`: roster of recipient public keys; without it, encrypt to your own identity +- `?armor=false`: write a binary blob instead of the default ASCII armor + +### URI examples + +```text +age://secrets.age +age://secrets.age?identity=~/.config/age/keys.txt +age://secrets.age?recipients-file=secrets.age.recipients +age://secrets.age?armor=false +``` + +### Project configuration + +```toml title="secretspec.toml" +[providers] +team_age = "age://secrets.age?recipients-file=secrets.age.recipients" + +[profiles.production] +DATABASE_URL = { description = "Database URL", providers = ["team_age"] } +``` + +Each developer configures their own identity through the `identity` credential, `AGE_IDENTITY`, or a personal `?identity=`, while the committed roster and blob path stay the same for everyone. + +## Storage model + +Every secret is one `KEY=value` entry inside the blob, keyed by the secret name. Project and profile do not appear in the file; point separate profiles at separate blobs to keep them apart, for example `secrets.prod.age` and `secrets.dev.age`. + +## Use existing secrets + +A secret's [`ref`](/reference/configuration/#secret-references) names the key to read inside the blob, so a declared secret can map to a differently named entry. The age provider has no sub-address, so a `ref` sets only `item`. + +```toml title="secretspec.toml" +[profiles.production] +DATABASE_URL = { description = "DB", ref = { item = "POSTGRES_URL" }, providers = ["age://secrets.age"] } +``` + +## CI/CD + +Commit the blob and its roster, and give the job an identity to decrypt with. The identity is a natural fit for the `identity` provider credential (sourced from another provider) or the `AGE_IDENTITY` environment variable: + +```bash +$ export AGE_IDENTITY="$CI_AGE_IDENTITY" +$ secretspec run --provider "age://secrets.age" -- deploy +``` + +## Security considerations + +A recipient can decrypt every secret in a blob, not individual entries within it. Put secrets that should reach different audiences in separate files, each with its own roster. diff --git a/docs/src/content/docs/quick-start.mdx b/docs/src/content/docs/quick-start.mdx index 184b2e42..bdb626e9 100644 --- a/docs/src/content/docs/quick-start.mdx +++ b/docs/src/content/docs/quick-start.mdx @@ -103,6 +103,7 @@ $ secretspec config init bws: Bitwarden Secrets Manager akv: Azure Key Vault infisical: Infisical secret management (0.16+) + age: age-encrypted file (0.17+) ? Select your default profile: > development default diff --git a/docs/src/content/docs/reference/providers.md b/docs/src/content/docs/reference/providers.md index b0171722..861903fe 100644 --- a/docs/src/content/docs/reference/providers.md +++ b/docs/src/content/docs/reference/providers.md @@ -226,6 +226,21 @@ the `production` environment. Projects whose environments do not correspond to p Values are read with Infisical's secret references expanded, matching its own CLI, so a value of `postgres://${DB_USER}@host` arrives resolved. +## age Provider (0.17+) + +**URI**: `age://PATH[?identity=FILE][&recipients-file=FILE][&armor=false]` - Stores secrets in a single age-encrypted file committed alongside code + +```bash +age://secrets.age # Encrypt to your own identity +age://secrets.age?identity=~/.config/age/keys.txt # Name the identity file +age://secrets.age?recipients-file=secrets.age.recipients # Share with a roster +``` + +**Features**: Read/write, committed-file storage, `age-plugin-*` recipients and identities +**Prerequisites**: An age identity from `age-keygen`, build with `--features age` +**Authentication**: The `identity` credential, `AGE_IDENTITY`, or `?identity=`; recipients from `?recipients-file=` or derived from the identity +**Storage**: One `KEY=value` entry per secret inside the encrypted blob at PATH + ## Provider Selection ### Command Line @@ -266,3 +281,4 @@ export SECRETSPEC_PROVIDER="dotenv:///config/.env" | BWS | ✅ End-to-end | Cloud (Bitwarden) | ✅ Yes | | AKV | ✅ Azure-managed | Cloud (Azure) | ✅ Yes | | Infisical (0.16+) | ✅ Infisical-managed | Cloud (Infisical) or self-hosted | ✅ Yes | +| age (0.17+) | ✅ age encryption | Local filesystem | ❌ No | diff --git a/docs/src/pages/index.astro b/docs/src/pages/index.astro index 85bb077e..93c37512 100644 --- a/docs/src/pages/index.astro +++ b/docs/src/pages/index.astro @@ -44,6 +44,7 @@ const providerMetadata: ProviderMetadata[] = [ { icon: 'googlecloud', slug: 'gcsm', label: 'Google Cloud Secret Manager' }, { icon: 'microsoftazure', slug: 'akv', label: 'Azure Key Vault' }, { slug: 'infisical', label: 'Infisical' }, + { slug: 'age', label: 'age' }, { slug: 'gopass', label: 'Gopass' }, { slug: 'protonpass', label: 'Proton Pass' }, { icon: 'gnuprivacyguard', slug: 'pass', label: 'Pass (GPG)' }, diff --git a/secretspec/README.md b/secretspec/README.md index 61e1efb9..a6143ef3 100644 --- a/secretspec/README.md +++ b/secretspec/README.md @@ -31,6 +31,7 @@ SecretSpec fixes this by separating secret **declaration** from secret **storage - [Bitwarden Secrets Manager](https://secretspec.dev/providers/bws) - [Azure Key Vault](https://secretspec.dev/providers/akv) - [Infisical](https://secretspec.dev/providers/infisical) (0.16+) + - [age](https://secretspec.dev/providers/age) (0.17+) - **[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 — declarative "generate if absent" @@ -68,6 +69,7 @@ $ secretspec config init bws: Bitwarden Secrets Manager akv: Azure Key Vault infisical: Infisical secret management (0.16+) + age: age-encrypted file (0.17+) ? Select your default profile: > development default @@ -153,6 +155,7 @@ SecretSpec supports multiple storage backends for secrets: - **[Bitwarden Secrets Manager](https://secretspec.dev/providers/bws)** - Bitwarden Secrets Manager integration - **[Azure Key Vault](https://secretspec.dev/providers/akv)** - Azure secret management - **[Infisical](https://secretspec.dev/providers/infisical)** (0.16+) - Infisical secret management +- **[age](https://secretspec.dev/providers/age)** (0.17+) - age-encrypted file ```bash $ secretspec run --provider keyring -- npm start From db728f9696cf7f4c524c1180e94d5abf475f6db5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Sun, 19 Jul 2026 21:53:18 -0600 Subject: [PATCH 3/3] fix: address age provider review feedback --- CHANGELOG.md | 5 + docs/src/content/docs/providers/age.md | 41 +++- docs/src/content/docs/reference/providers.md | 9 +- docs/src/pages/index.astro | 6 +- secretspec/src/provider/age.rs | 228 ++++++++++++++++--- 5 files changed, 249 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 653f9a4d..a9766850 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- age provider (`age://`) for storing dotenv-style secret sets in an + age-encrypted file, with ASCII armor by default, team recipient rosters, + direct X25519 and SSH key support, native tagged recipients, and + non-interactive age plugins. Hybrid ML-KEM-768 + X25519 keys are recommended + for new setups to protect stored ciphertext against future quantum attacks. - Vault / OpenBao JWT/OIDC authentication (`?auth=jwt`) logs in through a configured Vault role using `VAULT_JWT`, or requests a short-lived OIDC token automatically in GitHub Actions and Forgejo Actions jobs with `id-token: diff --git a/docs/src/content/docs/providers/age.md b/docs/src/content/docs/providers/age.md index 0f1da348..6b2a4464 100644 --- a/docs/src/content/docs/providers/age.md +++ b/docs/src/content/docs/providers/age.md @@ -23,12 +23,21 @@ The age provider keeps secrets in a single [age](https://age-encryption.org)-enc ## Quick start +Use an age v1.3 hybrid post-quantum key for new setups. SecretSpec's Rust age +library currently accesses this key type through the non-interactive +`age-plugin-pq` compatibility plugin: + ```bash -$ secretspec set DATABASE_URL --provider "age://secrets.age?identity=~/.config/age/keys.txt" +$ mkdir -p "$HOME/.config/age" +$ age-keygen -pq -o "$HOME/.config/age/keys.txt" +Public key: age1pq1... +$ age-plugin-pq -identity -o "$HOME/.config/age/plugin-identity.txt" "$HOME/.config/age/keys.txt" + +$ secretspec set DATABASE_URL --provider "age://secrets.age?identity=$HOME/.config/age/plugin-identity.txt" Enter value for DATABASE_URL: postgresql://localhost/mydb ✓ Secret 'DATABASE_URL' saved to age (profile: default) -$ secretspec get DATABASE_URL --provider "age://secrets.age?identity=~/.config/age/keys.txt" +$ secretspec get DATABASE_URL --provider "age://secrets.age?identity=$HOME/.config/age/plugin-identity.txt" ``` With no recipients configured the blob is encrypted to your own identity, so the same key that reads it also writes it. @@ -37,7 +46,10 @@ With no recipients configured the blob is encrypted to your own identity, so the ### Prerequisites -- An age identity, created with `age-keygen` +- An age identity. For new keys, age's hybrid ML-KEM-768 + X25519 key generated + by `age-keygen -pq` is recommended for post-quantum protection. +- `age-plugin-pq` on `PATH` when using the recommended hybrid key with + SecretSpec's current Rust age library - Build with `--features age` ### Identity @@ -48,7 +60,7 @@ The private key is resolved from the first of these sources: the `identity` prov Recipients are age public keys and are never secret, so they are configured rather than supplied as credentials. With no `?recipients-file=`, the blob is encrypted to the public key derived from your own identity. To share the file, point `?recipients-file=` at a roster file listing every recipient. -A roster is a plain text file in age's recipients format: one recipient per line, `#` for comments, blank lines ignored. Recipients may be `age1...` keys or `ssh-ed25519`/`ssh-rsa` keys. +A roster is a plain text file in age's recipients format: one recipient per line, `#` for comments, blank lines ignored. Recipients may be classic `age1...` keys, hybrid `age1pq1...` keys, native tagged `age1tag...`/`age1tagpq...` recipients, or `ssh-ed25519`/`ssh-rsa` keys. Hybrid `age1pq1...` encryption requires `age-plugin-pq`; tagged recipients are parsed natively before the generic plugin fallback. ```text title="secrets.age.recipients" # alice @@ -61,15 +73,19 @@ Because an age file does not record its recipients, every write re-encrypts to w ### Plugins and post-quantum keys -Identity and recipient parsing both go through age's plugin protocol, so keys handled by an `age-plugin-*` binary work when that binary is on `PATH`. +Native X25519 and SSH identities are supported directly. Plugin identities and recipients work when their `age-plugin-*` binary is on `PATH` **and the plugin operation is non-interactive**. SecretSpec currently supplies no age callback UI, so plugins that issue `confirm`, `request-public`, or `request-secret` requests can fail. A plugin that handles interaction entirely through its own OS UI may still work. -Post-quantum keys need one extra step. SecretSpec is built on the Rust age library, which does not read the native `AGE-SECRET-KEY-PQ-1` identity form that `age-keygen -pq` writes. Convert it to the plugin form once with `age-plugin-pq`: +Post-quantum keys need one conversion step. SecretSpec is built on the Rust age library, which does not yet read the native `AGE-SECRET-KEY-PQ-1` identity form that `age-keygen -pq` writes. Convert it to the plugin form once; `-o` creates the new secret identity file with mode `0600` and refuses to overwrite it: ```bash -$ age-plugin-pq -identity < keys.txt > plugin-identity.txt -$ secretspec get DATABASE_URL --provider "age://secrets.age?identity=plugin-identity.txt" +$ age-plugin-pq -identity -o "$HOME/.config/age/plugin-identity.txt" "$HOME/.config/age/keys.txt" ``` +Use only post-quantum recipients (`age1pq1...` or `age1tagpq1...`) together in +a roster. Age intentionally rejects a mixture of post-quantum and classic +recipients, because the classic recipient would remove the file's +post-quantum protection. + ## Configuration ### URI format @@ -87,7 +103,7 @@ age://[?key=value&...] ```text age://secrets.age -age://secrets.age?identity=~/.config/age/keys.txt +age://secrets.age?identity=/home/alice/.config/age/plugin-identity.txt age://secrets.age?recipients-file=secrets.age.recipients age://secrets.age?armor=false ``` @@ -129,3 +145,10 @@ $ secretspec run --provider "age://secrets.age" -- deploy ## Security considerations A recipient can decrypt every secret in a blob, not individual entries within it. Put secrets that should reach different audiences in separate files, each with its own roster. + +Hybrid post-quantum recipients protect stored ciphertext against +harvest-now/decrypt-later attacks, but static file encryption does not provide +forward secrecy. Anyone who later obtains a long-term identity can decrypt +historical ciphertext that still exists in Git history or backups. Rotate and +erase identities, re-encrypt the blob, and manage repository history according +to your retention policy when that risk matters. diff --git a/docs/src/content/docs/reference/providers.md b/docs/src/content/docs/reference/providers.md index 095c7b82..18815d99 100644 --- a/docs/src/content/docs/reference/providers.md +++ b/docs/src/content/docs/reference/providers.md @@ -235,16 +235,19 @@ Values are read with Infisical's secret references expanded, matching its own CL ## age Provider (0.17+) +> **Version compatibility:** The age provider is upcoming in SecretSpec 0.17 +> and is unavailable in the current SecretSpec 0.16 release. + **URI**: `age://PATH[?identity=FILE][&recipients-file=FILE][&armor=false]` - Stores secrets in a single age-encrypted file committed alongside code ```bash age://secrets.age # Encrypt to your own identity -age://secrets.age?identity=~/.config/age/keys.txt # Name the identity file +age://secrets.age?identity=/home/alice/.config/age/plugin-identity.txt age://secrets.age?recipients-file=secrets.age.recipients # Share with a roster ``` -**Features**: Read/write, committed-file storage, `age-plugin-*` recipients and identities -**Prerequisites**: An age identity from `age-keygen`, build with `--features age` +**Features**: Read/write, committed-file storage, X25519 and SSH keys, native tagged recipients, and non-interactive `age-plugin-*` recipients and identities +**Prerequisites**: An age identity; hybrid ML-KEM-768 + X25519 keys from `age-keygen -pq` are recommended for new setups and currently require the non-interactive `age-plugin-pq` compatibility plugin. Build with `--features age`. **Authentication**: The `identity` credential, `AGE_IDENTITY`, or `?identity=`; recipients from `?recipients-file=` or derived from the identity **Storage**: One `KEY=value` entry per secret inside the encrypted blob at PATH diff --git a/docs/src/pages/index.astro b/docs/src/pages/index.astro index 93c37512..6dbf4585 100644 --- a/docs/src/pages/index.astro +++ b/docs/src/pages/index.astro @@ -44,7 +44,7 @@ const providerMetadata: ProviderMetadata[] = [ { icon: 'googlecloud', slug: 'gcsm', label: 'Google Cloud Secret Manager' }, { icon: 'microsoftazure', slug: 'akv', label: 'Azure Key Vault' }, { slug: 'infisical', label: 'Infisical' }, - { slug: 'age', label: 'age' }, + { slug: 'age', label: 'age (0.17+)' }, { slug: 'gopass', label: 'Gopass' }, { slug: 'protonpass', label: 'Proton Pass' }, { icon: 'gnuprivacyguard', slug: 'pass', label: 'Pass (GPG)' }, @@ -142,6 +142,7 @@ $ secretspec config init ? Select your preferred provider backend: keyring: Uses system keychain (Recommended) infisical: Infisical secret management + age: age-encrypted file (0.17+) Saved to ~/.config/secretspec/config.toml # 3. Run your app with secrets injected @@ -208,7 +209,8 @@ $ secretspec run --profile production -- npm start
$ secretspec config init
 ? Select your preferred provider backend:
 > keyring: Uses system keychain (Recommended)
-  infisical: Infisical secret management
+ infisical: Infisical secret management + age: age-encrypted file (0.17+)
diff --git a/secretspec/src/provider/age.rs b/secretspec/src/provider/age.rs index 9c8259b1..5b2e5ffb 100644 --- a/secretspec/src/provider/age.rs +++ b/secretspec/src/provider/age.rs @@ -18,9 +18,9 @@ //! //! The private key is resolved from the `identity` provider credential, then //! the `AGE_IDENTITY` environment variable, then `?identity=`. -//! Recipient parsing and identity resolution both go through age's plugin -//! system, so plugin keys such as the ML-KEM/X25519 (X-Wing) `age1pq1...` -//! recipients work when their `age-plugin-*` binary is on `PATH`. +//! X25519 and SSH identities are supported directly. Non-interactive plugin +//! keys also work when their `age-plugin-*` binary is on `PATH`, including the +//! ML-KEM-768 + X25519 `age1pq1...` recipient via `age-plugin-pq`. use super::{Address, Provider, ProviderCredentials, ProviderUrl, credential_or_env, flat_item}; use crate::config::{NativeAddress, Secret}; @@ -30,7 +30,7 @@ use age::{Decryptor, Encryptor, Identity, IdentityFile, NoCallbacks, Recipient}; use secrecy::{ExposeSecret, SecretString}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use std::io::{Read, Write}; +use std::io::{Cursor, Read, Write}; use std::path::{Path, PathBuf}; /// Semantic credential name for the age identity @@ -42,6 +42,51 @@ fn provider_err(msg: impl Into) -> SecretSpecError { SecretSpecError::ProviderOperationFailed(msg.into()) } +/// An identity source after distinguishing multi-line SSH private keys from +/// age's one-identity-per-line file format. +enum ParsedIdentity { + Age(IdentityFile), + Ssh(age::ssh::Identity), +} + +impl ParsedIdentity { + fn to_recipients(&self) -> Result>> { + match self { + Self::Age(identity_file) => identity_file.to_recipients().map_err(|e| { + provider_err(format!("Failed to derive recipient from identity: {}", e)) + }), + Self::Ssh(identity) => age::ssh::Recipient::try_from(identity.clone()) + .map(|recipient| vec![Box::new(recipient) as Box]) + .map_err(|e| { + provider_err(format!( + "Failed to derive age recipient from SSH identity: {:?}", + e + )) + }), + } + } + + fn into_identities(self) -> Result>> { + match self { + Self::Age(identity_file) => identity_file + .into_identities() + .map_err(|e| provider_err(format!("Failed to load age identities: {}", e))), + Self::Ssh(identity) => Ok(vec![Box::new(identity.with_callbacks(NoCallbacks))]), + } + } +} + +/// SSH private keys are multi-line files, while native and plugin age +/// identities are one-per-line. Try the SSH format first because +/// `IdentityFile` intentionally does not parse it. +fn parse_identity(data: &[u8], filename: Option) -> std::io::Result { + if let Ok(identity) = age::ssh::Identity::from_buffer(Cursor::new(data), filename) { + return Ok(ParsedIdentity::Ssh(identity)); + } + + IdentityFile::from_buffer(Cursor::new(data)).map(ParsedIdentity::Age) +} + /// Configuration for the age provider. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgeConfig { @@ -128,23 +173,27 @@ impl AgeProvider { } } - /// Parses the configured identity file from credential, env, or path - fn identity_file(&self) -> Result> { + /// Parses the configured identity from credential, env, or path. + fn identity(&self) -> Result { if let Some(material) = credential_or_env(&self.credentials, IDENTITY, AGE_IDENTITY_ENV) { - return IdentityFile::from_buffer(std::io::Cursor::new(material.into_bytes())) - .map(|f| f.with_callbacks(NoCallbacks)) + return parse_identity(material.as_bytes(), None) .map_err(|e| provider_err(format!("Failed to parse age identity: {}", e))); } if let Some(path) = &self.config.identity_path { - return IdentityFile::from_file(path.display().to_string()) - .map(|f| f.with_callbacks(NoCallbacks)) - .map_err(|e| { - provider_err(format!( - "Failed to read age identity file {}: {}", - path.display(), - e - )) - }); + let data = std::fs::read(path).map_err(|e| { + provider_err(format!( + "Failed to read age identity file {}: {}", + path.display(), + e + )) + })?; + return parse_identity(&data, Some(path.display().to_string())).map_err(|e| { + provider_err(format!( + "Failed to parse age identity file {}: {}", + path.display(), + e + )) + }); } Err(provider_err( "No age identity configured. Set the `identity` credential, the \ @@ -156,9 +205,7 @@ impl AgeProvider { fn recipients(&self) -> Result>> { match &self.config.recipients_file { Some(path) => parse_recipients_file(path), - None => self.identity_file()?.to_recipients().map_err(|e| { - provider_err(format!("Failed to derive recipient from identity: {}", e)) - }), + None => self.identity()?.to_recipients(), } } @@ -168,10 +215,7 @@ impl AgeProvider { return Ok(HashMap::new()); } let ciphertext = std::fs::read(&self.config.path)?; - let identities = self - .identity_file()? - .into_identities() - .map_err(|e| provider_err(format!("Failed to load age identities: {}", e)))?; + let identities = self.identity()?.into_identities()?; let reader = ArmoredReader::new(&ciphertext[..]); let decryptor = Decryptor::new(reader) @@ -253,11 +297,19 @@ fn parse_recipients_file(path: &Path) -> Result>> Ok(recipients) } -/// Parses one recipient string as x25519, ssh, or a plugin recipient +/// Parses one recipient string in the same order as age's recipients-file +/// parser. Native tagged recipients must precede the generic plugin syntax +/// because `age1tag...` would otherwise be treated as `age-plugin-tag`. fn parse_recipient(s: &str) -> Result> { if let Ok(r) = s.parse::() { return Ok(Box::new(r)); } + if let Ok(r) = s.parse::() { + return Ok(Box::new(r)); + } + if let Ok(r) = s.parse::() { + return Ok(Box::new(r)); + } if let Ok(r) = s.parse::() { return Ok(Box::new(r)); } @@ -291,7 +343,27 @@ impl Provider for AgeProvider { } fn uri(&self) -> String { - format!("age:{}", self.config.path.display()) + let mut uri = format!("age:{}", self.config.path.display()); + let mut query = Vec::new(); + + // Identity sources are deliberately omitted because they are private + // configuration. Recipient rosters and output format are non-secret + // and must survive audit/report URI reconstruction. + if let Some(path) = &self.config.recipients_file { + query.push(format!( + "recipients-file={}", + ProviderUrl::encode_query(&path.display().to_string()) + )); + } + if !self.config.armor { + query.push("armor=false".to_string()); + } + if !query.is_empty() { + uri.push('?'); + uri.push_str(&query.join("&")); + } + + uri } fn with_credentials(&mut self, credentials: ProviderCredentials) { @@ -370,6 +442,41 @@ mod tests { "AGE-SECRET-KEY-15SFU79V44S2N3G4HKMG578KN5VXWM4GNLZUWVLY2Z8ENUPUCNWXQPQ5X33\n", ); const TEST_RECIPIENT: &str = "age1rcq2v5ckqn2r538m8qxz0xhx2am83zhxr60yfvmlsugkt6tygpcss829at"; + const TEST_SSH_IDENTITY: &str = "\ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACB7Ci6nqZYaVvrjm8+XbzII89TsXzP111AflR7WeorBjQAAAJCfEwtqnxML +agAAAAtzc2gtZWQyNTUxOQAAACB7Ci6nqZYaVvrjm8+XbzII89TsXzP111AflR7WeorBjQ +AAAEADBJvjZT8X6JRJI8xVq/1aU8nMVgOtVnmdwqWwrSlXG3sKLqeplhpW+uObz5dvMgjz +1OxfM/XXUB+VHtZ6isGNAAAADHN0cjRkQGNhcmJvbgE= +-----END OPENSSH PRIVATE KEY-----"; + const TEST_SSH_RECIPIENT: &str = + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHsKLqeplhpW+uObz5dvMgjz1OxfM/XXUB+VHtZ6isGN"; + const TEST_TAG_RECIPIENT: &str = + "age1tag1qt8lw0ual6avlwmwatk888yqnmdamm7xfd0wak53ut6elz5c4swx2yqdj4e"; + const TEST_TAGPQ_RECIPIENT: &str = concat!( + "age1tagpq1m3e4wvp6hzcrn9exhy0ae3xfx2sjymp594k3tg7j4dpmj922we65vtnmrt2pyallax8669zqkr2pmfchp", + "tr4n38kug2xmcmp3adk2lnjqu00x5kxz5pvhmrltvfh9wuq973pcx35cnq8syn9qd3tzpehgztl4xpzr3tpd67g8af9trnjpc05g", + "h7wu536aq4qt2y8zhsm4tvrfpsfl36qs5fpzysnk3sp9w77qzeg49357xex40v4s2lvt620swyys7u8yxdcnu4rkkwxdmt55gsuc", + "3h5c5swahnegjgqwc60hn085ec3sjztwm45l44y3j2at9t6v9zra4ek3kek6waecqm98yaxl37w0d2zra626nz63jdm5sg59w7ly", + "ptw83zm6fntd8d0x03a9z6h9prfgpygzar6zrxjcrt4cdctk2mhf95s4a6v4zklfd49xhpsaeujm57thx2x3e3hwzc86ftfhmq5m", + "kxxz3d6r8ws24xj4qfn73eyezg2wy094e3why592pghz27ruq3vkyegrv80eftnw9wqzwgvnwyseaus0yt84fylzrpzp6x2fguxu", + "qjmgudr8xd33qm30evdpxd3jvjg8qh4q60kyq80jgff369k7nrepdc38grd2dava520excqp0ey0x39khx8ry03yffcatgv84fsx", + "5j49djpapedsy693zute5xv5g2ewzrlj5se7akvkc4g4vmzhputpq8eyj9wz5dz6qtn7g3cfpd95nahw4ytspan0feyye04dcylv", + "24ege7zkaj004gjwcxqxfqu2quawa83sx452jqjn8t48czp0xspwgnmvjyhttzzy6nhq8xzkdwnvsfefkwva6asrqc93zjn4rly5", + "gnlv93xy3uzmr39szvjnf63426qzyeyvguc4vdcquwgsxgq236afcpqz866ny4tn7ckc0umefj242rt5vtvwqzzrvfev2mpvqcuf", + "p9pqvefyv4ftyuhgausfzuaadsczeykmft5wv3frzgrcp9ztr93h478ke4t86spp2uhyjkj73mp9g92ddk2fpv7v3njzsqgwhq37", + "89sqrgkskehn0zjscckhwftyq4vet7vrlx2hs5kd9cwnq6t0djffhh3zquh4j3p0yaj9z2rc9wykg0usqw7983rrgur9jg8rnnqy", + "pwcz2lyclnnc705fc5g3an93ps60q6mxqp85u0ewtxdjlqcks84yduft0a0g6e7naew3v9u2d08knarvajn8q3gq9pgxde3s7nx9", + "4lus48wwvw2xjm7k82tvylec2393jdsuvch2xpe77w8hpv9nvsxfsrs270njpmfvpmgyk2cffl9tjp3qqcc4dfkf5rme2dg0x7ew", + "8g39www5smm705q5da4eqvnqwrkavtq6xje9ss38hnkglz4eddz8f5qruvqmq2ff9l22gwkv8h432rdkysy0grkul8e2fedvkyya", + "pfxt760udcgu92m54wl9yavmj4ga3ph9r5n99cjrq6wj5v33x33fe5vkjvfwnnt40wuv2hyexc9f4ylyqv9ldqq9epd4yuv8vrsf", + "x2qy2kqz08kqhnzspy6s0x8fa5c2xkg5y2q0rvz4vnk7rp0acg6eksc3t7cxnn8y7glkjsqja3p56uz6vvhcw55d3ysad0hvsqxp", + "jnc7svenf2gc5xn5kyr0et2vvyruxlnpqcdpqh9pzplumy5yzjxftyzh9ujfw0jq7ee60zx2x23p0jzyh9dvmly8p9h9ysptlqu7", + "kwnejd65dnr75a0np2fvke8xen38r57w6z3wz3mycjmmn267wwxndfh9jdps7uxtct2wwfgamkpa5ap8s96lhfjztpwcm6fguhph", + "u38yunu2v4vz3syzrvgwtqpemkewzp766nyu6texxvjlaemnhyyqutkcy6a42vqfsz49rw5wr4gt70r4vdaasehqjg46fnyts4st", + "hrxadfllha3avu49wsj2c4jx", + ); fn config_from(uri: &str) -> AgeConfig { let url = ProviderUrl::new(Url::parse(uri).unwrap()); @@ -418,6 +525,45 @@ mod tests { assert!(parse_recipient("not-an-age-recipient").is_err()); } + #[test] + fn parses_native_tagged_recipients_without_plugins() { + for encoded in [TEST_TAG_RECIPIENT, TEST_TAGPQ_RECIPIENT] { + let recipient = parse_recipient(encoded).unwrap(); + let encryptor = + Encryptor::with_recipients(std::iter::once(recipient.as_ref() as &dyn Recipient)) + .unwrap(); + let mut ciphertext = Vec::new(); + let mut writer = encryptor.wrap_output(&mut ciphertext).unwrap(); + writer.write_all(b"tagged recipient").unwrap(); + writer.finish().unwrap(); + assert!(!ciphertext.is_empty()); + } + } + + #[test] + fn reported_uri_preserves_non_secret_options() { + let provider = AgeProvider::new(AgeConfig { + path: PathBuf::from("secrets.age"), + identity_path: Some(PathBuf::from("/private/identity.txt")), + recipients_file: Some(PathBuf::from("team &+ roster.txt")), + armor: false, + }); + + let uri = provider.uri(); + assert_eq!( + uri, + "age:secrets.age?recipients-file=team%20%26%2B%20roster.txt&armor=false" + ); + assert!(!uri.contains("identity")); + + let reparsed = config_from(&uri); + assert_eq!( + reparsed.recipients_file, + Some(PathBuf::from("team &+ roster.txt")) + ); + assert!(!reparsed.armor); + } + fn write_identity(dir: &Path) -> PathBuf { let key = dir.join("key.txt"); std::fs::write(&key, TEST_IDENTITY).unwrap(); @@ -511,4 +657,34 @@ mod tests { "teamsecret" ); } + + #[test] + fn ssh_identity_and_recipient_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let roster = dir.path().join("roster.recipients"); + std::fs::write(&roster, format!("{}\n", TEST_SSH_RECIPIENT)).unwrap(); + + let mut provider = AgeProvider::new(AgeConfig { + path: dir.path().join("ssh.age"), + identity_path: None, + recipients_file: Some(roster), + armor: true, + }); + let mut credentials = ProviderCredentials::new(); + credentials.insert( + IDENTITY.to_string(), + SecretString::new(TEST_SSH_IDENTITY.to_string().into()), + ); + provider.with_credentials(credentials); + + let addr = Address::convention("proj", "default", "API_KEY"); + provider + .set(addr, &SecretString::new("ssh-secret".to_string().into())) + .unwrap(); + + assert_eq!( + provider.get(addr).unwrap().unwrap().expose_secret(), + "ssh-secret" + ); + } }