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:
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+55
-22
@@ -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.
|
||||||
id, err := pqcrypt.GeneratePQIdentity()
|
func writeKeyFile(path, passphrase, decoy string) error {
|
||||||
if err != nil {
|
var slots [2][]byte
|
||||||
|
var b [1]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
slot, err := sealSlot(id, passphrase)
|
realSlot := int(b[0] & 1)
|
||||||
id.Close()
|
for i, pass := range [2]string{passphrase, decoy} {
|
||||||
if err != nil {
|
idx := realSlot ^ i
|
||||||
return err
|
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 {
|
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
|
||||||
aead, err := slotAEAD(passphrase, slot[:slotSaltSize])
|
for i := range 2 {
|
||||||
if err != nil {
|
slot := block.Bytes[len(header)+i*slotSize:][:slotSize]
|
||||||
return nil, err
|
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],
|
if found == nil {
|
||||||
slot[slotSaltSize+chacha20poly1305.NonceSizeX:], header)
|
|
||||||
if err != 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:])
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -58,8 +60,9 @@ 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,8 +183,10 @@ 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 err := tmp.Sync(); err != nil {
|
if !s.noSync {
|
||||||
return err
|
if err := tmp.Sync(); err != nil {
|
||||||
|
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
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user