diff --git a/README.md b/README.md index 71c86db..523e316 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,7 @@ Ideally you should have the drive fully encrypted, but that might not always be Alternatively, androidqf allows to encrypt each acquisition with a provided [age](https://age-encryption.org) public key. Preferably, this public key belongs to a keypair for which the end-user does not possess, or at least carry, the private key. In this way, the end-user would not be able to decrypt the acquired data even under duress. -androidqf streams each acquisition into a zip archive. If you place a file called `key.txt` in the current working directory, androidqf will encrypt the zip stream with age and write `.zip.age`; otherwise, it writes an unencrypted `.zip`. androidqf also checks for `key.txt` in the same folder as the executable; if both files exist, the current working directory takes precedence. +androidqf streams each acquisition into a zip archive. If you place a file called `key.txt` in the current working directory, androidqf will encrypt the zip stream with age and write `.zip.age`; otherwise, it writes an unencrypted `.zip`. Put one age recipient per line in `key.txt`; each recipient can decrypt the resulting acquisition. Empty lines and lines beginning with `#` are ignored. androidqf also checks for `key.txt` in the same folder as the executable; if both files exist, the current working directory takes precedence. Encrypted acquisitions do not create a plaintext acquisition archive. Device files that must be validated before they are added to an encrypted archive are diff --git a/acquisition/streaming_zip.go b/acquisition/streaming_zip.go index 40ceecd..bdd8f5b 100644 --- a/acquisition/streaming_zip.go +++ b/acquisition/streaming_zip.go @@ -55,7 +55,8 @@ func (hw *hashingWriter) Write(p []byte) (int, error) { } // NewStreamingZipWriter creates a streaming zip writer in outputDir. If key.txt -// exists, the zip stream is age-encrypted and written as .zip.age. +// exists, the zip stream is age-encrypted for every recipient in the file and +// written as .zip.age. func NewStreamingZipWriter(uuid, outputDir string) (*StreamingZipWriter, error) { if outputDir == "" { cwd, err := os.Getwd() @@ -95,24 +96,29 @@ func NewStreamingZipWriter(uuid, outputDir string) (*StreamingZipWriter, error) var encWriter io.WriteCloser var zipSink io.Writer = file if ok { - log.Info("Found age public key, streaming to encrypted zip archive.") + log.Info("Found age recipient file, streaming to encrypted zip archive.") - publicKey, err := os.ReadFile(keyFilePath) + keyFile, err := os.Open(keyFilePath) if err != nil { file.Close() os.Remove(outputPath) - return nil, fmt.Errorf("failed to read public key: %v", err) + return nil, fmt.Errorf("failed to open age recipient file: %v", err) } - publicKeyStr := strings.TrimSpace(string(publicKey)) - recipient, err := age.ParseX25519Recipient(publicKeyStr) + recipients, err := age.ParseRecipients(keyFile) + closeErr := keyFile.Close() if err != nil { file.Close() os.Remove(outputPath) - return nil, fmt.Errorf("failed to parse public key %q: %v", publicKeyStr, err) + return nil, fmt.Errorf("failed to parse age recipient file: %v", err) + } + if closeErr != nil { + file.Close() + os.Remove(outputPath) + return nil, fmt.Errorf("failed to close age recipient file: %v", closeErr) } - encWriter, err = age.Encrypt(file, recipient) + encWriter, err = age.Encrypt(file, recipients...) if err != nil { file.Close() os.Remove(outputPath) diff --git a/acquisition/streaming_zip_test.go b/acquisition/streaming_zip_test.go index 9937fac..1d977cf 100644 --- a/acquisition/streaming_zip_test.go +++ b/acquisition/streaming_zip_test.go @@ -138,6 +138,74 @@ func TestNewStreamingZipWriterUsesCurrentWorkingDirectory(t *testing.T) { } } +func TestNewStreamingZipWriterEncryptsForEveryRecipient(t *testing.T) { + cwd := t.TempDir() + t.Chdir(cwd) + + var identities []*age.X25519Identity + var recipientFile strings.Builder + recipientFile.WriteString("# Acquisition recipients\n\n") + for range 2 { + identity, err := age.GenerateX25519Identity() + if err != nil { + t.Fatalf("GenerateX25519Identity() error = %v", err) + } + identities = append(identities, identity) + recipientFile.WriteString(identity.Recipient().String()) + recipientFile.WriteByte('\n') + } + if err := os.WriteFile( + filepath.Join(cwd, keyFileName), + []byte(recipientFile.String()), + 0o600, + ); err != nil { + t.Fatalf("WriteFile(key.txt) error = %v", err) + } + + ezw, err := NewStreamingZipWriter("test-acquisition", cwd) + if err != nil { + t.Fatalf("NewStreamingZipWriter() error = %v", err) + } + if err := ezw.CreateFileFromString("evidence.txt", "collected evidence"); err != nil { + t.Fatalf("CreateFileFromString() error = %v", err) + } + if err := ezw.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + + encrypted, err := os.ReadFile(ezw.GetOutputPath()) + if err != nil { + t.Fatalf("ReadFile(encrypted archive) error = %v", err) + } + for i, identity := range identities { + decrypted, err := age.Decrypt(bytes.NewReader(encrypted), identity) + if err != nil { + t.Fatalf("age.Decrypt() for recipient %d error = %v", i+1, err) + } + archive, err := io.ReadAll(decrypted) + if err != nil { + t.Fatalf("ReadAll(decrypted archive) for recipient %d error = %v", i+1, err) + } + + reader, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive))) + if err != nil { + t.Fatalf("zip.NewReader() for recipient %d error = %v", i+1, err) + } + entry, err := reader.File[0].Open() + if err != nil { + t.Fatalf("Open(evidence.txt) for recipient %d error = %v", i+1, err) + } + content, err := io.ReadAll(entry) + entry.Close() + if err != nil { + t.Fatalf("ReadAll(evidence.txt) for recipient %d error = %v", i+1, err) + } + if string(content) != "collected evidence" { + t.Fatalf("evidence.txt for recipient %d = %q", i+1, content) + } + } +} + func TestValidateZipEntryName(t *testing.T) { tests := []struct { name string diff --git a/docs/acquisition-archives.md b/docs/acquisition-archives.md index 884de55..def183a 100644 --- a/docs/acquisition-archives.md +++ b/docs/acquisition-archives.md @@ -13,10 +13,12 @@ acquisition UUID: | Configuration | Output | |---|---| | No `key.txt` found | `.zip` | -| Valid age recipient in `key.txt` | `.zip.age` | +| One or more valid age recipients in `key.txt` | `.zip.age` | androidqf looks for `key.txt` in the current working directory and then beside -the executable. A key in the current working directory takes precedence. +the executable. A key in the current working directory takes precedence. The +file accepts one age recipient per line; empty lines and lines beginning with +`#` are ignored. Every listed recipient can decrypt the resulting acquisition. The archive contains the collected module outputs documented in the main [README](../README.md#how-to-use), plus these acquisition-level entries: @@ -50,8 +52,8 @@ finalized. ## Encrypted acquisitions -When `key.txt` is present, the ZIP stream is passed directly through age into -`.zip.age`: +When a valid `key.txt` is present, the ZIP stream is encrypted to every +recipient in the file and passed directly through age into `.zip.age`: ```text device data -> ZIP writer -> age encryption -> .zip.age