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
+53 -5
View File
@@ -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)