diff --git a/README.md b/README.md index 3232002..ea70f09 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 eb34c3e..1942db7 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 { diff --git a/agessh/agessh.go b/agessh/agessh.go index ec2ccdd..9af9a7c 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/cmd/age-keygen/keygen.go b/cmd/age-keygen/keygen.go index 0913de6..269b560 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 0000000..0773e62 --- /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/age.go b/cmd/age/age.go index 58a75fe..cebcc82 100644 --- a/cmd/age/age.go +++ b/cmd/age/age.go @@ -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 3541232..fcd7fdc 100644 --- a/cmd/age/age_test.go +++ b/cmd/age/age_test.go @@ -6,6 +6,8 @@ package main import ( "os" + "os/exec" + "path/filepath" "testing" "filippo.io/age" @@ -58,6 +60,19 @@ func (testPlugin) Unwrap(ss []*age.Stanza) ([]byte, error) { func TestScript(t *testing.T) { testscript.Run(t, testscript.Params{ 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. }) } diff --git a/cmd/age/parse.go b/cmd/age/parse.go index 70dcd86..9d1a5ff 100644 --- a/cmd/age/parse.go +++ b/cmd/age/parse.go @@ -33,6 +33,8 @@ 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"): @@ -124,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 == "-" { @@ -204,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 0000000..3e8f971 --- /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 0000000..a34db82 --- /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/doc/age-keygen.1.ronn b/doc/age-keygen.1.ronn index fef248c..6bef7b6 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` ]
+`age-keygen` [`-pq`] [`-o` ]
`age-keygen` `-y` [`-o` ] []
## DESCRIPTION @@ -17,6 +17,11 @@ standard error. ## OPTIONS +* `-pq`: + Generate a post-quantum hybrid ML-KEM-768 + X25519 key pair. + + In the future, this might become the default. + * `-o`, `--output`=: Write the identity to instead of standard output. @@ -31,22 +36,29 @@ standard error. ## 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 # created: 2021-01-02T15:30:45+01:00 # public key: age1lvyvwawkr0mcnnnncaghunadrqkmuf9e6507x9y920xxpp866cnql7dp2z 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 - Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p + Public key: age1pq1cd[... 1950 more characters ...] Convert an identity to a recipient: $ age-keygen -y key.txt - age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p + age1pq1cd[... 1950 more characters ...] ## SEE ALSO diff --git a/doc/age.1.ronn b/doc/age.1.ronn index 1d71a4b..64d0c4b 100644 --- a/doc/age.1.ronn +++ b/doc/age.1.ronn @@ -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 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 -encodings and strong encryption based on X25519. They are the recommended -recipient type for most applications. +encodings and strong encryption based on X25519 for classic keys, and X25519 + +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 -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: 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 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 -Generate a new identity, encrypt data, and decrypt: +Generate a new post-quantum identity, encrypt data, and decrypt: - $ age-keygen -o key.txt - Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p + $ age-keygen -pq -o key.txt + 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 Encrypt `example.jpg` to multiple recipients and output to `example.jpg.age`: - $ age -o example.jpg.age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p \ - -r age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqqvfafg example.jpg + $ age -o example.jpg.age -r age1pq167[...] -r age1pq1e3[...] example.jpg Encrypt to a list of recipients: $ cat > recipients.txt # Alice - age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p + age1pq167[... 1950 more characters ...] # Bob - age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqqvfafg + age1pq1e3[... 1950 more characters ...] $ age -R recipients.txt example.jpg > example.jpg.age diff --git a/parse.go b/parse.go index 373d1a8..7361565 100644 --- a/parse.go +++ b/parse.go @@ -16,10 +16,10 @@ import ( // // 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 -// average application. +// average application, and plugins, which involve invoking external programs. // -// Currently, all returned values are of type *X25519Identity, but different -// types might be returned in the future. +// Currently, all returned values are of type *[X25519Identity] or +// *[HybridIdentity], but different types might be returned in the future. func ParseIdentities(f io.Reader) ([]Identity, error) { const privateKeySizeLimit = 1 << 24 // 16 MiB var ids []Identity @@ -31,7 +31,7 @@ func ParseIdentities(f io.Reader) ([]Identity, error) { if strings.HasPrefix(line, "#") || line == "" { continue } - i, err := ParseX25519Identity(line) + i, err := parseIdentity(line) if err != nil { 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 } +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 // line. Empty lines and lines starting with "#" are ignored. // // 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 -// 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 -// types might be returned in the future. +// Currently, all returned values are of type *[X25519Recipient] or +// *[HybridRecipient] but different types might be returned in the future. func ParseRecipients(f io.Reader) ([]Recipient, error) { const recipientFileSizeLimit = 1 << 24 // 16 MiB var recs []Recipient @@ -66,7 +78,7 @@ func ParseRecipients(f io.Reader) ([]Recipient, error) { if strings.HasPrefix(line, "#") || line == "" { continue } - r, err := ParseX25519Recipient(line) + r, err := parseRecipient(line) if err != nil { // Hide the error since it might unintentionally leak the contents // of confidential files. @@ -82,3 +94,14 @@ func ParseRecipients(f io.Reader) ([]Recipient, error) { } 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) + } +} diff --git a/plugin/encode.go b/plugin/encode.go index 0a59fbe..628d6f2 100644 --- a/plugin/encode.go +++ b/plugin/encode.go @@ -5,10 +5,13 @@ package plugin import ( + "crypto/ecdh" + "crypto/mlkem" "fmt" "strings" "filippo.io/age/internal/bech32" + "filippo.io/hpke" ) // EncodeIdentity encodes a plugin identity string for a plugin with the given @@ -78,3 +81,28 @@ func validPluginName(name string) bool { } 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()) +} diff --git a/plugin/encode_go1.20.go b/plugin/encode_go1.20.go deleted file mode 100644 index 6b17166..0000000 --- a/plugin/encode_go1.20.go +++ /dev/null @@ -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()) -} diff --git a/pq.go b/pq.go new file mode 100644 index 0000000..46a5b06 --- /dev/null +++ b/pq.go @@ -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) +} diff --git a/recipients_test.go b/recipients_test.go index 52ceb58..b837248 100644 --- a/recipients_test.go +++ b/recipients_test.go @@ -7,6 +7,7 @@ package age_test import ( "bytes" "crypto/rand" + "io" "testing" "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) { password := "twitch.tv/filosottile" diff --git a/scrypt.go b/scrypt.go index 73d13b7..0ed2859 100644 --- a/scrypt.go +++ b/scrypt.go @@ -27,7 +27,7 @@ const scryptLabel = "age-encryption.org/v1/scrypt" // for the same file. // // Its use is not recommended for automated systems, which should prefer -// X25519Recipient. +// [HybridRecipient] or [X25519Recipient]. type ScryptRecipient struct { password []byte workFactor int diff --git a/tag/tag_test.go b/tag/tag_test.go index dcbe931..cf094d1 100644 --- a/tag/tag_test.go +++ b/tag/tag_test.go @@ -107,3 +107,34 @@ func TestHybridRoundTrip(t *testing.T) { 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) + } +} diff --git a/x25519.go b/x25519.go index 6cd87a8..6c0814d 100644 --- a/x25519.go +++ b/x25519.go @@ -21,8 +21,9 @@ import ( const x25519Label = "age-encryption.org/v1/X25519" -// X25519Recipient is the standard age public key. Messages encrypted to this -// recipient can be decrypted with the corresponding X25519Identity. +// X25519Recipient is the standard age pre-quantum public key. Messages +// 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 // the message alone if it is encrypted to a certain recipient. @@ -105,8 +106,9 @@ func (r *X25519Recipient) String() string { return s } -// X25519Identity is the standard age private key, which can decrypt messages -// encrypted to the corresponding X25519Recipient. +// X25519Identity is the standard pre-quantum age private key, which can decrypt +// messages encrypted to the corresponding [X25519Recipient]. For post-quantum +// resistance, use [HybridIdentity]. type X25519Identity struct { secretKey, ourPublicKey []byte }