age,cmd/age,cmd/age-keygen: add post-quantum hybrid keys

This commit is contained in:
Filippo Valsorda
2025-11-17 12:32:50 +01:00
committed by Filippo Valsorda
parent 6ece9e45ee
commit c6fcb5300c
20 changed files with 720 additions and 91 deletions

View File

@@ -12,7 +12,7 @@
age is a simple, modern and secure file encryption tool, format, and Go library. 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 $ 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). 🦀 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. 🔑 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. ✨ 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 ## 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. 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 ### 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. 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.

32
age.go
View File

@@ -6,9 +6,9 @@
// specification. // specification.
// //
// For most use cases, use the [Encrypt] and [Decrypt] functions with // For most use cases, use the [Encrypt] and [Decrypt] functions with
// [X25519Recipient] and [X25519Identity]. If passphrase encryption is required, use // [HybridRecipient] and [HybridIdentity]. If passphrase encryption is
// [ScryptRecipient] and [ScryptIdentity]. For compatibility with existing SSH keys // required, use [ScryptRecipient] and [ScryptIdentity]. For compatibility with
// use the filippo.io/age/agessh package. // existing SSH keys use the filippo.io/age/agessh package.
// //
// age encrypted files are binary and not malleable. For encoding them as text, // age encrypted files are binary and not malleable. For encoding them as text,
// use the filippo.io/age/armor package. // 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 // 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 // application-specific paths. The CLI supports files where private keys are
// listed one per line, ignoring empty lines and lines starting with "#". These // 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 // 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 // support native (X25519 and hybrid) keys, and not SSH keys. The latter are
// encryption operations. If you need to tie into existing key management // supported for manual encryption operations. If you need to tie into existing
// infrastructure, you might want to consider implementing your own Recipient // key management infrastructure, you might want to consider implementing your
// and Identity. // own [Recipient] and [Identity].
// //
// # Backwards compatibility // # Backwards compatibility
// //
@@ -52,6 +52,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"slices"
"sort" "sort"
"filippo.io/age/internal/format" "filippo.io/age/internal/format"
@@ -59,7 +60,7 @@ import (
) )
// An Identity is passed to [Decrypt] to unwrap an opaque file key from a // 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. // plugin, or a custom implementation.
type Identity interface { type Identity interface {
// Unwrap must return an error wrapping [ErrIncorrectIdentity] if none of // 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") 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 // 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. // a plugin, or a custom implementation.
type Recipient interface { type Recipient interface {
// Most age API users won't need to interact with this method directly, and // 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 { if i == 0 {
labels = l labels = l
} else if !slicesEqual(labels, l) { } else if !slicesEqual(labels, l) {
return nil, fmt.Errorf("incompatible recipients") return nil, incompatibleLabelsError(labels, l)
} }
for _, s := range stanzas { for _, s := range stanzas {
hdr.Recipients = append(hdr.Recipients, (*format.Stanza)(s)) hdr.Recipients = append(hdr.Recipients, (*format.Stanza)(s))
@@ -188,6 +189,15 @@ func slicesEqual(s1, s2 []string) bool {
return true 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 // NoIdentityMatchError is returned by [Decrypt] when none of the supplied
// identities match the encrypted file. // identities match the encrypted file.
type NoIdentityMatchError struct { type NoIdentityMatchError struct {

View File

@@ -7,7 +7,7 @@
// encryption with age-encryption.org/v1. // encryption with age-encryption.org/v1.
// //
// These recipient types should only be used for compatibility with existing // 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 // Note that these recipient types are not anonymous: the encrypted message will
// include a short 32-bit ID of the public key. // include a short 32-bit ID of the public key.

View File

@@ -18,15 +18,18 @@ import (
) )
const usage = `Usage: const usage = `Usage:
age-keygen [-o OUTPUT] age-keygen [-pq] [-o OUTPUT]
age-keygen -y [-o OUTPUT] [INPUT] age-keygen -y [-o OUTPUT] [INPUT]
Options: 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. -o, --output OUTPUT Write the result to the file at path OUTPUT.
-y Convert an identity file to a recipients file. -y Convert an identity file to a recipients file.
age-keygen generates a new native X25519 key pair, and outputs it to age-keygen generates a new native X25519 or, with the -pq flag, post-quantum
standard output or to the OUTPUT file. 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 an OUTPUT file is specified, the public key is printed to standard error.
If OUTPUT already exists, it is not overwritten. If OUTPUT already exists, it is not overwritten.
@@ -42,6 +45,11 @@ Examples:
# public key: age1lvyvwawkr0mcnnnncaghunadrqkmuf9e6507x9y920xxpp866cnql7dp2z # public key: age1lvyvwawkr0mcnnnncaghunadrqkmuf9e6507x9y920xxpp866cnql7dp2z
AGE-SECRET-KEY-1N9JEPW6DWJ0ZQUDX63F5A03GX8QUW7PXDE39N8UYF82VZ9PC8UFS3M7XA9 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 $ age-keygen -o key.txt
Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
@@ -52,12 +60,11 @@ func main() {
log.SetFlags(0) log.SetFlags(0)
flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) } flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) }
var ( var outFlag string
versionFlag, convertFlag bool var pqFlag, versionFlag, convertFlag bool
outFlag string
)
flag.BoolVar(&versionFlag, "version", false, "print the version") 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.BoolVar(&convertFlag, "y", false, "convert identities to recipients")
flag.StringVar(&outFlag, "o", "", "output to `FILE` (default stdout)") flag.StringVar(&outFlag, "o", "", "output to `FILE` (default stdout)")
flag.StringVar(&outFlag, "output", "", "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 { if len(flag.Args()) > 1 && convertFlag {
errorf("too many arguments") errorf("too many arguments")
} }
if pqFlag && convertFlag {
errorf("-pq cannot be used with -y")
}
if versionFlag { if versionFlag {
if buildInfo, ok := debug.ReadBuildInfo(); ok { if buildInfo, ok := debug.ReadBuildInfo(); ok {
fmt.Println(buildInfo.Main.Version) 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 { if fi, err := out.Stat(); err == nil && fi.Mode().IsRegular() && fi.Mode().Perm()&0004 != 0 {
warning("writing secret key to a world-readable file") warning("writing secret key to a world-readable file")
} }
generate(out) generate(out, pqFlag)
} }
} }
func generate(out *os.File) { 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() k, err := age.GenerateX25519Identity()
if err != nil { if err != nil {
errorf("internal error: %v", err) errorf("internal error: %v", err)
} }
i = k
r = k.Recipient()
}
if !term.IsTerminal(int(out.Fd())) { 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, "# created: %s\n", time.Now().Format(time.RFC3339))
fmt.Fprintf(out, "# public key: %s\n", k.Recipient()) fmt.Fprintf(out, "# public key: %s\n", r)
fmt.Fprintf(out, "%s\n", k) fmt.Fprintf(out, "%s\n", i)
} }
func convert(in io.Reader, out io.Writer) { 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") errorf("no identities found in the input")
} }
for _, id := range ids { for _, id := range ids {
id, ok := id.(*age.X25519Identity) switch id := id.(type) {
if !ok { 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) errorf("internal error: unexpected identity type: %T", id)
} }
fmt.Fprintf(out, "%s\n", id.Recipient())
} }
} }

View File

@@ -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)
}

View File

@@ -494,6 +494,8 @@ func identitiesToRecipients(ids []age.Identity) ([]age.Recipient, error) {
switch id := id.(type) { switch id := id.(type) {
case *age.X25519Identity: case *age.X25519Identity:
recipients = append(recipients, id.Recipient()) recipients = append(recipients, id.Recipient())
case *age.HybridIdentity:
recipients = append(recipients, id.Recipient())
case *plugin.Identity: case *plugin.Identity:
recipients = append(recipients, id.Recipient()) recipients = append(recipients, id.Recipient())
case *agessh.RSAIdentity: case *agessh.RSAIdentity:

View File

@@ -6,6 +6,8 @@ package main
import ( import (
"os" "os"
"os/exec"
"path/filepath"
"testing" "testing"
"filippo.io/age" "filippo.io/age"
@@ -58,6 +60,19 @@ func (testPlugin) Unwrap(ss []*age.Stanza) ([]byte, error) {
func TestScript(t *testing.T) { func TestScript(t *testing.T) {
testscript.Run(t, testscript.Params{ testscript.Run(t, testscript.Params{
Dir: "testdata", Dir: "testdata",
Setup: func(e *testscript.Env) 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()
},
// TODO: enable AGEDEBUG=plugin without breaking stderr checks. // TODO: enable AGEDEBUG=plugin without breaking stderr checks.
}) })
} }

View File

@@ -33,6 +33,8 @@ func parseRecipient(arg string) (age.Recipient, error) {
switch { switch {
case strings.HasPrefix(arg, "age1tag1") || strings.HasPrefix(arg, "age1tagpq1"): case strings.HasPrefix(arg, "age1tag1") || strings.HasPrefix(arg, "age1tagpq1"):
return tag.ParseRecipient(arg) return tag.ParseRecipient(arg)
case strings.HasPrefix(arg, "age1pq1"):
return age.ParseHybridRecipient(arg)
case strings.HasPrefix(arg, "age1") && strings.Count(arg, "1") > 1: case strings.HasPrefix(arg, "age1") && strings.Count(arg, "1") > 1:
return plugin.NewRecipient(arg, pluginTerminalUI) return plugin.NewRecipient(arg, pluginTerminalUI)
case strings.HasPrefix(arg, "age1"): case strings.HasPrefix(arg, "age1"):
@@ -124,8 +126,9 @@ func sshKeyType(s string) (string, bool) {
} }
// parseIdentitiesFile parses a file that contains age or SSH keys. It returns // parseIdentitiesFile parses a file that contains age or SSH keys. It returns
// one or more of *age.X25519Identity, *agessh.RSAIdentity, *agessh.Ed25519Identity, // one or more of *[age.X25519Identity], *[age.HybridIdentity],
// *agessh.EncryptedSSHIdentity, or *EncryptedIdentity. // *[agessh.RSAIdentity], *[agessh.Ed25519Identity],
// *[agessh.EncryptedSSHIdentity], or *[EncryptedIdentity].
func parseIdentitiesFile(name string) ([]age.Identity, error) { func parseIdentitiesFile(name string) ([]age.Identity, error) {
var f *os.File var f *os.File
if name == "-" { if name == "-" {
@@ -204,12 +207,14 @@ func parseIdentity(s string) (age.Identity, error) {
return plugin.NewIdentity(s, pluginTerminalUI) return plugin.NewIdentity(s, pluginTerminalUI)
case strings.HasPrefix(s, "AGE-SECRET-KEY-1"): case strings.HasPrefix(s, "AGE-SECRET-KEY-1"):
return age.ParseX25519Identity(s) return age.ParseX25519Identity(s)
case strings.HasPrefix(s, "AGE-SECRET-KEY-PQ-1"):
return age.ParseHybridIdentity(s)
default: default:
return nil, fmt.Errorf("unknown identity type") 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) { func parseIdentities(f io.Reader) ([]age.Identity, error) {
const privateKeySizeLimit = 1 << 24 // 16 MiB const privateKeySizeLimit = 1 << 24 // 16 MiB
var ids []age.Identity var ids []age.Identity

47
cmd/age/testdata/hybrid.txt vendored Normal file
View File

@@ -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

25
cmd/age/testdata/keygen.txt vendored Normal file
View File

@@ -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

View File

@@ -3,7 +3,7 @@ age-keygen(1) -- generate age(1) key pairs
## SYNOPSIS ## SYNOPSIS
`age-keygen` [`-o` <OUTPUT>]<br> `age-keygen` [`-pq`] [`-o` <OUTPUT>]<br>
`age-keygen` `-y` [`-o` <OUTPUT>] [<INPUT>]<br> `age-keygen` `-y` [`-o` <OUTPUT>] [<INPUT>]<br>
## DESCRIPTION ## DESCRIPTION
@@ -17,6 +17,11 @@ standard error.
## OPTIONS ## OPTIONS
* `-pq`:
Generate a post-quantum hybrid ML-KEM-768 + X25519 key pair.
In the future, this might become the default.
* `-o`, `--output`=<OUTPUT>: * `-o`, `--output`=<OUTPUT>:
Write the identity to <OUTPUT> instead of standard output. Write the identity to <OUTPUT> instead of standard output.
@@ -31,22 +36,29 @@ standard error.
## EXAMPLES ## EXAMPLES
Generate a new identity: Generate a new post-quantum identity:
$ age-keygen -pq
# created: 2025-11-17T13:39:06+01:00
# public key: age1pq167[... 1950 more characters ...]
AGE-SECRET-KEY-PQ-1K30MYPZAHAXHR22YHH27EGDVLU0QNSUH3DSV7J7NR3X6D9LHXNWSDLTV4T
Generate a new traditional identity:
$ age-keygen $ age-keygen
# created: 2021-01-02T15:30:45+01:00 # created: 2021-01-02T15:30:45+01:00
# public key: age1lvyvwawkr0mcnnnncaghunadrqkmuf9e6507x9y920xxpp866cnql7dp2z # public key: age1lvyvwawkr0mcnnnncaghunadrqkmuf9e6507x9y920xxpp866cnql7dp2z
AGE-SECRET-KEY-1N9JEPW6DWJ0ZQUDX63F5A03GX8QUW7PXDE39N8UYF82VZ9PC8UFS3M7XA9 AGE-SECRET-KEY-1N9JEPW6DWJ0ZQUDX63F5A03GX8QUW7PXDE39N8UYF82VZ9PC8UFS3M7XA9
Write a new identity to `key.txt`: Write a new post-quantum identity to `key.txt`:
$ age-keygen -o key.txt $ age-keygen -o key.txt
Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p Public key: age1pq1cd[... 1950 more characters ...]
Convert an identity to a recipient: Convert an identity to a recipient:
$ age-keygen -y key.txt $ age-keygen -y key.txt
age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p age1pq1cd[... 1950 more characters ...]
## SEE ALSO ## SEE ALSO

View File

@@ -148,21 +148,35 @@ overhead per recipient, plus 16 bytes every 64KiB of plaintext.
to. `IDENTITIES` are private values, like a private key, that allow decrypting to. `IDENTITIES` are private values, like a private key, that allow decrypting
a file encrypted to the corresponding `RECIPIENT`. a file encrypted to the corresponding `RECIPIENT`.
### Native X25519 keys ### Native keys
Native `age` key pairs are generated with age-keygen(1), and provide small Native `age` key pairs are generated with age-keygen(1), and provide small
encodings and strong encryption based on X25519. They are the recommended encodings and strong encryption based on X25519 for classic keys, and X25519 +
recipient type for most applications. ML-KEM-768 for post-quantum hybrid keys. The post-quantum hybrid keys are secure
against future quantum computers and are the recommended recipient type for most
applications.
A `RECIPIENT` encoding begins with `age1` and looks like the following: A hybrid `RECIPIENT` encoding begins with `age1pq1` and looks like the following:
age1pq167[... 1950 more characters ...]
A hybrid `IDENTITY` encoding begins with `AGE-SECRET-KEY-PQ-1` and looks like
the following:
AGE-SECRET-KEY-PQ-1K30MYPZAHAXHR22YHH27EGDVLU0QNSUH3DSV7J7NR3X6D9LHXNWSDLTV4T
A classic `RECIPIENT` encoding begins with `age1` and looks like the following:
age1gde3ncmahlqd9gg50tanl99r960llztrhfapnmx853s4tjum03uqfssgdh age1gde3ncmahlqd9gg50tanl99r960llztrhfapnmx853s4tjum03uqfssgdh
An `IDENTITY` encoding begins with `AGE-SECRET-KEY-1` and looks like the A classic `IDENTITY` encoding begins with `AGE-SECRET-KEY-1` and looks like the
following: following:
AGE-SECRET-KEY-1KTYK6RVLN5TAPE7VF6FQQSKZ9HWWCDSKUGXXNUQDWZ7XXT5YK5LSF3UTKQ AGE-SECRET-KEY-1KTYK6RVLN5TAPE7VF6FQQSKZ9HWWCDSKUGXXNUQDWZ7XXT5YK5LSF3UTKQ
A file can't be encrypted to both post-quantum and classic keys, as that would
defeat the post-quantum security of the encryption.
An encrypted file can't be linked to the native recipient it's encrypted to An encrypted file can't be linked to the native recipient it's encrypted to
without access to the corresponding identity. without access to the corresponding identity.
@@ -243,27 +257,26 @@ by default. In this case, a flag will be provided to force the operation.
## EXAMPLES ## EXAMPLES
Generate a new identity, encrypt data, and decrypt: Generate a new post-quantum identity, encrypt data, and decrypt:
$ age-keygen -o key.txt $ age-keygen -pq -o key.txt
Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p Public key: age1pq167[... 1950 more characters ...]
$ tar cvz ~/data | age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p > data.tar.gz.age $ tar cvz ~/data | age -r age1pq167[...] > data.tar.gz.age
$ age -d -o data.tar.gz -i key.txt data.tar.gz.age $ age -d -o data.tar.gz -i key.txt data.tar.gz.age
Encrypt `example.jpg` to multiple recipients and output to `example.jpg.age`: Encrypt `example.jpg` to multiple recipients and output to `example.jpg.age`:
$ age -o example.jpg.age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p \ $ age -o example.jpg.age -r age1pq167[...] -r age1pq1e3[...] example.jpg
-r age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqqvfafg example.jpg
Encrypt to a list of recipients: Encrypt to a list of recipients:
$ cat > recipients.txt $ cat > recipients.txt
# Alice # Alice
age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p age1pq167[... 1950 more characters ...]
# Bob # Bob
age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqqvfafg age1pq1e3[... 1950 more characters ...]
$ age -R recipients.txt example.jpg > example.jpg.age $ age -R recipients.txt example.jpg > example.jpg.age

View File

@@ -16,10 +16,10 @@ import (
// //
// This is the same syntax as the private key files accepted by the CLI, except // This is the same syntax as the private key files accepted by the CLI, except
// the CLI also accepts SSH private keys, which are not recommended for the // the CLI also accepts SSH private keys, which are not recommended for the
// average application. // average application, and plugins, which involve invoking external programs.
// //
// Currently, all returned values are of type *X25519Identity, but different // Currently, all returned values are of type *[X25519Identity] or
// types might be returned in the future. // *[HybridIdentity], but different types might be returned in the future.
func ParseIdentities(f io.Reader) ([]Identity, error) { func ParseIdentities(f io.Reader) ([]Identity, error) {
const privateKeySizeLimit = 1 << 24 // 16 MiB const privateKeySizeLimit = 1 << 24 // 16 MiB
var ids []Identity var ids []Identity
@@ -31,7 +31,7 @@ func ParseIdentities(f io.Reader) ([]Identity, error) {
if strings.HasPrefix(line, "#") || line == "" { if strings.HasPrefix(line, "#") || line == "" {
continue continue
} }
i, err := ParseX25519Identity(line) i, err := parseIdentity(line)
if err != nil { if err != nil {
return nil, fmt.Errorf("error at line %d: %v", n, err) return nil, fmt.Errorf("error at line %d: %v", n, err)
} }
@@ -46,15 +46,27 @@ func ParseIdentities(f io.Reader) ([]Identity, error) {
return ids, nil return ids, nil
} }
func parseIdentity(arg string) (Identity, error) {
switch {
case strings.HasPrefix(arg, "AGE-SECRET-KEY-1"):
return ParseX25519Identity(arg)
case strings.HasPrefix(arg, "AGE-SECRET-KEY-PQ-1"):
return ParseHybridIdentity(arg)
default:
return nil, fmt.Errorf("unknown identity type: %q", arg)
}
}
// ParseRecipients parses a file with one or more public key encodings, one per // ParseRecipients parses a file with one or more public key encodings, one per
// line. Empty lines and lines starting with "#" are ignored. // line. Empty lines and lines starting with "#" are ignored.
// //
// This is the same syntax as the recipients files accepted by the CLI, except // This is the same syntax as the recipients files accepted by the CLI, except
// the CLI also accepts SSH recipients, which are not recommended for the // the CLI also accepts SSH recipients, which are not recommended for the
// average application. // average application, tagged recipients, which have different privacy
// properties, and plugins, which involve invoking external programs.
// //
// Currently, all returned values are of type *X25519Recipient, but different // Currently, all returned values are of type *[X25519Recipient] or
// types might be returned in the future. // *[HybridRecipient] but different types might be returned in the future.
func ParseRecipients(f io.Reader) ([]Recipient, error) { func ParseRecipients(f io.Reader) ([]Recipient, error) {
const recipientFileSizeLimit = 1 << 24 // 16 MiB const recipientFileSizeLimit = 1 << 24 // 16 MiB
var recs []Recipient var recs []Recipient
@@ -66,7 +78,7 @@ func ParseRecipients(f io.Reader) ([]Recipient, error) {
if strings.HasPrefix(line, "#") || line == "" { if strings.HasPrefix(line, "#") || line == "" {
continue continue
} }
r, err := ParseX25519Recipient(line) r, err := parseRecipient(line)
if err != nil { if err != nil {
// Hide the error since it might unintentionally leak the contents // Hide the error since it might unintentionally leak the contents
// of confidential files. // of confidential files.
@@ -82,3 +94,14 @@ func ParseRecipients(f io.Reader) ([]Recipient, error) {
} }
return recs, nil return recs, nil
} }
func parseRecipient(arg string) (Recipient, error) {
switch {
case strings.HasPrefix(arg, "age1pq1"):
return ParseHybridRecipient(arg)
case strings.HasPrefix(arg, "age1"):
return ParseX25519Recipient(arg)
default:
return nil, fmt.Errorf("unknown recipient type: %q", arg)
}
}

View File

@@ -5,10 +5,13 @@
package plugin package plugin
import ( import (
"crypto/ecdh"
"crypto/mlkem"
"fmt" "fmt"
"strings" "strings"
"filippo.io/age/internal/bech32" "filippo.io/age/internal/bech32"
"filippo.io/hpke"
) )
// EncodeIdentity encodes a plugin identity string for a plugin with the given // EncodeIdentity encodes a plugin identity string for a plugin with the given
@@ -78,3 +81,28 @@ func validPluginName(name string) bool {
} }
return true return true
} }
// EncodeX25519Recipient encodes a native X25519 recipient from a
// [crypto/ecdh.X25519] public key. It's meant for plugins that implement
// identities that are compatible with native recipients.
func EncodeX25519Recipient(pk *ecdh.PublicKey) (string, error) {
if pk.Curve() != ecdh.X25519() {
return "", fmt.Errorf("wrong ecdh Curve")
}
return bech32.Encode("age", pk.Bytes())
}
// EncodeHybridRecipient encodes a native MLKEM768-X25519 recipient from a
// [crypto/mlkem.EncapsulationKey768] and a [crypto/ecdh.X25519] public key.
// It's meant for plugins that implement identities that are compatible with
// native recipients.
func EncodeHybridRecipient(pq *mlkem.EncapsulationKey768, t *ecdh.PublicKey) (string, error) {
if t.Curve() != ecdh.X25519() {
return "", fmt.Errorf("wrong ecdh Curve")
}
pk, err := hpke.NewHybridPublicKey(pq, t)
if err != nil {
return "", fmt.Errorf("failed to create hybrid public key: %v", err)
}
return bech32.Encode("age1pq", pk.Bytes())
}

View File

@@ -1,24 +0,0 @@
// Copyright 2023 The age Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build go1.20
package plugin
import (
"crypto/ecdh"
"fmt"
"filippo.io/age/internal/bech32"
)
// EncodeX25519Recipient encodes a native X25519 recipient from a
// [crypto/ecdh.X25519] public key. It's meant for plugins that implement
// identities that are compatible with native recipients.
func EncodeX25519Recipient(pk *ecdh.PublicKey) (string, error) {
if pk.Curve() != ecdh.X25519() {
return "", fmt.Errorf("wrong ecdh Curve")
}
return bech32.Encode("age", pk.Bytes())
}

181
pq.go Normal file
View File

@@ -0,0 +1,181 @@
// Copyright 2025 The age Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package age
import (
"errors"
"fmt"
"strings"
"filippo.io/age/internal/bech32"
"filippo.io/age/internal/format"
"filippo.io/hpke"
"golang.org/x/crypto/chacha20poly1305"
)
const pqLabel = "age-encryption.org/mlkem768x25519"
// HybridRecipient is the standard age public key. Messages encrypted to
// this recipient can be decrypted with the corresponding [HybridIdentity].
//
// This recipient is safe against future cryptographically-relevant quantum
// computers, and can only be used along with other post-quantum recipients.
//
// This recipient is anonymous, in the sense that an attacker can't tell from
// the message alone if it is encrypted to a certain recipient.
type HybridRecipient struct {
pk hpke.PublicKey
}
var _ Recipient = &HybridRecipient{}
// newHybridRecipient returns a new [HybridRecipient] from a raw HPKE public key.
func newHybridRecipient(publicKey []byte) (*HybridRecipient, error) {
pk, err := hpke.MLKEM768X25519().NewPublicKey(publicKey)
if err != nil {
return nil, errors.New("invalid MLKEM768-X25519 public key")
}
return &HybridRecipient{pk: pk}, nil
}
// ParseHybridRecipient returns a new [HybridRecipient] from a Bech32 public key
// encoding with the "age1pq1" prefix.
func ParseHybridRecipient(s string) (*HybridRecipient, error) {
t, k, err := bech32.Decode(s)
if err != nil {
return nil, fmt.Errorf("malformed recipient %q: %v", s, err)
}
if t != "age1pq" {
return nil, fmt.Errorf("malformed recipient %q: invalid type %q", s, t)
}
r, err := newHybridRecipient(k)
if err != nil {
return nil, fmt.Errorf("malformed recipient %q: %v", s, err)
}
return r, nil
}
func (r *HybridRecipient) Wrap(fileKey []byte) ([]*Stanza, error) {
s, _, err := r.WrapWithLabels(fileKey)
return s, err
}
// WrapWithLabels implements [RecipientWithLabels], returning a single
// "postquantum" label. This ensures a HybridRecipient can't be mixed with other
// recipients that would defeat its post-quantum security.
//
// To unsafely bypass this restriction, wrap HybridRecipient in a [Recipient]
// type that doesn't expose WrapWithLabels.
func (r *HybridRecipient) WrapWithLabels(fileKey []byte) ([]*Stanza, []string, error) {
enc, s, err := hpke.NewSender(r.pk, hpke.HKDFSHA256(), hpke.ChaCha20Poly1305(), []byte(pqLabel))
if err != nil {
return nil, nil, fmt.Errorf("failed to set up HPKE sender: %v", err)
}
ct, err := s.Seal(nil, fileKey)
if err != nil {
return nil, nil, fmt.Errorf("failed to encrypt file key: %v", err)
}
l := &Stanza{
Type: "mlkem768x25519",
Args: []string{format.EncodeToString(enc)},
Body: ct,
}
return []*Stanza{l}, []string{"postquantum"}, nil
}
// String returns the Bech32 public key encoding of r.
func (r *HybridRecipient) String() string {
s, _ := bech32.Encode("age1pq", r.pk.Bytes())
return s
}
// HybridIdentity is the standard age private key, which can decrypt messages
// encrypted to the corresponding [HybridRecipient].
type HybridIdentity struct {
k hpke.PrivateKey
}
var _ Identity = &HybridIdentity{}
// newHybridIdentity returns a new [HybridIdentity] from a raw HPKE private key.
func newHybridIdentity(secretKey []byte) (*HybridIdentity, error) {
k, err := hpke.MLKEM768X25519().NewPrivateKey(secretKey)
if err != nil {
return nil, errors.New("invalid MLKEM768-X25519 secret key")
}
return &HybridIdentity{k: k}, nil
}
// GenerateHybridIdentity randomly generates a new [HybridIdentity].
func GenerateHybridIdentity() (*HybridIdentity, error) {
k, err := hpke.MLKEM768X25519().GenerateKey()
if err != nil {
return nil, fmt.Errorf("failed to generate post-quantum identity: %v", err)
}
return &HybridIdentity{k: k}, nil
}
// ParseHybridIdentity returns a new [HybridIdentity] from a Bech32 private key
// encoding with the "AGE-SECRET-KEY-PQ-1" prefix.
func ParseHybridIdentity(s string) (*HybridIdentity, error) {
t, k, err := bech32.Decode(s)
if err != nil {
return nil, fmt.Errorf("malformed secret key: %v", err)
}
if t != "AGE-SECRET-KEY-PQ-" {
return nil, fmt.Errorf("malformed secret key: unknown type %q", t)
}
r, err := newHybridIdentity(k)
if err != nil {
return nil, fmt.Errorf("malformed secret key: %v", err)
}
return r, nil
}
func (i *HybridIdentity) Unwrap(stanzas []*Stanza) ([]byte, error) {
return multiUnwrap(i.unwrap, stanzas)
}
func (i *HybridIdentity) unwrap(block *Stanza) ([]byte, error) {
if block.Type != "mlkem768x25519" {
return nil, ErrIncorrectIdentity
}
if len(block.Args) != 1 {
return nil, errors.New("invalid mlkem768x25519 recipient block")
}
enc, err := format.DecodeString(block.Args[0])
if err != nil {
return nil, fmt.Errorf("failed to parse mlkem768x25519 recipient: %v", err)
}
if len(block.Body) != fileKeySize+chacha20poly1305.Overhead {
return nil, errIncorrectCiphertextSize
}
r, err := hpke.NewRecipient(enc, i.k, hpke.HKDFSHA256(), hpke.ChaCha20Poly1305(), []byte(pqLabel))
if err != nil {
// MLKEM768-X25519 does implicit rejection, so a mismatched key does not
// hit this error path, but is only detected later when trying to open.
return nil, fmt.Errorf("invalid mlkem768x25519 recipient: %v", err)
}
fileKey, err := r.Open(nil, block.Body)
if err != nil {
return nil, ErrIncorrectIdentity
}
return fileKey, nil
}
// Recipient returns the public [HybridRecipient] value corresponding to i.
func (i *HybridIdentity) Recipient() *HybridRecipient {
return &HybridRecipient{pk: i.k.PublicKey()}
}
// String returns the Bech32 private key encoding of i.
func (i *HybridIdentity) String() string {
b, _ := i.k.Bytes()
s, _ := bech32.Encode("AGE-SECRET-KEY-PQ-", b)
return strings.ToUpper(s)
}

View File

@@ -7,6 +7,7 @@ package age_test
import ( import (
"bytes" "bytes"
"crypto/rand" "crypto/rand"
"io"
"testing" "testing"
"filippo.io/age" "filippo.io/age"
@@ -49,6 +50,67 @@ func TestX25519RoundTrip(t *testing.T) {
} }
} }
func TestHybridRoundTrip(t *testing.T) {
i, err := age.GenerateHybridIdentity()
if err != nil {
t.Fatal(err)
}
r := i.Recipient()
if r1, err := age.ParseHybridRecipient(r.String()); err != nil {
t.Fatal(err)
} else if r1.String() != r.String() {
t.Errorf("recipient did not round-trip through parsing: got %q, want %q", r1, r)
}
if i1, err := age.ParseHybridIdentity(i.String()); err != nil {
t.Fatal(err)
} else if i1.String() != i.String() {
t.Errorf("identity did not round-trip through parsing: got %q, want %q", i1, i)
}
fileKey := make([]byte, 16)
if _, err := rand.Read(fileKey); err != nil {
t.Fatal(err)
}
stanzas, err := r.Wrap(fileKey)
if err != nil {
t.Fatal(err)
}
out, err := i.Unwrap(stanzas)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(fileKey, out) {
t.Errorf("invalid output: %x, expected %x", out, fileKey)
}
}
func TestHybridMixingRestrictions(t *testing.T) {
x25519, err := age.GenerateX25519Identity()
if err != nil {
t.Fatal(err)
}
hybrid, err := age.GenerateHybridIdentity()
if err != nil {
t.Fatal(err)
}
// Hybrid recipients can be used together.
if _, err := age.Encrypt(io.Discard, hybrid.Recipient(), hybrid.Recipient()); err != nil {
t.Errorf("expected two hybrid recipients to work, got %v", err)
}
// Hybrid and X25519 recipients cannot be mixed.
if _, err := age.Encrypt(io.Discard, hybrid.Recipient(), x25519.Recipient()); err == nil {
t.Error("expected hybrid mixed with X25519 to fail")
}
if _, err := age.Encrypt(io.Discard, x25519.Recipient(), hybrid.Recipient()); err == nil {
t.Error("expected X25519 mixed with hybrid to fail")
}
}
func TestScryptRoundTrip(t *testing.T) { func TestScryptRoundTrip(t *testing.T) {
password := "twitch.tv/filosottile" password := "twitch.tv/filosottile"

View File

@@ -27,7 +27,7 @@ const scryptLabel = "age-encryption.org/v1/scrypt"
// for the same file. // for the same file.
// //
// Its use is not recommended for automated systems, which should prefer // Its use is not recommended for automated systems, which should prefer
// X25519Recipient. // [HybridRecipient] or [X25519Recipient].
type ScryptRecipient struct { type ScryptRecipient struct {
password []byte password []byte
workFactor int workFactor int

View File

@@ -107,3 +107,34 @@ func TestHybridRoundTrip(t *testing.T) {
t.Errorf("invalid output: %q, expected %q", out, plaintext) t.Errorf("invalid output: %q, expected %q", out, plaintext)
} }
} }
func TestTagHybridMixingRestrictions(t *testing.T) {
x25519, err := age.GenerateX25519Identity()
if err != nil {
t.Fatal(err)
}
tagHybrid := tagtest.NewHybridIdentity(t).Recipient()
// Hybrid tag recipients can be used together with hybrid recipients.
hybrid, err := age.GenerateHybridIdentity()
if err != nil {
t.Fatal(err)
}
if _, err := age.Encrypt(io.Discard, tagHybrid, hybrid.Recipient()); err != nil {
t.Errorf("expected hybrid tag + hybrid to work, got %v", err)
}
// Hybrid tag and X25519 recipients cannot be mixed.
if _, err := age.Encrypt(io.Discard, tagHybrid, x25519.Recipient()); err == nil {
t.Error("expected hybrid tag mixed with X25519 to fail")
}
if _, err := age.Encrypt(io.Discard, x25519.Recipient(), tagHybrid); err == nil {
t.Error("expected X25519 mixed with hybrid tag to fail")
}
// Classic tag and X25519 recipients can be mixed (both are non-PQ).
tagClassic := tagtest.NewClassicIdentity(t).Recipient()
if _, err := age.Encrypt(io.Discard, tagClassic, x25519.Recipient()); err != nil {
t.Errorf("expected classic tag + X25519 to work, got %v", err)
}
}

View File

@@ -21,8 +21,9 @@ import (
const x25519Label = "age-encryption.org/v1/X25519" const x25519Label = "age-encryption.org/v1/X25519"
// X25519Recipient is the standard age public key. Messages encrypted to this // X25519Recipient is the standard age pre-quantum public key. Messages
// recipient can be decrypted with the corresponding X25519Identity. // encrypted to this recipient can be decrypted with the corresponding
// [X25519Identity]. For post-quantum resistance, use [HybridRecipient].
// //
// This recipient is anonymous, in the sense that an attacker can't tell from // This recipient is anonymous, in the sense that an attacker can't tell from
// the message alone if it is encrypted to a certain recipient. // the message alone if it is encrypted to a certain recipient.
@@ -105,8 +106,9 @@ func (r *X25519Recipient) String() string {
return s return s
} }
// X25519Identity is the standard age private key, which can decrypt messages // X25519Identity is the standard pre-quantum age private key, which can decrypt
// encrypted to the corresponding X25519Recipient. // messages encrypted to the corresponding [X25519Recipient]. For post-quantum
// resistance, use [HybridIdentity].
type X25519Identity struct { type X25519Identity struct {
secretKey, ourPublicKey []byte secretKey, ourPublicKey []byte
} }