Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
646 changes: 634 additions & 12 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ rsa = { version = "0.9", features = ["pem"] }
uuid = { version = "1", features = ["v4"] }
data-encoding = "2"
detect-coding-agent = "0.1"
age = { version = "0.12", features = ["armor", "plugin", "ssh"] }

# The profile that 'dist' will build with
[profile.dist]
Expand Down
7 changes: 6 additions & 1 deletion docs/astro.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ $ secretspec import dotenv://.env.production

## Providers

Secrets can be stored in: keyring (default), dotenv files, environment variables, 1Password, Gopass (0.15+), LastPass, Pass, Proton Pass, Google Cloud Secret Manager, AWS Secrets Manager, HashiCorp Vault / OpenBao, Bitwarden Secrets Manager, Azure Key Vault, or Infisical (0.16+).`,
Secrets can be stored in: keyring (default), dotenv files, environment variables, 1Password, Gopass (0.15+), LastPass, Pass, Proton Pass, Google Cloud Secret Manager, AWS Secrets Manager, HashiCorp Vault / OpenBao, Bitwarden Secrets Manager, Azure Key Vault, Infisical (0.16+), or age (0.17+).`,
}),
],
title: "SecretSpec",
Expand Down Expand Up @@ -228,6 +228,11 @@ Secrets can be stored in: keyring (default), dotenv files, environment variables
slug: "providers/infisical",
badge: { text: "0.16+", variant: "note" },
},
{
label: "age",
slug: "providers/age",
badge: { text: "0.17+", variant: "note" },
},
],
},
{
Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/concepts/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ DATABASE_URL = { description = "Production database", providers = ["prod_vault"]
| [bws](/providers/bws/) | Bitwarden Secrets Manager (requires the `bws` build feature) | ✓ | ✓ | ✓ |
| [akv](/providers/akv/) | Azure Key Vault (requires the `akv` build feature) | ✓ | ✓ | ✓ |
| [infisical](/providers/infisical/) (0.16+) | Infisical (requires the `infisical` build feature) | ✓ | ✓ | ✓ |
| [age](/providers/age/) (0.17+) | An age-encrypted file (requires the `age` build feature) | ✓ | ✓ | ✓ |

Each provider page starts with a minimal working example, then covers setup,
project configuration, storage conventions, existing provider-native secrets,
Expand Down
131 changes: 131 additions & 0 deletions docs/src/content/docs/providers/age.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
---
title: age Provider
description: Store secrets in an age-encrypted file committed alongside code
---

:::note[Version compatibility]
The age provider is an upcoming SecretSpec 0.17 feature and is not available in SecretSpec 0.16.
:::

The age provider keeps secrets in a single [age](https://age-encryption.org)-encrypted file that you can commit to your repository. The plaintext inside is a dotenv-style `KEY=value` blob that SecretSpec encrypts to one or more age recipients and decrypts with your age identity. A read decrypts the blob; a write decrypts it, updates one key, and re-encrypts the whole blob to the current recipients.

## At a glance

| | |
| --- | --- |
| Provider | `age` |
| URI | `age://<path>[?options]` |
| Access | Read and write |
| Best for | Encrypted secrets committed alongside code |
| Authentication | An age identity (private key) |
| Build feature | `age` |
| Default storage | A dotenv blob at the configured path, keyed by the secret name |

## Quick start

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

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.

Enter value for DATABASE_URL: postgresql://localhost/mydb
✓ Secret 'DATABASE_URL' saved to age (profile: default)

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

With no recipients configured the blob is encrypted to your own identity, so the same key that reads it also writes it.

## Setup

### Prerequisites

- An age identity, created with `age-keygen`
- Build with `--features age`

### Identity

The private key is resolved from the first of these sources: the `identity` provider credential, the `AGE_IDENTITY` environment variable holding the key material, or `?identity=<path>` naming an identity file. The credential and environment forms carry the key material directly; the URI form names a file on disk. Routing the identity through the credential system lets the age key itself be a managed secret, for example one stored in the system keyring.

### Recipients

Recipients are age public keys and are never secret, so they are configured rather than supplied as credentials. With no `?recipients-file=`, the blob is encrypted to the public key derived from your own identity. To share the file, point `?recipients-file=` at a roster file listing every recipient.

A roster is a plain text file in age's recipients format: one recipient per line, `#` for comments, blank lines ignored. Recipients may be `age1...` keys or `ssh-ed25519`/`ssh-rsa` keys.

```text title="secrets.age.recipients"
# alice
age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
# a deploy host
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...
```

Because an age file does not record its recipients, every write re-encrypts to whatever `?recipients-file=` names at that moment. Keep that file complete and committed so a write never drops a reader. When the roster changes, run a write against each secret to re-encrypt it to the new set.

### Plugins and post-quantum keys

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

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.


Post-quantum keys need one extra step. SecretSpec is built on the Rust age library, which does not read the native `AGE-SECRET-KEY-PQ-1` identity form that `age-keygen -pq` writes. Convert it to the plugin form once with `age-plugin-pq`:

```bash
$ age-plugin-pq -identity < keys.txt > plugin-identity.txt
$ secretspec get DATABASE_URL --provider "age://secrets.age?identity=plugin-identity.txt"
```

## Configuration

### URI format

```text
age://<path>[?key=value&...]
```

- `path`: the encrypted blob file, resolved against the project root when relative
- `?identity=<path>`: identity file, used when no credential or `AGE_IDENTITY` is set
- `?recipients-file=<path>`: roster of recipient public keys; without it, encrypt to your own identity
- `?armor=false`: write a binary blob instead of the default ASCII armor

### URI examples

```text
age://secrets.age
age://secrets.age?identity=~/.config/age/keys.txt
age://secrets.age?recipients-file=secrets.age.recipients
age://secrets.age?armor=false
```

### Project configuration

```toml title="secretspec.toml"
[providers]
team_age = "age://secrets.age?recipients-file=secrets.age.recipients"

[profiles.production]
DATABASE_URL = { description = "Database URL", providers = ["team_age"] }
```

Each developer configures their own identity through the `identity` credential, `AGE_IDENTITY`, or a personal `?identity=`, while the committed roster and blob path stay the same for everyone.

## Storage model

Every secret is one `KEY=value` entry inside the blob, keyed by the secret name. Project and profile do not appear in the file; point separate profiles at separate blobs to keep them apart, for example `secrets.prod.age` and `secrets.dev.age`.

## Use existing secrets

A secret's [`ref`](/reference/configuration/#secret-references) names the key to read inside the blob, so a declared secret can map to a differently named entry. The age provider has no sub-address, so a `ref` sets only `item`.

```toml title="secretspec.toml"
[profiles.production]
DATABASE_URL = { description = "DB", ref = { item = "POSTGRES_URL" }, providers = ["age://secrets.age"] }
```

## CI/CD

Commit the blob and its roster, and give the job an identity to decrypt with. The identity is a natural fit for the `identity` provider credential (sourced from another provider) or the `AGE_IDENTITY` environment variable:

```bash
$ export AGE_IDENTITY="$CI_AGE_IDENTITY"
$ secretspec run --provider "age://secrets.age" -- deploy
```

## Security considerations

A recipient can decrypt every secret in a blob, not individual entries within it. Put secrets that should reach different audiences in separate files, each with its own roster.
1 change: 1 addition & 0 deletions docs/src/content/docs/quick-start.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ $ secretspec config init
bws: Bitwarden Secrets Manager
akv: Azure Key Vault
infisical: Infisical secret management (0.16+)
age: age-encrypted file (0.17+)
? Select your default profile:
> development
default
Expand Down
16 changes: 16 additions & 0 deletions docs/src/content/docs/reference/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,21 @@ the `production` environment. Projects whose environments do not correspond to p
Values are read with Infisical's secret references expanded, matching its own CLI, so a value of
`postgres://${DB_USER}@host` arrives resolved.

## age Provider (0.17+)

**URI**: `age://PATH[?identity=FILE][&recipients-file=FILE][&armor=false]` - Stores secrets in a single age-encrypted file committed alongside code

```bash
age://secrets.age # Encrypt to your own identity
age://secrets.age?identity=~/.config/age/keys.txt # Name the identity file
age://secrets.age?recipients-file=secrets.age.recipients # Share with a roster
```

**Features**: Read/write, committed-file storage, `age-plugin-*` recipients and identities
**Prerequisites**: An age identity from `age-keygen`, build with `--features age`
**Authentication**: The `identity` credential, `AGE_IDENTITY`, or `?identity=`; recipients from `?recipients-file=` or derived from the identity
**Storage**: One `KEY=value` entry per secret inside the encrypted blob at PATH

## Provider Selection

### Command Line
Expand Down Expand Up @@ -266,3 +281,4 @@ export SECRETSPEC_PROVIDER="dotenv:///config/.env"
| BWS | ✅ End-to-end | Cloud (Bitwarden) | ✅ Yes |
| AKV | ✅ Azure-managed | Cloud (Azure) | ✅ Yes |
| Infisical (0.16+) | ✅ Infisical-managed | Cloud (Infisical) or self-hosted | ✅ Yes |
| age (0.17+) | ✅ age encryption | Local filesystem | ❌ No |
1 change: 1 addition & 0 deletions docs/src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const providerMetadata: ProviderMetadata[] = [
{ icon: 'googlecloud', slug: 'gcsm', label: 'Google Cloud Secret Manager' },
{ icon: 'microsoftazure', slug: 'akv', label: 'Azure Key Vault' },
{ slug: 'infisical', label: 'Infisical' },
{ slug: 'age', label: 'age' },

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.

{ slug: 'gopass', label: 'Gopass' },
{ slug: 'protonpass', label: 'Proton Pass' },
{ icon: 'gnuprivacyguard', slug: 'pass', label: 'Pass (GPG)' },
Expand Down
4 changes: 3 additions & 1 deletion secretspec/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,10 @@ rsa.workspace = true
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.


[features]
default = ["cli", "keyring", "gcsm", "awssm", "vault", "bws", "akv", "infisical"]
default = ["cli", "keyring", "gcsm", "awssm", "vault", "bws", "akv", "infisical", "age"]
cli = ["dep:toml_edit"]
keyring = ["dep:keyring", "dep:whoami"]
# Compile libdbus from source into the binary instead of linking the system
Expand All @@ -68,3 +69,4 @@ vault = ["dep:reqwest"]
infisical = ["dep:reqwest", "tokio/sync"]
bws = ["dep:bitwarden", "dep:rustls"]
akv = ["dep:azure_core", "dep:azure_identity", "dep:azure_security_keyvault_secrets"]
age = ["dep:age"]
3 changes: 3 additions & 0 deletions secretspec/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ SecretSpec fixes this by separating secret **declaration** from secret **storage
- [Bitwarden Secrets Manager](https://secretspec.dev/providers/bws)
- [Azure Key Vault](https://secretspec.dev/providers/akv)
- [Infisical](https://secretspec.dev/providers/infisical) (0.16+)
- [age](https://secretspec.dev/providers/age) (0.17+)
- **[Type-Safe Rust SDK](https://secretspec.dev/sdk/rust/)**: Generate strongly-typed structs from your `secretspec.toml` for compile-time safety
- **[Profile Support](https://secretspec.dev/concepts/profiles/)**: Override secret requirements and defaults per profile (development, production, etc.)
- **[Secret Generation](https://secretspec.dev/concepts/generation/)**: Auto-generate passwords, tokens, UUIDs, and more when secrets are missing — declarative "generate if absent"
Expand Down Expand Up @@ -68,6 +69,7 @@ $ secretspec config init
bws: Bitwarden Secrets Manager
akv: Azure Key Vault
infisical: Infisical secret management (0.16+)
age: age-encrypted file (0.17+)
? Select your default profile:
> development
default
Expand Down Expand Up @@ -153,6 +155,7 @@ SecretSpec supports multiple storage backends for secrets:
- **[Bitwarden Secrets Manager](https://secretspec.dev/providers/bws)** - Bitwarden Secrets Manager integration
- **[Azure Key Vault](https://secretspec.dev/providers/akv)** - Azure secret management
- **[Infisical](https://secretspec.dev/providers/infisical)** (0.16+) - Infisical secret management
- **[age](https://secretspec.dev/providers/age)** (0.17+) - age-encrypted file

```bash
$ secretspec run --provider keyring -- npm start
Expand Down
Loading
Loading