mirror of
https://github.com/FiloSottile/age.git
synced 2025-12-23 05:25:14 +00:00
age,cmd/age,cmd/age-keygen: add post-quantum hybrid keys
This commit is contained in:
committed by
Filippo Valsorda
parent
6ece9e45ee
commit
c6fcb5300c
28
README.md
28
README.md
@@ -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
32
age.go
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
148
cmd/age-plugin-pq/plugin-pq.go
Normal file
148
cmd/age-plugin-pq/plugin-pq.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
47
cmd/age/testdata/hybrid.txt
vendored
Normal 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
25
cmd/age/testdata/keygen.txt
vendored
Normal 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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
39
parse.go
39
parse.go
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
181
pq.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
10
x25519.go
10
x25519.go
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user