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:
Hanzo AI
2026-04-13 07:24:13 -07:00
parent f994a81ab9
commit 769d08f5e0
5 changed files with 527 additions and 29 deletions
+91 -21
View File
@@ -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
+2 -2
View File
@@ -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 {
+12 -6
View File
@@ -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"):
+85
View File
@@ -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}
}
+337
View File
@@ -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
}