Skip to content

feat: add age provider#174

Open
ap-1 wants to merge 4 commits into
cachix:mainfrom
ap-1:feat/age-provider
Open

feat: add age provider#174
ap-1 wants to merge 4 commits into
cachix:mainfrom
ap-1:feat/age-provider

Conversation

@ap-1

@ap-1 ap-1 commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Closes #4

  • The file holds every secret as a KEY=value entry, and project and profile do not namespace it, the same as the dotenv provider
  • Routing different secrets to different keys or files is left to secretspec's existing per-secret provider selection
  • Writing a secret re-encrypts the whole blob to the current recipients, so rekeying happens as a side effect of any write
  • The provider encrypts to your own identity when no ?recipients-file= is given, and to the committed roster file when one is
  • The identity resolves the way the vault provider resolves its token, from the identity credential, then AGE_IDENTITY, then a ?identity= file
  • Output is ASCII-armored by default so git diffs stay readable, and reads detect armored or binary automatically
  • Identities and recipients are parsed through age's plugin machinery (IdentityFile and RecipientPluginV1 with NoCallbacks), so every age-plugin-*works automatically

I verified the post-quantum path on my personal system since it needs age-plugin-pq on PATH, the remaining behavior is covered in the unit tests

ap-1 added 2 commits July 19, 2026 19:29
Signed-off-by: Anish Pallati <i@anish.land>
Assisted-by: Claude Opus 4.8
Signed-off-by: Anish Pallati <i@anish.land>
Assisted-by: Claude Opus 4.8

@domenkozar domenkozar left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review findings are attached inline: one P1 correctness issue and six P2 compatibility, auditability, and documentation issues.

Comment thread secretspec/src/provider/age.rs Outdated
/// Parses the configured identity file from credential, env, or path
fn identity_file(&self) -> Result<IdentityFile<NoCallbacks>> {
if let Some(material) = credential_or_env(&self.credentials, IDENTITY, AGE_IDENTITY_ENV) {
return IdentityFile::from_buffer(std::io::Cursor::new(material.into_bytes()))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — Parse SSH private keys before age identity files

When ?recipients-file= contains the documented ssh-ed25519 or ssh-rsa recipient and the matching OpenSSH private key is supplied through identity, this path rejects it because age::IdentityFile parses only X25519 and plugin identities. A team-mode write can therefore succeed, but subsequent SecretSpec reads using that SSH recipient fail. Detect age::ssh::Identity first and include it among the decryption identities.

if let Ok(r) = s.parse::<age::ssh::Recipient>() {
return Ok(Box::new(r));
}
if let Ok(r) = s.parse::<age::plugin::Recipient>() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — Parse native tagged recipients before plugin recipients

When a roster contains a valid age 0.12 native age1tag... or age1tagpq... recipient, this fallback misclassifies it as a plugin recipient and requires age-plugin-tag or age-plugin-tagpq. The age crate exposes native tag::Recipient and tagpq::Recipient, and its recipients-file parser accepts them, so parse those before the plugin fallback.

Comment thread secretspec/src/provider/age.rs Outdated
}

fn uri(&self) -> String {
format!("age:{}", self.config.path.display())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — Preserve non-secret age options in the reported URI

Provider::uri() feeds audit logs, fallback warnings, and resolution reports and is documented to preserve non-secret configuration, but this drops recipients-file and armor=false. A write that changes the reader roster is therefore recorded identically to a default write and cannot be reproduced from the report. Append the encoded non-secret query options while continuing to exclude identity material.

Comment thread docs/src/content/docs/providers/age.md Outdated
## Quick start

```bash
$ secretspec set DATABASE_URL --provider "age://secrets.age?identity=~/.config/age/keys.txt"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — Replace the literal tilde in the quick start

Because the URI is quoted, the shell does not expand ~, and the provider instead rebases it to <project>/~/.config/age/keys.txt. Copying either quick-start command therefore fails even when the key exists under the user's home directory. Use $HOME or an absolute path and update the repeated URI example as well.

Comment thread secretspec/Cargo.toml
uuid.workspace = true
data-encoding.workspace = true
detect-coding-agent.workspace = true
age = { workspace = true, optional = true }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — Add the age provider to the unreleased changelog

This Rust feature adds a default provider but leaves /CHANGELOG.md unchanged. The repository instructions require a user-facing entry under the existing Unreleased section for every Rust change; otherwise the 0.17 release notes will omit the new provider.

Comment thread docs/src/pages/index.astro Outdated
{ icon: 'googlecloud', slug: 'gcsm', label: 'Google Cloud Secret Manager' },
{ icon: 'microsoftazure', slug: 'akv', label: 'Azure Key Vault' },
{ slug: 'infisical', label: 'Infisical' },
{ slug: 'age', label: 'age' },

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — Mark age as upcoming across the landing page

The documentation publishes from main while the released CLI is 0.16, but this marquee renders plain age without the required (0.17+), making an unavailable provider look current. The new-provider checklist also requires adding age to the landing-page secretspec config init snippets, which remain unchanged. Label this entry and add age ... (0.17+) there too.

Comment thread docs/src/content/docs/providers/age.md Outdated

### Plugins and post-quantum keys

Identity and recipient parsing both go through age's plugin protocol, so keys handled by an `age-plugin-*` binary work when that binary is on `PATH`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — Qualify support for callback-dependent age plugins

This statement is false for plugins that issue confirm, request-public, or request-secret: recipient and identity plugins are constructed with NoCallbacks, which returns None and suppresses messages. Hardware-, PIN-, or passphrase-dependent plugins can therefore fail despite being on PATH. Either provide callbacks or document that only non-interactive plugins are supported.

@domenkozar

Copy link
Copy Markdown
Member

Implemented all seven findings from the inline review in commit db728f9.

Highlights:

  • Parse OpenSSH private identities directly and native age1tag / age1tagpq recipients before plugin fallback.
  • Preserve encoded recipients-file and armor=false options in reported URIs while excluding identity material.
  • Fix path examples, qualify non-interactive plugin support, recommend hybrid ML-KEM-768 + X25519 keys for new setups, and explain that static file encryption does not provide forward secrecy.
  • Add the 0.17 landing-page visibility and Unreleased changelog entry.

Validation: pre-commit Clippy and rustfmt passed; cargo test --all passed; all 9 focused age-provider tests passed; the Astro docs build and git diff --check passed. The additional rustdoc warnings-as-errors run remains blocked only by nine pre-existing private intra-doc links on main.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support Age

2 participants