Freeze the Go implementation as a test-only oracle, with vectors
oracle/go is the Go code at 4c1507c, moved unchanged, plus test-only
generators that record what it computes. tests/vectors holds the
deterministic crypto/store vectors and synthetic fixtures they produce.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
// Copyright 2026 The vault Authors.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/andydixon/vault/internal/pqcrypt"
|
||||
"golang.org/x/crypto/argon2"
|
||||
"golang.org/x/crypto/chacha20poly1305"
|
||||
)
|
||||
|
||||
// A key file holds exactly two passphrase slots. Each slot seals a
|
||||
// (suite ID, master seed) pair under an Argon2id-derived key. When no decoy
|
||||
// passphrase is set, the second slot is random bytes of identical length, so
|
||||
// the file never reveals whether a decoy exists, and the real slot's position
|
||||
// is chosen at random so it never reveals which one is real.
|
||||
|
||||
const (
|
||||
keyPEMType = "VAULT KEY"
|
||||
argonTime = 3
|
||||
argonMemory = 64 * 1024 // KiB
|
||||
argonThreads = 4
|
||||
slotSaltSize = 16
|
||||
seedSize = 32
|
||||
slotPlainSize = 2 + seedSize
|
||||
slotSize = slotSaltSize + chacha20poly1305.NonceSizeX + slotPlainSize + chacha20poly1305.Overhead
|
||||
)
|
||||
|
||||
var keyMagic = []byte("VAULTKEY1")
|
||||
|
||||
// keyHeader is the authenticated, non-secret prefix of every key file.
|
||||
func keyHeader() []byte {
|
||||
h := append([]byte(nil), keyMagic...)
|
||||
h = binary.BigEndian.AppendUint32(h, argonTime)
|
||||
h = binary.BigEndian.AppendUint32(h, argonMemory)
|
||||
return append(h, argonThreads)
|
||||
}
|
||||
|
||||
// sealSlot encrypts the identity's seed under passphrase.
|
||||
func sealSlot(id *pqcrypt.PQIdentity, passphrase string) ([]byte, error) {
|
||||
slot := make([]byte, slotSaltSize+chacha20poly1305.NonceSizeX, slotSize)
|
||||
if _, err := rand.Read(slot); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aead, err := slotAEAD(passphrase, slot[:slotSaltSize])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plain := binary.BigEndian.AppendUint16(nil, id.SuiteID())
|
||||
seed := id.Seed()
|
||||
plain = append(plain, seed...)
|
||||
clear(seed)
|
||||
defer clear(plain)
|
||||
return aead.Seal(slot, slot[slotSaltSize:], plain, keyHeader()), nil
|
||||
}
|
||||
|
||||
func slotAEAD(passphrase string, salt []byte) (interface {
|
||||
Seal(dst, nonce, plaintext, ad []byte) []byte
|
||||
Open(dst, nonce, ciphertext, ad []byte) ([]byte, error)
|
||||
}, error) {
|
||||
key := argon2.IDKey([]byte(passphrase), salt, argonTime, argonMemory, argonThreads, chacha20poly1305.KeySize)
|
||||
defer clear(key)
|
||||
return chacha20poly1305.NewX(key)
|
||||
}
|
||||
|
||||
// writeKeyFile creates a new key file. decoy may be empty, in which case the
|
||||
// second slot is filled with random bytes.
|
||||
func writeKeyFile(path, passphrase, decoy string) error {
|
||||
var slots [2][]byte
|
||||
var b [1]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
realSlot := int(b[0] & 1)
|
||||
for i, pass := range [2]string{passphrase, decoy} {
|
||||
idx := realSlot ^ i
|
||||
if pass == "" {
|
||||
slots[idx] = make([]byte, slotSize)
|
||||
if _, err := rand.Read(slots[idx]); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
id, err := pqcrypt.GeneratePQIdentity()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
slots[idx], err = sealSlot(id, pass)
|
||||
id.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
body := append(keyHeader(), slots[0]...)
|
||||
body = append(body, slots[1]...)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := pem.Encode(f, &pem.Block{Type: keyPEMType, Bytes: body}); err != nil {
|
||||
f.Close()
|
||||
os.Remove(path)
|
||||
return err
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
// unlockKeyFile returns the identity whose slot the passphrase opens. Both
|
||||
// slots are always tried, so timing does not reveal which slot matched.
|
||||
func unlockKeyFile(path, passphrase string) (*pqcrypt.PQIdentity, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block, rest := pem.Decode(data)
|
||||
header := keyHeader()
|
||||
if block == nil || block.Type != keyPEMType || len(bytes.TrimSpace(rest)) != 0 ||
|
||||
len(block.Bytes) != len(header)+2*slotSize || !bytes.Equal(block.Bytes[:len(header)], header) {
|
||||
return nil, errors.New("not a vault key file")
|
||||
}
|
||||
var found *pqcrypt.PQIdentity
|
||||
for i := range 2 {
|
||||
slot := block.Bytes[len(header)+i*slotSize:][:slotSize]
|
||||
aead, err := slotAEAD(passphrase, slot[:slotSaltSize])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plain, err := aead.Open(nil, slot[slotSaltSize:][:chacha20poly1305.NonceSizeX],
|
||||
slot[slotSaltSize+chacha20poly1305.NonceSizeX:], header)
|
||||
if err != nil || found != nil {
|
||||
clear(plain)
|
||||
continue
|
||||
}
|
||||
found, err = pqcrypt.NewPQIdentityFromSeed(binary.BigEndian.Uint16(plain), plain[2:])
|
||||
clear(plain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
return nil, errors.New("incorrect passphrase")
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
Reference in New Issue
Block a user