diff --git a/Changelog.md b/Changelog.md index 983591b..1746ffd 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,5 +1,20 @@ > History prior to the ZeeCrypt fork belongs to the original [Picocrypt](https://github.com/Picocrypt/Picocrypt) project. New entries for ZeeCrypt will be added above this note going forward. +# v1.52 (Unreleased) +
.incomplete file. Previously it was left on disk with unauthenticated plaintext, and the file chosen for overwriting was deleted instead..tmp file (which has a readable header) next to the volume..incomplete chunks.*.tmp) is no longer overwritten or deleted; you're asked to remove it instead. Cleanup only ever deletes files the app created itself.[, * or ? deleted the volume and left its chunks unusable.go test . (with `CGO_ENABLED=1`) runs headless end-to-end tests that drive the same code paths as the UI: round trips across every option, tampering/corruption and Reed-Solomon repair, cleanup of temporary files on failure, and compatibility with the v1.51 volumes in `testdata/`. Each encryption or decryption runs Argon2id with 1 GiB of memory, so the suite takes about a minute and needs a few GiB of free RAM.
+
# Updating the app icon
The icon is embedded automatically via `rsrc_windows_386.syso`/`rsrc_windows_amd64.syso` in this directory, which `go build` links in without any extra flags. If you change `images/lock.ico`, regenerate these files:
```
diff --git a/src/ZeeCrypt.go b/src/ZeeCrypt.go
index 1d17f08..24af0b1 100644
--- a/src/ZeeCrypt.go
+++ b/src/ZeeCrypt.go
@@ -2,7 +2,7 @@ package main
/*
-ZeeCrypt v1.51 (fork of Picocrypt by Evan Su)
+ZeeCrypt v1.52 (fork of Picocrypt by Evan Su)
Copyright (c) Evan Su
Released under GPL-3.0-only
https://github.com/TheZeekA/ZeeCrypt
@@ -66,7 +66,7 @@ var TRANSPARENT = color.RGBA{0x00, 0x00, 0x00, 0x00}
// Generic variables
var window *giu.MasterWindow
-var version = "v1.51"
+var version = "v1.52"
var dpi float32
var mode string
var working bool
@@ -168,6 +168,12 @@ var rs64, rsErr6 = infectious.NewFEC(64, 192)
var rs128, rsErr7 = infectious.NewFEC(128, 136)
var fastDecode bool
+// Temporary files created by work(), so failure paths only ever delete
+// files that work() itself made (see removeTempInput)
+var tempZipFile string // zip of the selected items, when encrypting
+var recombinedFile string // split volume recombined into one file
+var unwrappedFile string // deniable volume with the deniability layer removed
+
// Compression variables and passthrough
var compressDone int64
var compressTotal int64
@@ -223,6 +229,12 @@ func (ezr *encryptedZipReader) Read(data []byte) (n int, err error) {
}
func onClickStartButton() {
+ // Enter calls this even while a modal is open; never start a second
+ // operation, or one that a pending self-update would kill
+ if working || showProgress || showOverwrite || updateApplying {
+ return
+ }
+
// Start button should be disabled if these conditions are true; don't do anything if so
if (len(keyfiles) == 0 && password == "") || (mode == "encrypt" && password != cpassword) {
return
@@ -271,7 +283,7 @@ func onClickStartButton() {
giu.Update()
if !recursively {
go func() {
- work()
+ work(false)
working = false
showProgress = false
giu.Update()
@@ -314,8 +326,9 @@ func onClickStartButton() {
splitSize = oldSplitSize
splitSelected = oldSplitSelected
delete = oldDelete
+ fastDecode = true // a repair pass on the previous file clears it
- work()
+ work(false)
if !working {
resetUI()
cancel(nil, nil)
@@ -432,7 +445,7 @@ func draw() {
modalId++
giu.Update()
go func() {
- work()
+ work(false)
working = false
showProgress = false
giu.Update()
@@ -1261,7 +1274,9 @@ func onDrop(names []string) {
}()
}
-func work() {
+// 'prepared' is true on the Reed-Solomon repair pass, where 'inputFile' is
+// already the recombined and/or unwrapped volume from the first pass
+func work(prepared bool) {
popupStatus = "Starting..."
mainStatus = "Working..."
mainStatusColor = NEUTRAL
@@ -1269,6 +1284,10 @@ func work() {
padded := false
giu.Update()
+ if !prepared {
+ tempZipFile, recombinedFile, unwrappedFile = "", "", ""
+ }
+
// Cryptography values
var salt []byte // Argon2 salt, 16 bytes
var hkdfSalt []byte // HKDF-SHA3 salt, 32 bytes
@@ -1323,11 +1342,17 @@ func work() {
// Open a temporary .zip for writing
inputFile = strings.TrimSuffix(outputFile, ".pcv") + ".tmp"
+ if _, err := os.Stat(inputFile); err == nil {
+ mainStatus = "Please remove " + filepath.Base(inputFile)
+ mainStatusColor = RED
+ return
+ }
file, err := os.Create(inputFile)
if err != nil { // Make sure file is writable
accessDenied("Write")
return
}
+ tempZipFile = inputFile
// Add each file to the .zip
tempZip := encryptedZipWriter{
@@ -1422,7 +1447,7 @@ func work() {
}
// Recombine a split file if necessary
- if recombine {
+ if recombine && !prepared {
totalFiles := 0
totalBytes := int64(0)
done := 0
@@ -1501,12 +1526,13 @@ func work() {
if err := fout.Close(); err != nil {
panic(err)
}
+ recombinedFile = outputFile + ".pcv"
inputFileOld = inputFile
inputFile = outputFile + ".pcv"
}
// Input volume has plausible deniability
- if mode == "decrypt" && deniability {
+ if mode == "decrypt" && deniability && !prepared {
popupStatus = "Removing deniability protection..."
progressInfo = ""
progress = 0
@@ -1531,19 +1557,30 @@ func work() {
inputFile = strings.TrimSuffix(inputFile, ".tmp")
}
inputFile += ".tmp"
+ if _, err := os.Stat(inputFile); err == nil {
+ fin.Close()
+ mainStatus = "Please remove " + filepath.Base(inputFile)
+ mainStatusColor = RED
+ inputFile = strings.TrimSuffix(inputFile, ".tmp")
+ removeTempInput()
+ return
+ }
fout, err := os.Create(inputFile)
if err != nil {
panic(err)
}
+ unwrappedFile = inputFile
// Get the Argon2 salt and XChaCha20 nonce from input volume
salt := make([]byte, 16)
nonce := make([]byte, 24)
if n, err := fin.Read(salt); err != nil || n != 16 {
- panic(errors.New("failed to read 16 bytes from file"))
+ broken(fin, fout, "The file is too short to be a volume", true)
+ return
}
if n, err := fin.Read(nonce); err != nil || n != 24 {
- panic(errors.New("failed to read 24 bytes from file"))
+ broken(fin, fout, "The file is too short to be a volume", true)
+ return
}
// Generate key and XChaCha20
@@ -1565,9 +1602,9 @@ func work() {
dst := make([]byte, len(src))
chacha.XORKeyStream(dst, src)
if n, err := fout.Write(dst); err != nil || n != len(dst) {
- fout.Close()
- os.Remove(fout.Name())
- panic(errors.New("failed to write dst"))
+ insufficientSpace(fin, fout)
+ removeTempInput()
+ return
}
// Update stats
@@ -1605,19 +1642,15 @@ func work() {
}
tmp := make([]byte, 15)
if n, err := fin.Read(tmp); err != nil || n != 15 {
- panic(errors.New("failed to read 15 bytes from file"))
+ broken(fin, nil, "Password is incorrect or the file is not a volume", true)
+ return
}
if err := fin.Close(); err != nil {
panic(err)
}
tmp, err = rsDecode(rs5, tmp)
if valid, _ := regexp.Match(`^v1\.\d{2}`, tmp); err != nil || !valid {
- os.Remove(inputFile)
- inputFile = strings.TrimSuffix(inputFile, ".tmp")
broken(nil, nil, "Password is incorrect or the file is not a volume", true)
- if recombine {
- inputFile = inputFileOld
- }
return
}
}
@@ -1630,6 +1663,7 @@ func work() {
// Subtract the header size from the total size if decrypting
stat, err := os.Stat(inputFile)
if err != nil {
+ removeTempInput()
resetUI()
accessDenied("Read")
return
@@ -1642,6 +1676,7 @@ func work() {
// Open input file in read-only mode
fin, err := os.Open(inputFile)
if err != nil {
+ removeTempInput()
resetUI()
accessDenied("Read")
return
@@ -1662,9 +1697,7 @@ func work() {
_, err = os.Stat(outputFile)
if split && err == nil { // File already exists
fin.Close()
- if len(allFiles) > 1 || len(onlyFolders) > 0 || compress {
- os.Remove(inputFile)
- }
+ removeTempInput()
mainStatus = "Please remove " + filepath.Base(outputFile)
mainStatusColor = RED
return
@@ -1674,9 +1707,7 @@ func work() {
fout, err = os.Create(outputFile + ".incomplete")
if err != nil {
fin.Close()
- if len(allFiles) > 1 || len(onlyFolders) > 0 || compress {
- os.Remove(inputFile)
- }
+ removeTempInput()
accessDenied("Write")
return
}
@@ -1693,9 +1724,7 @@ func work() {
if len(comments) > 99999 {
fin.Close()
fout.Close()
- if len(allFiles) > 1 || len(onlyFolders) > 0 || compress {
- os.Remove(inputFile)
- }
+ removeTempInput()
os.Remove(fout.Name())
mainStatus = "Comment exceeds the maximum length of 99,999 characters"
mainStatusColor = RED
@@ -1773,9 +1802,7 @@ func work() {
for _, err := range errs {
if err != nil {
insufficientSpace(fin, fout)
- if len(allFiles) > 1 || len(onlyFolders) > 0 || compress {
- os.Remove(inputFile)
- }
+ removeTempInput()
os.Remove(fout.Name())
return
}
@@ -1856,6 +1883,12 @@ func work() {
}
}
+ // A deniable volume only reveals that it needs keyfiles once unwrapped
+ if mode == "decrypt" && keyfile && len(keyfiles) == 0 {
+ broken(fin, nil, "This volume requires keyfiles, please select them", true)
+ return
+ }
+
popupStatus = "Deriving key..."
giu.Update()
@@ -1990,25 +2023,41 @@ func work() {
popupStatus = "Calculating values..."
giu.Update()
+ var passwordKey []byte // pre-keyfile key, only to recognize v1.50/1.51 volumes
+ if len(keyfiles) > 0 || keyfile {
+ // Prevent an even number of duplicate keyfiles
+ if bytes.Equal(keyfileKey, make([]byte, 32)) {
+ mainStatus = "Duplicate keyfiles detected"
+ mainStatusColor = RED
+ fin.Close()
+ removeTempInput()
+ if fout != nil {
+ fout.Close()
+ os.Remove(fout.Name())
+ }
+ return
+ }
+
+ // XOR the encryption key with the keyfile key
+ tmp := key
+ passwordKey = tmp
+ key = make([]byte, 32)
+ for i := range key {
+ key[i] = tmp[i] ^ keyfileKey[i]
+ }
+ }
+
// Authenticate the header's decryption parameters (flags, salts, IVs) with
// an HMAC keyed by a subkey independent from the data-encryption and
// data-MAC keys. A successful comparison proves both a correct password
- // and an untampered header, replacing the old bare hash of the key.
+ // and keyfiles, and an untampered header, replacing the old bare hash of
+ // the key. The subkey is derived after the keyfile key is mixed in (v1.52):
+ // otherwise a keyfile-only volume's header MAC would depend only on an
+ // empty password, letting anyone forge it.
// The comment field is intentionally excluded (see the UI tooltip warning
// that comments aren't tamper-protected): it isn't re-derived from disk
// during decryption, so including it here could cause spurious failures.
- headerSubkey := make([]byte, 32)
- headerHKDF := hkdf.New(sha3.New256, key, hkdfSalt, []byte("zeecrypt-header-mac"))
- if n, err := headerHKDF.Read(headerSubkey); err != nil || n != 32 {
- panic(errors.New("fatal hkdf.Read error"))
- }
- headerMACFunc := hmac.New(sha3.New512, headerSubkey)
- for _, part := range [][]byte{flags, salt, hkdfSalt, serpentIV, nonce} {
- if _, err := headerMACFunc.Write(part); err != nil {
- panic(err)
- }
- }
- headerMAC = headerMACFunc.Sum(nil)
+ headerMAC = computeHeaderMAC(key, hkdfSalt, flags, salt, hkdfSalt, serpentIV, nonce)
// Validate the password and/or keyfiles
if mode == "decrypt" {
@@ -2024,24 +2073,23 @@ func work() {
if keep {
kept = true
} else {
- if !keyCorrect {
- mainStatus = "The provided password is incorrect, or the file has been tampered with"
- } else {
+ // Wrong keyfiles also fail the header MAC, so check them first
+ if (keyfile || len(keyfiles) > 0) && !keyfileCorrect {
if keyfileOrdered {
mainStatus = "Incorrect keyfiles or ordering"
} else {
mainStatus = "Incorrect keyfiles"
}
- if deniability {
- fin.Close()
- os.Remove(inputFile)
- inputFile = strings.TrimSuffix(inputFile, ".tmp")
- }
+ } else if passwordKey != nil && subtle.ConstantTimeCompare(headerMACRef,
+ computeHeaderMAC(passwordKey, hkdfSalt, flags, salt, hkdfSalt, serpentIV, nonce)) == 1 {
+ // v1.52 changed the header MAC for keyfile volumes. The old
+ // MAC is only recognized for this message, never accepted:
+ // without a password anyone can forge it.
+ mainStatus = "This keyfile volume is from v1.50/1.51, use ZeeCrypt v1.51 to decrypt it"
+ } else {
+ mainStatus = "The provided password is incorrect, or the file has been tampered with"
}
broken(fin, nil, mainStatus, true)
- if recombine {
- inputFile = inputFileOld
- }
return
}
}
@@ -2050,36 +2098,12 @@ func work() {
fout, err = os.Create(outputFile + ".incomplete")
if err != nil {
fin.Close()
- if recombine {
- os.Remove(inputFile)
- }
+ removeTempInput()
accessDenied("Write")
return
}
}
- if len(keyfiles) > 0 || keyfile {
- // Prevent an even number of duplicate keyfiles
- if bytes.Equal(keyfileKey, make([]byte, 32)) {
- mainStatus = "Duplicate keyfiles detected"
- mainStatusColor = RED
- fin.Close()
- if len(allFiles) > 1 || len(onlyFolders) > 0 || compress {
- os.Remove(inputFile)
- }
- fout.Close()
- os.Remove(fout.Name())
- return
- }
-
- // XOR the encryption key with the keyfile key
- tmp := key
- key = make([]byte, 32)
- for i := range key {
- key[i] = tmp[i] ^ keyfileKey[i]
- }
- }
-
done, counter := 0, 0
chacha, err := chacha20.NewUnauthenticatedCipher(key, nonce)
if err != nil {
@@ -2123,9 +2147,7 @@ func work() {
for {
if !working {
cancel(fin, fout)
- if recombine || len(allFiles) > 1 || len(onlyFolders) > 0 || compress {
- os.Remove(inputFile)
- }
+ removeTempInput()
os.Remove(fout.Name())
return
}
@@ -2211,6 +2233,15 @@ func work() {
giu.Update()
}
}
+ } else if len(dst) < 136 {
+ // A trailing fragment smaller than one encoded chunk can only
+ // come from a truncated or appended-to file
+ if keep {
+ kept = true
+ } else {
+ broken(fin, fout, "The input file is irrecoverably damaged", false)
+ return
+ }
} else {
// Decode the full chunks
chunks := len(dst)/136 - 1
@@ -2264,9 +2295,7 @@ func work() {
_, err = fout.Write(dst)
if err != nil {
insufficientSpace(fin, fout)
- if recombine || len(allFiles) > 1 || len(onlyFolders) > 0 || compress {
- os.Remove(inputFile)
- }
+ removeTempInput()
os.Remove(fout.Name())
return
}
@@ -2345,7 +2374,8 @@ func work() {
fastDecode = false
fin.Close()
fout.Close()
- work()
+ os.Remove(fout.Name()) // unverified fast-pass output
+ work(true)
return
}
@@ -2528,7 +2558,17 @@ func work() {
startTime := time.Now()
for i := range chunks {
// Make the chunk
- fout, _ := os.Create(fmt.Sprintf("%s.%d.incomplete", outputFile, i))
+ fout, err := os.Create(fmt.Sprintf("%s.%d.incomplete", outputFile, i))
+ if err != nil {
+ fin.Close()
+ removeTempInput()
+ os.Remove(outputFile)
+ for _, j := range splitted { // Remove existing chunks
+ os.Remove(j)
+ }
+ accessDenied("Write")
+ return
+ }
done := 0
// Copy data into the chunk
@@ -2544,14 +2584,12 @@ func work() {
}
if !working {
cancel(fin, fout)
- if len(allFiles) > 1 || len(onlyFolders) > 0 || compress {
- os.Remove(inputFile)
- }
+ removeTempInput()
os.Remove(outputFile)
for _, j := range splitted { // Remove existing chunks
os.Remove(j)
}
- os.Remove(fmt.Sprintf("%s.%d", outputFile, i))
+ os.Remove(fmt.Sprintf("%s.%d.incomplete", outputFile, i))
return
}
@@ -2559,14 +2597,12 @@ func work() {
_, err = fout.Write(data)
if err != nil {
insufficientSpace(fin, fout)
- if len(allFiles) > 1 || len(onlyFolders) > 0 || compress {
- os.Remove(inputFile)
- }
+ removeTempInput()
os.Remove(outputFile)
for _, j := range splitted { // Remove existing chunks
os.Remove(j)
}
- os.Remove(fmt.Sprintf("%s.%d", outputFile, i))
+ os.Remove(fmt.Sprintf("%s.%d.incomplete", outputFile, i))
return
}
done += read
@@ -2589,7 +2625,7 @@ func work() {
if finishedFiles == chunks {
finishedFiles--
}
- splitted = append(splitted, fmt.Sprintf("%s.%d", outputFile, i))
+ splitted = append(splitted, fmt.Sprintf("%s.%d.incomplete", outputFile, i))
progressInfo = fmt.Sprintf("%d/%d", finishedFiles+1, chunks)
giu.Update()
}
@@ -2600,11 +2636,7 @@ func work() {
if err := os.Remove(outputFile); err != nil {
panic(err)
}
- names, err = filepath.Glob(outputFile + ".*.incomplete")
- if err != nil {
- panic(err)
- }
- for _, i := range names {
+ for _, i := range splitted {
if err := os.Rename(i, strings.TrimSuffix(i, ".incomplete")); err != nil {
panic(err)
}
@@ -2724,12 +2756,33 @@ func broken(fin *os.File, fout *os.File, message string, keepOutput bool) {
mainStatus = message
mainStatusColor = RED
- // Clean up files since decryption failed
- if recombine {
- os.Remove(inputFile)
- }
+ // Clean up files since decryption failed. The output is only ever
+ // written to the ".incomplete" file; 'outputFile' itself may be an
+ // existing file the user chose to overwrite, so it's left alone.
+ removeTempInput()
if !keepOutput {
- os.Remove(outputFile)
+ os.Remove(outputFile + ".incomplete")
+ }
+}
+
+// Remove the temporary input that work() prepared: the zip of the selected
+// items when encrypting, or the recombined and/or unwrapped deniable volume
+// when decrypting. 'inputFile' is restored to what the user selected so the
+// operation can be retried (e.g. with the correct password).
+func removeTempInput() {
+ if tempZipFile != "" {
+ os.Remove(tempZipFile)
+ tempZipFile = ""
+ }
+ if unwrappedFile != "" {
+ os.Remove(unwrappedFile)
+ unwrappedFile = ""
+ inputFile = strings.TrimSuffix(inputFile, ".tmp")
+ }
+ if recombinedFile != "" {
+ os.Remove(recombinedFile)
+ recombinedFile = ""
+ inputFile = inputFileOld
}
}
@@ -2801,6 +2854,23 @@ func resetUI() {
giu.Update()
}
+// HMAC-SHA3-512 over the header's decryption parameters, keyed by an HKDF-SHA3
+// subkey of 'key' that's independent from the data-encryption and MAC keys
+func computeHeaderMAC(key []byte, hkdfSalt []byte, parts ...[]byte) []byte {
+ subkey := make([]byte, 32)
+ r := hkdf.New(sha3.New256, key, hkdfSalt, []byte("zeecrypt-header-mac"))
+ if n, err := r.Read(subkey); err != nil || n != 32 {
+ panic(errors.New("fatal hkdf.Read error"))
+ }
+ mac := hmac.New(sha3.New512, subkey)
+ for _, part := range parts {
+ if _, err := mac.Write(part); err != nil {
+ panic(err)
+ }
+ }
+ return mac.Sum(nil)
+}
+
// Reed-Solomon encoder
func rsEncode(rs *infectious.FEC, data []byte) []byte {
res := make([]byte, rs.Total())
@@ -2843,9 +2913,13 @@ func pad(data []byte) []byte {
return append(data, padding...)
}
-// PKCS#7 unpad
+// PKCS#7 unpad; invalid padding (a corrupted final byte) is left in place
+// so the MAC check fails and triggers repair instead of crashing here
func unpad(data []byte) []byte {
padLen := int(data[127])
+ if padLen < 1 || padLen > 128 {
+ return data
+ }
return data[:128-padLen]
}
@@ -3196,8 +3270,14 @@ func checkForUpdates() {
updateDownloadURL = downloadURL
updateChecksum = m[1]
updateAvailable = true
- showUpdate = true
- modalId++
+
+ // Don't open over the progress (or any other) modal: that would
+ // allow "Update Now" to exit mid-operation. The version label now
+ // reads "Update available" and can be clicked once things are idle.
+ if !working && !showProgress && !showOverwrite && !showKeyfile && !showPassgen {
+ showUpdate = true
+ modalId++
+ }
}()
}
@@ -3207,7 +3287,8 @@ func checkForUpdates() {
// current exe is renamed aside, the verified new one takes its place, and a
// new process is spawned before this one exits.
func applyUpdate() {
- if updateApplying {
+ // Never swap the exe and exit while an operation is running
+ if updateApplying || working || showProgress {
return
}
updateApplying = true
@@ -3262,6 +3343,13 @@ func applyUpdate() {
return
}
+ // An operation may have started during the download
+ if working || showProgress {
+ os.Remove(tmpPath)
+ updateError = "Finish the current operation, then update again"
+ return
+ }
+
currentExe, err := os.Executable()
if err != nil {
os.Remove(tmpPath)
diff --git a/src/forge_test.go b/src/forge_test.go
new file mode 100644
index 0000000..67ad918
--- /dev/null
+++ b/src/forge_test.go
@@ -0,0 +1,46 @@
+package main
+
+import (
+ "crypto/hmac"
+ "os"
+ "testing"
+
+ "golang.org/x/crypto/argon2"
+ "golang.org/x/crypto/hkdf"
+ "golang.org/x/crypto/sha3"
+)
+
+// forgeKeyfileOnlyHeader rewrites the nonce of a keyfile-only (empty password),
+// comment-less, non-paranoid volume and recomputes the header MAC the way an
+// attacker without the keyfiles could: from argon2id("", salt) alone.
+func forgeKeyfileOnlyHeader(t *testing.T, vol string) {
+ t.Helper()
+ b, err := os.ReadFile(vol)
+ if err != nil {
+ t.Fatal(err)
+ }
+ must := func(d []byte, err error) []byte {
+ if err != nil {
+ t.Fatal(err)
+ }
+ return d
+ }
+ flags := must(rsDecode(rs5, b[30:45]))
+ salt := must(rsDecode(rs16, b[45:93]))
+ hkdfSalt := must(rsDecode(rs32, b[93:189]))
+ serpentIV := must(rsDecode(rs16, b[189:237]))
+ nonce := randBytes(t, 24)
+ copy(b[237:309], rsEncode(rs24, nonce))
+
+ key := argon2.IDKey([]byte(""), salt, 4, 1<<20, 4, 32)
+ sub := make([]byte, 32)
+ if _, err := hkdf.New(sha3.New256, key, hkdfSalt, []byte("zeecrypt-header-mac")).Read(sub); err != nil {
+ t.Fatal(err)
+ }
+ m := hmac.New(sha3.New512, sub)
+ for _, p := range [][]byte{flags, salt, hkdfSalt, serpentIV, nonce} {
+ m.Write(p)
+ }
+ copy(b[309:501], rsEncode(rs64, m.Sum(nil)))
+ writeFile(t, vol, b)
+}
diff --git a/src/helpers_test.go b/src/helpers_test.go
new file mode 100644
index 0000000..d2d51be
--- /dev/null
+++ b/src/helpers_test.go
@@ -0,0 +1,147 @@
+package main
+
+// Headless test harness: drives onDrop()/work() the way the UI does, without a
+// window. Tests share the app's globals, so they must not run in parallel.
+// Each encrypt/decrypt runs Argon2id with 1 GiB of memory; the suite takes ~1 min.
+
+import (
+ "bytes"
+ "crypto/rand"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/Picocrypt/imgui-go"
+)
+
+func TestMain(m *testing.M) {
+ imgui.CreateContext(nil) // resetUI() calls imgui.ClearActiveID()
+ os.Exit(m.Run())
+}
+
+func setup() {
+ resetUI()
+ working = false
+ fastDecode = true
+ showProgress = false
+}
+
+func waitScan() {
+ for scanning {
+ time.Sleep(5 * time.Millisecond)
+ }
+}
+
+func randBytes(t *testing.T, n int) []byte {
+ b := make([]byte, n)
+ if _, err := rand.Read(b); err != nil {
+ t.Fatal(err)
+ }
+ return b
+}
+
+func writeFile(t *testing.T, path string, data []byte) {
+ if err := os.WriteFile(path, data, 0600); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// encrypt drops 'paths', applies options and runs work(). Returns the output path and status.
+func encrypt(t *testing.T, paths []string, pw string, kf []string, opt func()) (string, string) {
+ t.Helper()
+ setup()
+ onDrop(paths)
+ waitScan()
+ password, cpassword = pw, pw
+ keyfiles = kf
+ if opt != nil {
+ opt()
+ }
+ out := outputFile
+ work(false)
+ working = false
+ return out, mainStatus
+}
+
+// decrypt drops 'vol', applies options and runs work(). Returns the output path and status.
+func decrypt(t *testing.T, vol string, pw string, kf []string, opt func()) (string, string) {
+ t.Helper()
+ setup()
+ onDrop([]string{vol})
+ waitScan()
+ password = pw
+ keyfiles = kf
+ if opt != nil {
+ opt()
+ }
+ out := outputFile
+ work(false)
+ working = false
+ return out, mainStatus
+}
+
+func mustStatus(t *testing.T, got, want string) {
+ t.Helper()
+ if !strings.Contains(got, want) {
+ t.Fatalf("status = %q, want it to contain %q", got, want)
+ }
+}
+
+func mustContent(t *testing.T, path string, want []byte) {
+ t.Helper()
+ got, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read %s: %v", path, err)
+ }
+ if !bytes.Equal(got, want) {
+ t.Fatalf("%s: content mismatch (%d vs %d bytes)", path, len(got), len(want))
+ }
+}
+
+// listDir returns the base names in dir, for asserting no leftovers.
+func listDir(t *testing.T, dir string) []string {
+ t.Helper()
+ ents, err := os.ReadDir(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var names []string
+ for _, e := range ents {
+ names = append(names, e.Name())
+ }
+ return names
+}
+
+func mustOnly(t *testing.T, dir string, want ...string) {
+ t.Helper()
+ got := listDir(t, dir)
+ set := map[string]bool{}
+ for _, w := range want {
+ set[w] = true
+ }
+ for _, g := range got {
+ if !set[g] {
+ t.Fatalf("unexpected leftover %q in %v (want only %v)", g, got, want)
+ }
+ }
+ for _, w := range want {
+ if _, err := os.Stat(filepath.Join(dir, w)); err != nil {
+ t.Fatalf("expected %q to exist; dir = %v", w, got)
+ }
+ }
+}
+
+func flipByte(t *testing.T, path string, off int64) {
+ t.Helper()
+ b, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if off < 0 {
+ off += int64(len(b))
+ }
+ b[off] ^= 0x5a
+ writeFile(t, path, b)
+}
diff --git a/src/testdata/.gitattributes b/src/testdata/.gitattributes
new file mode 100644
index 0000000..fa1385d
--- /dev/null
+++ b/src/testdata/.gitattributes
@@ -0,0 +1 @@
+* -text
diff --git a/src/testdata/legacy-v1.51/README.md b/src/testdata/legacy-v1.51/README.md
new file mode 100644
index 0000000..bbdd6fc
--- /dev/null
+++ b/src/testdata/legacy-v1.51/README.md
@@ -0,0 +1,6 @@
+Volumes encrypted by ZeeCrypt v1.51, used by `TestLegacyVolumes`:
+
+- `pw.txt.pcv`: password `legacy-pw`
+- `kf.txt.pcv`: keyfile `kf.key` only, no password
+- `pkf.txt.pcv`: password `legacy-pw` and keyfile `kf.key`
+- `plain.orig`: the plaintext of all three
diff --git a/src/testdata/legacy-v1.51/kf.key b/src/testdata/legacy-v1.51/kf.key
new file mode 100644
index 0000000..0617fcb
--- /dev/null
+++ b/src/testdata/legacy-v1.51/kf.key
@@ -0,0 +1 @@
+legacy keyfile contents
\ No newline at end of file
diff --git a/src/testdata/legacy-v1.51/kf.txt.pcv b/src/testdata/legacy-v1.51/kf.txt.pcv
new file mode 100644
index 0000000..182f239
Binary files /dev/null and b/src/testdata/legacy-v1.51/kf.txt.pcv differ
diff --git a/src/testdata/legacy-v1.51/pkf.txt.pcv b/src/testdata/legacy-v1.51/pkf.txt.pcv
new file mode 100644
index 0000000..a93ad7d
Binary files /dev/null and b/src/testdata/legacy-v1.51/pkf.txt.pcv differ
diff --git a/src/testdata/legacy-v1.51/plain.orig b/src/testdata/legacy-v1.51/plain.orig
new file mode 100644
index 0000000..9f2bdec
--- /dev/null
+++ b/src/testdata/legacy-v1.51/plain.orig
@@ -0,0 +1 @@
+legacy plaintext from v1.51
diff --git a/src/testdata/legacy-v1.51/pw.txt.pcv b/src/testdata/legacy-v1.51/pw.txt.pcv
new file mode 100644
index 0000000..47f8da1
Binary files /dev/null and b/src/testdata/legacy-v1.51/pw.txt.pcv differ
diff --git a/src/winres/winres.json b/src/winres/winres.json
index d06ebb2..4a569b5 100644
--- a/src/winres/winres.json
+++ b/src/winres/winres.json
@@ -16,13 +16,13 @@
"#1": {
"0000": {
"fixed": {
- "file_version": "1.51.0.0",
- "product_version": "1.51.0.0"
+ "file_version": "1.52.0.0",
+ "product_version": "1.52.0.0"
},
"info": {
"0409": {
"CompanyName": "Teddy Jones",
- "FileVersion": "1.51",
+ "FileVersion": "1.52",
"LegalCopyright": "© Evan Su & contributors, GPLv3",
"ProductName": "ZeeCrypt"
}
diff --git a/src/work_test.go b/src/work_test.go
new file mode 100644
index 0000000..f32743c
--- /dev/null
+++ b/src/work_test.go
@@ -0,0 +1,437 @@
+package main
+
+import (
+ "archive/zip"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// ---------- Regression: normal round trips must keep working ----------
+
+func TestRoundTrips(t *testing.T) {
+ cases := []struct {
+ name string
+ pw string
+ nkf int
+ opt func()
+ }{
+ {"password", "pw-1", 0, nil},
+ {"paranoid", "pw-2", 0, func() { paranoid = true }},
+ {"reedsolo", "pw-3", 0, func() { reedsolo = true }},
+ {"keyfile-only", "", 1, nil},
+ {"pw+2 keyfiles ordered", "pw-4", 2, func() { keyfileOrdered = true }},
+ {"paranoid+rs+keyfiles+comment", "pw-5", 2, func() { paranoid = true; reedsolo = true; comments = "hello" }},
+ {"deniable", "pw-6", 0, func() { deniability = true }},
+ {"deniable+keyfile", "pw-7", 1, func() { deniability = true }},
+ {"deniable+rs+split", "pw-8", 0, func() { deniability = true; reedsolo = true; split = true; splitSize = "2"; splitSelected = 0 }},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ dir := t.TempDir()
+ in := filepath.Join(dir, "f.bin")
+ data := randBytes(t, 70000)
+ writeFile(t, in, data)
+ var kf []string
+ for i := 0; i < c.nkf; i++ {
+ p := filepath.Join(dir, "k"+string(rune('0'+i))+".key")
+ writeFile(t, p, randBytes(t, 100))
+ kf = append(kf, p)
+ }
+ vol, st := encrypt(t, []string{in}, c.pw, kf, c.opt)
+ mustStatus(t, st, "Completed")
+ os.Remove(in)
+ isSplit := strings.Contains(c.name, "split")
+ drop := vol
+ if isSplit {
+ drop = vol + ".0"
+ }
+ out, st := decrypt(t, drop, c.pw, kf, nil)
+ mustStatus(t, st, "Completed")
+ mustContent(t, out, data)
+ for _, n := range listDir(t, dir) {
+ if strings.HasSuffix(n, ".tmp") || strings.HasSuffix(n, ".incomplete") || (isSplit && n == "f.bin.pcv") {
+ t.Fatalf("leftover %s in %v", n, listDir(t, dir))
+ }
+ }
+ })
+ }
+}
+
+func TestMultiFileZipKeepsInputs(t *testing.T) {
+ dir := t.TempDir()
+ a, b := filepath.Join(dir, "a.txt"), filepath.Join(dir, "b.txt")
+ writeFile(t, a, []byte("aaa"))
+ writeFile(t, b, []byte("bbb"))
+ vol, st := encrypt(t, []string{a, b}, "pw", nil, func() { compress = true })
+ mustStatus(t, st, "Completed")
+ mustContent(t, a, []byte("aaa"))
+ mustContent(t, b, []byte("bbb"))
+ out, st := decrypt(t, vol, "pw", nil, nil)
+ mustStatus(t, st, "Completed")
+ zr, err := zip.OpenReader(out)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer zr.Close()
+ if len(zr.File) != 2 {
+ t.Fatalf("zip has %d entries", len(zr.File))
+ }
+ mustOnly(t, dir, "a.txt", "b.txt", filepath.Base(vol), filepath.Base(out))
+}
+
+func TestWrongCredentialsMessages(t *testing.T) {
+ dir := t.TempDir()
+ in := filepath.Join(dir, "f.bin")
+ k1, k2, k3 := filepath.Join(dir, "1.key"), filepath.Join(dir, "2.key"), filepath.Join(dir, "3.key")
+ writeFile(t, in, randBytes(t, 3000))
+ for _, k := range []string{k1, k2, k3} {
+ writeFile(t, k, randBytes(t, 50))
+ }
+ vol, st := encrypt(t, []string{in}, "right", []string{k1, k2}, func() { keyfileOrdered = true })
+ mustStatus(t, st, "Completed")
+ os.Remove(in)
+
+ _, st = decrypt(t, vol, "wrong", []string{k1, k2}, nil)
+ mustStatus(t, st, "password is incorrect")
+ _, st = decrypt(t, vol, "right", []string{k1, k3}, nil)
+ mustStatus(t, st, "Incorrect keyfiles or ordering")
+ _, st = decrypt(t, vol, "right", []string{k2, k1}, nil)
+ mustStatus(t, st, "Incorrect keyfiles or ordering")
+ _, st = decrypt(t, vol, "wrong", []string{k1, k3}, nil)
+ mustStatus(t, st, "Incorrect keyfiles")
+ mustOnly(t, dir, "f.bin.pcv", "1.key", "2.key", "3.key")
+}
+
+func TestDuplicateKeyfiles(t *testing.T) {
+ dir := t.TempDir()
+ in := filepath.Join(dir, "f.bin")
+ k1, k2 := filepath.Join(dir, "1.key"), filepath.Join(dir, "2.key")
+ writeFile(t, in, []byte("x"))
+ writeFile(t, k1, []byte("same"))
+ writeFile(t, k2, []byte("same"))
+ _, st := encrypt(t, []string{in}, "pw", []string{k1, k2}, nil)
+ mustStatus(t, st, "Duplicate keyfiles")
+ mustOnly(t, dir, "f.bin", "1.key", "2.key")
+}
+
+func TestForceDecryptStillOutputs(t *testing.T) {
+ dir := t.TempDir()
+ in := filepath.Join(dir, "f.bin")
+ writeFile(t, in, randBytes(t, 5000))
+ vol, _ := encrypt(t, []string{in}, "pw", nil, nil)
+ os.Remove(in)
+ flipByte(t, vol, 789+10)
+ out, st := decrypt(t, vol, "pw", nil, func() { keep = true })
+ mustStatus(t, st, "modified. Please be careful")
+ if _, err := os.Stat(out); err != nil {
+ t.Fatal("force decrypt produced no output")
+ }
+}
+
+// ---------- Finding #1: failed decrypt leaves plaintext / deletes overwritten file ----------
+
+func TestFailedDecryptCleansIncompleteAndKeepsExisting(t *testing.T) {
+ dir := t.TempDir()
+ in := filepath.Join(dir, "doc.txt")
+ writeFile(t, in, randBytes(t, 300000))
+ vol, _ := encrypt(t, []string{in}, "pw", nil, nil)
+ writeFile(t, in, []byte("EXISTING")) // the file the user chose to overwrite
+ flipByte(t, vol, 789+1000)
+ out, st := decrypt(t, vol, "pw", nil, nil)
+ mustStatus(t, st, "damaged or modified")
+ mustContent(t, out, []byte("EXISTING"))
+ mustOnly(t, dir, "doc.txt", "doc.txt.pcv")
+}
+
+// ---------- Finding #2/#3: Reed-Solomon repair on deniable and split volumes ----------
+
+func TestRepairDeniable(t *testing.T) {
+ dir := t.TempDir()
+ in := filepath.Join(dir, "f.bin")
+ data := randBytes(t, 50000)
+ writeFile(t, in, data)
+ vol, st := encrypt(t, []string{in}, "pw", nil, func() { deniability = true; reedsolo = true })
+ mustStatus(t, st, "Completed")
+ os.Remove(in)
+ flipByte(t, vol, 40+789+500) // outer layer is a stream cipher: flips one inner byte
+ out, st := decrypt(t, vol, "pw", nil, nil)
+ mustStatus(t, st, "Completed")
+ mustContent(t, out, data)
+ mustOnly(t, dir, "f.bin", "f.bin.pcv")
+}
+
+func TestRepairSplit(t *testing.T) {
+ dir := t.TempDir()
+ in := filepath.Join(dir, "f.bin")
+ data := randBytes(t, 5000)
+ writeFile(t, in, data)
+ vol, st := encrypt(t, []string{in}, "pw", nil, func() { reedsolo = true; split = true; splitSize = "2"; splitSelected = 0 })
+ mustStatus(t, st, "Completed")
+ os.Remove(in)
+ flipByte(t, vol+".1", 50) // (2048+50-789)%136 = 85: a data byte, not parity
+ out, st := decrypt(t, vol+".1", "pw", nil, nil)
+ mustStatus(t, st, "Completed")
+ mustContent(t, out, data)
+ mustOnly(t, dir, "f.bin", "f.bin.pcv.0", "f.bin.pcv.1", "f.bin.pcv.2", "f.bin.pcv.3")
+}
+
+func TestRepairDeniableSplit(t *testing.T) {
+ dir := t.TempDir()
+ in := filepath.Join(dir, "f.bin")
+ data := randBytes(t, 5000)
+ writeFile(t, in, data)
+ vol, st := encrypt(t, []string{in}, "pw", nil, func() { deniability = true; reedsolo = true; split = true; splitSize = "2"; splitSelected = 0 })
+ mustStatus(t, st, "Completed")
+ os.Remove(in)
+ flipByte(t, vol+".0", 40+789+20)
+ out, st := decrypt(t, vol+".0", "pw", nil, nil)
+ mustStatus(t, st, "Completed")
+ mustContent(t, out, data)
+ mustOnly(t, dir, "f.bin", "f.bin.pcv.0", "f.bin.pcv.1", "f.bin.pcv.2", "f.bin.pcv.3")
+}
+
+// ---------- Finding #4 (+ tail fragment): corrupted padding / appended bytes ----------
+
+func TestCorruptPaddingByteRepaired(t *testing.T) {
+ dir := t.TempDir()
+ in := filepath.Join(dir, "f.bin")
+ data := randBytes(t, 1000)
+ writeFile(t, in, data)
+ vol, _ := encrypt(t, []string{in}, "pw", nil, func() { reedsolo = true })
+ os.Remove(in)
+ b, _ := os.ReadFile(vol)
+ b[len(b)-9] = 0xff // PKCS#7 length byte of the final RS block (valid: 24)
+ writeFile(t, vol, b)
+ out, st := decrypt(t, vol, "pw", nil, nil)
+ mustStatus(t, st, "Completed")
+ mustContent(t, out, data)
+}
+
+func TestAppendedTailRejectedCleanly(t *testing.T) {
+ dir := t.TempDir()
+ in := filepath.Join(dir, "f.bin")
+ writeFile(t, in, randBytes(t, 1<<20))
+ vol, _ := encrypt(t, []string{in}, "pw", nil, func() { reedsolo = true })
+ os.Remove(in)
+ f, _ := os.OpenFile(vol, os.O_APPEND|os.O_WRONLY, 0)
+ f.Write([]byte("0123456789"))
+ f.Close()
+ _, st := decrypt(t, vol, "pw", nil, nil)
+ mustStatus(t, st, "irrecoverably damaged")
+ mustOnly(t, dir, "f.bin.pcv")
+}
+
+// ---------- Finding #5: keyfile-only header forgery ----------
+
+func TestKeyfileOnlyForgedHeaderRejected(t *testing.T) {
+ dir := t.TempDir()
+ in, kf := filepath.Join(dir, "s.bin"), filepath.Join(dir, "k.key")
+ writeFile(t, in, randBytes(t, 5000))
+ writeFile(t, kf, randBytes(t, 64))
+ vol, st := encrypt(t, []string{in}, "", []string{kf}, nil)
+ mustStatus(t, st, "Completed")
+ os.Remove(in)
+ forgeKeyfileOnlyHeader(t, vol)
+ _, st = decrypt(t, vol, "", []string{kf}, nil)
+ // Rejected at the header, with no output. (It's forged with the old derivation,
+ // so it's reported as a v1.50/1.51 volume; either message means "not accepted".)
+ if strings.Contains(st, "Completed") || !(strings.Contains(st, "tampered") || strings.Contains(st, "v1.51")) {
+ t.Fatalf("forged header not rejected: %q", st)
+ }
+ mustOnly(t, dir, "s.bin.pcv", "k.key")
+}
+
+// ---------- Finding #6: deniable failures must not leave the unwrapped .tmp ----------
+
+func TestDeniableFailuresLeaveNoTmp(t *testing.T) {
+ dir := t.TempDir()
+ in, kf := filepath.Join(dir, "f.bin"), filepath.Join(dir, "k.key")
+ data := randBytes(t, 20000)
+ writeFile(t, in, data)
+ writeFile(t, kf, randBytes(t, 64))
+ vol, _ := encrypt(t, []string{in}, "pw", []string{kf}, func() { deniability = true })
+ os.Remove(in)
+
+ // Wrong password
+ _, st := decrypt(t, vol, "nope", []string{kf}, nil)
+ mustStatus(t, st, "incorrect")
+ mustOnly(t, dir, "f.bin.pcv", "k.key")
+
+ // No keyfiles selected (only discoverable after unwrapping), then retry in the same UI state
+ _, st = decrypt(t, vol, "pw", nil, nil)
+ mustStatus(t, st, "requires keyfiles")
+ mustOnly(t, dir, "f.bin.pcv", "k.key")
+ keyfiles = []string{kf}
+ fastDecode = true
+ work(false)
+ working = false
+ mustStatus(t, mainStatus, "Completed")
+ mustContent(t, filepath.Join(dir, "f.bin"), data)
+}
+
+func TestDeniableTamperedLeavesNoTmp(t *testing.T) {
+ dir := t.TempDir()
+ in := filepath.Join(dir, "f.bin")
+ writeFile(t, in, randBytes(t, 20000))
+ vol, _ := encrypt(t, []string{in}, "pw", nil, func() { deniability = true })
+ os.Remove(in)
+ flipByte(t, vol, 40+789+100) // auth tag mismatch
+ _, st := decrypt(t, vol, "pw", nil, nil)
+ mustStatus(t, st, "damaged or modified")
+ mustOnly(t, dir, "f.bin.pcv")
+}
+
+func TestDeniableSplitWrongPasswordThenRetry(t *testing.T) {
+ dir := t.TempDir()
+ in := filepath.Join(dir, "f.bin")
+ data := randBytes(t, 5000)
+ writeFile(t, in, data)
+ vol, _ := encrypt(t, []string{in}, "pw", nil, func() { deniability = true; split = true; splitSize = "2"; splitSelected = 0 })
+ os.Remove(in)
+ chunks := listDir(t, dir)
+ _, st := decrypt(t, vol+".0", "bad", nil, nil)
+ mustStatus(t, st, "incorrect")
+ mustOnly(t, dir, chunks...)
+ password = "pw"
+ fastDecode = true
+ work(false)
+ working = false
+ mustStatus(t, mainStatus, "Completed")
+ mustContent(t, filepath.Join(dir, "f.bin"), data)
+ mustOnly(t, dir, append(chunks, "f.bin")...)
+}
+
+func TestTinyFileTreatedAsDeniable(t *testing.T) {
+ dir := t.TempDir()
+ vol := filepath.Join(dir, "junk.pcv")
+ writeFile(t, vol, randBytes(t, 20))
+ _, st := decrypt(t, vol, "pw", nil, nil)
+ mustStatus(t, st, "too short")
+ mustOnly(t, dir, "junk.pcv")
+}
+
+// ---------- Finding #7: cancelled split leaves no chunks ----------
+
+func TestCancelledSplitLeavesNothing(t *testing.T) {
+ dir := t.TempDir()
+ in := filepath.Join(dir, "f.bin")
+ writeFile(t, in, randBytes(t, 4<<20))
+ go func() {
+ deadline := time.Now().Add(60 * time.Second)
+ for !strings.HasPrefix(popupStatus, "Splitting") && time.Now().Before(deadline) {
+ time.Sleep(time.Millisecond)
+ }
+ working = false
+ }()
+ _, st := encrypt(t, []string{in}, "pw", nil, func() { split = true; splitSize = "100"; splitSelected = 0 })
+ mustStatus(t, st, "cancelled")
+ mustOnly(t, dir, "f.bin")
+}
+
+// ---------- Finding #8: no self-update while working ----------
+
+func TestApplyUpdateRefusedWhileWorking(t *testing.T) {
+ setup()
+ working, showProgress = true, true
+ applyUpdate()
+ if updateApplying {
+ t.Fatal("applyUpdate started while an operation was running")
+ }
+ working, showProgress = false, false
+}
+
+// ---------- Compat: volumes produced by v1.51 ----------
+// Password-only volumes still open; keyfile volumes are rejected with a pointer to v1.51.
+
+func TestLegacyVolumes(t *testing.T) {
+ // Volumes made by v1.51 (see testdata/legacy-v1.51/README.md)
+ src := filepath.Join("testdata", "legacy-v1.51")
+ dir := t.TempDir()
+ for _, n := range []string{"pw.txt.pcv", "kf.txt.pcv", "pkf.txt.pcv", "kf.key", "plain.orig"} {
+ b, err := os.ReadFile(filepath.Join(src, n))
+ if err != nil {
+ t.Fatal(err)
+ }
+ writeFile(t, filepath.Join(dir, n), b)
+ }
+ want, _ := os.ReadFile(filepath.Join(dir, "plain.orig"))
+
+ out, st := decrypt(t, filepath.Join(dir, "pw.txt.pcv"), "legacy-pw", nil, nil)
+ mustStatus(t, st, "Completed")
+ mustContent(t, out, want)
+
+ kf := []string{filepath.Join(dir, "kf.key")}
+ _, st = decrypt(t, filepath.Join(dir, "kf.txt.pcv"), "", kf, nil)
+ mustStatus(t, st, "use ZeeCrypt v1.51")
+ _, st = decrypt(t, filepath.Join(dir, "pkf.txt.pcv"), "legacy-pw", kf, nil)
+ mustStatus(t, st, "use ZeeCrypt v1.51")
+ _, st = decrypt(t, filepath.Join(dir, "pkf.txt.pcv"), "typo", kf, nil)
+ mustStatus(t, st, "password is incorrect")
+ mustOnly(t, dir, "pw.txt", "pw.txt.pcv", "kf.txt.pcv", "pkf.txt.pcv", "kf.key", "plain.orig")
+}
+
+// ---------- Review follow-ups ----------
+
+func TestExistingTmpNotClobbered(t *testing.T) {
+ dir := t.TempDir()
+ in := filepath.Join(dir, "f.bin")
+ writeFile(t, in, randBytes(t, 3000))
+ vol, _ := encrypt(t, []string{in}, "pw", nil, func() { deniability = true })
+ os.Remove(in)
+ writeFile(t, vol+".tmp", []byte("USERDATA"))
+ _, st := decrypt(t, vol, "pw", nil, nil)
+ mustStatus(t, st, "Please remove f.bin.pcv.tmp")
+ mustContent(t, vol+".tmp", []byte("USERDATA"))
+ mustOnly(t, dir, "f.bin.pcv", "f.bin.pcv.tmp")
+}
+
+func TestStartIgnoredWhileWorking(t *testing.T) {
+ setup()
+ dir := t.TempDir()
+ in := filepath.Join(dir, "f.bin")
+ writeFile(t, in, []byte("x"))
+ onDrop([]string{in})
+ waitScan()
+ password, cpassword = "pw", "pw"
+ working = true
+ onClickStartButton() // what the Enter key does mid-operation
+ working = false
+ if showProgress || showOverwrite {
+ t.Fatal("onClickStartButton started an operation while one was running")
+ }
+ mustOnly(t, dir, "f.bin")
+}
+
+func TestSplitNameWithGlobMetacharacters(t *testing.T) {
+ dir := t.TempDir()
+ in := filepath.Join(dir, "report[1].bin")
+ data := randBytes(t, 5000)
+ writeFile(t, in, data)
+ vol, st := encrypt(t, []string{in}, "pw", nil, func() { split = true; splitSize = "2"; splitSelected = 0 })
+ mustStatus(t, st, "Completed")
+ os.Remove(in)
+ mustOnly(t, dir, "report[1].bin.pcv.0", "report[1].bin.pcv.1", "report[1].bin.pcv.2")
+ out, st := decrypt(t, vol+".0", "pw", nil, nil)
+ mustStatus(t, st, "Completed")
+ mustContent(t, out, data)
+}
+
+func TestRemoveTempInputIdempotent(t *testing.T) {
+ setup()
+ dir := t.TempDir()
+ user := filepath.Join(dir, "foo.pcv")
+ writeFile(t, user, []byte("user volume"))
+ rec := filepath.Join(dir, "bar.pcv")
+ writeFile(t, rec, []byte("recombined"))
+ mode, recombine = "decrypt", true
+ inputFileOld, inputFile, recombinedFile = user, rec, rec
+ removeTempInput()
+ removeTempInput()
+ mustOnly(t, dir, "foo.pcv")
+ if inputFile != user {
+ t.Fatalf("inputFile = %q", inputFile)
+ }
+}