Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
376 changes: 375 additions & 1 deletion 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 @@ -64,6 +64,7 @@ urlencoding = "2.1"

# Provider dependencies
age = { version = "0.11", features = ["ssh"] }
age-xwing = { git = "https://github.com/thrzl/age-xwing", tag = "0.1.0" }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Using a git dependency can introduce security and maintenance risks, as it relies on the availability and integrity of the external repository. It's generally better to use dependencies from a registry like crates.io. If age-xwing is not available on crates.io, consider forking it into this project's organization or vendoring the code to have more control over it and ensure its long-term availability.

aws-config = { version = "1", features = ["sso", "credentials-process", "credentials-login"] }
aws-sdk-kms = { version = "1" }
aws-sdk-secretsmanager = { version = "1" }
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Your `fnox.toml` config file either contains encrypted secrets or references to

### 🔐 Encryption (secrets in git, encrypted)

- [**age**](https://fnox.jdx.dev/providers/age) - Modern encryption (works with SSH keys!)
- [**age**](https://fnox.jdx.dev/providers/age) - Modern encryption (works with SSH keys and post-quantum keys!)
- [**aws-kms**](https://fnox.jdx.dev/providers/aws-kms) - AWS Key Management Service
- [**azure-kms**](https://fnox.jdx.dev/providers/azure-kms) - Azure Key Vault encryption
- [**gcp-kms**](https://fnox.jdx.dev/providers/gcp-kms) - Google Cloud KMS
Expand Down
82 changes: 81 additions & 1 deletion docs/providers/age.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Age Encryption

Age is a modern encryption tool that's simple, secure, and works beautifully with SSH keys.
Age is a modern encryption tool that's simple, secure, and works beautifully with SSH keys and post-quantum keys.

## Quick Start

Expand Down Expand Up @@ -177,6 +177,79 @@ cat ~/.ssh/id_ed25519.pub
cat ~/.ssh/id_rsa.pub
```

## Post-Quantum Key Support

fnox supports age post-quantum keys (ML-KEM-768 + X25519 hybrid encryption), providing future-proof security against quantum attacks.

### Generate Post-Quantum Key

```bash
# Generate post-quantum key (age CLI >= 1.3.0 required)
age-keygen -pq -o ~/.config/fnox/age.txt

# View the generated key
cat ~/.config/fnox/age.txt
```

Output:

```
# created: 2024-01-15T10:30:45-08:00
# public key: age1pqzwq3l6x6z8x9y0z1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p7q8r9s0t1u2v3w4x5y6z7a8b9c0d1e2f3g4h5i6j7k8l9m0n1o2p3q4r5s6t7u8v9w0x1y2z3a4b5c6d7e8f9g0h1i2j3k4l5m6n7o8p9q0r1s2t3u4v5w6x7y8z9a0b1c2d3e4f5g6h7i8j9k0l1m2n3o4p5q6r7s8t9u0v1w2x3y4z5a6b7c8d9e0f1g2h3i4j5k6l7m8n9o0p1q2r3s4t5u6v7w8x9y0z1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p7q8r9s0t1u2v3w4x5y6z7
AGE-SECRET-KEY-PQ-1ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCDEFGHIJKLMNOPQRSTUVWX
```

### Configure Post-Quantum Keys

Add to `fnox.toml`:

```toml
[providers]
age = { type = "age", recipients = ["age1pqzwq3l6x6z8x9y0z1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p7q8r9s0t1u2v3w4x5y6z7a8b9c0d1e2f3g4h5i6j7k8l9m0n1o2p3q4r5s6t7u8v9w0x1y2z3a4b5c6d7e8f9g0h1i2j3k4l5m6n7o8p9q0r1s2t3u4v5w6x7y8z9a0b1c2d3e4f5g6h7i8j9k0l1m2n3o4p5q6r7s8t7u8v9w0x1y2z3a4b5c6d7e8f9g0h1i2j3k4l5m6n7o8p9q0r1s2t3u4v5w6x7y8z9"] }
```

Set the private key:

```bash
export FNOX_AGE_KEY=$(cat ~/.config/fnox/age.txt | grep "AGE-SECRET-KEY")
```

### Post-Quantum Key Formats

- **Public keys**: Start with `age1pq` (bech32-encoded)
- **Private keys**: Start with `AGE-SECRET-KEY-PQ-`

### Post-Quantum vs Traditional Keys

| Feature | Traditional (age/ssh) | Post-Quantum |
| ----------- | --------------------- | ----------------- |
| Security | Classical | Quantum-resistant |
| Key Size | ~60 bytes (age) | ~180 bytes (PQ) |
| Performance | Faster | Slightly slower |
| Age CLI | Any version | >= 1.3.0 required |

### Limitations

::: warning Recipient Type Mixing
**You cannot mix different recipient types in a single encryption operation.** This means:

- All recipients must be either: traditional age keys OR SSH keys OR post-quantum keys
- You cannot encrypt for both `age1...` and `age1pq...` recipients in the same secret

If you need to support multiple key types, create separate providers:

```toml
[providers.age-traditional]
type = "age"
recipients = ["age1...", "ssh-ed25519 ..."]

[providers.age-pq]
type = "age"
recipients = ["age1pq..."]
```

:::

## Team Workflow

### 1. Collect Public Keys
Expand Down Expand Up @@ -345,6 +418,7 @@ jobs:
- ✅ Zero runtime dependencies (after initial setup)
- ✅ Free forever
- ✅ Works with SSH keys you already have
- ✅ Post-quantum key support for future-proof security
- ✅ Simple and secure
- ✅ Team-friendly (multiple recipients)

Expand All @@ -366,6 +440,7 @@ Your private key doesn't match any of the recipients. Check:
# Verify your public key matches a recipient
cat ~/.config/fnox/age.txt # Check public key
cat ~/.ssh/id_ed25519.pub # Check SSH public key
cat ~/.config/fnox/age-pq.txt # Check post-quantum public key

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

There's an inconsistency in the documentation. The key generation example earlier uses ~/.config/fnox/age.txt as the output file, but this troubleshooting command refers to ~/.config/fnox/age-pq.txt. To avoid confusion, the filename should be consistent across the document.

Suggested change
cat ~/.config/fnox/age-pq.txt # Check post-quantum public key
cat ~/.config/fnox/age.txt # Check post-quantum public key


# Compare with fnox.toml recipients
cat fnox.toml | grep recipients
Expand All @@ -383,6 +458,11 @@ cat fnox.toml | grep recipients
- Check that the private key file path is correct
- Ensure the private key is NOT password-protected

### Post-quantum key not working

- Check that the public key starts with `age1pq`
- Check that the private key starts with `AGE-SECRET-KEY-PQ-`

## Next Steps

- [Real-World Example](/guide/real-world-example) - Complete project setup with age
Expand Down
14 changes: 7 additions & 7 deletions docs/providers/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ fnox supports multiple secret storage and encryption providers. Choose the ones

Store encrypted secrets in your `fnox.toml` file. The encrypted ciphertext is safe to commit to version control.

| Provider | Description | Best For |
| --------------------------------- | ---------------------------------------- | ----------------------------------------- |
| [age](/providers/age) | Modern encryption (works with SSH keys!) | Development secrets, open source projects |
| [AWS KMS](/providers/aws-kms) | AWS Key Management Service | AWS-based projects requiring IAM control |
| [Azure KMS](/providers/azure-kms) | Azure Key Vault encryption | Azure-based projects |
| [GCP KMS](/providers/gcp-kms) | Google Cloud KMS | GCP-based projects |
| Provider | Description | Best For |
| --------------------------------- | --------------------------------------------------------- | ----------------------------------------- |
| [age](/providers/age) | Modern encryption (works with SSH keys and post-quantum!) | Development secrets, open source projects |
| [AWS KMS](/providers/aws-kms) | AWS Key Management Service | AWS-based projects requiring IAM control |
| [Azure KMS](/providers/azure-kms) | Azure Key Vault encryption | Azure-based projects |
| [GCP KMS](/providers/gcp-kms) | Google Cloud KMS | GCP-based projects |

### ☁️ Cloud Secret Storage (remote, centralized)

Expand Down Expand Up @@ -86,7 +86,7 @@ DATABASE_URL = { provider = "aws", value = "database-url" }

Choose a provider and get started:

- [Age Encryption](/providers/age) - Simple, free, works with SSH keys
- [Age Encryption](/providers/age) - Simple, free, works with SSH keys and post-quantum keys
- [AWS Parameter Store](/providers/aws-ps) - Simple, cost-effective AWS secret storage
- [AWS Secrets Manager](/providers/aws-sm) - For AWS production workloads with rotation
- [1Password](/providers/1password) - Leverage existing 1Password setup
Expand Down
1 change: 1 addition & 0 deletions mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ bitwarden-secrets-manager = "latest"
vault = "latest"
infisical = "latest"
usage = "latest"
rust = "latest"

[tasks.test]
description = "Run both cargo and bats tests"
Expand Down
82 changes: 58 additions & 24 deletions src/providers/age.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use crate::env;
use crate::error::{FnoxError, Result};
use age::secrecy::SecretString;
use age_xwing::{HybridIdentity, HybridRecipient};
use async_trait::async_trait;
use std::io::Read;
use std::path::PathBuf;
Expand Down Expand Up @@ -39,7 +41,21 @@ impl crate::providers::Provider for AgeEncryptionProvider {
let mut parsed_recipients: Vec<Box<dyn age::Recipient + Send + Sync>> = Vec::new();

for recipient in &self.recipients {
// Try parsing as SSH recipient first
// Try parsing as post-quantum recipient first
if recipient.starts_with("age1pq") {
let pq_recipient = HybridRecipient::from_string(recipient).map_err(|e| {
FnoxError::AgeEncryptionFailed {
details: format!(
"Failed to parse post-quantum recipient '{}': {}",
recipient, e
),
}
})?;
parsed_recipients.push(Box::new(pq_recipient));
continue;
}

// Try parsing as SSH recipient
if let Ok(ssh_recipient) = recipient.parse::<age::ssh::Recipient>() {
parsed_recipients.push(Box::new(ssh_recipient));
continue;
Expand All @@ -58,6 +74,9 @@ impl crate::providers::Provider for AgeEncryptionProvider {
}
}

// Note: The age library does not support mixing different recipient types (e.g., x25519 and post-quantum)
// in the same encryption operation due to incompatible labels. All recipients must be of the same type.

if parsed_recipients.is_empty() {
return Err(FnoxError::AgeNotConfigured);
}
Expand Down Expand Up @@ -156,30 +175,45 @@ impl crate::providers::Provider for AgeEncryptionProvider {

// Try parsing as SSH identity first, then fall back to age identity file
let identities = {
let mut cursor = std::io::Cursor::new(identity_content.as_bytes());

// First try to parse as SSH identity
match age::ssh::Identity::from_buffer(
&mut cursor,
key_file_path_opt
.as_ref()
.map(|p| p.to_string_lossy().to_string()),
) {
Ok(ssh_identity) => {
// SSH identity parsed successfully
vec![Box::new(ssh_identity) as Box<dyn age::Identity>]
}
Err(_) => {
// Not an SSH identity, try age identity file
cursor.set_position(0);
age::IdentityFile::from_buffer(cursor)
.map_err(|e| FnoxError::AgeIdentityParseFailed {
details: e.to_string(),
})?
.into_identities()
// Check if identity content contains post-quantum key
if let Some(pq_line) = identity_content
.lines()
.find(|line| line.starts_with("AGE-SECRET-KEY-PQ-"))
{
// Parse as HybridIdentity
let identity =
HybridIdentity::from_string(SecretString::new(pq_line.to_string().into()))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The .into() call here is redundant because pq_line.to_string() already returns a String. You can remove it to improve code clarity.

Suggested change
HybridIdentity::from_string(SecretString::new(pq_line.to_string().into()))
HybridIdentity::from_string(SecretString::new(pq_line.to_string()))

.map_err(|e| FnoxError::AgeIdentityParseFailed {
details: e.to_string(),
})?
details: format!("Failed to parse post-quantum identity: {}", e),
})?;

vec![Box::new(identity) as Box<dyn age::Identity>]
} else {
let mut cursor = std::io::Cursor::new(identity_content.as_bytes());

// First try to parse as SSH identity
match age::ssh::Identity::from_buffer(
&mut cursor,
key_file_path_opt
.as_ref()
.map(|p| p.to_string_lossy().to_string()),
) {
Ok(ssh_identity) => {
// SSH identity parsed successfully
vec![Box::new(ssh_identity) as Box<dyn age::Identity>]
}
Err(_) => {
// Not an SSH identity, try age identity file
cursor.set_position(0);
age::IdentityFile::from_buffer(cursor)
.map_err(|e| FnoxError::AgeIdentityParseFailed {
details: e.to_string(),
})?
.into_identities()
.map_err(|e| FnoxError::AgeIdentityParseFailed {
details: e.to_string(),
})?
}
}
}
};
Expand Down
90 changes: 90 additions & 0 deletions test/age.bats
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,93 @@ EOF
assert_success
assert_output "secret-value"
}

@test "decrypts using post-quantum age keys" {
# Skip if age not installed
if ! command -v age-keygen >/dev/null 2>&1; then
skip "age-keygen not installed"
fi

# Check if age supports -pq flag (version 1.3.0+)
if ! age-keygen --help 2>&1 | grep -q "\-pq"; then
skip "age does not support post-quantum keys (need age >= 1.3.0)"
fi

# Generate post-quantum age key
local keygen_output
keygen_output=$(age-keygen -pq -o key.txt 2>&1)
local public_key
public_key=$(echo "$keygen_output" | grep "^Public key:" | cut -d' ' -f3)
local private_key
private_key=$(grep "^AGE-SECRET-KEY-PQ" key.txt)

# Create config with post-quantum provider
cat >fnox.toml <<EOF
root = true

[providers.age]
type = "age"
recipients = ["$public_key"]

[secrets]
EOF

# Set a secret
run "$FNOX_BIN" set MY_SECRET "secret-value"
assert_success

# Verify the secret was encrypted
assert_config_contains "MY_SECRET"
assert_config_not_contains "secret-value"

# Should be able to get it back
export FNOX_AGE_KEY=$private_key
run "$FNOX_BIN" get MY_SECRET
assert_success
assert_output "secret-value"
}

@test "supports mixed recipient types (x25519 and post-quantum)" {
# Skip if age not installed
if ! command -v age-keygen >/dev/null 2>&1; then
skip "age-keygen not installed"
fi

if ! age-keygen --help 2>&1 | grep -q "\-pq"; then
skip "age does not support post-quantum keys (need age >= 1.3.0)"
fi

# Generate regular age key
local regular_output
regular_output=$(age-keygen -o regular.txt 2>&1)
local regular_public
regular_public=$(echo "$regular_output" | grep "^Public key:" | cut -d' ' -f3)
local _regular_private
_regular_private=$(grep "^AGE-SECRET-KEY-1" regular.txt)

# Generate post-quantum age key
local pq_output
pq_output=$(age-keygen -pq -o pq.txt 2>&1)
local pq_public
pq_public=$(echo "$pq_output" | grep "^Public key:" | cut -d' ' -f3)
local _pq_private
_pq_private=$(grep "^AGE-SECRET-KEY-PQ" pq.txt)

# Create config with both recipient types
# Note: The age library currently does not support mixing x25519 and post-quantum
# recipients in the same encryption operation due to incompatible labels
cat >fnox.toml <<EOF
root = true

[providers.age]
type = "age"
recipients = ["$regular_public", "$pq_public"]

[secrets]
EOF

# This is expected to fail with IncompatibleRecipients error
# The age format requires all recipients to have compatible labels
run "$FNOX_BIN" set MY_SECRET "secret-value"
assert_failure
}
Loading