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
+5 -2
View File
@@ -8,8 +8,9 @@ vault is a post-quantum encrypted file vault: one `package main` at the repo
root that builds a single `vault` binary. Module `github.com/andydixon/vault`. root that builds a single `vault` binary. Module `github.com/andydixon/vault`.
- `main.go` - CLI: `genkey`, `init`, `add`, `list`, `get`, `mv`, `rm`. - `main.go` - CLI: `genkey`, `init`, `add`, `list`, `get`, `mv`, `rm`.
- `keyfile.go` - passphrase-protected key file (Argon2id + - `keyfile.go` - key file with two passphrase slots (Argon2id +
XChaCha20-Poly1305 sealing a suite ID and 32-byte master seed). XChaCha20-Poly1305 sealing a suite ID and 32-byte master seed). The second
slot is either a decoy identity or random bytes; slot order is random.
- `store.go` - on-disk vault: `records/` holds fixed-size encrypted catalogue - `store.go` - on-disk vault: `records/` holds fixed-size encrypted catalogue
records, `objects/` holds padded encrypted file bodies. Records a key cannot records, `objects/` holds padded encrypted file bodies. Records a key cannot
open are skipped, not treated as errors. open are skipped, not treated as errors.
@@ -40,5 +41,7 @@ export VAULT_PASSPHRASE=test VAULT_KEY=$PWD/k VAULT_DIR=$PWD/s
size; objects stay bucket-padded. These hide metadata from the storage host. size; objects stay bucket-padded. These hide metadata from the storage host.
- An object's plaintext starts with its own ID, binding it to its record. - An object's plaintext starts with its own ID, binding it to its record.
- `get` publishes output only after the entire object has authenticated. - `get` publishes output only after the entire object has authenticated.
- Decoy deniability: both key slots stay the same size and are both always
tried; `init` always writes filler; foreign records are skipped silently.
- Keep dependencies pure Go. - Keep dependencies pure Go.
- Diagnostics go to standard error, lower-case, no trailing full stops. - Diagnostics go to standard error, lower-case, no trailing full stops.
+26
View File
@@ -39,6 +39,32 @@ existing file and writes nothing until the whole object has authenticated.
The passphrase is read from the terminal, or from `$VAULT_PASSPHRASE` for The passphrase is read from the terminal, or from `$VAULT_PASSPHRASE` for
scripts. scripts.
## Decoy passphrase
`vault genkey` offers an optional second passphrase. In scripts, set
`$VAULT_DECOY_PASSPHRASE`. Each passphrase unlocks its own key, so a command
run with the decoy passphrase lists, adds and retrieves only decoy files. The
real files never appear.
```sh
vault genkey # set a passphrase, then a decoy passphrase
vault add plans.pdf # with the real passphrase
vault add shopping.txt # with the decoy passphrase
vault list # shows only what that passphrase stored
```
Two measures keep the real files deniable:
- The key file always holds two slots of identical size in random order. An
unused slot is random bytes, so the file does not show whether a decoy
exists or which slot is real.
- `init` fills every vault with 8 to 32 random entries that nobody can
decrypt. Anyone holding only the decoy passphrase sees unreadable entries in
every vault, so hidden real files look the same as filler.
A decoy can only be set when the key is created. Someone who watches the
vault over time can still see it grow when real files are added.
## Limits ## Limits
- One writer at a time. Concurrent writes to the same vault are not locked. - One writer at a time. Concurrent writes to the same vault are not locked.
+44 -11
View File
@@ -17,8 +17,11 @@ import (
"golang.org/x/crypto/chacha20poly1305" "golang.org/x/crypto/chacha20poly1305"
) )
// A key file seals a (suite ID, master seed) pair under an Argon2id-derived // A key file holds exactly two passphrase slots. Each slot seals a
// key. The private seed never touches disk unencrypted. // (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 ( const (
keyPEMType = "VAULT KEY" keyPEMType = "VAULT KEY"
@@ -68,18 +71,36 @@ func slotAEAD(passphrase string, salt []byte) (interface {
return chacha20poly1305.NewX(key) return chacha20poly1305.NewX(key)
} }
// writeKeyFile creates a new key file holding a fresh identity. // writeKeyFile creates a new key file. decoy may be empty, in which case the
func writeKeyFile(path, passphrase string) error { // 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() id, err := pqcrypt.GeneratePQIdentity()
if err != nil { if err != nil {
return err return err
} }
slot, err := sealSlot(id, passphrase) slots[idx], err = sealSlot(id, pass)
id.Close() id.Close()
if err != nil { if err != nil {
return err 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 { if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err return err
} }
@@ -99,7 +120,8 @@ func writeKeyFile(path, passphrase string) error {
return f.Close() 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) { func unlockKeyFile(path, passphrase string) (*pqcrypt.PQIdentity, error) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
@@ -108,19 +130,30 @@ func unlockKeyFile(path, passphrase string) (*pqcrypt.PQIdentity, error) {
block, rest := pem.Decode(data) block, rest := pem.Decode(data)
header := keyHeader() header := keyHeader()
if block == nil || block.Type != keyPEMType || len(bytes.TrimSpace(rest)) != 0 || 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") return nil, errors.New("not a vault key file")
} }
slot := block.Bytes[len(header):] var found *pqcrypt.PQIdentity
for i := range 2 {
slot := block.Bytes[len(header)+i*slotSize:][:slotSize]
aead, err := slotAEAD(passphrase, slot[:slotSaltSize]) aead, err := slotAEAD(passphrase, slot[:slotSaltSize])
if err != nil { if err != nil {
return nil, err return nil, err
} }
plain, err := aead.Open(nil, slot[slotSaltSize:][:chacha20poly1305.NonceSizeX], plain, err := aead.Open(nil, slot[slotSaltSize:][:chacha20poly1305.NonceSizeX],
slot[slotSaltSize+chacha20poly1305.NonceSizeX:], header) 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 { if err != nil {
return nil, err
}
}
if found == nil {
return nil, errors.New("incorrect passphrase") return nil, errors.New("incorrect passphrase")
} }
defer clear(plain) return found, nil
return pqcrypt.NewPQIdentityFromSeed(binary.BigEndian.Uint16(plain), plain[2:])
} }
+38 -3
View File
@@ -32,9 +32,19 @@ Options (any command):
--key PATH key file (default $VAULT_KEY, else ~/.vault/key) --key PATH key file (default $VAULT_KEY, else ~/.vault/key)
--vault DIR vault directory (default $VAULT_DIR, else ~/.vault/store) --vault DIR vault directory (default $VAULT_DIR, else ~/.vault/store)
The passphrase is read from the terminal, or from $VAULT_PASSPHRASE.` The passphrase is read from the terminal, or from $VAULT_PASSPHRASE.
const passphraseEnv = "VAULT_PASSPHRASE" Decoy passphrase: genkey offers a second, optional passphrase
($VAULT_DECOY_PASSPHRASE when scripted). It unlocks a separate key, so
every command run with it sees and changes only the decoy files, and the
real files stay invisible. The key file does not reveal whether a decoy
exists, and init fills each vault with unreadable filler, so hidden
files look like filler to anyone holding only the decoy passphrase.`
const (
passphraseEnv = "VAULT_PASSPHRASE"
decoyEnv = "VAULT_DECOY_PASSPHRASE"
)
func main() { func main() {
if len(os.Args) < 2 { if len(os.Args) < 2 {
@@ -64,7 +74,11 @@ func main() {
fail("%s already exists", *keyPath) fail("%s already exists", *keyPath)
} }
pass := newPassphrase("passphrase") pass := newPassphrase("passphrase")
check(writeKeyFile(*keyPath, pass)) decoy := decoyPassphrase()
if decoy == pass {
fail("decoy passphrase must differ from the passphrase")
}
check(writeKeyFile(*keyPath, pass, decoy))
fmt.Fprintf(os.Stderr, "key written to %s\n", *keyPath) fmt.Fprintf(os.Stderr, "key written to %s\n", *keyPath)
case "init": case "init":
want(0, 0) want(0, 0)
@@ -163,6 +177,27 @@ func newPassphrase(what string) string {
return p return p
} }
// decoyPassphrase asks for an optional second passphrase. Empty means none.
func decoyPassphrase() string {
if p, ok := os.LookupEnv(decoyEnv); ok {
return p
}
if _, ok := os.LookupEnv(passphraseEnv); ok {
return "" // non-interactive run without a decoy
}
p, err := term.ReadSecret("Enter decoy passphrase (empty for none):")
check(err)
if len(p) == 0 {
return ""
}
again, err := term.ReadSecret("Confirm decoy passphrase:")
check(err)
if string(p) != string(again) {
fail("decoy passphrases do not match")
}
return string(p)
}
// addPath adds a file, or every regular file under a directory, beneath dest. // addPath adds a file, or every regular file under a directory, beneath dest.
func addPath(s *store, cat map[string][]*record, src, dest string) error { func addPath(s *store, cat map[string][]*record, src, dest string) error {
info, err := os.Stat(src) info, err := os.Stat(src)
+49 -1
View File
@@ -44,6 +44,8 @@ const (
fileLimit = int64(1) << 40 fileLimit = int64(1) << 40
minPadding = int64(64 << 10) minPadding = int64(64 << 10)
idSize = 16 idSize = 16
fillerMin = 8
fillerMax = 32
) )
type record struct { type record struct {
@@ -60,6 +62,7 @@ type record struct {
type store struct { type store struct {
root string root string
id *pqcrypt.PQIdentity id *pqcrypt.PQIdentity
noSync bool // filler needs no per-file durability
} }
func initStore(root string) error { func initStore(root string) error {
@@ -71,9 +74,52 @@ func initStore(root string) error {
return err return err
} }
} }
if err := writeFiller(root); err != nil {
return err
}
return os.WriteFile(filepath.Join(root, markerName), []byte(markerContent), 0o600) return os.WriteFile(filepath.Join(root, markerName), []byte(markerContent), 0o600)
} }
// writeFiller stores a random number of record/object pairs sealed to a key
// that is thrown away. Every vault therefore holds entries nobody can open,
// so a file hidden from a decoy passphrase is indistinguishable from filler.
func writeFiller(root string) error {
throwaway, err := pqcrypt.GeneratePQIdentity()
if err != nil {
return err
}
defer throwaway.Close()
s := &store{root: root, id: throwaway, noSync: true}
var b [2]byte
if _, err := rand.Read(b[:1]); err != nil {
return err
}
for range fillerMin + int(b[0])%(fillerMax-fillerMin+1) {
if _, err := rand.Read(b[:]); err != nil {
return err
}
// Object buckets from 64 KiB to 4 MiB, weighted towards small ones.
objSize := minPadding << (min(b[0]%8, b[1]%8) % 7)
for _, part := range []struct {
dir string
size int64
}{{recordsDir, recordSize}, {objectsDir, objSize}} {
id, err := randomID()
if err != nil {
return err
}
err = s.writeCiphertext(part.dir, id, func(w io.Writer) error {
_, err := io.CopyN(w, rand.Reader, part.size)
return err
})
if err != nil {
return err
}
}
}
return nil
}
func openStore(root string, id *pqcrypt.PQIdentity) (*store, error) { func openStore(root string, id *pqcrypt.PQIdentity) (*store, error) {
marker, err := os.ReadFile(filepath.Join(root, markerName)) marker, err := os.ReadFile(filepath.Join(root, markerName))
if err != nil || string(marker) != markerContent { if err != nil || string(marker) != markerContent {
@@ -137,9 +183,11 @@ func (s *store) writeCiphertext(dir, id string, write func(io.Writer) error) err
if err := w.Close(); err != nil { if err := w.Close(); err != nil {
return err return err
} }
if !s.noSync {
if err := tmp.Sync(); err != nil { if err := tmp.Sync(); err != nil {
return err return err
} }
}
if err := tmp.Close(); err != nil { if err := tmp.Close(); err != nil {
return err return err
} }
@@ -147,7 +195,7 @@ func (s *store) writeCiphertext(dir, id string, write func(io.Writer) error) err
if err := os.Chtimes(tmp.Name(), epoch, epoch); err != nil { if err := os.Chtimes(tmp.Name(), epoch, epoch); err != nil {
return err return err
} }
if err := os.Rename(tmp.Name(), filepath.Join(dir, id)); err != nil { if err := os.Rename(tmp.Name(), filepath.Join(dir, id)); err != nil || s.noSync {
return err return err
} }
return syncDir(dir) return syncDir(dir)
+37 -2
View File
@@ -12,7 +12,7 @@ import (
func TestKeyFile(t *testing.T) { func TestKeyFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "key") path := filepath.Join(t.TempDir(), "key")
if err := writeKeyFile(path, "pass"); err != nil { if err := writeKeyFile(path, "pass", ""); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := unlockKeyFile(path, "wrong"); err == nil { if _, err := unlockKeyFile(path, "wrong"); err == nil {
@@ -39,7 +39,7 @@ func newTestStore(t *testing.T, keyPath, pass string) *store {
func TestStoreLifecycle(t *testing.T) { func TestStoreLifecycle(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
keyPath := filepath.Join(dir, "key") keyPath := filepath.Join(dir, "key")
if err := writeKeyFile(keyPath, "pass"); err != nil { if err := writeKeyFile(keyPath, "pass", ""); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := initStore(filepath.Join(dir, "store")); err != nil { if err := initStore(filepath.Join(dir, "store")); err != nil {
@@ -94,3 +94,38 @@ func TestStoreLifecycle(t *testing.T) {
t.Fatalf("after delete: %v", cat) t.Fatalf("after delete: %v", cat)
} }
} }
func TestDecoy(t *testing.T) {
dir := t.TempDir()
keyPath := filepath.Join(dir, "key")
if err := writeKeyFile(keyPath, "real", "decoy"); err != nil {
t.Fatal(err)
}
if err := initStore(filepath.Join(dir, "store")); err != nil {
t.Fatal(err)
}
src := filepath.Join(dir, "in")
os.WriteFile(src, []byte("data"), 0o600)
real, decoy := newTestStore(t, keyPath, "real"), newTestStore(t, keyPath, "decoy")
for s, name := range map[*store]string{real: "secret", decoy: "shopping"} {
cat, err := s.catalogue()
if err != nil || len(cat) != 0 {
t.Fatalf("fresh vault catalogue = %v, %v", cat, err)
}
if _, err := s.add(cat, src, name); err != nil {
t.Fatal(err)
}
}
for s, name := range map[*store]string{real: "secret", decoy: "shopping"} {
if cat, _ := s.catalogue(); len(cat) != 1 || cat[name] == nil {
t.Fatalf("catalogue = %v, want only %s", cat, name)
}
}
if _, err := unlockKeyFile(keyPath, "neither"); err == nil {
t.Fatal("wrong passphrase unlocked the key")
}
entries, _ := os.ReadDir(filepath.Join(dir, "store", recordsDir))
if len(entries) < fillerMin+2 {
t.Fatalf("only %d records; filler missing", len(entries))
}
}