Skip to content

Commit 908136a

Browse files
committed
age,cmd/age,cmd/age-keygen: add post-quantum hybrid keys
1 parent ee85859 commit 908136a

File tree

19 files changed

+694
-78
lines changed

19 files changed

+694
-78
lines changed

README.md

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
age is a simple, modern and secure file encryption tool, format, and Go library.
1414

15-
It features small explicit keys, no config options, and UNIX-style composability.
15+
It features small explicit keys, post-quantum support, no config options, and UNIX-style composability.
1616

1717
```
1818
$ age-keygen -o key.txt
@@ -25,13 +25,13 @@ $ age --decrypt -i key.txt data.tar.gz.age > data.tar.gz
2525

2626
🦀 An alternative interoperable Rust implementation is available at [github.com/str4d/rage](https://github.com/str4d/rage).
2727

28-
🌍 [Typage](https://github.com/FiloSottile/typage) is a TypeScript implementation. It works in the browser, in Node.js, and in Bun.
28+
🌍 [Typage](https://github.com/FiloSottile/typage) is a TypeScript implementation. It works in the browser, Node.js, Deno, and Bun.
2929

3030
🔑 Hardware PIV tokens such as YubiKeys are supported through the [age-plugin-yubikey](https://github.com/str4d/age-plugin-yubikey) plugin.
3131

3232
✨ For more plugins, implementations, tools, and integrations, check out the [awesome age](https://github.com/FiloSottile/awesome-age) list.
3333

34-
💬 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.
34+
💬 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.
3535

3636
## Installation
3737

@@ -229,6 +229,28 @@ $ age -R recipients.txt example.jpg > example.jpg.age
229229

230230
If the argument to `-R` (or `-i`) is `-`, the file is read from standard input.
231231

232+
### Post-quantum keys
233+
234+
To generate hybrid post-quantum keys, which are secure against future quantum
235+
computer attacks, use the `-pq` flag with `age-keygen`. This may become the
236+
default in the future.
237+
238+
Post-quantum identities start with `AGE-SECRET-KEY-PQ-1...` and recipients with
239+
`age1pq1...`. The recipients are unfortunately ~2000 characters long.
240+
241+
```
242+
$ age-keygen -pq -o key.txt
243+
$ age-keygen -y key.txt > recipient.txt
244+
$ age -R recipient.txt example.jpg > example.jpg.age
245+
$ age -d -i key.txt example.jpg.age > example.jpg
246+
```
247+
248+
Support for post-quantum keys is built into age v1.3.0 and later. Alternatively,
249+
the `age-plugin-pq` binary can be installed and placed in `$PATH` to add support
250+
to any version and implementation of age that supports plugins. Recipients will
251+
work out of the box, while identities will have to be converted to plugin
252+
identities with `age-plugin-pq -identity`.
253+
232254
### Passphrases
233255

234256
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.

age.go

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66
// specification.
77
//
88
// For most use cases, use the [Encrypt] and [Decrypt] functions with
9-
// [X25519Recipient] and [X25519Identity]. If passphrase encryption is required, use
10-
// [ScryptRecipient] and [ScryptIdentity]. For compatibility with existing SSH keys
11-
// use the filippo.io/age/agessh package.
9+
// [HybridRecipient] and [HybridIdentity]. If passphrase encryption is
10+
// required, use [ScryptRecipient] and [ScryptIdentity]. For compatibility with
11+
// existing SSH keys use the filippo.io/age/agessh package.
1212
//
1313
// age encrypted files are binary and not malleable. For encoding them as text,
1414
// use the filippo.io/age/armor package.
@@ -26,13 +26,13 @@
2626
// There is no default path for age keys. Instead, they should be stored at
2727
// application-specific paths. The CLI supports files where private keys are
2828
// listed one per line, ignoring empty lines and lines starting with "#". These
29-
// files can be parsed with ParseIdentities.
29+
// files can be parsed with [ParseIdentities].
3030
//
3131
// When integrating age into a new system, it's recommended that you only
32-
// support X25519 keys, and not SSH keys. The latter are supported for manual
33-
// encryption operations. If you need to tie into existing key management
34-
// infrastructure, you might want to consider implementing your own Recipient
35-
// and Identity.
32+
// support native (X25519 and hybrid) keys, and not SSH keys. The latter are
33+
// supported for manual encryption operations. If you need to tie into existing
34+
// key management infrastructure, you might want to consider implementing your
35+
// own [Recipient] and [Identity].
3636
//
3737
// # Backwards compatibility
3838
//
@@ -52,14 +52,15 @@ import (
5252
"errors"
5353
"fmt"
5454
"io"
55+
"slices"
5556
"sort"
5657

5758
"filippo.io/age/internal/format"
5859
"filippo.io/age/internal/stream"
5960
)
6061

6162
// An Identity is passed to [Decrypt] to unwrap an opaque file key from a
62-
// recipient stanza. It can be for example a secret key like [X25519Identity], a
63+
// recipient stanza. It can be for example a secret key like [HybridIdentity], a
6364
// plugin, or a custom implementation.
6465
type Identity interface {
6566
// Unwrap must return an error wrapping [ErrIncorrectIdentity] if none of
@@ -76,7 +77,7 @@ type Identity interface {
7677
var ErrIncorrectIdentity = errors.New("incorrect identity for recipient block")
7778

7879
// A Recipient is passed to [Encrypt] to wrap an opaque file key to one or more
79-
// recipient stanza(s). It can be for example a public key like [X25519Recipient],
80+
// recipient stanza(s). It can be for example a public key like [HybridRecipient],
8081
// a plugin, or a custom implementation.
8182
type Recipient interface {
8283
// 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) {
142143
if i == 0 {
143144
labels = l
144145
} else if !slicesEqual(labels, l) {
145-
return nil, fmt.Errorf("incompatible recipients")
146+
return nil, incompatibleLabelsError(labels, l)
146147
}
147148
for _, s := range stanzas {
148149
hdr.Recipients = append(hdr.Recipients, (*format.Stanza)(s))
@@ -188,6 +189,15 @@ func slicesEqual(s1, s2 []string) bool {
188189
return true
189190
}
190191

192+
func incompatibleLabelsError(l1, l2 []string) error {
193+
hasPQ1 := slices.Contains(l1, "postquantum")
194+
hasPQ2 := slices.Contains(l2, "postquantum")
195+
if hasPQ1 != hasPQ2 {
196+
return fmt.Errorf("incompatible recipients: can't mix post-quantum and classic recipients, or the file would be vulnerable to quantum computers")
197+
}
198+
return fmt.Errorf("incompatible recipients: %q and %q can't be mixed", l1, l2)
199+
}
200+
191201
// NoIdentityMatchError is returned by [Decrypt] when none of the supplied
192202
// identities match the encrypted file.
193203
type NoIdentityMatchError struct {

agessh/agessh.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
// encryption with age-encryption.org/v1.
88
//
99
// These recipient types should only be used for compatibility with existing
10-
// keys, and native X25519 keys should be preferred otherwise.
10+
// keys, and native keys should be preferred otherwise.
1111
//
1212
// Note that these recipient types are not anonymous: the encrypted message will
1313
// include a short 32-bit ID of the public key.

cmd/age-keygen/keygen.go

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,18 @@ import (
1818
)
1919

2020
const usage = `Usage:
21-
age-keygen [-o OUTPUT]
21+
age-keygen [-pq] [-o OUTPUT]
2222
age-keygen -y [-o OUTPUT] [INPUT]
2323
2424
Options:
25+
-pq Generate a post-quantum hybrid ML-KEM-768 + X25519 key pair.
26+
(This might become the default in the future.)
2527
-o, --output OUTPUT Write the result to the file at path OUTPUT.
2628
-y Convert an identity file to a recipients file.
2729
28-
age-keygen generates a new native X25519 key pair, and outputs it to
29-
standard output or to the OUTPUT file.
30+
age-keygen generates a new native X25519 or, with the -pq flag, post-quantum
31+
hybrid ML-KEM-768 + X25519 key pair, and outputs it to standard output or to
32+
the OUTPUT file.
3033
3134
If an OUTPUT file is specified, the public key is printed to standard error.
3235
If OUTPUT already exists, it is not overwritten.
@@ -42,6 +45,11 @@ Examples:
4245
# public key: age1lvyvwawkr0mcnnnncaghunadrqkmuf9e6507x9y920xxpp866cnql7dp2z
4346
AGE-SECRET-KEY-1N9JEPW6DWJ0ZQUDX63F5A03GX8QUW7PXDE39N8UYF82VZ9PC8UFS3M7XA9
4447
48+
$ age-keygen -pq
49+
# created: 2025-11-17T12:15:17+01:00
50+
# public key: age1pq1pd[... 1950 more characters ...]
51+
AGE-SECRET-KEY-PQ-1XXC4XS9DXHZ6TREKQTT3XECY8VNNU7GJ83C3Y49D0GZ3ZUME4JWS6QC3EF
52+
4553
$ age-keygen -o key.txt
4654
Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
4755
@@ -52,12 +60,11 @@ func main() {
5260
log.SetFlags(0)
5361
flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) }
5462

55-
var (
56-
versionFlag, convertFlag bool
57-
outFlag string
58-
)
63+
var outFlag string
64+
var pqFlag, versionFlag, convertFlag bool
5965

6066
flag.BoolVar(&versionFlag, "version", false, "print the version")
67+
flag.BoolVar(&pqFlag, "pq", false, "generate a post-quantum key pair")
6168
flag.BoolVar(&convertFlag, "y", false, "convert identities to recipients")
6269
flag.StringVar(&outFlag, "o", "", "output to `FILE` (default stdout)")
6370
flag.StringVar(&outFlag, "output", "", "output to `FILE` (default stdout)")
@@ -68,6 +75,9 @@ func main() {
6875
if len(flag.Args()) > 1 && convertFlag {
6976
errorf("too many arguments")
7077
}
78+
if pqFlag && convertFlag {
79+
errorf("-pq cannot be used with -y")
80+
}
7181
if versionFlag {
7282
if buildInfo, ok := debug.ReadBuildInfo(); ok {
7383
fmt.Println(buildInfo.Main.Version)
@@ -107,23 +117,36 @@ func main() {
107117
if fi, err := out.Stat(); err == nil && fi.Mode().IsRegular() && fi.Mode().Perm()&0004 != 0 {
108118
warning("writing secret key to a world-readable file")
109119
}
110-
generate(out)
120+
generate(out, pqFlag)
111121
}
112122
}
113123

114-
func generate(out *os.File) {
115-
k, err := age.GenerateX25519Identity()
116-
if err != nil {
117-
errorf("internal error: %v", err)
124+
func generate(out *os.File, pq bool) {
125+
var i age.Identity
126+
var r age.Recipient
127+
if pq {
128+
k, err := age.GenerateHybridIdentity()
129+
if err != nil {
130+
errorf("internal error: %v", err)
131+
}
132+
i = k
133+
r = k.Recipient()
134+
} else {
135+
k, err := age.GenerateX25519Identity()
136+
if err != nil {
137+
errorf("internal error: %v", err)
138+
}
139+
i = k
140+
r = k.Recipient()
118141
}
119142

120143
if !term.IsTerminal(int(out.Fd())) {
121-
fmt.Fprintf(os.Stderr, "Public key: %s\n", k.Recipient())
144+
fmt.Fprintf(os.Stderr, "Public key: %s\n", r)
122145
}
123146

124147
fmt.Fprintf(out, "# created: %s\n", time.Now().Format(time.RFC3339))
125-
fmt.Fprintf(out, "# public key: %s\n", k.Recipient())
126-
fmt.Fprintf(out, "%s\n", k)
148+
fmt.Fprintf(out, "# public key: %s\n", r)
149+
fmt.Fprintf(out, "%s\n", i)
127150
}
128151

129152
func convert(in io.Reader, out io.Writer) {
@@ -135,11 +158,15 @@ func convert(in io.Reader, out io.Writer) {
135158
errorf("no identities found in the input")
136159
}
137160
for _, id := range ids {
138-
id, ok := id.(*age.X25519Identity)
139-
if !ok {
161+
switch id := id.(type) {
162+
case *age.X25519Identity:
163+
fmt.Fprintf(out, "%s\n", id.Recipient())
164+
case *age.HybridIdentity:
165+
fmt.Fprintf(out, "%s\n", id.Recipient())
166+
default:
140167
errorf("internal error: unexpected identity type: %T", id)
141168
}
142-
fmt.Fprintf(out, "%s\n", id.Recipient())
169+
143170
}
144171
}
145172

0 commit comments

Comments
 (0)