diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 836abae6..61b922db 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,19 +16,20 @@ jobs: - {GOOS: linux, GOARCH: amd64} - {GOOS: linux, GOARCH: arm, GOARM: 6} - {GOOS: linux, GOARCH: arm64} - - {GOOS: darwin, GOARCH: amd64} - {GOOS: darwin, GOARCH: arm64} - {GOOS: windows, GOARCH: amd64} - {GOOS: freebsd, GOARCH: amd64} steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 + persist-credentials: false - name: Install Go uses: actions/setup-go@v5 with: go-version: 1.x + cache: false - name: Build binary run: | cp LICENSE "$RUNNER_TEMP/LICENSE" diff --git a/.github/workflows/ronn.yml b/.github/workflows/ronn.yml index 9d4d0ae3..a0001070 100644 --- a/.github/workflows/ronn.yml +++ b/.github/workflows/ronn.yml @@ -13,23 +13,23 @@ jobs: name: Ronn runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Install ronn - run: sudo apt-get update && sudo apt-get install -y ronn - - name: Run ronn - run: bash -O globstar -c 'ronn **/*.ronn' - - name: Undo email mangling - # rdiscount randomizes the output for no good reason, which causes - # changes to always get committed. Sigh. - # https://github.com/davidfstr/rdiscount/blob/6b1471ec3/ext/generate.c#L781-L795 - run: |- - for f in doc/*.html; do - awk '/Filippo Valsorda/ { $0 = "

Filippo Valsorda age@filippo.io

" } { print }' "$f" > "$f.tmp" - mv "$f.tmp" "$f" - done - - name: Upload generated files - uses: actions/upload-artifact@v4 + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: geomys/sandboxed-step@v1.2.1 + with: + persist-workspace-changes: true + run: | + sudo apt-get update && sudo apt-get install -y ronn + bash -O globstar -c 'ronn **/*.ronn' + # rdiscount randomizes the output for no good reason, which causes + # changes to always get committed. Sigh. + # https://github.com/davidfstr/rdiscount/blob/6b1471ec3/ext/generate.c#L781-L795 + for f in doc/*.html; do + awk '/Filippo Valsorda/ { $0 = "

Filippo Valsorda age@filippo.io

" } { print }' "$f" > "$f.tmp" + mv "$f.tmp" "$f" + done + - uses: actions/upload-artifact@v4 with: name: man-pages path: | @@ -42,10 +42,10 @@ jobs: contents: write runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Download generated files - uses: actions/download-artifact@v4 + - uses: actions/checkout@v5 + with: + persist-credentials: true + - uses: actions/download-artifact@v4 with: name: man-pages path: doc/ diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4331fa34..18c656e6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,55 +1,79 @@ name: Go tests -on: [push, pull_request] +on: + push: + pull_request: + schedule: # daily at 09:42 UTC + - cron: '42 9 * * *' + workflow_dispatch: permissions: contents: read jobs: test: - name: Test strategy: fail-fast: false matrix: - go: [1.19.x, 1.x] - os: [ubuntu-latest, macos-latest, windows-latest] + go: + - { go-version: stable } + - { go-version: oldstable } + - { go-version-file: go.mod } + os: + - ubuntu-latest + - macos-latest + - windows-latest runs-on: ${{ matrix.os }} steps: - - name: Install Go ${{ matrix.go }} - uses: actions/setup-go@v5 - with: - go-version: ${{ matrix.go }} - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Run tests - run: go test -race ./... - gotip: - name: Test (Go tip) + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: actions/setup-go@v6 + with: + go-version: ${{ matrix.go.go-version }} + go-version-file: ${{ matrix.go.go-version-file }} + - run: | + go test -race ./... + test-latest: + runs-on: ubuntu-latest strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - runs-on: ${{ matrix.os }} + go: + - { go-version: stable } + - { go-version: oldstable } + - { go-version-file: go.mod } + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: actions/setup-go@v6 + with: + go-version: ${{ matrix.go.go-version }} + go-version-file: ${{ matrix.go.go-version-file }} + - uses: geomys/sandboxed-step@v1.2.1 + with: + run: | + go get -u -t ./... + go test -race ./... + staticcheck: + runs-on: ubuntu-latest steps: - - name: Install bootstrap Go - uses: actions/setup-go@v5 + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: actions/setup-go@v6 with: go-version: stable - - name: Install Go tip (UNIX) - if: runner.os != 'Windows' - run: | - git clone --filter=tree:0 https://go.googlesource.com/go $HOME/gotip - cd $HOME/gotip/src && ./make.bash - echo "$HOME/gotip/bin" >> $GITHUB_PATH - - name: Install Go tip (Windows) - if: runner.os == 'Windows' - run: | - git clone --filter=tree:0 https://go.googlesource.com/go $HOME/gotip - cd $HOME/gotip/src && ./make.bat - echo "$HOME/gotip/bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - run: go version - - name: Run tests - run: go test -race ./... + - uses: geomys/sandboxed-step@v1.2.1 + with: + run: go run honnef.co/go/tools/cmd/staticcheck@latest ./... + govulncheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: actions/setup-go@v6 + with: + go-version: stable + - uses: geomys/sandboxed-step@v1.2.1 + with: + run: go run golang.org/x/vuln/cmd/govulncheck@latest ./... diff --git a/README.md b/README.md index 3232002f..ea70f098 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ age is a simple, modern and secure file encryption tool, format, and Go library. -It features small explicit keys, no config options, and UNIX-style composability. +It features small explicit keys, post-quantum support, no config options, and UNIX-style composability. ``` $ age-keygen -o key.txt @@ -25,13 +25,13 @@ $ age --decrypt -i key.txt data.tar.gz.age > data.tar.gz 🦀 An alternative interoperable Rust implementation is available at [github.com/str4d/rage](https://github.com/str4d/rage). -🌍 [Typage](https://github.com/FiloSottile/typage) is a TypeScript implementation. It works in the browser, in Node.js, and in Bun. +🌍 [Typage](https://github.com/FiloSottile/typage) is a TypeScript implementation. It works in the browser, Node.js, Deno, and Bun. 🔑 Hardware PIV tokens such as YubiKeys are supported through the [age-plugin-yubikey](https://github.com/str4d/age-plugin-yubikey) plugin. ✨ For more plugins, implementations, tools, and integrations, check out the [awesome age](https://github.com/FiloSottile/awesome-age) list. -💬 The author pronounces it `[aɡe̞]` [with a hard *g*](https://translate.google.com/?sl=it&text=aghe), like GIF, and is always spelled lowercase. +💬 The author pronounces it `[aɡe̞]` [with a hard *g*](https://translate.google.com/?sl=it&text=aghe), like GIF, and it's always spelled lowercase. ## Installation @@ -229,6 +229,28 @@ $ age -R recipients.txt example.jpg > example.jpg.age If the argument to `-R` (or `-i`) is `-`, the file is read from standard input. +### Post-quantum keys + +To generate hybrid post-quantum keys, which are secure against future quantum +computer attacks, use the `-pq` flag with `age-keygen`. This may become the +default in the future. + +Post-quantum identities start with `AGE-SECRET-KEY-PQ-1...` and recipients with +`age1pq1...`. The recipients are unfortunately ~2000 characters long. + +``` +$ age-keygen -pq -o key.txt +$ age-keygen -y key.txt > recipient.txt +$ age -R recipient.txt example.jpg > example.jpg.age +$ age -d -i key.txt example.jpg.age > example.jpg +``` + +Support for post-quantum keys is built into age v1.3.0 and later. Alternatively, +the `age-plugin-pq` binary can be installed and placed in `$PATH` to add support +to any version and implementation of age that supports plugins. Recipients will +work out of the box, while identities will have to be converted to plugin +identities with `age-plugin-pq -identity`. + ### Passphrases Files can be encrypted with a passphrase by using `-p/--passphrase`. By default age will automatically generate a secure passphrase. Passphrase protected files are automatically detected at decrypt time. diff --git a/age.go b/age.go index eb34c3e4..00c39f61 100644 --- a/age.go +++ b/age.go @@ -6,9 +6,9 @@ // specification. // // For most use cases, use the [Encrypt] and [Decrypt] functions with -// [X25519Recipient] and [X25519Identity]. If passphrase encryption is required, use -// [ScryptRecipient] and [ScryptIdentity]. For compatibility with existing SSH keys -// use the filippo.io/age/agessh package. +// [HybridRecipient] and [HybridIdentity]. If passphrase encryption is +// required, use [ScryptRecipient] and [ScryptIdentity]. For compatibility with +// existing SSH keys use the filippo.io/age/agessh package. // // age encrypted files are binary and not malleable. For encoding them as text, // use the filippo.io/age/armor package. @@ -26,13 +26,13 @@ // There is no default path for age keys. Instead, they should be stored at // application-specific paths. The CLI supports files where private keys are // listed one per line, ignoring empty lines and lines starting with "#". These -// files can be parsed with ParseIdentities. +// files can be parsed with [ParseIdentities]. // // When integrating age into a new system, it's recommended that you only -// support X25519 keys, and not SSH keys. The latter are supported for manual -// encryption operations. If you need to tie into existing key management -// infrastructure, you might want to consider implementing your own Recipient -// and Identity. +// support native (X25519 and hybrid) keys, and not SSH keys. The latter are +// supported for manual encryption operations. If you need to tie into existing +// key management infrastructure, you might want to consider implementing your +// own [Recipient] and [Identity]. // // # Backwards compatibility // @@ -52,6 +52,7 @@ import ( "errors" "fmt" "io" + "slices" "sort" "filippo.io/age/internal/format" @@ -59,7 +60,7 @@ import ( ) // An Identity is passed to [Decrypt] to unwrap an opaque file key from a -// recipient stanza. It can be for example a secret key like [X25519Identity], a +// recipient stanza. It can be for example a secret key like [HybridIdentity], a // plugin, or a custom implementation. type Identity interface { // Unwrap must return an error wrapping [ErrIncorrectIdentity] if none of @@ -76,7 +77,7 @@ type Identity interface { var ErrIncorrectIdentity = errors.New("incorrect identity for recipient block") // A Recipient is passed to [Encrypt] to wrap an opaque file key to one or more -// recipient stanza(s). It can be for example a public key like [X25519Recipient], +// recipient stanza(s). It can be for example a public key like [HybridRecipient], // a plugin, or a custom implementation. type Recipient interface { // Most age API users won't need to interact with this method directly, and @@ -142,7 +143,7 @@ func Encrypt(dst io.Writer, recipients ...Recipient) (io.WriteCloser, error) { if i == 0 { labels = l } else if !slicesEqual(labels, l) { - return nil, fmt.Errorf("incompatible recipients") + return nil, incompatibleLabelsError(labels, l) } for _, s := range stanzas { hdr.Recipients = append(hdr.Recipients, (*format.Stanza)(s)) @@ -188,6 +189,15 @@ func slicesEqual(s1, s2 []string) bool { return true } +func incompatibleLabelsError(l1, l2 []string) error { + hasPQ1 := slices.Contains(l1, "postquantum") + hasPQ2 := slices.Contains(l2, "postquantum") + if hasPQ1 != hasPQ2 { + return fmt.Errorf("incompatible recipients: can't mix post-quantum and classic recipients, or the file would be vulnerable to quantum computers") + } + return fmt.Errorf("incompatible recipients: %q and %q can't be mixed", l1, l2) +} + // NoIdentityMatchError is returned by [Decrypt] when none of the supplied // identities match the encrypted file. type NoIdentityMatchError struct { @@ -204,6 +214,7 @@ func (*NoIdentityMatchError) Error() string { // // It returns a Reader reading the decrypted plaintext of the age file read // from src. All identities will be tried until one successfully decrypts the file. +// Native, non-interactive identities are tried before any other identities. // // If no identity matches the encrypted file, the returned error will be of type // [NoIdentityMatchError]. @@ -230,6 +241,24 @@ func decryptHdr(hdr *format.Header, identities ...Identity) ([]byte, error) { if len(identities) == 0 { return nil, errors.New("no identities specified") } + slices.SortStableFunc(identities, func(a, b Identity) int { + var aIsNative, bIsNative bool + switch a.(type) { + case *X25519Identity, *HybridIdentity, *ScryptIdentity: + aIsNative = true + } + switch b.(type) { + case *X25519Identity, *HybridIdentity, *ScryptIdentity: + bIsNative = true + } + if aIsNative && !bIsNative { + return -1 + } + if !aIsNative && bIsNative { + return 1 + } + return 0 + }) stanzas := make([]*Stanza, 0, len(hdr.Recipients)) for _, s := range hdr.Recipients { diff --git a/age_test.go b/age_test.go index ef870d47..dfc753ba 100644 --- a/age_test.go +++ b/age_test.go @@ -285,6 +285,50 @@ func TestLabels(t *testing.T) { } } +// testIdentity is a non-native identity that records if Unwrap is called. +type testIdentity struct { + called bool +} + +func (ti *testIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) { + ti.called = true + return nil, age.ErrIncorrectIdentity +} + +func TestDecryptNativeIdentitiesFirst(t *testing.T) { + correct, err := age.GenerateX25519Identity() + if err != nil { + t.Fatal(err) + } + unrelated, err := age.GenerateX25519Identity() + if err != nil { + t.Fatal(err) + } + + buf := &bytes.Buffer{} + w, err := age.Encrypt(buf, correct.Recipient()) + if err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + + nonNative := &testIdentity{} + + // Pass identities: unrelated native, non-native, correct native. + // Native identities should be tried first, so correct should match + // before nonNative is ever called. + _, err = age.Decrypt(bytes.NewReader(buf.Bytes()), unrelated, nonNative, correct) + if err != nil { + t.Fatal(err) + } + + if nonNative.called { + t.Error("non-native identity was called, but native identities should be tried first") + } +} + func TestDetachedHeader(t *testing.T) { i, err := age.GenerateX25519Identity() if err != nil { diff --git a/agessh/agessh.go b/agessh/agessh.go index ec2ccdda..9af9a7c8 100644 --- a/agessh/agessh.go +++ b/agessh/agessh.go @@ -7,7 +7,7 @@ // encryption with age-encryption.org/v1. // // These recipient types should only be used for compatibility with existing -// keys, and native X25519 keys should be preferred otherwise. +// keys, and native keys should be preferred otherwise. // // Note that these recipient types are not anonymous: the encrypted message will // include a short 32-bit ID of the public key. diff --git a/armor/armor.go b/armor/armor.go index ac6397e3..4ba94738 100644 --- a/armor/armor.go +++ b/armor/armor.go @@ -140,6 +140,9 @@ func (r *armoredReader) Read(p []byte) (int, error) { if string(line) == Footer { return 0, r.setErr(drainTrailing()) } + if len(line) == 0 { + return 0, r.setErr(errors.New("empty line in armored data")) + } if len(line) > format.ColumnsPerLine { return 0, r.setErr(errors.New("column limit exceeded")) } diff --git a/cmd/age-keygen/keygen.go b/cmd/age-keygen/keygen.go index 0913de6c..269b5609 100644 --- a/cmd/age-keygen/keygen.go +++ b/cmd/age-keygen/keygen.go @@ -18,15 +18,18 @@ import ( ) const usage = `Usage: - age-keygen [-o OUTPUT] + age-keygen [-pq] [-o OUTPUT] age-keygen -y [-o OUTPUT] [INPUT] Options: + -pq Generate a post-quantum hybrid ML-KEM-768 + X25519 key pair. + (This might become the default in the future.) -o, --output OUTPUT Write the result to the file at path OUTPUT. -y Convert an identity file to a recipients file. -age-keygen generates a new native X25519 key pair, and outputs it to -standard output or to the OUTPUT file. +age-keygen generates a new native X25519 or, with the -pq flag, post-quantum +hybrid ML-KEM-768 + X25519 key pair, and outputs it to standard output or to +the OUTPUT file. If an OUTPUT file is specified, the public key is printed to standard error. If OUTPUT already exists, it is not overwritten. @@ -42,6 +45,11 @@ Examples: # public key: age1lvyvwawkr0mcnnnncaghunadrqkmuf9e6507x9y920xxpp866cnql7dp2z AGE-SECRET-KEY-1N9JEPW6DWJ0ZQUDX63F5A03GX8QUW7PXDE39N8UYF82VZ9PC8UFS3M7XA9 + $ age-keygen -pq + # created: 2025-11-17T12:15:17+01:00 + # public key: age1pq1pd[... 1950 more characters ...] + AGE-SECRET-KEY-PQ-1XXC4XS9DXHZ6TREKQTT3XECY8VNNU7GJ83C3Y49D0GZ3ZUME4JWS6QC3EF + $ age-keygen -o key.txt Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p @@ -52,12 +60,11 @@ func main() { log.SetFlags(0) flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) } - var ( - versionFlag, convertFlag bool - outFlag string - ) + var outFlag string + var pqFlag, versionFlag, convertFlag bool flag.BoolVar(&versionFlag, "version", false, "print the version") + flag.BoolVar(&pqFlag, "pq", false, "generate a post-quantum key pair") flag.BoolVar(&convertFlag, "y", false, "convert identities to recipients") flag.StringVar(&outFlag, "o", "", "output to `FILE` (default stdout)") flag.StringVar(&outFlag, "output", "", "output to `FILE` (default stdout)") @@ -68,6 +75,9 @@ func main() { if len(flag.Args()) > 1 && convertFlag { errorf("too many arguments") } + if pqFlag && convertFlag { + errorf("-pq cannot be used with -y") + } if versionFlag { if buildInfo, ok := debug.ReadBuildInfo(); ok { fmt.Println(buildInfo.Main.Version) @@ -107,23 +117,36 @@ func main() { if fi, err := out.Stat(); err == nil && fi.Mode().IsRegular() && fi.Mode().Perm()&0004 != 0 { warning("writing secret key to a world-readable file") } - generate(out) + generate(out, pqFlag) } } -func generate(out *os.File) { - k, err := age.GenerateX25519Identity() - if err != nil { - errorf("internal error: %v", err) +func generate(out *os.File, pq bool) { + var i age.Identity + var r age.Recipient + if pq { + k, err := age.GenerateHybridIdentity() + if err != nil { + errorf("internal error: %v", err) + } + i = k + r = k.Recipient() + } else { + k, err := age.GenerateX25519Identity() + if err != nil { + errorf("internal error: %v", err) + } + i = k + r = k.Recipient() } if !term.IsTerminal(int(out.Fd())) { - fmt.Fprintf(os.Stderr, "Public key: %s\n", k.Recipient()) + fmt.Fprintf(os.Stderr, "Public key: %s\n", r) } fmt.Fprintf(out, "# created: %s\n", time.Now().Format(time.RFC3339)) - fmt.Fprintf(out, "# public key: %s\n", k.Recipient()) - fmt.Fprintf(out, "%s\n", k) + fmt.Fprintf(out, "# public key: %s\n", r) + fmt.Fprintf(out, "%s\n", i) } func convert(in io.Reader, out io.Writer) { @@ -135,11 +158,15 @@ func convert(in io.Reader, out io.Writer) { errorf("no identities found in the input") } for _, id := range ids { - id, ok := id.(*age.X25519Identity) - if !ok { + switch id := id.(type) { + case *age.X25519Identity: + fmt.Fprintf(out, "%s\n", id.Recipient()) + case *age.HybridIdentity: + fmt.Fprintf(out, "%s\n", id.Recipient()) + default: errorf("internal error: unexpected identity type: %T", id) } - fmt.Fprintf(out, "%s\n", id.Recipient()) + } } diff --git a/cmd/age-plugin-pq/plugin-pq.go b/cmd/age-plugin-pq/plugin-pq.go new file mode 100644 index 00000000..0773e620 --- /dev/null +++ b/cmd/age-plugin-pq/plugin-pq.go @@ -0,0 +1,148 @@ +package main + +import ( + "flag" + "fmt" + "io" + "log" + "os" + "runtime/debug" + + "filippo.io/age" + "filippo.io/age/internal/bech32" + "filippo.io/age/plugin" +) + +const usage = `Usage: + age-plugin-pq -identity [-o OUTPUT] [INPUT] + +Options: + -identity Convert one or more native post-quantum identities from + INPUT or from standard input to plugin identities. + -o, --output OUTPUT Write the result to the file at path OUTPUT instead of + standard output. + +age-plugin-pq is an age plugin for post-quantum hybrid ML-KEM-768 + X25519 +recipients and identities. These are supported natively by age v1.3.0 and later, +but this plugin can be placed in $PATH to add support to any version and +implementation of age that supports plugins. + +Recipients work out of the box, while identities need to be converted to plugin +identities with -identity. If OUTPUT already exists, it is not overwritten.` + +func main() { + log.SetFlags(0) + + p, err := plugin.New("pq") + if err != nil { + log.Fatal(err) + } + p.RegisterFlags(nil) + + flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) } + + var outFlag string + var versionFlag, identityFlag bool + flag.BoolVar(&versionFlag, "version", false, "print the version") + flag.BoolVar(&identityFlag, "identity", false, "convert identities to plugin identities") + flag.StringVar(&outFlag, "o", "", "output to `FILE` (default stdout)") + flag.StringVar(&outFlag, "output", "", "output to `FILE` (default stdout)") + flag.Parse() + + if versionFlag { + if buildInfo, ok := debug.ReadBuildInfo(); ok { + fmt.Println(buildInfo.Main.Version) + return + } + fmt.Println("(unknown)") + return + } + + if identityFlag { + if len(flag.Args()) > 1 { + errorf("too many arguments") + } + + out := os.Stdout + if outFlag != "" { + f, err := os.OpenFile(outFlag, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + errorf("failed to open output file %q: %v", outFlag, err) + } + defer func() { + if err := f.Close(); err != nil { + errorf("failed to close output file %q: %v", outFlag, err) + } + }() + out = f + } + if fi, err := out.Stat(); err == nil && fi.Mode().IsRegular() && fi.Mode().Perm()&0004 != 0 { + warning("writing secret key to a world-readable file") + } + + in := os.Stdin + if inFile := flag.Arg(0); inFile != "" && inFile != "-" { + f, err := os.Open(inFile) + if err != nil { + errorf("failed to open input file %q: %v", inFile, err) + } + defer f.Close() + in = f + } + + convert(in, out) + return + } + + p.HandleRecipientEncoding(func(s string) (age.Recipient, error) { + return age.ParseHybridRecipient(s) + }) + p.HandleIdentity(func(data []byte) (age.Identity, error) { + // Convert from a AGE-PLUGIN-PQ-1... payload to a + // AGE-SECRET-KEY-PQ-1... identity encoding. + s, err := bech32.Encode("AGE-SECRET-KEY-PQ-", data) + if err != nil { + return nil, err + } + return age.ParseHybridIdentity(s) + }) + p.HandleIdentityAsRecipient(func(data []byte) (age.Recipient, error) { + s, err := bech32.Encode("AGE-SECRET-KEY-PQ-", data) + if err != nil { + return nil, err + } + i, err := age.ParseHybridIdentity(s) + if err != nil { + return nil, err + } + return i.Recipient(), nil + }) + os.Exit(p.Main()) +} + +func convert(in io.Reader, out io.Writer) { + ids, err := age.ParseIdentities(in) + if err != nil { + errorf("failed to parse identities: %v", err) + } + for i, id := range ids { + hybridID, ok := id.(*age.HybridIdentity) + if !ok { + errorf("identity #%d is not a post-quantum hybrid identity", i+1) + } + _, data, err := bech32.Decode(hybridID.String()) + if err != nil { + errorf("failed to decode identity #%d: %v", i+1, err) + } + fmt.Fprintln(out, plugin.EncodeIdentity("pq", data)) + } +} + +func errorf(format string, v ...interface{}) { + log.Printf("age-plugin-pq: error: "+format, v...) + log.Fatalf("age-plugin-pq: report unexpected or unhelpful errors at https://filippo.io/age/report") +} + +func warning(msg string) { + log.Printf("age-plugin-pq: warning: %s", msg) +} diff --git a/cmd/age-plugin-tag/plugin-tag.go b/cmd/age-plugin-tag/plugin-tag.go new file mode 100644 index 00000000..a3c5269b --- /dev/null +++ b/cmd/age-plugin-tag/plugin-tag.go @@ -0,0 +1,32 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + + "filippo.io/age" + "filippo.io/age/plugin" + "filippo.io/age/tag" +) + +const usage = `age-plugin-tag is an age plugin for P-256 tagged recipients. These are supported +natively by age v1.3.0 and later, but this plugin can be placed in $PATH to add +support to any version and implementation of age that supports plugins. + +Usually, tagged recipients are the public side of private keys held in hardware, +where the identity side is handled by a different plugin.` + +func main() { + flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) } + + p, err := plugin.New("tag") + if err != nil { + log.Fatal(err) + } + p.HandleRecipient(func(b []byte) (age.Recipient, error) { + return tag.NewClassicRecipient(b) + }) + os.Exit(p.Main()) +} diff --git a/cmd/age-plugin-tagpq/plugin-tagpq.go b/cmd/age-plugin-tagpq/plugin-tagpq.go new file mode 100644 index 00000000..8577f65b --- /dev/null +++ b/cmd/age-plugin-tagpq/plugin-tagpq.go @@ -0,0 +1,33 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + + "filippo.io/age" + "filippo.io/age/plugin" + "filippo.io/age/tag" +) + +const usage = `age-plugin-tagpq is an age plugin for ML-KEM-768 + P-256 post-quantum hybrid +tagged recipients. These are supported natively by age v1.3.0 and later, but +this plugin can be placed in $PATH to add support to any version and +implementation of age that supports plugins. + +Usually, tagged recipients are the public side of private keys held in hardware, +where the identity side is handled by a different plugin.` + +func main() { + flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) } + + p, err := plugin.New("tagpq") + if err != nil { + log.Fatal(err) + } + p.HandleRecipient(func(b []byte) (age.Recipient, error) { + return tag.NewHybridRecipient(b) + }) + os.Exit(p.Main()) +} diff --git a/cmd/age/age.go b/cmd/age/age.go index 58a75fee..9b51f6b2 100644 --- a/cmd/age/age.go +++ b/cmd/age/age.go @@ -99,7 +99,7 @@ func main() { if len(os.Args) == 1 { flag.Usage() - exit(1) + os.Exit(1) } var ( @@ -494,6 +494,8 @@ func identitiesToRecipients(ids []age.Identity) ([]age.Recipient, error) { switch id := id.(type) { case *age.X25519Identity: recipients = append(recipients, id.Recipient()) + case *age.HybridIdentity: + recipients = append(recipients, id.Recipient()) case *plugin.Identity: recipients = append(recipients, id.Recipient()) case *agessh.RSAIdentity: diff --git a/cmd/age/age_test.go b/cmd/age/age_test.go index 35412322..52133842 100644 --- a/cmd/age/age_test.go +++ b/cmd/age/age_test.go @@ -6,6 +6,9 @@ package main import ( "os" + "os/exec" + "path/filepath" + "sync" "testing" "filippo.io/age" @@ -14,22 +17,15 @@ import ( ) func TestMain(m *testing.M) { - os.Exit(testscript.RunMain(m, map[string]func() int{ - "age": func() (exitCode int) { - testOnlyPanicInsteadOfExit = true - defer func() { - if testOnlyDidExit { - exitCode = recover().(int) - } - }() + testscript.Main(m, map[string]func(){ + "age": func() { testOnlyConfigureScryptIdentity = func(r *age.ScryptRecipient) { r.SetWorkFactor(10) } testOnlyFixedRandomWord = "four" main() - return 0 }, - "age-plugin-test": func() (exitCode int) { + "age-plugin-test": func() { p, _ := plugin.New("test") p.HandleRecipient(func(data []byte) (age.Recipient, error) { return testPlugin{}, nil @@ -37,9 +33,9 @@ func TestMain(m *testing.M) { p.HandleIdentity(func(data []byte) (age.Identity, error) { return testPlugin{}, nil }) - return p.Main() + os.Exit(p.Main()) }, - })) + }) } type testPlugin struct{} @@ -55,9 +51,26 @@ func (testPlugin) Unwrap(ss []*age.Stanza) ([]byte, error) { return nil, age.ErrIncorrectIdentity } +var buildExtraCommands = sync.OnceValue(func() error { + bindir := filepath.SplitList(os.Getenv("PATH"))[0] + // Build age-keygen and age-plugin-pq into the test binary directory. + cmd := exec.Command("go", "build", "-o", bindir) + if testing.CoverMode() != "" { + cmd.Args = append(cmd.Args, "-cover") + } + cmd.Args = append(cmd.Args, "filippo.io/age/cmd/age-keygen") + cmd.Args = append(cmd.Args, "filippo.io/age/cmd/age-plugin-pq") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +}) + func TestScript(t *testing.T) { testscript.Run(t, testscript.Params{ Dir: "testdata", + Setup: func(e *testscript.Env) error { + return buildExtraCommands() + }, // TODO: enable AGEDEBUG=plugin without breaking stderr checks. }) } diff --git a/cmd/age/parse.go b/cmd/age/parse.go index 4a59e7a4..9d1a5ff8 100644 --- a/cmd/age/parse.go +++ b/cmd/age/parse.go @@ -16,6 +16,7 @@ import ( "filippo.io/age/agessh" "filippo.io/age/armor" "filippo.io/age/plugin" + "filippo.io/age/tag" "golang.org/x/crypto/cryptobyte" "golang.org/x/crypto/ssh" ) @@ -30,6 +31,10 @@ func (gitHubRecipientError) Error() string { func parseRecipient(arg string) (age.Recipient, error) { switch { + case strings.HasPrefix(arg, "age1tag1") || strings.HasPrefix(arg, "age1tagpq1"): + return tag.ParseRecipient(arg) + case strings.HasPrefix(arg, "age1pq1"): + return age.ParseHybridRecipient(arg) case strings.HasPrefix(arg, "age1") && strings.Count(arg, "1") > 1: return plugin.NewRecipient(arg, pluginTerminalUI) case strings.HasPrefix(arg, "age1"): @@ -121,8 +126,9 @@ func sshKeyType(s string) (string, bool) { } // parseIdentitiesFile parses a file that contains age or SSH keys. It returns -// one or more of *age.X25519Identity, *agessh.RSAIdentity, *agessh.Ed25519Identity, -// *agessh.EncryptedSSHIdentity, or *EncryptedIdentity. +// one or more of *[age.X25519Identity], *[age.HybridIdentity], +// *[agessh.RSAIdentity], *[agessh.Ed25519Identity], +// *[agessh.EncryptedSSHIdentity], or *[EncryptedIdentity]. func parseIdentitiesFile(name string) ([]age.Identity, error) { var f *os.File if name == "-" { @@ -201,12 +207,14 @@ func parseIdentity(s string) (age.Identity, error) { return plugin.NewIdentity(s, pluginTerminalUI) case strings.HasPrefix(s, "AGE-SECRET-KEY-1"): return age.ParseX25519Identity(s) + case strings.HasPrefix(s, "AGE-SECRET-KEY-PQ-1"): + return age.ParseHybridIdentity(s) default: return nil, fmt.Errorf("unknown identity type") } } -// parseIdentities is like age.ParseIdentities, but supports plugin identities. +// parseIdentities is like [age.ParseIdentities], but supports plugin identities. func parseIdentities(f io.Reader) ([]age.Identity, error) { const privateKeySizeLimit = 1 << 24 // 16 MiB var ids []age.Identity diff --git a/cmd/age/testdata/hybrid.txt b/cmd/age/testdata/hybrid.txt new file mode 100644 index 00000000..3e8f971a --- /dev/null +++ b/cmd/age/testdata/hybrid.txt @@ -0,0 +1,47 @@ +# encrypt and decrypt a file with -r +age -r age1pq1044e3hfmq2tltgck54m0gnpv8rfrwkdxjgl38psnwg8zfyv4uqdu63kk0u94ztt0q35k9dzuwgv405kxq6due2sre2kfsjftf7k2vz2mdpetku9pufy9d6ny6k5ss0cghpxdxrux0yd5xy40f8fu8ch9xn2rczyek2pqetqk0l9fqj0e3hfhkyfmcf9j6cpe5mf5stt9cymfq3xm276x4sramhn8srr42g78q9rkmqesevy33p4kdhkuggl99x9zx7j05uflx3s8q558xeaq9q9ctxqx52rzg6yffwykxngvxf7g3n83ddawagq2lnk59kcnexm54jrsmkjk9nemlge9v6p7fxu5z6yvq3ghpet8ww2c3h9fylvjx6j9njwz744376ap5r3xcpzh7qlk0kq3gknzfqhmxmjqypf6xz6geffdmy25apzsfr4ce0x825njkf62mgzx0x9n3zt0490u4dnt4468zse4zvd9sv0ycx2u2wsseuy4yymrg0nmfuxcwq9q0jykq7g5f59vunf95vnmcw336ksrh43fgapsd40u4227ymx56xtudtrte39twnq6fdfdj4p9pgx7pcqdk953za76fqcnvatx2cmywurag7ky4y8ecq5lvf25rydhxg6gmcn6vf78vtsqw79hp3mzv2u4v4jzn0nk50kl4gpv7k60cgz79c6k30jzy8dyetqtwg04tguxkv2p4hqj02yvsz2szaqdn9flu24rhth3v0t33023nwy27gt0x32eqpa5tpz6ygz3yzcsa3w889drpynzd5hlywuk9dwgafuax7upfnnxch4znrx4vactasg32u7vl886pxfswskk7wjgqkggrewtnaecfusl3nmg0xxh8e5nl5eft0xqwj5rk9ajn2j6yxehhgg5k6kpw5pk382hxmpu8wcd3rqx6m8905c7f8v0x2z9gyduxtqp2fdy9w2z4g2rrgu4d5cqes5tnwag007h2f326s3j9p6970xyd73rv5awx27zzezrax467c6vuye62ha88pzhw9xytjpezw3vg3l6x4k9rxwm9f6lwj6j7r9makg59rhgt0355s3jx9vw3raz5qdk5cnpny56q5h3dyd75suskuz63wmmfp9333tx4ry38vnh7jxy0j0cka99y7v2dmyjldjy0mqjetcefsmmg3pw0h9ll0rtskpmxz2cs8vmx6lu4v5rjtzpzgaqt46cwvnmhy5e3ye0tv6sre342lwg0tfd0yjkfz2haqc3hprpqf5ce8c9lx076dnk5cukn3tu38jgprrxt9sk9nqzt8grvkc4qzlg8jjf638v07ej9cf4eu48kphrgyjy83qm822xq8v9k22zwa7txr439x74j2f9ay7frkwtfdwn02enyx8stjpr4wgg25n5tj7uxyx0dqt3hnrqf7dcgt9fqnkjgemjfzyck6pawewptcayxm49sq3477f4cvm2p8fmg27rcmwm5l2vmzvdmymvkrr3aywu04y3zlhs7awu3r9u9lseheqpffvm2xf8y6w2qaj9g22pcu0sdsnqseusrxq9awru5lyqxnzftlslgpe3njegxkze98rveswp3u0xgx69wkg38vgp4k55lwwgv5l7ccv503mh9q23nud4xtm499xq276u40rxyrzaqfyuz2f3v5qr2vq00xs4g3ruynmq0c6nprvjcwqc5zrdn4cnzeeuwumjvqfjdpxr739n4cqcwytc8pjmfcj2qs3l2a8p5fzq7nvtng2jvvklx0x5ypnlkeuegsm587f6d4dkwel2es0lwwt88y2jdg8enjyph02tnjyndlx34h52fngjk96ggtu6vcxqkreqcxs4gwa8ww40t0kzlxfzyfhtjmfdm5fcrr5pt2xm4yslfynr0h9wkranhyadxf33kjn9xpg2fq5hnlq0 -o test.age input +age -d -i key.txt test.age +cmp stdout input +! stderr . + +# encrypt and decrypt a file with -i +age -e -i key.txt -o test.age input +age -d -i key.txt test.age +cmp stdout input +! stderr . + +# encrypt and decrypt a file with the wrong key +age -r age12phkzssndd5axajas2h74vtge62c86xjhd6u9anyanqhzvdg6sps0xthgl -o test.age input +! age -d -i key.txt test.age +stderr 'no identity matched any of the recipients' + +age -r age1pq13tgqupu990y5qlwwjy4zrzwvxg54cq89x9pxzdjenv4cl7affsqeerksrc6ufw3ndp4h5p3l5hm2jekkd7uay2mlh9eqg3sjcwmv5dcrwguc8djf83qr8gswtdrpav3jpw47kgkh6z0rnfk92xyqeyce48t02n4qz68augry3r3nd4rpdsw998vnnxzd2vepypktx2wxhfvd56jw7p32te6ttcyhc7tkqy2ks77jtjl8cgyal43pcd3g9wkcsgytccqtcnzcucjjdadpr0v7ye8u9fqusq6cq2ah0e4ejrltc8sk6763d7mjkuchmc5tkx6xztygqjx4ldxqh7jzkkyuv6ywt07ys4x09eq7gat4wuznrvgs3pkxjju8t9n0gpfqrsv658m5v09ltqc3c5z30n43ld43dwe5dn8nsk89f43tzsw2zta5kvl95zv0a94tc5y9r4ncj8g63fgljzhdxl96eg39rgd56yuvr2xdvavkk93a85n99jpjeqkfveprqc7eqft0wp7qgxcgsrwcdymgp0cqaj93jp4ckln36ylcwdzew234esa2awty25zc6pmc0jcpkrfuuptr9z5a4kx9ff4ppfff2lw95um853tcg47fa444er9k9j4e8vy5hfjx626j3tz3kc94jl8r66ahfprwtcyxae3swe9xxjhx5dfnt53n5fs2ut83t597wscjg9jc4f6h4uen2ll2ne30w7uvts8vuc5mpcfpp9espa78y0f8588m62kzez5kxxtcxykdkrnjx08w5az3pr6g6dsykwazcxtetpkq85mg7kay84z58slmvxmq56342pm6v8y3evr5wvc9g4j87jfhmryymkz9wk2en2v6tfy83rz657t0w7p3va46jt22f29u9ggzt9nu6qtguw4cp7cpj572ccyenw5rdh43h6s54g73gvxapsq3hw5yfkgttcyhkrjf9ykzd3wm5zsqgp5fca0nr56dnqe3kqrszasagqne0a83cguzwj9aw456jaxg9rurq4n4f6qec4srd0rxqfqc6rw22c2p4xyg25pls4dmxvce2fah8m6shue84preky2h0c4sa9y9tjuzn20gr9aav96e689nqx23s7zz0lvhq925jdkn04cdpzfscvhh93vh9f2tmwkzsq7pq0ncjk8g8cu75u77lyej46a4m2fefy8v2x4nzay05kulhyzk69520r7fdg3fzh0z7ysequcltr94hyf88h46hfujk30x9jp8u0pcjywh06g7tv483ypmc3pm3x3z4h9ph66lx228t3r96hyt764yxe2rn5clnfxkj3k5wf5hffehj58y56qyykwayk6qt9skvctxw20v9xy5ppnld3lushqpsutxht7cqygypdn5d2ppdgaerqzgehyrpdwzkhu0qe8w3u5h6htz8aa5zfpzy4s7sl8zdv3q0gq8ez08p86e4v3m82882yvawrvyrcxewrznwkvvez8m5aj6ktgvynyy0kc2trmtjzvjlxdf06e3rjmf55lwxrucfwu2sxdtnte83fgq0tvr2juv3pfqp8ddsrnckzqcfcvjp02mfg5y4aqlsunxpfcrdm46fphwsslrudwrfh542xg62kphca6h3xxqn538pprkknt35y00ygvrse5mxpvnstvcrnak5qduhf5dqslkn0yeadgpq6tv4wzy98kdjdzp22cpq6dy3kve856y2qlk7elyqyzj7ezpnh3vjwwmcm7ctp23k2sct86jd46ztplsq5vdjg9lyspxt0k8qx5udu9lwgulzapn3kdg7yd2zdz5dqf9933mpzwc32x8uxn8h2v5hlhdd4qk0uvwxhxyul8keaw39pz2avywk3wfly6veet9pjnj7nqecrgz824whs9sf7wl2shxk9kvkteht4z9x3w2gc6hqz5y -o test.age input +! age -d -i key.txt test.age +stderr 'no identity matched any of the recipients' + +# cannot mix hybrid and X25519 recipients +! age -r age1pq1044e3hfmq2tltgck54m0gnpv8rfrwkdxjgl38psnwg8zfyv4uqdu63kk0u94ztt0q35k9dzuwgv405kxq6due2sre2kfsjftf7k2vz2mdpetku9pufy9d6ny6k5ss0cghpxdxrux0yd5xy40f8fu8ch9xn2rczyek2pqetqk0l9fqj0e3hfhkyfmcf9j6cpe5mf5stt9cymfq3xm276x4sramhn8srr42g78q9rkmqesevy33p4kdhkuggl99x9zx7j05uflx3s8q558xeaq9q9ctxqx52rzg6yffwykxngvxf7g3n83ddawagq2lnk59kcnexm54jrsmkjk9nemlge9v6p7fxu5z6yvq3ghpet8ww2c3h9fylvjx6j9njwz744376ap5r3xcpzh7qlk0kq3gknzfqhmxmjqypf6xz6geffdmy25apzsfr4ce0x825njkf62mgzx0x9n3zt0490u4dnt4468zse4zvd9sv0ycx2u2wsseuy4yymrg0nmfuxcwq9q0jykq7g5f59vunf95vnmcw336ksrh43fgapsd40u4227ymx56xtudtrte39twnq6fdfdj4p9pgx7pcqdk953za76fqcnvatx2cmywurag7ky4y8ecq5lvf25rydhxg6gmcn6vf78vtsqw79hp3mzv2u4v4jzn0nk50kl4gpv7k60cgz79c6k30jzy8dyetqtwg04tguxkv2p4hqj02yvsz2szaqdn9flu24rhth3v0t33023nwy27gt0x32eqpa5tpz6ygz3yzcsa3w889drpynzd5hlywuk9dwgafuax7upfnnxch4znrx4vactasg32u7vl886pxfswskk7wjgqkggrewtnaecfusl3nmg0xxh8e5nl5eft0xqwj5rk9ajn2j6yxehhgg5k6kpw5pk382hxmpu8wcd3rqx6m8905c7f8v0x2z9gyduxtqp2fdy9w2z4g2rrgu4d5cqes5tnwag007h2f326s3j9p6970xyd73rv5awx27zzezrax467c6vuye62ha88pzhw9xytjpezw3vg3l6x4k9rxwm9f6lwj6j7r9makg59rhgt0355s3jx9vw3raz5qdk5cnpny56q5h3dyd75suskuz63wmmfp9333tx4ry38vnh7jxy0j0cka99y7v2dmyjldjy0mqjetcefsmmg3pw0h9ll0rtskpmxz2cs8vmx6lu4v5rjtzpzgaqt46cwvnmhy5e3ye0tv6sre342lwg0tfd0yjkfz2haqc3hprpqf5ce8c9lx076dnk5cukn3tu38jgprrxt9sk9nqzt8grvkc4qzlg8jjf638v07ej9cf4eu48kphrgyjy83qm822xq8v9k22zwa7txr439x74j2f9ay7frkwtfdwn02enyx8stjpr4wgg25n5tj7uxyx0dqt3hnrqf7dcgt9fqnkjgemjfzyck6pawewptcayxm49sq3477f4cvm2p8fmg27rcmwm5l2vmzvdmymvkrr3aywu04y3zlhs7awu3r9u9lseheqpffvm2xf8y6w2qaj9g22pcu0sdsnqseusrxq9awru5lyqxnzftlslgpe3njegxkze98rveswp3u0xgx69wkg38vgp4k55lwwgv5l7ccv503mh9q23nud4xtm499xq276u40rxyrzaqfyuz2f3v5qr2vq00xs4g3ruynmq0c6nprvjcwqc5zrdn4cnzeeuwumjvqfjdpxr739n4cqcwytc8pjmfcj2qs3l2a8p5fzq7nvtng2jvvklx0x5ypnlkeuegsm587f6d4dkwel2es0lwwt88y2jdg8enjyph02tnjyndlx34h52fngjk96ggtu6vcxqkreqcxs4gwa8ww40t0kzlxfzyfhtjmfdm5fcrr5pt2xm4yslfynr0h9wkranhyadxf33kjn9xpg2fq5hnlq0 -r age12phkzssndd5axajas2h74vtge62c86xjhd6u9anyanqhzvdg6sps0xthgl -o test.age input +stderr 'incompatible' + +! age -r age12phkzssndd5axajas2h74vtge62c86xjhd6u9anyanqhzvdg6sps0xthgl -r age1pq1044e3hfmq2tltgck54m0gnpv8rfrwkdxjgl38psnwg8zfyv4uqdu63kk0u94ztt0q35k9dzuwgv405kxq6due2sre2kfsjftf7k2vz2mdpetku9pufy9d6ny6k5ss0cghpxdxrux0yd5xy40f8fu8ch9xn2rczyek2pqetqk0l9fqj0e3hfhkyfmcf9j6cpe5mf5stt9cymfq3xm276x4sramhn8srr42g78q9rkmqesevy33p4kdhkuggl99x9zx7j05uflx3s8q558xeaq9q9ctxqx52rzg6yffwykxngvxf7g3n83ddawagq2lnk59kcnexm54jrsmkjk9nemlge9v6p7fxu5z6yvq3ghpet8ww2c3h9fylvjx6j9njwz744376ap5r3xcpzh7qlk0kq3gknzfqhmxmjqypf6xz6geffdmy25apzsfr4ce0x825njkf62mgzx0x9n3zt0490u4dnt4468zse4zvd9sv0ycx2u2wsseuy4yymrg0nmfuxcwq9q0jykq7g5f59vunf95vnmcw336ksrh43fgapsd40u4227ymx56xtudtrte39twnq6fdfdj4p9pgx7pcqdk953za76fqcnvatx2cmywurag7ky4y8ecq5lvf25rydhxg6gmcn6vf78vtsqw79hp3mzv2u4v4jzn0nk50kl4gpv7k60cgz79c6k30jzy8dyetqtwg04tguxkv2p4hqj02yvsz2szaqdn9flu24rhth3v0t33023nwy27gt0x32eqpa5tpz6ygz3yzcsa3w889drpynzd5hlywuk9dwgafuax7upfnnxch4znrx4vactasg32u7vl886pxfswskk7wjgqkggrewtnaecfusl3nmg0xxh8e5nl5eft0xqwj5rk9ajn2j6yxehhgg5k6kpw5pk382hxmpu8wcd3rqx6m8905c7f8v0x2z9gyduxtqp2fdy9w2z4g2rrgu4d5cqes5tnwag007h2f326s3j9p6970xyd73rv5awx27zzezrax467c6vuye62ha88pzhw9xytjpezw3vg3l6x4k9rxwm9f6lwj6j7r9makg59rhgt0355s3jx9vw3raz5qdk5cnpny56q5h3dyd75suskuz63wmmfp9333tx4ry38vnh7jxy0j0cka99y7v2dmyjldjy0mqjetcefsmmg3pw0h9ll0rtskpmxz2cs8vmx6lu4v5rjtzpzgaqt46cwvnmhy5e3ye0tv6sre342lwg0tfd0yjkfz2haqc3hprpqf5ce8c9lx076dnk5cukn3tu38jgprrxt9sk9nqzt8grvkc4qzlg8jjf638v07ej9cf4eu48kphrgyjy83qm822xq8v9k22zwa7txr439x74j2f9ay7frkwtfdwn02enyx8stjpr4wgg25n5tj7uxyx0dqt3hnrqf7dcgt9fqnkjgemjfzyck6pawewptcayxm49sq3477f4cvm2p8fmg27rcmwm5l2vmzvdmymvkrr3aywu04y3zlhs7awu3r9u9lseheqpffvm2xf8y6w2qaj9g22pcu0sdsnqseusrxq9awru5lyqxnzftlslgpe3njegxkze98rveswp3u0xgx69wkg38vgp4k55lwwgv5l7ccv503mh9q23nud4xtm499xq276u40rxyrzaqfyuz2f3v5qr2vq00xs4g3ruynmq0c6nprvjcwqc5zrdn4cnzeeuwumjvqfjdpxr739n4cqcwytc8pjmfcj2qs3l2a8p5fzq7nvtng2jvvklx0x5ypnlkeuegsm587f6d4dkwel2es0lwwt88y2jdg8enjyph02tnjyndlx34h52fngjk96ggtu6vcxqkreqcxs4gwa8ww40t0kzlxfzyfhtjmfdm5fcrr5pt2xm4yslfynr0h9wkranhyadxf33kjn9xpg2fq5hnlq0 -o test.age input +stderr 'incompatible' + +# convert to plugin identity and use plugin +exec age-plugin-pq -identity -o key-plugin.txt key.txt + +age -e -i key.txt -o test.age input +age -d -i key-plugin.txt test.age +cmp stdout input +! stderr . + +age -e -i key-plugin.txt -o test.age input +age -d -i key.txt test.age +cmp stdout input +! stderr . + +-- input -- +test +-- key.txt -- +# created: 2025-11-17T13:27:37+01:00 +# public key: age1pq1044e3hfmq2tltgck54m0gnpv8rfrwkdxjgl38psnwg8zfyv4uqdu63kk0u94ztt0q35k9dzuwgv405kxq6due2sre2kfsjftf7k2vz2mdpetku9pufy9d6ny6k5ss0cghpxdxrux0yd5xy40f8fu8ch9xn2rczyek2pqetqk0l9fqj0e3hfhkyfmcf9j6cpe5mf5stt9cymfq3xm276x4sramhn8srr42g78q9rkmqesevy33p4kdhkuggl99x9zx7j05uflx3s8q558xeaq9q9ctxqx52rzg6yffwykxngvxf7g3n83ddawagq2lnk59kcnexm54jrsmkjk9nemlge9v6p7fxu5z6yvq3ghpet8ww2c3h9fylvjx6j9njwz744376ap5r3xcpzh7qlk0kq3gknzfqhmxmjqypf6xz6geffdmy25apzsfr4ce0x825njkf62mgzx0x9n3zt0490u4dnt4468zse4zvd9sv0ycx2u2wsseuy4yymrg0nmfuxcwq9q0jykq7g5f59vunf95vnmcw336ksrh43fgapsd40u4227ymx56xtudtrte39twnq6fdfdj4p9pgx7pcqdk953za76fqcnvatx2cmywurag7ky4y8ecq5lvf25rydhxg6gmcn6vf78vtsqw79hp3mzv2u4v4jzn0nk50kl4gpv7k60cgz79c6k30jzy8dyetqtwg04tguxkv2p4hqj02yvsz2szaqdn9flu24rhth3v0t33023nwy27gt0x32eqpa5tpz6ygz3yzcsa3w889drpynzd5hlywuk9dwgafuax7upfnnxch4znrx4vactasg32u7vl886pxfswskk7wjgqkggrewtnaecfusl3nmg0xxh8e5nl5eft0xqwj5rk9ajn2j6yxehhgg5k6kpw5pk382hxmpu8wcd3rqx6m8905c7f8v0x2z9gyduxtqp2fdy9w2z4g2rrgu4d5cqes5tnwag007h2f326s3j9p6970xyd73rv5awx27zzezrax467c6vuye62ha88pzhw9xytjpezw3vg3l6x4k9rxwm9f6lwj6j7r9makg59rhgt0355s3jx9vw3raz5qdk5cnpny56q5h3dyd75suskuz63wmmfp9333tx4ry38vnh7jxy0j0cka99y7v2dmyjldjy0mqjetcefsmmg3pw0h9ll0rtskpmxz2cs8vmx6lu4v5rjtzpzgaqt46cwvnmhy5e3ye0tv6sre342lwg0tfd0yjkfz2haqc3hprpqf5ce8c9lx076dnk5cukn3tu38jgprrxt9sk9nqzt8grvkc4qzlg8jjf638v07ej9cf4eu48kphrgyjy83qm822xq8v9k22zwa7txr439x74j2f9ay7frkwtfdwn02enyx8stjpr4wgg25n5tj7uxyx0dqt3hnrqf7dcgt9fqnkjgemjfzyck6pawewptcayxm49sq3477f4cvm2p8fmg27rcmwm5l2vmzvdmymvkrr3aywu04y3zlhs7awu3r9u9lseheqpffvm2xf8y6w2qaj9g22pcu0sdsnqseusrxq9awru5lyqxnzftlslgpe3njegxkze98rveswp3u0xgx69wkg38vgp4k55lwwgv5l7ccv503mh9q23nud4xtm499xq276u40rxyrzaqfyuz2f3v5qr2vq00xs4g3ruynmq0c6nprvjcwqc5zrdn4cnzeeuwumjvqfjdpxr739n4cqcwytc8pjmfcj2qs3l2a8p5fzq7nvtng2jvvklx0x5ypnlkeuegsm587f6d4dkwel2es0lwwt88y2jdg8enjyph02tnjyndlx34h52fngjk96ggtu6vcxqkreqcxs4gwa8ww40t0kzlxfzyfhtjmfdm5fcrr5pt2xm4yslfynr0h9wkranhyadxf33kjn9xpg2fq5hnlq0 +AGE-SECRET-KEY-PQ-16XDSLZ3XCSZ3236YJ5J0T9NAPLZTP96LJKQCVHJYUDTQVJR5J5PQTDPQCX diff --git a/cmd/age/testdata/keygen.txt b/cmd/age/testdata/keygen.txt new file mode 100644 index 00000000..a34db827 --- /dev/null +++ b/cmd/age/testdata/keygen.txt @@ -0,0 +1,25 @@ +exec age-keygen +stdout '# created: 20' +stdout '# public key: age1' +stdout 'AGE-SECRET-KEY-1' +stderr 'Public key: age1' + +exec age-keygen -pq +stdout '# created: 20' +stdout '# public key: age1pq1' +stdout 'AGE-SECRET-KEY-PQ-1' +stderr 'Public key: age1pq1' + +exec age-keygen -pq -o key.txt +! stdout . +stderr 'Public key: age1pq1' +grep '# created: 20' key.txt +grep '# public key: age1pq1' key.txt +grep 'AGE-SECRET-KEY-PQ-1' key.txt + +stdin key.txt +exec age-keygen -y +stdout age1pq1 + +exec age-keygen -y key.txt +stdout age1pq1 diff --git a/cmd/age/tui.go b/cmd/age/tui.go index 3d26d918..ac364f0b 100644 --- a/cmd/age/tui.go +++ b/cmd/age/tui.go @@ -37,7 +37,7 @@ func printf(format string, v ...interface{}) { func errorf(format string, v ...interface{}) { l.Printf("age: error: "+format, v...) l.Printf("age: report unexpected or unhelpful errors at https://filippo.io/age/report") - exit(1) + os.Exit(1) } func warningf(format string, v ...interface{}) { @@ -50,21 +50,7 @@ func errorWithHint(error string, hints ...string) { l.Printf("age: hint: %s", hint) } l.Printf("age: report unexpected or unhelpful errors at https://filippo.io/age/report") - exit(1) -} - -// If testOnlyPanicInsteadOfExit is true, exit will set testOnlyDidExit and -// panic instead of calling os.Exit. This way, the wrapper in TestMain can -// recover the panic and return the exit code only if it was originated in exit. -var testOnlyPanicInsteadOfExit bool -var testOnlyDidExit bool - -func exit(code int) { - if testOnlyPanicInsteadOfExit { - testOnlyDidExit = true - panic(code) - } - os.Exit(code) + os.Exit(1) } // clearLine clears the current line on the terminal, or opens a new line if diff --git a/doc/age-keygen.1.ronn b/doc/age-keygen.1.ronn index fef248c8..6bef7b65 100644 --- a/doc/age-keygen.1.ronn +++ b/doc/age-keygen.1.ronn @@ -3,7 +3,7 @@ age-keygen(1) -- generate age(1) key pairs ## SYNOPSIS -`age-keygen` [`-o` ]
+`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 1d71a4b4..ef76785f 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. @@ -223,6 +237,20 @@ instruct the user to perform encryption with the `-e`/`--encrypt` and doesn't make sense (such as a password-encryption plugin) may instruct the user to use the `-j` flag. +#### Tagged recipients + +`age` can natively encrypt to recipients starting with `age1tag1` (using P-256 +ECDH) or `age1tagpq1` (using the ML-KEM-768 + P-256 post-quantum hybrid). These +are intended to be the public side of private keys held in hardware. + +They are directly supported to avoid the need to install the plugin, which may +be platform-specific, on the encrypting side. + +The tag reduces privacy, by allowing an observer to correlate files with a +recipient (but not files amongst them without knowledge of the recipient), +but this is also a desirable property for hardware keys that require user +interaction for each decryption operation. + ## EXIT STATUS `age` will exit 0 if and only if encryption or decryption are successful for the @@ -243,27 +271,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/go.mod b/go.mod index 31df01fe..20b6eca4 100644 --- a/go.mod +++ b/go.mod @@ -1,17 +1,19 @@ module filippo.io/age -go 1.19 +go 1.24.0 require ( filippo.io/edwards25519 v1.1.0 - golang.org/x/crypto v0.24.0 - golang.org/x/sys v0.21.0 - golang.org/x/term v0.21.0 + filippo.io/hpke v0.4.0 + filippo.io/nistec v0.0.4 + golang.org/x/crypto v0.45.0 + golang.org/x/sys v0.38.0 + golang.org/x/term v0.37.0 ) // Test dependencies. require ( - c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 - github.com/rogpeppe/go-internal v1.12.0 - golang.org/x/tools v0.22.0 // indirect + c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd + github.com/rogpeppe/go-internal v1.14.1 + golang.org/x/tools v0.39.0 // indirect ) diff --git a/go.sum b/go.sum index fd0f776d..baad7f46 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,18 @@ -c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0= -c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w= +c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M= +c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= -golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= -golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= -golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= -golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= -golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= -golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= -golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= +filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A= +filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY= +filippo.io/nistec v0.0.4 h1:F14ZHT5htWlMnQVPndX9ro9arf56cBhQxq4LnDI491s= +filippo.io/nistec v0.0.4/go.mod h1:PK/lw8I1gQT4hUML4QGaqljwdDaFcMyFKSXN7kjrtKI= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= diff --git a/parse.go b/parse.go index 373d1a89..73615654 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/client.go b/plugin/client.go index 28ccbf1f..cd3a8526 100644 --- a/plugin/client.go +++ b/plugin/client.go @@ -8,9 +8,10 @@ package plugin import ( "bufio" + "crypto/rand" "fmt" "io" - "math/rand" + mathrand "math/rand/v2" "os" "path/filepath" "strconv" @@ -468,15 +469,15 @@ func writeStanzaWithBody(conn io.Writer, t string, body []byte) error { } func writeGrease(conn io.Writer) (sent bool, err error) { - if rand.Intn(3) == 0 { + if mathrand.IntN(3) == 0 { return false, nil } - s := &format.Stanza{Type: fmt.Sprintf("grease-%x", rand.Int())} - for i := 0; i < rand.Intn(3); i++ { - s.Args = append(s.Args, fmt.Sprintf("%d", rand.Intn(100))) + s := &format.Stanza{Type: fmt.Sprintf("grease-%x", mathrand.Int())} + for i := 0; i < mathrand.IntN(3); i++ { + s.Args = append(s.Args, fmt.Sprintf("%d", mathrand.IntN(100))) } - if rand.Intn(2) == 0 { - s.Body = make([]byte, rand.Intn(100)) + if mathrand.IntN(2) == 0 { + s.Body = make([]byte, mathrand.IntN(100)) rand.Read(s.Body) } return true, s.Marshal(conn) diff --git a/plugin/encode.go b/plugin/encode.go index 0a59fbe0..628d6f22 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 6b171660..00000000 --- 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 00000000..46a5b067 --- /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 52ceb580..b8372483 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 73d13b7f..0ed28592 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/internal/age-plugin-tagtest/plugin-tagtest.go b/tag/internal/age-plugin-tagtest/plugin-tagtest.go new file mode 100644 index 00000000..82e4c7c8 --- /dev/null +++ b/tag/internal/age-plugin-tagtest/plugin-tagtest.go @@ -0,0 +1,61 @@ +// Command age-plugin-tagtest is a that decrypts files encrypted to fixed +// age1tag1... or age1tagpq1... recipients for testing purposes. +// +// It can be used with the "-j" flag: +// +// go install ./tag/internal/age-plugin-tagtest +// age -d -j tagtest file.age +package main + +import ( + "errors" + "fmt" + "log" + "os" + + "filippo.io/age" + "filippo.io/age/plugin" + "filippo.io/age/tag/internal/tagtest" +) + +const classicRecipient = "age1tag1qwe0kafsjrar4txm6heqnhpfuggzr0gvznz7fvygxrlq90u5mq2pysxtw6h" + +const hybridRecipient = "age1tagpq14h4z7cks9sxftfc8tq4xektt4854ur9rv76tvujdvtzk2fmyywkvh9z2emz3x4epvhz7qdt2v7uksyyq2cdzf3k04ny0g5sc3u4heqh3r9v4cnwhfjw0a2azpgmnk9xk02wvywt5szcq6q3jvwsjxvkn3tsk52vqjczdcvc398ym4j6cvqas4w99gkgt7ur3fmt4g873phr23tgxw3f7wgsz9zxz7m8cp27vpq3h5vc8nssjemtr2etmtmqkg4fzn2u9x9zvtysuya5yrytgx482ftx9864h8a6pprarxd3d0qe8nw2at5ekg3tsahtef7kawasxjamyckw2ans6v933vuypcfrra32f89r2v72mka9hhc55s49xe2khfsq7w9r2zynuzfx4fg6v7jjncsc87rw2yy8qp8hr27edus6zw5xd6m3hax2nxhl2dys9792z3wp5c034sfkrxe86guj7pfdh7sytzrufl9euhuhyf9w6c7z2nwf7v5v8f2s4gplgvfx4jj4le22k2qn242qkqkcwx7llfyrct7jm2wcv0ytypeh6h93ezgtd7q6zr428qze3dec5jxlc5xxjetyephp42fljft3s02p0570kjwyfeyjcnks2vglkvyus5g9l4z6m0gf8wu22ygfm40028txwjlxvvgnn7c36z783c6tmc9k5nef8nucj6u3ustff5vhtnzhnscsvsz79wzrkv3sujtntx4wezucy6lp49flmnyydn3khk2xsesw0ekn4u44nzqw2g2rjyrrl7crshlzttgpe0jvqycjzp9kmtz23t3yu0w9j4n344nnrf88k2jqqfpjxte38pcn0epr879pqsuvajxrkmsas89pvfrzwcewneujn08guj5pvvrtn5hzzg2y6u4wwjqqxx4x8w65yc4dchf750dft8kgcttt2f6j0j5v8s7tkaua78tte7artdfar544vl0rau79h95mc4ghp887z82s6rq93txpkvan86n963kagqkldngnkjcn28zdrh38vdxj002zqs9mx7zjvg3ynzdfhfakkynt9fyqpaxpsdrsqrycuhw5ykwgjz6wldef7xtu6p689234hstxe7v8e5422f2dy8ystn57z3fvy9yfrm4t3lye6ejk5n6x8zqexmql7lx965xcxuuy38xzyt8j9qprnwgfqgx54l4tnjdpzdde6xgmwtnpkfwvyr7rkgnavvjn6a3e56wtvjx3evmhjjxvukpq5zqrj0s4sntkz3yeszs5dty8q0q6m7dgp6mjpvaer0c4343g72eycfqzkjupeaemh0n8e935hqs8fh3jgk7fzyxctzuqlx6d2q9jaf8r9wu4sjxj5w6ppw7m9c3hxrzpcv2uek3kxnndgf2hd99q9v2ux8pjkv29ntslvnvhy09dvcy9578rt89gf4cj4cu79zjxtlj3dpct6rjme02zj3qspsade96njkkufu9zuq2lk3qwvddpxjkqm2hnpqwck54zug7ctvkgvk325lwkg4q5rf73zkgys5e9y8jqc96ntdyl4r78lgtw4k5uljk5ttf46s3gc0rq0jwmddnxt875twwq92505zh3zkse5ag2dhjjxyfzkn7xv3j0kv9r3jzpvgep8fq6z8mar509u4fvnhvthp2ah0r45lsyq0mm6fwkcs30v8k9wzvgt6uvcty6qsjvarjs3htym69zu43m4jd3k4tllrr8c05v6p6spuhup4hkk2p9fp9lxafe3pntcn4nk83gzhjjpcjwyg7jcyz5uancu0fakgz27up7ymzp2xv3sqyqewkkqynskw9qkvysrncxj0cy7dt6q8dsseuwmc2urfmcvkykf82wfa54t85hqx8gywhmhzunm2x0d66a4pwl0xl78fhkces5dpq8pfnp35m5a3u8vdam64zx5s5x9cmnrx3zr066f4f8hlecqnq2fd5quw79ljg3q5nvs6ggmm4gkc" + +func init() { + c := tagtest.NewClassicIdentity("age-plugin-tagtest").Recipient().String() + if c != classicRecipient { + log.Fatalf("unexpected classic recipient: %s", c) + } + h := tagtest.NewHybridIdentity("age-plugin-tagtest").Recipient().String() + if h != hybridRecipient { + log.Fatalf("unexpected hybrid recipient: %s", h) + } +} + +func main() { + p, err := plugin.New("tagtest") + if err != nil { + log.Fatal(err) + } + p.HandleIdentity(func(b []byte) (age.Identity, error) { + if len(b) != 0 { + return nil, fmt.Errorf("unexpected identity data") + } + return &tagtestIdentity{}, nil + }) + os.Exit(p.Main()) +} + +type tagtestIdentity struct{} + +func (i *tagtestIdentity) Unwrap(ss []*age.Stanza) ([]byte, error) { + classic := tagtest.NewClassicIdentity("age-plugin-tagtest") + if key, err := classic.Unwrap(ss); err == nil { + return key, nil + } else if !errors.Is(err, age.ErrIncorrectIdentity) { + return nil, err + } + hybrid := tagtest.NewHybridIdentity("age-plugin-tagtest") + return hybrid.Unwrap(ss) +} diff --git a/tag/internal/tagtest/tagtest.go b/tag/internal/tagtest/tagtest.go new file mode 100644 index 00000000..ed9d11be --- /dev/null +++ b/tag/internal/tagtest/tagtest.go @@ -0,0 +1,152 @@ +// 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 tagtest + +import ( + "crypto/ecdh" + "crypto/subtle" + "fmt" + + "filippo.io/age" + "filippo.io/age/internal/format" + "filippo.io/age/tag" + "filippo.io/hpke" + "filippo.io/nistec" +) + +type ClassicIdentity struct { + k hpke.PrivateKey +} + +var _ age.Identity = &ClassicIdentity{} + +func NewClassicIdentity(seed string) *ClassicIdentity { + k, err := hpke.DHKEM(ecdh.P256()).DeriveKeyPair([]byte(seed)) + if err != nil { + panic(fmt.Sprintf("failed to generate key: %v", err)) + } + return &ClassicIdentity{k: k} +} + +func (i *ClassicIdentity) Recipient() *tag.Recipient { + uncompressed := i.k.PublicKey().Bytes() + p, err := nistec.NewP256Point().SetBytes(uncompressed) + if err != nil { + panic(fmt.Sprintf("failed to parse public key: %v", err)) + } + r, err := tag.NewClassicRecipient(p.BytesCompressed()) + if err != nil { + panic(fmt.Sprintf("failed to create recipient: %v", err)) + } + return r +} + +func (i *ClassicIdentity) Unwrap(ss []*age.Stanza) ([]byte, error) { + for _, s := range ss { + if s.Type != "p256tag" { + continue + } + if len(s.Args) != 2 { + return nil, fmt.Errorf("malformed stanza") + } + tagArg, err := format.DecodeString(s.Args[0]) + if err != nil { + return nil, fmt.Errorf("malformed tag: %v", err) + } + if len(tagArg) != 4 { + return nil, fmt.Errorf("invalid tag length: %d", len(tagArg)) + } + enc, err := format.DecodeString(s.Args[1]) + if err != nil { + return nil, fmt.Errorf("malformed encapsulated key: %v", err) + } + if len(enc) != 65 { + return nil, fmt.Errorf("invalid encapsulated key length: %d", len(enc)) + } + if len(s.Body) != 32 { + return nil, fmt.Errorf("invalid encrypted file key length: %d", len(s.Body)) + } + + expTag, err := i.Recipient().Tag(enc) + if err != nil { + return nil, fmt.Errorf("failed to compute tag: %v", err) + } + if subtle.ConstantTimeCompare(tagArg, expTag[:4]) != 1 { + return nil, age.ErrIncorrectIdentity + } + + r, err := hpke.NewRecipient(enc, i.k, hpke.HKDFSHA256(), hpke.ChaCha20Poly1305(), []byte("age-encryption.org/p256tag")) + if err != nil { + return nil, fmt.Errorf("failed to unwrap file key: %v", err) + } + return r.Open(nil, s.Body) + } + return nil, age.ErrIncorrectIdentity +} + +type HybridIdentity struct { + k hpke.PrivateKey +} + +var _ age.Identity = &HybridIdentity{} + +func NewHybridIdentity(seed string) *HybridIdentity { + k, err := hpke.MLKEM768P256().DeriveKeyPair([]byte(seed)) + if err != nil { + panic(fmt.Sprintf("failed to generate key: %v", err)) + } + return &HybridIdentity{k: k} +} + +func (i *HybridIdentity) Recipient() *tag.Recipient { + r, err := tag.NewHybridRecipient(i.k.PublicKey().Bytes()) + if err != nil { + panic(fmt.Sprintf("failed to create recipient: %v", err)) + } + return r +} + +func (i *HybridIdentity) Unwrap(ss []*age.Stanza) ([]byte, error) { + for _, s := range ss { + if s.Type != "mlkem768p256tag" { + continue + } + if len(s.Args) != 2 { + return nil, fmt.Errorf("malformed stanza") + } + tagArg, err := format.DecodeString(s.Args[0]) + if err != nil { + return nil, fmt.Errorf("malformed tag: %v", err) + } + if len(tagArg) != 4 { + return nil, fmt.Errorf("invalid tag length: %d", len(tagArg)) + } + enc, err := format.DecodeString(s.Args[1]) + if err != nil { + return nil, fmt.Errorf("malformed encapsulated key: %v", err) + } + if len(enc) != 1153 { + return nil, fmt.Errorf("invalid encapsulated key length: %d", len(enc)) + } + if len(s.Body) != 32 { + return nil, fmt.Errorf("invalid encrypted file key length: %d", len(s.Body)) + } + + expTag, err := i.Recipient().Tag(enc) + if err != nil { + return nil, fmt.Errorf("failed to compute tag: %v", err) + } + if subtle.ConstantTimeCompare(tagArg, expTag[:4]) != 1 { + return nil, age.ErrIncorrectIdentity + } + + r, err := hpke.NewRecipient(enc, i.k, hpke.HKDFSHA256(), hpke.ChaCha20Poly1305(), []byte("age-encryption.org/mlkem768p256tag")) + if err != nil { + return nil, fmt.Errorf("failed to unwrap file key: %v", err) + } + return r.Open(nil, s.Body) + } + return nil, age.ErrIncorrectIdentity +} diff --git a/tag/tag.go b/tag/tag.go new file mode 100644 index 00000000..3b307091 --- /dev/null +++ b/tag/tag.go @@ -0,0 +1,189 @@ +// 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 tag implements tagged P-256 or hybrid P-256 + ML-KEM-768 recipients, +// which can be used with identities stored on hardware keys, usually supported +// by dedicated plugins. +// +// The tag reduces privacy, by allowing an observer to correlate files with a +// recipient (but not files amongst them without knowledge of the recipient), +// but this is also a desirable property for hardware keys that require user +// interaction for each decryption operation. +package tag + +import ( + "crypto/ecdh" + "crypto/hkdf" + "crypto/mlkem" + "crypto/sha256" + "fmt" + "slices" + + "filippo.io/age" + "filippo.io/age/internal/format" + "filippo.io/age/plugin" + "filippo.io/hpke" + "filippo.io/nistec" +) + +// Recipient is a tagged P-256 or hybrid P-256 + ML-KEM-768 recipient. +// +// The latter recipient is safe against future cryptographically-relevant +// quantum computers, and can only be used along with other post-quantum +// recipients. +type Recipient struct { + pk hpke.PublicKey +} + +var _ age.Recipient = &Recipient{} + +// ParseRecipient returns a new [Recipient] from a Bech32 public key +// encoding with the "age1tag1" or "age1tagpq1" prefix. +func ParseRecipient(s string) (*Recipient, error) { + t, k, err := plugin.ParseRecipient(s) + if err != nil { + return nil, fmt.Errorf("malformed recipient %q: %v", s, err) + } + switch t { + case "tag": + r, err := NewClassicRecipient(k) + if err != nil { + return nil, fmt.Errorf("malformed recipient %q: %v", s, err) + } + return r, nil + case "tagpq": + r, err := NewHybridRecipient(k) + if err != nil { + return nil, fmt.Errorf("malformed recipient %q: %v", s, err) + } + return r, nil + default: + return nil, fmt.Errorf("malformed recipient %q: invalid type %q", s, t) + } +} + +const compressedPointSize = 1 + 32 +const uncompressedPointSize = 1 + 32 + 32 + +// NewClassicRecipient returns a new P-256 [Recipient] from a raw public key. +func NewClassicRecipient(publicKey []byte) (*Recipient, error) { + if len(publicKey) != compressedPointSize { + return nil, fmt.Errorf("invalid tag recipient public key size %d", len(publicKey)) + } + p, err := nistec.NewP256Point().SetBytes(publicKey) + if err != nil { + return nil, fmt.Errorf("invalid tag recipient public key: %v", err) + } + k, err := hpke.DHKEM(ecdh.P256()).NewPublicKey(p.Bytes()) + if err != nil { + return nil, fmt.Errorf("invalid tag recipient public key: %v", err) + } + return &Recipient{k}, nil +} + +// NewHybridRecipient returns a new hybrid P-256 + ML-KEM-768 [Recipient] from +// raw concatenated public keys. +func NewHybridRecipient(publicKey []byte) (*Recipient, error) { + k, err := hpke.MLKEM768P256().NewPublicKey(publicKey) + if err != nil { + return nil, fmt.Errorf("invalid tagpq recipient public key: %v", err) + } + return &Recipient{k}, nil +} + +// Hybrid reports whether r is a hybrid P-256 + ML-KEM-768 recipient. +func (r *Recipient) Hybrid() bool { + return r.pk.KEM().ID() == hpke.MLKEM768P256().ID() +} + +func (r *Recipient) Wrap(fileKey []byte) ([]*age.Stanza, error) { + s, _, err := r.WrapWithLabels(fileKey) + return s, err +} + +// Tag computes the 4-byte tag for the given ciphertext enc. +// +// This is a low-level method exposed for use by plugins that implement +// identities compatible with tagged recipients. +func (r *Recipient) Tag(enc []byte) ([]byte, error) { + label, tagRecipient := "age-encryption.org/p256tag", r.Bytes() + if r.Hybrid() { + label = "age-encryption.org/mlkem768p256tag" + // In hybrid mode, the tag is computed over just the P-256 part. + tagRecipient = tagRecipient[mlkem.EncapsulationKeySize768:] + if len(enc) != mlkem.CiphertextSize768+uncompressedPointSize { + return nil, fmt.Errorf("invalid ciphertext size") + } + } else if len(enc) != uncompressedPointSize { + return nil, fmt.Errorf("invalid ciphertext size") + } + rh := sha256.Sum256(tagRecipient) + tag, err := hkdf.Extract(sha256.New, append(slices.Clip(enc), rh[:4]...), []byte(label)) + if err != nil { + return nil, fmt.Errorf("failed to compute tag: %v", err) + } + return tag[:4], nil +} + +// WrapWithLabels implements [age.RecipientWithLabels], returning a single +// "postquantum" label if r is a hybrid P-256 + ML-KEM-768 recipient. This +// ensures a hybrid Recipient can't be mixed with other recipients that would +// defeat its post-quantum security. +// +// To unsafely bypass this restriction, wrap Recipient in an [age.Recipient] +// type that doesn't expose WrapWithLabels. +func (r *Recipient) WrapWithLabels(fileKey []byte) ([]*age.Stanza, []string, error) { + label, arg := "age-encryption.org/p256tag", "p256tag" + if r.Hybrid() { + label, arg = "age-encryption.org/mlkem768p256tag", "mlkem768p256tag" + } + + enc, s, err := hpke.NewSender(r.pk, hpke.HKDFSHA256(), hpke.ChaCha20Poly1305(), []byte(label)) + 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) + } + + tag, err := r.Tag(enc) + if err != nil { + return nil, nil, fmt.Errorf("failed to compute tag: %v", err) + } + + l := &age.Stanza{ + Type: arg, + Args: []string{ + format.EncodeToString(tag[:4]), + format.EncodeToString(enc), + }, + Body: ct, + } + + if r.Hybrid() { + return []*age.Stanza{l}, []string{"postquantum"}, nil + } + return []*age.Stanza{l}, nil, nil +} + +// Bytes returns the raw recipient encoding. +func (r *Recipient) Bytes() []byte { + if r.Hybrid() { + return r.pk.Bytes() + } + p, err := nistec.NewP256Point().SetBytes(r.pk.Bytes()) + if err != nil { + panic("internal error: invalid P-256 public key") + } + return p.BytesCompressed() +} + +// String returns the Bech32 public key encoding of r. +func (r *Recipient) String() string { + if r.Hybrid() { + return plugin.EncodeRecipient("tagpq", r.Bytes()) + } + return plugin.EncodeRecipient("tag", r.Bytes()) +} diff --git a/tag/tag_test.go b/tag/tag_test.go new file mode 100644 index 00000000..a77e4a63 --- /dev/null +++ b/tag/tag_test.go @@ -0,0 +1,140 @@ +// 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 tag_test + +import ( + "bytes" + "io" + "testing" + + "filippo.io/age" + "filippo.io/age/tag" + "filippo.io/age/tag/internal/tagtest" +) + +func TestClassicRoundTrip(t *testing.T) { + i := tagtest.NewClassicIdentity("test") + r := i.Recipient() + + if r.Hybrid() { + t.Error("classic recipient incorrectly reports as hybrid") + } + + r1, err := tag.ParseRecipient(r.String()) + if err != nil { + t.Fatal(err) + } + if r1.String() != r.String() { + t.Errorf("recipient did not round-trip through parsing: got %q, want %q", r1.String(), r.String()) + } + if r1.Hybrid() { + t.Error("parsed classic recipient incorrectly reports as hybrid") + } + + plaintext := []byte("hello world") + + encrypted := &bytes.Buffer{} + w, err := age.Encrypt(encrypted, r) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(plaintext); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + + decrypted, err := age.Decrypt(encrypted, i) + if err != nil { + t.Fatal(err) + } + out, err := io.ReadAll(decrypted) + if err != nil { + t.Fatal(err) + } + + if !bytes.Equal(plaintext, out) { + t.Errorf("invalid output: %q, expected %q", out, plaintext) + } +} + +func TestHybridRoundTrip(t *testing.T) { + i := tagtest.NewHybridIdentity("test") + r := i.Recipient() + + if !r.Hybrid() { + t.Error("hybrid recipient incorrectly reports as classic") + } + + r1, err := tag.ParseRecipient(r.String()) + if err != nil { + t.Fatal(err) + } + if r1.String() != r.String() { + t.Errorf("recipient did not round-trip through parsing: got %q, want %q", r1.String(), r.String()) + } + if !r1.Hybrid() { + t.Error("parsed hybrid recipient incorrectly reports as classic") + } + + plaintext := []byte("hello world") + + encrypted := &bytes.Buffer{} + w, err := age.Encrypt(encrypted, r) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(plaintext); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + + decrypted, err := age.Decrypt(encrypted, i) + if err != nil { + t.Fatal(err) + } + out, err := io.ReadAll(decrypted) + if err != nil { + t.Fatal(err) + } + + if !bytes.Equal(plaintext, out) { + 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("test").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("test").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/testkit_test.go b/testkit_test.go index 78ccc43d..8c29b24d 100644 --- a/testkit_test.go +++ b/testkit_test.go @@ -9,6 +9,7 @@ package age_test import ( "bytes" + "compress/zlib" "crypto/sha256" "encoding/hex" "errors" @@ -55,6 +56,7 @@ type vector struct { } func parseVector(t *testing.T, test []byte) *vector { + var z bool v := &vector{file: test} for { line, rest, ok := bytes.Cut(v.file, []byte("\n")) @@ -92,7 +94,11 @@ func parseVector(t *testing.T, test []byte) *vector { } v.fileKey = (*[16]byte)(h) case "identity": + var i age.Identity i, err := age.ParseX25519Identity(value) + if err != nil { + i, err = age.ParseHybridIdentity(value) + } if err != nil { t.Fatal(err) } @@ -105,12 +111,31 @@ func parseVector(t *testing.T, test []byte) *vector { v.identities = append(v.identities, i) case "armored": v.armored = true + case "compressed": + if value != "zlib" { + t.Fatal("invalid test file: unknown compression:", value) + } + z = true case "comment": t.Log(value) default: t.Fatal("invalid test file: unknown header key:", key) } } + if z { + r, err := zlib.NewReader(bytes.NewReader(v.file)) + if err != nil { + t.Fatal(err) + } + b, err := io.ReadAll(r) + if err != nil { + t.Fatal(err) + } + if err := r.Close(); err != nil { + t.Fatal(err) + } + v.file = b + } return v } diff --git a/x25519.go b/x25519.go index 6cd87a8d..6c0814db 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 }