mirror of
https://github.com/luxfi/crypto.git
synced 2026-07-27 01:54:50 +00:00
feat: X-Wing (X25519 + ML-KEM-768) age recipient for post-quantum encryption
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package encryption
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"filippo.io/age"
|
||||
)
|
||||
|
||||
// XWingRecipient implements age.Recipient using X-Wing hybrid KEM
|
||||
// (X25519 + ML-KEM-768, NIST FIPS 203). This is the canonical
|
||||
// post-quantum recipient for all Lux encryption.
|
||||
//
|
||||
// Wraps age's native HybridRecipient which uses the same construction
|
||||
// (HPKE with MLKEM768-X25519).
|
||||
type XWingRecipient struct {
|
||||
r *age.HybridRecipient
|
||||
}
|
||||
|
||||
// XWingIdentity implements age.Identity using X-Wing hybrid KEM.
|
||||
// It can decrypt messages encrypted to the corresponding XWingRecipient.
|
||||
type XWingIdentity struct {
|
||||
i *age.HybridIdentity
|
||||
}
|
||||
|
||||
// Compile-time interface checks.
|
||||
var (
|
||||
_ age.Recipient = (*XWingRecipient)(nil)
|
||||
_ age.Identity = (*XWingIdentity)(nil)
|
||||
)
|
||||
|
||||
// GenerateXWingIdentity creates a new X-Wing keypair for age encryption.
|
||||
func GenerateXWingIdentity() (*XWingIdentity, error) {
|
||||
i, err := age.GenerateHybridIdentity()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xwing keygen: %w", err)
|
||||
}
|
||||
return &XWingIdentity{i: i}, nil
|
||||
}
|
||||
|
||||
// ParseXWingIdentity parses an X-Wing identity from its Bech32 encoding
|
||||
// (AGE-SECRET-KEY-PQ-1...).
|
||||
func ParseXWingIdentity(s string) (*XWingIdentity, error) {
|
||||
i, err := age.ParseHybridIdentity(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xwing parse identity: %w", err)
|
||||
}
|
||||
return &XWingIdentity{i: i}, nil
|
||||
}
|
||||
|
||||
// ParseXWingRecipient parses an X-Wing recipient from its Bech32 encoding
|
||||
// (age1pq1...).
|
||||
func ParseXWingRecipient(s string) (*XWingRecipient, error) {
|
||||
r, err := age.ParseHybridRecipient(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xwing parse recipient: %w", err)
|
||||
}
|
||||
return &XWingRecipient{r: r}, nil
|
||||
}
|
||||
|
||||
// Recipient returns the public XWingRecipient for this identity.
|
||||
func (id *XWingIdentity) Recipient() *XWingRecipient {
|
||||
return &XWingRecipient{r: id.i.Recipient()}
|
||||
}
|
||||
|
||||
// Unwrap implements age.Identity.
|
||||
func (id *XWingIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) {
|
||||
return id.i.Unwrap(stanzas)
|
||||
}
|
||||
|
||||
// String returns the Bech32-encoded secret key (AGE-SECRET-KEY-PQ-1...).
|
||||
func (id *XWingIdentity) String() string {
|
||||
return id.i.String()
|
||||
}
|
||||
|
||||
// Wrap implements age.Recipient.
|
||||
func (r *XWingRecipient) Wrap(fileKey []byte) ([]*age.Stanza, error) {
|
||||
return r.r.Wrap(fileKey)
|
||||
}
|
||||
|
||||
// String returns the Bech32-encoded public key (age1pq1...).
|
||||
func (r *XWingRecipient) String() string {
|
||||
return r.r.String()
|
||||
}
|
||||
|
||||
// EncryptWithXWing encrypts data to one or more X-Wing recipients.
|
||||
func EncryptWithXWing(data []byte, recipients ...*XWingRecipient) ([]byte, error) {
|
||||
if len(recipients) == 0 {
|
||||
return nil, fmt.Errorf("xwing encrypt: no recipients")
|
||||
}
|
||||
|
||||
// Convert to age.Recipient slice.
|
||||
rs := make([]age.Recipient, len(recipients))
|
||||
for i, r := range recipients {
|
||||
rs[i] = r
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
w, err := age.Encrypt(&buf, rs...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xwing encrypt: %w", err)
|
||||
}
|
||||
if _, err := w.Write(data); err != nil {
|
||||
return nil, fmt.Errorf("xwing encrypt write: %w", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, fmt.Errorf("xwing encrypt close: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// DecryptWithXWing decrypts data using one or more X-Wing identities.
|
||||
func DecryptWithXWing(data []byte, identities ...*XWingIdentity) ([]byte, error) {
|
||||
if len(identities) == 0 {
|
||||
return nil, fmt.Errorf("xwing decrypt: no identities")
|
||||
}
|
||||
|
||||
// Convert to age.Identity slice.
|
||||
ids := make([]age.Identity, len(identities))
|
||||
for i, id := range identities {
|
||||
ids[i] = id
|
||||
}
|
||||
|
||||
r, err := age.Decrypt(bytes.NewReader(data), ids...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xwing decrypt: %w", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if _, err := io.Copy(&buf, r); err != nil {
|
||||
return nil, fmt.Errorf("xwing decrypt read: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package encryption
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
)
|
||||
|
||||
func TestXWingGenerateIdentity(t *testing.T) {
|
||||
id, err := GenerateXWingIdentity()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateXWingIdentity: %v", err)
|
||||
}
|
||||
if id == nil {
|
||||
t.Fatal("identity is nil")
|
||||
}
|
||||
r := id.Recipient()
|
||||
if r == nil {
|
||||
t.Fatal("recipient is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestXWingRoundTrip(t *testing.T) {
|
||||
id, err := GenerateXWingIdentity()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateXWingIdentity: %v", err)
|
||||
}
|
||||
|
||||
plaintext := []byte("post-quantum encrypted message via X-Wing")
|
||||
|
||||
ciphertext, err := EncryptWithXWing(plaintext, id.Recipient())
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptWithXWing: %v", err)
|
||||
}
|
||||
|
||||
if bytes.Equal(plaintext, ciphertext) {
|
||||
t.Fatal("ciphertext must differ from plaintext")
|
||||
}
|
||||
|
||||
decrypted, err := DecryptWithXWing(ciphertext, id)
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptWithXWing: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(plaintext, decrypted) {
|
||||
t.Fatalf("round-trip mismatch: got %q, want %q", decrypted, plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXWingWrongIdentity(t *testing.T) {
|
||||
id1, err := GenerateXWingIdentity()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateXWingIdentity: %v", err)
|
||||
}
|
||||
id2, err := GenerateXWingIdentity()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateXWingIdentity: %v", err)
|
||||
}
|
||||
|
||||
ciphertext, err := EncryptWithXWing([]byte("secret"), id1.Recipient())
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptWithXWing: %v", err)
|
||||
}
|
||||
|
||||
_, err = DecryptWithXWing(ciphertext, id2)
|
||||
if err == nil {
|
||||
t.Fatal("expected error decrypting with wrong identity")
|
||||
}
|
||||
}
|
||||
|
||||
func TestXWingEmptyData(t *testing.T) {
|
||||
id, err := GenerateXWingIdentity()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateXWingIdentity: %v", err)
|
||||
}
|
||||
|
||||
ciphertext, err := EncryptWithXWing([]byte{}, id.Recipient())
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptWithXWing empty: %v", err)
|
||||
}
|
||||
|
||||
decrypted, err := DecryptWithXWing(ciphertext, id)
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptWithXWing empty: %v", err)
|
||||
}
|
||||
|
||||
if len(decrypted) != 0 {
|
||||
t.Fatalf("expected empty plaintext, got %d bytes", len(decrypted))
|
||||
}
|
||||
}
|
||||
|
||||
func TestXWingLargeData(t *testing.T) {
|
||||
id, err := GenerateXWingIdentity()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateXWingIdentity: %v", err)
|
||||
}
|
||||
|
||||
// 1MB payload
|
||||
plaintext := make([]byte, 1024*1024)
|
||||
for i := range plaintext {
|
||||
plaintext[i] = byte(i % 256)
|
||||
}
|
||||
|
||||
ciphertext, err := EncryptWithXWing(plaintext, id.Recipient())
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptWithXWing large: %v", err)
|
||||
}
|
||||
|
||||
decrypted, err := DecryptWithXWing(ciphertext, id)
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptWithXWing large: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(plaintext, decrypted) {
|
||||
t.Fatal("large data round-trip mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestXWingMultipleRecipients(t *testing.T) {
|
||||
id1, err := GenerateXWingIdentity()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateXWingIdentity 1: %v", err)
|
||||
}
|
||||
id2, err := GenerateXWingIdentity()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateXWingIdentity 2: %v", err)
|
||||
}
|
||||
|
||||
plaintext := []byte("multi-recipient message")
|
||||
|
||||
ciphertext, err := EncryptWithXWing(plaintext, id1.Recipient(), id2.Recipient())
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptWithXWing multi: %v", err)
|
||||
}
|
||||
|
||||
// Both identities can decrypt.
|
||||
dec1, err := DecryptWithXWing(ciphertext, id1)
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptWithXWing id1: %v", err)
|
||||
}
|
||||
if !bytes.Equal(plaintext, dec1) {
|
||||
t.Fatal("id1 decryption mismatch")
|
||||
}
|
||||
|
||||
dec2, err := DecryptWithXWing(ciphertext, id2)
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptWithXWing id2: %v", err)
|
||||
}
|
||||
if !bytes.Equal(plaintext, dec2) {
|
||||
t.Fatal("id2 decryption mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestXWingIdentityStringRoundTrip(t *testing.T) {
|
||||
id, err := GenerateXWingIdentity()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateXWingIdentity: %v", err)
|
||||
}
|
||||
|
||||
// Serialize and parse back.
|
||||
idStr := id.String()
|
||||
id2, err := ParseXWingIdentity(idStr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseXWingIdentity: %v", err)
|
||||
}
|
||||
|
||||
recStr := id.Recipient().String()
|
||||
rec2, err := ParseXWingRecipient(recStr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseXWingRecipient: %v", err)
|
||||
}
|
||||
|
||||
// Encrypt with parsed recipient, decrypt with parsed identity.
|
||||
plaintext := []byte("serialization round-trip")
|
||||
ciphertext, err := EncryptWithXWing(plaintext, rec2)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptWithXWing parsed: %v", err)
|
||||
}
|
||||
|
||||
decrypted, err := DecryptWithXWing(ciphertext, id2)
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptWithXWing parsed: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(plaintext, decrypted) {
|
||||
t.Fatal("parsed key round-trip mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestXWingCoexistWithPassword(t *testing.T) {
|
||||
// Encrypt with password, then separately with X-Wing.
|
||||
// Verifies they can coexist in the same workflow.
|
||||
id, err := GenerateXWingIdentity()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateXWingIdentity: %v", err)
|
||||
}
|
||||
|
||||
plaintext := []byte("coexistence test")
|
||||
password := "test-password-123"
|
||||
|
||||
// Password-encrypt.
|
||||
pwEnc, err := EncryptDataWithPassword(plaintext, password)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptDataWithPassword: %v", err)
|
||||
}
|
||||
pwDec, err := DecryptPrivateKeyWithPassword(pwEnc, password)
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptPrivateKeyWithPassword: %v", err)
|
||||
}
|
||||
if !bytes.Equal(plaintext, pwDec) {
|
||||
t.Fatal("password round-trip failed")
|
||||
}
|
||||
|
||||
// X-Wing encrypt.
|
||||
xwEnc, err := EncryptWithXWing(plaintext, id.Recipient())
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptWithXWing: %v", err)
|
||||
}
|
||||
xwDec, err := DecryptWithXWing(xwEnc, id)
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptWithXWing: %v", err)
|
||||
}
|
||||
if !bytes.Equal(plaintext, xwDec) {
|
||||
t.Fatal("xwing round-trip failed")
|
||||
}
|
||||
|
||||
// The ciphertexts are different (different recipients, different randomness).
|
||||
if bytes.Equal(pwEnc, xwEnc) {
|
||||
t.Fatal("password and xwing ciphertexts should differ")
|
||||
}
|
||||
}
|
||||
|
||||
func TestXWingAgeNativeInterop(t *testing.T) {
|
||||
// Encrypt with age's native HybridRecipient, decrypt with our XWingIdentity.
|
||||
id, err := GenerateXWingIdentity()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateXWingIdentity: %v", err)
|
||||
}
|
||||
|
||||
plaintext := []byte("native age interop")
|
||||
|
||||
// Encrypt using age.Encrypt directly with our recipient (which implements age.Recipient).
|
||||
var buf bytes.Buffer
|
||||
w, err := age.Encrypt(&buf, id.Recipient())
|
||||
if err != nil {
|
||||
t.Fatalf("age.Encrypt: %v", err)
|
||||
}
|
||||
if _, err := w.Write(plaintext); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
|
||||
// Decrypt with our helper.
|
||||
decrypted, err := DecryptWithXWing(buf.Bytes(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptWithXWing native: %v", err)
|
||||
}
|
||||
if !bytes.Equal(plaintext, decrypted) {
|
||||
t.Fatal("native interop mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestXWingNoRecipients(t *testing.T) {
|
||||
_, err := EncryptWithXWing([]byte("data"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error with no recipients")
|
||||
}
|
||||
}
|
||||
|
||||
func TestXWingNoIdentities(t *testing.T) {
|
||||
_, err := DecryptWithXWing([]byte("data"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error with no identities")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkXWingRoundTrip(b *testing.B) {
|
||||
id, err := GenerateXWingIdentity()
|
||||
if err != nil {
|
||||
b.Fatalf("GenerateXWingIdentity: %v", err)
|
||||
}
|
||||
r := id.Recipient()
|
||||
plaintext := []byte("benchmark payload for X-Wing age encryption")
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
ct, _ := EncryptWithXWing(plaintext, r)
|
||||
DecryptWithXWing(ct, id)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkXWingLargeData(b *testing.B) {
|
||||
id, err := GenerateXWingIdentity()
|
||||
if err != nil {
|
||||
b.Fatalf("GenerateXWingIdentity: %v", err)
|
||||
}
|
||||
r := id.Recipient()
|
||||
plaintext := make([]byte, 1024*1024) // 1MB
|
||||
for i := range plaintext {
|
||||
plaintext[i] = byte(i % 256)
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
ct, _ := EncryptWithXWing(plaintext, r)
|
||||
DecryptWithXWing(ct, id)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user