From 2a577452fd5f9b59e23914c6eb61904285cc4499 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Thu, 8 Oct 2026 13:06:50 +0200 Subject: [PATCH 1/9] feat: integrate credential generators and X.509 identities --- .github/workflows/haskell-build.yml | 9 +- CHANGELOG.md | 7 + Cargo.lock | 44 + Cargo.toml | 8 +- docs/public/schema/secretspec.schema.json | 111 +- docs/src/content/docs/concepts/declarative.md | 11 +- docs/src/content/docs/concepts/generation.mdx | 89 +- docs/src/content/docs/reference/cli.mdx | 6 + .../content/docs/reference/configuration.mdx | 290 ++- secretspec/Cargo.toml | 6 + secretspec/README.md | 2 +- secretspec/src/compiled_spec.rs | 20 + secretspec/src/config.rs | 929 +++++++++- secretspec/src/error.rs | 17 + secretspec/src/generator.rs | 587 +++++- secretspec/src/lib.rs | 17 +- secretspec/src/native.rs | 154 +- secretspec/src/plan.rs | 51 +- secretspec/src/report.rs | 37 +- secretspec/src/resolve.rs | 7 +- secretspec/src/secrets.rs | 782 ++++++-- secretspec/src/spec.rs | 335 +++- secretspec/src/tests.rs | 133 ++ secretspec/src/typed.rs | 329 ++++ secretspec/src/typed_tests.rs | 1572 +++++++++++++++++ secretspec/src/x509_identity.rs | 1076 +++++++++++ secretspec/tests/config_schemas.rs | 9 + 27 files changed, 6397 insertions(+), 241 deletions(-) create mode 100644 secretspec/src/typed.rs create mode 100644 secretspec/src/typed_tests.rs create mode 100644 secretspec/src/x509_identity.rs diff --git a/.github/workflows/haskell-build.yml b/.github/workflows/haskell-build.yml index 4de54ff20..f8186d9eb 100644 --- a/.github/workflows/haskell-build.yml +++ b/.github/workflows/haskell-build.yml @@ -124,6 +124,10 @@ 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 MSYS2 prepends its Cygwin Perl to PATH. + # Vendored OpenSSL requires a native Windows Perl for an MSVC target. + - 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 +150,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 f040c0048..971f85483 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 diff --git a/Cargo.lock b/Cargo.lock index e73e1e252..204d88c25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1037,6 +1037,16 @@ 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" @@ -1052,6 +1062,15 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[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" @@ -3038,6 +3057,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" @@ -4608,6 +4636,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-src" +version = "300.6.1+3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" +dependencies = [ + "cc", +] + [[package]] name = "openssl-sys" version = "0.9.117" @@ -4616,6 +4653,7 @@ checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", + "openssl-src", "pkg-config", "vcpkg", ] @@ -5999,6 +6037,8 @@ dependencies = [ "azure_identity", "azure_security_keyvault_secrets", "base64", + "bech32", + "bip39", "clap", "clap_complete", "clap_complete_nushell", @@ -6007,6 +6047,7 @@ dependencies = [ "detect-coding-agent", "dotenv-ng", "dunce", + "ed25519-dalek", "etcetera", "google-cloud-gax", "google-cloud-secretmanager-v1", @@ -6024,6 +6065,8 @@ dependencies = [ "libc", "linkme", "miette", + "openssl", + "p256", "percent-encoding", "pgp", "proptest", @@ -6053,6 +6096,7 @@ dependencies = [ "wait-timeout", "whoami", "windows-sys 0.61.2", + "x25519-dalek", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 13ab2dc4b..8eb16a356 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,7 @@ 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" } +openssl = { version = "0.10.81", features = ["vendored"] } # The profile that 'dist' will build with [profile.dist] 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..d1cfc440e 100644 --- a/docs/src/content/docs/reference/cli.mdx +++ b/docs/src/content/docs/reference/cli.mdx @@ -515,6 +515,12 @@ 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`. 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 921746ad2..6125d2b72 100644 --- a/docs/src/content/docs/reference/configuration.mdx +++ b/docs/src/content/docs/reference/configuration.mdx @@ -244,8 +244,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 | @@ -265,6 +268,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 @@ -294,7 +308,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: @@ -335,7 +351,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 @@ -822,6 +839,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 */} @@ -836,6 +863,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 @@ -895,22 +926,194 @@ 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 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". + +#### 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 @@ -1132,6 +1335,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" } ``` @@ -1148,6 +1360,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 | @@ -1155,6 +1369,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 */} @@ -1186,6 +1404,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 @@ -1199,6 +1473,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..1dc22f7b3 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,7 @@ age = { workspace = true, optional = true } kube = { workspace = true, optional = true } k8s-openapi = { workspace = true, optional = true } json-patch = { workspace = true, optional = true } +openssl.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 b1a85dfe8..8f028baeb 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, @@ -2179,21 +2274,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, } @@ -2206,6 +2303,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`. @@ -2300,18 +2465,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`. @@ -2352,6 +2546,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, }) @@ -2381,6 +2578,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, } @@ -2433,6 +2633,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. @@ -2487,16 +2892,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() @@ -2579,6 +2988,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 { @@ -2618,9 +3040,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(), ); } @@ -2679,9 +3111,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(), ); } @@ -2718,6 +3160,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() { @@ -2728,7 +3411,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)); } @@ -2749,7 +3439,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)); } @@ -2805,13 +3502,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 @@ -2825,6 +3524,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, @@ -2840,8 +3547,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), }) @@ -3795,6 +4505,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( @@ -4110,6 +4921,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..ad6a28dd9 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,123 @@ 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 data_encoding::BASE64; + use openssl::pkcs12::Pkcs12; + use openssl::x509::X509; + + 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 = + X509::from_pem(secrets["CERT"]["value"].as_str().unwrap().as_bytes()).unwrap(); + let archive = BASE64 + .decode(secrets["PFX"]["value"].as_str().unwrap().as_bytes()) + .unwrap(); + let archive = Pkcs12::from_der(&archive).unwrap(); + assert!(archive.parse2("").is_err()); + let identity = archive.parse2("archive-password").unwrap(); + assert_eq!( + certificate.to_der().unwrap(), + identity.cert.unwrap().to_der().unwrap() + ); + assert!( + identity + .pkey + .unwrap() + .public_eq(&certificate.public_key().unwrap()) + ); + } } 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..8143462d1 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.21. Composed, } diff --git a/secretspec/src/secrets.rs b/secretspec/src/secrets.rs index 4ac2596b2..035795a72 100644 --- a/secretspec/src/secrets.rs +++ b/secretspec/src/secrets.rs @@ -13,7 +13,7 @@ use crate::provider::{ Address, OwnedAddress, ProducedValuePersistence, Provider as ProviderTrait, ProviderCredentials, ProviderValue, same_storage_container, }; -use crate::report::{ResolutionReport, ResolutionStatus, SecretResolution}; +use crate::report::{Derivation, ResolutionReport, ResolutionStatus, SecretResolution}; use crate::resolve::{ NamedResolution, RESOLVE_SCHEMA_VERSION, ResolveResponse, ResolvedSecret, ResolvedSource, }; @@ -24,7 +24,8 @@ use colored::Colorize; use data_encoding::{ BASE64, BASE64_NOPAD, BASE64URL, BASE64URL_NOPAD, Encoding, HEXLOWER, HEXLOWER_PERMISSIVE, }; -use secrecy::ExposeSecret; +use secrecy::zeroize::Zeroizing; +use secrecy::{ExposeSecret, SecretSlice, SecretString}; #[cfg(unix)] use signal_hook::consts::signal::{SIGHUP, SIGINT, SIGTERM}; #[cfg(unix)] @@ -175,9 +176,9 @@ fn resolved_source(entry: &SecretResolution) -> ResolvedSource { /// Stands in for a secret's name in diagnostics when the active scope hides it. /// /// Every accessed-but-not-visible secret is by construction a dependency of a -/// visible composed secret, so this describes what it is without disclosing +/// visible derived secret, so this describes what it is without disclosing /// which secret it is. -pub(crate) const HIDDEN_SECRET_LABEL: &str = "a hidden composition input"; +pub(crate) const HIDDEN_SECRET_LABEL: &str = "a hidden derived-secret input"; /// Emits a warning when a provider in a fallback chain fails so the user /// can see why a particular link was skipped, without aborting the chain. @@ -1407,6 +1408,13 @@ struct ResolutionExecution<'secrets, 'plan, 'filter, 'addresses> { failed_primary_uris: HashMap, SecretSpecError>, cached_uris: HashMap, fallback_results: HashMap, + /// Registry-typed provider values parked by the provider pass until the + /// derived pass, where their credentials have resolved and the type + /// contract decodes them exactly once. + pending_typed: HashMap, + /// Decoded identities, keyed by secret name, for the projections that + /// derive from them in the same pass. + identities: HashMap, } impl<'secrets, 'plan, 'filter, 'addresses> @@ -1439,6 +1447,58 @@ impl<'secrets, 'plan, 'filter, 'addresses> failed_primary_uris: HashMap::new(), cached_uris: HashMap::new(), fallback_results: HashMap::new(), + pending_typed: HashMap::new(), + identities: HashMap::new(), + } + } + + /// Insert a value that crossed a storage boundary. A registry-typed stored + /// value is parked instead: its contract decodes it in the derived pass, + /// once the credentials it may need have resolved. + fn insert_stored( + &mut self, + planned: &PlannedSecret, + diagnostic_name: &str, + value: SecretBytes, + ) -> Result<()> { + if planned.is_typed_stored() { + self.pending_typed + .insert(planned.name.clone(), PendingTyped::Stored(value)); + return Ok(()); + } + self.manager.insert_resolved( + &mut self.values, + &mut self.temp_files, + planned, + diagnostic_name, + value, + ResolvedRepresentation::Stored, + ) + } + + /// Insert a freshly generated value. Text is already logical; generated + /// binary identities are parked like stored ones so one code path decodes, + /// validates, and delivers every typed value. + fn insert_generated( + &mut self, + planned: &PlannedSecret, + diagnostic_name: &str, + value: GeneratedValue, + ) -> Result<()> { + match value { + GeneratedValue::Text(value) => self.manager.insert_resolved( + &mut self.values, + &mut self.temp_files, + planned, + diagnostic_name, + value, + ResolvedRepresentation::Logical, + ), + GeneratedValue::Binary(bytes) => { + self.pending_typed + .insert(planned.name.clone(), PendingTyped::Logical(bytes)); + Ok(()) + } } } @@ -1460,7 +1520,7 @@ impl<'secrets, 'plan, 'filter, 'addresses> self.fetch_primary_values()?; self.fetch_fallback_values(); self.resolve_provider_backed_values()?; - self.resolve_composed_values()?; + self.resolve_derived_values()?; self.finish() } @@ -1658,14 +1718,7 @@ impl<'secrets, 'plan, 'filter, 'addresses> manager.write_cached_secret(planned, route, profile, &value); } if materialize.values() { - manager.insert_resolved( - &mut self.values, - &mut self.temp_files, - planned, - diagnostic_name, - value.value, - ResolvedRepresentation::Stored, - )?; + self.insert_stored(planned, diagnostic_name, value.value)?; } status = ResolutionStatus::Resolved; } @@ -1727,14 +1780,7 @@ impl<'secrets, 'plan, 'filter, 'addresses> source_provider = fallback_uri; if materialize.values() { manager.write_cached_secret(planned, route, profile, &value); - manager.insert_resolved( - &mut self.values, - &mut self.temp_files, - planned, - diagnostic_name, - value.value, - ResolvedRepresentation::Stored, - )?; + self.insert_stored(planned, diagnostic_name, value.value)?; } status = ResolutionStatus::Resolved; } else { @@ -1767,13 +1813,10 @@ impl<'secrets, 'plan, 'filter, 'addresses> let generated_value = manager .try_generate_secret(planned, profile)? .expect("compiled Generate policy has a generator"); - manager.insert_resolved( - &mut self.values, - &mut self.temp_files, + self.insert_generated( planned, diagnostic_name, generated_value, - ResolvedRepresentation::Logical, )?; status = ResolutionStatus::Resolved; } else if required @@ -1828,49 +1871,48 @@ impl<'secrets, 'plan, 'filter, 'addresses> default_applied, generated, composed: false, + derivation: None, as_path: planned.as_path(), }); } Ok(()) } - fn resolve_composed_values(&mut self) -> Result<()> { - fn composition_order<'a>( + /// Resolve every value the executor produces itself, in one topological + /// pass: composed templates, `from` conversions and selections, and the + /// typed decoding of provider-backed registry values whose credentials had + /// to resolve first. A node runs after its dependencies, so declaration + /// order never matters and a typed value is decoded exactly once. + fn resolve_derived_values(&mut self) -> Result<()> { + fn order<'a>( planned: &'a PlannedSecret, - composed: &HashMap<&str, &'a PlannedSecret>, + nodes: &HashMap<&str, &'a PlannedSecret>, visited: &mut HashSet<&'a str>, ordered: &mut Vec<&'a PlannedSecret>, ) { if !visited.insert(planned.name.as_str()) { return; } - let template = planned - .composition() - .expect("only composed nodes are ordered"); - for dependency in template.dependencies() { - if let Some(dependency) = composed.get(dependency.as_str()) { - composition_order(dependency, composed, visited, ordered); + for dependency in planned.dependencies() { + if let Some(dependency) = nodes.get(dependency) { + order(dependency, nodes, visited, ordered); } } ordered.push(planned); } - let composed: HashMap<&str, &PlannedSecret> = self + let is_node = |secret: &PlannedSecret| secret.is_derived() || secret.is_typed_stored(); + let nodes: HashMap<&str, &PlannedSecret> = self .plan .secrets .iter() - .filter(|secret| secret.is_composed()) + .filter(|secret| is_node(secret)) .map(|secret| (secret.name.as_str(), secret)) .collect(); - let mut ordered = Vec::with_capacity(composed.len()); + let mut ordered = Vec::with_capacity(nodes.len()); let mut visited = HashSet::new(); - for planned in self - .plan - .secrets - .iter() - .filter(|secret| secret.is_composed()) - { - composition_order(planned, &composed, &mut visited, &mut ordered); + for planned in self.plan.secrets.iter().filter(|secret| is_node(secret)) { + order(planned, &nodes, &mut visited, &mut ordered); } if ordered.is_empty() { @@ -1883,55 +1925,92 @@ impl<'secrets, 'plan, 'filter, 'addresses> .map(|entry| (entry.name.clone(), entry.status.clone())) .collect(); for planned in ordered { - let template = planned - .composition() - .expect("only composed nodes are ordered"); - let dependencies_resolved = template + let dependencies_resolved = planned .dependencies() .iter() - .all(|dependency| statuses.get(dependency) == Some(&ResolutionStatus::Resolved)); + .all(|dependency| statuses.get(*dependency) == Some(&ResolutionStatus::Resolved)); + + if planned.is_typed_stored() { + // The provider pass already recorded this secret. It stays + // resolved only while its credentials did: an archive whose + // password is missing cannot be opened, so the secret is + // missing under its own policy rather than exposed unusable. + let fetched = + statuses.get(planned.name.as_str()) == Some(&ResolutionStatus::Resolved); + if fetched && dependencies_resolved { + if self.materialize.values() { + self.finalize_typed_stored(planned)?; + } + } else if fetched { + self.pending_typed.remove(planned.name.as_str()); + let status = match planned.secret.missing { + MissingPolicy::Omit => { + self.missing_optional.push(planned.name.clone()); + ResolutionStatus::MissingOptional + } + _ => { + self.missing_required.push(planned.name.clone()); + ResolutionStatus::MissingRequired + } + }; + if let Some(entry) = self + .resolution + .iter_mut() + .find(|entry| entry.name == planned.name) + { + entry.status = status.clone(); + } + statuses.insert(planned.name.clone(), status); + } + continue; + } + let status = if dependencies_resolved { - if let Some(expiry) = template + if let Some(expiry) = planned .dependencies() .iter() - .filter_map(|name| self.secret_expiries.get(name)) + .filter_map(|name| self.secret_expiries.get(*name)) .copied() .min() { self.secret_expiries.insert(planned.name.clone(), expiry); } - if let Some(refresh) = template + if let Some(refresh) = planned .dependencies() .iter() - .filter_map(|name| self.refreshes.get(name)) + .filter_map(|name| self.refreshes.get(*name)) .copied() .min() { self.refreshes.insert(planned.name.clone(), refresh); } if self.materialize.values() { - let inputs = template - .dependencies() - .iter() - .filter_map(|name| self.values.get(name).map(|value| (name, value))) - .map(|(name, value)| { - let diagnostic_name = - Secrets::diagnostic_secret_name(name, self.output_filter); - Self::text_input(diagnostic_name, value) - .map(|text| (name.as_str(), text)) - }) - .collect::>>()?; - let rendered = template - .render(|dependency| inputs.get(dependency).copied()) - .map_err(SecretSpecError::CompositionFailed)?; - self.manager.insert_resolved( - &mut self.values, - &mut self.temp_files, - planned, - Secrets::diagnostic_secret_name(&planned.name, self.output_filter), - SecretBytes::from_utf8(rendered), - ResolvedRepresentation::Logical, - )?; + if let Some(template) = planned.composition() { + let inputs = template + .dependencies() + .iter() + .filter_map(|name| self.values.get(name).map(|value| (name, value))) + .map(|(name, value)| { + let diagnostic_name = + Secrets::diagnostic_secret_name(name, self.output_filter); + Self::text_input(diagnostic_name, value) + .map(|text| (name.as_str(), text)) + }) + .collect::>>()?; + let rendered = template + .render(|dependency| inputs.get(dependency).copied()) + .map_err(SecretSpecError::CompositionFailed)?; + self.manager.insert_resolved( + &mut self.values, + &mut self.temp_files, + planned, + Secrets::diagnostic_secret_name(&planned.name, self.output_filter), + SecretBytes::from_utf8(rendered), + ResolvedRepresentation::Logical, + )?; + } else { + self.resolve_from(planned)?; + } } ResolutionStatus::Resolved } else { @@ -1945,11 +2024,22 @@ impl<'secrets, 'plan, 'filter, 'addresses> ResolutionStatus::MissingOptional } MissingPolicy::Generate | MissingPolicy::UseDefault | MissingPolicy::Prompt => { - unreachable!("composed source conflicts are rejected at load time") + unreachable!("derived source conflicts are rejected at load time") } } }; + let derivation = if planned.is_composed() { + Derivation::Composed + } else if let Some(source) = planned.from() { + if planned.extract().is_some() { + Derivation::SelectedFrom(source.to_string()) + } else { + Derivation::ConvertedFrom(source.to_string()) + } + } else { + unreachable!("derived nodes are composed or converted") + }; statuses.insert(planned.name.clone(), status.clone()); self.resolution.push(SecretResolution { name: planned.name.clone(), @@ -1959,6 +2049,7 @@ impl<'secrets, 'plan, 'filter, 'addresses> default_applied: false, generated: false, composed: true, + derivation: Some(derivation), as_path: planned.as_path(), }); } @@ -1973,6 +2064,184 @@ impl<'secrets, 'plan, 'filter, 'addresses> }) } + /// The value bound to one credential role of a typed secret, validated + /// against the bounds every credential shares. `None` when the role is + /// unbound, which the type contract treats as an empty credential. + fn credential_value( + &self, + planned: &PlannedSecret, + role: &str, + ) -> Result> { + let Some(credential) = planned.credential(role) else { + return Ok(None); + }; + let diagnostic_name = Secrets::diagnostic_secret_name(&planned.name, self.output_filter); + let value = + self.values + .get(credential) + .ok_or_else(|| SecretSpecError::CredentialInvalid { + name: diagnostic_name.to_string(), + role: role.to_string(), + reason: format!( + "depends on '{}', which did not resolve", + Secrets::diagnostic_secret_name(credential, self.output_filter) + ), + })?; + crate::typed::validate_credential_value( + role, + value + .try_as_utf8() + .map_err(|_| SecretSpecError::CredentialInvalid { + name: diagnostic_name.to_string(), + role: role.to_string(), + reason: "credential is not valid UTF-8".to_string(), + })?, + ) + .map_err(|reason| SecretSpecError::CredentialInvalid { + name: diagnostic_name.to_string(), + role: role.to_string(), + reason, + })?; + Ok(Some(SecretString::new( + value.try_as_utf8().expect("validated credential").into(), + ))) + } + + /// Deliver a binary logical value: a file holding the raw bytes with the + /// type's suffix when `as_path`, otherwise inline text in the value's + /// encoding. This is the delivery contract for registry-typed binary + /// values, stored or derived. + fn deliver_bytes(&mut self, planned: &PlannedSecret, bytes: &[u8]) -> Result<()> { + if planned.as_path() { + let (owner, path) = self + .manager + .write_secret_to_temp_file(bytes, planned.config().file_suffix())?; + self.temp_files.push(owner); + self.values + .insert(planned.name.clone(), SecretBytes::from_utf8(path)); + } else { + let encoding = planned + .encoding() + .expect("binary values always carry an encoding"); + self.values.insert( + planned.name.clone(), + Secrets::encode_logical_bytes(encoding, bytes), + ); + } + Ok(()) + } + + /// Decode and validate a parked registry-typed value with the credentials + /// bound to it, deliver its canonical bytes, and keep the decoded identity + /// for the projections that derive from it. + fn finalize_typed_stored(&mut self, planned: &PlannedSecret) -> Result<()> { + let diagnostic_name = Secrets::diagnostic_secret_name(&planned.name, self.output_filter); + let pending = self + .pending_typed + .remove(planned.name.as_str()) + .expect("a resolved typed stored secret parked its value"); + let bytes: SecretSlice = match pending { + PendingTyped::Stored(value) => { + let encoding = planned + .encoding() + .expect("binary stored types always carry an encoding"); + SecretSlice::from( + Secrets::decode_stored_value(encoding, diagnostic_name, &value)?.to_vec(), + ) + } + PendingTyped::Logical(bytes) => bytes, + }; + let password = self.credential_value(planned, "password")?; + let identity = crate::x509_identity::Identity::decode( + bytes.expose_secret(), + password.as_ref().map(|password| password.expose_secret()), + diagnostic_name, + )?; + self.deliver_bytes(planned, bytes.expose_secret())?; + self.identities.insert(planned.name.clone(), identity); + Ok(()) + } + + /// Produce a `from` secret: select a field from the source's text with + /// `extract`, or convert the source identity into the target type. + fn resolve_from(&mut self, planned: &PlannedSecret) -> Result<()> { + let source_name = planned.from().expect("a `from` secret names its source"); + let diagnostic_name = Secrets::diagnostic_secret_name(&planned.name, self.output_filter); + + if let Some(extract) = planned.extract() { + let source_planned = self + .plan + .secrets + .iter() + .find(|candidate| candidate.name == source_name) + .expect("validated `from` source is in the resolution plan"); + let source = self + .values + .get(source_name) + .expect("a resolved `from` source has a value"); + let not_utf8 = |error: std::str::Utf8Error| SecretSpecError::DecodeFailed { + name: diagnostic_name.to_string(), + encoding: extract.format.as_str(), + reason: format!("source value is not valid UTF-8: {error}"), + }; + let text = if source_planned.as_path() { + let bytes = Zeroizing::new(std::fs::read( + source.try_as_utf8().expect("materialized path is UTF-8"), + )?); + SecretBytes::from_utf8(std::str::from_utf8(&bytes).map_err(not_utf8)?) + } else { + source.clone() + }; + let value = Secrets::extract_stored_value( + extract, + diagnostic_name, + std::str::from_utf8(text.expose_secret()).map_err(not_utf8)?, + )?; + return self.manager.insert_resolved( + &mut self.values, + &mut self.temp_files, + planned, + diagnostic_name, + value, + ResolvedRepresentation::Logical, + ); + } + + let contract = planned + .config() + .typed_contract() + .expect("a converting `from` secret has a registry type"); + let projection = contract + .projection + .expect("derivable registry types define a projection"); + let password = self.credential_value(planned, "password")?; + let projected = { + let identity = self + .identities + .get(source_name) + .expect("an x509_identity source is decoded before its projections"); + identity.project( + projection, + planned.config().typed_format(), + password.as_ref().map(|password| password.expose_secret()), + diagnostic_name, + )? + }; + match projected { + crate::x509_identity::ProjectedValue::Text(value) => self.manager.insert_resolved( + &mut self.values, + &mut self.temp_files, + planned, + diagnostic_name, + SecretBytes::from_utf8(value.expose_secret()), + ResolvedRepresentation::Logical, + ), + crate::x509_identity::ProjectedValue::Binary(bytes) => { + self.deliver_bytes(planned, bytes.expose_secret()) + } + } + } + fn apply_output_filter(&mut self) { let Some(filter) = self.output_filter else { return; @@ -2219,6 +2488,20 @@ fn keep_owned_files(files: Vec) -> Result<()> { Ok(()) } +/// A freshly generated value before the provider's textual storage boundary. +enum GeneratedValue { + Text(SecretBytes), + Binary(SecretSlice), +} + +/// A registry-typed value waiting for the derived pass to decode it: either +/// the text read from a store, still in its storage encoding, or the logical +/// bytes a generator just produced. +enum PendingTyped { + Stored(SecretBytes), + Logical(SecretSlice), +} + /// Whether a resolved string came from a storage boundary and is eligible for /// decoding, or is already the logical value produced inside SecretSpec. #[derive(Clone, Copy)] @@ -3855,7 +4138,10 @@ impl Secrets { /// Encode logical bytes into the canonical stored representation. fn encode_logical_value(encoding: SecretEncoding, value: &SecretBytes) -> SecretBytes { - let bytes = value.expose_secret(); + Self::encode_logical_bytes(encoding, value.expose_secret()) + } + + fn encode_logical_bytes(encoding: SecretEncoding, bytes: &[u8]) -> SecretBytes { let encoded = match encoding { SecretEncoding::Base64 => BASE64.encode(bytes), SecretEncoding::Base64Url => BASE64URL_NOPAD.encode(bytes), @@ -3872,6 +4158,32 @@ impl Secrets { .map(|encoding| Self::encode_logical_value(encoding, value)) } + /// Convert operator input into the provider representation. Binary typed + /// values are entered in their encoded form, so decode them before + /// validating and re-encoding canonically. Text values are logical input + /// and only need their configured storage encoding applied. + fn prompted_value_for_storage( + planned: &PlannedSecret, + diagnostic_name: &str, + value: &SecretBytes, + ) -> Result> { + if !planned.is_typed_stored() { + return Ok(Self::encoded_for_storage(planned, value)); + } + + let encoding = planned + .encoding() + .expect("binary stored types always carry an encoding"); + let bytes = Self::decode_stored_value(encoding, diagnostic_name, value)?; + if planned.credential("password").is_none() { + crate::x509_identity::validate(bytes.expose_secret(), diagnostic_name)?; + } + Ok(Some(Self::encode_logical_bytes( + encoding, + bytes.expose_secret(), + ))) + } + /// Validate a stored value before import copies it into another provider. /// Import moves the stored representation verbatim, so an invalid encoded /// source must fail before any target write (and especially before source @@ -3882,9 +4194,20 @@ impl Secrets { diagnostic_name: &str, value: &SecretBytes, ) -> Result<()> { - if let Some(encoding) = planned.encoding() { - Self::decode_stored_value(encoding, diagnostic_name, value)?; + let Some(encoding) = planned.encoding() else { + return Ok(()); + }; + let decoded = Self::decode_stored_value(encoding, diagnostic_name, value)?; + if planned.is_typed_stored() { + // Import copies stored bytes without resolving the profile, so an + // archive that a bound credential unlocks can only be checked for + // its encoding here; an unprotected one is validated in full. + if planned.credential("password").is_none() { + crate::x509_identity::validate(decoded.expose_secret(), diagnostic_name)?; + } + return Ok(()); } + {} Ok(()) } @@ -3985,7 +4308,8 @@ impl Secrets { .map(|value| value.expose_secret()) .or_else(|| decoded.as_ref().map(|(_, decoded)| decoded.expose_secret())) .unwrap_or_else(|| value.expose_secret()); - let (owner, path) = self.write_secret_to_temp_file(bytes)?; + let (owner, path) = + self.write_secret_to_temp_file(bytes, planned.config().file_suffix())?; Ok(PreparedSecret::File { owner, path }) } else { Ok(PreparedSecret::Inline( @@ -4286,10 +4610,8 @@ impl Secrets { if !acc.insert(name.to_string()) { return; } - if let Some(secret) = profile.secrets.get(name) - && let Some(template) = &secret.composition - { - for dependency in template.dependencies() { + if let Some(secret) = profile.secrets.get(name) { + for dependency in secret.dependencies() { visit(dependency, profile, acc); } } @@ -5217,7 +5539,7 @@ impl Secrets { let Some(route) = &planned.route else { if named { return Err(SecretSpecError::ProviderOperationFailed(format!( - "secret '{name}' is composed and has no provider cache" + "secret '{name}' is derived and has no provider cache" ))); } continue; @@ -5284,7 +5606,7 @@ impl Secrets { /// Stores a UTF-8 secret value. Available starting with SecretSpec 0.21. pub fn set_text(&self, name: &str, value: &str) -> Result<()> { - self.set(name, SecretBytes::from_utf8(value)) + self.set_with_input_format(name, |_| Ok(SecretBytes::from_utf8(value)), true) } /// Prompts for a textual secret value and stores it. Available starting @@ -5295,30 +5617,35 @@ impl Secrets { /// stores `value`. Use [`Self::set`] or `secretspec set --from-file` to /// store exact bytes. pub fn prompt_and_set(&self, name: &str) -> Result<()> { - self.set_with_input(name, |profile_name| { - if io::stdin().is_terminal() { - let secret = inquire::Password::new(&format!( - "Enter value for {name} (profile: {profile_name}):" - )) - .without_confirmation() - .prompt()?; - Ok(SecretBytes::from_utf8(secret)) - } else { - // Read from stdin when input is piped - let mut buffer = Vec::new(); - io::stdin().read_to_end(&mut buffer)?; - let text = SecretBytes::from_vec(buffer); - let text = - text.try_as_utf8_for(name) - .map_err(|_| SecretSpecError::SecretNotText { - name: name.to_string(), - reason: "piped input is not valid UTF-8; use `--from-file -` to store \ + self.set_with_input_format( + name, + |profile_name| { + if io::stdin().is_terminal() { + let secret = inquire::Password::new(&format!( + "Enter value for {name} (profile: {profile_name}):" + )) + .without_confirmation() + .prompt()?; + Ok(SecretBytes::from_utf8(secret)) + } else { + // Read from stdin when input is piped + let mut buffer = Vec::new(); + io::stdin().read_to_end(&mut buffer)?; + let text = SecretBytes::from_vec(buffer); + let text = + text.try_as_utf8_for(name) + .map_err(|_| SecretSpecError::SecretNotText { + name: name.to_string(), + reason: + "piped input is not valid UTF-8; use `--from-file -` to store \ exact bytes" - .to_string(), - })?; - Ok(SecretBytes::from_utf8(text.trim())) - } - }) + .to_string(), + })?; + Ok(SecretBytes::from_utf8(text.trim())) + } + }, + true, + ) } /// Requests the value only after validating and reporting the write destination. @@ -5327,7 +5654,16 @@ impl Secrets { name: &str, input: impl FnOnce(&str) -> Result, ) -> Result<()> { - let stored = self.store_secret_with_input(name, input)?; + self.set_with_input_format(name, input, false) + } + + fn set_with_input_format( + &self, + name: &str, + input: impl FnOnce(&str) -> Result, + encoded_typed_input: bool, + ) -> Result<()> { + let stored = self.store_secret_with_input(name, input, encoded_typed_input)?; eprintln!( "{} Secret '{}' saved to {} (profile: {})", "✓".green(), @@ -5342,6 +5678,7 @@ impl Secrets { &self, name: &str, input: impl FnOnce(&str) -> Result, + encoded_typed_input: bool, ) -> Result { self.ensure_reason_for(AuditAction::Set, Some(name))?; // Check if the secret exists in the spec @@ -5379,27 +5716,30 @@ impl Secrets { } }; - // A composed secret plans no route: its value is derived, so a write - // has nowhere to go. - let Some(route) = &planned.route else { - let err = SecretSpecError::ComposedSecretReadOnly(name.to_string()); - self.record_key_error(AuditAction::Set, &profile_name, name, None, None, &err); - return Err(err); - }; - - if planned.extract().is_some() { + if planned.extract().is_some() && planned.from().is_none() { let err = SecretSpecError::ExtractedSecretReadOnly(name.to_string()); self.record_key_error( AuditAction::Set, &profile_name, name, - route.primary().map(str::to_string), + planned + .route + .as_ref() + .and_then(|route| route.primary().map(str::to_string)), planned.reference(), &err, ); return Err(err); } + // A derived secret plans no route: its value is produced from other + // secrets, so a write has nowhere to go. + let Some(route) = &planned.route else { + let err = Self::derived_read_only(&planned); + self.record_key_error(AuditAction::Set, &profile_name, name, None, None, &err); + return Err(err); + }; + let backend = match self.write_provider_for_route(route, Some(&profile_name)) { Ok(backend) => backend, Err(err) => { @@ -5464,7 +5804,32 @@ impl Secrets { return Err(err); } - let encoded_value = Self::encoded_for_storage(&planned, &value); + // Terminal input represents typed binary values in their declared + // encoding. Byte APIs and --from-file already supply logical bytes. + let prepared = if encoded_typed_input { + Self::prompted_value_for_storage(&planned, name, &value) + } else { + let valid = if planned.is_typed_stored() && planned.credential("password").is_none() { + crate::x509_identity::validate(value.expose_secret(), name) + } else { + Ok(()) + }; + valid.map(|()| Self::encoded_for_storage(&planned, &value)) + }; + let encoded_value = match prepared { + Ok(value) => value, + Err(err) => { + self.record_key_error( + AuditAction::Set, + &profile_name, + name, + Some(backend.uri()), + None, + &err, + ); + return Err(err); + } + }; let stored_value = encoded_value.as_ref().unwrap_or(&value); let result = backend.set(addr, stored_value); self.audit_write_result( @@ -5521,23 +5886,26 @@ impl Secrets { } }; - let Some(route) = &planned.route else { - let error = SecretSpecError::ComposedSecretReadOnly(name.to_string()); - self.record_key_error(AuditAction::Delete, &profile_name, name, None, None, &error); - return Err(error); - }; - if planned.extract().is_some() { + if planned.extract().is_some() && planned.from().is_none() { let error = SecretSpecError::ExtractedSecretReadOnly(name.to_string()); self.record_key_error( AuditAction::Delete, &profile_name, name, - route.primary().map(str::to_string), + planned + .route + .as_ref() + .and_then(|route| route.primary().map(str::to_string)), planned.reference(), &error, ); return Err(error); } + let Some(route) = &planned.route else { + let error = Self::derived_read_only(&planned); + self.record_key_error(AuditAction::Delete, &profile_name, name, None, None, &error); + return Err(error); + }; let backend = match self.write_provider_for_route(route, Some(&profile_name)) { Ok(backend) => backend, Err(error) => { @@ -5770,7 +6138,24 @@ impl Secrets { let value = SecretBytes::from_utf8(prompt.prompt()?); - let encoded_value = Self::encoded_for_storage(&planned, &value); + let encoded_value = match Self::prompted_value_for_storage( + &planned, + secret_name, + &value, + ) { + Ok(value) => value, + Err(error) => { + self.record_key_error( + AuditAction::Set, + &profile_display, + secret_name, + Some(backend.uri()), + planned.reference(), + &error, + ); + return Err(error); + } + }; let stored_value = encoded_value.as_ref().unwrap_or(&value); let address = self.address_for_spec( &planned, @@ -6286,7 +6671,7 @@ impl Secrets { &self, planned: &PlannedSecret, profile_name: &str, - ) -> Result> { + ) -> Result> { let name = planned.name.as_str(); let gen_config = match &planned.config().generate { Some(config) if config.is_enabled() => config, @@ -6308,7 +6693,11 @@ impl Secrets { } }; - let value = crate::generator::generate(secret_type, gen_config)?; + let value = if secret_type == "x509_identity" { + GeneratedValue::Binary(crate::x509_identity::generate(gen_config)?) + } else { + GeneratedValue::Text(crate::generator::generate(secret_type, gen_config)?) + }; // Store the generated value at the plan's address, through the plan's // write route: the same decisions every other write path executes. @@ -6345,8 +6734,22 @@ impl Secrets { // The provider states why a write is refused; wrapping it here would // only nest a second "Provider operation failed" prefix. backend.check_writable(addr)?; - let encoded_value = Self::encoded_for_storage(planned, &value); - let stored_value = encoded_value.as_ref().unwrap_or(&value); + let encoded_value = match &value { + GeneratedValue::Text(value) => Self::encoded_for_storage(planned, value), + GeneratedValue::Binary(value) => Some(Self::encode_logical_bytes( + planned + .encoding() + .expect("binary generators require a validated encoding"), + value.expose_secret(), + )), + }; + let stored_value = match (&value, encoded_value.as_ref()) { + (_, Some(value)) => value, + (GeneratedValue::Text(value), None) => value, + (GeneratedValue::Binary(_), None) => { + unreachable!("binary generators require a stored encoding") + } + }; let set_result = backend.set(addr, stored_value); // Generating a secret writes a brand-new value to the provider; record it // like any other write so the audit log captures every stored secret. @@ -6492,6 +6895,18 @@ impl Secrets { Ok(value) } + /// The read-only error for a secret that plans no route: a `from` secret + /// names the source to change, a composed one has no single source. + fn derived_read_only(planned: &PlannedSecret) -> SecretSpecError { + match planned.from() { + Some(source) => SecretSpecError::DerivedSecretReadOnly { + name: planned.name.clone(), + from: source.to_string(), + }, + None => SecretSpecError::ComposedSecretReadOnly(planned.name.clone()), + } + } + /// Writes secret bytes to a temporary file and returns the file handle and path /// /// # Arguments @@ -6508,10 +6923,14 @@ impl Secrets { fn write_secret_to_temp_file( &self, secret: &[u8], + suffix: Option<&str>, ) -> Result<(tempfile::NamedTempFile, String)> { use std::io::Write; - let mut temp_file = tempfile::NamedTempFile::new().map_err(SecretSpecError::Io)?; + let mut temp_file = tempfile::Builder::new() + .suffix(suffix.unwrap_or("")) + .tempfile() + .map_err(SecretSpecError::Io)?; temp_file.write_all(secret).map_err(SecretSpecError::Io)?; @@ -6779,7 +7198,7 @@ impl Secrets { ) -> Result { with_ipc_audit_purpose(purpose, || { self.require_ipc_surface(AuditAction::Set, name)?; - self.store_secret_with_input(name, |_| Ok(SecretBytes::from_utf8(value))) + self.store_secret_with_input(name, |_| Ok(SecretBytes::from_utf8(value)), false) }) } @@ -7295,10 +7714,8 @@ impl Secrets { if !names.insert(name.to_string()) { return; } - if let Some(template) = &profile.secrets[name].composition { - for dependency in template.dependencies() { - visit(dependency, profile, names); - } + for dependency in profile.secrets[name].dependencies() { + visit(dependency, profile, names); } } @@ -7326,6 +7743,12 @@ impl Secrets { .iter() .map(|entry| (entry.name.as_str(), &entry.status)) .collect(); + let fetched_from_provider: HashSet<&str> = errors + .resolution + .iter() + .filter(|entry| entry.source_provider.is_some()) + .map(|entry| entry.name.as_str()) + .collect(); let profile = self .manifest .profile(profile_name) @@ -7335,22 +7758,59 @@ impl Secrets { name: &str, profile: &crate::compiled_spec::CompiledProfile, statuses: &HashMap<&str, &ResolutionStatus>, + fetched_from_provider: &HashSet<&str>, promptable: &mut HashSet, ) { - let Some(template) = &profile.secrets[name].composition else { + let secret = &profile.secrets[name]; + // A stored typed value can be fetched successfully but remain + // unresolved because a credential is missing. It is a dependency + // node in that state, not a missing leaf: prompting for it would + // overwrite the archive that is already present. + if secret + .config + .typed_contract() + .is_some_and(|contract| contract.is_stored()) + && fetched_from_provider.contains(name) + { + for (_, credential) in secret.config.credential_secrets() { + if statuses.get(credential).copied() != Some(&ResolutionStatus::Resolved) { + visit( + credential, + profile, + statuses, + fetched_from_provider, + promptable, + ); + } + } + return; + } + if !secret.config.is_derived() { promptable.insert(name.to_string()); return; - }; - for dependency in template.dependencies() { - if statuses.get(dependency.as_str()).copied() != Some(&ResolutionStatus::Resolved) { - visit(dependency, profile, statuses, promptable); + } + for dependency in secret.dependencies() { + if statuses.get(dependency).copied() != Some(&ResolutionStatus::Resolved) { + visit( + dependency, + profile, + statuses, + fetched_from_provider, + promptable, + ); } } } let mut promptable = HashSet::new(); for name in &errors.missing_required { - visit(name, profile, &statuses, &mut promptable); + visit( + name, + profile, + &statuses, + &fetched_from_provider, + &mut promptable, + ); } let mut promptable: Vec = promptable.into_iter().collect(); promptable.sort(); @@ -8933,6 +9393,38 @@ mod encoding_tests { assert_eq!(stored.expose_secret(), expected.as_bytes()); } } + + #[test] + fn prompted_binary_value_is_decoded_before_canonical_storage() { + let generated = crate::x509_identity::generate(&crate::config::GenerateConfig::Options( + crate::config::GenerateOptions { + san: Some(vec!["dns:localhost".to_string()]), + ..Default::default() + }, + )) + .unwrap(); + let canonical = BASE64.encode(generated.expose_secret()); + let entered = SecretBytes::from_utf8(format!("{canonical}\n")); + + let config = crate::tests::resolve_test_config(HashMap::from([( + "TLS_IDENTITY".to_string(), + crate::config::Secret { + description: Some("identity".to_string()), + secret_type: Some("x509_identity".to_string()), + ..Default::default() + }, + )])); + let spec = Secrets::new(config, None, None, None); + let planned = spec + .plan_secret("TLS_IDENTITY", "default", None) + .unwrap() + .unwrap(); + + let stored = Secrets::prompted_value_for_storage(&planned, "TLS_IDENTITY", &entered) + .unwrap() + .unwrap(); + assert_eq!(stored.expose_secret(), canonical.as_bytes()); + } } #[cfg(test)] diff --git a/secretspec/src/spec.rs b/secretspec/src/spec.rs index a0ef1941f..18e358bb5 100644 --- a/secretspec/src/spec.rs +++ b/secretspec/src/spec.rs @@ -7,11 +7,12 @@ use crate::compiled_spec::CompiledSpec; use crate::config::{ - Config, GenerateConfig, GenerateOptions, NativeAddress, Profile as ConfigProfile, - ProfileDefaults, Project, ProjectDefaults, ProviderAlias, RequireReason, Scope, - Secret as ConfigSecret, SecretEncoding, SecretExtract, + Config, CredentialBinding, GenerateConfig, GenerateOptions, NativeAddress, + Profile as ConfigProfile, ProfileDefaults, Project, ProjectDefaults, ProviderAlias, + RequireReason, Scope, Secret as ConfigSecret, SecretEncoding, SecretExtract, }; use crate::error::{Result, SecretSpecError}; +use std::collections::BTreeMap; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::str::FromStr; @@ -793,12 +794,46 @@ impl Secret { self } - /// Extract a value from a structured provider result. + /// Extract a value from a structured provider result, or from the text of + /// the secret named by [`from`](Self::from). pub fn extract(mut self, extract: SecretExtract) -> Self { self.config.extract = Some(extract); self } + /// Set the semantic type. For the typed contracts (`x509_identity` and the + /// `pkcs12`, `pkcs8_private_key`, `x509_certificate`, + /// `x509_certificate_chain`, and `x509_issuer_chain` targets, 0.22+) the + /// type governs decoding, conversion, credentials, and delivery. + pub fn secret_type(mut self, secret_type: impl Into) -> Self { + self.config.secret_type = Some(secret_type.into()); + self + } + + /// Choose the serialization of a typed value that offers more than one, + /// such as DER instead of the default PEM (0.22+). + pub fn format(mut self, format: crate::typed::Format) -> Self { + self.config.format = Some(format.as_str().to_string()); + self + } + + /// Derive this value from another declared secret: convert it with a + /// derivable type or select from it with `extract` (0.22+). + pub fn from(mut self, source: impl Into) -> Self { + self.config.from = Some(source.into()); + self + } + + /// Bind a credential role of this typed value, such as the `password` + /// that opens or protects a PKCS#12 archive, to a declared secret (0.22+). + pub fn credential(mut self, role: impl Into, secret: impl Into) -> Self { + self.config + .credentials + .get_or_insert_with(BTreeMap::new) + .insert(role.into(), CredentialBinding::Secret(secret.into())); + self + } + /// Generate the value when it is absent. pub fn generate(mut self, generation: Generation) -> Self { let (secret_type, config) = generation.into_config(); @@ -839,6 +874,22 @@ pub enum Generation { /// Characters from which the password is drawn. charset: PasswordCharset, }, + /// A human-readable passphrase composed from independently selected words (0.22+). + Passphrase { + /// Word count; `None` uses SecretSpec's seven-word default. + words: Option, + /// Text placed between words. + separator: String, + }, + /// A checksum-protected recovery mnemonic (0.22+). + Mnemonic { + /// Mnemonic encoding algorithm. + algorithm: MnemonicAlgorithm, + /// Word count; `None` uses SecretSpec's 24-word default. + words: Option, + /// Word-list language. + language: MnemonicLanguage, + }, /// Random bytes rendered as hexadecimal. Hex { /// Byte count; `None` uses SecretSpec's default. @@ -874,6 +925,26 @@ pub enum Generation { /// Optional comment embedded in the OpenSSH key. comment: Option, }, + /// A Base64-encoded WireGuard private key (0.22+). + WireguardPrivateKey, + /// A private signing key serialized as a JSON Web Key (0.22+). + JwkPrivateKey { + /// JOSE signing algorithm and optional RSA key size. + algorithm: JwkKeyAlgorithm, + /// Optional JWK `kid` metadata. + kid: Option, + }, + /// A native X25519 age identity (0.22+). + AgeIdentity, + /// A self-signed P-256 X.509 identity stored as PKCS#12 (0.22+). + X509Identity { + /// DNS and IP subject alternative names (`dns:name` or `ip:address`). + subject_alt_names: Vec, + /// Extended key usages placed on the certificate. + usages: Vec, + /// Validity in days; `None` uses SecretSpec's 30-day default. + valid_for_days: Option, + }, } impl Generation { @@ -885,6 +956,23 @@ impl Generation { } } + /// A seven-word passphrase separated with hyphens (0.22+). + pub fn passphrase() -> Self { + Self::Passphrase { + words: None, + separator: "-".to_string(), + } + } + + /// A 24-word English BIP-39 mnemonic (0.22+). + pub fn mnemonic() -> Self { + Self::Mnemonic { + algorithm: MnemonicAlgorithm::Bip39, + words: None, + language: MnemonicLanguage::English, + } + } + /// Hexadecimal output with SecretSpec's default random-byte count. pub fn hex() -> Self { Self::Hex { bytes: None } @@ -927,6 +1015,33 @@ impl Generation { } } + /// A Base64-encoded WireGuard private key (0.22+). + pub fn wireguard_private_key() -> Self { + Self::WireguardPrivateKey + } + + /// An Ed25519 private signing JWK (0.22+). + pub fn jwk_private_key() -> Self { + Self::JwkPrivateKey { + algorithm: JwkKeyAlgorithm::Ed25519, + kid: None, + } + } + + /// A native X25519 age identity (0.22+). + pub fn age_identity() -> Self { + Self::AgeIdentity + } + + /// A self-signed P-256 X.509 server identity with a 30-day validity (0.22+). + pub fn x509_identity(subject_alt_names: impl IntoIterator>) -> Self { + Self::X509Identity { + subject_alt_names: subject_alt_names.into_iter().map(Into::into).collect(), + usages: vec![X509Usage::ServerAuth], + valid_for_days: None, + } + } + fn into_config(self) -> (&'static str, GenerateConfig) { match self { Self::Password { length, charset } => ( @@ -937,6 +1052,27 @@ impl Generation { ..GenerateOptions::default() }), ), + Self::Passphrase { words, separator } => ( + "passphrase", + GenerateConfig::Options(GenerateOptions { + words, + separator: Some(separator), + ..GenerateOptions::default() + }), + ), + Self::Mnemonic { + algorithm, + words, + language, + } => ( + "mnemonic", + GenerateConfig::Options(GenerateOptions { + algorithm: Some(algorithm.as_str().to_string()), + words, + language: Some(language.as_str().to_string()), + ..GenerateOptions::default() + }), + ), Self::Hex { bytes } => ( "hex", GenerateConfig::Options(GenerateOptions { @@ -994,6 +1130,125 @@ impl Generation { ..GenerateOptions::default() }), ), + Self::WireguardPrivateKey => ("wireguard_private_key", GenerateConfig::Bool(true)), + Self::JwkPrivateKey { algorithm, kid } => ( + "jwk_private_key", + GenerateConfig::Options(GenerateOptions { + algorithm: Some(algorithm.as_str().to_string()), + bits: algorithm.bits(), + kid, + ..GenerateOptions::default() + }), + ), + Self::AgeIdentity => ("age_identity", GenerateConfig::Bool(true)), + Self::X509Identity { + subject_alt_names, + usages, + valid_for_days, + } => ( + "x509_identity", + GenerateConfig::Options(GenerateOptions { + issuer: Some("self_signed".to_string()), + algorithm: Some("p256".to_string()), + san: Some(subject_alt_names), + usages: Some( + usages + .into_iter() + .map(|usage| usage.as_str().to_string()) + .collect(), + ), + valid_for: valid_for_days.map(|days| format!("{days}d")), + ..GenerateOptions::default() + }), + ), + } + } +} + +/// Extended key usage for a generated X.509 identity. +/// +/// Available starting with SecretSpec 0.22. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum X509Usage { + /// TLS server authentication. + ServerAuth, + /// TLS client authentication. + ClientAuth, +} + +impl X509Usage { + fn as_str(self) -> &'static str { + match self { + Self::ServerAuth => "server_auth", + Self::ClientAuth => "client_auth", + } + } +} + +/// The encoding algorithm for a generated recovery mnemonic. +/// +/// Available starting with SecretSpec 0.22. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum MnemonicAlgorithm { + /// BIP-39 entropy and checksum encoding. + Bip39, +} + +impl MnemonicAlgorithm { + fn as_str(self) -> &'static str { + match self { + Self::Bip39 => "bip39", + } + } +} + +/// The word-list language for a generated recovery mnemonic. +/// +/// Available starting with SecretSpec 0.22. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum MnemonicLanguage { + /// The BIP-39 English word list. + English, +} + +impl MnemonicLanguage { + fn as_str(self) -> &'static str { + match self { + Self::English => "english", + } + } +} + +/// The algorithm for a generated private JSON Web Key. +/// +/// Available starting with SecretSpec 0.22. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum JwkKeyAlgorithm { + /// Ed25519 with the fully specified JOSE algorithm `Ed25519` (RFC 9864). + Ed25519, + /// NIST P-256 with JOSE algorithm `ES256`. + P256, + /// RSA with JOSE algorithm `RS256`. `None` uses the 3072-bit default. + Rsa { bits: Option }, +} + +impl JwkKeyAlgorithm { + fn as_str(self) -> &'static str { + match self { + Self::Ed25519 => "ed25519", + Self::P256 => "p256", + Self::Rsa { .. } => "rsa", + } + } + + fn bits(self) -> Option { + match self { + Self::Rsa { bits } => bits, + Self::Ed25519 | Self::P256 => None, } } } @@ -1229,6 +1484,78 @@ mod tests { assert_eq!(rsa.comment.as_deref(), Some("deploy@example.com")); } + #[test] + fn additional_credential_generation_builders_map_to_document_options() { + let (secret_type, GenerateConfig::Options(passphrase)) = Generation::Passphrase { + words: Some(8), + separator: " ".to_string(), + } + .into_config() else { + panic!("passphrase generation must use options"); + }; + assert_eq!(secret_type, "passphrase"); + assert_eq!(passphrase.words, Some(8)); + assert_eq!(passphrase.separator.as_deref(), Some(" ")); + + let (secret_type, GenerateConfig::Options(mnemonic)) = Generation::Mnemonic { + algorithm: MnemonicAlgorithm::Bip39, + words: Some(12), + language: MnemonicLanguage::English, + } + .into_config() else { + panic!("mnemonic generation must use options"); + }; + assert_eq!(secret_type, "mnemonic"); + assert_eq!(mnemonic.algorithm.as_deref(), Some("bip39")); + assert_eq!(mnemonic.words, Some(12)); + assert_eq!(mnemonic.language.as_deref(), Some("english")); + + let (secret_type, GenerateConfig::Options(default_mnemonic)) = + Generation::mnemonic().into_config() + else { + panic!("mnemonic generation must use options"); + }; + assert_eq!(secret_type, "mnemonic"); + assert_eq!(default_mnemonic.words, None); + + let (secret_type, GenerateConfig::Options(jwk)) = Generation::JwkPrivateKey { + algorithm: JwkKeyAlgorithm::Rsa { bits: Some(4096) }, + kid: Some("release-2026".to_string()), + } + .into_config() else { + panic!("JWK generation must use options"); + }; + assert_eq!(secret_type, "jwk_private_key"); + assert_eq!(jwk.algorithm.as_deref(), Some("rsa")); + assert_eq!(jwk.bits, Some(4096)); + assert_eq!(jwk.kid.as_deref(), Some("release-2026")); + + assert!(matches!( + Generation::wireguard_private_key().into_config(), + ("wireguard_private_key", GenerateConfig::Bool(true)) + )); + assert!(matches!( + Generation::age_identity().into_config(), + ("age_identity", GenerateConfig::Bool(true)) + )); + + let (secret_type, GenerateConfig::Options(x509)) = + Generation::x509_identity(["dns:localhost", "ip:127.0.0.1"]).into_config() + else { + panic!("X.509 generation must use options"); + }; + assert_eq!(secret_type, "x509_identity"); + assert_eq!(x509.algorithm.as_deref(), Some("p256")); + assert_eq!( + x509.san, + Some(vec![ + "dns:localhost".to_string(), + "ip:127.0.0.1".to_string() + ]) + ); + assert_eq!(x509.usages, Some(vec!["server_auth".to_string()])); + } + #[test] fn builder_edits_a_copy_without_mutating_the_original_spec() { let original = Spec::builder("embedded") diff --git a/secretspec/src/tests.rs b/secretspec/src/tests.rs index 5c4394e31..0ceb9b899 100644 --- a/secretspec/src/tests.rs +++ b/secretspec/src/tests.rs @@ -6383,6 +6383,10 @@ fn test_config_rejects_invalid_openpgp_generation_options() { r#"KEY = { description = "Key", type = "openpgp_private_key", generate = { user_id = "Bot", algorithm = "ed25519", bits = 3072 } }"#, r#"KEY = { description = "Key", type = "openpgp_private_key", generate = { user_id = "Bot", algorithm = "rsa", bits = 1024 } }"#, r#"KEY = { description = "Key", type = "openpgp_private_key", generate = { user_id = "Bot", algorithm = "rsa", bits = 16384 } }"#, + r#"KEY = { description = "Key", type = "openpgp_private_key", generate = { user_id = "Bot", length = 1 } }"#, + r#"KEY = { description = "Key", type = "openpgp_private_key", generate = { user_id = "Bot", bytes = 1 } }"#, + r#"KEY = { description = "Key", type = "openpgp_private_key", generate = { user_id = "Bot", charset = "ascii" } }"#, + r#"KEY = { description = "Key", type = "openpgp_private_key", generate = { user_id = "Bot", command = "echo ignored" } }"#, ] { let toml_content = format!( "[project]\nname = \"test-gen\"\nrevision = \"1.0\"\n\n[profiles.default]\n{declaration}\n" @@ -6429,6 +6433,10 @@ fn test_config_rejects_invalid_ssh_generation_options() { r#"KEY = { description = "Key", type = "ssh_private_key", generate = { algorithm = "rsa", bits = 16384 } }"#, r#"KEY = { description = "Key", type = "ssh_private_key", generate = { comment = "bad\ncomment" } }"#, r#"KEY = { description = "Key", type = "ssh_private_key", generate = { user_id = "Bot" } }"#, + r#"KEY = { description = "Key", type = "ssh_private_key", generate = { length = 1 } }"#, + r#"KEY = { description = "Key", type = "ssh_private_key", generate = { bytes = 1 } }"#, + r#"KEY = { description = "Key", type = "ssh_private_key", generate = { charset = "ascii" } }"#, + r#"KEY = { description = "Key", type = "ssh_private_key", generate = { command = "echo ignored" } }"#, r#"KEY = { description = "Key", type = "openpgp_private_key", generate = { user_id = "Bot", comment = "ssh-only" } }"#, ] { let toml_content = format!( @@ -6438,6 +6446,79 @@ fn test_config_rejects_invalid_ssh_generation_options() { } } +#[test] +fn test_config_parses_additional_credential_generators() { + let toml_content = r#" +[project] +name = "test-gen" +revision = "1.0" + +[profiles.default] +RECOVERY = { description = "Recovery phrase", type = "passphrase", generate = { words = 8, separator = "." } } +WALLET_SEED = { description = "Wallet recovery mnemonic", type = "mnemonic", generate = { algorithm = "bip39", words = 24, language = "english" } } +WG_KEY = { description = "WireGuard key", type = "wireguard_private_key", generate = true } +JWT_KEY = { description = "JWT key", type = "jwk_private_key", generate = { algorithm = "p256", kid = "release-2026" } } +AGE_KEY = { description = "age identity", type = "age_identity", generate = true } +"#; + let config = parse_spec_from_str(toml_content, None).unwrap(); + let profile = &config.profiles["default"]; + + let Some(GenerateConfig::Options(passphrase)) = &profile.secrets["RECOVERY"].generate else { + panic!("expected passphrase options"); + }; + assert_eq!(passphrase.words, Some(8)); + assert_eq!(passphrase.separator.as_deref(), Some(".")); + + let Some(GenerateConfig::Options(mnemonic)) = &profile.secrets["WALLET_SEED"].generate else { + panic!("expected mnemonic options"); + }; + assert_eq!(mnemonic.algorithm.as_deref(), Some("bip39")); + assert_eq!(mnemonic.words, Some(24)); + assert_eq!(mnemonic.language.as_deref(), Some("english")); + + let Some(GenerateConfig::Options(jwk)) = &profile.secrets["JWT_KEY"].generate else { + panic!("expected JWK options"); + }; + assert_eq!(jwk.algorithm.as_deref(), Some("p256")); + assert_eq!(jwk.kid.as_deref(), Some("release-2026")); + + for name in ["WG_KEY", "AGE_KEY"] { + assert!(matches!( + profile.secrets[name].generate, + Some(GenerateConfig::Bool(true)) + )); + } +} + +#[test] +fn test_config_rejects_invalid_additional_credential_generation_options() { + for declaration in [ + r#"KEY = { description = "Key", type = "passphrase", generate = { words = 5 } }"#, + r#"KEY = { description = "Key", type = "passphrase", generate = { separator = "" } }"#, + r#"KEY = { description = "Key", type = "passphrase", generate = { algorithm = "rsa" } }"#, + r#"KEY = { description = "Key", type = "mnemonic", generate = { words = 11 } }"#, + r#"KEY = { description = "Key", type = "mnemonic", generate = { words = 13 } }"#, + r#"KEY = { description = "Key", type = "mnemonic", generate = { words = 25 } }"#, + r#"KEY = { description = "Key", type = "mnemonic", generate = { algorithm = "electrum" } }"#, + r#"KEY = { description = "Key", type = "mnemonic", generate = { language = "spanish" } }"#, + r#"KEY = { description = "Key", type = "mnemonic", generate = { separator = "-" } }"#, + r#"KEY = { description = "Key", type = "jwk_private_key", generate = { algorithm = "ed25519", bits = 3072 } }"#, + r#"KEY = { description = "Key", type = "jwk_private_key", generate = { algorithm = "rsa", bits = 1024 } }"#, + r#"KEY = { description = "Key", type = "jwk_private_key", generate = { algorithm = "secp256k1" } }"#, + r#"KEY = { description = "Key", type = "jwk_private_key", generate = { kid = " " } }"#, + r#"KEY = { description = "Key", type = "wireguard_private_key", generate = { bytes = 32 } }"#, + r#"KEY = { description = "Key", type = "age_identity", generate = { algorithm = "x25519" } }"#, + ] { + let toml_content = format!( + "[project]\nname = \"test-gen\"\nrevision = \"1.0\"\n\n[profiles.default]\n{declaration}\n" + ); + assert!( + parse_spec_from_str(&toml_content, None).is_err(), + "accepted invalid declaration: {declaration}" + ); + } +} + #[test] fn test_config_type_without_generate_is_valid() { let toml_content = r#" @@ -6823,6 +6904,7 @@ DB_PASSWORD = { description = "Password", type = "password", generate = true } API_TOKEN = { description = "Token", type = "hex", generate = { bytes = 16 } } SESSION_KEY = { description = "Session", type = "base64", generate = { bytes = 24 } } REQUEST_ID = { description = "ID", type = "uuid", generate = true } +WALLET_RECOVERY = { description = "Wallet recovery mnemonic", type = "mnemonic", generate = true } "#; fs::write(&config_file, toml_content).unwrap(); @@ -6845,6 +6927,7 @@ REQUEST_ID = { description = "ID", type = "uuid", generate = true } assert!(validated.resolved.secrets.contains_key("API_TOKEN")); assert!(validated.resolved.secrets.contains_key("SESSION_KEY")); assert!(validated.resolved.secrets.contains_key("REQUEST_ID")); + assert!(validated.resolved.secrets.contains_key("WALLET_RECOVERY")); // Verify types let pw = validated @@ -6874,6 +6957,17 @@ REQUEST_ID = { description = "ID", type = "uuid", generate = true } .unwrap(); assert_eq!(uuid.len(), 36); assert!(uuid.contains('-')); + + let mnemonic = validated + .resolved + .secrets + .get("WALLET_RECOVERY") + .unwrap() + .try_as_utf8() + .unwrap(); + let parsed = bip39::Mnemonic::parse_in_normalized(bip39::Language::English, mnemonic) + .expect("resolved mnemonic must have a valid BIP-39 checksum"); + assert_eq!(parsed.word_count(), 24); } #[test] @@ -6948,6 +7042,9 @@ fn test_resolve_secret_config_merges_type_and_generate() { encoding: None, extract: None, secret_type: Some("password".to_string()), + format: None, + from: None, + credentials: None, generate: Some(crate::config::GenerateConfig::Bool(true)), prompt: None, }, @@ -10373,6 +10470,42 @@ secrets = ["DATABASE_URL"] ); } + #[test] + fn prompting_fetched_identity_descends_to_missing_password() { + let temp = TempDir::new().unwrap(); + let env_path = temp.path().join(".env"); + // The bytes need not form a PFX yet: resolution parks a fetched typed + // value until its credential resolves, so it must not inspect or offer + // to replace the existing archive in this pass. + fs::write(&env_path, "TLS_IDENTITY=Zm9v\n").unwrap(); + let manifest = r#" +[project] +name = "typed-prompt" +revision = "1.0" + +[profiles.default] +PFX_PASSWORD = { description = "PFX password" } +TLS_IDENTITY = { description = "identity", type = "x509_identity", credentials = { password = "PFX_PASSWORD" } } +"#; + let config = config(manifest); + config.validate().unwrap(); + let spec = Secrets::new( + config, + None, + Some(format!("dotenv://{}", env_path.display())), + None, + ); + let errors = match spec.validate().unwrap() { + Ok(_) => panic!("the missing password must keep the identity unresolved"), + Err(errors) => errors, + }; + + assert_eq!( + spec.scoped_promptable_missing(&errors, "default").unwrap(), + vec!["PFX_PASSWORD".to_string()] + ); + } + /// `run --scope` scrubs the raw dependencies of an in-scope composed secret /// from the child — via the separate `scope_excluded_names` path, not the /// resolution output filter — even when the parent shell exported them. diff --git a/secretspec/src/typed.rs b/secretspec/src/typed.rs new file mode 100644 index 000000000..f8f6d1707 --- /dev/null +++ b/secretspec/src/typed.rs @@ -0,0 +1,329 @@ +//! Registry of typed secret contracts (SecretSpec 0.22+). +//! +//! `type` is informational for most secrets. For the types registered here it +//! is authoritative: it decides how a stored value is decoded and validated, +//! which `credentials` roles may unlock or protect it, which declared secrets a +//! `from` target may derive from, how the result is serialized, and how the +//! result is delivered. Every string match on these type names lives here so +//! configuration, planning, and resolution cannot drift. + +use crate::config::SecretEncoding; + +/// The stored or generated canonical identity type. The only registry type a +/// provider may hold in 0.22. +pub(crate) const X509_IDENTITY: &str = "x509_identity"; + +/// Longest credential value accepted for a typed secret, in bytes. PKCS#12 +/// passwords are re-encoded as BMPString; a bound keeps that allocation and +/// the KDF input small without constraining any realistic password. +pub(crate) const MAX_CREDENTIAL_BYTES: usize = 1024; + +/// A serialization for a typed value whose type offers more than one, written +/// as the secret's `format`. +/// +/// Available since SecretSpec 0.22. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum Format { + /// Textual PEM encoding. + Pem, + /// Binary DER encoding. + Der, +} + +impl Format { + pub(crate) fn parse(value: &str) -> Option { + match value { + "pem" => Some(Self::Pem), + "der" => Some(Self::Der), + _ => None, + } + } + + /// The manifest spelling of this format. + pub const fn as_str(self) -> &'static str { + match self { + Self::Pem => "pem", + Self::Der => "der", + } + } + + pub(crate) const fn is_binary(self) -> bool { + matches!(self, Self::Der) + } + + pub(crate) const fn suffix(self) -> &'static str { + match self { + Self::Pem => ".pem", + Self::Der => ".der", + } + } +} + +/// What a derived registry type produces from its `from` source. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Projection { + /// The complete identity as a PKCS#12/PFX archive, optionally protected by + /// the `password` credential. + Pkcs12, + /// The unencrypted PKCS#8 private key. + Pkcs8PrivateKey, + /// The leaf certificate. + Certificate, + /// The leaf followed by its ordered issuer chain. + CertificateChain, + /// The ordered issuer chain without the leaf. + IssuerChain, +} + +/// One registered type's contract. +#[derive(Debug)] +pub(crate) struct TypeContract { + /// The manifest spelling. + pub(crate) name: &'static str, + /// Accepted `format` values, default first. Empty when the representation + /// is fixed, in which case `format` is rejected. + pub(crate) formats: &'static [Format], + /// Types a `from` source may declare. Empty for stored types. + pub(crate) sources: &'static [&'static str], + /// Accepted `credentials` roles. + pub(crate) roles: &'static [&'static str], + /// The projection a `from` target produces, or `None` for a stored type. + pub(crate) projection: Option, + /// Whether a fixed representation is binary. + fixed_binary: bool, + /// Preferred file suffix of a fixed representation. + fixed_suffix: &'static str, +} + +impl TypeContract { + /// Whether providers may hold this type. Every other registry type exists + /// only as a `from` target. + pub(crate) fn is_stored(&self) -> bool { + self.projection.is_none() + } + + /// Resolve the effective format: the requested one when the type accepts + /// it, the type's default when none is requested, or `None` for a fixed + /// representation. + pub(crate) fn format(&self, requested: Option<&str>) -> Result, String> { + match (requested, self.formats.first()) { + (None, default) => Ok(default.copied()), + (Some(_), None) => Err(format!( + "`format` is not valid for type = \"{}\"; its representation is fixed", + self.name + )), + (Some(requested), Some(_)) => Format::parse(requested) + .filter(|format| self.formats.contains(format)) + .map(Some) + .ok_or_else(|| { + format!( + "unknown format '{}' for type = \"{}\"; expected {}", + requested, + self.name, + self.formats + .iter() + .map(|format| format!("`{}`", format.as_str())) + .collect::>() + .join(" or ") + ) + }), + } + } + + /// Whether the logical value is bytes rather than UTF-8 text. + pub(crate) fn is_binary(&self, format: Option) -> bool { + format.map_or(self.fixed_binary, Format::is_binary) + } + + /// Preferred file suffix for `as_path` delivery. + pub(crate) fn suffix(&self, format: Option) -> &'static str { + format.map_or(self.fixed_suffix, Format::suffix) + } + + /// The codec a binary value uses wherever it must be text, when the + /// declaration does not choose one. + pub(crate) fn default_encoding(&self, format: Option) -> Option { + self.is_binary(format).then_some(SecretEncoding::Base64) + } + + pub(crate) fn accepts_source(&self, source_type: Option<&str>) -> bool { + source_type.is_some_and(|source_type| self.sources.contains(&source_type)) + } + + pub(crate) fn accepts_role(&self, role: &str) -> bool { + self.roles.contains(&role) + } +} + +const CONTRACTS: &[TypeContract] = &[ + TypeContract { + name: X509_IDENTITY, + formats: &[], + sources: &[], + roles: &["password"], + projection: None, + fixed_binary: true, + fixed_suffix: ".pfx", + }, + TypeContract { + name: "pkcs12", + formats: &[], + sources: &[X509_IDENTITY], + roles: &["password"], + projection: Some(Projection::Pkcs12), + fixed_binary: true, + fixed_suffix: ".pfx", + }, + TypeContract { + name: "pkcs8_private_key", + formats: &[Format::Pem, Format::Der], + sources: &[X509_IDENTITY], + roles: &[], + projection: Some(Projection::Pkcs8PrivateKey), + fixed_binary: false, + fixed_suffix: ".pem", + }, + TypeContract { + name: "x509_certificate", + formats: &[Format::Pem, Format::Der], + sources: &[X509_IDENTITY], + roles: &[], + projection: Some(Projection::Certificate), + fixed_binary: false, + fixed_suffix: ".pem", + }, + TypeContract { + name: "x509_certificate_chain", + formats: &[Format::Pem], + sources: &[X509_IDENTITY], + roles: &[], + projection: Some(Projection::CertificateChain), + fixed_binary: false, + fixed_suffix: ".pem", + }, + TypeContract { + name: "x509_issuer_chain", + formats: &[Format::Pem], + sources: &[X509_IDENTITY], + roles: &[], + projection: Some(Projection::IssuerChain), + fixed_binary: false, + fixed_suffix: ".pem", + }, +]; + +/// Look up a registry type. `None` for every informational type such as +/// `password` or `ssh_private_key`. +pub(crate) fn contract(type_name: &str) -> Option<&'static TypeContract> { + CONTRACTS.iter().find(|contract| contract.name == type_name) +} + +/// Types a `from` declaration may convert to, for diagnostics. +pub(crate) fn derivable_type_names() -> impl Iterator { + CONTRACTS + .iter() + .filter(|contract| !contract.is_stored()) + .map(|contract| contract.name) +} + +/// Reject a credential value that would silently change meaning or abuse the +/// crypto backend. An empty value is an error rather than "no credential": the +/// declaration bound one and it must be present. NUL is rejected because +/// PKCS#12 consumers disagree about its interpretation. +pub(crate) fn validate_credential_value(role: &str, value: &str) -> Result<(), String> { + if value.is_empty() { + return Err(format!( + "credential `{role}` resolved to an empty value; omit the binding for an empty credential" + )); + } + if value.contains('\0') { + return Err(format!("credential `{role}` must not contain NUL")); + } + if value.len() > MAX_CREDENTIAL_BYTES { + return Err(format!( + "credential `{role}` exceeds {MAX_CREDENTIAL_BYTES} bytes" + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stored_and_derived_contracts_are_distinct() { + let identity = contract(X509_IDENTITY).unwrap(); + assert!(identity.is_stored()); + assert!(identity.sources.is_empty()); + assert!(identity.accepts_role("password")); + assert!(identity.is_binary(None)); + assert_eq!(identity.suffix(None), ".pfx"); + assert_eq!( + identity.default_encoding(None), + Some(SecretEncoding::Base64) + ); + + for name in derivable_type_names() { + let target = contract(name).unwrap(); + assert!(!target.is_stored(), "{name}"); + assert!(target.accepts_source(Some(X509_IDENTITY)), "{name}"); + assert!(!target.accepts_source(Some("pkcs12")), "{name} chains"); + assert!(!target.accepts_source(None), "{name} untyped"); + } + assert!(contract("password").is_none()); + assert_eq!( + derivable_type_names().collect::>(), + [ + "pkcs12", + "pkcs8_private_key", + "x509_certificate", + "x509_certificate_chain", + "x509_issuer_chain", + ] + ); + } + + #[test] + fn formats_default_per_type_and_reject_fixed_representations() { + let key = contract("pkcs8_private_key").unwrap(); + assert_eq!(key.format(None).unwrap(), Some(Format::Pem)); + assert_eq!(key.format(Some("der")).unwrap(), Some(Format::Der)); + assert!(!key.is_binary(Some(Format::Pem))); + assert!(key.is_binary(Some(Format::Der))); + assert_eq!(key.suffix(Some(Format::Der)), ".der"); + assert_eq!(key.default_encoding(Some(Format::Pem)), None); + assert_eq!( + key.default_encoding(Some(Format::Der)), + Some(SecretEncoding::Base64) + ); + let unknown = key.format(Some("p12")).unwrap_err(); + assert!(unknown.contains("expected `pem` or `der`"), "{unknown}"); + + let chain = contract("x509_certificate_chain").unwrap(); + assert_eq!(chain.format(None).unwrap(), Some(Format::Pem)); + let der = chain.format(Some("der")).unwrap_err(); + assert!(der.contains("expected `pem`"), "{der}"); + + let pfx = contract("pkcs12").unwrap(); + assert_eq!(pfx.format(None).unwrap(), None); + let fixed = pfx.format(Some("der")).unwrap_err(); + assert!(fixed.contains("representation is fixed"), "{fixed}"); + assert!(pfx.is_binary(None)); + assert_eq!(pfx.suffix(None), ".pfx"); + } + + #[test] + fn credential_values_are_bounded() { + assert!(validate_credential_value("password", "correct horse").is_ok()); + let empty = validate_credential_value("password", "").unwrap_err(); + assert!(empty.contains("empty"), "{empty}"); + let nul = validate_credential_value("password", "a\0b").unwrap_err(); + assert!(nul.contains("NUL"), "{nul}"); + let long = validate_credential_value("password", &"x".repeat(MAX_CREDENTIAL_BYTES + 1)) + .unwrap_err(); + assert!(long.contains("exceeds"), "{long}"); + assert!(validate_credential_value("password", &"x".repeat(MAX_CREDENTIAL_BYTES)).is_ok()); + } +} diff --git a/secretspec/src/typed_tests.rs b/secretspec/src/typed_tests.rs new file mode 100644 index 000000000..04bc6fe09 --- /dev/null +++ b/secretspec/src/typed_tests.rs @@ -0,0 +1,1572 @@ +//! End-to-end coverage for typed, converted, and password protected secrets +//! (0.21+): `type`, `format`, `from`, and `credentials` across configuration +//! validation, resolution, scopes, reports, and the read-only lifecycle. + +use crate::config::{Config, GenerateConfig, GenerateOptions}; +use crate::report::{Derivation, ResolutionStatus}; +use crate::resolve::ResolvedSource; +use crate::tests::scrub_resolution_env; +use crate::x509_identity::Identity; +use crate::{Format, SecretSpecError, Secrets}; +use data_encoding::{BASE64, HEXLOWER}; +use openssl::pkcs12::Pkcs12; +use openssl::pkey::PKey; +use openssl::x509::X509; +use secrecy::ExposeSecret; +use std::fs; +use tempfile::TempDir; + +fn manifest(body: &str) -> Config { + toml::from_str(&format!( + "[project]\nname = \"typed\"\nrevision = \"1.0\"\n\n{body}" + )) + .expect("manifest parses") +} + +fn validation_error(body: &str) -> String { + manifest(body).validate().unwrap_err().to_string() +} + +fn assert_valid(body: &str) { + manifest(body).validate().unwrap_or_else(|error| { + panic!("expected a valid manifest, got: {error}\n{body}"); + }); +} + +fn generated_identity() -> Vec { + let config = GenerateConfig::Options(GenerateOptions { + san: Some(vec!["dns:localhost".to_string()]), + ..Default::default() + }); + crate::x509_identity::generate(&config) + .unwrap() + .expose_secret() + .to_vec() +} + +fn protected_identity(password: &str) -> Vec { + let bytes = generated_identity(); + Identity::decode(&bytes, None, "ID") + .unwrap() + .to_pkcs12(Some(password), "ID") + .unwrap() + .expose_secret() + .to_vec() +} + +/// A spec whose every stored secret lives in one dotenv file. +fn dotenv_spec(body: &str, env: &str) -> (TempDir, Secrets) { + let dir = TempDir::new().unwrap(); + let env_path = dir.path().join(".env"); + fs::write(&env_path, env).unwrap(); + let provider = format!("dotenv://{}", env_path.display()); + ( + dir, + Secrets::new(manifest(body), None, Some(provider), None), + ) +} + +fn null_spec(body: &str) -> Secrets { + Secrets::new(manifest(body), None, Some("null://".to_string()), None) +} + +fn open_pfx(bytes: &[u8], password: &str) -> openssl::pkcs12::ParsedPkcs12_2 { + Pkcs12::from_der(bytes) + .unwrap() + .parse2(password) + .unwrap_or_else(|error| panic!("PFX must open with {password:?}: {error}")) +} + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +#[test] +fn every_target_type_and_format_validates_against_an_identity() { + assert_valid( + r#" +[profiles.default] +TLS_IDENTITY = { description = "identity", type = "x509_identity" } +TLS_IDENTITY_HEX = { description = "hex at rest", type = "x509_identity", encoding = "hex" } +TLS_PFX = { description = "pfx", type = "pkcs12", from = "TLS_IDENTITY", as_path = true } +TLS_PFX_B64 = { description = "inline pfx", type = "pkcs12", from = "TLS_IDENTITY" } +TLS_PFX_HEX = { description = "inline hex pfx", type = "pkcs12", from = "TLS_IDENTITY", encoding = "hex" } +TLS_KEY = { description = "key", type = "pkcs8_private_key", from = "TLS_IDENTITY" } +TLS_KEY_PEM = { description = "key", type = "pkcs8_private_key", format = "pem", from = "TLS_IDENTITY", as_path = true } +TLS_KEY_DER = { description = "key", type = "pkcs8_private_key", format = "der", from = "TLS_IDENTITY" } +TLS_CERT = { description = "cert", type = "x509_certificate", from = "TLS_IDENTITY" } +TLS_CERT_DER = { description = "cert", type = "x509_certificate", format = "der", from = "TLS_IDENTITY", as_path = true } +TLS_CHAIN = { description = "chain", type = "x509_certificate_chain", from = "TLS_IDENTITY" } +TLS_CHAIN_PEM = { description = "chain", type = "x509_certificate_chain", format = "pem", from = "TLS_IDENTITY" } +TLS_ISSUERS = { description = "issuers", type = "x509_issuer_chain", from = "TLS_IDENTITY", required = false } +"#, + ); +} + +#[test] +fn from_requires_extract_or_a_derivable_type() { + let bare = validation_error( + r#" +[profiles.default] +A = { description = "a" } +B = { description = "b", from = "A" } +"#, + ); + assert!( + bare.contains("`from` requires `extract` to select from the source or a derivable `type`"), + "{bare}" + ); + + let informational = validation_error( + r#" +[profiles.default] +A = { description = "a" } +B = { description = "b", type = "password", from = "A" } +"#, + ); + assert!( + informational.contains("type = \"password\" cannot be derived with `from`"), + "{informational}" + ); + assert!(informational.contains("`pkcs12`"), "{informational}"); + assert!( + informational.contains("`x509_issuer_chain`"), + "{informational}" + ); + + let stored = validation_error( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity" } +B = { description = "b", type = "x509_identity", from = "A" } +"#, + ); + assert!( + stored.contains("type = \"x509_identity\" is stored by a provider and cannot be derived"), + "{stored}" + ); + + let both = validation_error( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity" } +B = { description = "b", type = "pkcs12", from = "A", extract = { format = "json", pointer = "/a" } } +"#, + ); + assert!( + both.contains( + "either selects with `extract` or converts with a derivable `type`, not both" + ), + "{both}" + ); + + let typed_selection = validation_error( + r#" +[profiles.default] +A = { description = "a" } +B = { description = "b", type = "password", from = "A", extract = { format = "json", pointer = "/a" } } +"#, + ); + assert!( + typed_selection.contains("`from` with `extract` cannot also set `type`"), + "{typed_selection}" + ); + + let bad_name = validation_error( + r#" +[profiles.default] +A = { description = "a" } +B = { description = "b", from = "not a name", extract = { format = "json", pointer = "/a" } } +"#, + ); + assert!( + bad_name.contains("`from` must name a declared secret using a valid identifier"), + "{bad_name}" + ); +} + +#[test] +fn stored_x509_identity_rejects_extract() { + let error = validation_error( + r#" +[profiles.default] +TLS_IDENTITY = { description = "identity", type = "x509_identity", extract = { format = "json", pointer = "/certificate" } } +"#, + ); + assert!( + error.contains("`extract` is not valid for stored type = \"x509_identity\""), + "{error}" + ); +} + +#[test] +fn from_secrets_reject_storage_and_generation_fields() { + for field in [ + r#"default = "x""#, + r#"providers = ["keyring"]"#, + r#"ref = { item = "x" }"#, + r#"refs = { keyring = { item = "x" } }"#, + r#"generate = { san = ["dns:localhost"] }"#, + r#"prompt = true"#, + ] { + let error = validation_error(&format!( + r#" +[profiles.default] +TLS_IDENTITY = {{ description = "identity", type = "x509_identity" }} +TLS_KEY = {{ description = "key", type = "pkcs8_private_key", from = "TLS_IDENTITY", {field} }} +"# + )); + assert!( + error.contains("`from` secrets cannot also set") + || error.contains("`generate` and `default`") + || error.contains("'generate' requires 'type'"), + "{field}: {error}" + ); + } + + let composed = validation_error( + r#" +[profiles.default] +A = { description = "a" } +B = { description = "b", composed = "${A}", from = "A", extract = { format = "json", pointer = "/a" } } +"#, + ); + assert!( + composed.contains("`composed` secrets cannot also set"), + "{composed}" + ); + assert!(composed.contains("`from`"), "{composed}"); +} + +#[test] +fn format_is_validated_against_the_type() { + let fixed = validation_error( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity" } +B = { description = "b", type = "pkcs12", format = "der", from = "A" } +"#, + ); + assert!( + fixed.contains("`format` is not valid for type = \"pkcs12\""), + "{fixed}" + ); + + let chain_der = validation_error( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity" } +B = { description = "b", type = "x509_certificate_chain", format = "der", from = "A" } +"#, + ); + assert!( + chain_der + .contains("unknown format 'der' for type = \"x509_certificate_chain\"; expected `pem`"), + "{chain_der}" + ); + + let unknown = validation_error( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity" } +B = { description = "b", type = "x509_certificate", format = "p7b", from = "A" } +"#, + ); + assert!( + unknown.contains( + "unknown format 'p7b' for type = \"x509_certificate\"; expected `pem` or `der`" + ), + "{unknown}" + ); + + let untyped = validation_error( + r#" +[profiles.default] +A = { description = "a", format = "pem" } +"#, + ); + assert!( + untyped.contains("`format` requires a `type` that accepts formats"), + "{untyped}" + ); + + let informational = validation_error( + r#" +[profiles.default] +A = { description = "a", type = "password", format = "pem" } +"#, + ); + assert!( + informational.contains("`format` is not valid for type = \"password\""), + "{informational}" + ); + + let identity = validation_error( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity", format = "der" } +"#, + ); + assert!(identity.contains("representation is fixed"), "{identity}"); +} + +#[test] +fn credential_roles_and_bindings_are_validated() { + let unknown_role = validation_error( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity", credentials = { passphrase = "P" } } +P = { description = "p" } +"#, + ); + assert!( + unknown_role.contains( + "unknown credential role `passphrase` for type = \"x509_identity\"; expected `password`" + ), + "{unknown_role}" + ); + + let no_roles = validation_error( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity" } +K = { description = "k", type = "pkcs8_private_key", from = "A", credentials = { password = "P" } } +P = { description = "p" } +"#, + ); + assert!( + no_roles.contains("type = \"pkcs8_private_key\" accepts no credentials"), + "{no_roles}" + ); + + let untyped = validation_error( + r#" +[profiles.default] +A = { description = "a", credentials = { password = "P" } } +P = { description = "p" } +"#, + ); + assert!( + untyped.contains("`credentials` requires a `type` that accepts credentials"), + "{untyped}" + ); + + let informational = validation_error( + r#" +[profiles.default] +A = { description = "a", type = "ssh_private_key", credentials = { password = "P" } } +P = { description = "p" } +"#, + ); + assert!( + informational.contains("`credentials` is not valid for type = \"ssh_private_key\""), + "{informational}" + ); + + let table = toml::from_str::( + r#" +[project] +name = "typed" +revision = "1.0" + +[profiles.default] +A = { description = "a", type = "x509_identity", credentials = { password = { provider = "keyring" } } } +"#, + ) + .unwrap_err() + .to_string(); + assert!( + table.contains("the `{ provider, ref }` table form is reserved"), + "{table}" + ); + + let bad_name = validation_error( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity", credentials = { password = "not a name" } } +"#, + ); + assert!( + bad_name + .contains("credential `password` must name a declared secret using a valid identifier"), + "{bad_name}" + ); + + let generated = validation_error( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity", generate = { san = ["dns:localhost"] }, credentials = { password = "P" } } +P = { description = "p" } +"#, + ); + assert!( + generated.contains( + "`credentials` cannot be combined with enabled `generate` for type = \"x509_identity\"" + ), + "{generated}" + ); + + // A stored identity and a derived archive both accept `password`. + assert_valid( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity", credentials = { password = "P" } } +B = { description = "b", type = "pkcs12", from = "A", credentials = { password = "Q" } } +P = { description = "p" } +Q = { description = "q" } +"#, + ); +} + +#[test] +fn credentials_must_name_text_secrets() { + let as_path = validation_error( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity", credentials = { password = "P" } } +P = { description = "p", as_path = true } +"#, + ); + assert!( + as_path.contains("credential `password` names 'P', which is delivered as a path"), + "{as_path}" + ); + + let binary = validation_error( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity" } +B = { description = "b", type = "pkcs12", from = "A", credentials = { password = "A" } } +"#, + ); + assert!( + binary + .contains("credential `password` names 'A', whose type = \"x509_identity\" is binary"), + "{binary}" + ); + + // A composed or selected text value is an acceptable credential. + assert_valid( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity" } +B = { description = "b", type = "pkcs12", from = "A", credentials = { password = "P" } } +DOC = { description = "doc", default = "{\"pw\":\"x\"}" } +P = { description = "p", from = "DOC", extract = { format = "json", pointer = "/pw" } } +"#, + ); +} + +#[test] +fn encoding_on_from_secrets_requires_a_binary_result() { + let pem = validation_error( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity" } +K = { description = "k", type = "pkcs8_private_key", from = "A", encoding = "base64" } +"#, + ); + assert!( + pem.contains("`encoding` on a `from` secret is valid only when the result is binary"), + "{pem}" + ); + + let selection = validation_error( + r#" +[profiles.default] +DOC = { description = "doc" } +F = { description = "f", from = "DOC", extract = { format = "json", pointer = "/a" }, encoding = "base64" } +"#, + ); + assert!( + selection.contains("`encoding` on a `from` secret is valid only when the result is binary"), + "{selection}" + ); + + assert_valid( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity" } +K = { description = "k", type = "pkcs8_private_key", format = "der", from = "A", encoding = "base64url" } +P = { description = "p", type = "pkcs12", from = "A", encoding = "hex" } +"#, + ); +} + +#[test] +fn target_types_require_from_and_identities_reject_defaults() { + let stored_target = validation_error( + r#" +[profiles.default] +P = { description = "p", type = "pkcs12", providers = ["keyring"] } +"#, + ); + assert!( + stored_target.contains("type = \"pkcs12\" requires `from`"), + "{stored_target}" + ); + + let generated_target = validation_error( + r#" +[profiles.default] +K = { description = "k", type = "pkcs8_private_key" } +"#, + ); + assert!( + generated_target.contains("type = \"pkcs8_private_key\" requires `from`"), + "{generated_target}" + ); + + let defaulted = validation_error( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity", default = "x" } +"#, + ); + assert!( + defaulted.contains( + "`type = \"x509_identity\"` cannot be combined with `default` or `prompt = true`" + ), + "{defaulted}" + ); + + let prompted = validation_error( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity", prompt = true } +"#, + ); + assert!( + prompted.contains( + "`type = \"x509_identity\"` cannot be combined with `default` or `prompt = true`" + ), + "{prompted}" + ); +} + +#[test] +fn extract_cannot_select_from_a_binary_source_and_targets_are_not_sources() { + let identity = validation_error( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity" } +F = { description = "f", from = "A", extract = { format = "json", pointer = "/a" } } +"#, + ); + assert!( + identity.contains( + "`extract` cannot select from 'A' because its type = \"x509_identity\" is binary" + ), + "{identity}" + ); + + let chained = validation_error( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity" } +P = { description = "p", type = "pkcs12", from = "A" } +K = { description = "k", type = "pkcs8_private_key", from = "P" } +"#, + ); + assert!( + chained.contains("`from` source 'P' has type = \"pkcs12\", but type = \"pkcs8_private_key\" derives from type = \"x509_identity\""), + "{chained}" + ); + + let der_selection = validation_error( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity" } +C = { description = "c", type = "x509_certificate", format = "der", from = "A" } +F = { description = "f", from = "C", extract = { format = "ini", pointer = "/a" } } +"#, + ); + assert!( + der_selection.contains( + "`extract` cannot select from 'C' because its type = \"x509_certificate\" is binary" + ), + "{der_selection}" + ); + + // A PEM target is text, so a selection from it is type-valid. + assert_valid( + r#" +[profiles.default] +A = { description = "a", type = "x509_identity" } +C = { description = "c", type = "x509_certificate", from = "A" } +F = { description = "f", from = "C", extract = { format = "ini", pointer = "/a" } } +"#, + ); +} + +#[test] +fn inheritance_merges_from_format_and_credentials_then_validates() { + let config = manifest( + r#" +[profiles.default] +TLS_IDENTITY = { description = "identity", type = "x509_identity" } +TLS_PFX_PASSWORD = { description = "password" } +TLS_PFX = { description = "pfx", type = "pkcs12", from = "TLS_IDENTITY", credentials = { password = "TLS_PFX_PASSWORD" } } +TLS_KEY = { description = "key", type = "pkcs8_private_key", format = "der", from = "TLS_IDENTITY", as_path = true } + +[profiles.production] +TLS_PFX = { description = "production pfx" } +TLS_KEY = { format = "pem" } +"#, + ); + config.validate().unwrap(); + let spec = Secrets::new(config, None, None, Some("production".to_string())); + let pfx = spec + .resolve_secret_config("TLS_PFX", Some("production")) + .unwrap(); + assert_eq!(pfx.description.as_deref(), Some("production pfx")); + assert_eq!(pfx.from.as_deref(), Some("TLS_IDENTITY")); + assert_eq!( + pfx.credential_secrets().collect::>(), + vec![("password", "TLS_PFX_PASSWORD")] + ); + let key = spec + .resolve_secret_config("TLS_KEY", Some("production")) + .unwrap(); + assert_eq!(key.format.as_deref(), Some("pem")); + assert_eq!(key.from.as_deref(), Some("TLS_IDENTITY")); + assert_eq!(key.as_path, Some(true)); + + // The merged view is what gets validated: an override that adds storage + // to an inherited `from` declaration fails like it would inline. + let error = validation_error( + r#" +[profiles.default] +TLS_IDENTITY = { description = "identity", type = "x509_identity" } +TLS_KEY = { description = "key", type = "pkcs8_private_key", from = "TLS_IDENTITY" } + +[profiles.production] +TLS_KEY = { providers = ["keyring"] } +"#, + ); + assert!(error.contains("`from` secrets cannot also set"), "{error}"); + + // An override may clear inherited credentials with an empty table. + let cleared = manifest( + r#" +[profiles.default] +TLS_IDENTITY = { description = "identity", type = "x509_identity" } +P = { description = "p" } +TLS_PFX = { description = "pfx", type = "pkcs12", from = "TLS_IDENTITY", credentials = { password = "P" } } + +[profiles.production] +TLS_PFX = { credentials = {} } +"#, + ); + cleared.validate().unwrap(); + let spec = Secrets::new(cleared, None, None, Some("production".to_string())); + let pfx = spec + .resolve_secret_config("TLS_PFX", Some("production")) + .unwrap(); + assert_eq!(pfx.credential_secrets().count(), 0); +} + +#[test] +fn typed_fields_round_trip_through_toml_and_the_builder() { + use crate::config::Secret; + + let secret: Secret = toml::from_str( + r#"description = "pfx" +type = "pkcs12" +from = "TLS_IDENTITY" +credentials = { password = "TLS_PFX_PASSWORD" } +as_path = true"#, + ) + .unwrap(); + assert_eq!(secret.from.as_deref(), Some("TLS_IDENTITY")); + assert_eq!( + secret.credential_secrets().collect::>(), + vec![("password", "TLS_PFX_PASSWORD")] + ); + assert!(secret.is_binary_value()); + assert_eq!(secret.file_suffix(), Some(".pfx")); + assert_eq!( + secret.effective_encoding(), + Some(crate::config::SecretEncoding::Base64) + ); + let rendered = toml::to_string(&secret).unwrap(); + assert!(rendered.contains("from = \"TLS_IDENTITY\""), "{rendered}"); + assert!( + rendered.contains("password = \"TLS_PFX_PASSWORD\""), + "{rendered}" + ); + assert!(!rendered.contains("format"), "{rendered}"); + let reparsed: Secret = toml::from_str(&rendered).unwrap(); + assert_eq!(reparsed.credentials, secret.credentials); + + let key: Secret = toml::from_str( + r#"description = "key" +type = "pkcs8_private_key" +format = "der" +from = "TLS_IDENTITY""#, + ) + .unwrap(); + assert_eq!(key.typed_format(), Some(Format::Der)); + assert!(key.is_binary_value()); + assert_eq!(key.file_suffix(), Some(".der")); + let pem: Secret = toml::from_str( + r#"description = "key" +type = "pkcs8_private_key" +from = "TLS_IDENTITY""#, + ) + .unwrap(); + assert_eq!(pem.typed_format(), Some(Format::Pem)); + assert!(!pem.is_binary_value()); + assert_eq!(pem.effective_encoding(), None); + assert_eq!(pem.file_suffix(), Some(".pem")); + + let built = crate::spec::Secret::new("Password protected Windows TLS identity") + .secret_type("pkcs12") + .from("TLS_IDENTITY") + .credential("password", "TLS_PFX_PASSWORD") + .as_path(true) + .into_config(); + assert_eq!(built.secret_type.as_deref(), Some("pkcs12")); + assert_eq!(built.from.as_deref(), Some("TLS_IDENTITY")); + assert_eq!( + built.credential_secrets().collect::>(), + vec![("password", "TLS_PFX_PASSWORD")] + ); + let der = crate::spec::Secret::new("Leaf certificate as DER") + .secret_type("x509_certificate") + .format(Format::Der) + .from("TLS_IDENTITY") + .into_config(); + assert_eq!(der.format.as_deref(), Some("der")); + der.validate().unwrap(); + + // The builder no longer has to spell the identity's encoding. + let identity = crate::spec::Secret::new("identity") + .generate(crate::spec::Generation::x509_identity(["dns:localhost"])) + .into_config(); + assert_eq!(identity.encoding, None); + assert_eq!( + identity.effective_encoding(), + Some(crate::config::SecretEncoding::Base64) + ); + identity.validate().unwrap(); +} + +// --------------------------------------------------------------------------- +// Resolution +// --------------------------------------------------------------------------- + +const GENERATED_PROFILE: &str = r#" +[profiles.default] +TLS_IDENTITY = { description = "identity", type = "x509_identity", generate = { san = ["dns:localhost", "ip:127.0.0.1"], usages = ["server_auth", "client_auth"], valid_for = "7d" } } +TLS_PFX_PASSWORD = { description = "pfx password", type = "passphrase", generate = true } +TLS_PFX = { description = "protected pfx", type = "pkcs12", from = "TLS_IDENTITY", credentials = { password = "TLS_PFX_PASSWORD" }, as_path = true } +TLS_PFX_B64 = { description = "inline pfx", type = "pkcs12", from = "TLS_IDENTITY", credentials = { password = "TLS_PFX_PASSWORD" } } +TLS_PFX_HEX = { description = "inline hex pfx", type = "pkcs12", from = "TLS_IDENTITY", encoding = "hex" } +TLS_KEY = { description = "key", type = "pkcs8_private_key", from = "TLS_IDENTITY" } +TLS_KEY_DER = { description = "key der", type = "pkcs8_private_key", format = "der", from = "TLS_IDENTITY", as_path = true } +TLS_KEY_DER_B64 = { description = "key der inline", type = "pkcs8_private_key", format = "der", from = "TLS_IDENTITY" } +TLS_CERTIFICATE = { description = "cert", type = "x509_certificate", from = "TLS_IDENTITY", as_path = true } +TLS_CERTIFICATE_DER = { description = "cert der", type = "x509_certificate", format = "der", from = "TLS_IDENTITY", as_path = true } +TLS_CHAIN = { description = "chain", type = "x509_certificate_chain", from = "TLS_IDENTITY" } +TLS_ISSUERS = { description = "issuers", type = "x509_issuer_chain", from = "TLS_IDENTITY" } +TLS_CONFIG = { description = "config", composed = "cert=${TLS_CERTIFICATE};key=${TLS_KEY}" } +"#; + +#[test] +fn generated_identity_derives_every_target_in_one_resolve() { + let _env = scrub_resolution_env(); + let spec = null_spec(GENERATED_PROFILE); + let response = spec.resolve().unwrap(); + assert!(response.is_ok(), "{:?}", response.missing_required); + + let secrets = &response.secrets; + // The canonical identity is inline Base64 of an empty-password archive. + let identity_bytes = BASE64 + .decode(secrets["TLS_IDENTITY"].value.as_deref().unwrap().as_bytes()) + .unwrap(); + let identity = open_pfx(&identity_bytes, ""); + let key = identity.pkey.as_ref().unwrap(); + let certificate = identity.cert.as_ref().unwrap(); + assert_eq!(secrets["TLS_IDENTITY"].source, ResolvedSource::Generated); + + // The protected archive opens only with the generated password and holds + // the same key and leaf. + let password = secrets["TLS_PFX_PASSWORD"].value.as_deref().unwrap(); + assert!( + password.split(|c: char| !c.is_alphanumeric()).count() >= 6, + "passphrase: {password}" + ); + let pfx_path = secrets["TLS_PFX"].path.as_deref().unwrap(); + assert!(pfx_path.ends_with(".pfx"), "{pfx_path}"); + assert!(secrets["TLS_PFX"].as_path); + let pfx_bytes = fs::read(pfx_path).unwrap(); + let protected = open_pfx(&pfx_bytes, password); + assert!(protected.pkey.as_ref().unwrap().public_eq(key)); + assert_eq!( + protected.cert.as_ref().unwrap().to_der().unwrap(), + certificate.to_der().unwrap() + ); + assert!( + Pkcs12::from_der(&pfx_bytes).unwrap().parse2("").is_err(), + "protected archive must not open with an empty password" + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = fs::metadata(pfx_path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o400, "owner-only temp file"); + } + + // Binary targets without `as_path` are inline text in their encoding. + let inline_pfx = BASE64 + .decode(secrets["TLS_PFX_B64"].value.as_deref().unwrap().as_bytes()) + .unwrap(); + assert!(open_pfx(&inline_pfx, password).pkey.unwrap().public_eq(key)); + let hex_pfx = HEXLOWER + .decode(secrets["TLS_PFX_HEX"].value.as_deref().unwrap().as_bytes()) + .unwrap(); + assert!(open_pfx(&hex_pfx, "").pkey.unwrap().public_eq(key)); + assert!(!secrets["TLS_PFX_B64"].as_path); + + // Key projections. + let pem_key = secrets["TLS_KEY"].value.as_deref().unwrap(); + assert!(pem_key.starts_with("-----BEGIN PRIVATE KEY-----")); + assert!( + PKey::private_key_from_pem(pem_key.as_bytes()) + .unwrap() + .public_eq(key) + ); + let der_path = secrets["TLS_KEY_DER"].path.as_deref().unwrap(); + assert!(der_path.ends_with(".der"), "{der_path}"); + let der_key = PKey::private_key_from_pkcs8(&fs::read(der_path).unwrap()).unwrap(); + assert!(der_key.public_eq(key)); + let inline_der = BASE64 + .decode( + secrets["TLS_KEY_DER_B64"] + .value + .as_deref() + .unwrap() + .as_bytes(), + ) + .unwrap(); + assert!( + PKey::private_key_from_pkcs8(&inline_der) + .unwrap() + .public_eq(key) + ); + + // Certificate projections. + let cert_path = secrets["TLS_CERTIFICATE"].path.as_deref().unwrap(); + assert!(cert_path.ends_with(".pem"), "{cert_path}"); + let cert_pem = fs::read(cert_path).unwrap(); + assert_eq!( + X509::from_pem(&cert_pem).unwrap().to_der().unwrap(), + certificate.to_der().unwrap() + ); + let cert_der_path = secrets["TLS_CERTIFICATE_DER"].path.as_deref().unwrap(); + assert!(cert_der_path.ends_with(".der"), "{cert_der_path}"); + assert_eq!( + fs::read(cert_der_path).unwrap(), + certificate.to_der().unwrap() + ); + assert_eq!( + secrets["TLS_CHAIN"].value.as_deref().unwrap().as_bytes(), + cert_pem.as_slice(), + "a self-signed identity's chain is its leaf" + ); + assert_eq!(secrets["TLS_ISSUERS"].value.as_deref(), Some("")); + + // Derived values feed compositions like any other secret. + let config = secrets["TLS_CONFIG"].value.as_deref().unwrap(); + assert!(config.starts_with(&format!("cert={cert_path};key=-----BEGIN PRIVATE KEY-----"))); + + for name in [ + "TLS_PFX", + "TLS_PFX_B64", + "TLS_KEY", + "TLS_KEY_DER", + "TLS_CERTIFICATE", + "TLS_CHAIN", + "TLS_ISSUERS", + ] { + assert_eq!(secrets[name].source, ResolvedSource::Composed, "{name}"); + assert_eq!(secrets[name].source_provider, None, "{name}"); + } + let names: Vec<&str> = secrets.keys().map(String::as_str).collect(); + assert_eq!( + names.len(), + 13, + "every declared name stays a flat field: {names:?}" + ); +} + +#[test] +fn a_stored_protected_identity_opens_with_its_declared_password() { + let _env = scrub_resolution_env(); + let archive = protected_identity("correct horse battery staple"); + let (_dir, spec) = dotenv_spec( + r#" +[profiles.default] +SERVICE_IDENTITY = { description = "identity", type = "x509_identity", credentials = { password = "SERVICE_IDENTITY_PASSWORD" }, as_path = true } +SERVICE_IDENTITY_PASSWORD = { description = "password" } +SERVICE_KEY = { description = "key", type = "pkcs8_private_key", from = "SERVICE_IDENTITY" } +SERVICE_CERTIFICATE = { description = "cert", type = "x509_certificate_chain", from = "SERVICE_IDENTITY" } +"#, + &format!( + "SERVICE_IDENTITY={}\nSERVICE_IDENTITY_PASSWORD=correct horse battery staple\n", + BASE64.encode(&archive) + ), + ); + let response = spec.resolve().unwrap(); + assert!(response.is_ok(), "{:?}", response.missing_required); + let secrets = &response.secrets; + + // The identity is delivered as its stored, still protected, archive. + let path = secrets["SERVICE_IDENTITY"].path.as_deref().unwrap(); + assert!(path.ends_with(".pfx"), "{path}"); + assert_eq!(fs::read(path).unwrap(), archive); + assert_eq!(secrets["SERVICE_IDENTITY"].source, ResolvedSource::Provider); + + let expected = open_pfx(&archive, "correct horse battery staple"); + let key = + PKey::private_key_from_pem(secrets["SERVICE_KEY"].value.as_deref().unwrap().as_bytes()) + .unwrap(); + assert!(expected.pkey.unwrap().public_eq(&key)); + let chain = secrets["SERVICE_CERTIFICATE"].value.as_deref().unwrap(); + assert_eq!( + X509::from_pem(chain.as_bytes()).unwrap().to_der().unwrap(), + expected.cert.unwrap().to_der().unwrap() + ); + assert_eq!(secrets["SERVICE_KEY"].source, ResolvedSource::Composed); +} + +#[test] +fn a_wrong_or_unbound_password_never_falls_back_to_guessing() { + let _env = scrub_resolution_env(); + let archive = BASE64.encode(&protected_identity("right")); + + let (_dir, spec) = dotenv_spec( + r#" +[profiles.default] +SERVICE_IDENTITY = { description = "identity", type = "x509_identity", credentials = { password = "PW" } } +PW = { description = "password" } +SERVICE_KEY = { description = "key", type = "pkcs8_private_key", from = "SERVICE_IDENTITY" } +"#, + &format!("SERVICE_IDENTITY={archive}\nPW=wrong\n"), + ); + let error = spec.resolve().unwrap_err(); + assert!( + matches!(&error, SecretSpecError::DecodeFailed { name, encoding, .. } if name == "SERVICE_IDENTITY" && *encoding == "pkcs12"), + "{error}" + ); + let text = error.to_string(); + assert!(text.contains("configured password"), "{text}"); + assert!( + !text.contains("wrong"), + "must not echo the password: {text}" + ); + + let (_dir, spec) = dotenv_spec( + r#" +[profiles.default] +SERVICE_IDENTITY = { description = "identity", type = "x509_identity" } +"#, + &format!("SERVICE_IDENTITY={archive}\n"), + ); + let text = spec.resolve().unwrap_err().to_string(); + assert!(text.contains("empty password"), "{text}"); + assert!(text.contains("credentials"), "{text}"); + + // A bound password on an unprotected archive is equally wrong. + let unprotected = BASE64.encode(&generated_identity()); + let (_dir, spec) = dotenv_spec( + r#" +[profiles.default] +SERVICE_IDENTITY = { description = "identity", type = "x509_identity", credentials = { password = "PW" } } +PW = { description = "password" } +"#, + &format!("SERVICE_IDENTITY={unprotected}\nPW=surprise\n"), + ); + let text = spec.resolve().unwrap_err().to_string(); + assert!(text.contains("configured password"), "{text}"); +} + +#[test] +fn a_missing_password_secret_makes_the_identity_and_its_projections_missing() { + let _env = scrub_resolution_env(); + let archive = BASE64.encode(&protected_identity("pw")); + + let (_dir, spec) = dotenv_spec( + r#" +[profiles.default] +SERVICE_IDENTITY = { description = "identity", type = "x509_identity", credentials = { password = "PW" } } +PW = { description = "password" } +SERVICE_KEY = { description = "key", type = "pkcs8_private_key", from = "SERVICE_IDENTITY" } +OTHER = { description = "unrelated" } +"#, + &format!("SERVICE_IDENTITY={archive}\nOTHER=fine\n"), + ); + let response = spec.resolve().unwrap(); + assert!(!response.is_ok()); + assert_eq!( + response.missing_required, + vec![ + "PW".to_string(), + "SERVICE_IDENTITY".to_string(), + "SERVICE_KEY".to_string() + ] + ); + + // The value-free report agrees, so a CI gate sees the gap. + let report = spec.report().unwrap(); + let status = |name: &str| { + report + .secrets + .iter() + .find(|entry| entry.name == name) + .unwrap() + .status + .clone() + }; + assert_eq!( + status("SERVICE_IDENTITY"), + ResolutionStatus::MissingRequired + ); + assert_eq!(status("SERVICE_KEY"), ResolutionStatus::MissingRequired); + assert_eq!(status("OTHER"), ResolutionStatus::Resolved); + + // Optional declarations degrade to missing optional instead. + let (_dir, spec) = dotenv_spec( + r#" +[profiles.default] +SERVICE_IDENTITY = { description = "identity", type = "x509_identity", credentials = { password = "PW" }, required = false } +PW = { description = "password", required = false } +SERVICE_KEY = { description = "key", type = "pkcs8_private_key", from = "SERVICE_IDENTITY", required = false } +"#, + &format!("SERVICE_IDENTITY={archive}\n"), + ); + let response = spec.resolve().unwrap(); + assert!(response.is_ok()); + assert!(response.secrets.is_empty(), "{:?}", response.secrets.keys()); + assert_eq!( + response.missing_optional, + vec![ + "PW".to_string(), + "SERVICE_IDENTITY".to_string(), + "SERVICE_KEY".to_string() + ] + ); +} + +#[test] +fn empty_nul_and_oversized_credential_values_are_rejected() { + let _env = scrub_resolution_env(); + let cases = [ + ("", "empty"), + ("with\u{0}nul", "NUL"), + (&"x".repeat(1025), "exceeds 1024 bytes"), + ]; + for (password, expected) in cases { + let escaped = password.replace('\u{0}', "\\u0000"); + let spec = null_spec(&format!( + r#" +[profiles.default] +TLS_IDENTITY = {{ description = "identity", type = "x509_identity", generate = {{ san = ["dns:localhost"] }} }} +PW = {{ description = "password", default = "{escaped}" }} +TLS_PFX = {{ description = "pfx", type = "pkcs12", from = "TLS_IDENTITY", credentials = {{ password = "PW" }} }} +"# + )); + let error = spec.resolve().unwrap_err(); + assert!( + matches!(&error, SecretSpecError::CredentialInvalid { name, role, .. } if name == "TLS_PFX" && role == "password"), + "{expected}: {error}" + ); + assert!(error.to_string().contains(expected), "{error}"); + assert_eq!(error.kind(), "credential_invalid"); + } + + // An omitted binding is the empty-password archive, not an error. + let spec = null_spec( + r#" +[profiles.default] +TLS_IDENTITY = { description = "identity", type = "x509_identity", generate = { san = ["dns:localhost"] } } +TLS_PFX = { description = "pfx", type = "pkcs12", from = "TLS_IDENTITY" } +"#, + ); + let response = spec.resolve().unwrap(); + let pfx = BASE64 + .decode( + response.secrets["TLS_PFX"] + .value + .as_deref() + .unwrap() + .as_bytes(), + ) + .unwrap(); + open_pfx(&pfx, ""); +} + +#[test] +fn scopes_fetch_hidden_sources_and_credentials_without_exposing_them() { + let _env = scrub_resolution_env(); + let body = format!( + "{GENERATED_PROFILE}\n[scopes.windows]\nsecrets = [\"TLS_PFX\", \"TLS_PFX_PASSWORD\"]\n\n[scopes.key_only]\nsecrets = [\"TLS_KEY\"]\n" + ); + + let mut spec = null_spec(&body); + spec.set_scope("windows"); + let response = spec.resolve().unwrap(); + assert!(response.is_ok(), "{:?}", response.missing_required); + assert_eq!( + response.secrets.keys().collect::>(), + vec!["TLS_PFX", "TLS_PFX_PASSWORD"], + "the hidden identity is fetched but not exposed" + ); + let password = response.secrets["TLS_PFX_PASSWORD"] + .value + .as_deref() + .unwrap(); + let pfx = fs::read(response.secrets["TLS_PFX"].path.as_deref().unwrap()).unwrap(); + open_pfx(&pfx, password); + assert_eq!(response.scope.as_deref(), Some("windows")); + + let mut spec = null_spec(&body); + spec.set_scope("key_only"); + let response = spec.resolve().unwrap(); + assert!(response.is_ok()); + assert_eq!(response.secrets.keys().collect::>(), vec!["TLS_KEY"]); + PKey::private_key_from_pem( + response.secrets["TLS_KEY"] + .value + .as_deref() + .unwrap() + .as_bytes(), + ) + .unwrap(); +} + +#[test] +fn declaration_order_does_not_affect_derived_resolution() { + let _env = scrub_resolution_env(); + // Targets before their source, credentials after their consumer. + let spec = null_spec( + r#" +[profiles.default] +TLS_KEY = { description = "key", type = "pkcs8_private_key", from = "TLS_IDENTITY" } +TLS_PFX = { description = "pfx", type = "pkcs12", from = "TLS_IDENTITY", credentials = { password = "PW" } } +TLS_IDENTITY = { description = "identity", type = "x509_identity", generate = { san = ["dns:localhost"] } } +PW = { description = "password", composed = "${PART_A}-${PART_B}" } +PART_B = { description = "b", default = "beta" } +PART_A = { description = "a", default = "alpha" } +"#, + ); + let response = spec.resolve().unwrap(); + assert!(response.is_ok(), "{:?}", response.missing_required); + let pfx = BASE64 + .decode( + response.secrets["TLS_PFX"] + .value + .as_deref() + .unwrap() + .as_bytes(), + ) + .unwrap(); + let opened = open_pfx(&pfx, "alpha-beta"); + let key = PKey::private_key_from_pem( + response.secrets["TLS_KEY"] + .value + .as_deref() + .unwrap() + .as_bytes(), + ) + .unwrap(); + assert!(opened.pkey.unwrap().public_eq(&key)); +} + +#[test] +fn json_and_ini_selection_from_declared_secrets() { + let _env = scrub_resolution_env(); + let spec = null_spec( + r#" +[profiles.default] +DOCUMENT = { description = "json", default = "{\"database\":{\"password\":\"from-json\",\"port\":5432}}" } +DATABASE_PASSWORD = { description = "password", from = "DOCUMENT", extract = { format = "json", pointer = "/database/password" } } +DATABASE_PORT = { description = "port", from = "DOCUMENT", extract = { format = "json", pointer = "/database/port" }, as_path = true } +INI_DOCUMENT = { description = "ini", default = "[database]\npassword = from-ini\n", as_path = true } +INI_PASSWORD = { description = "ini password", from = "INI_DOCUMENT", extract = { format = "ini", pointer = "/database/password" } } +MISSING_FIELD = { description = "absent", from = "DOCUMENT", extract = { format = "json", pointer = "/database/nope" }, required = false } +"#, + ); + let response = spec.resolve(); + // A pointer that does not match is a hard error, not a miss: the document + // resolved and the declaration is wrong. + let error = response.unwrap_err().to_string(); + assert!(error.contains("MISSING_FIELD"), "{error}"); + assert!(error.contains("/database/nope"), "{error}"); + + let spec = null_spec( + r#" +[profiles.default] +DOCUMENT = { description = "json", default = "{\"database\":{\"password\":\"from-json\",\"port\":5432}}" } +DATABASE_PASSWORD = { description = "password", from = "DOCUMENT", extract = { format = "json", pointer = "/database/password" } } +DATABASE_PORT = { description = "port", from = "DOCUMENT", extract = { format = "json", pointer = "/database/port" }, as_path = true } +INI_DOCUMENT = { description = "ini", default = "[database]\npassword = from-ini\n", as_path = true } +INI_PASSWORD = { description = "ini password", from = "INI_DOCUMENT", extract = { format = "ini", pointer = "/database/password" } } +"#, + ); + let response = spec.resolve().unwrap(); + assert!(response.is_ok(), "{:?}", response.missing_required); + let secrets = &response.secrets; + assert_eq!( + secrets["DATABASE_PASSWORD"].value.as_deref(), + Some("from-json") + ); + assert_eq!( + fs::read_to_string(secrets["DATABASE_PORT"].path.as_deref().unwrap()).unwrap(), + "5432" + ); + assert_eq!( + secrets["INI_PASSWORD"].value.as_deref(), + Some("from-ini"), + "a selection reads an `as_path` source's file" + ); + assert_eq!( + secrets["DATABASE_PASSWORD"].source, + ResolvedSource::Composed + ); +} + +#[test] +fn rewrapping_a_stored_identity_under_a_new_password() { + let _env = scrub_resolution_env(); + let legacy = protected_identity("legacy"); + let (_dir, spec) = dotenv_spec( + r#" +[profiles.default] +LEGACY_IDENTITY = { description = "legacy", type = "x509_identity", credentials = { password = "LEGACY_PASSWORD" } } +LEGACY_PASSWORD = { description = "legacy password" } +NEW_PASSWORD = { description = "new password", default = "fresh" } +NEW_PFX = { description = "rewrapped", type = "pkcs12", from = "LEGACY_IDENTITY", credentials = { password = "NEW_PASSWORD" }, as_path = true } +"#, + &format!( + "LEGACY_IDENTITY={}\nLEGACY_PASSWORD=legacy\n", + BASE64.encode(&legacy) + ), + ); + let response = spec.resolve().unwrap(); + assert!(response.is_ok(), "{:?}", response.missing_required); + let rewrapped = fs::read(response.secrets["NEW_PFX"].path.as_deref().unwrap()).unwrap(); + assert!( + Pkcs12::from_der(&rewrapped) + .unwrap() + .parse2("legacy") + .is_err() + ); + let opened = open_pfx(&rewrapped, "fresh"); + let original = open_pfx(&legacy, "legacy"); + assert!(opened.pkey.unwrap().public_eq(&original.pkey.unwrap())); + assert_ne!(rewrapped, legacy, "a rewrapped archive is re-encrypted"); +} + +#[test] +fn an_optional_identity_that_is_absent_omits_its_projections() { + let _env = scrub_resolution_env(); + let (_dir, spec) = dotenv_spec( + r#" +[profiles.default] +SERVICE_IDENTITY = { description = "identity", type = "x509_identity", required = false } +SERVICE_KEY = { description = "key", type = "pkcs8_private_key", from = "SERVICE_IDENTITY", required = false } +SERVICE_PFX = { description = "pfx", type = "pkcs12", from = "SERVICE_IDENTITY" } +"#, + "", + ); + let response = spec.resolve().unwrap(); + assert!(!response.is_ok()); + assert_eq!(response.missing_required, vec!["SERVICE_PFX".to_string()]); + assert_eq!( + response.missing_optional, + vec!["SERVICE_IDENTITY".to_string(), "SERVICE_KEY".to_string()] + ); +} + +#[test] +fn a_stored_identity_with_an_invalid_encoding_or_archive_fails_cleanly() { + let _env = scrub_resolution_env(); + let (_dir, spec) = dotenv_spec( + r#" +[profiles.default] +SERVICE_IDENTITY = { description = "identity", type = "x509_identity" } +"#, + "SERVICE_IDENTITY=not*base64!\n", + ); + let error = spec.resolve().unwrap_err(); + assert!( + matches!(&error, SecretSpecError::DecodeFailed { encoding, .. } if *encoding == "base64"), + "{error}" + ); + + let (_dir, spec) = dotenv_spec( + r#" +[profiles.default] +SERVICE_IDENTITY = { description = "identity", type = "x509_identity", encoding = "hex" } +"#, + &format!("SERVICE_IDENTITY={}\n", HEXLOWER.encode(b"not an archive")), + ); + let text = spec.resolve().unwrap_err().to_string(); + assert!(text.contains("invalid PKCS#12 identity"), "{text}"); +} + +// --------------------------------------------------------------------------- +// Reports and provenance +// --------------------------------------------------------------------------- + +#[test] +fn value_free_reports_predict_derivation_without_materializing() { + let _env = scrub_resolution_env(); + let archive = BASE64.encode(&protected_identity("pw")); + let (dir, spec) = dotenv_spec( + r#" +[profiles.default] +SERVICE_IDENTITY = { description = "identity", type = "x509_identity", credentials = { password = "PW" }, as_path = true } +PW = { description = "password" } +SERVICE_KEY = { description = "key", type = "pkcs8_private_key", from = "SERVICE_IDENTITY", as_path = true } +DOCUMENT = { description = "doc", default = "{\"a\":1}" } +FIELD = { description = "field", from = "DOCUMENT", extract = { format = "json", pointer = "/a" } } +DSN = { description = "dsn", composed = "x=${FIELD}" } +"#, + &format!("SERVICE_IDENTITY={archive}\nPW=pw\n"), + ); + + let before: Vec<_> = fs::read_dir(std::env::temp_dir()) + .unwrap() + .filter_map(|entry| entry.ok()) + .filter(|entry| { + let name = entry.file_name().to_string_lossy().to_string(); + name.ends_with(".pfx") || name.ends_with(".pem") + }) + .map(|entry| entry.path()) + .collect(); + + let report = spec.report().unwrap(); + let entry = |name: &str| { + report + .secrets + .iter() + .find(|entry| entry.name == name) + .unwrap_or_else(|| panic!("missing {name}")) + }; + assert_eq!(entry("SERVICE_IDENTITY").status, ResolutionStatus::Resolved); + assert!(entry("SERVICE_IDENTITY").derivation.is_none()); + assert_eq!( + entry("SERVICE_KEY").derivation, + Some(Derivation::ConvertedFrom("SERVICE_IDENTITY".to_string())) + ); + assert_eq!( + entry("FIELD").derivation, + Some(Derivation::SelectedFrom("DOCUMENT".to_string())) + ); + assert_eq!(entry("DSN").derivation, Some(Derivation::Composed)); + assert!(entry("SERVICE_KEY").composed); + assert!(entry("SERVICE_KEY").as_path); + let explained = report.to_explain_string(); + assert!( + explained.contains("converted from SERVICE_IDENTITY"), + "{explained}" + ); + assert!(explained.contains("selected from DOCUMENT"), "{explained}"); + assert!(explained.contains("ok composed"), "{explained}"); + + let after: Vec<_> = fs::read_dir(std::env::temp_dir()) + .unwrap() + .filter_map(|entry| entry.ok()) + .filter(|entry| { + let name = entry.file_name().to_string_lossy().to_string(); + name.ends_with(".pfx") || name.ends_with(".pem") + }) + .map(|entry| entry.path()) + .filter(|path| !before.contains(path)) + .collect(); + assert!( + after.is_empty(), + "report must not materialize files: {after:?}" + ); + + let stripped = spec.resolve_without_values().unwrap(); + assert!(stripped.is_ok()); + assert_eq!(stripped.secrets["SERVICE_KEY"].value, None); + assert_eq!(stripped.secrets["SERVICE_KEY"].path, None); + assert_eq!( + stripped.secrets["SERVICE_KEY"].source, + ResolvedSource::Composed + ); + drop(dir); +} + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +#[test] +fn derived_secrets_are_read_only_and_name_their_source() { + let _env = scrub_resolution_env(); + let (_dir, spec) = dotenv_spec( + r#" +[profiles.default] +SERVICE_IDENTITY = { description = "identity", type = "x509_identity" } +SERVICE_KEY = { description = "key", type = "pkcs8_private_key", from = "SERVICE_IDENTITY" } +DOCUMENT = { description = "doc" } +FIELD = { description = "field", from = "DOCUMENT", extract = { format = "json", pointer = "/a" } } +STORED_FIELD = { description = "stored field", extract = { format = "json", pointer = "/a" }, required = false } +DSN = { description = "dsn", composed = "x=${FIELD}" } +"#, + "", + ); + + let error = spec + .set("SERVICE_KEY", crate::SecretBytes::from_utf8("value")) + .unwrap_err(); + assert!( + matches!(&error, SecretSpecError::DerivedSecretReadOnly { name, from } if name == "SERVICE_KEY" && from == "SERVICE_IDENTITY"), + "{error}" + ); + assert_eq!(error.kind(), "derived_secret_read_only"); + assert!( + error + .to_string() + .contains("change 'SERVICE_IDENTITY' instead") + ); + + let error = spec + .set("FIELD", crate::SecretBytes::from_utf8("value")) + .unwrap_err(); + assert!( + matches!(&error, SecretSpecError::DerivedSecretReadOnly { from, .. } if from == "DOCUMENT"), + "a selection names its source, not the extract rule: {error}" + ); + let error = spec.delete("SERVICE_KEY").unwrap_err(); + assert!( + matches!(&error, SecretSpecError::DerivedSecretReadOnly { from, .. } if from == "SERVICE_IDENTITY"), + "{error}" + ); + let error = spec.delete("FIELD").unwrap_err(); + assert!( + matches!(&error, SecretSpecError::DerivedSecretReadOnly { .. }), + "{error}" + ); + + // Existing read-only classes are unchanged. + assert!(matches!( + spec.set("STORED_FIELD", crate::SecretBytes::from_utf8("v")) + .unwrap_err(), + SecretSpecError::ExtractedSecretReadOnly(_) + )); + assert!(matches!( + spec.set("DSN", crate::SecretBytes::from_utf8("v")) + .unwrap_err(), + SecretSpecError::ComposedSecretReadOnly(_) + )); + + // The source itself is writable, and the identity gets its stored form. + spec.set("DOCUMENT", crate::SecretBytes::from_utf8(r#"{"a":"one"}"#)) + .unwrap(); + spec.set( + "SERVICE_IDENTITY", + crate::SecretBytes::from_vec(generated_identity()), + ) + .unwrap(); + let response = spec.resolve().unwrap(); + assert!(response.is_ok(), "{:?}", response.missing_required); + assert_eq!(response.secrets["FIELD"].value.as_deref(), Some("one")); + assert!( + response.secrets["SERVICE_KEY"] + .value + .as_deref() + .unwrap() + .starts_with("-----BEGIN PRIVATE KEY-----") + ); +} + +#[test] +fn binary_and_text_setters_store_the_same_identity_representation() { + let _env = scrub_resolution_env(); + let bytes = generated_identity(); + let (_dir, spec) = dotenv_spec( + r#" +[profiles.default] +RAW = { description = "raw input", type = "x509_identity", as_path = true } +TEXT = { description = "encoded input", type = "x509_identity", as_path = true } +"#, + "", + ); + spec.set("RAW", crate::SecretBytes::from_slice(&bytes)) + .unwrap(); + spec.set_text("TEXT", &BASE64.encode(&bytes)).unwrap(); + let result = spec.resolve().unwrap(); + for name in ["RAW", "TEXT"] { + let path = result.secrets[name].path.as_ref().unwrap(); + assert_eq!(fs::read(path).unwrap(), bytes); + fs::remove_file(path).unwrap(); + } +} + +#[cfg(feature = "cli")] +#[test] +fn ipc_derived_identity_files_are_leased_and_persistent_generation_is_read_only() { + use crate::secrets::{IpcAuditPurpose, OwnedNamedResolution}; + let _env = scrub_resolution_env(); + let body = r#" +[profiles.default] +IDENTITY = { description = "identity", type = "x509_identity", generate = { san = ["dns:localhost"] } } +CERT = { description = "certificate", type = "x509_certificate", from = "IDENTITY", as_path = true } +UNRELATED = { description = "must not be resolved" } +"#; + let purpose = || IpcAuditPurpose { + consumer: "typed-test".into(), + operation: "resolve".into(), + host: None, + path: None, + }; + let mut ephemeral = null_spec(body); + ephemeral.refuse_produced_writes(); + let OwnedNamedResolution::File { file, .. } = ephemeral + .resolve_named_owned_for_ipc("CERT", purpose(), false) + .unwrap() + else { + panic!("expected an owned certificate file") + }; + let path = file.path().to_owned(); + X509::from_pem(&fs::read(&path).unwrap()).unwrap(); + assert_eq!(path.extension().unwrap(), "pem"); + drop(file); + assert!(!path.exists()); + + let (dir, mut persistent) = dotenv_spec(body, ""); + persistent.refuse_produced_writes(); + let error = persistent + .resolve_named_owned_for_ipc("CERT", purpose(), false) + .err() + .unwrap(); + assert!( + matches!(error, SecretSpecError::ProducedValueWriteRefused(_)), + "{error}" + ); + assert_eq!(fs::read_to_string(dir.path().join(".env")).unwrap(), ""); +} + +#[test] +fn derived_sources_do_not_inherit_project_storage_defaults() { + let _env = scrub_resolution_env(); + let config = manifest( + r#" +[defaults] +providers = ["null"] +[profiles.default] +IDENTITY = { description = "identity", type = "x509_identity", generate = { san = ["dns:localhost"] } } +CERT = { description = "certificate", type = "x509_certificate", from = "IDENTITY" } +"#, + ); + config.validate().unwrap(); + let result = Secrets::new(config, None, None, None).resolve().unwrap(); + X509::from_pem(result.secrets["CERT"].value.as_ref().unwrap().as_bytes()).unwrap(); +} diff --git a/secretspec/src/x509_identity.rs b/secretspec/src/x509_identity.rs new file mode 100644 index 000000000..b35d739ce --- /dev/null +++ b/secretspec/src/x509_identity.rs @@ -0,0 +1,1076 @@ +//! X.509 identity generation, PKCS#12 decoding, and typed projections. +//! +//! An `x509_identity` is canonically a PKCS#12 archive holding one private key, +//! its leaf certificate, and an optional issuer chain. This module generates +//! such archives, opens stored ones (with the configured password or an empty +//! one, never guessing), validates them strictly, and projects them into the +//! `pkcs12`, `pkcs8_private_key`, `x509_certificate`, `x509_certificate_chain`, +//! and `x509_issuer_chain` targets of the type registry. + +use crate::SecretSpecError; +use crate::config::{GenerateConfig, GenerateOptions}; +use crate::typed::{Format, Projection}; +use openssl::asn1::{Asn1Integer, Asn1Time}; +use openssl::bn::{BigNum, MsbOption}; +use openssl::ec::{EcGroup, EcKey}; +use openssl::error::ErrorStack; +use openssl::hash::MessageDigest; +use openssl::nid::Nid; +use openssl::pkcs12::Pkcs12; +use openssl::pkey::{PKey, Private}; +use openssl::stack::Stack; +use openssl::x509::extension::{ + BasicConstraints, ExtendedKeyUsage, KeyUsage, SubjectAlternativeName, SubjectKeyIdentifier, +}; +use openssl::x509::{X509, X509NameBuilder}; +use secrecy::zeroize::Zeroizing; +use secrecy::{SecretSlice, SecretString}; +use std::cmp::Ordering; +use std::net::IpAddr; +use std::time::{SystemTime, UNIX_EPOCH}; + +const MAX_IDENTITY_BYTES: usize = 10 * 1024 * 1024; +const MAX_CHAIN_CERTIFICATES: usize = 16; +// Current CA/Browser Forum TLS Baseline Requirements cap publicly trusted +// subscriber certificates at 200 days (effective 2026-03-15). Self-signed +// development certificates are outside that policy, but using the same ceiling +// keeps generated lifetimes conservative. +const MAX_VALID_DAYS: u32 = 200; +const PFX_ITERATIONS: u32 = 100_000; +const FRIENDLY_NAME: &str = "SecretSpec X.509 identity"; + +/// A value projected from an identity: PEM text or DER/PFX bytes. +pub(crate) enum ProjectedValue { + Text(SecretString), + Binary(SecretSlice), +} + +fn generation_failed(context: &str, error: impl std::fmt::Display) -> SecretSpecError { + SecretSpecError::GenerationFailed(format!("{context}: {error}")) +} + +fn decode_failed(name: &str, error: impl std::fmt::Display) -> SecretSpecError { + SecretSpecError::DecodeFailed { + name: name.to_string(), + encoding: "pkcs12", + reason: error.to_string(), + } +} + +/// The first reason in an OpenSSL error stack, without the library, function, +/// and file context of the full stack, which can echo caller-supplied data. +fn short_reason(error: &ErrorStack) -> String { + error + .errors() + .first() + .and_then(|error| error.reason()) + .unwrap_or("malformed data") + .to_string() +} + +pub(crate) fn parse_valid_days(value: Option<&str>) -> Result { + let value = value.unwrap_or("30d"); + let days = value + .strip_suffix('d') + .ok_or_else(|| { + "generate.valid_for must be a whole number of days such as `30d`".to_string() + })? + .parse::() + .map_err(|_| { + "generate.valid_for must be a whole number of days such as `30d`".to_string() + })?; + if !(1..=MAX_VALID_DAYS).contains(&days) { + return Err(format!( + "X.509 generate.valid_for must be between 1d and {MAX_VALID_DAYS}d" + )); + } + Ok(days) +} + +pub(crate) fn validate_san(value: &str) -> Result<(), String> { + if let Some(dns) = value.strip_prefix("dns:") { + validate_dns_name(dns) + } else if let Some(ip) = value.strip_prefix("ip:") { + ip.parse::() + .map(|_| ()) + .map_err(|_| format!("invalid X.509 IP SAN `{value}`")) + } else { + Err(format!( + "invalid X.509 SAN `{value}`; expected `dns:name` or `ip:address`" + )) + } +} + +fn validate_dns_name(name: &str) -> Result<(), String> { + let ordinary = name.strip_prefix("*.").unwrap_or(name); + if ordinary.is_empty() + || ordinary.len() > 253 + || !ordinary.is_ascii() + || ordinary.split('.').any(|label| { + label.is_empty() + || label.len() > 63 + || label.starts_with('-') + || label.ends_with('-') + || !label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + }) + { + return Err(format!("invalid X.509 DNS SAN `dns:{name}`")); + } + Ok(()) +} + +fn options(config: &GenerateConfig) -> Result<&GenerateOptions, SecretSpecError> { + match config { + GenerateConfig::Options(options) => Ok(options), + GenerateConfig::Bool(_) => Err(SecretSpecError::GenerationFailed( + "type = \"x509_identity\" requires generate = { san = [\"dns:localhost\"] }" + .to_string(), + )), + } +} + +/// Serialize an identity as a PKCS#12 archive using one explicit modern +/// profile: PBES2/PBKDF2 with HMAC-SHA-256, AES-256-CBC for key and +/// certificate privacy, and HMAC-SHA-256 integrity, each with a fixed high +/// iteration count. Never RC2, 3DES, or SHA-1, whatever OpenSSL's platform +/// default is. An empty password yields an interchange container, not a +/// security boundary; the caller decides which it needs. +fn build_archive( + key: &PKey, + certificate: &X509, + chain: &[X509], + password: &str, +) -> Result, ErrorStack> { + let mut builder = Pkcs12::builder(); + builder + .name(FRIENDLY_NAME) + .pkey(key) + .cert(certificate) + .key_algorithm(Nid::AES_256_CBC) + .cert_algorithm(Nid::AES_256_CBC) + .key_iter(PFX_ITERATIONS) + .mac_iter(PFX_ITERATIONS) + .mac_md(MessageDigest::sha256()); + if !chain.is_empty() { + let mut issuers = Stack::new()?; + for issuer in chain { + issuers.push(issuer.clone())?; + } + builder.ca(issuers); + } + let archive = builder.build2(password)?; + let der = Zeroizing::new(archive.to_der()?); + Ok(der.as_slice().to_vec().into()) +} + +pub(crate) fn generate(config: &GenerateConfig) -> crate::Result> { + let options = options(config)?; + let valid_days = parse_valid_days(options.valid_for.as_deref()) + .map_err(SecretSpecError::GenerationFailed)?; + let sans = options.san.as_deref().ok_or_else(|| { + SecretSpecError::GenerationFailed("X.509 generation requires generate.san".to_string()) + })?; + for san in sans { + validate_san(san).map_err(SecretSpecError::GenerationFailed)?; + } + + let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1) + .map_err(|error| generation_failed("failed to select P-256", error))?; + let ec_key = EcKey::generate(&group) + .map_err(|error| generation_failed("failed to generate P-256 private key", error))?; + let private_key = PKey::from_ec_key(ec_key) + .map_err(|error| generation_failed("failed to prepare P-256 private key", error))?; + + let common_name = sans + .iter() + .find_map(|san| san.strip_prefix("dns:")) + .filter(|name| name.len() <= 64) + .unwrap_or("SecretSpec generated identity"); + let mut subject = X509NameBuilder::new() + .map_err(|error| generation_failed("failed to create X.509 subject", error))?; + subject + .append_entry_by_nid(Nid::COMMONNAME, common_name) + .map_err(|error| generation_failed("failed to set X.509 common name", error))?; + let subject = subject.build(); + + let mut serial = BigNum::new() + .map_err(|error| generation_failed("failed to allocate X.509 serial", error))?; + serial + .rand(128, MsbOption::ONE, false) + .map_err(|error| generation_failed("failed to generate X.509 serial", error))?; + let serial = Asn1Integer::from_bn(&serial) + .map_err(|error| generation_failed("failed to encode X.509 serial", error))?; + + let mut certificate = X509::builder() + .map_err(|error| generation_failed("failed to create X.509 certificate", error))?; + certificate + .set_version(2) + .and_then(|_| certificate.set_serial_number(&serial)) + .and_then(|_| certificate.set_subject_name(&subject)) + .and_then(|_| certificate.set_issuer_name(&subject)) + .and_then(|_| certificate.set_pubkey(&private_key)) + .map_err(|error| generation_failed("failed to initialize X.509 certificate", error))?; + // A small backdate tolerates clock skew between the generating machine and + // a local TLS peer without extending the requested total validity period. + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| generation_failed("system clock is before the Unix epoch", error))? + .as_secs(); + let not_before_unix = now.saturating_sub(300); + let not_after_unix = not_before_unix + u64::from(valid_days) * 86_400; + let not_before = Asn1Time::from_unix(not_before_unix as i64) + .map_err(|error| generation_failed("failed to set X.509 start time", error))?; + let not_after = Asn1Time::from_unix(not_after_unix as i64) + .map_err(|error| generation_failed("failed to set X.509 expiry", error))?; + certificate + .set_not_before(¬_before) + .and_then(|_| certificate.set_not_after(¬_after)) + .map_err(|error| generation_failed("failed to set X.509 validity", error))?; + + let basic_constraints = BasicConstraints::new() + .critical() + .build() + .map_err(|error| generation_failed("failed to build basic constraints", error))?; + let key_usage = KeyUsage::new() + .critical() + .digital_signature() + .build() + .map_err(|error| generation_failed("failed to build key usage", error))?; + certificate + .append_extension(basic_constraints) + .and_then(|_| certificate.append_extension(key_usage)) + .map_err(|error| generation_failed("failed to add X.509 key constraints", error))?; + + let default_usages = ["server_auth".to_string()]; + let usages = options.usages.as_deref().unwrap_or(&default_usages); + let mut extended = ExtendedKeyUsage::new(); + for usage in usages { + match usage.as_str() { + "server_auth" => { + extended.server_auth(); + } + "client_auth" => { + extended.client_auth(); + } + _ => unreachable!("generation options are validated before generation"), + } + } + certificate + .append_extension( + extended + .build() + .map_err(|error| generation_failed("failed to build extended key usage", error))?, + ) + .map_err(|error| generation_failed("failed to add extended key usage", error))?; + + let mut san_extension = SubjectAlternativeName::new(); + for san in sans { + if let Some(dns) = san.strip_prefix("dns:") { + san_extension.dns(dns); + } else if let Some(ip) = san.strip_prefix("ip:") { + san_extension.ip(ip); + } + } + { + let context = certificate.x509v3_context(None, None); + let san = san_extension.build(&context).map_err(|error| { + generation_failed("failed to build subject alternative names", error) + })?; + let subject_key = SubjectKeyIdentifier::new() + .build(&context) + .map_err(|error| generation_failed("failed to build subject key identifier", error))?; + certificate + .append_extension(san) + .and_then(|_| certificate.append_extension(subject_key)) + .map_err(|error| generation_failed("failed to add X.509 extensions", error))?; + } + certificate + .sign(&private_key, MessageDigest::sha256()) + .map_err(|error| generation_failed("failed to sign X.509 certificate", error))?; + let certificate = certificate.build(); + + // The canonical stored archive uses an empty password on purpose: the + // provider protects the identity at rest, and a consumer that needs a + // protected PFX derives one with `type = "pkcs12"` and its own password. + build_archive(&private_key, &certificate, &[], "") + .map_err(|error| generation_failed("failed to build PKCS#12 identity", error)) +} + +/// A decoded and validated identity: the private key, its leaf certificate, +/// and the issuer chain in leaf-to-root order. +pub(crate) struct Identity { + key: PKey, + certificate: X509, + chain: Vec, +} + +impl std::fmt::Debug for Identity { + /// Never prints key or certificate material. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Identity") + .field("chain_len", &self.chain.len()) + .finish_non_exhaustive() + } +} + +impl Identity { + /// Open a PKCS#12 archive with the configured password, or the empty + /// password when none is configured. There is no fallback between the + /// two: a protected archive without a bound password fails, and so does a + /// wrong password. + pub(crate) fn decode(bytes: &[u8], password: Option<&str>, name: &str) -> crate::Result { + if bytes.is_empty() || bytes.len() > MAX_IDENTITY_BYTES { + return Err(decode_failed( + name, + format!("PKCS#12 identity must be between 1 and {MAX_IDENTITY_BYTES} bytes"), + )); + } + let archive = Pkcs12::from_der(bytes).map_err(|error| { + decode_failed( + name, + format!("invalid PKCS#12 identity: {}", short_reason(&error)), + ) + })?; + let archive = archive + .parse2(password.unwrap_or("")) + .map_err(|_| { + decode_failed( + name, + if password.is_some() { + "PKCS#12 identity could not be opened with the configured password" + } else { + "PKCS#12 identity could not be opened with an empty password; bind its password with `credentials = { password = \"...\" }` if the archive is protected" + }, + ) + })?; + let key = archive + .pkey + .ok_or_else(|| decode_failed(name, "PKCS#12 identity has no private key"))?; + let certificate = archive + .cert + .ok_or_else(|| decode_failed(name, "PKCS#12 identity has no leaf certificate"))?; + let mut remaining: Vec = archive + .ca + .map(|certificates| { + certificates + .into_iter() + .map(|cert| cert.to_owned()) + .collect() + }) + .unwrap_or_default(); + if remaining.len() > MAX_CHAIN_CERTIFICATES { + return Err(decode_failed( + name, + format!( + "PKCS#12 identity has more than {MAX_CHAIN_CERTIFICATES} chain certificates" + ), + )); + } + let certificate_key = certificate.public_key().map_err(|error| { + decode_failed( + name, + format!("leaf certificate has no usable public key: {error}"), + ) + })?; + if !certificate_key.public_eq(&key) { + return Err(decode_failed( + name, + "leaf certificate does not match the private key", + )); + } + + let now = Asn1Time::days_from_now(0).map_err(|error| { + decode_failed(name, format!("failed to read current time: {error}")) + })?; + if certificate + .not_before() + .compare(&now) + .map_err(|error| decode_failed(name, error))? + == Ordering::Greater + { + return Err(decode_failed(name, "leaf certificate is not valid yet")); + } + if certificate + .not_after() + .compare(&now) + .map_err(|error| decode_failed(name, error))? + == Ordering::Less + { + return Err(decode_failed(name, "leaf certificate has expired")); + } + + for chain_certificate in &remaining { + if chain_certificate + .not_before() + .compare(&now) + .map_err(|error| decode_failed(name, error))? + == Ordering::Greater + || chain_certificate + .not_after() + .compare(&now) + .map_err(|error| decode_failed(name, error))? + == Ordering::Less + { + return Err(decode_failed( + name, + "PKCS#12 certificate chain contains a certificate outside its validity period", + )); + } + } + + // Certificate bags in PKCS#12 are a set, not an ordered chain. + // Reconstruct the leaf-to-root order by issuer/subject name and verify + // every signature; reject unrelated or ambiguous bags instead of + // silently exporting them. + let mut chain = Vec::with_capacity(remaining.len()); + let mut child = certificate.clone(); + while !remaining.is_empty() { + let child_issuer = child + .issuer_name() + .to_der() + .map_err(|error| decode_failed(name, error))?; + let mut matching = Vec::new(); + for (index, candidate) in remaining.iter().enumerate() { + if candidate + .subject_name() + .to_der() + .map_err(|error| decode_failed(name, error))? + != child_issuer + { + continue; + } + let issuer_key = candidate.public_key().map_err(|error| { + decode_failed( + name, + format!("chain certificate has no usable public key: {error}"), + ) + })?; + if child.verify(&issuer_key).map_err(|error| { + decode_failed(name, format!("failed to verify certificate chain: {error}")) + })? { + matching.push(index); + } + } + if matching.len() != 1 { + return Err(decode_failed( + name, + "PKCS#12 certificate bags do not form one unambiguous, coherent chain", + )); + } + child = remaining.remove(matching[0]); + chain.push(child.clone()); + } + + if certificate + .issuer_name() + .to_der() + .and_then(|issuer| { + certificate + .subject_name() + .to_der() + .map(|subject| issuer == subject) + }) + .map_err(|error| decode_failed(name, error))? + { + let leaf_key = certificate.public_key().map_err(|error| { + decode_failed( + name, + format!("leaf certificate has no usable public key: {error}"), + ) + })?; + if !certificate + .verify(&leaf_key) + .map_err(|error| decode_failed(name, error))? + { + return Err(decode_failed( + name, + "self-signed leaf certificate has an invalid signature", + )); + } + } + + Ok(Self { + key, + certificate, + chain, + }) + } + + /// Number of issuer certificates behind the leaf. + #[cfg(test)] + pub(crate) fn chain_len(&self) -> usize { + self.chain.len() + } + + /// Repackage the identity as a PKCS#12 archive protected by `password` + /// (empty when `None`), keeping the complete issuer chain. + pub(crate) fn to_pkcs12( + &self, + password: Option<&str>, + name: &str, + ) -> crate::Result> { + build_archive( + &self.key, + &self.certificate, + &self.chain, + password.unwrap_or(""), + ) + .map_err(|error| { + decode_failed( + name, + format!("failed to build PKCS#12 archive: {}", short_reason(&error)), + ) + }) + } + + /// Produce one registry target from this identity. `password` applies to + /// [`Projection::Pkcs12`] only; `format` selects PEM or DER where the + /// target offers both and defaults to PEM. + pub(crate) fn project( + &self, + projection: Projection, + format: Option, + password: Option<&str>, + name: &str, + ) -> crate::Result { + let format = format.unwrap_or(Format::Pem); + match projection { + Projection::Pkcs12 => self.to_pkcs12(password, name).map(ProjectedValue::Binary), + Projection::Pkcs8PrivateKey => match format { + Format::Der => self + .key + .private_key_to_pkcs8() + .map(protected_binary) + .map(ProjectedValue::Binary) + .map_err(|error| { + decode_failed(name, format!("failed to encode PKCS#8 DER: {error}")) + }), + Format::Pem => self + .key + .private_key_to_pem_pkcs8() + .map_err(|error| { + decode_failed(name, format!("failed to encode PKCS#8 PEM: {error}")) + }) + .and_then(|bytes| protected_text(bytes, name)) + .map(ProjectedValue::Text), + }, + Projection::Certificate => match format { + Format::Der => self + .certificate + .to_der() + .map(protected_binary) + .map(ProjectedValue::Binary) + .map_err(|error| { + decode_failed(name, format!("failed to encode certificate DER: {error}")) + }), + Format::Pem => self + .certificate + .to_pem() + .map_err(|error| { + decode_failed(name, format!("failed to encode certificate PEM: {error}")) + }) + .and_then(|bytes| protected_text(bytes, name)) + .map(ProjectedValue::Text), + }, + Projection::CertificateChain => { + let mut pem = self.certificate.to_pem().map_err(|error| { + decode_failed(name, format!("failed to encode certificate PEM: {error}")) + })?; + pem.extend(self.issuer_pem(name)?); + protected_text(pem, name).map(ProjectedValue::Text) + } + Projection::IssuerChain => { + protected_text(self.issuer_pem(name)?, name).map(ProjectedValue::Text) + } + } + } + + fn issuer_pem(&self, name: &str) -> crate::Result> { + let mut pem = Vec::new(); + for certificate in &self.chain { + pem.extend(certificate.to_pem().map_err(|error| { + decode_failed( + name, + format!("failed to encode chain certificate PEM: {error}"), + ) + })?); + } + Ok(pem) + } +} + +/// Validate a stored archive that no credential unlocks. Used where the value +/// is inspected without resolving its dependencies, such as `import`. +pub(crate) fn validate(bytes: &[u8], name: &str) -> crate::Result<()> { + Identity::decode(bytes, None, name).map(|_| ()) +} + +fn protected_text(bytes: Vec, name: &str) -> crate::Result { + let bytes = Zeroizing::new(bytes); + let text = std::str::from_utf8(bytes.as_slice()).map_err(|error| { + decode_failed(name, format!("OpenSSL produced invalid PEM text: {error}")) + })?; + Ok(SecretString::new(text.to_owned().into())) +} + +fn protected_binary(bytes: Vec) -> SecretSlice { + let bytes = Zeroizing::new(bytes); + bytes.as_slice().to_vec().into() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::GenerateOptions; + use openssl::x509::X509Name; + use secrecy::ExposeSecret; + + fn config() -> GenerateConfig { + GenerateConfig::Options(GenerateOptions { + algorithm: Some("p256".to_string()), + san: Some(vec![ + "dns:localhost".to_string(), + "ip:127.0.0.1".to_string(), + ]), + usages: Some(vec!["server_auth".to_string()]), + valid_for: Some("30d".to_string()), + ..Default::default() + }) + } + + fn p256() -> PKey { + let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1).unwrap(); + PKey::from_ec_key(EcKey::generate(&group).unwrap()).unwrap() + } + + fn name(common_name: &str) -> X509Name { + let mut builder = X509NameBuilder::new().unwrap(); + builder + .append_entry_by_nid(Nid::COMMONNAME, common_name) + .unwrap(); + builder.build() + } + + /// A certificate for `subject`, signed by `issuer` (self-signed when + /// `None`), valid from `not_before` to `not_after` seconds relative to now. + fn certificate( + subject: &str, + key: &PKey, + issuer: Option<(&PKey, &X509)>, + ca: bool, + not_before: i64, + not_after: i64, + ) -> X509 { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + let subject = name(subject); + let mut builder = X509::builder().unwrap(); + builder.set_version(2).unwrap(); + let mut serial = BigNum::new().unwrap(); + serial.rand(64, MsbOption::ONE, false).unwrap(); + builder + .set_serial_number(&Asn1Integer::from_bn(&serial).unwrap()) + .unwrap(); + builder.set_subject_name(&subject).unwrap(); + builder + .set_issuer_name(issuer.map_or(&subject, |(_, cert)| cert.subject_name())) + .unwrap(); + builder.set_pubkey(key).unwrap(); + builder + .set_not_before(&Asn1Time::from_unix(now + not_before).unwrap()) + .unwrap(); + builder + .set_not_after(&Asn1Time::from_unix(now + not_after).unwrap()) + .unwrap(); + let mut constraints = BasicConstraints::new(); + if ca { + constraints.ca(); + } + builder + .append_extension(constraints.critical().build().unwrap()) + .unwrap(); + builder + .sign(issuer.map_or(key, |(key, _)| key), MessageDigest::sha256()) + .unwrap(); + builder.build() + } + + const DAY: i64 = 86_400; + + /// Root -> intermediate -> leaf, all currently valid. + struct Chain { + root: X509, + intermediate: X509, + leaf_key: PKey, + leaf: X509, + } + + fn chain() -> Chain { + let root_key = p256(); + let root = certificate("Test Root", &root_key, None, true, -DAY, 365 * DAY); + let intermediate_key = p256(); + let intermediate = certificate( + "Test Intermediate", + &intermediate_key, + Some((&root_key, &root)), + true, + -DAY, + 180 * DAY, + ); + let leaf_key = p256(); + let leaf = certificate( + "leaf.example", + &leaf_key, + Some((&intermediate_key, &intermediate)), + false, + -DAY, + 30 * DAY, + ); + Chain { + root, + intermediate, + leaf_key, + leaf, + } + } + + fn archive(key: &PKey, leaf: &X509, bags: &[X509], password: &str) -> Vec { + build_archive(key, leaf, bags, password) + .unwrap() + .expose_secret() + .to_vec() + } + + #[test] + fn generates_and_projects_a_matching_identity() { + let identity = generate(&config()).unwrap(); + validate(identity.expose_secret(), "TLS_IDENTITY").unwrap(); + let identity = Identity::decode(identity.expose_secret(), None, "TLS_IDENTITY").unwrap(); + assert_eq!(identity.chain_len(), 0); + + let ProjectedValue::Text(key) = identity + .project(Projection::Pkcs8PrivateKey, None, None, "TLS_KEY") + .unwrap() + else { + panic!("expected PEM text") + }; + assert!( + key.expose_secret() + .starts_with("-----BEGIN PRIVATE KEY-----") + ); + let ProjectedValue::Binary(key_der) = identity + .project( + Projection::Pkcs8PrivateKey, + Some(Format::Der), + None, + "TLS_KEY_DER", + ) + .unwrap() + else { + panic!("expected DER bytes") + }; + let from_der = PKey::private_key_from_pkcs8(key_der.expose_secret()).unwrap(); + assert!(from_der.public_eq(&identity.key)); + + let ProjectedValue::Text(certificate) = identity + .project(Projection::Certificate, None, None, "TLS_CERTIFICATE") + .unwrap() + else { + panic!("expected PEM text") + }; + assert!( + certificate + .expose_secret() + .starts_with("-----BEGIN CERTIFICATE-----") + ); + let ProjectedValue::Text(chain) = identity + .project(Projection::CertificateChain, None, None, "TLS_CHAIN") + .unwrap() + else { + panic!("expected PEM text") + }; + assert_eq!( + chain.expose_secret(), + certificate.expose_secret(), + "a self-signed identity's chain is just its leaf" + ); + let ProjectedValue::Text(issuers) = identity + .project(Projection::IssuerChain, None, None, "TLS_ISSUERS") + .unwrap() + else { + panic!("expected PEM text") + }; + assert!(issuers.expose_secret().is_empty()); + } + + #[test] + fn generation_uses_fallback_cn_for_long_valid_dns_san() { + let long_dns = format!("{}.example.internal", "a".repeat(63)); + assert!(long_dns.len() > 64); + let generated = generate(&GenerateConfig::Options(GenerateOptions { + san: Some(vec![format!("dns:{long_dns}")]), + ..Default::default() + })) + .unwrap(); + let identity = Identity::decode(generated.expose_secret(), None, "TLS_IDENTITY").unwrap(); + let common_name = identity + .certificate + .subject_name() + .entries_by_nid(Nid::COMMONNAME) + .next() + .unwrap() + .data() + .to_string() + .unwrap(); + assert_eq!(common_name, "SecretSpec generated identity"); + } + + #[test] + fn protected_archives_open_only_with_their_password() { + let generated = generate(&config()).unwrap(); + let identity = Identity::decode(generated.expose_secret(), None, "ID").unwrap(); + let protected = identity + .to_pkcs12(Some("correct horse battery staple"), "PFX") + .unwrap(); + + // The configured password opens it and the identity is intact. + let reopened = Identity::decode( + protected.expose_secret(), + Some("correct horse battery staple"), + "PFX", + ) + .unwrap(); + assert!(reopened.key.public_eq(&identity.key)); + assert_eq!( + reopened.certificate.to_der().unwrap(), + identity.certificate.to_der().unwrap() + ); + + // Neither a wrong password nor a missing one falls back to guessing. + let wrong = Identity::decode(protected.expose_secret(), Some("wrong"), "PFX") + .unwrap_err() + .to_string(); + assert!(wrong.contains("configured password"), "{wrong}"); + assert!( + !wrong.contains("wrong"), + "must not echo the password: {wrong}" + ); + let missing = Identity::decode(protected.expose_secret(), None, "PFX") + .unwrap_err() + .to_string(); + assert!(missing.contains("empty password"), "{missing}"); + assert!(missing.contains("credentials"), "{missing}"); + + // And a password is not accepted for an archive that has none. + let unprotected = Identity::decode(generated.expose_secret(), Some("x"), "ID") + .unwrap_err() + .to_string(); + assert!(unprotected.contains("configured password"), "{unprotected}"); + } + + #[test] + fn rewrapping_replaces_the_password_and_keeps_the_chain() { + let chain = chain(); + let legacy = archive( + &chain.leaf_key, + &chain.leaf, + &[chain.intermediate.clone(), chain.root.clone()], + "legacy", + ); + let identity = Identity::decode(&legacy, Some("legacy"), "LEGACY").unwrap(); + assert_eq!(identity.chain_len(), 2); + + let rewrapped = identity.to_pkcs12(Some("fresh"), "NEW").unwrap(); + assert!(Identity::decode(rewrapped.expose_secret(), Some("legacy"), "NEW").is_err()); + let reopened = Identity::decode(rewrapped.expose_secret(), Some("fresh"), "NEW").unwrap(); + assert_eq!(reopened.chain_len(), 2); + assert_eq!( + reopened.chain[0].to_der().unwrap(), + chain.intermediate.to_der().unwrap() + ); + assert_eq!( + reopened.chain[1].to_der().unwrap(), + chain.root.to_der().unwrap() + ); + } + + #[test] + fn unordered_bags_are_reconstructed_into_one_leaf_to_root_chain() { + let chain = chain(); + // Root before intermediate: the bag order is not the chain order. + let bytes = archive( + &chain.leaf_key, + &chain.leaf, + &[chain.root.clone(), chain.intermediate.clone()], + "", + ); + let identity = Identity::decode(&bytes, None, "ID").unwrap(); + assert_eq!( + identity + .chain + .iter() + .map(|cert| cert.to_der().unwrap()) + .collect::>(), + vec![ + chain.intermediate.to_der().unwrap(), + chain.root.to_der().unwrap() + ] + ); + + let ProjectedValue::Text(full) = identity + .project(Projection::CertificateChain, None, None, "CHAIN") + .unwrap() + else { + panic!("expected PEM text") + }; + let ProjectedValue::Text(issuers) = identity + .project(Projection::IssuerChain, None, None, "ISSUERS") + .unwrap() + else { + panic!("expected PEM text") + }; + let leaf_pem = String::from_utf8(chain.leaf.to_pem().unwrap()).unwrap(); + let intermediate_pem = String::from_utf8(chain.intermediate.to_pem().unwrap()).unwrap(); + let root_pem = String::from_utf8(chain.root.to_pem().unwrap()).unwrap(); + assert_eq!( + full.expose_secret(), + format!("{leaf_pem}{intermediate_pem}{root_pem}") + ); + assert_eq!( + issuers.expose_secret(), + format!("{intermediate_pem}{root_pem}") + ); + assert_eq!(full.expose_secret().matches("BEGIN CERTIFICATE").count(), 3); + } + + #[test] + fn unrelated_duplicate_and_expired_chain_bags_are_rejected() { + let chain = chain(); + + let stranger_key = p256(); + let stranger = certificate("Stranger", &stranger_key, None, true, -DAY, DAY); + let unrelated = archive( + &chain.leaf_key, + &chain.leaf, + &[chain.intermediate.clone(), chain.root.clone(), stranger], + "", + ); + let error = Identity::decode(&unrelated, None, "ID") + .unwrap_err() + .to_string(); + assert!(error.contains("unambiguous, coherent chain"), "{error}"); + + let duplicated = archive( + &chain.leaf_key, + &chain.leaf, + &[ + chain.intermediate.clone(), + chain.intermediate.clone(), + chain.root.clone(), + ], + "", + ); + let error = Identity::decode(&duplicated, None, "ID") + .unwrap_err() + .to_string(); + assert!(error.contains("unambiguous, coherent chain"), "{error}"); + + let root_key = p256(); + let expired_root = certificate("Old Root", &root_key, None, true, -3 * DAY, -DAY); + let leaf_key = p256(); + let leaf = certificate( + "leaf.example", + &leaf_key, + Some((&root_key, &expired_root)), + false, + -DAY, + DAY, + ); + let expired_issuer = archive(&leaf_key, &leaf, &[expired_root], ""); + let error = Identity::decode(&expired_issuer, None, "ID") + .unwrap_err() + .to_string(); + assert!(error.contains("outside its validity period"), "{error}"); + } + + #[test] + fn leaf_validity_and_key_match_are_enforced() { + let key = p256(); + let expired = certificate("expired.example", &key, None, false, -3 * DAY, -DAY); + let error = Identity::decode(&archive(&key, &expired, &[], ""), None, "ID") + .unwrap_err() + .to_string(); + assert!(error.contains("has expired"), "{error}"); + + let future = certificate("future.example", &key, None, false, DAY, 3 * DAY); + let error = Identity::decode(&archive(&key, &future, &[], ""), None, "ID") + .unwrap_err() + .to_string(); + assert!(error.contains("not valid yet"), "{error}"); + + // OpenSSL's own builder refuses a leaf that does not match the key, so + // a mismatched archive can only come from another tool; the decoder's + // check stays as defense in depth and is exercised by the builder here. + let other_key = p256(); + let mismatched = certificate("other.example", &other_key, None, false, -DAY, DAY); + assert!(build_archive(&key, &mismatched, &[], "").is_err()); + } + + #[test] + fn oversized_empty_and_overlong_chain_archives_are_rejected() { + let empty = Identity::decode(&[], None, "ID").unwrap_err().to_string(); + assert!(empty.contains("between 1 and"), "{empty}"); + let huge = vec![0u8; MAX_IDENTITY_BYTES + 1]; + let oversized = Identity::decode(&huge, None, "ID").unwrap_err().to_string(); + assert!(oversized.contains("between 1 and"), "{oversized}"); + let garbage = Identity::decode(b"not a pfx", None, "ID") + .unwrap_err() + .to_string(); + assert!(garbage.contains("invalid PKCS#12 identity"), "{garbage}"); + + // Seventeen issuer bags exceed the chain cap before any chain walk. + let mut issuers = Vec::new(); + let mut parent: Option<(PKey, X509)> = None; + for index in 0..=MAX_CHAIN_CERTIFICATES { + let key = p256(); + let cert = certificate( + &format!("CA {index}"), + &key, + parent.as_ref().map(|(key, cert)| (key, cert)), + true, + -DAY, + DAY, + ); + issuers.push(cert.clone()); + parent = Some((key, cert)); + } + let (issuer_key, issuer) = parent.unwrap(); + let leaf_key = p256(); + let leaf = certificate( + "leaf.example", + &leaf_key, + Some((&issuer_key, &issuer)), + false, + -DAY, + DAY, + ); + let error = Identity::decode(&archive(&leaf_key, &leaf, &issuers, ""), None, "ID") + .unwrap_err() + .to_string(); + assert!(error.contains("more than 16 chain certificates"), "{error}"); + } + + #[test] + fn validates_san_and_validity_bounds() { + assert!(validate_san("dns:*.example.com").is_ok()); + assert!(validate_san("ip:::1").is_ok()); + assert!(validate_san("example.com").is_err()); + assert!(validate_san("dns:bad_name").is_err()); + assert!(parse_valid_days(Some("200d")).is_ok()); + assert!(parse_valid_days(Some("201d")).is_err()); + } +} diff --git a/secretspec/tests/config_schemas.rs b/secretspec/tests/config_schemas.rs index 8f8ad93ed..0a8b5830a 100644 --- a/secretspec/tests/config_schemas.rs +++ b/secretspec/tests/config_schemas.rs @@ -70,6 +70,15 @@ FILE = { as_path = true, encoding = "base64", ref = { item = "file", field = "va EXTRACTED = { refs = { vault = { item = "data" } }, extract = { format = "json", pointer = "/token" } } PGP = { type = "openpgp_private_key", generate = { user_id = "Test ", algorithm = "rsa", bits = 3072, capabilities = ["sign"] } } SSH = { type = "ssh_private_key", generate = { comment = "test" } } +PASSPHRASE = { type = "passphrase", generate = { words = 8, separator = "." } } +MNEMONIC = { type = "mnemonic", generate = { algorithm = "bip39", language = "english", words = 24 } } +WG = { type = "wireguard_private_key", generate = true } +JWK = { type = "jwk_private_key", generate = { algorithm = "p256", kid = "service" } } +AGE = { type = "age_identity", generate = true } +IDENTITY = { type = "x509_identity", generate = { san = ["dns:localhost"], issuer = "self_signed", usages = ["server_auth"], valid_for = "30d" } } +CERT = { type = "x509_certificate", from = "IDENTITY", format = "der", as_path = true } +PFX = { type = "pkcs12", from = "IDENTITY", credentials = { password = "TOKEN" } } + [profiles.production.defaults] inherit = false default = "production" From f9b3fd7a1191909ad0d0f8af5360fbff5ff42c03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Thu, 8 Oct 2026 16:21:27 +0200 Subject: [PATCH 2/9] fix: select MSYS2 Perl for Windows Haskell OpenSSL build --- .github/workflows/haskell-build.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/haskell-build.yml b/.github/workflows/haskell-build.yml index f8186d9eb..1add242c8 100644 --- a/.github/workflows/haskell-build.yml +++ b/.github/workflows/haskell-build.yml @@ -124,7 +124,7 @@ 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 MSYS2 prepends its Cygwin Perl to PATH. + # Build the MSVC CLI before entering the MSYS2 build environment. # Vendored OpenSSL requires a native Windows Perl for an MSVC target. - name: Build CLI for the test-suite run: cargo build -p secretspec @@ -135,7 +135,7 @@ jobs: with: msystem: MINGW64 path-type: inherit - install: mingw-w64-x86_64-gcc mingw-w64-x86_64-binutils + install: mingw-w64-x86_64-gcc mingw-w64-x86_64-binutils make perl - name: Install GHC and cabal uses: haskell-actions/setup@0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d # v2.12.1 with: @@ -144,6 +144,9 @@ jobs: shell: msys2 {0} run: | set -euo pipefail + # OpenSSL's MinGW build needs Unix paths from MSYS2 Perl. Select it + # explicitly because the inherited PATH also contains Windows Perl. + export OPENSSL_SRC_PERL="$(cygpath -w /usr/bin/perl.exe)" # The archive for GHC's MinGW linker; --crate-type overrides the # crate's list so the unused cdylib is never linked. Release like # the shipped artifacts: a debug archive carries sectionless .dwo From eba5d53018f9263dc672cd4487492c305aaede2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Thu, 8 Oct 2026 19:21:22 +0200 Subject: [PATCH 3/9] fix: preserve X.509 credential deadlines --- CHANGELOG.md | 3 + secretspec/src/secrets.rs | 104 ++++++++++++++++++++++++++++------ secretspec/src/typed_tests.rs | 54 ++++++++++++++++++ 3 files changed, 143 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 971f85483..717910323 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Stored X.509 identities and their derived secrets retain credential expiry + and refresh deadlines so SDK consumers refresh them when their inputs change. + - IPC v1 now keeps timestamps, durations, retry delays, expiry values, and counts within the JSON integer range that JavaScript clients can represent exactly. Values outside that range are rejected instead of being rounded. diff --git a/secretspec/src/secrets.rs b/secretspec/src/secrets.rs index 035795a72..a5cf757ea 100644 --- a/secretspec/src/secrets.rs +++ b/secretspec/src/secrets.rs @@ -1930,6 +1930,26 @@ impl<'secrets, 'plan, 'filter, 'addresses> .iter() .all(|dependency| statuses.get(*dependency) == Some(&ResolutionStatus::Resolved)); + if dependencies_resolved { + // Stored typed values depend on credentials just as derived + // values depend on their inputs. Preserve the earlier of the + // value's own deadline and every dependency's deadline. + for deadlines in [&mut self.secret_expiries, &mut self.refreshes] { + if let Some(deadline) = planned + .dependencies() + .iter() + .filter_map(|name| deadlines.get(*name)) + .copied() + .min() + { + deadlines + .entry(planned.name.clone()) + .and_modify(|existing| *existing = (*existing).min(deadline)) + .or_insert(deadline); + } + } + } + if planned.is_typed_stored() { // The provider pass already recorded this secret. It stays // resolved only while its credentials did: an archive whose @@ -1966,24 +1986,6 @@ impl<'secrets, 'plan, 'filter, 'addresses> } let status = if dependencies_resolved { - if let Some(expiry) = planned - .dependencies() - .iter() - .filter_map(|name| self.secret_expiries.get(*name)) - .copied() - .min() - { - self.secret_expiries.insert(planned.name.clone(), expiry); - } - if let Some(refresh) = planned - .dependencies() - .iter() - .filter_map(|name| self.refreshes.get(*name)) - .copied() - .min() - { - self.refreshes.insert(planned.name.clone(), refresh); - } if self.materialize.values() { if let Some(template) = planned.composition() { let inputs = template @@ -9823,3 +9825,69 @@ mod retry_policy_tests { assert_eq!(provider.0.unwrap(), crate::RetryPolicy::default()); } } + +#[cfg(test)] +mod resolution_deadline_tests { + use super::*; + + #[test] + fn typed_identity_and_projections_use_the_earliest_input_deadlines() { + let _env = crate::tests::scrub_resolution_env(); + let config: Config = toml::from_str( + r#" +[project] +name = "identity-deadlines" +revision = "1.0" +[profiles.default] +ID = { description = "identity", type = "x509_identity", credentials = { password = "PW" } } +PW = { description = "password" } +CERT = { description = "certificate", type = "x509_certificate", from = "ID" } +"#, + ) + .unwrap(); + let secrets = Secrets::new(config, None, Some("null://".into()), None); + let plan = secrets.build_plan(Some("default")).unwrap(); + + // Cover missing deadlines, either input expiring first, and expiry + // and refresh being governed by different inputs. The value-free + // pass uses the same provider metadata and dependency graph as reads. + for (identity_expiry, password_expiry, identity_refresh, password_refresh) in [ + (None, Some(200), None, Some(400)), + (Some(100), Some(200), Some(300), Some(400)), + (Some(200), Some(100), Some(400), Some(300)), + (Some(100), Some(200), Some(400), Some(300)), + (Some(100), None, Some(300), None), + (None, None, None, None), + ] { + let mut execution = + ResolutionExecution::new(&secrets, &plan, Materialize::None, None, None); + execution.fetched_values = HashMap::from([ + ( + "ID".into(), + ProviderValue::new(SecretBytes::from_utf8("unused"), identity_expiry), + ), + ( + "PW".into(), + ProviderValue::new(SecretBytes::from_utf8("password"), password_expiry), + ), + ]); + for (name, refresh) in [("ID", identity_refresh), ("PW", password_refresh)] { + if let Some(refresh) = refresh { + execution.refreshes.insert(name.into(), refresh); + } + } + execution.resolve_provider_backed_values().unwrap(); + execution.resolve_derived_values().unwrap(); + + let expected_expiry = identity_expiry.into_iter().chain(password_expiry).min(); + let expected_refresh = identity_refresh.into_iter().chain(password_refresh).min(); + for name in ["ID", "CERT"] { + assert_eq!( + execution.secret_expiries.get(name).copied(), + expected_expiry + ); + assert_eq!(execution.refreshes.get(name).copied(), expected_refresh); + } + } + } +} diff --git a/secretspec/src/typed_tests.rs b/secretspec/src/typed_tests.rs index 04bc6fe09..d6abc0ac0 100644 --- a/secretspec/src/typed_tests.rs +++ b/secretspec/src/typed_tests.rs @@ -1570,3 +1570,57 @@ CERT = { description = "certificate", type = "x509_certificate", from = "IDENTIT let result = Secrets::new(config, None, None, None).resolve().unwrap(); X509::from_pem(result.secrets["CERT"].value.as_ref().unwrap().as_bytes()).unwrap(); } + +#[cfg(feature = "cli")] +#[test] +fn named_identity_and_certificate_inherit_password_refresh_deadline() { + let _env = scrub_resolution_env(); + let dir = TempDir::new().unwrap(); + let source = dir.path().join("source.env"); + let cache = dir.path().join("cache.env"); + fs::write( + &source, + format!( + "ID={}\nPW=secret-password\n", + BASE64.encode(&protected_identity("secret-password")) + ), + ) + .unwrap(); + let config = manifest(&format!( + r#" +[providers] +source = "dotenv://{}" +cache = "dotenv://{}" +password = {{ fallback = ["source"], cache = {{ provider = "cache", max_age = "1h" }} }} +[profiles.default] +ID = {{ description = "identity", type = "x509_identity", credentials = {{ password = "PW" }}, providers = ["source"] }} +PW = {{ description = "password", providers = ["password"] }} +CERT = {{ description = "cert", type = "x509_certificate", from = "ID" }} +"#, + source.display(), + cache.display() + )); + config.validate().unwrap(); + let secrets = Secrets::new(config, None, None, None); + secrets.resolve_named_owned("CERT").unwrap(); + let crate::secrets::OwnedNamedResolution::Value { + refresh_at_unix_ms: password_refresh, + .. + } = secrets.resolve_named_owned("PW").unwrap() + else { + panic!("the cached password must resolve to an inline value"); + }; + assert!(password_refresh.is_some()); + for name in ["ID", "CERT"] { + let crate::secrets::OwnedNamedResolution::Value { + refresh_at_unix_ms, .. + } = secrets.resolve_named_owned(name).unwrap() + else { + panic!("{name} must resolve to an inline value"); + }; + assert_eq!( + refresh_at_unix_ms, password_refresh, + "{name} must preserve its hidden password refresh deadline" + ); + } +} From a39678f83362ed3a79d3125e10cdf23da1979f45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Sat, 10 Oct 2026 10:03:10 +0100 Subject: [PATCH 4/9] refactor: replace OpenSSL X.509 backend with Rust libraries --- .github/workflows/haskell-build.yml | 6 +- CHANGELOG.md | 4 + Cargo.lock | 504 +++++++-- Cargo.toml | 8 +- RELEASE.md | 20 + .../content/docs/reference/configuration.mdx | 8 +- secretspec/Cargo.toml | 7 +- secretspec/src/native.rs | 23 +- secretspec/src/typed.rs | 11 + secretspec/src/typed_tests.rs | 81 +- secretspec/src/x509_identity.rs | 1006 +++++++++-------- secretspec/tests/fixtures/x509/README.md | 16 + .../tests/fixtures/x509/bmp-password.pfx | Bin 0 -> 1178 bytes secretspec/tests/fixtures/x509/ed25519.der | Bin 0 -> 363 bytes secretspec/tests/fixtures/x509/ed25519.pfx | Bin 0 -> 1032 bytes secretspec/tests/fixtures/x509/generate.py | 52 + .../tests/fixtures/x509/legacy-3des.pfx | Bin 0 -> 1020 bytes secretspec/tests/fixtures/x509/p256.der | Bin 0 -> 427 bytes secretspec/tests/fixtures/x509/p256.pfx | Bin 0 -> 1178 bytes secretspec/tests/fixtures/x509/p384.der | Bin 0 -> 489 bytes secretspec/tests/fixtures/x509/p384.pfx | Bin 0 -> 1292 bytes secretspec/tests/fixtures/x509/p521.der | Bin 0 -> 563 bytes secretspec/tests/fixtures/x509/p521.pfx | Bin 0 -> 1421 bytes secretspec/tests/fixtures/x509/rsa.der | Bin 0 -> 823 bytes secretspec/tests/fixtures/x509/rsa.pfx | Bin 0 -> 2653 bytes .../fixtures/x509/supplementary-password.pfx | Bin 0 -> 1178 bytes 26 files changed, 1116 insertions(+), 630 deletions(-) create mode 100644 secretspec/tests/fixtures/x509/README.md create mode 100644 secretspec/tests/fixtures/x509/bmp-password.pfx create mode 100644 secretspec/tests/fixtures/x509/ed25519.der create mode 100644 secretspec/tests/fixtures/x509/ed25519.pfx create mode 100644 secretspec/tests/fixtures/x509/generate.py create mode 100644 secretspec/tests/fixtures/x509/legacy-3des.pfx create mode 100644 secretspec/tests/fixtures/x509/p256.der create mode 100644 secretspec/tests/fixtures/x509/p256.pfx create mode 100644 secretspec/tests/fixtures/x509/p384.der create mode 100644 secretspec/tests/fixtures/x509/p384.pfx create mode 100644 secretspec/tests/fixtures/x509/p521.der create mode 100644 secretspec/tests/fixtures/x509/p521.pfx create mode 100644 secretspec/tests/fixtures/x509/rsa.der create mode 100644 secretspec/tests/fixtures/x509/rsa.pfx create mode 100644 secretspec/tests/fixtures/x509/supplementary-password.pfx diff --git a/.github/workflows/haskell-build.yml b/.github/workflows/haskell-build.yml index 1add242c8..19d57cd7f 100644 --- a/.github/workflows/haskell-build.yml +++ b/.github/workflows/haskell-build.yml @@ -125,7 +125,6 @@ jobs: - 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. - # Vendored OpenSSL requires a native Windows Perl for an MSVC target. - 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 @@ -135,7 +134,7 @@ jobs: with: msystem: MINGW64 path-type: inherit - install: mingw-w64-x86_64-gcc mingw-w64-x86_64-binutils make perl + install: mingw-w64-x86_64-gcc mingw-w64-x86_64-binutils - name: Install GHC and cabal uses: haskell-actions/setup@0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d # v2.12.1 with: @@ -144,9 +143,6 @@ jobs: shell: msys2 {0} run: | set -euo pipefail - # OpenSSL's MinGW build needs Unix paths from MSYS2 Perl. Select it - # explicitly because the inherited PATH also contains Windows Perl. - export OPENSSL_SRC_PERL="$(cygpath -w /usr/bin/perl.exe)" # The archive for GHC's MinGW linker; --crate-type overrides the # crate's list so the unused cdylib is never linked. Release like # the shipped artifacts: a debug archive carries sectionless .dwo diff --git a/CHANGELOG.md b/CHANGELOG.md index 717910323..4b530d591 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- X.509 identity generation, validation, and PKCS#12 conversion no longer + 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. diff --git a/Cargo.lock b/Cargo.lock index 204d88c25..24c2d548a 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" @@ -1053,7 +1124,7 @@ 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]] @@ -1062,6 +1133,15 @@ 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" @@ -1396,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", @@ -1538,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" @@ -1815,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" @@ -1859,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", @@ -1975,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" @@ -2052,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" @@ -2127,7 +2265,7 @@ dependencies = [ "digest 0.10.7", "num-bigint-dig", "num-traits", - "pkcs8", + "pkcs8 0.10.2", "rfc6979", "sha2 0.10.9", "signature", @@ -2152,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", ] @@ -2170,7 +2308,7 @@ dependencies = [ "elliptic-curve", "rfc6979", "signature", - "spki", + "spki 0.7.3", ] [[package]] @@ -2179,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", ] @@ -2219,7 +2357,7 @@ dependencies = [ "group", "hkdf", "pem-rfc7468 0.7.0", - "pkcs8", + "pkcs8 0.10.2", "rand_core 0.6.4", "sec1", "serde_json", @@ -2459,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" @@ -2752,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]] @@ -2774,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", @@ -2800,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", @@ -2932,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", @@ -3105,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", @@ -3302,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", @@ -3907,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", @@ -3929,7 +4082,7 @@ 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", @@ -3963,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", @@ -4038,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", @@ -4053,7 +4206,7 @@ dependencies = [ "jsonpath-rust", "k8s-openapi", "kube-core", - "pem", + "pem 3.0.6", "rustls 0.23.37", "rustls-platform-verifier", "secrecy", @@ -4581,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" @@ -4636,15 +4798,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "openssl-src" -version = "300.6.1+3.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" -dependencies = [ - "cc", -] - [[package]] name = "openssl-sys" version = "0.9.117" @@ -4653,7 +4806,6 @@ checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", - "openssl-src", "pkg-config", "vcpkg", ] @@ -4715,6 +4867,28 @@ version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" +[[package]] +name = "p12-keystore" +version = "0.3.2" +source = "git+https://github.com/ancwrd1/p12-keystore?rev=baae350b8b5a144a37a3373d592cf91a5b0e9f4b#baae350b8b5a144a37a3373d592cf91a5b0e9f4b" +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" @@ -4819,7 +4993,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", ] @@ -4895,12 +5079,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", @@ -4917,7 +5101,7 @@ dependencies = [ "cx448", "derive_builder", "derive_more", - "des", + "des 0.8.1", "digest 0.10.7", "dsa", "eax", @@ -5008,8 +5192,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]] @@ -5019,7 +5234,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]] @@ -5050,7 +5275,7 @@ checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ "cpufeatures 0.2.17", "opaque-debug", - "universal-hash", + "universal-hash 0.5.1", ] [[package]] @@ -5062,7 +5287,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]] @@ -5176,7 +5412,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", @@ -5440,6 +5676,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" @@ -5533,7 +5792,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", @@ -5571,7 +5830,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", @@ -5631,7 +5890,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -5656,11 +5915,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", ] @@ -5671,7 +5930,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", @@ -5743,6 +6002,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" @@ -5840,7 +6108,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" dependencies = [ "ring", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -5852,7 +6120,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -5970,6 +6238,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" @@ -5977,7 +6257,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" dependencies = [ "ring", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -5989,7 +6269,7 @@ dependencies = [ "base16ct", "der 0.7.10", "generic-array", - "pkcs8", + "pkcs8 0.10.2", "serdect 0.2.0", "subtle", "zeroize", @@ -6031,12 +6311,13 @@ 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", @@ -6065,13 +6346,15 @@ dependencies = [ "libc", "linkme", "miette", - "openssl", + "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", @@ -6088,6 +6371,7 @@ dependencies = [ "ssh-key", "tempfile", "thiserror 2.0.18", + "time", "tokio", "toml 1.1.4+spec-1.1.0", "toml_edit", @@ -6097,6 +6381,7 @@ dependencies = [ "whoami", "windows-sys 0.61.2", "x25519-dalek", + "x509-parser", ] [[package]] @@ -6231,7 +6516,7 @@ checksum = "7bd22781911de0ca6debda95f073c8f18bec65d1a94f1fa9573f3102e514cea4" dependencies = [ "ahash", "annotate-snippets", - "base64", + "base64 0.22.1", "encoding_rs_io", "getrandom 0.3.4", "granit-parser", @@ -6335,7 +6620,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", @@ -6599,6 +6884,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" @@ -7092,7 +7387,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", @@ -7139,7 +7434,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", @@ -7268,7 +7563,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", @@ -7283,7 +7578,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", @@ -7413,6 +7708,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" @@ -7425,7 +7736,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", @@ -7443,7 +7754,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", @@ -8161,12 +8472,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 8eb16a356..37977be5c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -95,7 +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" } -openssl = { version = "0.10.81", features = ["vendored"] } +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" +# Replace this Git pin with the crates.io release containing p12-keystore#13. +p12-keystore = { version = "0.3.2", git = "https://github.com/ancwrd1/p12-keystore", rev = "baae350b8b5a144a37a3373d592cf91a5b0e9f4b" } # The profile that 'dist' will build with [profile.dist] diff --git a/RELEASE.md b/RELEASE.md index e1399cdd2..b5ab6664f 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -33,6 +33,26 @@ 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 temporarily pins `p12-keystore` to Git commit +`baae350b8b5a144a37a3373d592cf91a5b0e9f4b`, which contains the merged +[bag-preserving archive API](https://github.com/ancwrd1/p12-keystore/pull/13). +Before publishing 0.22: + +1. Replace the `p12-keystore` workspace dependency with the crates.io version + containing that API, removing `git` and `rev`, and update `Cargo.lock`. +2. Run the Rust tests, SDK CI, and `cargo package -p secretspec` with the + published dependencies. Package verification cannot use the Git-only API + after Cargo rewrites a Git dependency to its registry version. +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/src/content/docs/reference/configuration.mdx b/docs/src/content/docs/reference/configuration.mdx index 6125d2b72..365696fa6 100644 --- a/docs/src/content/docs/reference/configuration.mdx +++ b/docs/src/content/docs/reference/configuration.mdx @@ -1077,7 +1077,8 @@ rather than a security boundary. `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 are validated once: the archive must be between 1 byte and +- Stored identities support RSA, P-256, P-384, P-521, and Ed25519 private keys. + 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 @@ -1086,7 +1087,10 @@ rather than a security boundary. 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". + 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 diff --git a/secretspec/Cargo.toml b/secretspec/Cargo.toml index 1dc22f7b3..bab0153fb 100644 --- a/secretspec/Cargo.toml +++ b/secretspec/Cargo.toml @@ -89,7 +89,12 @@ age = { workspace = true, optional = true } kube = { workspace = true, optional = true } k8s-openapi = { workspace = true, optional = true } json-patch = { workspace = true, optional = true } -openssl.workspace = 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/src/native.rs b/secretspec/src/native.rs index ad6a28dd9..53e9bfc7c 100644 --- a/secretspec/src/native.rs +++ b/secretspec/src/native.rs @@ -759,9 +759,9 @@ mod tests { #[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 openssl::pkcs12::Pkcs12; - use openssl::x509::X509; + use p12_keystore::Pkcs12Archive; let _env = crate::tests::scrub_resolution_env(); let dir = tempfile::TempDir::new().unwrap(); @@ -808,22 +808,17 @@ mod tests { let secrets = response["response"]["secrets"].as_object().unwrap(); assert_eq!(secrets.len(), 2); let certificate = - X509::from_pem(secrets["CERT"]["value"].as_str().unwrap().as_bytes()).unwrap(); + 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(); - let archive = Pkcs12::from_der(&archive).unwrap(); - assert!(archive.parse2("").is_err()); - let identity = archive.parse2("archive-password").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!( - certificate.to_der().unwrap(), - identity.cert.unwrap().to_der().unwrap() - ); - assert!( - identity - .pkey - .unwrap() - .public_eq(&certificate.public_key().unwrap()) + key.public_key_raw(), + parsed.public_key().subject_public_key.data.as_ref() ); } } diff --git a/secretspec/src/typed.rs b/secretspec/src/typed.rs index f8f6d1707..7c95a0a48 100644 --- a/secretspec/src/typed.rs +++ b/secretspec/src/typed.rs @@ -245,6 +245,11 @@ pub(crate) fn validate_credential_value(role: &str, value: &str) -> Result<(), S "credential `{role}` exceeds {MAX_CREDENTIAL_BYTES} bytes" )); } + if value.chars().any(|character| u32::from(character) > 0xffff) { + return Err(format!( + "credential `{role}` contains a supplementary Unicode character; PKCS#12 passwords currently support only the Unicode Basic Multilingual Plane" + )); + } Ok(()) } @@ -317,6 +322,12 @@ mod tests { #[test] fn credential_values_are_bounded() { assert!(validate_credential_value("password", "correct horse").is_ok()); + assert!(validate_credential_value("password", "päss漢字").is_ok()); + assert!( + validate_credential_value("password", "päss🔑") + .unwrap_err() + .contains("supplementary Unicode") + ); let empty = validate_credential_value("password", "").unwrap_err(); assert!(empty.contains("empty"), "{empty}"); let nul = validate_credential_value("password", "a\0b").unwrap_err(); diff --git a/secretspec/src/typed_tests.rs b/secretspec/src/typed_tests.rs index d6abc0ac0..f458703c1 100644 --- a/secretspec/src/typed_tests.rs +++ b/secretspec/src/typed_tests.rs @@ -7,11 +7,11 @@ use crate::report::{Derivation, ResolutionStatus}; use crate::resolve::ResolvedSource; use crate::tests::scrub_resolution_env; use crate::x509_identity::Identity; +use crate::x509_identity::test_support::{cert_from_pem, key_from_pem, open_pfx}; use crate::{Format, SecretSpecError, Secrets}; use data_encoding::{BASE64, HEXLOWER}; -use openssl::pkcs12::Pkcs12; -use openssl::pkey::PKey; -use openssl::x509::X509; +use p12_keystore::Pkcs12Archive; +use rcgen::KeyPair; use secrecy::ExposeSecret; use std::fs; use tempfile::TempDir; @@ -70,13 +70,6 @@ fn null_spec(body: &str) -> Secrets { Secrets::new(manifest(body), None, Some("null://".to_string()), None) } -fn open_pfx(bytes: &[u8], password: &str) -> openssl::pkcs12::ParsedPkcs12_2 { - Pkcs12::from_der(bytes) - .unwrap() - .parse2(password) - .unwrap_or_else(|error| panic!("PFX must open with {password:?}: {error}")) -} - // --------------------------------------------------------------------------- // Configuration // --------------------------------------------------------------------------- @@ -786,8 +779,8 @@ fn generated_identity_derives_every_target_in_one_resolve() { .decode(secrets["TLS_IDENTITY"].value.as_deref().unwrap().as_bytes()) .unwrap(); let identity = open_pfx(&identity_bytes, ""); - let key = identity.pkey.as_ref().unwrap(); - let certificate = identity.cert.as_ref().unwrap(); + let key = &identity.0; + let certificate = &identity.1; assert_eq!(secrets["TLS_IDENTITY"].source, ResolvedSource::Generated); // The protected archive opens only with the generated password and holds @@ -802,13 +795,10 @@ fn generated_identity_derives_every_target_in_one_resolve() { assert!(secrets["TLS_PFX"].as_path); let pfx_bytes = fs::read(pfx_path).unwrap(); let protected = open_pfx(&pfx_bytes, password); - assert!(protected.pkey.as_ref().unwrap().public_eq(key)); - assert_eq!( - protected.cert.as_ref().unwrap().to_der().unwrap(), - certificate.to_der().unwrap() - ); + assert!(protected.0.public_key_raw() == key.public_key_raw()); + assert_eq!(protected.1.as_der().to_vec(), certificate.as_der().to_vec()); assert!( - Pkcs12::from_der(&pfx_bytes).unwrap().parse2("").is_err(), + Pkcs12Archive::from_pkcs12(&pfx_bytes, "").is_err(), "protected archive must not open with an empty password" ); #[cfg(unix)] @@ -822,25 +812,21 @@ fn generated_identity_derives_every_target_in_one_resolve() { let inline_pfx = BASE64 .decode(secrets["TLS_PFX_B64"].value.as_deref().unwrap().as_bytes()) .unwrap(); - assert!(open_pfx(&inline_pfx, password).pkey.unwrap().public_eq(key)); + assert!(open_pfx(&inline_pfx, password).0.public_key_raw() == key.public_key_raw()); let hex_pfx = HEXLOWER .decode(secrets["TLS_PFX_HEX"].value.as_deref().unwrap().as_bytes()) .unwrap(); - assert!(open_pfx(&hex_pfx, "").pkey.unwrap().public_eq(key)); + assert!(open_pfx(&hex_pfx, "").0.public_key_raw() == key.public_key_raw()); assert!(!secrets["TLS_PFX_B64"].as_path); // Key projections. let pem_key = secrets["TLS_KEY"].value.as_deref().unwrap(); assert!(pem_key.starts_with("-----BEGIN PRIVATE KEY-----")); - assert!( - PKey::private_key_from_pem(pem_key.as_bytes()) - .unwrap() - .public_eq(key) - ); + assert!(key_from_pem(pem_key.as_bytes()).unwrap().public_key_raw() == key.public_key_raw()); let der_path = secrets["TLS_KEY_DER"].path.as_deref().unwrap(); assert!(der_path.ends_with(".der"), "{der_path}"); - let der_key = PKey::private_key_from_pkcs8(&fs::read(der_path).unwrap()).unwrap(); - assert!(der_key.public_eq(key)); + let der_key = KeyPair::try_from(fs::read(der_path).unwrap().as_slice()).unwrap(); + assert!(der_key.public_key_raw() == key.public_key_raw()); let inline_der = BASE64 .decode( secrets["TLS_KEY_DER_B64"] @@ -851,9 +837,10 @@ fn generated_identity_derives_every_target_in_one_resolve() { ) .unwrap(); assert!( - PKey::private_key_from_pkcs8(&inline_der) + KeyPair::try_from(inline_der.as_slice()) .unwrap() - .public_eq(key) + .public_key_raw() + == key.public_key_raw() ); // Certificate projections. @@ -861,14 +848,14 @@ fn generated_identity_derives_every_target_in_one_resolve() { assert!(cert_path.ends_with(".pem"), "{cert_path}"); let cert_pem = fs::read(cert_path).unwrap(); assert_eq!( - X509::from_pem(&cert_pem).unwrap().to_der().unwrap(), - certificate.to_der().unwrap() + cert_from_pem(&cert_pem).unwrap().as_der().to_vec(), + certificate.as_der().to_vec() ); let cert_der_path = secrets["TLS_CERTIFICATE_DER"].path.as_deref().unwrap(); assert!(cert_der_path.ends_with(".der"), "{cert_der_path}"); assert_eq!( fs::read(cert_der_path).unwrap(), - certificate.to_der().unwrap() + certificate.as_der().to_vec() ); assert_eq!( secrets["TLS_CHAIN"].value.as_deref().unwrap().as_bytes(), @@ -929,14 +916,12 @@ SERVICE_CERTIFICATE = { description = "cert", type = "x509_certificate_chain", f assert_eq!(secrets["SERVICE_IDENTITY"].source, ResolvedSource::Provider); let expected = open_pfx(&archive, "correct horse battery staple"); - let key = - PKey::private_key_from_pem(secrets["SERVICE_KEY"].value.as_deref().unwrap().as_bytes()) - .unwrap(); - assert!(expected.pkey.unwrap().public_eq(&key)); + let key = key_from_pem(secrets["SERVICE_KEY"].value.as_deref().unwrap().as_bytes()).unwrap(); + assert!(expected.0.public_key_raw() == key.public_key_raw()); let chain = secrets["SERVICE_CERTIFICATE"].value.as_deref().unwrap(); assert_eq!( - X509::from_pem(chain.as_bytes()).unwrap().to_der().unwrap(), - expected.cert.unwrap().to_der().unwrap() + cert_from_pem(chain.as_bytes()).unwrap().as_der().to_vec(), + expected.1.as_der().to_vec() ); assert_eq!(secrets["SERVICE_KEY"].source, ResolvedSource::Composed); } @@ -1065,6 +1050,7 @@ fn empty_nul_and_oversized_credential_values_are_rejected() { let cases = [ ("", "empty"), ("with\u{0}nul", "NUL"), + ("with🔑emoji", "supplementary Unicode"), (&"x".repeat(1025), "exceeds 1024 bytes"), ]; for (password, expected) in cases { @@ -1136,7 +1122,7 @@ fn scopes_fetch_hidden_sources_and_credentials_without_exposing_them() { let response = spec.resolve().unwrap(); assert!(response.is_ok()); assert_eq!(response.secrets.keys().collect::>(), vec!["TLS_KEY"]); - PKey::private_key_from_pem( + key_from_pem( response.secrets["TLS_KEY"] .value .as_deref() @@ -1173,7 +1159,7 @@ PART_A = { description = "a", default = "alpha" } ) .unwrap(); let opened = open_pfx(&pfx, "alpha-beta"); - let key = PKey::private_key_from_pem( + let key = key_from_pem( response.secrets["TLS_KEY"] .value .as_deref() @@ -1181,7 +1167,7 @@ PART_A = { description = "a", default = "alpha" } .as_bytes(), ) .unwrap(); - assert!(opened.pkey.unwrap().public_eq(&key)); + assert!(opened.0.public_key_raw() == key.public_key_raw()); } #[test] @@ -1257,15 +1243,10 @@ NEW_PFX = { description = "rewrapped", type = "pkcs12", from = "LEGACY_IDENTITY" let response = spec.resolve().unwrap(); assert!(response.is_ok(), "{:?}", response.missing_required); let rewrapped = fs::read(response.secrets["NEW_PFX"].path.as_deref().unwrap()).unwrap(); - assert!( - Pkcs12::from_der(&rewrapped) - .unwrap() - .parse2("legacy") - .is_err() - ); + assert!(Pkcs12Archive::from_pkcs12(&rewrapped, "legacy").is_err()); let opened = open_pfx(&rewrapped, "fresh"); let original = open_pfx(&legacy, "legacy"); - assert!(opened.pkey.unwrap().public_eq(&original.pkey.unwrap())); + assert!(opened.0.public_key_raw() == original.0.public_key_raw()); assert_ne!(rewrapped, legacy, "a rewrapped archive is re-encrypted"); } @@ -1536,7 +1517,7 @@ UNRELATED = { description = "must not be resolved" } panic!("expected an owned certificate file") }; let path = file.path().to_owned(); - X509::from_pem(&fs::read(&path).unwrap()).unwrap(); + cert_from_pem(&fs::read(&path).unwrap()).unwrap(); assert_eq!(path.extension().unwrap(), "pem"); drop(file); assert!(!path.exists()); @@ -1568,7 +1549,7 @@ CERT = { description = "certificate", type = "x509_certificate", from = "IDENTIT ); config.validate().unwrap(); let result = Secrets::new(config, None, None, None).resolve().unwrap(); - X509::from_pem(result.secrets["CERT"].value.as_ref().unwrap().as_bytes()).unwrap(); + cert_from_pem(result.secrets["CERT"].value.as_ref().unwrap().as_bytes()).unwrap(); } #[cfg(feature = "cli")] diff --git a/secretspec/src/x509_identity.rs b/secretspec/src/x509_identity.rs index b35d739ce..87ebdc526 100644 --- a/secretspec/src/x509_identity.rs +++ b/secretspec/src/x509_identity.rs @@ -1,45 +1,29 @@ -//! X.509 identity generation, PKCS#12 decoding, and typed projections. -//! -//! An `x509_identity` is canonically a PKCS#12 archive holding one private key, -//! its leaf certificate, and an optional issuer chain. This module generates -//! such archives, opens stored ones (with the configured password or an empty -//! one, never guessing), validates them strictly, and projects them into the -//! `pkcs12`, `pkcs8_private_key`, `x509_certificate`, `x509_certificate_chain`, -//! and `x509_issuer_chain` targets of the type registry. +//! X.509 identity generation, bag-preserving PKCS#12 decoding, and projections. +//! Rust certificate and archive APIs share the application's AWS-LC backend. use crate::SecretSpecError; use crate::config::{GenerateConfig, GenerateOptions}; use crate::typed::{Format, Projection}; -use openssl::asn1::{Asn1Integer, Asn1Time}; -use openssl::bn::{BigNum, MsbOption}; -use openssl::ec::{EcGroup, EcKey}; -use openssl::error::ErrorStack; -use openssl::hash::MessageDigest; -use openssl::nid::Nid; -use openssl::pkcs12::Pkcs12; -use openssl::pkey::{PKey, Private}; -use openssl::stack::Stack; -use openssl::x509::extension::{ - BasicConstraints, ExtendedKeyUsage, KeyUsage, SubjectAlternativeName, SubjectKeyIdentifier, +use p12_keystore::{ + Certificate, EncryptionAlgorithm, KeyStore, KeyStoreEntry, MacAlgorithm, Pkcs12Archive, + PrivateKey, PrivateKeyChain, +}; +use rcgen::{ + CertificateParams, DistinguishedName, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair, + KeyUsagePurpose, PublicKeyData, SanType, }; -use openssl::x509::{X509, X509NameBuilder}; use secrecy::zeroize::Zeroizing; -use secrecy::{SecretSlice, SecretString}; -use std::cmp::Ordering; +use secrecy::{ExposeSecret, SecretSlice, SecretString}; use std::net::IpAddr; -use std::time::{SystemTime, UNIX_EPOCH}; +use time::{Duration, OffsetDateTime}; +use x509_parser::prelude::{FromDer, X509Certificate}; const MAX_IDENTITY_BYTES: usize = 10 * 1024 * 1024; const MAX_CHAIN_CERTIFICATES: usize = 16; -// Current CA/Browser Forum TLS Baseline Requirements cap publicly trusted -// subscriber certificates at 200 days (effective 2026-03-15). Self-signed -// development certificates are outside that policy, but using the same ceiling -// keeps generated lifetimes conservative. const MAX_VALID_DAYS: u32 = 200; const PFX_ITERATIONS: u32 = 100_000; const FRIENDLY_NAME: &str = "SecretSpec X.509 identity"; -/// A value projected from an identity: PEM text or DER/PFX bytes. pub(crate) enum ProjectedValue { Text(SecretString), Binary(SecretSlice), @@ -57,17 +41,6 @@ fn decode_failed(name: &str, error: impl std::fmt::Display) -> SecretSpecError { } } -/// The first reason in an OpenSSL error stack, without the library, function, -/// and file context of the full stack, which can echo caller-supplied data. -fn short_reason(error: &ErrorStack) -> String { - error - .errors() - .first() - .and_then(|error| error.reason()) - .unwrap_or("malformed data") - .to_string() -} - pub(crate) fn parse_valid_days(value: Option<&str>) -> Result { let value = value.unwrap_or("30d"); let days = value @@ -131,37 +104,31 @@ fn options(config: &GenerateConfig) -> Result<&GenerateOptions, SecretSpecError> } } -/// Serialize an identity as a PKCS#12 archive using one explicit modern -/// profile: PBES2/PBKDF2 with HMAC-SHA-256, AES-256-CBC for key and -/// certificate privacy, and HMAC-SHA-256 integrity, each with a fixed high -/// iteration count. Never RC2, 3DES, or SHA-1, whatever OpenSSL's platform -/// default is. An empty password yields an interchange container, not a -/// security boundary; the caller decides which it needs. +/// Emit the same modern encryption and integrity profile on every platform. fn build_archive( - key: &PKey, - certificate: &X509, - chain: &[X509], + key: &[u8], + certificate: &Certificate, + chain: &[Certificate], password: &str, -) -> Result, ErrorStack> { - let mut builder = Pkcs12::builder(); - builder - .name(FRIENDLY_NAME) - .pkey(key) - .cert(certificate) - .key_algorithm(Nid::AES_256_CBC) - .cert_algorithm(Nid::AES_256_CBC) - .key_iter(PFX_ITERATIONS) - .mac_iter(PFX_ITERATIONS) - .mac_md(MessageDigest::sha256()); - if !chain.is_empty() { - let mut issuers = Stack::new()?; - for issuer in chain { - issuers.push(issuer.clone())?; - } - builder.ca(issuers); - } - let archive = builder.build2(password)?; - let der = Zeroizing::new(archive.to_der()?); +) -> Result, p12_keystore::error::Error> { + let mut store = KeyStore::new(); + store.add_entry( + FRIENDLY_NAME, + KeyStoreEntry::PrivateKeyChain(PrivateKeyChain::new( + "identity", + PrivateKey::from_der(key)?, + std::iter::once(certificate.clone()).chain(chain.iter().cloned()), + )), + ); + let der = Zeroizing::new( + store + .writer(password) + .encryption_algorithm(EncryptionAlgorithm::PbeWithHmacSha256AndAes256) + .encryption_iterations(PFX_ITERATIONS) + .mac_algorithm(MacAlgorithm::HmacSha256) + .mac_iterations(PFX_ITERATIONS) + .write()?, + ); Ok(der.as_slice().to_vec().into()) } @@ -169,145 +136,84 @@ pub(crate) fn generate(config: &GenerateConfig) -> crate::Result let options = options(config)?; let valid_days = parse_valid_days(options.valid_for.as_deref()) .map_err(SecretSpecError::GenerationFailed)?; - let sans = options.san.as_deref().ok_or_else(|| { - SecretSpecError::GenerationFailed("X.509 generation requires generate.san".to_string()) - })?; + let sans = options + .san + .as_deref() + .filter(|sans| !sans.is_empty()) + .ok_or_else(|| { + SecretSpecError::GenerationFailed("X.509 generation requires generate.san".into()) + })?; + let mut params = CertificateParams::default(); for san in sans { validate_san(san).map_err(SecretSpecError::GenerationFailed)?; + params + .subject_alt_names + .push(if let Some(dns) = san.strip_prefix("dns:") { + SanType::DnsName( + dns.try_into() + .map_err(|error| generation_failed("invalid DNS SAN", error))?, + ) + } else { + SanType::IpAddress( + san.strip_prefix("ip:") + .expect("validated IP SAN") + .parse() + .expect("validated IP address"), + ) + }); } - - let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1) - .map_err(|error| generation_failed("failed to select P-256", error))?; - let ec_key = EcKey::generate(&group) - .map_err(|error| generation_failed("failed to generate P-256 private key", error))?; - let private_key = PKey::from_ec_key(ec_key) - .map_err(|error| generation_failed("failed to prepare P-256 private key", error))?; - let common_name = sans .iter() .find_map(|san| san.strip_prefix("dns:")) .filter(|name| name.len() <= 64) .unwrap_or("SecretSpec generated identity"); - let mut subject = X509NameBuilder::new() - .map_err(|error| generation_failed("failed to create X.509 subject", error))?; - subject - .append_entry_by_nid(Nid::COMMONNAME, common_name) - .map_err(|error| generation_failed("failed to set X.509 common name", error))?; - let subject = subject.build(); - - let mut serial = BigNum::new() - .map_err(|error| generation_failed("failed to allocate X.509 serial", error))?; - serial - .rand(128, MsbOption::ONE, false) - .map_err(|error| generation_failed("failed to generate X.509 serial", error))?; - let serial = Asn1Integer::from_bn(&serial) - .map_err(|error| generation_failed("failed to encode X.509 serial", error))?; - - let mut certificate = X509::builder() - .map_err(|error| generation_failed("failed to create X.509 certificate", error))?; - certificate - .set_version(2) - .and_then(|_| certificate.set_serial_number(&serial)) - .and_then(|_| certificate.set_subject_name(&subject)) - .and_then(|_| certificate.set_issuer_name(&subject)) - .and_then(|_| certificate.set_pubkey(&private_key)) - .map_err(|error| generation_failed("failed to initialize X.509 certificate", error))?; - // A small backdate tolerates clock skew between the generating machine and - // a local TLS peer without extending the requested total validity period. - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|error| generation_failed("system clock is before the Unix epoch", error))? - .as_secs(); - let not_before_unix = now.saturating_sub(300); - let not_after_unix = not_before_unix + u64::from(valid_days) * 86_400; - let not_before = Asn1Time::from_unix(not_before_unix as i64) - .map_err(|error| generation_failed("failed to set X.509 start time", error))?; - let not_after = Asn1Time::from_unix(not_after_unix as i64) - .map_err(|error| generation_failed("failed to set X.509 expiry", error))?; - certificate - .set_not_before(¬_before) - .and_then(|_| certificate.set_not_after(¬_after)) - .map_err(|error| generation_failed("failed to set X.509 validity", error))?; - - let basic_constraints = BasicConstraints::new() - .critical() - .build() - .map_err(|error| generation_failed("failed to build basic constraints", error))?; - let key_usage = KeyUsage::new() - .critical() - .digital_signature() - .build() - .map_err(|error| generation_failed("failed to build key usage", error))?; - certificate - .append_extension(basic_constraints) - .and_then(|_| certificate.append_extension(key_usage)) - .map_err(|error| generation_failed("failed to add X.509 key constraints", error))?; - + params.distinguished_name = DistinguishedName::new(); + params + .distinguished_name + .push(DnType::CommonName, common_name); + params.is_ca = IsCa::ExplicitNoCa; + params.key_usages = vec![KeyUsagePurpose::DigitalSignature]; let default_usages = ["server_auth".to_string()]; - let usages = options.usages.as_deref().unwrap_or(&default_usages); - let mut extended = ExtendedKeyUsage::new(); - for usage in usages { - match usage.as_str() { - "server_auth" => { - extended.server_auth(); - } - "client_auth" => { - extended.client_auth(); + for usage in options.usages.as_deref().unwrap_or(&default_usages) { + params.extended_key_usages.push(match usage.as_str() { + "server_auth" => ExtendedKeyUsagePurpose::ServerAuth, + "client_auth" => ExtendedKeyUsagePurpose::ClientAuth, + _ => { + return Err(SecretSpecError::GenerationFailed( + "invalid X.509 extended key usage".into(), + )); } - _ => unreachable!("generation options are validated before generation"), - } + }); } - certificate - .append_extension( - extended - .build() - .map_err(|error| generation_failed("failed to build extended key usage", error))?, - ) - .map_err(|error| generation_failed("failed to add extended key usage", error))?; - - let mut san_extension = SubjectAlternativeName::new(); - for san in sans { - if let Some(dns) = san.strip_prefix("dns:") { - san_extension.dns(dns); - } else if let Some(ip) = san.strip_prefix("ip:") { - san_extension.ip(ip); - } - } - { - let context = certificate.x509v3_context(None, None); - let san = san_extension.build(&context).map_err(|error| { - generation_failed("failed to build subject alternative names", error) - })?; - let subject_key = SubjectKeyIdentifier::new() - .build(&context) - .map_err(|error| generation_failed("failed to build subject key identifier", error))?; - certificate - .append_extension(san) - .and_then(|_| certificate.append_extension(subject_key)) - .map_err(|error| generation_failed("failed to add X.509 extensions", error))?; - } - certificate - .sign(&private_key, MessageDigest::sha256()) + let mut serial = [0u8; 16]; + use rand_08::RngCore; + rand_08::rngs::OsRng + .try_fill_bytes(&mut serial) + .map_err(|error| generation_failed("failed to generate X.509 serial", error))?; + serial[0] |= 0x80; + params.serial_number = Some(rcgen::SerialNumber::from_slice(&serial)); + params.not_before = OffsetDateTime::now_utc() - Duration::seconds(300); + params.not_after = params.not_before + Duration::days(i64::from(valid_days)); + let key = KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256) + .map_err(|error| generation_failed("failed to generate P-256 private key", error))?; + let generated = params + .self_signed(&key) .map_err(|error| generation_failed("failed to sign X.509 certificate", error))?; - let certificate = certificate.build(); - - // The canonical stored archive uses an empty password on purpose: the - // provider protects the identity at rest, and a consumer that needs a - // protected PFX derives one with `type = "pkcs12"` and its own password. - build_archive(&private_key, &certificate, &[], "") + let certificate = Certificate::from_der(generated.der()) + .map_err(|error| generation_failed("failed to encode X.509 certificate", error))?; + // Empty-password storage relies on the provider's protection. Consumers + // derive a separately protected PKCS#12 archive when they need one. + build_archive(key.serialized_der(), &certificate, &[], "") .map_err(|error| generation_failed("failed to build PKCS#12 identity", error)) } -/// A decoded and validated identity: the private key, its leaf certificate, -/// and the issuer chain in leaf-to-root order. pub(crate) struct Identity { - key: PKey, - certificate: X509, - chain: Vec, + key: SecretSlice, + certificate: Certificate, + chain: Vec, } impl std::fmt::Debug for Identity { - /// Never prints key or certificate material. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Identity") .field("chain_len", &self.chain.len()) @@ -315,11 +221,85 @@ impl std::fmt::Debug for Identity { } } +/// x509-parser's dispatcher omits P-521 and some SHA-512 combinations. +/// Verify those with the same AWS-LC backend, without accepting a failed +/// signature under a different algorithm or ignoring its named curve. +fn signature_valid( + certificate: &X509Certificate<'_>, + issuer: Option<&x509_parser::x509::SubjectPublicKeyInfo<'_>>, +) -> bool { + use aws_lc_rs::signature; + if certificate.signature_algorithm != certificate.tbs_certificate.signature { + return false; + } + match certificate.verify_signature(issuer) { + Ok(()) => return true, + Err(x509_parser::error::X509Error::SignatureUnsupportedAlgorithm) => {} + Err(_) => return false, + } + let public_key = issuer.unwrap_or_else(|| certificate.public_key()); + if public_key.algorithm.algorithm.to_id_string() != "1.2.840.10045.2.1" { + return false; + } + let Some(curve) = public_key + .algorithm + .parameters + .as_ref() + .and_then(|value| value.as_oid().ok()) + else { + return false; + }; + let algorithm: &dyn signature::VerificationAlgorithm = match ( + curve.to_id_string().as_str(), + certificate + .signature_algorithm + .algorithm + .to_id_string() + .as_str(), + ) { + ("1.2.840.10045.3.1.7", "1.2.840.10045.4.1") => &signature::ECDSA_P256_SHA1_ASN1, + ("1.2.840.10045.3.1.7", "1.2.840.10045.4.3.4") => &signature::ECDSA_P256_SHA512_ASN1, + ("1.3.132.0.34", "1.2.840.10045.4.3.4") => &signature::ECDSA_P384_SHA512_ASN1, + ("1.3.132.0.35", "1.2.840.10045.4.1") => &signature::ECDSA_P521_SHA1_ASN1, + ("1.3.132.0.35", "1.2.840.10045.4.3.1") => &signature::ECDSA_P521_SHA224_ASN1, + ("1.3.132.0.35", "1.2.840.10045.4.3.2") => &signature::ECDSA_P521_SHA256_ASN1, + ("1.3.132.0.35", "1.2.840.10045.4.3.3") => &signature::ECDSA_P521_SHA384_ASN1, + ("1.3.132.0.35", "1.2.840.10045.4.3.4") => &signature::ECDSA_P521_SHA512_ASN1, + _ => return false, + }; + signature::UnparsedPublicKey::new(algorithm, public_key.subject_public_key.data.as_ref()) + .verify( + certificate.tbs_certificate.as_ref(), + certificate.signature_value.data.as_ref(), + ) + .is_ok() +} + +fn parsed_certificate<'a>(der: &'a [u8], name: &str) -> crate::Result> { + let (remaining, certificate) = X509Certificate::from_der(der) + .map_err(|_| decode_failed(name, "invalid X.509 certificate"))?; + if !remaining.is_empty() { + return Err(decode_failed(name, "trailing data after X.509 certificate")); + } + Ok(certificate) +} + +fn password_supported(password: Option<&str>, name: &str) -> crate::Result<()> { + if let Some(password) = password { + crate::typed::validate_credential_value("password", password).map_err(|reason| { + SecretSpecError::CredentialInvalid { + name: name.into(), + role: "password".into(), + reason, + } + })?; + } + Ok(()) +} + impl Identity { - /// Open a PKCS#12 archive with the configured password, or the empty - /// password when none is configured. There is no fallback between the - /// two: a protected archive without a bound password fails, and so does a - /// wrong password. + /// MAC verification and decryption precede application key and chain checks. + /// No alternate password, linking policy, or certificate filtering is used. pub(crate) fn decode(bytes: &[u8], password: Option<&str>, name: &str) -> crate::Result { if bytes.is_empty() || bytes.len() > MAX_IDENTITY_BYTES { return Err(decode_failed( @@ -327,40 +307,21 @@ impl Identity { format!("PKCS#12 identity must be between 1 and {MAX_IDENTITY_BYTES} bytes"), )); } - let archive = Pkcs12::from_der(bytes).map_err(|error| { - decode_failed( + password_supported(password, name)?; + let mut archive = Pkcs12Archive::from_pkcs12(bytes, password.unwrap_or("")) + .map_err(|error| decode_failed(name, match error { + p12_keystore::error::Error::DerError(_) | p12_keystore::error::Error::InvalidVersion => + "invalid PKCS#12 identity", + _ if password.is_some() => "PKCS#12 identity could not be opened with the configured password", + _ => "PKCS#12 identity could not be opened with an empty password; bind its password with `credentials = { password = \"...\" }` if the archive is protected", + }))?; + if archive.keys.len() != 1 { + return Err(decode_failed( name, - format!("invalid PKCS#12 identity: {}", short_reason(&error)), - ) - })?; - let archive = archive - .parse2(password.unwrap_or("")) - .map_err(|_| { - decode_failed( - name, - if password.is_some() { - "PKCS#12 identity could not be opened with the configured password" - } else { - "PKCS#12 identity could not be opened with an empty password; bind its password with `credentials = { password = \"...\" }` if the archive is protected" - }, - ) - })?; - let key = archive - .pkey - .ok_or_else(|| decode_failed(name, "PKCS#12 identity has no private key"))?; - let certificate = archive - .cert - .ok_or_else(|| decode_failed(name, "PKCS#12 identity has no leaf certificate"))?; - let mut remaining: Vec = archive - .ca - .map(|certificates| { - certificates - .into_iter() - .map(|cert| cert.to_owned()) - .collect() - }) - .unwrap_or_default(); - if remaining.len() > MAX_CHAIN_CERTIFICATES { + "PKCS#12 identity must hold exactly one private key", + )); + } + if archive.certs.len() > MAX_CHAIN_CERTIFICATES + 1 { return Err(decode_failed( name, format!( @@ -368,50 +329,63 @@ impl Identity { ), )); } - let certificate_key = certificate.public_key().map_err(|error| { - decode_failed( - name, - format!("leaf certificate has no usable public key: {error}"), - ) - })?; - if !certificate_key.public_eq(&key) { + let bag = archive.keys.pop().expect("exactly one private key"); + let key: SecretSlice = bag.key.as_der().to_vec().into(); + let public_key = KeyPair::try_from(key.expose_secret()) + .map_err(|_| decode_failed(name, "unsupported or invalid PKCS#8 private key; supported keys are RSA, P-256, P-384, P-521, and Ed25519"))?; + let public_der = public_key.subject_public_key_info(); + let (_, expected_public) = + x509_parser::x509::SubjectPublicKeyInfo::from_der(&public_der) + .map_err(|_| decode_failed(name, "invalid private key public component"))?; + let mut certificate_ders = std::collections::HashSet::new(); + for certificate in &archive.certs { + if !certificate_ders.insert(certificate.cert.as_der()) { + return Err(decode_failed( + name, + "duplicate certificate bags do not form one unambiguous, coherent chain", + )); + } + } + let mut matching = Vec::new(); + for (index, certificate) in archive.certs.iter().enumerate() { + let parsed = parsed_certificate(certificate.cert.as_der(), name)?; + if parsed.public_key().algorithm == expected_public.algorithm + && parsed.public_key().subject_public_key == expected_public.subject_public_key + { + matching.push(index); + } + } + // A CA and its leaf may reuse the same public key. The local key ID + // identifies the leaf in that case, but never substitutes for matching + // its actual public key or validating every remaining certificate bag. + if matching.len() > 1 + && let Some(key_id) = &bag.local_key_id + { + matching.retain(|index| { + archive.certs[*index].local_key_id.as_deref() == Some(key_id.as_ref()) + }); + } + if matching.len() != 1 { return Err(decode_failed( name, - "leaf certificate does not match the private key", + "PKCS#12 identity must hold one leaf certificate that matches the private key", )); } - - let now = Asn1Time::days_from_now(0).map_err(|error| { - decode_failed(name, format!("failed to read current time: {error}")) - })?; - if certificate - .not_before() - .compare(&now) - .map_err(|error| decode_failed(name, error))? - == Ordering::Greater - { + let certificate = archive.certs.remove(matching[0]).cert; + let mut remaining: Vec = + archive.certs.into_iter().map(|bag| bag.cert).collect(); + let leaf = parsed_certificate(certificate.as_der(), name)?; + let now = x509_parser::time::ASN1Time::now(); + if leaf.validity().not_before > now { return Err(decode_failed(name, "leaf certificate is not valid yet")); } - if certificate - .not_after() - .compare(&now) - .map_err(|error| decode_failed(name, error))? - == Ordering::Less - { + if leaf.validity().not_after < now { return Err(decode_failed(name, "leaf certificate has expired")); } - - for chain_certificate in &remaining { - if chain_certificate - .not_before() - .compare(&now) - .map_err(|error| decode_failed(name, error))? - == Ordering::Greater - || chain_certificate - .not_after() - .compare(&now) - .map_err(|error| decode_failed(name, error))? - == Ordering::Less + for issuer in &remaining { + if !parsed_certificate(issuer.as_der(), name)? + .validity() + .is_valid_at(now) { return Err(decode_failed( name, @@ -419,37 +393,15 @@ impl Identity { )); } } - - // Certificate bags in PKCS#12 are a set, not an ordered chain. - // Reconstruct the leaf-to-root order by issuer/subject name and verify - // every signature; reject unrelated or ambiguous bags instead of - // silently exporting them. - let mut chain = Vec::with_capacity(remaining.len()); - let mut child = certificate.clone(); + let mut chain: Vec = Vec::with_capacity(remaining.len()); while !remaining.is_empty() { - let child_issuer = child - .issuer_name() - .to_der() - .map_err(|error| decode_failed(name, error))?; + let child = parsed_certificate(chain.last().unwrap_or(&certificate).as_der(), name)?; let mut matching = Vec::new(); for (index, candidate) in remaining.iter().enumerate() { - if candidate - .subject_name() - .to_der() - .map_err(|error| decode_failed(name, error))? - != child_issuer + let issuer = parsed_certificate(candidate.as_der(), name)?; + if issuer.subject() == child.issuer() + && signature_valid(&child, Some(issuer.public_key())) { - continue; - } - let issuer_key = candidate.public_key().map_err(|error| { - decode_failed( - name, - format!("chain certificate has no usable public key: {error}"), - ) - })?; - if child.verify(&issuer_key).map_err(|error| { - decode_failed(name, format!("failed to verify certificate chain: {error}")) - })? { matching.push(index); } } @@ -459,38 +411,14 @@ impl Identity { "PKCS#12 certificate bags do not form one unambiguous, coherent chain", )); } - child = remaining.remove(matching[0]); - chain.push(child.clone()); + chain.push(remaining.remove(matching[0])); } - - if certificate - .issuer_name() - .to_der() - .and_then(|issuer| { - certificate - .subject_name() - .to_der() - .map(|subject| issuer == subject) - }) - .map_err(|error| decode_failed(name, error))? - { - let leaf_key = certificate.public_key().map_err(|error| { - decode_failed( - name, - format!("leaf certificate has no usable public key: {error}"), - ) - })?; - if !certificate - .verify(&leaf_key) - .map_err(|error| decode_failed(name, error))? - { - return Err(decode_failed( - name, - "self-signed leaf certificate has an invalid signature", - )); - } + if leaf.issuer() == leaf.subject() && !signature_valid(&leaf, None) { + return Err(decode_failed( + name, + "self-signed leaf certificate has an invalid signature", + )); } - Ok(Self { key, certificate, @@ -498,36 +426,26 @@ impl Identity { }) } - /// Number of issuer certificates behind the leaf. #[cfg(test)] pub(crate) fn chain_len(&self) -> usize { self.chain.len() } - /// Repackage the identity as a PKCS#12 archive protected by `password` - /// (empty when `None`), keeping the complete issuer chain. pub(crate) fn to_pkcs12( &self, password: Option<&str>, name: &str, ) -> crate::Result> { + password_supported(password, name)?; build_archive( - &self.key, + self.key.expose_secret(), &self.certificate, &self.chain, password.unwrap_or(""), ) - .map_err(|error| { - decode_failed( - name, - format!("failed to build PKCS#12 archive: {}", short_reason(&error)), - ) - }) + .map_err(|_| decode_failed(name, "failed to build PKCS#12 archive")) } - /// Produce one registry target from this identity. `password` applies to - /// [`Projection::Pkcs12`] only; `format` selects PEM or DER where the - /// target offers both and defaults to PEM. pub(crate) fn project( &self, projection: Projection, @@ -539,92 +457,98 @@ impl Identity { match projection { Projection::Pkcs12 => self.to_pkcs12(password, name).map(ProjectedValue::Binary), Projection::Pkcs8PrivateKey => match format { - Format::Der => self - .key - .private_key_to_pkcs8() - .map(protected_binary) - .map(ProjectedValue::Binary) - .map_err(|error| { - decode_failed(name, format!("failed to encode PKCS#8 DER: {error}")) - }), - Format::Pem => self - .key - .private_key_to_pem_pkcs8() - .map_err(|error| { - decode_failed(name, format!("failed to encode PKCS#8 PEM: {error}")) - }) - .and_then(|bytes| protected_text(bytes, name)) - .map(ProjectedValue::Text), + Format::Der => Ok(ProjectedValue::Binary( + self.key.expose_secret().to_vec().into(), + )), + Format::Pem => Ok(ProjectedValue::Text(secret_pem( + "PRIVATE KEY", + self.key.expose_secret(), + ))), }, Projection::Certificate => match format { - Format::Der => self - .certificate - .to_der() - .map(protected_binary) - .map(ProjectedValue::Binary) - .map_err(|error| { - decode_failed(name, format!("failed to encode certificate DER: {error}")) - }), - Format::Pem => self - .certificate - .to_pem() - .map_err(|error| { - decode_failed(name, format!("failed to encode certificate PEM: {error}")) - }) - .and_then(|bytes| protected_text(bytes, name)) - .map(ProjectedValue::Text), + Format::Der => Ok(ProjectedValue::Binary( + self.certificate.as_der().to_vec().into(), + )), + Format::Pem => Ok(ProjectedValue::Text(secret_pem( + "CERTIFICATE", + self.certificate.as_der(), + ))), }, - Projection::CertificateChain => { - let mut pem = self.certificate.to_pem().map_err(|error| { - decode_failed(name, format!("failed to encode certificate PEM: {error}")) - })?; - pem.extend(self.issuer_pem(name)?); - protected_text(pem, name).map(ProjectedValue::Text) - } - Projection::IssuerChain => { - protected_text(self.issuer_pem(name)?, name).map(ProjectedValue::Text) + Projection::CertificateChain | Projection::IssuerChain => { + let mut output = Zeroizing::new(String::new()); + if projection == Projection::CertificateChain { + output.push_str( + secret_pem("CERTIFICATE", self.certificate.as_der()).expose_secret(), + ); + } + for issuer in &self.chain { + output.push_str(secret_pem("CERTIFICATE", issuer.as_der()).expose_secret()); + } + Ok(ProjectedValue::Text(SecretString::new( + output.as_str().into(), + ))) } } } - - fn issuer_pem(&self, name: &str) -> crate::Result> { - let mut pem = Vec::new(); - for certificate in &self.chain { - pem.extend(certificate.to_pem().map_err(|error| { - decode_failed( - name, - format!("failed to encode chain certificate PEM: {error}"), - ) - })?); - } - Ok(pem) - } } -/// Validate a stored archive that no credential unlocks. Used where the value -/// is inspected without resolving its dependencies, such as `import`. pub(crate) fn validate(bytes: &[u8], name: &str) -> crate::Result<()> { Identity::decode(bytes, None, name).map(|_| ()) } -fn protected_text(bytes: Vec, name: &str) -> crate::Result { - let bytes = Zeroizing::new(bytes); - let text = std::str::from_utf8(bytes.as_slice()).map_err(|error| { - decode_failed(name, format!("OpenSSL produced invalid PEM text: {error}")) - })?; - Ok(SecretString::new(text.to_owned().into())) +fn secret_pem(label: &str, der: &[u8]) -> SecretString { + let encoded = Zeroizing::new(data_encoding::BASE64.encode(der)); + let mut output = Zeroizing::new(format!("-----BEGIN {label}-----\n")); + for line in encoded.as_bytes().chunks(64) { + output.push_str(std::str::from_utf8(line).expect("Base64 is ASCII")); + output.push('\n'); + } + output.push_str(&format!("-----END {label}-----\n")); + SecretString::new(output.as_str().into()) } -fn protected_binary(bytes: Vec) -> SecretSlice { - let bytes = Zeroizing::new(bytes); - bytes.as_slice().to_vec().into() -} +#[cfg(test)] +pub(crate) mod test_support { + use super::*; + pub(crate) fn key_from_pem(bytes: &[u8]) -> Result { + KeyPair::from_pem(std::str::from_utf8(bytes).unwrap()) + } + pub(crate) fn cert_from_pem(bytes: &[u8]) -> Result { + let pem = pem::parse(bytes).unwrap(); + Certificate::from_der(pem.contents()) + } + pub(crate) fn pem_text(certificate: &Certificate) -> String { + secret_pem("CERTIFICATE", certificate.as_der()) + .expose_secret() + .to_owned() + } + pub(crate) fn open_pfx(bytes: &[u8], password: &str) -> (KeyPair, Certificate) { + let mut archive = Pkcs12Archive::from_pkcs12(bytes, password).unwrap(); + assert_eq!(archive.keys.len(), 1); + let key = KeyPair::try_from(archive.keys.remove(0).key.as_der()).unwrap(); + let leaf = archive + .certs + .into_iter() + .find(|bag| { + parsed_certificate(bag.cert.as_der(), "fixture") + .unwrap() + .public_key() + .subject_public_key + .data + .as_ref() + == key.public_key_raw() + }) + .unwrap() + .cert; + (key, leaf) + } +} #[cfg(test)] mod tests { + use super::test_support::*; use super::*; use crate::config::GenerateOptions; - use openssl::x509::X509Name; use secrecy::ExposeSecret; fn config() -> GenerateConfig { @@ -640,73 +564,60 @@ mod tests { }) } - fn p256() -> PKey { - let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1).unwrap(); - PKey::from_ec_key(EcKey::generate(&group).unwrap()).unwrap() - } - - fn name(common_name: &str) -> X509Name { - let mut builder = X509NameBuilder::new().unwrap(); - builder - .append_entry_by_nid(Nid::COMMONNAME, common_name) - .unwrap(); - builder.build() + fn p256() -> KeyPair { + KeyPair::generate().unwrap() } - /// A certificate for `subject`, signed by `issuer` (self-signed when - /// `None`), valid from `not_before` to `not_after` seconds relative to now. fn certificate( subject: &str, - key: &PKey, - issuer: Option<(&PKey, &X509)>, + key: &KeyPair, + issuer: Option<(&KeyPair, &Certificate)>, ca: bool, not_before: i64, not_after: i64, - ) -> X509 { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as i64; - let subject = name(subject); - let mut builder = X509::builder().unwrap(); - builder.set_version(2).unwrap(); - let mut serial = BigNum::new().unwrap(); - serial.rand(64, MsbOption::ONE, false).unwrap(); - builder - .set_serial_number(&Asn1Integer::from_bn(&serial).unwrap()) - .unwrap(); - builder.set_subject_name(&subject).unwrap(); - builder - .set_issuer_name(issuer.map_or(&subject, |(_, cert)| cert.subject_name())) - .unwrap(); - builder.set_pubkey(key).unwrap(); - builder - .set_not_before(&Asn1Time::from_unix(now + not_before).unwrap()) - .unwrap(); - builder - .set_not_after(&Asn1Time::from_unix(now + not_after).unwrap()) - .unwrap(); - let mut constraints = BasicConstraints::new(); - if ca { - constraints.ca(); - } - builder - .append_extension(constraints.critical().build().unwrap()) - .unwrap(); - builder - .sign(issuer.map_or(key, |(key, _)| key), MessageDigest::sha256()) - .unwrap(); - builder.build() + ) -> Certificate { + let mut params = CertificateParams::default(); + params.distinguished_name = DistinguishedName::new(); + params.distinguished_name.push(DnType::CommonName, subject); + params.is_ca = if ca { + IsCa::Ca(rcgen::BasicConstraints::Unconstrained) + } else { + IsCa::ExplicitNoCa + }; + let now = OffsetDateTime::now_utc(); + params.not_before = now + Duration::seconds(not_before); + params.not_after = now + Duration::seconds(not_after); + let generated = if let Some((issuer_key, issuer_cert)) = issuer { + let parsed = parsed_certificate(issuer_cert.as_der(), "fixture").unwrap(); + let common_name = parsed + .subject() + .iter_common_name() + .next() + .unwrap() + .as_str() + .unwrap(); + let mut issuer_params = CertificateParams::default(); + issuer_params.distinguished_name = DistinguishedName::new(); + issuer_params + .distinguished_name + .push(DnType::CommonName, common_name); + params + .signed_by(key, &rcgen::Issuer::new(issuer_params, issuer_key)) + .unwrap() + } else { + params.self_signed(key).unwrap() + }; + Certificate::from_der(generated.der()).unwrap() } const DAY: i64 = 86_400; /// Root -> intermediate -> leaf, all currently valid. struct Chain { - root: X509, - intermediate: X509, - leaf_key: PKey, - leaf: X509, + root: Certificate, + intermediate: Certificate, + leaf_key: KeyPair, + leaf: Certificate, } fn chain() -> Chain { @@ -738,8 +649,8 @@ mod tests { } } - fn archive(key: &PKey, leaf: &X509, bags: &[X509], password: &str) -> Vec { - build_archive(key, leaf, bags, password) + fn archive(key: &KeyPair, leaf: &Certificate, bags: &[Certificate], password: &str) -> Vec { + build_archive(key.serialized_der(), leaf, bags, password) .unwrap() .expose_secret() .to_vec() @@ -773,8 +684,13 @@ mod tests { else { panic!("expected DER bytes") }; - let from_der = PKey::private_key_from_pkcs8(key_der.expose_secret()).unwrap(); - assert!(from_der.public_eq(&identity.key)); + let from_der = KeyPair::try_from(key_der.expose_secret()).unwrap(); + assert_eq!( + from_der.public_key_raw(), + KeyPair::try_from(identity.key.expose_secret()) + .unwrap() + .public_key_raw() + ); let ProjectedValue::Text(certificate) = identity .project(Projection::Certificate, None, None, "TLS_CERTIFICATE") @@ -817,14 +733,13 @@ mod tests { })) .unwrap(); let identity = Identity::decode(generated.expose_secret(), None, "TLS_IDENTITY").unwrap(); - let common_name = identity - .certificate - .subject_name() - .entries_by_nid(Nid::COMMONNAME) + let parsed = parsed_certificate(identity.certificate.as_der(), "ID").unwrap(); + let common_name = parsed + .subject() + .iter_common_name() .next() .unwrap() - .data() - .to_string() + .as_str() .unwrap(); assert_eq!(common_name, "SecretSpec generated identity"); } @@ -844,10 +759,17 @@ mod tests { "PFX", ) .unwrap(); - assert!(reopened.key.public_eq(&identity.key)); assert_eq!( - reopened.certificate.to_der().unwrap(), - identity.certificate.to_der().unwrap() + KeyPair::try_from(reopened.key.expose_secret()) + .unwrap() + .public_key_raw(), + KeyPair::try_from(identity.key.expose_secret()) + .unwrap() + .public_key_raw() + ); + assert_eq!( + reopened.certificate.as_der().to_vec(), + identity.certificate.as_der().to_vec() ); // Neither a wrong password nor a missing one falls back to guessing. @@ -889,12 +811,12 @@ mod tests { let reopened = Identity::decode(rewrapped.expose_secret(), Some("fresh"), "NEW").unwrap(); assert_eq!(reopened.chain_len(), 2); assert_eq!( - reopened.chain[0].to_der().unwrap(), - chain.intermediate.to_der().unwrap() + reopened.chain[0].as_der().to_vec(), + chain.intermediate.as_der().to_vec() ); assert_eq!( - reopened.chain[1].to_der().unwrap(), - chain.root.to_der().unwrap() + reopened.chain[1].as_der().to_vec(), + chain.root.as_der().to_vec() ); } @@ -913,11 +835,11 @@ mod tests { identity .chain .iter() - .map(|cert| cert.to_der().unwrap()) + .map(|cert| cert.as_der().to_vec()) .collect::>(), vec![ - chain.intermediate.to_der().unwrap(), - chain.root.to_der().unwrap() + chain.intermediate.as_der().to_vec(), + chain.root.as_der().to_vec() ] ); @@ -933,9 +855,9 @@ mod tests { else { panic!("expected PEM text") }; - let leaf_pem = String::from_utf8(chain.leaf.to_pem().unwrap()).unwrap(); - let intermediate_pem = String::from_utf8(chain.intermediate.to_pem().unwrap()).unwrap(); - let root_pem = String::from_utf8(chain.root.to_pem().unwrap()).unwrap(); + let leaf_pem = pem_text(&chain.leaf); + let intermediate_pem = pem_text(&chain.intermediate); + let root_pem = pem_text(&chain.root); assert_eq!( full.expose_secret(), format!("{leaf_pem}{intermediate_pem}{root_pem}") @@ -1012,12 +934,13 @@ mod tests { .to_string(); assert!(error.contains("not valid yet"), "{error}"); - // OpenSSL's own builder refuses a leaf that does not match the key, so - // a mismatched archive can only come from another tool; the decoder's - // check stays as defense in depth and is exercised by the builder here. let other_key = p256(); let mismatched = certificate("other.example", &other_key, None, false, -DAY, DAY); - assert!(build_archive(&key, &mismatched, &[], "").is_err()); + let bytes = archive(&key, &mismatched, &[], ""); + let error = Identity::decode(&bytes, None, "ID") + .unwrap_err() + .to_string(); + assert!(error.contains("matches the private key"), "{error}"); } #[test] @@ -1034,7 +957,7 @@ mod tests { // Seventeen issuer bags exceed the chain cap before any chain walk. let mut issuers = Vec::new(); - let mut parent: Option<(PKey, X509)> = None; + let mut parent: Option<(KeyPair, Certificate)> = None; for index in 0..=MAX_CHAIN_CERTIFICATES { let key = p256(); let cert = certificate( @@ -1064,6 +987,17 @@ mod tests { assert!(error.contains("more than 16 chain certificates"), "{error}"); } + #[test] + fn local_key_id_identifies_a_leaf_that_reuses_its_issuers_key() { + let key = p256(); + let root = certificate("Shared-key root", &key, None, true, -DAY, DAY); + let leaf = certificate("leaf.example", &key, Some((&key, &root)), false, -DAY, DAY); + let bytes = archive(&key, &leaf, &[root], ""); + let identity = Identity::decode(&bytes, None, "ID").unwrap(); + assert_eq!(identity.certificate.as_der(), leaf.as_der()); + assert_eq!(identity.chain_len(), 1); + } + #[test] fn validates_san_and_validity_bounds() { assert!(validate_san("dns:*.example.com").is_ok()); @@ -1074,3 +1008,115 @@ mod tests { assert!(parse_valid_days(Some("201d")).is_err()); } } + +#[cfg(test)] +mod interoperability_tests { + use super::*; + + #[test] + fn imports_openssl_archives_and_preserves_every_supported_key_type() { + for (archive, expected_certificate) in [ + ( + include_bytes!("../tests/fixtures/x509/p256.pfx").as_slice(), + include_bytes!("../tests/fixtures/x509/p256.der").as_slice(), + ), + ( + include_bytes!("../tests/fixtures/x509/p384.pfx").as_slice(), + include_bytes!("../tests/fixtures/x509/p384.der").as_slice(), + ), + ( + include_bytes!("../tests/fixtures/x509/p521.pfx").as_slice(), + include_bytes!("../tests/fixtures/x509/p521.der").as_slice(), + ), + ( + include_bytes!("../tests/fixtures/x509/rsa.pfx").as_slice(), + include_bytes!("../tests/fixtures/x509/rsa.der").as_slice(), + ), + ( + include_bytes!("../tests/fixtures/x509/ed25519.pfx").as_slice(), + include_bytes!("../tests/fixtures/x509/ed25519.der").as_slice(), + ), + ( + include_bytes!("../tests/fixtures/x509/legacy-3des.pfx").as_slice(), + include_bytes!("../tests/fixtures/x509/p256.der").as_slice(), + ), + ] { + let identity = Identity::decode(archive, Some("fixture-password"), "ID").unwrap(); + assert_eq!(identity.certificate.as_der(), expected_certificate); + let rewrapped = identity.to_pkcs12(Some("päss漢字"), "PFX").unwrap(); + let reopened = + Identity::decode(rewrapped.expose_secret(), Some("päss漢字"), "PFX").unwrap(); + assert_eq!(reopened.certificate.as_der(), expected_certificate); + assert_eq!(reopened.key.expose_secret(), identity.key.expose_secret()); + } + } + + #[test] + fn bmp_passwords_interoperate_and_supplementary_passwords_fail_explicitly() { + Identity::decode( + include_bytes!("../tests/fixtures/x509/bmp-password.pfx"), + Some("päss漢字"), + "ID", + ) + .unwrap(); + let error = Identity::decode( + include_bytes!("../tests/fixtures/x509/supplementary-password.pfx"), + Some("päss🔑"), + "ID", + ) + .unwrap_err(); + assert!( + matches!(&error, SecretSpecError::CredentialInvalid { name, role, .. } if name == "ID" && role == "password") + ); + assert!(error.to_string().contains("supplementary Unicode")); + assert!(!error.to_string().contains("päss")); + let identity = Identity::decode( + include_bytes!("../tests/fixtures/x509/p256.pfx"), + Some("fixture-password"), + "ID", + ) + .unwrap(); + assert!(matches!( + identity.to_pkcs12(Some("päss🔑"), "PFX"), + Err(SecretSpecError::CredentialInvalid { .. }) + )); + } + + #[test] + fn rejects_multiple_keys_and_invalid_self_signatures_without_filtering_bags() { + let archive = Pkcs12Archive::from_pkcs12( + include_bytes!("../tests/fixtures/x509/p256.pfx"), + "fixture-password", + ) + .unwrap(); + let key = archive.keys[0].key.clone(); + let certificate = archive.certs[0].cert.clone(); + let mut store = KeyStore::new(); + store.add_entry( + "identity", + KeyStoreEntry::PrivateKeyChain(PrivateKeyChain::new( + "identity", + key.clone(), + [certificate.clone()], + )), + ); + store.add_entry( + "extra key", + KeyStoreEntry::PrivateKeyChain(PrivateKeyChain::new("extra", key.clone(), [])), + ); + let bytes = store.writer("").write().unwrap(); + let error = Identity::decode(&bytes, None, "ID") + .unwrap_err() + .to_string(); + assert!(error.contains("exactly one private key"), "{error}"); + + let mut corrupt = certificate.as_der().to_vec(); + *corrupt.last_mut().unwrap() ^= 1; + let corrupt = Certificate::from_der(&corrupt).unwrap(); + let bytes = build_archive(key.as_der(), &corrupt, &[], "").unwrap(); + let error = Identity::decode(bytes.expose_secret(), None, "ID") + .unwrap_err() + .to_string(); + assert!(error.contains("invalid signature"), "{error}"); + } +} diff --git a/secretspec/tests/fixtures/x509/README.md b/secretspec/tests/fixtures/x509/README.md new file mode 100644 index 000000000..0c82c36bb --- /dev/null +++ b/secretspec/tests/fixtures/x509/README.md @@ -0,0 +1,16 @@ +# OpenSSL interoperability fixtures + +These archives contain disposable test keys and self-signed certificates for +`fixture.example`. Their passwords are public test data. Generated with +OpenSSL 3.6.4 using `python3 generate.py`; OpenSSL is needed only to regenerate +fixtures, not to build or run the Rust tests. + +The RSA, P-256, P-384, P-521, and Ed25519 archives use password +`fixture-password`, AES-256-CBC with PBKDF2/HMAC-SHA-256, a SHA-256 MAC, and +100,000 iterations. The legacy archive uses 3DES/SHA-1. The Unicode archives +use `päss漢字` and `päss🔑`. The `.der` files record the expected leaf certificate. +Certificates expire in 2126 so fixture lifetime does not drive routine updates. + +Regeneration replaces every fixture with newly generated keys and certificates. +The tests compare each archive with its corresponding certificate rather than +assuming a particular random key or serial number. diff --git a/secretspec/tests/fixtures/x509/bmp-password.pfx b/secretspec/tests/fixtures/x509/bmp-password.pfx new file mode 100644 index 0000000000000000000000000000000000000000..3430851ff7f4305792817012dc1ab053cec4be45 GIT binary patch literal 1178 zcmXqLVwuLo$ZXKW;>^aW)#lOmotKfFaX}M{2}=`;0Z>@SpowWWLW*rc6Vn=?&7b^!t}uR}gzc6O+N?sB;3=#DoIhnFS>e9C9Ta=+>{`=HS@?Bg;h^x zZkg$Rr{B>(Hj(epS<3C+=Z$(hgb|AU`7|sx9oJvl&)QV-u}b=?W%QeM<=WCG{u!n}SN0IOc|s%dmsGp_VV|$J&!lCk8(4Sz zl8;-}A9btnpB|&2eq`{C3uf2<)z0%+kYaINTfCatd&_Q{2kEyGZS9p72E<$IX+{B7%xUsYNoH_Leb zGPcE{yDl=`DvbR6G24}aMd%evXy4AGw@MF296Y((cHXfW2fkmfPq=OBu73UO+n@=Y z$}b}K2TN51G~esYDxUb;dB+QxooQFRv}f{Z8@hn<9yBaCMGOVx_!!a{G8rluN*GER ziWpK2lo8URhGHxt(_MZZy?q}LR^6qjCpX3E`5vC7vM=Nsf2$O2s<%f}+d zBBH)~{_?x?BNk*%P~afKDo6fT$AhW!7VmHOw5dJ3k-PJI3U(C zak4TP$g*)Jw0SV5GP5vhu?Rd;m?b(68=$~faN9uC%zs^3{zwx4-)0d11m8y2i`uf{z`W{KO%#77}a{QRo zjF_X<@vmP=@0hf}Z09*y{;tbjxwil3e(7E`eQUsvmp?YQIoZii2-#isroq|GiBn-t zx7mZMbJC8T5G{()`MG`774t(4M)istCzS*|-4>m-!*9y1t55h|zx^gLU9jEg!igQz zeOf+roDFH;zTE7ybkSNn)00~J^j%bK`F$qOvN8EI@2Ky`-zW8Ns_bXd`x2bD!}{3j znXc)&JIeNk3eQs)S-Sn&7TpfsQr_D-(^Rq!cuP5ZHOVZHh$&cNsOU1Y+%{?Lwy zqSes}2M-A5@P$^zs0U17A&jPJ?7B;|zoSQAh4Q{=x8J-TMn%HU@83lyiGba6iB5 zo9SomsfRwT;GBBv=BrHp6LoFEH#^NHtkX{LoKk0#W#|G*=}@P0iWmyW@iC+^WHMAR zlrWSs6fvY4C?ljr4aHbQ!j7xX6aN?F)pUl3r(b-vf(Q4~2L@gSh6cRw9K*!O%D|!^ zzM)5|?&XHP6V6$*S-#xKl(7eO z_3Uc<%5~q2y%{DvY}ev*6{*yJYOyUK1fx+s1W$5!>l3Um;wKq^tZL)7i8`(zplU*b9DaGUtv?$vkA(SoG%X0 z?^>|Od0|(1{!E9*AquvuS2;FTfAv-N(z>y+UH#6R&4ICE0!$ZdO@F^q-4yEJJ!kuy z`KxC>l{m4=ReeEM;c`_$>!`(N*DNzK>-Si^y>jV`2}d@(`f^cbf}G@*y|xN*RpIUP zmhaA)T$j7(LX%)}m%&=mX_ngI4;>d?I_t%=g?(a@#jfLA)@8j3T%psBOmN<3t}deP zajdmS*`?c8?biq2?wk^3RZ^5=)!g=X=O zID=dTs}u{<9=@ByD3gBP`9^W>(dg69f(tC4toLG{6UvVKNtPiZ=4mC;ikZxYHHBLsD_lX z7Bn%6votXZ1BLm4n466omd>~snHDr|HE7&~nfR*UGIWW|yZU@u(emkmesm!C)PpPAAvuJZiX!KF|0*Z!(?HLx;JG~i@o z)#hVnl450G5n1e)ocZ+2)@tW>e!-6)a{fw7WxvECuzKf{dqG`NYMoz{qO8BXc?b>^ E0Pc3N=>Px# literal 0 HcmV?d00001 diff --git a/secretspec/tests/fixtures/x509/p256.der b/secretspec/tests/fixtures/x509/p256.der new file mode 100644 index 0000000000000000000000000000000000000000..0dd0e61ce5a2c77b5f323173bce23e6c1273bc53 GIT binary patch literal 427 zcmXqLVq9*}#OS+#nTe5!NyM$yt^15aoL9;Hyc1ugQ^fV&l+i^EhYA!pvkK zWhh}F#>O1V!py^;mRV6!T9m4nT9KGrkdtblAkJ%KW@unwX=r3(XkZy7!Ea<}WCrA# zLIv>Zjx-QvV+Y&A#0a&JnUS5@iGjthi*xd~=UaH5zmRp;tf8E@k6*{E1_Je*B*7JeCHJ7i{ZJCdYCl2JtPbP)_%4Zyn$BaY+!pT{ADP2?_?MnXWR|q)kF1Zn|DyrlMv*fB literal 0 HcmV?d00001 diff --git a/secretspec/tests/fixtures/x509/p256.pfx b/secretspec/tests/fixtures/x509/p256.pfx new file mode 100644 index 0000000000000000000000000000000000000000..895c8b5c12fc7aed778aa9bf84787eddbd4ae0f7 GIT binary patch literal 1178 zcmXqLVwuLo$ZXKW;>^aW)#lOmotKfFaX}M{2}=`;0Z>@SpowWWLW*rc6Vn=?&SO-u$`*ZgvN=h5(1RqbPm ziNK2d|1-q?d^{s>FL1##(_)|P-zB28muBsnvm(!^XJvmH`?Q-4l1AUR%KVxi#*^Jz zC$>&1(XmTt=SsO_PegLAeu~ce*#G8&cB=NBTf0uL)>xS%wBnmV@o%%`m6~ghx%_J1 zKI5o&hUXvqg$Fc(6gakg|F%phQ|+6yYPH#>zU+zxs*yXnK32c6o0`gJy- z$sDz>dTNUzr#1)GMp#Yt47zIFzwYXlm{a#>uia9x!+VmMTh>1F8F#cFEcqDlzI57# z{{E@eaR)D7SQKu0xrzTfo0}`=>{U|pw0s>FPCO*I`K6QG#x{wDtRknsH`iO*UeMVo zyZ3;H$2|FO^Hg)&ANt*}i*@}esTh;q$v&!Eg|oXJ7BpSg)l3Oh zwA~l>H`}0W>AMXNJm2SBGTOJ%za~d8OsZbkcZqaxZQ4PDmz{~Lx!H83T2+o0Ri71J z7c^y^uE->I+tV{|pEZ4S#ky%joAR;i3x#{^{mlD9b5dOPdYwxTG05zpz$lAbeybwEa_jR=zI(Q zIQycR>9K^<@iz6phI=MzD=c`eyX<-t-zS#F2@Nbd3y(Rd@H8sQ2D{8l-FlOERoxlil=m#O-ne(QtAsly99)?AVlHQi|vbNtwvkN>(SoaJ0>c-ip!BEw~urqvdj zskEiW&pf@o=i+82ro|^)emOL+x01j8S#OdmOYa}gnIhbx6{1YBGSi!FcW-?wvhuK= zqiRx(({vBX2f~wzG!qy^eWw)N+SV_%(9i{x_n=|HDPkxf$H$PykjYTNP{L5kP{fdG zpp1|fH56kJneOuQ=>0QOy2VwV|2nwzY5v+@wXOzU28IT_@I1`K$jZQ?&^Z6~tK&0+ z+qC~rJ;ifJvmvL}Pe4)Cx^idZW#8ZK)$3RU+NzGvzA9Dq&{AR+%R@WuHQ*2h0Ks40 Ap8x;= literal 0 HcmV?d00001 diff --git a/secretspec/tests/fixtures/x509/p384.der b/secretspec/tests/fixtures/x509/p384.der new file mode 100644 index 0000000000000000000000000000000000000000..d45114f973899c57aed31d489edae6619d19ea88 GIT binary patch literal 489 zcmXqLVti`Q#F({!nTe5!NksiV-|y*te+!Ej*`)1GaPM!`dUDHvi;Y98&EuRc3p0~} zl%a%y7#nja3o{RYT4qH_X;G?PYDHphK~AcHf;g{{nW2G!rJ<3Dp@C(T1iz7?kr|L{ z3KhVsyUaj3kpQK!NC+?E(b3xqF=nteyGeEJ!E?|17z3%>5#7ItvTsb|Ud z(Jb|?`?X6p+?=i&;y%;wOWeYx7i$;i8{`_u0(~UQ$0EievXsq4`$6E3pQ`@O)6xws z1n!>tx7k1*B(2N>^mzkz1^gfd!i|l4PePZJ?uWecp_Y&3^oYshZ?Uj|b&pY1j`pw9YZSIA!xeEgSvTXSX E0Gk7^>i_@% literal 0 HcmV?d00001 diff --git a/secretspec/tests/fixtures/x509/p384.pfx b/secretspec/tests/fixtures/x509/p384.pfx new file mode 100644 index 0000000000000000000000000000000000000000..d04c651e91f6ce411ba5294e8745bf046059559b GIT binary patch literal 1292 zcmXqLV&!0BWHxAG*~-SL)#lOmotKfFaX}NyGL|NmML^+s22D)A5mIamnwZ`IgEim9=s*-nwSc@^xuoJRY^Z-KIp}; z<`L)T_|=lr)$fEnw6Hqh{ES7cG5xx3-;NBM(#RFB+`0L_{(OJ`;U$k5octe{%!3Wt zj}&U;uB~73-of4OWyt#L*{gcCYQH*ESHE)8NB$(OioM+*R&AgA^3}4F@Ah5XJ}qe7 zDy<_{VJT;?|9KJjI`w9nO5@E2fsGTVpQ~MQXGyxsr-=A{e`RGa_Gkq-&3Y;~Js|#i zYRjpA2|N40XV3O}$Tgo;x4|{g=S!|cyv~-V*A84cmu=wwr@E4>{)_kZj~aUzUV3eO zRCOe1dEm~6HoqS&a6EVHgO$s0PIgj4`3{mj%`&zaAz1k$-j;6pn+qd7W6i}HK za^M|9`N>nu1k9M;II!9@uU;E*AzT0E&NH@mjUFD```K&H?fW{*ISgedZWfz*roQTBz@UczZcld-{x0n3Xi!BhlIcIlyBUfn0m4gS)A2{WtJryMYRGOfk5 zTP)kWbHXjbyCP>}pD-7c-o3fxuj8RzFBSAv-gwEkYWW=qly&>OH1$r+AJv+M6Xny@ zvS$e|Gu|%w)bp+0x+yPbT=$Oa6hk<-FX4;)pIBGPM*2O^3?28=>b{OMJ%|>@0QQr^-ua~ z^4kQ>O6mL0i~jHZF`c3B@b_8&pKRS{(8M?!o{&K~gRzIDiLnDH+ycZ6Y~0XH!^6eM z1eDYS3aS#y35xvJ`)oG${E45}b8O|?tQCZF0{5MzJ@dr$#7^n2bn`2k@tdXbK*@@V z=#GCs*5CiXQ+&}EyQx~n-CS|n%E7l-6pSvMeP_n;TYULZn-7zMjWYI4JjXm~qxJ3P zt=z{vJ?9?&WNLc)%8YuBysf1_Ha$!isW=gMgPoVv?=<^8*-zy~ZO2cS^M2;A`*V&n z;#jR-+j^ni`i1=Yv;5Y~l&?JYY~MsxKD85C0ew&2My%rs={fPjcze%^mdh_vYR3)E8&f~=I!<=2f3CWrvv11^11|$Z z173Kk!NkbQz@jkk&(SFJlB?WPx4bMmqAa!Z%JYwrQ+DemoqEv!{aoA0G8O^j%mbD- TU%XbI{kLPrpUgdd;1~b^YYsC% literal 0 HcmV?d00001 diff --git a/secretspec/tests/fixtures/x509/p521.der b/secretspec/tests/fixtures/x509/p521.der new file mode 100644 index 0000000000000000000000000000000000000000..040887d5f9b6854f1673441fab0318a2dfd019cb GIT binary patch literal 563 zcmXqLV$wHgVw|vmnTe5!NyM+!^I_UQTi4bE*&pAo%qAMT(OyPm~b;m@uVuy+Jp zH(9vKaEkwg(#81(xdyU8f6MZ*h_Q&IY&h<XJT;0GxXW@P-&!fL<_qzt4$0)i~U27*l(fr%98%#A$^28|s|P7Ked3bbD7P%o8n zxK+5U=?$lX-lA{st{5j|&2a0uw8N2i$D0lE$3FcLau3w^TInIdwxzplr ct-9n6|Do4s{WrX9i@1LFQNe~!EWXnU08`h|tpET3 literal 0 HcmV?d00001 diff --git a/secretspec/tests/fixtures/x509/p521.pfx b/secretspec/tests/fixtures/x509/p521.pfx new file mode 100644 index 0000000000000000000000000000000000000000..511eb618a4f2a3b100aebf9020ab86d6b3b97ec3 GIT binary patch literal 1421 zcmXqLV(nyNWHxAGHDlw{YV&CO&dbQoxS)wuou!FY87M4o(8O$wkYZcV#H<4p(qLj_ z08)ww8AgKyIER(jAlSeN!R0YfWf9o4rQ%1BjM-{$?n{3dxcAIrVrFbxV8Fx10kM{e zla;|hmW?x^&4V$OnT1h{MZoPt`j4&^_4ghhb)atv71(M=z8b5s0 z=`KsX^TfD=tBmgf^a;LY>CV@MuB7T5nWi24JV|xq1I^A&oG(sZ znxY@Rz0aX#nk(1rJ@q*=_wH2s{!MGvuXWQHqMkp@ShXrPWPT-M)>K^|mNE&Oa=WD4 z@`35+8xPGqyU#7`hxeWZZZnpy=yJT`xW7T6>x0m=+ne(I*&qFl7YSD4IGYo``(5IS zc^?)Yl=Ejyw-Z&D+uxM5yKC-+*p=?9=esD&`rO5{^MBBP(WFV` zFV(mFYzP*peUP%_@53kUi{6NZGTuFT^T>(G9E+F!6VkY9$n|jdwTnUhWf^xrZ@gga z^JPM{>ZD4^$J04y%4~UCa`RW^yA(GQw${tM-y{I;Zm`9$Hn**>v>s#uBOQ zwiiqO^#{e9<-aJC{4Lh7OXK}lBY)MVJ#h;(mIOqtyLwYw&Y+3$4m=}(au(xRmL|rN zK;ff6e2|SBn(27B7@2^QSwO)wLOCg(P4Ub^fz)=XmI*n5l~vXAi~O=PwRr{?rkOq&6j|q5CC_vR2~2T literal 0 HcmV?d00001 diff --git a/secretspec/tests/fixtures/x509/rsa.der b/secretspec/tests/fixtures/x509/rsa.der new file mode 100644 index 0000000000000000000000000000000000000000..a7ff8e4b92be0d8f6a070d4f82a52a1e899f606d GIT binary patch literal 823 zcmXqLVm3BtVv=6K%*4pVBx0{sG{Y!mse$3`?VCfIzh#EHAB#5NW#iOp^Jx3d%gD&h z%3vU6C}AMR#vIDR%)_6SSy572l&Y6nk(gVMlWL$K&TC|5XkcJzXk=n&U>POBZ)9j> z2IQJT1@P)_VpKx5jFFXrxrvdV!Jvtei>Zl`kzsRVmS5QN6=DZpRg{a~*IYC2Z_ji! zk~`#$Hu z*=Y>(WvyQUd^T^hRSVyrDn`=i>XyHB0_3LY{%XY(`WzQH2b_FO$(xJtIXzM;xe*hxWT z?&{>uKL=ikyg0e|rTatPmiJvnZnL!7FKg?z?UgCCMcv`F^~sIE3*LOsR6qJevkrTM#ldvtOm?L z%0LPvAjl$YAlQTvsi^S}j1oqM8Mg~KN{f$9FfO%8_|RIOm@GTD_q|SvPCB!?%>84V zd9FErC=Q%k`MvAIcKZdr#+%g4$_`&}*PL_iuSm1|-Y*eXXSOBGYfC%f5yLaF@{Q+? z%`E{-G7o;fvMHITJZA3d)|LOFULKpg+0K6LO5Il+I*}`7b)4Owua~frTzO{Uj58t6 zT14g*IhchuU3$b^^;LoEuz>d28IR@~teqecV6PM8Fjs@+Y=gsuGu4Z({LNmmMdkSC z%iAsYeY%>lN3Q%pp!>Fif!3S~t-KHZ-Jc+_Jw@&JG}9B+VpG#%&nC>wZHsWK)mgK| iB)n&7|4aL%n#Qt%f7?cEqHqNNvl8E(v|?e15`!; literal 0 HcmV?d00001 diff --git a/secretspec/tests/fixtures/x509/rsa.pfx b/secretspec/tests/fixtures/x509/rsa.pfx new file mode 100644 index 0000000000000000000000000000000000000000..d9979c0761e602c01971322c7eedddeb95d34541 GIT binary patch literal 2653 zcmai$c{~%0AICT5m}7aIPg#zX(6vc1gePapksK9er8P$~$24YP!ZPMI7IVdt`@TlV zky|;YCK5xga^z0Gp5O0zJ^lInpvy!ZwA0 ze83DMcoRefufd4muP`D=>o?-fAc9W*ij=s3fL}}gHvz(s{~kQTuxl{Y-=`4l7)XSU zTNisYcjYhl<}$jg80q#Z1IX4&1q=YN5(JHZRDgLC)l|wI$2_7-P9&`dixjJJSVh6 z&{gF2;H4$wvIKuLvsK2RiEp zV(L4WW~|-7Hn(wkP;U>}J6KEs%;Tk%PkG;{({_;dh>ZTQQ4!Zvk@ex)UUG(A2)^uge+QtIsh+sosX)TjC$SwA9LHHg z2t09W=@qll-K*2qgNP1(V^QxDjb3*@xX?1LOg~ST-Ue2ce7AIIS$I$tx9ZXWczF0+ zuxrUqu2FaC=y~3-qSTpNJpQpaHfQKjT4qpXT92GYcXc}-`R>b=Da)d(V2d><7w1o{ z1dgn1);j`GbXpYsFfB^ncF7!($1Lklg6sq}JU>q8=Vo@ydSLN%+2o=Uf_j@nK#EbT5j9O|+HSPMA~Os18BSx7MU*kbA&m&}gGxN_&Jq_0w4q zz@ZbX-O>f9&oaw#)7?_cK4|yH4J*0j0k@QUa1;X_QsIMC?R*!Z8B^v$pT)yg0 zdI_#djqRFKdG(N5;YX}pg$g^n^Ah1CG52yd!y6KBJ@|Ay$Os!jxm8h7hZL>ZQZPH4 zGq7v8fbi6AQaJ!+HRV)j)1yO%l!6IXebwD{Z|yKCXO|QdcW$K`c`?LDIdBVtM#Nyl z;^#s&+r|CpiLxTPe%x)fTPvqFxr?`kvXjIVC#Yh2=`~$WGx0r*(qpsh4s;ZD@;uzT zLN=7o0i%2|+{5*idRpvyOH2g#PM`_35jmuhTdnC_v&bf*hO6&qq)X`A0YB0z1+QAG zZFj=%gZ%t{;FZ_a^KnT7+{V|qOW0Rwr5DJ%*9^xw4Rv2Lo_HuHGp|bXm`&LSKrc8XI_Sfj;>#HurkWy_qZ#%5RUW2}1 zTP5Ezg3kLW7Y&Ju2Qgqoo`Qcx@V8lb(m+I>lwWb;uZiUq{6{)M0zj@`Q1e%K{{NZ8 z**FV0SD2&EQoMIfbb$Z+|2K)C_;6C2_N~N_wHY52B-|221P$c)9+Kc{5`(mR@Ezh0 z&aju)`7u93sxNk6f21;%GhQ@VS17M|-M|n}J2bi1!|F{z{tcPdrx08i(s;`KCYy%yVfeFkriL%zu-&&u6iZ^{YpqR1r5W01` zg;l8n18ZAp)321T9~H%Ys@k7~E<#_#g-EC~u4nEP$0T~&cS_hD(K!;?Hj#gS+T*g> zAZJ$_P=8wX!z(Jm8>vOUhir-P|2}9ie7dw`-z2V#GvXx=?~inQt@x<_*aUm}s4Sa_ z#w{OM^osC}I=HD9N^Ek<2j~6y8h0srQq-EZ^dM6v23KNcGw68R0iok(E-np?AL_o? zmq)ogu>CAlX@FatVJLLxRArTCmxaP+yI&WYh)ODjG|abwe!7OV9OqwjQjh6~r1Zp` zonqdNJJ$5(=&}gCd~#m%YO*CpuV*D!5Mf7|eOv6jJw~FHSSOdWempCOLQ{QhSe3{F zWkgU?>xc$0aX=JQTrxr$BU&z-`x<5qEsky&oSllA4@QQ&%XRaZ2%7}*k|dAzEZyxi z{W*=A_T4&xkbGBbA9rN8byR%lT$qvN%loJkmFyzqOKZHm3reo8w{?gIF?Ml~`b|1# zP_w!~Fvwq7N_(CeWo=Bk%)Cpgj1*q3Dd=K#s3PY!%CLI;g+C24{;F#_iooessK_Ug#{RW9?pD{rYcif8?eJS5sxku9VoqW z(rE8K+U&*T0*9Iu>o&&c!DpvS$Cyc}p8S|&hP_!c!h9To^Qs2t%5*7elaEg96q<^O zG%qGZv?;e8Zq8BjY3?#Jn5JLDIjv*RyXosp8|Y^FLdTOLn=B_ zDbW7ioC3&4)mMh69rEZUrj4e9wG@R99eiwYzWA0GmrR6{$bQu8_!?ns z4^54-P>74iXS&Yc0!m>KdDZ(Ryw+4>$Kk@&53WKEr~VWR64d9kbCQGbvGA-rt0|B7 z%*UcOr{867VD9C-!4jYI{^)b5b(fJYl9CZmj(jexw)~PZ9JKEMTl1NZ>2051Rvrtlj}z$HQA zXtSl))d>^LUK7F3x*(EIHGs;&FjE*DCj4&=;{x&kKytenv<{{xojb>MennAAb)B;k o`t>3-YyHCWLy~NVon?>+-utf^tH9fLYrkdYbKFOd{8!2U0ZEeU00000 literal 0 HcmV?d00001 diff --git a/secretspec/tests/fixtures/x509/supplementary-password.pfx b/secretspec/tests/fixtures/x509/supplementary-password.pfx new file mode 100644 index 0000000000000000000000000000000000000000..086e09179f2fece637a8028ccbcaa0f4cc2b993a GIT binary patch literal 1178 zcmXqLVwuLo$ZXKW;>^aW)#lOmotKfFaX}M{2}=`;0Z>@SpowWWLW*rc6Vn=?&ha zL_UsE-t>}v^3&~=*0)w1SSWhW{?^+nhM6X|3iD&vgdXDB6k)#lQ^;>;{?ILAUoQ%L z?+%`}*;H=Nm(b78BBma)6qw_~aB|3=`Wb1=VDDre>V?w(~R$;dU}T@+G#>$^4s`jmiUq z>4)c?a&Ooc=b87OncZZq(1bI_so8(O^D72DK7K4{ZQ7WmnvvTiksu zuSflLfxy=MSt16nWSQ{DHCtBv$@59i=dkD1ob$DwS+xAfsj$!L${(kT zYY1{`7Wg`C=4i|KJC7%TWB&u&6}*203mErJzt7Q98<*sH@pVLx_s)x31;{0WK%gdym)!Ci#y@kQ# z|DMzOL(-qu9SS8GlYaZmpOeq_kYR%S%Ox=qFIKGH8My6;zRrbt ziPdkfyxwon#FzHj`7 zPY(_VySqa|E$R5)>UBF8o$K1>u+!sc^dj5$0WaJ{&K-^xI42=eyDFJqW%du=o|m#= zWy0ZtoR0i|%>Gpc9GIe#u-)gUZ^x`>wFj&WT|jvc8Wx-)h5~YY3~3CR3>6F|45bW3 z45qGBy6MUn*l6^4k9#+;75U#v-s*uT3DeV53| literal 0 HcmV?d00001 From ac6832d2061625bb5e1edfcd5eadc14a86e2f8ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Sat, 10 Oct 2026 10:27:10 +0100 Subject: [PATCH 5/9] build: use released p12-keystore with archive preservation --- CHANGELOG.md | 2 +- Cargo.lock | 13 +++++++------ Cargo.toml | 4 ++-- RELEASE.md | 13 +++++-------- 4 files changed, 15 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ea852cf1..4d9f19333 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - X.509 identity generation, validation, and PKCS#12 conversion no longer - require OpenSSL. PKCS#12 passwords currently support the Unicode Basic + 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 diff --git a/Cargo.lock b/Cargo.lock index 24c2d548a..793936491 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1460,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", @@ -4086,7 +4086,7 @@ dependencies = [ "block-modes", "byteorder", "cbc 0.2.1", - "chacha20 0.10.0", + "chacha20 0.10.2", "chrono", "cipher 0.5.2", "flate2", @@ -4869,8 +4869,9 @@ checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" [[package]] name = "p12-keystore" -version = "0.3.2" -source = "git+https://github.com/ancwrd1/p12-keystore?rev=baae350b8b5a144a37a3373d592cf91a5b0e9f4b#baae350b8b5a144a37a3373d592cf91a5b0e9f4b" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85e57be257994fa4398afbbbd5a28dfc282fbd408f45d3d3f0a35ff6f864a1e8" dependencies = [ "cbc 0.2.1", "cms", @@ -5618,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", ] diff --git a/Cargo.toml b/Cargo.toml index 37977be5c..45d295e66 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -100,8 +100,8 @@ rcgen = { version = "0.14.10", default-features = false, features = ["crypto", " x509-parser = { version = "0.18", features = ["verify-aws"] } pem = "3" time = "0.3" -# Replace this Git pin with the crates.io release containing p12-keystore#13. -p12-keystore = { version = "0.3.2", git = "https://github.com/ancwrd1/p12-keystore", rev = "baae350b8b5a144a37a3373d592cf91a5b0e9f4b" } +# 0.4 includes the bag-preserving archive API from p12-keystore#13. +p12-keystore = "0.4" # The profile that 'dist' will build with [profile.dist] diff --git a/RELEASE.md b/RELEASE.md index b5ab6664f..37df4b6ef 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -35,17 +35,14 @@ The detailed compatibility matrix is maintained in ## SecretSpec 0.22 X.509 dependency release -The X.509 backend temporarily pins `p12-keystore` to Git commit -`baae350b8b5a144a37a3373d592cf91a5b0e9f4b`, which contains the merged +The X.509 backend uses `p12-keystore` 0.4, which contains the merged [bag-preserving archive API](https://github.com/ancwrd1/p12-keystore/pull/13). Before publishing 0.22: -1. Replace the `p12-keystore` workspace dependency with the crates.io version - containing that API, removing `git` and `rev`, and update `Cargo.lock`. -2. Run the Rust tests, SDK CI, and `cargo package -p secretspec` with the - published dependencies. Package verification cannot use the Git-only API - after Cargo rewrites a Git dependency to its registry version. -3. Keep the documented BMP password restriction until +1. Run the Rust tests, SDK CI, and `cargo package -p secretspec` with the + published dependencies. Verify the packaged manifest resolves the registry + archive API, without a Git override. +2. 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. From 0e9dd845d10f90f86ed22504c03a322e063c06aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Sat, 10 Oct 2026 10:35:10 +0100 Subject: [PATCH 6/9] docs: record IPC prerequisite for package verification --- RELEASE.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 37df4b6ef..8fe46bde1 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -39,10 +39,15 @@ The X.509 backend uses `p12-keystore` 0.4, which contains the merged [bag-preserving archive API](https://github.com/ancwrd1/p12-keystore/pull/13). Before publishing 0.22: -1. Run the Rust tests, SDK CI, and `cargo package -p secretspec` with the +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 X.509 dependencies are available. +2. Run the Rust tests, SDK CI, and `cargo package -p secretspec` with the published dependencies. Verify the packaged manifest resolves the registry archive API, without a Git override. -2. Keep the documented BMP password restriction until +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. From 1e458e9a8a29949a910e8a0b3ae9d144514dc2e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Tue, 13 Oct 2026 12:12:46 +0100 Subject: [PATCH 7/9] fix: preserve Unicode identities and redact scoped sources --- CHANGELOG.md | 5 ++ Cargo.lock | 3 +- Cargo.toml | 4 +- RELEASE.md | 23 ++++--- docs/src/content/docs/reference/cli.mdx | 3 +- .../content/docs/reference/configuration.mdx | 4 ++ secretspec/src/resolve.rs | 2 +- secretspec/src/secrets.rs | 1 + secretspec/src/typed_tests.rs | 51 ++++++++++++++- secretspec/src/x509_identity.rs | 65 ++++++++++++++++++- 10 files changed, 145 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d9f19333..6ea38db86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,11 @@ 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 when exporting PKCS#12. 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. diff --git a/Cargo.lock b/Cargo.lock index 793936491..f5cae82ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4870,8 +4870,7 @@ checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" [[package]] name = "p12-keystore" version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85e57be257994fa4398afbbbd5a28dfc282fbd408f45d3d3f0a35ff6f864a1e8" +source = "git+https://github.com/domenkozar/p12-keystore?rev=981a75e7d4e775f23c7226b6c68dd1a68f301fcf#981a75e7d4e775f23c7226b6c68dd1a68f301fcf" dependencies = [ "cbc 0.2.1", "cms", diff --git a/Cargo.toml b/Cargo.toml index 45d295e66..4d306e5c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -100,8 +100,8 @@ rcgen = { version = "0.14.10", default-features = false, features = ["crypto", " x509-parser = { version = "0.18", features = ["verify-aws"] } pem = "3" time = "0.3" -# 0.4 includes the bag-preserving archive API from p12-keystore#13. -p12-keystore = "0.4" +# Return to crates.io after the Unicode certificate-label fix in p12-keystore#14 ships. +p12-keystore = { version = "0.4", git = "https://github.com/domenkozar/p12-keystore", rev = "981a75e7d4e775f23c7226b6c68dd1a68f301fcf" } # The profile that 'dist' will build with [profile.dist] diff --git a/RELEASE.md b/RELEASE.md index 8fe46bde1..18c9a3409 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -35,19 +35,26 @@ The detailed compatibility matrix is maintained in ## SecretSpec 0.22 X.509 dependency release -The X.509 backend uses `p12-keystore` 0.4, which contains the merged -[bag-preserving archive API](https://github.com/ancwrd1/p12-keystore/pull/13). +The X.509 backend pins `p12-keystore` to commit +`981a75e7d4e775f23c7226b6c68dd1a68f301fcf` for the +[Unicode certificate-label fix](https://github.com/ancwrd1/p12-keystore/pull/14). +The published 0.4.0 release already includes the bag-preserving archive API, +but cannot rewrap certificate subjects containing supplementary Unicode. Before publishing 0.22: -1. Align the workspace `secretspec-ipc` dependency with the release version +1. Replace the Git pin with the crates.io version containing the certificate-label + fix and update `Cargo.lock`. Set that version as the minimum requirement. + Cargo replaces Git dependencies with their registry versions when packaging; + leaving the 0.4.0 requirement would silently lose this fix in the published crate. +2. 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 X.509 dependencies are available. -2. Run the Rust tests, SDK CI, and `cargo package -p secretspec` with the - published dependencies. Verify the packaged manifest resolves the registry - archive API, without a Git override. -3. Keep the documented BMP password restriction until + registry crate fails even though the archive API is available. +3. 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. +4. 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. diff --git a/docs/src/content/docs/reference/cli.mdx b/docs/src/content/docs/reference/cli.mdx index d1cfc440e..9ad4d3e01 100644 --- a/docs/src/content/docs/reference/cli.mdx +++ b/docs/src/content/docs/reference/cli.mdx @@ -517,7 +517,8 @@ Writing secret 'API_KEY' to keyring (profile: production) Derived secrets declared with `from` appear as `converted from SOURCE` or -`selected from SOURCE` in `--explain`. Reports check their dependencies without +`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. diff --git a/docs/src/content/docs/reference/configuration.mdx b/docs/src/content/docs/reference/configuration.mdx index 55ae460d8..2e4f1ea94 100644 --- a/docs/src/content/docs/reference/configuration.mdx +++ b/docs/src/content/docs/reference/configuration.mdx @@ -1085,6 +1085,10 @@ rather than a security boundary. 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 diff --git a/secretspec/src/resolve.rs b/secretspec/src/resolve.rs index 8143462d1..4a3ed4099 100644 --- a/secretspec/src/resolve.rs +++ b/secretspec/src/resolve.rs @@ -43,7 +43,7 @@ pub enum ResolvedSource { /// for wire compatibility. /// /// Template composition is available since SecretSpec 0.16; `from` - /// derivation is available since SecretSpec 0.21. + /// derivation is available since SecretSpec 0.22. Composed, } diff --git a/secretspec/src/secrets.rs b/secretspec/src/secrets.rs index a5cf757ea..05aeafa69 100644 --- a/secretspec/src/secrets.rs +++ b/secretspec/src/secrets.rs @@ -2034,6 +2034,7 @@ impl<'secrets, 'plan, 'filter, 'addresses> let derivation = if planned.is_composed() { Derivation::Composed } else if let Some(source) = planned.from() { + let source = Secrets::diagnostic_secret_name(source, self.output_filter); if planned.extract().is_some() { Derivation::SelectedFrom(source.to_string()) } else { diff --git a/secretspec/src/typed_tests.rs b/secretspec/src/typed_tests.rs index 3d657a19c..d1a0f3b7d 100644 --- a/secretspec/src/typed_tests.rs +++ b/secretspec/src/typed_tests.rs @@ -1,5 +1,5 @@ //! End-to-end coverage for typed, converted, and password protected secrets -//! (0.21+): `type`, `format`, `from`, and `credentials` across configuration +//! (0.22+): `type`, `format`, `from`, and `credentials` across configuration //! validation, resolution, scopes, reports, and the read-only lifecycle. use crate::config::{Config, GenerateConfig, GenerateOptions}; @@ -1605,3 +1605,52 @@ CERT = {{ description = "cert", type = "x509_certificate", from = "ID" }} ); } } + +#[test] +fn scoped_reports_redact_hidden_conversion_and_selection_sources() { + let _env = scrub_resolution_env(); + let config = manifest( + r#" +[profiles.default] +HIDDEN_ID = { description = "identity", type = "x509_identity", generate = { san = ["dns:localhost"] } } +CERT = { description = "certificate", type = "x509_certificate", from = "HIDDEN_ID" } +HIDDEN_DOCUMENT = { description = "document", default = '{"field":"value"}' } +FIELD = { description = "field", from = "HIDDEN_DOCUMENT", extract = { format = "json", pointer = "/field" } } +[scopes.visible] +secrets = ["CERT", "FIELD"] +[scopes.all] +secrets = ["HIDDEN_ID", "CERT", "HIDDEN_DOCUMENT", "FIELD"] +"#, + ); + config.validate().unwrap(); + for scope in [Some("visible"), Some("all"), None] { + let mut secrets = Secrets::new(config.clone(), None, Some("null://".to_string()), None); + if let Some(scope) = scope { + secrets.set_scope(scope); + } + let report = secrets.report().unwrap(); + let explanation = report.to_explain_string(); + if scope == Some("visible") { + assert!(!explanation.contains("HIDDEN_ID"), "{explanation}"); + assert!(!explanation.contains("HIDDEN_DOCUMENT"), "{explanation}"); + assert_eq!(report.secrets.len(), 2); + assert!(explanation.contains(&format!( + "converted from {}", + crate::secrets::HIDDEN_SECRET_LABEL + ))); + assert!(explanation.contains(&format!( + "selected from {}", + crate::secrets::HIDDEN_SECRET_LABEL + ))); + } else { + assert!( + explanation.contains("converted from HIDDEN_ID"), + "{explanation}" + ); + assert!( + explanation.contains("selected from HIDDEN_DOCUMENT"), + "{explanation}" + ); + } + } +} diff --git a/secretspec/src/x509_identity.rs b/secretspec/src/x509_identity.rs index 87ebdc526..729c4ccd9 100644 --- a/secretspec/src/x509_identity.rs +++ b/secretspec/src/x509_identity.rs @@ -413,7 +413,10 @@ impl Identity { } chain.push(remaining.remove(matching[0])); } - if leaf.issuer() == leaf.subject() && !signature_valid(&leaf, None) { + // Matching names make a certificate self-issued, not necessarily + // self-signed. A supplied issuer chain has already verified its + // signature with the issuer's key, which may differ from the leaf's. + if chain.is_empty() && leaf.issuer() == leaf.subject() && !signature_valid(&leaf, None) { return Err(decode_failed( name, "self-signed leaf certificate has an invalid signature", @@ -723,6 +726,66 @@ mod tests { assert!(issuers.expose_secret().is_empty()); } + #[test] + fn self_issued_leaf_uses_its_distinct_issuers_key() { + let root_key = p256(); + let root = certificate("same-name", &root_key, None, true, -DAY, 365 * DAY); + let leaf_key = p256(); + let leaf = certificate( + "same-name", + &leaf_key, + Some((&root_key, &root)), + false, + -DAY, + 30 * DAY, + ); + let parsed = parsed_certificate(leaf.as_der(), "LEAF").unwrap(); + assert_eq!(parsed.subject(), parsed.issuer()); + assert!(!signature_valid(&parsed, None)); + assert!(signature_valid( + &parsed, + Some( + parsed_certificate(root.as_der(), "ROOT") + .unwrap() + .public_key() + ) + )); + let encoded = archive(&leaf_key, &leaf, std::slice::from_ref(&root), "pw"); + let decoded = Identity::decode(&encoded, Some("pw"), "ID").unwrap(); + assert_eq!(decoded.certificate.as_der(), leaf.as_der()); + assert_eq!(decoded.chain_len(), 1); + assert_eq!(decoded.chain[0].as_der(), root.as_der()); + } + + #[test] + fn unicode_subjects_survive_rewrapping_in_every_chain_certificate() { + let root_key = p256(); + let root = certificate("Root🚀", &root_key, None, true, -DAY, 365 * DAY); + let leaf_key = p256(); + let leaf = certificate( + "Leaf🔑", + &leaf_key, + Some((&root_key, &root)), + false, + -DAY, + 30 * DAY, + ); + let encoded = archive(&leaf_key, &leaf, std::slice::from_ref(&root), "old"); + let decoded = Identity::decode(&encoded, Some("old"), "ID").unwrap(); + let rewrapped = decoded.to_pkcs12(Some("new"), "PFX").unwrap(); + let reopened = Identity::decode(rewrapped.expose_secret(), Some("new"), "PFX").unwrap(); + assert_eq!(reopened.key.expose_secret(), decoded.key.expose_secret()); + assert_eq!(reopened.certificate.as_der(), leaf.as_der()); + assert_eq!(reopened.chain_len(), 1); + assert_eq!(reopened.chain[0].as_der(), root.as_der()); + let bags = Pkcs12Archive::from_pkcs12(rewrapped.expose_secret(), "new").unwrap(); + assert!(bags.certs.iter().all(|bag| bag.friendly_name.is_none())); + assert_eq!( + bags.keys[0].local_key_id.as_ref().map(|id| id.as_ref()), + bags.certs[0].local_key_id.as_deref(), + ); + } + #[test] fn generation_uses_fallback_cn_for_long_valid_dns_san() { let long_dns = format!("{}.example.internal", "a".repeat(63)); From cb49bb711e409c1958939671176b3020298e4f21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Tue, 13 Oct 2026 12:41:53 +0100 Subject: [PATCH 8/9] build: pin merged upstream p12-keystore fix --- CHANGELOG.md | 3 ++- Cargo.lock | 2 +- Cargo.toml | 2 +- RELEASE.md | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ea38db86..2c8377565 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Imported X.509 identities accept self-issued certificates signed by a distinct issuer with the same name and preserve supplementary Unicode in certificate - subjects when exporting PKCS#12. Scoped explanations redact hidden derivation + subjects for both leaf and issuer certificates when exporting PKCS#12. + Scoped explanations redact hidden derivation source names. - X.509 identity generation, validation, and PKCS#12 conversion no longer diff --git a/Cargo.lock b/Cargo.lock index f5cae82ec..6bf850b1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4870,7 +4870,7 @@ checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" [[package]] name = "p12-keystore" version = "0.4.0" -source = "git+https://github.com/domenkozar/p12-keystore?rev=981a75e7d4e775f23c7226b6c68dd1a68f301fcf#981a75e7d4e775f23c7226b6c68dd1a68f301fcf" +source = "git+https://github.com/ancwrd1/p12-keystore?rev=06bdb4da27c8b4696e1af1756a5765f21288787c#06bdb4da27c8b4696e1af1756a5765f21288787c" dependencies = [ "cbc 0.2.1", "cms", diff --git a/Cargo.toml b/Cargo.toml index 4d306e5c2..19fc50619 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -101,7 +101,7 @@ x509-parser = { version = "0.18", features = ["verify-aws"] } pem = "3" time = "0.3" # Return to crates.io after the Unicode certificate-label fix in p12-keystore#14 ships. -p12-keystore = { version = "0.4", git = "https://github.com/domenkozar/p12-keystore", rev = "981a75e7d4e775f23c7226b6c68dd1a68f301fcf" } +p12-keystore = { version = "0.4", git = "https://github.com/ancwrd1/p12-keystore", rev = "06bdb4da27c8b4696e1af1756a5765f21288787c" } # The profile that 'dist' will build with [profile.dist] diff --git a/RELEASE.md b/RELEASE.md index 18c9a3409..12638a8a1 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -36,7 +36,7 @@ The detailed compatibility matrix is maintained in ## SecretSpec 0.22 X.509 dependency release The X.509 backend pins `p12-keystore` to commit -`981a75e7d4e775f23c7226b6c68dd1a68f301fcf` for the +`06bdb4da27c8b4696e1af1756a5765f21288787c` from upstream for the merged [Unicode certificate-label fix](https://github.com/ancwrd1/p12-keystore/pull/14). The published 0.4.0 release already includes the bag-preserving archive API, but cannot rewrap certificate subjects containing supplementary Unicode. From bb01b977afdd485eb61a4145f4081c0122f4447c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Tue, 13 Oct 2026 18:24:16 +0100 Subject: [PATCH 9/9] build: use released p12-keystore 0.4.1 --- CHANGELOG.md | 3 ++- Cargo.lock | 5 +++-- Cargo.toml | 4 ++-- RELEASE.md | 19 +++++++------------ 4 files changed, 14 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c8377565..205ec4787 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. + subjects for both leaf and issuer certificates when exporting PKCS#12, + including in builds using published dependencies. Scoped explanations redact hidden derivation source names. diff --git a/Cargo.lock b/Cargo.lock index 6bf850b1d..6700ab2c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4869,8 +4869,9 @@ checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" [[package]] name = "p12-keystore" -version = "0.4.0" -source = "git+https://github.com/ancwrd1/p12-keystore?rev=06bdb4da27c8b4696e1af1756a5765f21288787c#06bdb4da27c8b4696e1af1756a5765f21288787c" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2ed4fa0ec7ec58b657d4184680c57da9e203e97771d29a566028869a5193e0" dependencies = [ "cbc 0.2.1", "cms", diff --git a/Cargo.toml b/Cargo.toml index 19fc50619..977abdab8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -100,8 +100,8 @@ rcgen = { version = "0.14.10", default-features = false, features = ["crypto", " x509-parser = { version = "0.18", features = ["verify-aws"] } pem = "3" time = "0.3" -# Return to crates.io after the Unicode certificate-label fix in p12-keystore#14 ships. -p12-keystore = { version = "0.4", git = "https://github.com/ancwrd1/p12-keystore", rev = "06bdb4da27c8b4696e1af1756a5765f21288787c" } +# 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 12638a8a1..54d507b25 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -35,26 +35,21 @@ The detailed compatibility matrix is maintained in ## SecretSpec 0.22 X.509 dependency release -The X.509 backend pins `p12-keystore` to commit -`06bdb4da27c8b4696e1af1756a5765f21288787c` from upstream for the merged -[Unicode certificate-label fix](https://github.com/ancwrd1/p12-keystore/pull/14). -The published 0.4.0 release already includes the bag-preserving archive API, -but cannot rewrap certificate subjects containing supplementary Unicode. +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. Replace the Git pin with the crates.io version containing the certificate-label - fix and update `Cargo.lock`. Set that version as the minimum requirement. - Cargo replaces Git dependencies with their registry versions when packaging; - leaving the 0.4.0 requirement would silently lose this fix in the published crate. -2. Align the workspace `secretspec-ipc` dependency with the release version +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. -3. Run the Rust tests, SDK CI, Unicode-subject interoperability checks, and +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. -4. Keep the documented BMP password restriction until +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.