mirror of
https://github.com/luxfi/age.git
synced 2026-07-27 03:39:38 +00:00
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).
This commit is contained in:
+91
-21
@@ -1,38 +1,107 @@
|
||||
# Post-Quantum Status
|
||||
# Post-Quantum Roadmap
|
||||
|
||||
## Shipped: ML-KEM-768 + X25519 Hybrid (v1.3.0+)
|
||||
## Two KEMs, Both First-Class
|
||||
|
||||
Native post-quantum support via `HybridRecipient` / `HybridIdentity` in `pq.go`.
|
||||
luxfi/age supports two hybrid post-quantum KEMs. Callers choose at runtime.
|
||||
|
||||
- **Algorithm**: ML-KEM-768 + X25519 (NIST FIPS 203 + Curve25519)
|
||||
- **HPKE suite**: `MLKEM768X25519` via `filippo.io/hpke`
|
||||
### 1. HPKE ML-KEM-768 + X25519 (shipped v1.3.0+)
|
||||
|
||||
- **Type**: `HybridRecipient` / `HybridIdentity` in `pq.go`
|
||||
- **Algorithm**: ML-KEM-768 + X25519 via `filippo.io/hpke` MLKEM768X25519 suite
|
||||
- **Stanza type**: `mlkem768x25519`
|
||||
- **Key prefix**: `age1pq` (Bech32)
|
||||
- **Label**: `"age-encryption.org/mlkem768x25519"` — enforces PQ-only mixing
|
||||
- **Plugin**: `age-plugin-pq` for backward compat with older age implementations
|
||||
- **Key prefix**: `age1pq1` (Bech32)
|
||||
- **Label**: `"age-encryption.org/mlkem768x25519"`
|
||||
- **When to use**: compatibility with existing keys, audited via FiloSottile age v1.3.0+
|
||||
|
||||
## Usage
|
||||
### 2. X-Wing (IETF draft-connolly-cfrg-xwing-kem-10)
|
||||
|
||||
- **Type**: `XWingRecipient` / `XWingIdentity` in `xwing.go`
|
||||
- **Algorithm**: ML-KEM-768 + X25519 with SHA3-256 combiner + 6-byte XWingLabel
|
||||
- **Stanza type**: `xwing`
|
||||
- **Key prefix**: `age1xw1` (Bech32)
|
||||
- **Label**: `"age-encryption.org/xwing"`
|
||||
- **When to use**: smaller code path, official IETF draft, simpler combiner, planned for FIPS hybrid mode
|
||||
|
||||
## Configurable KEM Selection (REQUIRED)
|
||||
|
||||
### A. Auto-detect from key prefix
|
||||
|
||||
```go
|
||||
// Zero-config: prefix determines KEM
|
||||
r, _ := age.ParseRecipient("age1pq1...") // -> HybridRecipient (HPKE MLKEM768X25519)
|
||||
r, _ := age.ParseRecipient("age1xw1...") // -> XWingRecipient (real X-Wing)
|
||||
```
|
||||
|
||||
### B. Env var for default keygen
|
||||
|
||||
```
|
||||
ENCRYPTION_KEM=hpke-mlkem768x25519|xwing # default: xwing for new keys
|
||||
```
|
||||
|
||||
CLI flags:
|
||||
```bash
|
||||
age-keygen --pq # uses ENCRYPTION_KEM or default (xwing)
|
||||
age-keygen --pq=hpke # force HPKE MLKEM768X25519
|
||||
age-keygen --pq=xwing # force X-Wing
|
||||
```
|
||||
|
||||
### C. Programmatic API
|
||||
|
||||
```go
|
||||
import "github.com/luxfi/age"
|
||||
|
||||
// Generate PQ keypair
|
||||
type PQKemType string
|
||||
const (
|
||||
PQKemHPKEMLKEM768X25519 PQKemType = "hpke-mlkem768x25519"
|
||||
PQKemXWing PQKemType = "xwing"
|
||||
)
|
||||
|
||||
// New: select KEM at keygen
|
||||
identity, _ := age.GeneratePQIdentity(age.PQKemXWing)
|
||||
|
||||
// Existing: unchanged, backward compat (HPKE MLKEM768X25519)
|
||||
identity, _ := age.GenerateHybridIdentity()
|
||||
recipient := identity.Recipient()
|
||||
|
||||
// Encrypt (PQ-safe)
|
||||
w, _ := age.Encrypt(out, recipient)
|
||||
w.Write(plaintext)
|
||||
w.Close()
|
||||
|
||||
// Decrypt
|
||||
r, _ := age.Decrypt(ciphertext, identity)
|
||||
io.ReadAll(r)
|
||||
```
|
||||
|
||||
### D. Replicate sidecar config
|
||||
|
||||
```
|
||||
REPLICATE_AGE_RECIPIENT=age1xw1... # auto-detects from prefix
|
||||
REPLICATE_KEM_PREFERENCE=xwing|hpke # optional, for new key generation
|
||||
REPLICATE_AGE_RECIPIENTS_FALLBACK=age1pq1... # comma-separated, multi-recipient migration
|
||||
```
|
||||
|
||||
### E. Operator CRD
|
||||
|
||||
```yaml
|
||||
apiVersion: lux.cloud/v1
|
||||
kind: LiquidReplicate
|
||||
spec:
|
||||
encryption:
|
||||
kem: xwing # enum: hpke-mlkem768x25519 | xwing
|
||||
recipients: # multiple supported
|
||||
- age1xw1...
|
||||
- age1pq1... # encrypt to both during migration
|
||||
```
|
||||
|
||||
## Migration Path
|
||||
|
||||
Encrypt to BOTH recipient types simultaneously during transition:
|
||||
|
||||
```bash
|
||||
age -r age1pq1aaa... -r age1xw1bbb... -o file.age plaintext
|
||||
```
|
||||
|
||||
Decryption tries each available identity until one works. No special code needed.
|
||||
|
||||
1. Generate new X-Wing keys alongside existing HPKE keys
|
||||
2. Encrypt to both recipients (multi-recipient)
|
||||
3. Once all consumers have X-Wing identities, drop HPKE recipients
|
||||
4. Old archives remain decryptable as long as HPKE identity is retained
|
||||
|
||||
## Security
|
||||
|
||||
- **Harvest-now-decrypt-later safe**: ML-KEM-768 protects against future quantum computers
|
||||
- **Harvest-now-decrypt-later safe**: both KEMs use ML-KEM-768 against future quantum computers
|
||||
- **Hybrid**: if ML-KEM is broken, X25519 still provides classical security
|
||||
- **Anonymous**: attacker can't tell which recipient a message is encrypted to
|
||||
- **Label enforcement**: PQ recipients can only be mixed with other PQ recipients (prevents downgrade)
|
||||
@@ -41,4 +110,5 @@ io.ReadAll(r)
|
||||
|
||||
- LP-102: Encrypted SQLite Replication Standard
|
||||
- NIST FIPS 203 (ML-KEM) — finalized August 2024
|
||||
- IETF draft-connolly-cfrg-xwing-kem-10 — X-Wing hybrid KEM
|
||||
- `filippo.io/hpke` — HPKE with ML-KEM-768 + X25519
|
||||
|
||||
@@ -324,11 +324,11 @@ func decryptHdr(hdr *format.Header, identities ...Identity) ([]byte, error) {
|
||||
slices.SortStableFunc(identities, func(a, b Identity) int {
|
||||
var aIsNative, bIsNative bool
|
||||
switch a.(type) {
|
||||
case *X25519Identity, *HybridIdentity, *ScryptIdentity:
|
||||
case *X25519Identity, *HybridIdentity, *XWingIdentity, *ScryptIdentity:
|
||||
aIsNative = true
|
||||
}
|
||||
switch b.(type) {
|
||||
case *X25519Identity, *HybridIdentity, *ScryptIdentity:
|
||||
case *X25519Identity, *HybridIdentity, *XWingIdentity, *ScryptIdentity:
|
||||
bIsNative = true
|
||||
}
|
||||
if aIsNative && !bIsNative {
|
||||
|
||||
@@ -19,8 +19,9 @@ import (
|
||||
// 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] or
|
||||
// *[HybridIdentity], but different types might be returned in the future.
|
||||
// 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
|
||||
@@ -52,10 +53,12 @@ func ParseIdentities(f io.Reader) ([]Identity, error) {
|
||||
|
||||
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-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)
|
||||
}
|
||||
@@ -69,8 +72,9 @@ func parseIdentity(arg string) (Identity, error) {
|
||||
// average application, tagged recipients, which have different privacy
|
||||
// properties, and plugins, which involve invoking external programs.
|
||||
//
|
||||
// Currently, all returned values are of type *[X25519Recipient] or
|
||||
// *[HybridRecipient] but different types might be returned in the future.
|
||||
// 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
|
||||
@@ -102,6 +106,8 @@ func ParseRecipients(f io.Reader) ([]Recipient, error) {
|
||||
|
||||
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"):
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright 2026 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 (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// PQKemType selects between the two hybrid post-quantum KEMs that age
|
||||
// supports. Both are first-class and produce distinct recipient prefixes:
|
||||
//
|
||||
// age1pq1… → PQKemHPKEMLKEM768X25519 (HPKE RFC 9180 with MLKEM768-X25519)
|
||||
// age1xw1… → PQKemXWing (IETF draft-connolly-cfrg-xwing-kem-10)
|
||||
//
|
||||
// At parse time the selection is automatic from the Bech32 prefix, so
|
||||
// callers that already have a key string don't need to set this.
|
||||
// PQKemType is only needed at *keygen* time when callers want to choose
|
||||
// which KEM to produce.
|
||||
type PQKemType string
|
||||
|
||||
const (
|
||||
// PQKemHPKEMLKEM768X25519 is the original post-quantum hybrid introduced
|
||||
// in age v1.3.0: HPKE (RFC 9180) with MLKEM768-X25519, HKDF-SHA256,
|
||||
// ChaCha20-Poly1305. Recipient prefix: age1pq1, identity prefix:
|
||||
// AGE-SECRET-KEY-PQ-1.
|
||||
PQKemHPKEMLKEM768X25519 PQKemType = "hpke-mlkem768x25519"
|
||||
|
||||
// PQKemXWing is the X-Wing KEM per IETF draft-connolly-cfrg-xwing-kem-10.
|
||||
// Simpler combiner (SHA3-256 direct) with the 6-byte XWingLabel, smaller
|
||||
// proof surface, HPKE KEM codepoint 25722. Recipient prefix: age1xw1,
|
||||
// identity prefix: AGE-SECRET-KEY-XW-1.
|
||||
PQKemXWing PQKemType = "xwing"
|
||||
|
||||
// DefaultPQKemEnv is the environment variable consulted by
|
||||
// [GeneratePQIdentity] when callers pass an empty PQKemType.
|
||||
DefaultPQKemEnv = "AGE_PQ_KEM"
|
||||
)
|
||||
|
||||
// resolvePQKem returns the effective KEM type, consulting the env var when
|
||||
// the caller didn't specify one. Defaults to X-Wing for new keys because
|
||||
// the IETF draft is the recommended future direction (smaller combiner,
|
||||
// simpler binding analysis, assigned HPKE codepoint).
|
||||
func resolvePQKem(kem PQKemType) PQKemType {
|
||||
if kem != "" {
|
||||
return kem
|
||||
}
|
||||
if v := PQKemType(os.Getenv(DefaultPQKemEnv)); v != "" {
|
||||
return v
|
||||
}
|
||||
return PQKemXWing
|
||||
}
|
||||
|
||||
// GeneratePQIdentity generates a new post-quantum hybrid identity using the
|
||||
// selected KEM. If kem is empty, the AGE_PQ_KEM environment variable is
|
||||
// consulted; if that is also unset, X-Wing is used.
|
||||
//
|
||||
// The returned [Identity] is either an [*XWingIdentity] or a
|
||||
// [*HybridIdentity]; callers can type-assert if they need KEM-specific
|
||||
// methods, but usually the generic [Identity] interface is sufficient.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// id, err := age.GeneratePQIdentity(age.PQKemXWing)
|
||||
// if err != nil { … }
|
||||
// fmt.Println(id.(interface{ Recipient() Recipient }).Recipient())
|
||||
func GeneratePQIdentity(kem PQKemType) (Identity, error) {
|
||||
switch resolvePQKem(kem) {
|
||||
case PQKemXWing:
|
||||
return GenerateXWingIdentity()
|
||||
case PQKemHPKEMLKEM768X25519:
|
||||
return GenerateHybridIdentity()
|
||||
default:
|
||||
return nil, fmt.Errorf("age: unknown PQ KEM %q (valid: %s, %s)",
|
||||
kem, PQKemXWing, PQKemHPKEMLKEM768X25519)
|
||||
}
|
||||
}
|
||||
|
||||
// SupportedPQKems returns the list of post-quantum KEM types supported by
|
||||
// this build of age. Useful for CLIs that enumerate options.
|
||||
func SupportedPQKems() []PQKemType {
|
||||
return []PQKemType{PQKemXWing, PQKemHPKEMLKEM768X25519}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
// 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 (
|
||||
"crypto/ecdh"
|
||||
"crypto/mlkem"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/luxfi/age/internal/bech32"
|
||||
"github.com/luxfi/age/internal/format"
|
||||
"golang.org/x/crypto/chacha20poly1305"
|
||||
"golang.org/x/crypto/hkdf"
|
||||
"golang.org/x/crypto/sha3"
|
||||
|
||||
"crypto/sha256"
|
||||
"io"
|
||||
)
|
||||
|
||||
// X-Wing KEM per IETF draft-connolly-cfrg-xwing-kem-10.
|
||||
//
|
||||
// This is the REAL X-Wing: a standalone KEM that combines ML-KEM-768
|
||||
// and X25519 with a SHA3-256 combiner and a fixed 6-byte label.
|
||||
// It is NOT the same as the HybridRecipient (HPKE MLKEM768X25519).
|
||||
//
|
||||
// Sizes per the spec:
|
||||
// - Seed (private): 32 bytes
|
||||
// - Public key: 1216 bytes (1184 ML-KEM-768 + 32 X25519)
|
||||
// - Ciphertext: 1120 bytes (1088 ML-KEM-768 + 32 X25519)
|
||||
// - Shared secret: 32 bytes
|
||||
|
||||
const (
|
||||
xwingSeedSize = 32
|
||||
xwingPublicKeySize = mlkem.EncapsulationKeySize768 + 32 // 1216
|
||||
xwingCiphertextSize = mlkem.CiphertextSize768 + 32 // 1120
|
||||
xwingSharedKeySize = 32
|
||||
|
||||
xwingLabel = "age-encryption.org/v1/xwing"
|
||||
)
|
||||
|
||||
// xwingKEMLabel is the 6-byte combiner label from the spec:
|
||||
//
|
||||
// \./
|
||||
// /^\
|
||||
var xwingKEMLabel = []byte(`\./` + `/^\`)
|
||||
|
||||
// XWingRecipient is a post-quantum age public key using the X-Wing KEM
|
||||
// (IETF draft-connolly-cfrg-xwing-kem-10). Messages encrypted to this
|
||||
// recipient can be decrypted with the corresponding [XWingIdentity].
|
||||
//
|
||||
// X-Wing combines ML-KEM-768 and X25519 in a standalone KEM with a
|
||||
// SHA3-256 combiner, distinct from the HPKE-based [HybridRecipient].
|
||||
type XWingRecipient struct {
|
||||
pkM []byte // 1184-byte ML-KEM-768 encapsulation key
|
||||
pkX []byte // 32-byte X25519 public key
|
||||
}
|
||||
|
||||
var _ Recipient = &XWingRecipient{}
|
||||
|
||||
// newXWingRecipient creates an XWingRecipient from the raw 1216-byte public key.
|
||||
func newXWingRecipient(pk []byte) (*XWingRecipient, error) {
|
||||
if len(pk) != xwingPublicKeySize {
|
||||
return nil, fmt.Errorf("invalid X-Wing public key: expected %d bytes, got %d", xwingPublicKeySize, len(pk))
|
||||
}
|
||||
// Validate the ML-KEM-768 portion.
|
||||
if _, err := mlkem.NewEncapsulationKey768(pk[:mlkem.EncapsulationKeySize768]); err != nil {
|
||||
return nil, fmt.Errorf("invalid X-Wing public key: ML-KEM-768: %v", err)
|
||||
}
|
||||
r := &XWingRecipient{
|
||||
pkM: make([]byte, mlkem.EncapsulationKeySize768),
|
||||
pkX: make([]byte, 32),
|
||||
}
|
||||
copy(r.pkM, pk[:mlkem.EncapsulationKeySize768])
|
||||
copy(r.pkX, pk[mlkem.EncapsulationKeySize768:])
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// ParseXWingRecipient returns a new [XWingRecipient] from a Bech32 public key
|
||||
// encoding with the "age1xw1" prefix.
|
||||
func ParseXWingRecipient(s string) (*XWingRecipient, error) {
|
||||
t, k, err := bech32.Decode(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("malformed recipient %q: %v", s, err)
|
||||
}
|
||||
if t != "age1xw" {
|
||||
return nil, fmt.Errorf("malformed recipient %q: invalid type %q", s, t)
|
||||
}
|
||||
r, err := newXWingRecipient(k)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("malformed recipient %q: %v", s, err)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (r *XWingRecipient) Wrap(fileKey []byte) ([]*Stanza, error) {
|
||||
s, _, err := r.WrapWithLabels(fileKey)
|
||||
return s, err
|
||||
}
|
||||
|
||||
// WrapWithLabels implements [RecipientWithLabels], returning a single
|
||||
// "postquantum" label. This ensures an XWingRecipient can be mixed with
|
||||
// other post-quantum recipients (like HybridRecipient) but not with
|
||||
// classic X25519 recipients.
|
||||
func (r *XWingRecipient) WrapWithLabels(fileKey []byte) ([]*Stanza, []string, error) {
|
||||
// Encapsulate: produce shared secret and ciphertext.
|
||||
ekM, err := mlkem.NewEncapsulationKey768(r.pkM)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("xwing: invalid ML-KEM-768 key: %v", err)
|
||||
}
|
||||
ssM, ctM := ekM.Encapsulate()
|
||||
|
||||
// X25519 ephemeral key exchange.
|
||||
ekX, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("xwing: generate X25519 ephemeral: %v", err)
|
||||
}
|
||||
ctX := ekX.PublicKey().Bytes() // 32-byte ephemeral public key IS the ct_X
|
||||
|
||||
pkXKey, err := ecdh.X25519().NewPublicKey(r.pkX)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("xwing: invalid X25519 public key: %v", err)
|
||||
}
|
||||
ssX, err := ekX.ECDH(pkXKey)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("xwing: X25519 ECDH: %v", err)
|
||||
}
|
||||
|
||||
// Combiner: ss = SHA3-256(ss_M || ss_X || ct_X || pk_X || XWingLabel)
|
||||
ss := xwingCombine(ssM, ssX, ctX, r.pkX)
|
||||
|
||||
// Derive a wrapping key from the shared secret and encrypt the file key.
|
||||
wrappingKey := xwingDeriveWrappingKey(ss, ctM, ctX)
|
||||
wrappedKey, err := aeadEncrypt(wrappingKey, fileKey)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("xwing: wrap file key: %v", err)
|
||||
}
|
||||
|
||||
// Ciphertext = ct_M || ct_X (1088 + 32 = 1120 bytes).
|
||||
ct := make([]byte, 0, xwingCiphertextSize)
|
||||
ct = append(ct, ctM...)
|
||||
ct = append(ct, ctX...)
|
||||
|
||||
l := &Stanza{
|
||||
Type: "xwing",
|
||||
Args: []string{format.EncodeToString(ct)},
|
||||
Body: wrappedKey,
|
||||
}
|
||||
return []*Stanza{l}, []string{"postquantum"}, nil
|
||||
}
|
||||
|
||||
// String returns the Bech32 public key encoding of r with "age1xw" prefix.
|
||||
func (r *XWingRecipient) String() string {
|
||||
pk := make([]byte, 0, xwingPublicKeySize)
|
||||
pk = append(pk, r.pkM...)
|
||||
pk = append(pk, r.pkX...)
|
||||
s, _ := bech32.Encode("age1xw", pk)
|
||||
return s
|
||||
}
|
||||
|
||||
// XWingIdentity is a post-quantum age private key using the X-Wing KEM,
|
||||
// which can decrypt messages encrypted to the corresponding [XWingRecipient].
|
||||
type XWingIdentity struct {
|
||||
seed [xwingSeedSize]byte
|
||||
dkM *mlkem.DecapsulationKey768
|
||||
skX *ecdh.PrivateKey
|
||||
pkX []byte // cached 32-byte X25519 public key
|
||||
}
|
||||
|
||||
var _ Identity = &XWingIdentity{}
|
||||
|
||||
// newXWingIdentity expands a 32-byte seed into an XWingIdentity per the spec.
|
||||
//
|
||||
// KeyGen:
|
||||
//
|
||||
// expanded = SHAKE256(seed, 96)
|
||||
// (pk_M, sk_M) = ML-KEM-768.KeyGen_internal(expanded[0:64])
|
||||
// sk_X = expanded[64:96]
|
||||
// pk_X = X25519(sk_X, basepoint)
|
||||
func newXWingIdentity(seed []byte) (*XWingIdentity, error) {
|
||||
if len(seed) != xwingSeedSize {
|
||||
return nil, fmt.Errorf("invalid X-Wing seed: expected %d bytes, got %d", xwingSeedSize, len(seed))
|
||||
}
|
||||
|
||||
// SHAKE256(seed, 96)
|
||||
h := sha3.NewShake256()
|
||||
h.Write(seed)
|
||||
var expanded [96]byte
|
||||
h.Read(expanded[:])
|
||||
|
||||
// ML-KEM-768 deterministic keygen from d||z (64 bytes).
|
||||
dkM, err := mlkem.NewDecapsulationKey768(expanded[:64])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xwing: ML-KEM-768 keygen: %v", err)
|
||||
}
|
||||
|
||||
// X25519 from expanded[64:96].
|
||||
skX, err := ecdh.X25519().NewPrivateKey(expanded[64:96])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xwing: X25519 keygen: %v", err)
|
||||
}
|
||||
|
||||
i := &XWingIdentity{
|
||||
dkM: dkM,
|
||||
skX: skX,
|
||||
pkX: skX.PublicKey().Bytes(),
|
||||
}
|
||||
copy(i.seed[:], seed)
|
||||
return i, nil
|
||||
}
|
||||
|
||||
// GenerateXWingIdentity randomly generates a new [XWingIdentity].
|
||||
func GenerateXWingIdentity() (*XWingIdentity, error) {
|
||||
var seed [xwingSeedSize]byte
|
||||
if _, err := rand.Read(seed[:]); err != nil {
|
||||
return nil, fmt.Errorf("xwing: random seed: %v", err)
|
||||
}
|
||||
return newXWingIdentity(seed[:])
|
||||
}
|
||||
|
||||
// ParseXWingIdentity returns a new [XWingIdentity] from a Bech32 private key
|
||||
// encoding with the "AGE-SECRET-KEY-XW-1" prefix.
|
||||
func ParseXWingIdentity(s string) (*XWingIdentity, error) {
|
||||
t, k, err := bech32.Decode(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("malformed secret key: %v", err)
|
||||
}
|
||||
if t != "AGE-SECRET-KEY-XW-" {
|
||||
return nil, fmt.Errorf("malformed secret key: unknown type %q", t)
|
||||
}
|
||||
i, err := newXWingIdentity(k)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("malformed secret key: %v", err)
|
||||
}
|
||||
return i, nil
|
||||
}
|
||||
|
||||
func (i *XWingIdentity) Unwrap(stanzas []*Stanza) ([]byte, error) {
|
||||
return multiUnwrap(i.unwrap, stanzas)
|
||||
}
|
||||
|
||||
func (i *XWingIdentity) unwrap(block *Stanza) ([]byte, error) {
|
||||
if block.Type != "xwing" {
|
||||
return nil, ErrIncorrectIdentity
|
||||
}
|
||||
if len(block.Args) != 1 {
|
||||
return nil, errors.New("invalid xwing recipient block")
|
||||
}
|
||||
ct, err := format.DecodeString(block.Args[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse xwing recipient: %v", err)
|
||||
}
|
||||
if len(ct) != xwingCiphertextSize {
|
||||
return nil, fmt.Errorf("invalid xwing ciphertext: expected %d bytes, got %d", xwingCiphertextSize, len(ct))
|
||||
}
|
||||
if len(block.Body) != fileKeySize+chacha20poly1305.Overhead {
|
||||
return nil, errIncorrectCiphertextSize
|
||||
}
|
||||
|
||||
ctM := ct[:mlkem.CiphertextSize768]
|
||||
ctX := ct[mlkem.CiphertextSize768:]
|
||||
|
||||
// ML-KEM-768 decapsulate.
|
||||
ssM, err := i.dkM.Decapsulate(ctM)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xwing: ML-KEM-768 decapsulate: %v", err)
|
||||
}
|
||||
|
||||
// X25519 shared secret.
|
||||
ctXKey, err := ecdh.X25519().NewPublicKey(ctX)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xwing: invalid X25519 ephemeral: %v", err)
|
||||
}
|
||||
ssX, err := i.skX.ECDH(ctXKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xwing: X25519 ECDH: %v", err)
|
||||
}
|
||||
|
||||
// Combiner: ss = SHA3-256(ss_M || ss_X || ct_X || pk_X || XWingLabel)
|
||||
ss := xwingCombine(ssM, ssX, ctX, i.pkX)
|
||||
|
||||
// Derive wrapping key and decrypt file key.
|
||||
wrappingKey := xwingDeriveWrappingKey(ss, ctM, ctX)
|
||||
fileKey, err := aeadDecrypt(wrappingKey, fileKeySize, block.Body)
|
||||
if err == errIncorrectCiphertextSize {
|
||||
return nil, errors.New("invalid xwing recipient block: incorrect file key size")
|
||||
} else if err != nil {
|
||||
return nil, ErrIncorrectIdentity
|
||||
}
|
||||
return fileKey, nil
|
||||
}
|
||||
|
||||
// Recipient returns the public [XWingRecipient] value corresponding to i.
|
||||
func (i *XWingIdentity) Recipient() *XWingRecipient {
|
||||
return &XWingRecipient{
|
||||
pkM: i.dkM.EncapsulationKey().Bytes(),
|
||||
pkX: append([]byte(nil), i.pkX...),
|
||||
}
|
||||
}
|
||||
|
||||
// String returns the Bech32 private key encoding of i.
|
||||
func (i *XWingIdentity) String() string {
|
||||
s, _ := bech32.Encode("AGE-SECRET-KEY-XW-", i.seed[:])
|
||||
return strings.ToUpper(s)
|
||||
}
|
||||
|
||||
// xwingCombine implements the X-Wing combiner:
|
||||
//
|
||||
// ss = SHA3-256(ss_M || ss_X || ct_X || pk_X || "\.\//^\")
|
||||
func xwingCombine(ssM, ssX, ctX, pkX []byte) []byte {
|
||||
h := sha3.New256()
|
||||
h.Write(ssM)
|
||||
h.Write(ssX)
|
||||
h.Write(ctX)
|
||||
h.Write(pkX)
|
||||
h.Write(xwingKEMLabel)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
// xwingDeriveWrappingKey derives a ChaCha20-Poly1305 wrapping key from the
|
||||
// X-Wing shared secret and ciphertext components, using HKDF-SHA256 for
|
||||
// domain separation within the age file format.
|
||||
func xwingDeriveWrappingKey(ss, ctM, ctX []byte) []byte {
|
||||
salt := make([]byte, 0, len(ctM)+len(ctX))
|
||||
salt = append(salt, ctM...)
|
||||
salt = append(salt, ctX...)
|
||||
h := hkdf.New(sha256.New, ss, salt, []byte(xwingLabel))
|
||||
key := make([]byte, chacha20poly1305.KeySize)
|
||||
if _, err := io.ReadFull(h, key); err != nil {
|
||||
panic("age: internal error: failed to read from HKDF: " + err.Error())
|
||||
}
|
||||
return key
|
||||
}
|
||||
Reference in New Issue
Block a user