diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 836abae6..61b922db 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -16,19 +16,20 @@ jobs:
- {GOOS: linux, GOARCH: amd64}
- {GOOS: linux, GOARCH: arm, GOARM: 6}
- {GOOS: linux, GOARCH: arm64}
- - {GOOS: darwin, GOARCH: amd64}
- {GOOS: darwin, GOARCH: arm64}
- {GOOS: windows, GOARCH: amd64}
- {GOOS: freebsd, GOARCH: amd64}
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
with:
fetch-depth: 0
+ persist-credentials: false
- name: Install Go
uses: actions/setup-go@v5
with:
go-version: 1.x
+ cache: false
- name: Build binary
run: |
cp LICENSE "$RUNNER_TEMP/LICENSE"
diff --git a/.github/workflows/ronn.yml b/.github/workflows/ronn.yml
index 9d4d0ae3..a0001070 100644
--- a/.github/workflows/ronn.yml
+++ b/.github/workflows/ronn.yml
@@ -13,23 +13,23 @@ jobs:
name: Ronn
runs-on: ubuntu-latest
steps:
- - name: Checkout
- uses: actions/checkout@v4
- - name: Install ronn
- run: sudo apt-get update && sudo apt-get install -y ronn
- - name: Run ronn
- run: bash -O globstar -c 'ronn **/*.ronn'
- - name: Undo email mangling
- # rdiscount randomizes the output for no good reason, which causes
- # changes to always get committed. Sigh.
- # https://github.com/davidfstr/rdiscount/blob/6b1471ec3/ext/generate.c#L781-L795
- run: |-
- for f in doc/*.html; do
- awk '/Filippo Valsorda/ { $0 = "
Filippo Valsorda age@filippo.io
" } { print }' "$f" > "$f.tmp"
- mv "$f.tmp" "$f"
- done
- - name: Upload generated files
- uses: actions/upload-artifact@v4
+ - uses: actions/checkout@v5
+ with:
+ persist-credentials: false
+ - uses: geomys/sandboxed-step@v1.2.1
+ with:
+ persist-workspace-changes: true
+ run: |
+ sudo apt-get update && sudo apt-get install -y ronn
+ bash -O globstar -c 'ronn **/*.ronn'
+ # rdiscount randomizes the output for no good reason, which causes
+ # changes to always get committed. Sigh.
+ # https://github.com/davidfstr/rdiscount/blob/6b1471ec3/ext/generate.c#L781-L795
+ for f in doc/*.html; do
+ awk '/Filippo Valsorda/ { $0 = "Filippo Valsorda age@filippo.io
" } { print }' "$f" > "$f.tmp"
+ mv "$f.tmp" "$f"
+ done
+ - uses: actions/upload-artifact@v4
with:
name: man-pages
path: |
@@ -42,10 +42,10 @@ jobs:
contents: write
runs-on: ubuntu-latest
steps:
- - name: Checkout
- uses: actions/checkout@v4
- - name: Download generated files
- uses: actions/download-artifact@v4
+ - uses: actions/checkout@v5
+ with:
+ persist-credentials: true
+ - uses: actions/download-artifact@v4
with:
name: man-pages
path: doc/
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 4331fa34..18c656e6 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -1,55 +1,79 @@
name: Go tests
-on: [push, pull_request]
+on:
+ push:
+ pull_request:
+ schedule: # daily at 09:42 UTC
+ - cron: '42 9 * * *'
+ workflow_dispatch:
permissions:
contents: read
jobs:
test:
- name: Test
strategy:
fail-fast: false
matrix:
- go: [1.19.x, 1.x]
- os: [ubuntu-latest, macos-latest, windows-latest]
+ go:
+ - { go-version: stable }
+ - { go-version: oldstable }
+ - { go-version-file: go.mod }
+ os:
+ - ubuntu-latest
+ - macos-latest
+ - windows-latest
runs-on: ${{ matrix.os }}
steps:
- - name: Install Go ${{ matrix.go }}
- uses: actions/setup-go@v5
- with:
- go-version: ${{ matrix.go }}
- - name: Checkout repository
- uses: actions/checkout@v4
- with:
- fetch-depth: 0
- - name: Run tests
- run: go test -race ./...
- gotip:
- name: Test (Go tip)
+ - uses: actions/checkout@v5
+ with:
+ persist-credentials: false
+ - uses: actions/setup-go@v6
+ with:
+ go-version: ${{ matrix.go.go-version }}
+ go-version-file: ${{ matrix.go.go-version-file }}
+ - run: |
+ go test -race ./...
+ test-latest:
+ runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
- os: [ubuntu-latest, macos-latest, windows-latest]
- runs-on: ${{ matrix.os }}
+ go:
+ - { go-version: stable }
+ - { go-version: oldstable }
+ - { go-version-file: go.mod }
+ steps:
+ - uses: actions/checkout@v5
+ with:
+ persist-credentials: false
+ - uses: actions/setup-go@v6
+ with:
+ go-version: ${{ matrix.go.go-version }}
+ go-version-file: ${{ matrix.go.go-version-file }}
+ - uses: geomys/sandboxed-step@v1.2.1
+ with:
+ run: |
+ go get -u -t ./...
+ go test -race ./...
+ staticcheck:
+ runs-on: ubuntu-latest
steps:
- - name: Install bootstrap Go
- uses: actions/setup-go@v5
+ - uses: actions/checkout@v5
+ with:
+ persist-credentials: false
+ - uses: actions/setup-go@v6
with:
go-version: stable
- - name: Install Go tip (UNIX)
- if: runner.os != 'Windows'
- run: |
- git clone --filter=tree:0 https://go.googlesource.com/go $HOME/gotip
- cd $HOME/gotip/src && ./make.bash
- echo "$HOME/gotip/bin" >> $GITHUB_PATH
- - name: Install Go tip (Windows)
- if: runner.os == 'Windows'
- run: |
- git clone --filter=tree:0 https://go.googlesource.com/go $HOME/gotip
- cd $HOME/gotip/src && ./make.bat
- echo "$HOME/gotip/bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
- - name: Checkout repository
- uses: actions/checkout@v4
- with:
- fetch-depth: 0
- - run: go version
- - name: Run tests
- run: go test -race ./...
+ - uses: geomys/sandboxed-step@v1.2.1
+ with:
+ run: go run honnef.co/go/tools/cmd/staticcheck@latest ./...
+ govulncheck:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+ with:
+ persist-credentials: false
+ - uses: actions/setup-go@v6
+ with:
+ go-version: stable
+ - uses: geomys/sandboxed-step@v1.2.1
+ with:
+ run: go run golang.org/x/vuln/cmd/govulncheck@latest ./...
diff --git a/README.md b/README.md
index 3232002f..ea70f098 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@
age is a simple, modern and secure file encryption tool, format, and Go library.
-It features small explicit keys, no config options, and UNIX-style composability.
+It features small explicit keys, post-quantum support, no config options, and UNIX-style composability.
```
$ age-keygen -o key.txt
@@ -25,13 +25,13 @@ $ age --decrypt -i key.txt data.tar.gz.age > data.tar.gz
🦀 An alternative interoperable Rust implementation is available at [github.com/str4d/rage](https://github.com/str4d/rage).
-🌍 [Typage](https://github.com/FiloSottile/typage) is a TypeScript implementation. It works in the browser, in Node.js, and in Bun.
+🌍 [Typage](https://github.com/FiloSottile/typage) is a TypeScript implementation. It works in the browser, Node.js, Deno, and Bun.
🔑 Hardware PIV tokens such as YubiKeys are supported through the [age-plugin-yubikey](https://github.com/str4d/age-plugin-yubikey) plugin.
✨ For more plugins, implementations, tools, and integrations, check out the [awesome age](https://github.com/FiloSottile/awesome-age) list.
-💬 The author pronounces it `[aɡe̞]` [with a hard *g*](https://translate.google.com/?sl=it&text=aghe), like GIF, and is always spelled lowercase.
+💬 The author pronounces it `[aɡe̞]` [with a hard *g*](https://translate.google.com/?sl=it&text=aghe), like GIF, and it's always spelled lowercase.
## Installation
@@ -229,6 +229,28 @@ $ age -R recipients.txt example.jpg > example.jpg.age
If the argument to `-R` (or `-i`) is `-`, the file is read from standard input.
+### Post-quantum keys
+
+To generate hybrid post-quantum keys, which are secure against future quantum
+computer attacks, use the `-pq` flag with `age-keygen`. This may become the
+default in the future.
+
+Post-quantum identities start with `AGE-SECRET-KEY-PQ-1...` and recipients with
+`age1pq1...`. The recipients are unfortunately ~2000 characters long.
+
+```
+$ age-keygen -pq -o key.txt
+$ age-keygen -y key.txt > recipient.txt
+$ age -R recipient.txt example.jpg > example.jpg.age
+$ age -d -i key.txt example.jpg.age > example.jpg
+```
+
+Support for post-quantum keys is built into age v1.3.0 and later. Alternatively,
+the `age-plugin-pq` binary can be installed and placed in `$PATH` to add support
+to any version and implementation of age that supports plugins. Recipients will
+work out of the box, while identities will have to be converted to plugin
+identities with `age-plugin-pq -identity`.
+
### Passphrases
Files can be encrypted with a passphrase by using `-p/--passphrase`. By default age will automatically generate a secure passphrase. Passphrase protected files are automatically detected at decrypt time.
diff --git a/age.go b/age.go
index eb34c3e4..00c39f61 100644
--- a/age.go
+++ b/age.go
@@ -6,9 +6,9 @@
// specification.
//
// For most use cases, use the [Encrypt] and [Decrypt] functions with
-// [X25519Recipient] and [X25519Identity]. If passphrase encryption is required, use
-// [ScryptRecipient] and [ScryptIdentity]. For compatibility with existing SSH keys
-// use the filippo.io/age/agessh package.
+// [HybridRecipient] and [HybridIdentity]. If passphrase encryption is
+// required, use [ScryptRecipient] and [ScryptIdentity]. For compatibility with
+// existing SSH keys use the filippo.io/age/agessh package.
//
// age encrypted files are binary and not malleable. For encoding them as text,
// use the filippo.io/age/armor package.
@@ -26,13 +26,13 @@
// There is no default path for age keys. Instead, they should be stored at
// application-specific paths. The CLI supports files where private keys are
// listed one per line, ignoring empty lines and lines starting with "#". These
-// files can be parsed with ParseIdentities.
+// files can be parsed with [ParseIdentities].
//
// When integrating age into a new system, it's recommended that you only
-// support X25519 keys, and not SSH keys. The latter are supported for manual
-// encryption operations. If you need to tie into existing key management
-// infrastructure, you might want to consider implementing your own Recipient
-// and Identity.
+// support native (X25519 and hybrid) keys, and not SSH keys. The latter are
+// supported for manual encryption operations. If you need to tie into existing
+// key management infrastructure, you might want to consider implementing your
+// own [Recipient] and [Identity].
//
// # Backwards compatibility
//
@@ -52,6 +52,7 @@ import (
"errors"
"fmt"
"io"
+ "slices"
"sort"
"filippo.io/age/internal/format"
@@ -59,7 +60,7 @@ import (
)
// An Identity is passed to [Decrypt] to unwrap an opaque file key from a
-// recipient stanza. It can be for example a secret key like [X25519Identity], a
+// recipient stanza. It can be for example a secret key like [HybridIdentity], a
// plugin, or a custom implementation.
type Identity interface {
// Unwrap must return an error wrapping [ErrIncorrectIdentity] if none of
@@ -76,7 +77,7 @@ type Identity interface {
var ErrIncorrectIdentity = errors.New("incorrect identity for recipient block")
// A Recipient is passed to [Encrypt] to wrap an opaque file key to one or more
-// recipient stanza(s). It can be for example a public key like [X25519Recipient],
+// recipient stanza(s). It can be for example a public key like [HybridRecipient],
// a plugin, or a custom implementation.
type Recipient interface {
// Most age API users won't need to interact with this method directly, and
@@ -142,7 +143,7 @@ func Encrypt(dst io.Writer, recipients ...Recipient) (io.WriteCloser, error) {
if i == 0 {
labels = l
} else if !slicesEqual(labels, l) {
- return nil, fmt.Errorf("incompatible recipients")
+ return nil, incompatibleLabelsError(labels, l)
}
for _, s := range stanzas {
hdr.Recipients = append(hdr.Recipients, (*format.Stanza)(s))
@@ -188,6 +189,15 @@ func slicesEqual(s1, s2 []string) bool {
return true
}
+func incompatibleLabelsError(l1, l2 []string) error {
+ hasPQ1 := slices.Contains(l1, "postquantum")
+ hasPQ2 := slices.Contains(l2, "postquantum")
+ if hasPQ1 != hasPQ2 {
+ return fmt.Errorf("incompatible recipients: can't mix post-quantum and classic recipients, or the file would be vulnerable to quantum computers")
+ }
+ return fmt.Errorf("incompatible recipients: %q and %q can't be mixed", l1, l2)
+}
+
// NoIdentityMatchError is returned by [Decrypt] when none of the supplied
// identities match the encrypted file.
type NoIdentityMatchError struct {
@@ -204,6 +214,7 @@ func (*NoIdentityMatchError) Error() string {
//
// It returns a Reader reading the decrypted plaintext of the age file read
// from src. All identities will be tried until one successfully decrypts the file.
+// Native, non-interactive identities are tried before any other identities.
//
// If no identity matches the encrypted file, the returned error will be of type
// [NoIdentityMatchError].
@@ -230,6 +241,24 @@ func decryptHdr(hdr *format.Header, identities ...Identity) ([]byte, error) {
if len(identities) == 0 {
return nil, errors.New("no identities specified")
}
+ slices.SortStableFunc(identities, func(a, b Identity) int {
+ var aIsNative, bIsNative bool
+ switch a.(type) {
+ case *X25519Identity, *HybridIdentity, *ScryptIdentity:
+ aIsNative = true
+ }
+ switch b.(type) {
+ case *X25519Identity, *HybridIdentity, *ScryptIdentity:
+ bIsNative = true
+ }
+ if aIsNative && !bIsNative {
+ return -1
+ }
+ if !aIsNative && bIsNative {
+ return 1
+ }
+ return 0
+ })
stanzas := make([]*Stanza, 0, len(hdr.Recipients))
for _, s := range hdr.Recipients {
diff --git a/age_test.go b/age_test.go
index ef870d47..dfc753ba 100644
--- a/age_test.go
+++ b/age_test.go
@@ -285,6 +285,50 @@ func TestLabels(t *testing.T) {
}
}
+// testIdentity is a non-native identity that records if Unwrap is called.
+type testIdentity struct {
+ called bool
+}
+
+func (ti *testIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) {
+ ti.called = true
+ return nil, age.ErrIncorrectIdentity
+}
+
+func TestDecryptNativeIdentitiesFirst(t *testing.T) {
+ correct, err := age.GenerateX25519Identity()
+ if err != nil {
+ t.Fatal(err)
+ }
+ unrelated, err := age.GenerateX25519Identity()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ buf := &bytes.Buffer{}
+ w, err := age.Encrypt(buf, correct.Recipient())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ nonNative := &testIdentity{}
+
+ // Pass identities: unrelated native, non-native, correct native.
+ // Native identities should be tried first, so correct should match
+ // before nonNative is ever called.
+ _, err = age.Decrypt(bytes.NewReader(buf.Bytes()), unrelated, nonNative, correct)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if nonNative.called {
+ t.Error("non-native identity was called, but native identities should be tried first")
+ }
+}
+
func TestDetachedHeader(t *testing.T) {
i, err := age.GenerateX25519Identity()
if err != nil {
diff --git a/agessh/agessh.go b/agessh/agessh.go
index ec2ccdda..9af9a7c8 100644
--- a/agessh/agessh.go
+++ b/agessh/agessh.go
@@ -7,7 +7,7 @@
// encryption with age-encryption.org/v1.
//
// These recipient types should only be used for compatibility with existing
-// keys, and native X25519 keys should be preferred otherwise.
+// keys, and native keys should be preferred otherwise.
//
// Note that these recipient types are not anonymous: the encrypted message will
// include a short 32-bit ID of the public key.
diff --git a/armor/armor.go b/armor/armor.go
index ac6397e3..4ba94738 100644
--- a/armor/armor.go
+++ b/armor/armor.go
@@ -140,6 +140,9 @@ func (r *armoredReader) Read(p []byte) (int, error) {
if string(line) == Footer {
return 0, r.setErr(drainTrailing())
}
+ if len(line) == 0 {
+ return 0, r.setErr(errors.New("empty line in armored data"))
+ }
if len(line) > format.ColumnsPerLine {
return 0, r.setErr(errors.New("column limit exceeded"))
}
diff --git a/cmd/age-keygen/keygen.go b/cmd/age-keygen/keygen.go
index 0913de6c..269b5609 100644
--- a/cmd/age-keygen/keygen.go
+++ b/cmd/age-keygen/keygen.go
@@ -18,15 +18,18 @@ import (
)
const usage = `Usage:
- age-keygen [-o OUTPUT]
+ age-keygen [-pq] [-o OUTPUT]
age-keygen -y [-o OUTPUT] [INPUT]
Options:
+ -pq Generate a post-quantum hybrid ML-KEM-768 + X25519 key pair.
+ (This might become the default in the future.)
-o, --output OUTPUT Write the result to the file at path OUTPUT.
-y Convert an identity file to a recipients file.
-age-keygen generates a new native X25519 key pair, and outputs it to
-standard output or to the OUTPUT file.
+age-keygen generates a new native X25519 or, with the -pq flag, post-quantum
+hybrid ML-KEM-768 + X25519 key pair, and outputs it to standard output or to
+the OUTPUT file.
If an OUTPUT file is specified, the public key is printed to standard error.
If OUTPUT already exists, it is not overwritten.
@@ -42,6 +45,11 @@ Examples:
# public key: age1lvyvwawkr0mcnnnncaghunadrqkmuf9e6507x9y920xxpp866cnql7dp2z
AGE-SECRET-KEY-1N9JEPW6DWJ0ZQUDX63F5A03GX8QUW7PXDE39N8UYF82VZ9PC8UFS3M7XA9
+ $ age-keygen -pq
+ # created: 2025-11-17T12:15:17+01:00
+ # public key: age1pq1pd[... 1950 more characters ...]
+ AGE-SECRET-KEY-PQ-1XXC4XS9DXHZ6TREKQTT3XECY8VNNU7GJ83C3Y49D0GZ3ZUME4JWS6QC3EF
+
$ age-keygen -o key.txt
Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
@@ -52,12 +60,11 @@ func main() {
log.SetFlags(0)
flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) }
- var (
- versionFlag, convertFlag bool
- outFlag string
- )
+ var outFlag string
+ var pqFlag, versionFlag, convertFlag bool
flag.BoolVar(&versionFlag, "version", false, "print the version")
+ flag.BoolVar(&pqFlag, "pq", false, "generate a post-quantum key pair")
flag.BoolVar(&convertFlag, "y", false, "convert identities to recipients")
flag.StringVar(&outFlag, "o", "", "output to `FILE` (default stdout)")
flag.StringVar(&outFlag, "output", "", "output to `FILE` (default stdout)")
@@ -68,6 +75,9 @@ func main() {
if len(flag.Args()) > 1 && convertFlag {
errorf("too many arguments")
}
+ if pqFlag && convertFlag {
+ errorf("-pq cannot be used with -y")
+ }
if versionFlag {
if buildInfo, ok := debug.ReadBuildInfo(); ok {
fmt.Println(buildInfo.Main.Version)
@@ -107,23 +117,36 @@ func main() {
if fi, err := out.Stat(); err == nil && fi.Mode().IsRegular() && fi.Mode().Perm()&0004 != 0 {
warning("writing secret key to a world-readable file")
}
- generate(out)
+ generate(out, pqFlag)
}
}
-func generate(out *os.File) {
- k, err := age.GenerateX25519Identity()
- if err != nil {
- errorf("internal error: %v", err)
+func generate(out *os.File, pq bool) {
+ var i age.Identity
+ var r age.Recipient
+ if pq {
+ k, err := age.GenerateHybridIdentity()
+ if err != nil {
+ errorf("internal error: %v", err)
+ }
+ i = k
+ r = k.Recipient()
+ } else {
+ k, err := age.GenerateX25519Identity()
+ if err != nil {
+ errorf("internal error: %v", err)
+ }
+ i = k
+ r = k.Recipient()
}
if !term.IsTerminal(int(out.Fd())) {
- fmt.Fprintf(os.Stderr, "Public key: %s\n", k.Recipient())
+ fmt.Fprintf(os.Stderr, "Public key: %s\n", r)
}
fmt.Fprintf(out, "# created: %s\n", time.Now().Format(time.RFC3339))
- fmt.Fprintf(out, "# public key: %s\n", k.Recipient())
- fmt.Fprintf(out, "%s\n", k)
+ fmt.Fprintf(out, "# public key: %s\n", r)
+ fmt.Fprintf(out, "%s\n", i)
}
func convert(in io.Reader, out io.Writer) {
@@ -135,11 +158,15 @@ func convert(in io.Reader, out io.Writer) {
errorf("no identities found in the input")
}
for _, id := range ids {
- id, ok := id.(*age.X25519Identity)
- if !ok {
+ switch id := id.(type) {
+ case *age.X25519Identity:
+ fmt.Fprintf(out, "%s\n", id.Recipient())
+ case *age.HybridIdentity:
+ fmt.Fprintf(out, "%s\n", id.Recipient())
+ default:
errorf("internal error: unexpected identity type: %T", id)
}
- fmt.Fprintf(out, "%s\n", id.Recipient())
+
}
}
diff --git a/cmd/age-plugin-pq/plugin-pq.go b/cmd/age-plugin-pq/plugin-pq.go
new file mode 100644
index 00000000..0773e620
--- /dev/null
+++ b/cmd/age-plugin-pq/plugin-pq.go
@@ -0,0 +1,148 @@
+package main
+
+import (
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "os"
+ "runtime/debug"
+
+ "filippo.io/age"
+ "filippo.io/age/internal/bech32"
+ "filippo.io/age/plugin"
+)
+
+const usage = `Usage:
+ age-plugin-pq -identity [-o OUTPUT] [INPUT]
+
+Options:
+ -identity Convert one or more native post-quantum identities from
+ INPUT or from standard input to plugin identities.
+ -o, --output OUTPUT Write the result to the file at path OUTPUT instead of
+ standard output.
+
+age-plugin-pq is an age plugin for post-quantum hybrid ML-KEM-768 + X25519
+recipients and identities. These are supported natively by age v1.3.0 and later,
+but this plugin can be placed in $PATH to add support to any version and
+implementation of age that supports plugins.
+
+Recipients work out of the box, while identities need to be converted to plugin
+identities with -identity. If OUTPUT already exists, it is not overwritten.`
+
+func main() {
+ log.SetFlags(0)
+
+ p, err := plugin.New("pq")
+ if err != nil {
+ log.Fatal(err)
+ }
+ p.RegisterFlags(nil)
+
+ flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) }
+
+ var outFlag string
+ var versionFlag, identityFlag bool
+ flag.BoolVar(&versionFlag, "version", false, "print the version")
+ flag.BoolVar(&identityFlag, "identity", false, "convert identities to plugin identities")
+ flag.StringVar(&outFlag, "o", "", "output to `FILE` (default stdout)")
+ flag.StringVar(&outFlag, "output", "", "output to `FILE` (default stdout)")
+ flag.Parse()
+
+ if versionFlag {
+ if buildInfo, ok := debug.ReadBuildInfo(); ok {
+ fmt.Println(buildInfo.Main.Version)
+ return
+ }
+ fmt.Println("(unknown)")
+ return
+ }
+
+ if identityFlag {
+ if len(flag.Args()) > 1 {
+ errorf("too many arguments")
+ }
+
+ out := os.Stdout
+ if outFlag != "" {
+ f, err := os.OpenFile(outFlag, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
+ if err != nil {
+ errorf("failed to open output file %q: %v", outFlag, err)
+ }
+ defer func() {
+ if err := f.Close(); err != nil {
+ errorf("failed to close output file %q: %v", outFlag, err)
+ }
+ }()
+ out = f
+ }
+ if fi, err := out.Stat(); err == nil && fi.Mode().IsRegular() && fi.Mode().Perm()&0004 != 0 {
+ warning("writing secret key to a world-readable file")
+ }
+
+ in := os.Stdin
+ if inFile := flag.Arg(0); inFile != "" && inFile != "-" {
+ f, err := os.Open(inFile)
+ if err != nil {
+ errorf("failed to open input file %q: %v", inFile, err)
+ }
+ defer f.Close()
+ in = f
+ }
+
+ convert(in, out)
+ return
+ }
+
+ p.HandleRecipientEncoding(func(s string) (age.Recipient, error) {
+ return age.ParseHybridRecipient(s)
+ })
+ p.HandleIdentity(func(data []byte) (age.Identity, error) {
+ // Convert from a AGE-PLUGIN-PQ-1... payload to a
+ // AGE-SECRET-KEY-PQ-1... identity encoding.
+ s, err := bech32.Encode("AGE-SECRET-KEY-PQ-", data)
+ if err != nil {
+ return nil, err
+ }
+ return age.ParseHybridIdentity(s)
+ })
+ p.HandleIdentityAsRecipient(func(data []byte) (age.Recipient, error) {
+ s, err := bech32.Encode("AGE-SECRET-KEY-PQ-", data)
+ if err != nil {
+ return nil, err
+ }
+ i, err := age.ParseHybridIdentity(s)
+ if err != nil {
+ return nil, err
+ }
+ return i.Recipient(), nil
+ })
+ os.Exit(p.Main())
+}
+
+func convert(in io.Reader, out io.Writer) {
+ ids, err := age.ParseIdentities(in)
+ if err != nil {
+ errorf("failed to parse identities: %v", err)
+ }
+ for i, id := range ids {
+ hybridID, ok := id.(*age.HybridIdentity)
+ if !ok {
+ errorf("identity #%d is not a post-quantum hybrid identity", i+1)
+ }
+ _, data, err := bech32.Decode(hybridID.String())
+ if err != nil {
+ errorf("failed to decode identity #%d: %v", i+1, err)
+ }
+ fmt.Fprintln(out, plugin.EncodeIdentity("pq", data))
+ }
+}
+
+func errorf(format string, v ...interface{}) {
+ log.Printf("age-plugin-pq: error: "+format, v...)
+ log.Fatalf("age-plugin-pq: report unexpected or unhelpful errors at https://filippo.io/age/report")
+}
+
+func warning(msg string) {
+ log.Printf("age-plugin-pq: warning: %s", msg)
+}
diff --git a/cmd/age-plugin-tag/plugin-tag.go b/cmd/age-plugin-tag/plugin-tag.go
new file mode 100644
index 00000000..a3c5269b
--- /dev/null
+++ b/cmd/age-plugin-tag/plugin-tag.go
@@ -0,0 +1,32 @@
+package main
+
+import (
+ "flag"
+ "fmt"
+ "log"
+ "os"
+
+ "filippo.io/age"
+ "filippo.io/age/plugin"
+ "filippo.io/age/tag"
+)
+
+const usage = `age-plugin-tag is an age plugin for P-256 tagged recipients. These are supported
+natively by age v1.3.0 and later, but this plugin can be placed in $PATH to add
+support to any version and implementation of age that supports plugins.
+
+Usually, tagged recipients are the public side of private keys held in hardware,
+where the identity side is handled by a different plugin.`
+
+func main() {
+ flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) }
+
+ p, err := plugin.New("tag")
+ if err != nil {
+ log.Fatal(err)
+ }
+ p.HandleRecipient(func(b []byte) (age.Recipient, error) {
+ return tag.NewClassicRecipient(b)
+ })
+ os.Exit(p.Main())
+}
diff --git a/cmd/age-plugin-tagpq/plugin-tagpq.go b/cmd/age-plugin-tagpq/plugin-tagpq.go
new file mode 100644
index 00000000..8577f65b
--- /dev/null
+++ b/cmd/age-plugin-tagpq/plugin-tagpq.go
@@ -0,0 +1,33 @@
+package main
+
+import (
+ "flag"
+ "fmt"
+ "log"
+ "os"
+
+ "filippo.io/age"
+ "filippo.io/age/plugin"
+ "filippo.io/age/tag"
+)
+
+const usage = `age-plugin-tagpq is an age plugin for ML-KEM-768 + P-256 post-quantum hybrid
+tagged recipients. These are supported natively by age v1.3.0 and later, but
+this plugin can be placed in $PATH to add support to any version and
+implementation of age that supports plugins.
+
+Usually, tagged recipients are the public side of private keys held in hardware,
+where the identity side is handled by a different plugin.`
+
+func main() {
+ flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) }
+
+ p, err := plugin.New("tagpq")
+ if err != nil {
+ log.Fatal(err)
+ }
+ p.HandleRecipient(func(b []byte) (age.Recipient, error) {
+ return tag.NewHybridRecipient(b)
+ })
+ os.Exit(p.Main())
+}
diff --git a/cmd/age/age.go b/cmd/age/age.go
index 58a75fee..9b51f6b2 100644
--- a/cmd/age/age.go
+++ b/cmd/age/age.go
@@ -99,7 +99,7 @@ func main() {
if len(os.Args) == 1 {
flag.Usage()
- exit(1)
+ os.Exit(1)
}
var (
@@ -494,6 +494,8 @@ func identitiesToRecipients(ids []age.Identity) ([]age.Recipient, error) {
switch id := id.(type) {
case *age.X25519Identity:
recipients = append(recipients, id.Recipient())
+ case *age.HybridIdentity:
+ recipients = append(recipients, id.Recipient())
case *plugin.Identity:
recipients = append(recipients, id.Recipient())
case *agessh.RSAIdentity:
diff --git a/cmd/age/age_test.go b/cmd/age/age_test.go
index 35412322..52133842 100644
--- a/cmd/age/age_test.go
+++ b/cmd/age/age_test.go
@@ -6,6 +6,9 @@ package main
import (
"os"
+ "os/exec"
+ "path/filepath"
+ "sync"
"testing"
"filippo.io/age"
@@ -14,22 +17,15 @@ import (
)
func TestMain(m *testing.M) {
- os.Exit(testscript.RunMain(m, map[string]func() int{
- "age": func() (exitCode int) {
- testOnlyPanicInsteadOfExit = true
- defer func() {
- if testOnlyDidExit {
- exitCode = recover().(int)
- }
- }()
+ testscript.Main(m, map[string]func(){
+ "age": func() {
testOnlyConfigureScryptIdentity = func(r *age.ScryptRecipient) {
r.SetWorkFactor(10)
}
testOnlyFixedRandomWord = "four"
main()
- return 0
},
- "age-plugin-test": func() (exitCode int) {
+ "age-plugin-test": func() {
p, _ := plugin.New("test")
p.HandleRecipient(func(data []byte) (age.Recipient, error) {
return testPlugin{}, nil
@@ -37,9 +33,9 @@ func TestMain(m *testing.M) {
p.HandleIdentity(func(data []byte) (age.Identity, error) {
return testPlugin{}, nil
})
- return p.Main()
+ os.Exit(p.Main())
},
- }))
+ })
}
type testPlugin struct{}
@@ -55,9 +51,26 @@ func (testPlugin) Unwrap(ss []*age.Stanza) ([]byte, error) {
return nil, age.ErrIncorrectIdentity
}
+var buildExtraCommands = sync.OnceValue(func() error {
+ bindir := filepath.SplitList(os.Getenv("PATH"))[0]
+ // Build age-keygen and age-plugin-pq into the test binary directory.
+ cmd := exec.Command("go", "build", "-o", bindir)
+ if testing.CoverMode() != "" {
+ cmd.Args = append(cmd.Args, "-cover")
+ }
+ cmd.Args = append(cmd.Args, "filippo.io/age/cmd/age-keygen")
+ cmd.Args = append(cmd.Args, "filippo.io/age/cmd/age-plugin-pq")
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ return cmd.Run()
+})
+
func TestScript(t *testing.T) {
testscript.Run(t, testscript.Params{
Dir: "testdata",
+ Setup: func(e *testscript.Env) error {
+ return buildExtraCommands()
+ },
// TODO: enable AGEDEBUG=plugin without breaking stderr checks.
})
}
diff --git a/cmd/age/parse.go b/cmd/age/parse.go
index 4a59e7a4..9d1a5ff8 100644
--- a/cmd/age/parse.go
+++ b/cmd/age/parse.go
@@ -16,6 +16,7 @@ import (
"filippo.io/age/agessh"
"filippo.io/age/armor"
"filippo.io/age/plugin"
+ "filippo.io/age/tag"
"golang.org/x/crypto/cryptobyte"
"golang.org/x/crypto/ssh"
)
@@ -30,6 +31,10 @@ func (gitHubRecipientError) Error() string {
func parseRecipient(arg string) (age.Recipient, error) {
switch {
+ case strings.HasPrefix(arg, "age1tag1") || strings.HasPrefix(arg, "age1tagpq1"):
+ return tag.ParseRecipient(arg)
+ case strings.HasPrefix(arg, "age1pq1"):
+ return age.ParseHybridRecipient(arg)
case strings.HasPrefix(arg, "age1") && strings.Count(arg, "1") > 1:
return plugin.NewRecipient(arg, pluginTerminalUI)
case strings.HasPrefix(arg, "age1"):
@@ -121,8 +126,9 @@ func sshKeyType(s string) (string, bool) {
}
// parseIdentitiesFile parses a file that contains age or SSH keys. It returns
-// one or more of *age.X25519Identity, *agessh.RSAIdentity, *agessh.Ed25519Identity,
-// *agessh.EncryptedSSHIdentity, or *EncryptedIdentity.
+// one or more of *[age.X25519Identity], *[age.HybridIdentity],
+// *[agessh.RSAIdentity], *[agessh.Ed25519Identity],
+// *[agessh.EncryptedSSHIdentity], or *[EncryptedIdentity].
func parseIdentitiesFile(name string) ([]age.Identity, error) {
var f *os.File
if name == "-" {
@@ -201,12 +207,14 @@ func parseIdentity(s string) (age.Identity, error) {
return plugin.NewIdentity(s, pluginTerminalUI)
case strings.HasPrefix(s, "AGE-SECRET-KEY-1"):
return age.ParseX25519Identity(s)
+ case strings.HasPrefix(s, "AGE-SECRET-KEY-PQ-1"):
+ return age.ParseHybridIdentity(s)
default:
return nil, fmt.Errorf("unknown identity type")
}
}
-// parseIdentities is like age.ParseIdentities, but supports plugin identities.
+// parseIdentities is like [age.ParseIdentities], but supports plugin identities.
func parseIdentities(f io.Reader) ([]age.Identity, error) {
const privateKeySizeLimit = 1 << 24 // 16 MiB
var ids []age.Identity
diff --git a/cmd/age/testdata/hybrid.txt b/cmd/age/testdata/hybrid.txt
new file mode 100644
index 00000000..3e8f971a
--- /dev/null
+++ b/cmd/age/testdata/hybrid.txt
@@ -0,0 +1,47 @@
+# encrypt and decrypt a file with -r
+age -r age1pq1044e3hfmq2tltgck54m0gnpv8rfrwkdxjgl38psnwg8zfyv4uqdu63kk0u94ztt0q35k9dzuwgv405kxq6due2sre2kfsjftf7k2vz2mdpetku9pufy9d6ny6k5ss0cghpxdxrux0yd5xy40f8fu8ch9xn2rczyek2pqetqk0l9fqj0e3hfhkyfmcf9j6cpe5mf5stt9cymfq3xm276x4sramhn8srr42g78q9rkmqesevy33p4kdhkuggl99x9zx7j05uflx3s8q558xeaq9q9ctxqx52rzg6yffwykxngvxf7g3n83ddawagq2lnk59kcnexm54jrsmkjk9nemlge9v6p7fxu5z6yvq3ghpet8ww2c3h9fylvjx6j9njwz744376ap5r3xcpzh7qlk0kq3gknzfqhmxmjqypf6xz6geffdmy25apzsfr4ce0x825njkf62mgzx0x9n3zt0490u4dnt4468zse4zvd9sv0ycx2u2wsseuy4yymrg0nmfuxcwq9q0jykq7g5f59vunf95vnmcw336ksrh43fgapsd40u4227ymx56xtudtrte39twnq6fdfdj4p9pgx7pcqdk953za76fqcnvatx2cmywurag7ky4y8ecq5lvf25rydhxg6gmcn6vf78vtsqw79hp3mzv2u4v4jzn0nk50kl4gpv7k60cgz79c6k30jzy8dyetqtwg04tguxkv2p4hqj02yvsz2szaqdn9flu24rhth3v0t33023nwy27gt0x32eqpa5tpz6ygz3yzcsa3w889drpynzd5hlywuk9dwgafuax7upfnnxch4znrx4vactasg32u7vl886pxfswskk7wjgqkggrewtnaecfusl3nmg0xxh8e5nl5eft0xqwj5rk9ajn2j6yxehhgg5k6kpw5pk382hxmpu8wcd3rqx6m8905c7f8v0x2z9gyduxtqp2fdy9w2z4g2rrgu4d5cqes5tnwag007h2f326s3j9p6970xyd73rv5awx27zzezrax467c6vuye62ha88pzhw9xytjpezw3vg3l6x4k9rxwm9f6lwj6j7r9makg59rhgt0355s3jx9vw3raz5qdk5cnpny56q5h3dyd75suskuz63wmmfp9333tx4ry38vnh7jxy0j0cka99y7v2dmyjldjy0mqjetcefsmmg3pw0h9ll0rtskpmxz2cs8vmx6lu4v5rjtzpzgaqt46cwvnmhy5e3ye0tv6sre342lwg0tfd0yjkfz2haqc3hprpqf5ce8c9lx076dnk5cukn3tu38jgprrxt9sk9nqzt8grvkc4qzlg8jjf638v07ej9cf4eu48kphrgyjy83qm822xq8v9k22zwa7txr439x74j2f9ay7frkwtfdwn02enyx8stjpr4wgg25n5tj7uxyx0dqt3hnrqf7dcgt9fqnkjgemjfzyck6pawewptcayxm49sq3477f4cvm2p8fmg27rcmwm5l2vmzvdmymvkrr3aywu04y3zlhs7awu3r9u9lseheqpffvm2xf8y6w2qaj9g22pcu0sdsnqseusrxq9awru5lyqxnzftlslgpe3njegxkze98rveswp3u0xgx69wkg38vgp4k55lwwgv5l7ccv503mh9q23nud4xtm499xq276u40rxyrzaqfyuz2f3v5qr2vq00xs4g3ruynmq0c6nprvjcwqc5zrdn4cnzeeuwumjvqfjdpxr739n4cqcwytc8pjmfcj2qs3l2a8p5fzq7nvtng2jvvklx0x5ypnlkeuegsm587f6d4dkwel2es0lwwt88y2jdg8enjyph02tnjyndlx34h52fngjk96ggtu6vcxqkreqcxs4gwa8ww40t0kzlxfzyfhtjmfdm5fcrr5pt2xm4yslfynr0h9wkranhyadxf33kjn9xpg2fq5hnlq0 -o test.age input
+age -d -i key.txt test.age
+cmp stdout input
+! stderr .
+
+# encrypt and decrypt a file with -i
+age -e -i key.txt -o test.age input
+age -d -i key.txt test.age
+cmp stdout input
+! stderr .
+
+# encrypt and decrypt a file with the wrong key
+age -r age12phkzssndd5axajas2h74vtge62c86xjhd6u9anyanqhzvdg6sps0xthgl -o test.age input
+! age -d -i key.txt test.age
+stderr 'no identity matched any of the recipients'
+
+age -r age1pq13tgqupu990y5qlwwjy4zrzwvxg54cq89x9pxzdjenv4cl7affsqeerksrc6ufw3ndp4h5p3l5hm2jekkd7uay2mlh9eqg3sjcwmv5dcrwguc8djf83qr8gswtdrpav3jpw47kgkh6z0rnfk92xyqeyce48t02n4qz68augry3r3nd4rpdsw998vnnxzd2vepypktx2wxhfvd56jw7p32te6ttcyhc7tkqy2ks77jtjl8cgyal43pcd3g9wkcsgytccqtcnzcucjjdadpr0v7ye8u9fqusq6cq2ah0e4ejrltc8sk6763d7mjkuchmc5tkx6xztygqjx4ldxqh7jzkkyuv6ywt07ys4x09eq7gat4wuznrvgs3pkxjju8t9n0gpfqrsv658m5v09ltqc3c5z30n43ld43dwe5dn8nsk89f43tzsw2zta5kvl95zv0a94tc5y9r4ncj8g63fgljzhdxl96eg39rgd56yuvr2xdvavkk93a85n99jpjeqkfveprqc7eqft0wp7qgxcgsrwcdymgp0cqaj93jp4ckln36ylcwdzew234esa2awty25zc6pmc0jcpkrfuuptr9z5a4kx9ff4ppfff2lw95um853tcg47fa444er9k9j4e8vy5hfjx626j3tz3kc94jl8r66ahfprwtcyxae3swe9xxjhx5dfnt53n5fs2ut83t597wscjg9jc4f6h4uen2ll2ne30w7uvts8vuc5mpcfpp9espa78y0f8588m62kzez5kxxtcxykdkrnjx08w5az3pr6g6dsykwazcxtetpkq85mg7kay84z58slmvxmq56342pm6v8y3evr5wvc9g4j87jfhmryymkz9wk2en2v6tfy83rz657t0w7p3va46jt22f29u9ggzt9nu6qtguw4cp7cpj572ccyenw5rdh43h6s54g73gvxapsq3hw5yfkgttcyhkrjf9ykzd3wm5zsqgp5fca0nr56dnqe3kqrszasagqne0a83cguzwj9aw456jaxg9rurq4n4f6qec4srd0rxqfqc6rw22c2p4xyg25pls4dmxvce2fah8m6shue84preky2h0c4sa9y9tjuzn20gr9aav96e689nqx23s7zz0lvhq925jdkn04cdpzfscvhh93vh9f2tmwkzsq7pq0ncjk8g8cu75u77lyej46a4m2fefy8v2x4nzay05kulhyzk69520r7fdg3fzh0z7ysequcltr94hyf88h46hfujk30x9jp8u0pcjywh06g7tv483ypmc3pm3x3z4h9ph66lx228t3r96hyt764yxe2rn5clnfxkj3k5wf5hffehj58y56qyykwayk6qt9skvctxw20v9xy5ppnld3lushqpsutxht7cqygypdn5d2ppdgaerqzgehyrpdwzkhu0qe8w3u5h6htz8aa5zfpzy4s7sl8zdv3q0gq8ez08p86e4v3m82882yvawrvyrcxewrznwkvvez8m5aj6ktgvynyy0kc2trmtjzvjlxdf06e3rjmf55lwxrucfwu2sxdtnte83fgq0tvr2juv3pfqp8ddsrnckzqcfcvjp02mfg5y4aqlsunxpfcrdm46fphwsslrudwrfh542xg62kphca6h3xxqn538pprkknt35y00ygvrse5mxpvnstvcrnak5qduhf5dqslkn0yeadgpq6tv4wzy98kdjdzp22cpq6dy3kve856y2qlk7elyqyzj7ezpnh3vjwwmcm7ctp23k2sct86jd46ztplsq5vdjg9lyspxt0k8qx5udu9lwgulzapn3kdg7yd2zdz5dqf9933mpzwc32x8uxn8h2v5hlhdd4qk0uvwxhxyul8keaw39pz2avywk3wfly6veet9pjnj7nqecrgz824whs9sf7wl2shxk9kvkteht4z9x3w2gc6hqz5y -o test.age input
+! age -d -i key.txt test.age
+stderr 'no identity matched any of the recipients'
+
+# cannot mix hybrid and X25519 recipients
+! age -r age1pq1044e3hfmq2tltgck54m0gnpv8rfrwkdxjgl38psnwg8zfyv4uqdu63kk0u94ztt0q35k9dzuwgv405kxq6due2sre2kfsjftf7k2vz2mdpetku9pufy9d6ny6k5ss0cghpxdxrux0yd5xy40f8fu8ch9xn2rczyek2pqetqk0l9fqj0e3hfhkyfmcf9j6cpe5mf5stt9cymfq3xm276x4sramhn8srr42g78q9rkmqesevy33p4kdhkuggl99x9zx7j05uflx3s8q558xeaq9q9ctxqx52rzg6yffwykxngvxf7g3n83ddawagq2lnk59kcnexm54jrsmkjk9nemlge9v6p7fxu5z6yvq3ghpet8ww2c3h9fylvjx6j9njwz744376ap5r3xcpzh7qlk0kq3gknzfqhmxmjqypf6xz6geffdmy25apzsfr4ce0x825njkf62mgzx0x9n3zt0490u4dnt4468zse4zvd9sv0ycx2u2wsseuy4yymrg0nmfuxcwq9q0jykq7g5f59vunf95vnmcw336ksrh43fgapsd40u4227ymx56xtudtrte39twnq6fdfdj4p9pgx7pcqdk953za76fqcnvatx2cmywurag7ky4y8ecq5lvf25rydhxg6gmcn6vf78vtsqw79hp3mzv2u4v4jzn0nk50kl4gpv7k60cgz79c6k30jzy8dyetqtwg04tguxkv2p4hqj02yvsz2szaqdn9flu24rhth3v0t33023nwy27gt0x32eqpa5tpz6ygz3yzcsa3w889drpynzd5hlywuk9dwgafuax7upfnnxch4znrx4vactasg32u7vl886pxfswskk7wjgqkggrewtnaecfusl3nmg0xxh8e5nl5eft0xqwj5rk9ajn2j6yxehhgg5k6kpw5pk382hxmpu8wcd3rqx6m8905c7f8v0x2z9gyduxtqp2fdy9w2z4g2rrgu4d5cqes5tnwag007h2f326s3j9p6970xyd73rv5awx27zzezrax467c6vuye62ha88pzhw9xytjpezw3vg3l6x4k9rxwm9f6lwj6j7r9makg59rhgt0355s3jx9vw3raz5qdk5cnpny56q5h3dyd75suskuz63wmmfp9333tx4ry38vnh7jxy0j0cka99y7v2dmyjldjy0mqjetcefsmmg3pw0h9ll0rtskpmxz2cs8vmx6lu4v5rjtzpzgaqt46cwvnmhy5e3ye0tv6sre342lwg0tfd0yjkfz2haqc3hprpqf5ce8c9lx076dnk5cukn3tu38jgprrxt9sk9nqzt8grvkc4qzlg8jjf638v07ej9cf4eu48kphrgyjy83qm822xq8v9k22zwa7txr439x74j2f9ay7frkwtfdwn02enyx8stjpr4wgg25n5tj7uxyx0dqt3hnrqf7dcgt9fqnkjgemjfzyck6pawewptcayxm49sq3477f4cvm2p8fmg27rcmwm5l2vmzvdmymvkrr3aywu04y3zlhs7awu3r9u9lseheqpffvm2xf8y6w2qaj9g22pcu0sdsnqseusrxq9awru5lyqxnzftlslgpe3njegxkze98rveswp3u0xgx69wkg38vgp4k55lwwgv5l7ccv503mh9q23nud4xtm499xq276u40rxyrzaqfyuz2f3v5qr2vq00xs4g3ruynmq0c6nprvjcwqc5zrdn4cnzeeuwumjvqfjdpxr739n4cqcwytc8pjmfcj2qs3l2a8p5fzq7nvtng2jvvklx0x5ypnlkeuegsm587f6d4dkwel2es0lwwt88y2jdg8enjyph02tnjyndlx34h52fngjk96ggtu6vcxqkreqcxs4gwa8ww40t0kzlxfzyfhtjmfdm5fcrr5pt2xm4yslfynr0h9wkranhyadxf33kjn9xpg2fq5hnlq0 -r age12phkzssndd5axajas2h74vtge62c86xjhd6u9anyanqhzvdg6sps0xthgl -o test.age input
+stderr 'incompatible'
+
+! age -r age12phkzssndd5axajas2h74vtge62c86xjhd6u9anyanqhzvdg6sps0xthgl -r age1pq1044e3hfmq2tltgck54m0gnpv8rfrwkdxjgl38psnwg8zfyv4uqdu63kk0u94ztt0q35k9dzuwgv405kxq6due2sre2kfsjftf7k2vz2mdpetku9pufy9d6ny6k5ss0cghpxdxrux0yd5xy40f8fu8ch9xn2rczyek2pqetqk0l9fqj0e3hfhkyfmcf9j6cpe5mf5stt9cymfq3xm276x4sramhn8srr42g78q9rkmqesevy33p4kdhkuggl99x9zx7j05uflx3s8q558xeaq9q9ctxqx52rzg6yffwykxngvxf7g3n83ddawagq2lnk59kcnexm54jrsmkjk9nemlge9v6p7fxu5z6yvq3ghpet8ww2c3h9fylvjx6j9njwz744376ap5r3xcpzh7qlk0kq3gknzfqhmxmjqypf6xz6geffdmy25apzsfr4ce0x825njkf62mgzx0x9n3zt0490u4dnt4468zse4zvd9sv0ycx2u2wsseuy4yymrg0nmfuxcwq9q0jykq7g5f59vunf95vnmcw336ksrh43fgapsd40u4227ymx56xtudtrte39twnq6fdfdj4p9pgx7pcqdk953za76fqcnvatx2cmywurag7ky4y8ecq5lvf25rydhxg6gmcn6vf78vtsqw79hp3mzv2u4v4jzn0nk50kl4gpv7k60cgz79c6k30jzy8dyetqtwg04tguxkv2p4hqj02yvsz2szaqdn9flu24rhth3v0t33023nwy27gt0x32eqpa5tpz6ygz3yzcsa3w889drpynzd5hlywuk9dwgafuax7upfnnxch4znrx4vactasg32u7vl886pxfswskk7wjgqkggrewtnaecfusl3nmg0xxh8e5nl5eft0xqwj5rk9ajn2j6yxehhgg5k6kpw5pk382hxmpu8wcd3rqx6m8905c7f8v0x2z9gyduxtqp2fdy9w2z4g2rrgu4d5cqes5tnwag007h2f326s3j9p6970xyd73rv5awx27zzezrax467c6vuye62ha88pzhw9xytjpezw3vg3l6x4k9rxwm9f6lwj6j7r9makg59rhgt0355s3jx9vw3raz5qdk5cnpny56q5h3dyd75suskuz63wmmfp9333tx4ry38vnh7jxy0j0cka99y7v2dmyjldjy0mqjetcefsmmg3pw0h9ll0rtskpmxz2cs8vmx6lu4v5rjtzpzgaqt46cwvnmhy5e3ye0tv6sre342lwg0tfd0yjkfz2haqc3hprpqf5ce8c9lx076dnk5cukn3tu38jgprrxt9sk9nqzt8grvkc4qzlg8jjf638v07ej9cf4eu48kphrgyjy83qm822xq8v9k22zwa7txr439x74j2f9ay7frkwtfdwn02enyx8stjpr4wgg25n5tj7uxyx0dqt3hnrqf7dcgt9fqnkjgemjfzyck6pawewptcayxm49sq3477f4cvm2p8fmg27rcmwm5l2vmzvdmymvkrr3aywu04y3zlhs7awu3r9u9lseheqpffvm2xf8y6w2qaj9g22pcu0sdsnqseusrxq9awru5lyqxnzftlslgpe3njegxkze98rveswp3u0xgx69wkg38vgp4k55lwwgv5l7ccv503mh9q23nud4xtm499xq276u40rxyrzaqfyuz2f3v5qr2vq00xs4g3ruynmq0c6nprvjcwqc5zrdn4cnzeeuwumjvqfjdpxr739n4cqcwytc8pjmfcj2qs3l2a8p5fzq7nvtng2jvvklx0x5ypnlkeuegsm587f6d4dkwel2es0lwwt88y2jdg8enjyph02tnjyndlx34h52fngjk96ggtu6vcxqkreqcxs4gwa8ww40t0kzlxfzyfhtjmfdm5fcrr5pt2xm4yslfynr0h9wkranhyadxf33kjn9xpg2fq5hnlq0 -o test.age input
+stderr 'incompatible'
+
+# convert to plugin identity and use plugin
+exec age-plugin-pq -identity -o key-plugin.txt key.txt
+
+age -e -i key.txt -o test.age input
+age -d -i key-plugin.txt test.age
+cmp stdout input
+! stderr .
+
+age -e -i key-plugin.txt -o test.age input
+age -d -i key.txt test.age
+cmp stdout input
+! stderr .
+
+-- input --
+test
+-- key.txt --
+# created: 2025-11-17T13:27:37+01:00
+# public key: age1pq1044e3hfmq2tltgck54m0gnpv8rfrwkdxjgl38psnwg8zfyv4uqdu63kk0u94ztt0q35k9dzuwgv405kxq6due2sre2kfsjftf7k2vz2mdpetku9pufy9d6ny6k5ss0cghpxdxrux0yd5xy40f8fu8ch9xn2rczyek2pqetqk0l9fqj0e3hfhkyfmcf9j6cpe5mf5stt9cymfq3xm276x4sramhn8srr42g78q9rkmqesevy33p4kdhkuggl99x9zx7j05uflx3s8q558xeaq9q9ctxqx52rzg6yffwykxngvxf7g3n83ddawagq2lnk59kcnexm54jrsmkjk9nemlge9v6p7fxu5z6yvq3ghpet8ww2c3h9fylvjx6j9njwz744376ap5r3xcpzh7qlk0kq3gknzfqhmxmjqypf6xz6geffdmy25apzsfr4ce0x825njkf62mgzx0x9n3zt0490u4dnt4468zse4zvd9sv0ycx2u2wsseuy4yymrg0nmfuxcwq9q0jykq7g5f59vunf95vnmcw336ksrh43fgapsd40u4227ymx56xtudtrte39twnq6fdfdj4p9pgx7pcqdk953za76fqcnvatx2cmywurag7ky4y8ecq5lvf25rydhxg6gmcn6vf78vtsqw79hp3mzv2u4v4jzn0nk50kl4gpv7k60cgz79c6k30jzy8dyetqtwg04tguxkv2p4hqj02yvsz2szaqdn9flu24rhth3v0t33023nwy27gt0x32eqpa5tpz6ygz3yzcsa3w889drpynzd5hlywuk9dwgafuax7upfnnxch4znrx4vactasg32u7vl886pxfswskk7wjgqkggrewtnaecfusl3nmg0xxh8e5nl5eft0xqwj5rk9ajn2j6yxehhgg5k6kpw5pk382hxmpu8wcd3rqx6m8905c7f8v0x2z9gyduxtqp2fdy9w2z4g2rrgu4d5cqes5tnwag007h2f326s3j9p6970xyd73rv5awx27zzezrax467c6vuye62ha88pzhw9xytjpezw3vg3l6x4k9rxwm9f6lwj6j7r9makg59rhgt0355s3jx9vw3raz5qdk5cnpny56q5h3dyd75suskuz63wmmfp9333tx4ry38vnh7jxy0j0cka99y7v2dmyjldjy0mqjetcefsmmg3pw0h9ll0rtskpmxz2cs8vmx6lu4v5rjtzpzgaqt46cwvnmhy5e3ye0tv6sre342lwg0tfd0yjkfz2haqc3hprpqf5ce8c9lx076dnk5cukn3tu38jgprrxt9sk9nqzt8grvkc4qzlg8jjf638v07ej9cf4eu48kphrgyjy83qm822xq8v9k22zwa7txr439x74j2f9ay7frkwtfdwn02enyx8stjpr4wgg25n5tj7uxyx0dqt3hnrqf7dcgt9fqnkjgemjfzyck6pawewptcayxm49sq3477f4cvm2p8fmg27rcmwm5l2vmzvdmymvkrr3aywu04y3zlhs7awu3r9u9lseheqpffvm2xf8y6w2qaj9g22pcu0sdsnqseusrxq9awru5lyqxnzftlslgpe3njegxkze98rveswp3u0xgx69wkg38vgp4k55lwwgv5l7ccv503mh9q23nud4xtm499xq276u40rxyrzaqfyuz2f3v5qr2vq00xs4g3ruynmq0c6nprvjcwqc5zrdn4cnzeeuwumjvqfjdpxr739n4cqcwytc8pjmfcj2qs3l2a8p5fzq7nvtng2jvvklx0x5ypnlkeuegsm587f6d4dkwel2es0lwwt88y2jdg8enjyph02tnjyndlx34h52fngjk96ggtu6vcxqkreqcxs4gwa8ww40t0kzlxfzyfhtjmfdm5fcrr5pt2xm4yslfynr0h9wkranhyadxf33kjn9xpg2fq5hnlq0
+AGE-SECRET-KEY-PQ-16XDSLZ3XCSZ3236YJ5J0T9NAPLZTP96LJKQCVHJYUDTQVJR5J5PQTDPQCX
diff --git a/cmd/age/testdata/keygen.txt b/cmd/age/testdata/keygen.txt
new file mode 100644
index 00000000..a34db827
--- /dev/null
+++ b/cmd/age/testdata/keygen.txt
@@ -0,0 +1,25 @@
+exec age-keygen
+stdout '# created: 20'
+stdout '# public key: age1'
+stdout 'AGE-SECRET-KEY-1'
+stderr 'Public key: age1'
+
+exec age-keygen -pq
+stdout '# created: 20'
+stdout '# public key: age1pq1'
+stdout 'AGE-SECRET-KEY-PQ-1'
+stderr 'Public key: age1pq1'
+
+exec age-keygen -pq -o key.txt
+! stdout .
+stderr 'Public key: age1pq1'
+grep '# created: 20' key.txt
+grep '# public key: age1pq1' key.txt
+grep 'AGE-SECRET-KEY-PQ-1' key.txt
+
+stdin key.txt
+exec age-keygen -y
+stdout age1pq1
+
+exec age-keygen -y key.txt
+stdout age1pq1
diff --git a/cmd/age/tui.go b/cmd/age/tui.go
index 3d26d918..ac364f0b 100644
--- a/cmd/age/tui.go
+++ b/cmd/age/tui.go
@@ -37,7 +37,7 @@ func printf(format string, v ...interface{}) {
func errorf(format string, v ...interface{}) {
l.Printf("age: error: "+format, v...)
l.Printf("age: report unexpected or unhelpful errors at https://filippo.io/age/report")
- exit(1)
+ os.Exit(1)
}
func warningf(format string, v ...interface{}) {
@@ -50,21 +50,7 @@ func errorWithHint(error string, hints ...string) {
l.Printf("age: hint: %s", hint)
}
l.Printf("age: report unexpected or unhelpful errors at https://filippo.io/age/report")
- exit(1)
-}
-
-// If testOnlyPanicInsteadOfExit is true, exit will set testOnlyDidExit and
-// panic instead of calling os.Exit. This way, the wrapper in TestMain can
-// recover the panic and return the exit code only if it was originated in exit.
-var testOnlyPanicInsteadOfExit bool
-var testOnlyDidExit bool
-
-func exit(code int) {
- if testOnlyPanicInsteadOfExit {
- testOnlyDidExit = true
- panic(code)
- }
- os.Exit(code)
+ os.Exit(1)
}
// clearLine clears the current line on the terminal, or opens a new line if
diff --git a/doc/age-keygen.1.ronn b/doc/age-keygen.1.ronn
index fef248c8..6bef7b65 100644
--- a/doc/age-keygen.1.ronn
+++ b/doc/age-keygen.1.ronn
@@ -3,7 +3,7 @@ age-keygen(1) -- generate age(1) key pairs
## SYNOPSIS
-`age-keygen` [`-o`