Initial vault: single-binary post-quantum file vault

Strip Fury back to its vault functionality in one `vault` command:
genkey, init, add (file or directory, optional folder inside the vault),
list, get, mv and rm.

The hybrid FrodoKEM-640 + ML-KEM-768 encryption core is kept under
internal/pqcrypt. Signing, policies, profiles, backups, SSH remotes,
FIDO2, agents, provenance and compression are removed.
This commit is contained in:
Andy Dixon
2026-09-26 14:05:10 +01:00
commit d95f2d04a4
26 changed files with 5689 additions and 0 deletions
+215
View File
@@ -0,0 +1,215 @@
// Copyright 2026 The fury Authors.
// SPDX-License-Identifier: GPL-3.0-or-later
package pqcrypt
import (
"crypto/sha256"
"errors"
"fmt"
"io"
"slices"
"github.com/andydixon/vault/internal/format"
"github.com/cloudflare/circl/kem"
"github.com/cloudflare/circl/kem/frodo/frodo640shake"
"github.com/cloudflare/circl/kem/mlkem/mlkem768"
"golang.org/x/crypto/hkdf"
)
// frodoMLKEM768 is the default post-quantum suite: a hybrid of FrodoKEM-640
// (unstructured-lattice) and ML-KEM-768 (module-lattice). The file key stays
// secret unless both KEMs are broken. It is pure Go, so fury keeps building as a
// static binary with no cgo. (A code-based suite such as HQC or Classic McEliece
// via liboqs can be registered later with a higher rank; see suite.go.)
type frodoMLKEM768 struct{}
func init() { registerSuite(frodoMLKEM768{}) }
func (frodoMLKEM768) id() uint16 { return 0x0001 }
func (frodoMLKEM768) tag() string { return "frodo640shake+mlkem768" }
func (frodoMLKEM768) rank() int { return 10 }
func (frodoMLKEM768) seedSize() int { return 32 }
func (frodoMLKEM768) schemes() (frodo, ml kem.Scheme) {
return frodo640shake.Scheme(), mlkem768.Scheme()
}
func (s frodoMLKEM768) publicKeySize() int {
frodo, ml := s.schemes()
return frodo.PublicKeySize() + ml.PublicKeySize()
}
// deriveKeypair expands a 32-byte master seed into per-KEM seeds via HKDF, then
// derives each keypair deterministically. The public key is frodoPub||mlkemPub
// and the private key is frodoPriv||mlkemPriv.
func (s frodoMLKEM768) deriveKeypair(seed []byte) (pub, priv []byte, err error) {
frodo, ml := s.schemes()
r := hkdf.New(sha256.New, seed, nil, []byte("fury/v1 seed-expand "+s.tag()))
frodoSeed := make([]byte, frodo.SeedSize())
mlSeed := make([]byte, ml.SeedSize())
// The per-KEM seeds regenerate the private keys, so they are as sensitive
// as the master seed and are cleared as soon as the keys are derived.
defer wipe(frodoSeed)
defer wipe(mlSeed)
if _, err := io.ReadFull(r, frodoSeed); err != nil {
return nil, nil, err
}
if _, err := io.ReadFull(r, mlSeed); err != nil {
return nil, nil, err
}
fpk, fsk := frodo.DeriveKeyPair(frodoSeed)
mpk, msk := ml.DeriveKeyPair(mlSeed)
fpkb, err := fpk.MarshalBinary()
if err != nil {
return nil, nil, err
}
mpkb, err := mpk.MarshalBinary()
if err != nil {
return nil, nil, err
}
fskb, err := fsk.MarshalBinary()
if err != nil {
return nil, nil, err
}
defer wipe(fskb)
mskb, err := msk.MarshalBinary()
if err != nil {
return nil, nil, err
}
defer wipe(mskb)
// fpkb and mpkb are public key bytes; wiping them would be theatre.
return slices.Concat(fpkb, mpkb), slices.Concat(fskb, mskb), nil
}
// wrap encapsulates to both KEMs and seals the file key under the combined
// KEK.
//
// INVARIANT (fresh encapsulation). Both Encapsulate calls draw fresh
// randomness on every invocation. Wrapping the same file key to the same
// recipient twice therefore produces two independent KEKs, never a repeat.
func (s frodoMLKEM768) wrap(pub, fileKey []byte) ([]*Stanza, error) {
frodo, ml := s.schemes()
if len(pub) != s.publicKeySize() {
return nil, fmt.Errorf("invalid %s public key size", s.tag())
}
fpk, err := frodo.UnmarshalBinaryPublicKey(pub[:frodo.PublicKeySize()])
if err != nil {
return nil, fmt.Errorf("invalid FrodoKEM public key: %v", err)
}
mpk, err := ml.UnmarshalBinaryPublicKey(pub[frodo.PublicKeySize():])
if err != nil {
return nil, fmt.Errorf("invalid ML-KEM public key: %v", err)
}
ct1, ss1, err := frodo.Encapsulate(fpk)
if err != nil {
return nil, fmt.Errorf("FrodoKEM encapsulation failed: %v", err)
}
ct2, ss2, err := ml.Encapsulate(mpk)
if err != nil {
return nil, fmt.Errorf("ML-KEM encapsulation failed: %v", err)
}
kek := s.deriveKEK(ss1, ss2, ct1, ct2)
defer wipe(ss1)
defer wipe(ss2)
defer wipe(kek)
body, err := aeadEncrypt(kek, fileKey)
if err != nil {
return nil, fmt.Errorf("failed to wrap file key: %v", err)
}
return []*Stanza{{
Type: s.tag(),
Args: []string{format.EncodeToString(ct1), format.EncodeToString(ct2)},
Body: body,
}}, nil
}
func (s frodoMLKEM768) unwrap(priv []byte, st *Stanza) ([]byte, error) {
frodo, ml := s.schemes()
if st.Type != s.tag() {
return nil, ErrIncorrectIdentity
}
if len(st.Args) != 2 {
return nil, fmt.Errorf("invalid %s stanza: expected 2 arguments", s.tag())
}
ct1, err := format.DecodeString(st.Args[0])
if err != nil {
return nil, fmt.Errorf("invalid FrodoKEM ciphertext: %v", err)
}
ct2, err := format.DecodeString(st.Args[1])
if err != nil {
return nil, fmt.Errorf("invalid ML-KEM ciphertext: %v", err)
}
if len(ct1) != frodo.CiphertextSize() || len(ct2) != ml.CiphertextSize() {
return nil, errIncorrectCiphertextSize
}
if len(priv) != frodo.PrivateKeySize()+ml.PrivateKeySize() {
return nil, errors.New("invalid private key size")
}
fsk, err := frodo.UnmarshalBinaryPrivateKey(priv[:frodo.PrivateKeySize()])
if err != nil {
return nil, fmt.Errorf("invalid FrodoKEM private key: %v", err)
}
// priv itself belongs to the caller's identity and is cleared by
// PQIdentity.Close; the parsed key objects are owned by CIRCL and expose
// no buffer Fury can clear.
msk, err := ml.UnmarshalBinaryPrivateKey(priv[frodo.PrivateKeySize():])
if err != nil {
return nil, fmt.Errorf("invalid ML-KEM private key: %v", err)
}
ss1, err := frodo.Decapsulate(fsk, ct1)
if err != nil {
return nil, fmt.Errorf("FrodoKEM decapsulation failed: %v", err)
}
ss2, err := ml.Decapsulate(msk, ct2)
if err != nil {
return nil, fmt.Errorf("ML-KEM decapsulation failed: %v", err)
}
// Both KEMs use implicit rejection, so a mismatched key yields a wrong
// shared secret rather than an error; detection happens here at aeadDecrypt.
kek := s.deriveKEK(ss1, ss2, ct1, ct2)
defer wipe(ss1)
defer wipe(ss2)
defer wipe(kek)
fileKey, err := aeadDecrypt(kek, fileKeySize, st.Body)
if err != nil {
return nil, ErrIncorrectIdentity
}
return fileKey, nil
}
// deriveKEK combines the two KEM shared secrets and both ciphertexts into a
// 256-bit key-encryption key.
//
// INVARIANT (both secrets contribute). ss1 and ss2 are concatenated as the
// HKDF input keying material, so the KEK stays unpredictable unless both KEMs
// are broken for the same target. Dropping either would silently reduce the
// construction to a single KEM.
//
// INVARIANT (transcript binding). Both ciphertexts are bound into the HKDF
// info string, preventing a mix-and-match of encapsulations across the two
// KEMs: an attacker cannot pair ct1 from one stanza with ct2 from another and
// obtain a KEK either party would compute.
//
// INVARIANT (single use). Because both encapsulations are performed freshly
// for every wrap, the resulting KEK is unique to that wrap. That is what
// makes the fixed AEAD nonce in aeadEncrypt safe; see primitives.go.
func (s frodoMLKEM768) deriveKEK(ss1, ss2, ct1, ct2 []byte) []byte {
secret := slices.Concat(ss1, ss2)
defer wipe(secret)
info := slices.Concat([]byte("fury/v1 kek "+s.tag()), ct1, ct2)
r := hkdf.New(sha256.New, secret, nil, info)
kek := make([]byte, 32)
if _, err := io.ReadFull(r, kek); err != nil {
panic("fury: internal error: failed to read from HKDF: " + err.Error())
}
return kek
}