Files
age/parse.go
T
Hanzo AI 769d08f5e0 feat(pq): configurable KEM selection — X-Wing + HPKE MLKEM768X25519
Both hybrid PQ KEMs are first-class; callers choose at runtime.

Auto-detect from Bech32 prefix (zero-config at decode):
  age1pq1… → HPKE MLKEM768X25519 (existing, kept for compat)
  age1xw1… → X-Wing (IETF draft-connolly-cfrg-xwing-kem-10)

Configurable at keygen:
  • GeneratePQIdentity(kem PQKemType) picks the KEM at generation time
  • Empty kem → AGE_PQ_KEM env var → default PQKemXWing
  • SupportedPQKems() enumerates options for CLIs

Real X-Wing construction (unchanged from prior commit — verified):
  SHA3-256(ssM || ssX || ctX || pkX || XWingLabel)
  XWingLabel = 0x5c2e2f2f5e5c (6 bytes)
  Seed 32 · Pub 1216 · CT 1120 · SS 32
  HPKE KEM codepoint 25722

Downgrade protection: "postquantum" label on WrapWithLabels prevents
mixing X-Wing with non-PQ recipients (same rule as HybridRecipient).

Migration path: age supports multi-recipient encryption natively, so
operators can encrypt to both age1pq1… and age1xw1… during transition:
  age -r age1pq1aaa… -r age1xw1bbb… -o file.age plaintext

Tests: 12 X-Wing tests pass including combiner spec vector, round-trip,
multi-recipient, cross-KEM (X-Wing file decrypted by X-Wing only).
2026-04-13 07:24:13 -07:00

119 lines
3.6 KiB
Go

// Copyright 2021 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 (
"bufio"
"fmt"
"io"
"strings"
"unicode/utf8"
)
// ParseIdentities parses a file with one or more private key encodings, one per
// line. Empty lines and lines starting with "#" are ignored.
//
// 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, and plugins, which involve invoking external programs.
//
// Currently, all returned values are of type *[X25519Identity],
// *[HybridIdentity], or *[XWingIdentity], 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
scanner := bufio.NewScanner(io.LimitReader(f, privateKeySizeLimit))
var n int
for scanner.Scan() {
n++
line := scanner.Text()
if strings.HasPrefix(line, "#") || line == "" {
continue
}
if !utf8.ValidString(line) {
return nil, fmt.Errorf("identities file is not valid UTF-8")
}
i, err := parseIdentity(line)
if err != nil {
return nil, fmt.Errorf("error at line %d: %v", n, err)
}
ids = append(ids, i)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("failed to read identities file: %v", err)
}
if len(ids) == 0 {
return nil, fmt.Errorf("no identities found")
}
return ids, nil
}
func parseIdentity(arg string) (Identity, error) {
switch {
case strings.HasPrefix(arg, "AGE-SECRET-KEY-XW-1"):
return ParseXWingIdentity(arg)
case strings.HasPrefix(arg, "AGE-SECRET-KEY-PQ-1"):
return ParseHybridIdentity(arg)
case strings.HasPrefix(arg, "AGE-SECRET-KEY-1"):
return ParseX25519Identity(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, tagged recipients, which have different privacy
// properties, and plugins, which involve invoking external programs.
//
// Currently, all returned values are of type *[X25519Recipient],
// *[HybridRecipient], or *[XWingRecipient], 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
scanner := bufio.NewScanner(io.LimitReader(f, recipientFileSizeLimit))
var n int
for scanner.Scan() {
n++
line := scanner.Text()
if strings.HasPrefix(line, "#") || line == "" {
continue
}
if !utf8.ValidString(line) {
return nil, fmt.Errorf("recipients file is not valid UTF-8")
}
r, err := parseRecipient(line)
if err != nil {
return nil, fmt.Errorf("error at line %d: %v", n, err)
}
recs = append(recs, r)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("failed to read recipients file: %v", err)
}
if len(recs) == 0 {
return nil, fmt.Errorf("no recipients found")
}
return recs, nil
}
func parseRecipient(arg string) (Recipient, error) {
switch {
case strings.HasPrefix(arg, "age1xw1"):
return ParseXWingRecipient(arg)
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)
}
}