diff --git a/CLAUDE.md b/CLAUDE.md index 4dd38b7..b2e1e7d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. - `main.go` - CLI: `genkey`, `init`, `add`, `list`, `get`, `mv`, `rm`. -- `keyfile.go` - passphrase-protected key file (Argon2id + - XChaCha20-Poly1305 sealing a suite ID and 32-byte master seed). +- `keyfile.go` - key file with two passphrase slots (Argon2id + + 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 records, `objects/` holds padded encrypted file bodies. Records a key cannot 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. - An object's plaintext starts with its own ID, binding it to its record. - `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. - Diagnostics go to standard error, lower-case, no trailing full stops. diff --git a/README.md b/README.md index 1842cd3..167f8cb 100644 --- a/README.md +++ b/README.md @@ -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 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 - One writer at a time. Concurrent writes to the same vault are not locked. diff --git a/keyfile.go b/keyfile.go index f2eabed..74b4da2 100644 --- a/keyfile.go +++ b/keyfile.go @@ -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 } diff --git a/main.go b/main.go index c3e2be4..3ef7fa9 100644 --- a/main.go +++ b/main.go @@ -32,9 +32,19 @@ Options (any command): --key PATH key file (default $VAULT_KEY, else ~/.vault/key) --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() { if len(os.Args) < 2 { @@ -64,7 +74,11 @@ func main() { fail("%s already exists", *keyPath) } 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) case "init": want(0, 0) @@ -163,6 +177,27 @@ func newPassphrase(what string) string { 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. func addPath(s *store, cat map[string][]*record, src, dest string) error { info, err := os.Stat(src) diff --git a/store.go b/store.go index cd4ec67..6c255ab 100644 --- a/store.go +++ b/store.go @@ -44,6 +44,8 @@ const ( fileLimit = int64(1) << 40 minPadding = int64(64 << 10) idSize = 16 + fillerMin = 8 + fillerMax = 32 ) type record struct { @@ -58,8 +60,9 @@ type record struct { } type store struct { - root string - id *pqcrypt.PQIdentity + root string + id *pqcrypt.PQIdentity + noSync bool // filler needs no per-file durability } func initStore(root string) error { @@ -71,9 +74,52 @@ func initStore(root string) error { return err } } + if err := writeFiller(root); err != nil { + return err + } 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) { marker, err := os.ReadFile(filepath.Join(root, markerName)) if err != nil || string(marker) != markerContent { @@ -137,8 +183,10 @@ func (s *store) writeCiphertext(dir, id string, write func(io.Writer) error) err if err := w.Close(); err != nil { return err } - if err := tmp.Sync(); err != nil { - return err + if !s.noSync { + if err := tmp.Sync(); err != nil { + return err + } } if err := tmp.Close(); err != nil { 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 { 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 syncDir(dir) diff --git a/vault_test.go b/vault_test.go index 6ed5794..cd9a9cc 100644 --- a/vault_test.go +++ b/vault_test.go @@ -12,7 +12,7 @@ import ( func TestKeyFile(t *testing.T) { path := filepath.Join(t.TempDir(), "key") - if err := writeKeyFile(path, "pass"); err != nil { + if err := writeKeyFile(path, "pass", ""); err != nil { t.Fatal(err) } 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) { dir := t.TempDir() keyPath := filepath.Join(dir, "key") - if err := writeKeyFile(keyPath, "pass"); err != nil { + if err := writeKeyFile(keyPath, "pass", ""); err != nil { t.Fatal(err) } if err := initStore(filepath.Join(dir, "store")); err != nil { @@ -94,3 +94,38 @@ func TestStoreLifecycle(t *testing.T) { 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)) + } +}