Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<UUID>.zip.age`; otherwise, it writes an unencrypted `<UUID>.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 `<UUID>.zip.age`; otherwise, it writes an unencrypted `<UUID>.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
Expand Down
22 changes: 14 additions & 8 deletions acquisition/streaming_zip.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
68 changes: 68 additions & 0 deletions acquisition/streaming_zip_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions docs/acquisition-archives.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ acquisition UUID:
| Configuration | Output |
|---|---|
| No `key.txt` found | `<UUID>.zip` |
| Valid age recipient in `key.txt` | `<UUID>.zip.age` |
| One or more valid age recipients in `key.txt` | `<UUID>.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:
Expand Down Expand Up @@ -50,8 +52,8 @@ finalized.

## Encrypted acquisitions

When `key.txt` is present, the ZIP stream is passed directly through age into
`<UUID>.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 `<UUID>.zip.age`:

```text
device data -> ZIP writer -> age encryption -> <UUID>.zip.age
Expand Down
Loading