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
+38 -3
View File
@@ -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)