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:
+55
-22
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user