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
+236
View File
@@ -0,0 +1,236 @@
// Copyright 2026 The fury Authors.
// SPDX-License-Identifier: GPL-3.0-or-later
package pqcrypt
import (
"crypto/rand"
"errors"
"fmt"
)
// A pqSuite is a registered post-quantum KEM suite. Each suite wraps the file
// key to a recipient and unwraps it with the corresponding identity. Suites are
// self-describing: their stanza tag is written into the header, so a file made
// with one suite keeps decrypting even after newer suites are added and made the
// default. This is the "use the best now, add better later, still decrypt older"
// property.
//
// Recipient public keys and identity private keys are opaque byte slices whose
// layout is defined by the suite. Identities are derived deterministically from
// a compact master seed so they can be stored and passphrase-wrapped cheaply.
type pqSuite interface {
// id is a stable numeric identifier embedded in recipient encodings.
id() uint16
// tag is the header stanza Type for this suite.
tag() string
// rank orders suites when picking a default; higher wins.
rank() int
// seedSize is the length of the master seed accepted by deriveKeypair.
seedSize() int
// deriveKeypair deterministically expands a master seed into a suite public
// key and private key.
deriveKeypair(seed []byte) (pub, priv []byte, err error)
// wrap encapsulates fileKey to pub, returning one or more header stanzas.
wrap(pub, fileKey []byte) ([]*Stanza, error)
// unwrap recovers fileKey from a stanza using priv. It must return an error
// wrapping ErrIncorrectIdentity if the stanza does not match.
unwrap(priv []byte, s *Stanza) ([]byte, error)
// publicKeySize is the expected length of a suite public key.
publicKeySize() int
}
var (
pqSuitesByTag = map[string]pqSuite{}
pqSuitesByID = map[uint16]pqSuite{}
)
// registerSuite adds a suite to the registry. It is called from suite files'
// init functions.
func registerSuite(s pqSuite) {
if _, ok := pqSuitesByTag[s.tag()]; ok {
panic("fury: duplicate suite tag " + s.tag())
}
if _, ok := pqSuitesByID[s.id()]; ok {
panic(fmt.Sprintf("fury: duplicate suite id %#04x", s.id()))
}
pqSuitesByTag[s.tag()] = s
pqSuitesByID[s.id()] = s
}
// defaultSuite returns the highest-ranked registered suite, used for key
// generation and encryption.
func defaultSuite() pqSuite {
var best pqSuite
for _, s := range pqSuitesByTag {
if best == nil || s.rank() > best.rank() {
best = s
}
}
if best == nil {
panic("fury: no PQ suites registered")
}
return best
}
// PQRecipient is a post-quantum public key. Messages encrypted to it can be
// decrypted with the corresponding [PQIdentity].
//
// It is anonymous: an observer can't tell from a message alone whether it is
// encrypted to a given recipient.
type PQRecipient struct {
suite pqSuite
pub []byte
}
var _ Recipient = &PQRecipient{}
var _ RecipientWithLabels = &PQRecipient{}
func (r *PQRecipient) Wrap(fileKey []byte) ([]*Stanza, error) {
return r.suite.wrap(r.pub, fileKey)
}
// WrapWithLabels returns a "postquantum" label so a PQRecipient can only be
// combined with other post-quantum recipients, never with a recipient type that
// would defeat its security.
func (r *PQRecipient) WrapWithLabels(fileKey []byte) ([]*Stanza, []string, error) {
s, err := r.Wrap(fileKey)
if err != nil {
return nil, nil, err
}
return s, []string{"postquantum"}, nil
}
// SuiteID returns the numeric identifier of the recipient's suite.
func (r *PQRecipient) SuiteID() uint16 { return r.suite.id() }
// Bytes returns the raw suite public key.
func (r *PQRecipient) Bytes() []byte { return append([]byte(nil), r.pub...) }
// PQIdentity is a post-quantum private key, derived from a compact master seed,
// which can decrypt messages encrypted to the corresponding [PQRecipient].
type PQIdentity struct {
suite pqSuite
seed []byte
pub []byte
priv []byte
}
var _ Identity = &PQIdentity{}
// GeneratePQIdentity randomly generates a new identity using the default suite.
func GeneratePQIdentity() (*PQIdentity, error) {
s := defaultSuite()
seed := make([]byte, s.seedSize())
if _, err := rand.Read(seed); err != nil {
return nil, fmt.Errorf("failed to generate seed: %w", err)
}
id, err := newPQIdentityFromSeed(s, seed)
wipe(seed)
return id, err
}
// newPQIdentityFromSeed derives an identity from a suite and master seed.
func newPQIdentityFromSeed(s pqSuite, seed []byte) (*PQIdentity, error) {
if len(seed) != s.seedSize() {
return nil, fmt.Errorf("invalid seed size %d for suite %s", len(seed), s.tag())
}
seed = append([]byte(nil), seed...)
pub, priv, err := s.deriveKeypair(seed)
if err != nil {
return nil, fmt.Errorf("failed to derive keypair: %w", err)
}
return &PQIdentity{suite: s, seed: seed, pub: pub, priv: priv}, nil
}
// usable reports whether the identity still holds its secret material. A
// closed identity, and one that was never fully initialised, both report
// false rather than panicking on a nil slice.
func (i *PQIdentity) usable() bool {
return i != nil && i.suite != nil && len(i.seed) == i.suite.seedSize() && len(i.priv) != 0
}
// ErrIdentityClosed is returned by operations on an identity whose secret
// material has been cleared by Close.
var ErrIdentityClosed = errors.New("fury identity is closed or uninitialised")
// Close overwrites the secret material this identity owns: its master seed
// and its expanded private key. Repeated calls are safe, and a partially
// initialised identity closes without panicking. The public key is retained,
// so [PQIdentity.Recipient] keeps working on a closed identity.
//
// Explicit overwriting shortens the lifetime of buffers Fury knows about. It
// cannot guarantee removal from physical memory: the Go compiler and runtime
// may have copied a value during allocation, garbage collection, stack growth
// or escape analysis, and Fury has no way to find or clear those copies. Treat
// this as defence in depth, not as erasure.
func (i *PQIdentity) Close() {
if i == nil {
return
}
wipe(i.seed)
wipe(i.priv)
i.seed, i.priv = nil, nil
}
func (i *PQIdentity) Unwrap(stanzas []*Stanza) ([]byte, error) {
if !i.usable() {
return nil, ErrIdentityClosed
}
return multiUnwrap(i.unwrap, stanzas)
}
func (i *PQIdentity) unwrap(s *Stanza) ([]byte, error) {
su, ok := pqSuitesByTag[s.Type]
if !ok || su.tag() != i.suite.tag() {
return nil, ErrIncorrectIdentity
}
fileKey, err := su.unwrap(i.priv, s)
if err != nil {
return nil, err
}
// Enforce the file key length centrally, so no future suite can feed a
// wrong-sized key into the payload STREAM by forgetting its own check.
if len(fileKey) != fileKeySize {
return nil, fmt.Errorf("suite %s returned a file key of %d bytes, want %d", su.tag(), len(fileKey), fileKeySize)
}
return fileKey, nil
}
// Recipient returns the public [PQRecipient] corresponding to i. It remains
// valid after [PQIdentity.Close], because a public key is not secret.
func (i *PQIdentity) Recipient() *PQRecipient {
return &PQRecipient{suite: i.suite, pub: append([]byte(nil), i.pub...)}
}
// Seed returns a copy of the compact master seed, or nil if the identity is
// closed.
//
// The returned slice is secret-equivalent: it regenerates both the private and
// the public key, so anything that can read it can decrypt everything the
// identity can. It must never be shared as a recipient, written unencrypted,
// logged, or passed to a subprocess.
//
// The copy is deliberate: it is never an alias into the identity's own buffer,
// so closing the identity does not clear it. From the moment it is returned it
// is the caller's responsibility to wipe it when finished and to keep its
// lifetime short.
func (i *PQIdentity) Seed() []byte {
if !i.usable() {
return nil
}
return append([]byte(nil), i.seed...)
}
// SuiteID returns the numeric identifier of the identity's suite.
func (i *PQIdentity) SuiteID() uint16 { return i.suite.id() }
// NewPQIdentityFromSeed derives an identity of the given suite from its master
// seed, as returned by [PQIdentity.Seed].
func NewPQIdentityFromSeed(suiteID uint16, seed []byte) (*PQIdentity, error) {
s, ok := pqSuitesByID[suiteID]
if !ok {
return nil, fmt.Errorf("unknown suite id %#04x", suiteID)
}
return newPQIdentityFromSeed(s, seed)
}