Add decoy passphrase

genkey optionally takes a second passphrase that unlocks a separate
decoy identity. Every command run with it sees and changes only decoy
files; records sealed to the other key are skipped silently.

The key file always carries two equal-size slots in random order, with
random bytes in an unused slot, and both slots are always tried. init
seeds each vault with 8-32 entries sealed to a discarded key, so real
files are indistinguishable from filler to a decoy-only holder.
This commit is contained in:
Andy Dixon
2026-09-26 14:06:54 +01:00
parent d95f2d04a4
commit be09659f8f
6 changed files with 214 additions and 34 deletions
+55 -22
View File
@@ -17,8 +17,11 @@ import (
"golang.org/x/crypto/chacha20poly1305"
)
// A key file seals a (suite ID, master seed) pair under an Argon2id-derived
// key. The private seed never touches disk unencrypted.
// 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"
@@ -68,18 +71,36 @@ func slotAEAD(passphrase string, salt []byte) (interface {
return chacha20poly1305.NewX(key)
}
// writeKeyFile creates a new key file holding a fresh identity.
func writeKeyFile(path, passphrase string) error {
id, err := pqcrypt.GeneratePQIdentity()
if err != nil {
// 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
}
slot, err := sealSlot(id, passphrase)
id.Close()
if 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(), slot...)
body := append(keyHeader(), slots[0]...)
body = append(body, slots[1]...)
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
@@ -99,7 +120,8 @@ func writeKeyFile(path, passphrase string) error {
return f.Close()
}
// unlockKeyFile returns the identity the passphrase unlocks.
// 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 {
@@ -108,19 +130,30 @@ func unlockKeyFile(path, passphrase string) (*pqcrypt.PQIdentity, error) {
block, rest := pem.Decode(data)
header := keyHeader()
if block == nil || block.Type != keyPEMType || len(bytes.TrimSpace(rest)) != 0 ||
len(block.Bytes) != len(header)+slotSize || !bytes.Equal(block.Bytes[:len(header)], header) {
len(block.Bytes) != len(header)+2*slotSize || !bytes.Equal(block.Bytes[:len(header)], header) {
return nil, errors.New("not a vault key file")
}
slot := block.Bytes[len(header):]
aead, err := slotAEAD(passphrase, slot[:slotSaltSize])
if err != nil {
return nil, err
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
}
}
plain, err := aead.Open(nil, slot[slotSaltSize:][:chacha20poly1305.NonceSizeX],
slot[slotSaltSize+chacha20poly1305.NonceSizeX:], header)
if err != nil {
if found == nil {
return nil, errors.New("incorrect passphrase")
}
defer clear(plain)
return pqcrypt.NewPQIdentityFromSeed(binary.BigEndian.Uint16(plain), plain[2:])
return found, nil
}