xwingkeyfile

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 2, 2026 License: MIT Imports: 7 Imported by: 0

README

xwing-keyfile

A Go library for X-Wing hybrid KEM key file serialization. X-Wing combines X25519 (classical) and ML-KEM-768 (post-quantum) into a single key encapsulation mechanism. This package defines a PEM file format for X-Wing key pairs and provides functions to marshal, unmarshal, and fingerprint keys. It depends only on Cloudflare CIRCL and the Go standard library.

Install

go get github.com/AleutianAI/xwing-keyfile

Usage

package main

import (
    "fmt"
    "os"

    xwingkeyfile "github.com/AleutianAI/xwing-keyfile"
    "github.com/cloudflare/circl/kem/xwing"
)

func main() {
    scheme := xwing.Scheme()

    // Generate a key pair.
    pub, priv, err := scheme.GenerateKeyPair()
    if err != nil {
        panic(err)
    }

    // Extract the 32-byte seed (canonical X-Wing private key).
    seed, err := xwingkeyfile.SeedFromPrivateKey(priv)
    if err != nil {
        panic(err)
    }

    // Write public key file.
    pubPEM, err := xwingkeyfile.MarshalPublicKey(pub)
    if err != nil {
        panic(err)
    }
    if err := os.WriteFile("keys.pub", pubPEM, 0644); err != nil {
        panic(err)
    }

    // Write private key file.
    privPEM, err := xwingkeyfile.MarshalPrivateKey(seed)
    if err != nil {
        panic(err)
    }
    if err := os.WriteFile("keys.priv", privPEM, 0600); err != nil {
        panic(err)
    }

    // Zero private key material after use.
    for i := range privPEM { privPEM[i] = 0 }
    seed = [32]byte{}

    // Print fingerprint.
    fp, err := xwingkeyfile.Fingerprint(pub)
    if err != nil {
        panic(err)
    }
    fmt.Printf("Public key fingerprint: %s\n", fp)
}
Reading key files
// Read and parse a public key.
data, err := os.ReadFile("keys.pub")
if err != nil {
    return err
}
pub, err := xwingkeyfile.UnmarshalPublicKey(data)
if err != nil {
    return err
}

// Read and parse a private key (returns 32-byte seed).
data, err = os.ReadFile("keys.priv")
if err != nil {
    return err
}
seed, err := xwingkeyfile.UnmarshalPrivateKey(data)
if err != nil {
    return err
}
defer func() { seed = [32]byte{} }() // zero when done
for i := range data { data[i] = 0 }  // zero file data too

// Re-derive the full key pair from the seed.
pub, priv := xwing.Scheme().DeriveKeyPair(seed[:])
Error handling

All validation errors wrap sentinel errors for programmatic handling:

import "errors"

_, err := xwingkeyfile.UnmarshalPublicKey(data)
if errors.Is(err, xwingkeyfile.ErrBadMagic) {
    // Wrong file format
}
if errors.Is(err, xwingkeyfile.ErrBadVersion) {
    // Unsupported file version
}

Available sentinels: ErrNoPEMBlock, ErrWrongPEMType, ErrBadMagic, ErrBadVersion, ErrBadPayloadSize, ErrTrailingData, ErrInputTooLarge, ErrInvalidKey.

Independent Key Generation

You do not need this package to generate X-Wing keys. Any implementation that produces the correct 1216-byte public key is compatible.

Using Cloudflare CIRCL directly
import "github.com/cloudflare/circl/kem/xwing"

scheme := xwing.Scheme()
pub, priv, _ := scheme.GenerateKeyPair()
pubBytes, _ := pub.MarshalBinary() // 1216 bytes
Using a fixed seed (deterministic)
seed := make([]byte, 32)
// Fill seed from your own entropy source (HSM, dice rolls, etc.)
pub, priv := xwing.Scheme().DeriveKeyPair(seed)
From any ML-KEM-768 + X25519 implementation

The public key is the concatenation:

pubKey = MLKEMPub (1184 bytes) || X25519Pub (32 bytes)

Where MLKEMPub is the ML-KEM-768 encapsulation key (FIPS 203) and X25519Pub is the X25519 public key (RFC 7748).

Test Vectors

The testdata/vectors.json file contains known-answer test vectors generated with Cloudflare CIRCL v1.6.3 against IETF draft-connolly-cfrg-xwing-kem-05 (final). Each vector specifies a seed, the expected public key bytes, and the expected fingerprint. Use these vectors to verify your implementation produces identical output.

Warning: The test vector seeds are public data. Never use them as real private keys.

Security

  • Private key files should be written with 0600 permissions.
  • Zero []byte slices containing private key material after use.
  • Zero the input data slice after calling UnmarshalPrivateKey — it contains the base64-encoded seed.
  • MarshalPrivateKey zeros its internal payload buffer. UnmarshalPrivateKey zeros the PEM decode buffer after extracting the seed, including on error paths.
  • encoding/pem and encoding/base64 create internal buffers that cannot be zeroed from user code. This is a known Go limitation. For high-assurance environments, consider mlockall and disabling core dumps at the process level.
  • Unmarshal functions reject input larger than 4096 bytes (MaxInputSize) and reject trailing data after the PEM block.

Dependencies

  • Cloudflare CIRCL v1.6.3 — X-Wing KEM implementation
  • Go standard library (crypto/sha512, encoding/hex, encoding/pem)

No other dependencies.

License

MIT — see LICENSE.


Technical Reference

The sections below cover the file format specification, the cryptographic rationale for X-Wing, and why this package exists. Skip this if you only need the API.

Why X-Wing

RSA and ECDH key exchange are broken by a sufficiently large quantum computer running Shor's algorithm. This is not a current threat, but ciphertext recorded today can be decrypted later ("harvest now, decrypt later"). For long-lived secrets, the migration window is now.

NIST standardized ML-KEM (FIPS 203, August 2024) as the post-quantum KEM replacement. ML-KEM-768 targets NIST security level 3 (128-bit post-quantum security). However, ML-KEM is based on module lattices, a class of problems with less cryptanalytic history than RSA or elliptic curves. A lattice-specific breakthrough would leave ML-KEM-only systems exposed with no fallback.

X-Wing addresses this by combining ML-KEM-768 with X25519 in a single KEM. The combined scheme is secure if either component is secure. An attacker must break both ML-KEM-768 (lattice) and X25519 (ECDH) to recover the shared secret. This is the standard hedge: deploy post-quantum now, keep classical as insurance.

X-Wing is specified in IETF draft-connolly-cfrg-xwing-kem-05 (final). It is a concrete, non-negotiable combination — no algorithm agility, no parameter selection. This reduces implementation risk.

Why CIRCL

Cloudflare CIRCL is the only production-grade Go implementation of X-Wing. It is maintained by Cloudflare's cryptography team, used in Cloudflare's TLS stack, and implements ML-KEM-768 per FIPS 203 and X25519 per RFC 7748. CIRCL's X-Wing implementation passes the IETF test vectors from the draft specification.

There is no Go standard library support for ML-KEM-768 or X-Wing as of Go 1.25. crypto/mlkem provides ML-KEM but not the X-Wing combiner. CIRCL is the pragmatic choice.

CIRCL is not FIPS-certified. For environments requiring FIPS 140-3 validation, the Go BoringCrypto build constraint provides FIPS-validated primitives, but does not cover ML-KEM or X-Wing. This is a known gap across the industry — no FIPS-validated X-Wing implementation exists as of 2026.

Why this package

CIRCL provides the KEM operations (keygen, encapsulate, decapsulate) but no file format. Keys exist only as in-memory Go types. This package solves the serialization problem: how to write keys to disk, read them back, and identify them.

The file format is intentionally minimal. PEM wrapping provides visual identification and copy-paste safety. The binary payload uses a 4-byte magic (ALT1) and a version byte to make files self-describing even without PEM headers. Private keys store only the 32-byte seed, not the expanded 2400-byte ML-KEM decapsulation key, because the expansion is deterministic (SHAKE256 per the X-Wing spec) and storing derived material increases attack surface for no benefit.

File Format Specification
Public Key (.pub)
-----BEGIN ALEUTIAN HYBRID KEM PUBLIC KEY-----
<base64 of binary payload>
-----END ALEUTIAN HYBRID KEM PUBLIC KEY-----

Binary payload (1221 bytes):

Offset Size Field Value
0 4 Magic ALT1 (0x41 0x4C 0x54 0x31)
4 1 Version 0x01 (public key file v1)
5 1184 ML-KEM-768 public key pk_M per X-Wing spec
1189 32 X25519 public key pk_X per X-Wing spec

Field order matches the canonical X-Wing wire format: ML-KEM first, X25519 second.

Private Key (.priv)
-----BEGIN ALEUTIAN HYBRID KEM PRIVATE KEY-----
<base64 of binary payload>
-----END ALEUTIAN HYBRID KEM PRIVATE KEY-----

Binary payload (37 bytes):

Offset Size Field Value
0 4 Magic ALT1 (0x41 0x4C 0x54 0x31)
4 1 Version 0x81 (private key file v1)
5 32 Seed X-Wing private key seed

Why store only the 32-byte seed? The X-Wing spec (IETF draft-connolly-cfrg-xwing-kem-05, section 5.2) defines the canonical private key as a 32-byte seed. The full ML-KEM decapsulation key (2400 bytes) and X25519 scalar (32 bytes) are derived deterministically via SHAKE256:

expanded = SHAKE256(seed, 96)
mlkem_d  = expanded[0:32]    // ML-KEM-768 d parameter
mlkem_z  = expanded[32:64]   // ML-KEM-768 z parameter
x25519   = expanded[64:96]   // X25519 private scalar

Storing only the seed minimizes attack surface on disk and aligns with the spec.

Version Byte Convention

The high bit of the version byte distinguishes key type:

Version Meaning
0x01 Public key file, format version 1
0x81 Private key file, format version 1
0x010x7F Reserved for future public key formats
0x810xFF Reserved for future private key formats

This ensures the binary payload is self-describing even without PEM headers.

Fingerprint

The fingerprint is the first 8 bytes of SHA-512(pubKeyBytes) rendered as 16 lowercase hex characters (zero-padded), where pubKeyBytes is the canonical 1216-byte public key (ML-KEM || X25519).

The fingerprint covers both components of the hybrid key. If only the ML-KEM component were hashed, substitution of the X25519 component (e.g., with a low-order point) would go undetected.

Privacy note: The fingerprint is a stable, deterministic identifier. It functions as a pseudonymous correlator — do not include it in cross-context logs or expose it to third parties without considering linkability implications.

Input Size Limits

Unmarshal functions reject input larger than 4096 bytes (MaxInputSize) and reject trailing data after the PEM block. This prevents denial-of-service via oversized inputs.

Documentation

Overview

Package xwingkeyfile provides PEM serialization for X-Wing (X25519 + ML-KEM-768) hybrid KEM key pairs.

X-Wing is a post-quantum/classical hybrid KEM defined in IETF draft-connolly-cfrg-xwing-kem-05 (final). This package defines a PEM-based file format for X-Wing keys, enabling interoperable key storage and exchange.

File Format

The binary payload (before PEM base64 encoding) is:

Public key:  ALT1 (4B magic) + 0x01 (version) + pubKey (1216B) = 1221B
Private key: ALT1 (4B magic) + 0x81 (version) + seed (32B)    = 37B

Private keys are stored as the 32-byte seed only. The full key pair is derived deterministically via SHAKE256 expansion per the X-Wing spec §5.2. This minimizes the cryptographic attack surface on disk.

Version bytes use the high bit to distinguish key type: 0x01 = public, 0x81 = private. This prevents misuse if the PEM framing is stripped.

Version bytes 0x01–0x7F are reserved for future public key formats; 0x81–0xFF are reserved for future private key formats.

Security

Callers MUST zero any []byte returned by MarshalPrivateKey after writing it to disk. The returned slice contains private key material.

Callers MUST zero the [32]byte seed returned by UnmarshalPrivateKey when done. Callers SHOULD also zero the input data slice passed to UnmarshalPrivateKey, as it contains the base64-encoded seed.

Fingerprint Privacy

The fingerprint returned by Fingerprint is a stable, deterministic hash of the public key. It functions as a pseudonymous identifier that can correlate activity across otherwise unlinkable contexts. Do not include fingerprints in cross-context logs or expose them to third parties without considering linkability implications.

Dependencies

This package depends only on Cloudflare CIRCL (github.com/cloudflare/circl). It has no Aleutian-internal dependencies and can be independently audited.

Index

Constants

View Source
const (
	// PubKeyVersion is the version byte for public key files.
	// The value 0x01 indicates public key file format version 1.
	PubKeyVersion = byte(0x01)

	// PrivKeyVersion is the version byte for private key files.
	// The high bit (0x80) distinguishes private from public even without PEM
	// headers, so files remain self-describing if the PEM framing is stripped.
	PrivKeyVersion = byte(0x81)

	// PubKeyPayloadSize is the binary payload size inside the PEM block for
	// public keys: magic(4) + version(1) + pubKey(1216) = 1221.
	PubKeyPayloadSize = 1221

	// PrivKeyPayloadSize is the binary payload size inside the PEM block for
	// private keys: magic(4) + version(1) + seed(32) = 37.
	PrivKeyPayloadSize = 37

	// PEMTypePublicKey is the PEM block type for public key files.
	PEMTypePublicKey = "ALEUTIAN HYBRID KEM PUBLIC KEY"

	// PEMTypePrivateKey is the PEM block type for private key files.
	PEMTypePrivateKey = "ALEUTIAN HYBRID KEM PRIVATE KEY"

	// MaxInputSize is the maximum input size accepted by unmarshal functions.
	// PEM overhead for a 1221-byte payload is ~1700 bytes; 4096 provides margin.
	MaxInputSize = 4096
)

Variables

View Source
var (
	// ErrNoPEMBlock indicates the input data does not contain a valid PEM block.
	ErrNoPEMBlock = errors.New("xwingkeyfile: no PEM block found")

	// ErrWrongPEMType indicates the PEM block type does not match the expected type.
	ErrWrongPEMType = errors.New("xwingkeyfile: wrong PEM block type")

	// ErrBadMagic indicates the first 4 bytes of the payload are not "ALT1".
	ErrBadMagic = errors.New("xwingkeyfile: wrong magic bytes")

	// ErrBadVersion indicates the version byte is not recognized.
	ErrBadVersion = errors.New("xwingkeyfile: unsupported file version")

	// ErrBadPayloadSize indicates the binary payload has an unexpected length.
	ErrBadPayloadSize = errors.New("xwingkeyfile: unexpected payload size")

	// ErrTrailingData indicates the PEM file contains data after the first block.
	ErrTrailingData = errors.New("xwingkeyfile: unexpected trailing data")

	// ErrInputTooLarge indicates the input data exceeds the maximum allowed size.
	ErrInputTooLarge = errors.New("xwingkeyfile: input too large")

	// ErrInvalidKey indicates the key bytes were rejected by the underlying
	// cryptographic library.
	ErrInvalidKey = errors.New("xwingkeyfile: invalid key")
)

Sentinel errors for programmatic error handling. All validation errors returned by unmarshal functions wrap one of these sentinels, enabling callers to use errors.Is instead of string matching.

Functions

func Fingerprint

func Fingerprint(pub kem.PublicKey) (string, error)

Fingerprint returns the 16-hex-char fingerprint of an X-Wing public key.

Description

Computes the first 8 bytes of SHA-512(pubKeyBytes) where pubKeyBytes is the canonical 1216-byte representation (MLKEMPub || X25519Pub), rendered as lowercase hexadecimal (16 characters, zero-padded).

The fingerprint covers both components of the hybrid key. If only the ML-KEM component were hashed, an attacker who substituted the X25519 component (e.g., with a low-order point) would not be detected.

Inputs

  • pub: An X-Wing public key implementing kem.PublicKey from CIRCL.

Outputs

  • string: 16-character lowercase hex fingerprint (64 bits of SHA-512). Always exactly 16 characters, zero-padded.
  • error: Non-nil if the public key cannot be marshaled.

Example

fp, err := xwingkeyfile.Fingerprint(pub)
if err != nil {
    return err
}
fmt.Fprintf(os.Stderr, "Public key fingerprint: %s\n", fp)

Limitations

  • 64 bits provides collision resistance for human-readable display only, not cryptographic binding. Do not use as a unique identifier in protocols.
  • The fingerprint is a stable pseudonymous identifier. Do not include it in cross-context logs or expose it to third parties without considering linkability implications (see package-level doc).

func GetMagic

func GetMagic() [4]byte

GetMagic returns a copy of the 4-byte file format magic identifier ("ALT1").

func MarshalPrivateKey

func MarshalPrivateKey(seed [32]byte) ([]byte, error)

MarshalPrivateKey serializes an X-Wing private key seed to PEM format.

Description

Constructs a binary payload of magic(4) + version(1) + seed(32) = 37 bytes, then wraps it in a PEM block with type "ALEUTIAN HYBRID KEM PRIVATE KEY".

Only the 32-byte seed is stored. The full key pair can be re-derived via xwing.Scheme().DeriveKeyPair(seed[:]).

Inputs

  • seed: The 32-byte X-Wing private key seed. This is the canonical private key representation per IETF draft-connolly-cfrg-xwing-kem-05 §5.2.

Outputs

  • []byte: PEM-encoded private key file contents, suitable for os.WriteFile(path, data, 0600).
  • error: Reserved for future validation (e.g., seed entropy checks). Currently always nil.

Example

seed, err := xwingkeyfile.SeedFromPrivateKey(priv)
if err != nil {
    return err
}
pemData, err := xwingkeyfile.MarshalPrivateKey(seed)
if err != nil {
    return err
}
if err := os.WriteFile("keys.priv", pemData, 0600); err != nil {
    return err
}
// MUST zero pemData after write:
for i := range pemData { pemData[i] = 0 }

Limitations

  • The returned []byte contains private key material. Callers MUST zero it after writing to disk.
  • encoding/pem creates internal buffers that cannot be zeroed from user code. This is a known limitation of Go's memory model.

Assumptions

  • seed is a cryptographically random 32-byte value (or derived from one).

func MarshalPublicKey

func MarshalPublicKey(pub kem.PublicKey) ([]byte, error)

MarshalPublicKey serializes an X-Wing public key to PEM format.

Description

Constructs a binary payload of magic(4) + version(1) + pubKey(1216) = 1221 bytes, then wraps it in a PEM block with type "ALEUTIAN HYBRID KEM PUBLIC KEY". The pubKey bytes are in canonical X-Wing order: MLKEMPub(1184) || X25519Pub(32).

Inputs

  • pub: An X-Wing public key implementing kem.PublicKey from CIRCL. Must marshal to exactly 1216 bytes.

Outputs

  • []byte: PEM-encoded public key file contents, suitable for os.WriteFile.
  • error: Non-nil if the public key cannot be marshaled or has wrong size.

Example

scheme := xwing.Scheme()
pub, _, err := scheme.GenerateKeyPair()
if err != nil {
    return err
}
pemData, err := xwingkeyfile.MarshalPublicKey(pub)
if err != nil {
    return err
}
if err := os.WriteFile("keys.pub", pemData, 0644); err != nil {
    return err
}

Assumptions

  • pub was generated by a compliant X-Wing implementation (CIRCL or compatible).

func SeedFromPrivateKey

func SeedFromPrivateKey(priv kem.PrivateKey) ([32]byte, error)

SeedFromPrivateKey extracts the 32-byte seed from a CIRCL X-Wing private key.

Description

Calls priv.MarshalBinary() and validates the result is exactly 32 bytes. This centralizes the assumption about CIRCL's private key serialization format so it can be updated in one place if CIRCL's API changes.

Inputs

Outputs

  • [32]byte: The private key seed. Callers MUST zero this when done.
  • error: Non-nil if the private key cannot be marshaled or has unexpected size.

Example

_, priv, _ := xwing.Scheme().GenerateKeyPair()
seed, err := xwingkeyfile.SeedFromPrivateKey(priv)
if err != nil {
    return err
}
defer func() { seed = [32]byte{} }()

Limitations

  • Assumes CIRCL's MarshalBinary returns the 32-byte seed as the canonical private key representation. If a future CIRCL version changes this layout, the length check will fail loudly rather than silently misinterpreting bytes.
  • The intermediate []byte from MarshalBinary is zeroed, but Go's garbage collector may have already copied it during heap allocation.

Assumptions

  • priv was produced by xwing.Scheme().GenerateKeyPair() or DeriveKeyPair().
  • CIRCL's X-Wing PrivateKey.MarshalBinary() returns exactly 32 bytes (the seed).

func UnmarshalPrivateKey

func UnmarshalPrivateKey(data []byte) ([32]byte, error)

UnmarshalPrivateKey parses a PEM-encoded X-Wing private key file.

Description

Decodes the PEM block, validates the block type, magic bytes, version byte, and payload size, then extracts the 32-byte seed. Zeros the PEM decode buffer after extracting the seed.

The caller can derive the full key pair from the seed via xwing.Scheme().DeriveKeyPair(seed[:]).

Inputs

  • data: Raw bytes of the PEM-encoded private key file (as read by os.ReadFile). Must not exceed MaxInputSize (4096 bytes). Callers SHOULD zero this slice after calling UnmarshalPrivateKey, as it contains the base64-encoded seed.

Outputs

  • [32]byte: The private key seed. Callers MUST zero this when done.
  • error: Non-nil if validation fails. Wraps sentinel errors as with UnmarshalPublicKey.

Example

data, err := os.ReadFile("keys.priv")
if err != nil {
    return err
}
seed, err := xwingkeyfile.UnmarshalPrivateKey(data)
if err != nil {
    return err
}
defer func() { seed = [32]byte{} }() // zero seed when done
// Also zero the file data:
for i := range data { data[i] = 0 }
pub, priv := xwing.Scheme().DeriveKeyPair(seed[:])

func UnmarshalPublicKey

func UnmarshalPublicKey(data []byte) (kem.PublicKey, error)

UnmarshalPublicKey parses a PEM-encoded X-Wing public key file.

Description

Decodes the PEM block, validates the block type, magic bytes, version byte, and payload size, then parses the public key using CIRCL's X-Wing scheme. Rejects input larger than MaxInputSize and trailing data after the PEM block.

Inputs

  • data: Raw bytes of the PEM-encoded public key file (as read by os.ReadFile). Must not exceed MaxInputSize (4096 bytes).

Outputs

Example

data, err := os.ReadFile("keys.pub")
if err != nil {
    return err
}
pub, err := xwingkeyfile.UnmarshalPublicKey(data)
if err != nil {
    return err
}
ct, ss, err := xwing.Scheme().Encapsulate(pub)

Types

This section is empty.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL