Skip to content

Commit a41e60c

Browse files
committed
Merge remote-tracking branch 'origin/main' into adb-device-selection
# Conflicts: # acquisition/acquisition.go
2 parents 37f85aa + c8ba59b commit a41e60c

16 files changed

Lines changed: 395 additions & 34 deletions

.github/workflows/container.yaml

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
name: Build container image
2+
3+
on:
4+
release:
5+
types:
6+
- published
7+
8+
permissions:
9+
contents: read
10+
packages: write
11+
12+
jobs:
13+
container:
14+
name: Build and publish container image
15+
runs-on: ubuntu-latest
16+
steps:
17+
- name: Checkout
18+
uses: actions/checkout@v4
19+
20+
- name: Set image version
21+
id: version
22+
run: |
23+
version="${{ github.event.release.tag_name }}"
24+
echo "version=${version#v}" >> "$GITHUB_OUTPUT"
25+
26+
- name: Set up QEMU
27+
uses: docker/setup-qemu-action@v3
28+
29+
- name: Set up Docker Buildx
30+
uses: docker/setup-buildx-action@v3
31+
32+
- name: Log in to GitHub Container Registry
33+
uses: docker/login-action@v3
34+
with:
35+
registry: ghcr.io
36+
username: ${{ github.actor }}
37+
password: ${{ secrets.GITHUB_TOKEN }}
38+
39+
- name: Extract container metadata
40+
id: meta
41+
uses: docker/metadata-action@v5
42+
with:
43+
images: ghcr.io/mvt-project/androidqf
44+
tags: |
45+
type=semver,pattern={{version}},value=${{ github.event.release.tag_name }}
46+
type=semver,pattern={{major}}.{{minor}},value=${{ github.event.release.tag_name }}
47+
type=raw,value=latest,enable=${{ github.event.release.prerelease == false }}
48+
49+
- name: Build and publish
50+
uses: docker/build-push-action@v6
51+
with:
52+
context: .
53+
platforms: linux/amd64,linux/arm64
54+
push: true
55+
build-args: |
56+
VERSION=${{ steps.version.outputs.version }}
57+
labels: ${{ steps.meta.outputs.labels }}
58+
tags: ${{ steps.meta.outputs.tags }}

Dockerfile

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
FROM alpine:3.23
2+
3+
ARG TARGETARCH=amd64
4+
ARG VERSION=1.8.3
5+
6+
RUN apk add --no-cache ca-certificates gcompat libgcc wget \
7+
&& case "${TARGETARCH}" in \
8+
amd64|arm64) ;; \
9+
*) echo "unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
10+
esac \
11+
&& wget -O /usr/local/bin/androidqf \
12+
"https://github.com/mvt-project/androidqf/releases/download/v${VERSION}/androidqf_linux_${TARGETARCH}_${VERSION}" \
13+
&& chmod +x /usr/local/bin/androidqf
14+
15+
WORKDIR /acquisition
16+
17+
ENTRYPOINT ["androidqf"]
18+
CMD ["-output", "/output"]

README.md

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,30 @@ but the `assets/` package directory and its Go source files must remain present.
6969
The `unbundle` build still imports the `assets` package, and the build will fail
7070
if the whole `assets/` directory is deleted.
7171

72+
## Container image
73+
74+
The release container image is published to GitHub Container Registry:
75+
76+
```bash
77+
docker pull ghcr.io/mvt-project/androidqf:latest
78+
```
79+
80+
To collect from a USB-connected Android device on Linux, pass through the USB
81+
bus and mount an output directory:
82+
83+
```bash
84+
docker run --rm -it --privileged \
85+
-v /dev/bus/usb:/dev/bus/usb \
86+
-v "$(pwd)/output:/output" \
87+
ghcr.io/mvt-project/androidqf:latest -fast -output /output
88+
```
89+
90+
You can also build the image locally for a released version:
91+
92+
```bash
93+
docker build --build-arg VERSION=1.8.3 -t androidqf .
94+
```
95+
7296
## How to use
7397

7498
> [!TIP]
@@ -78,7 +102,7 @@ Before launching androidqf you need to have the target Android device connected
78102

79103
Once USB debugging is enabled, you can proceed launching androidqf. It will first attempt to connect to the device over the USB bridge, which should result in the Android phone to prompt you to manually authorize the host keys. Make sure to authorize them, ideally permanently so that the prompt wouldn't appear again.
80104

81-
Now androidqf should be executing and creating an acquisition folder at the same path you have placed your androidqf binary. At some point in the execution, androidqf will prompt you some choices: these prompts will pause the acquisition until you provide a selection, so pay attention.
105+
Now androidqf should be executing and creating an acquisition folder in your current working directory. At some point in the execution, androidqf will prompt you some choices: these prompts will pause the acquisition until you provide a selection, so pay attention.
82106

83107
The following data can be extracted:
84108

@@ -162,7 +186,7 @@ Ideally you should have the drive fully encrypted, but that might not always be
162186

163187
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.
164188

165-
If you place a file called `key.txt` in the same folder as the androidqf executable, androidqf will automatically attempt to compress and encrypt each acquisition and delete the original unencrypted copies.
189+
If you place a file called `key.txt` in the current working directory, androidqf will automatically attempt to compress and encrypt each acquisition and delete the original unencrypted copies. androidqf also checks for `key.txt` in the same folder as the executable; if both files exist, the current working directory takes precedence.
166190

167191
Once you have retrieved an encrypted acquisition file, you can decrypt it with age like so:
168192

acquisition/acquisition.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,9 @@ func New(path string) (*Acquisition, error) {
118118
}
119119

120120
func (a *Acquisition) Complete() {
121-
a.Completed = time.Now().UTC()
121+
if a.Completed.IsZero() {
122+
a.Completed = time.Now().UTC()
123+
}
122124

123125
// Handle streaming mode completion
124126
if a.StreamingMode && a.EncryptedWriter != nil {
@@ -266,6 +268,10 @@ func (a *Acquisition) StoreInfo() error {
266268
return nil
267269
}
268270

271+
if a.Completed.IsZero() {
272+
a.Completed = time.Now().UTC()
273+
}
274+
269275
log.Info("Saving details about acquisition and device...")
270276

271277
info, err := json.MarshalIndent(a, "", " ")

acquisition/acquisition_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package acquisition
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
)
9+
10+
func TestStoreInfoSetsCompletedTimestamp(t *testing.T) {
11+
acq := &Acquisition{
12+
UUID: "test-acquisition",
13+
StoragePath: t.TempDir(),
14+
}
15+
16+
if err := acq.StoreInfo(); err != nil {
17+
t.Fatalf("StoreInfo() error = %v", err)
18+
}
19+
20+
if acq.Completed.IsZero() {
21+
t.Fatal("StoreInfo() left Completed unset")
22+
}
23+
24+
info, err := os.ReadFile(filepath.Join(acq.StoragePath, "acquisition.json"))
25+
if err != nil {
26+
t.Fatalf("ReadFile(acquisition.json) error = %v", err)
27+
}
28+
29+
var stored Acquisition
30+
if err := json.Unmarshal(info, &stored); err != nil {
31+
t.Fatalf("json.Unmarshal(acquisition.json) error = %v", err)
32+
}
33+
if stored.Completed.IsZero() {
34+
t.Fatal("acquisition.json contains a zero completed timestamp")
35+
}
36+
}
37+
38+
func TestCompleteDoesNotOverwriteExistingCompletedTimestamp(t *testing.T) {
39+
acq := &Acquisition{
40+
UUID: "test-acquisition",
41+
StoragePath: t.TempDir(),
42+
}
43+
44+
if err := acq.StoreInfo(); err != nil {
45+
t.Fatalf("StoreInfo() error = %v", err)
46+
}
47+
completed := acq.Completed
48+
49+
acq.Complete()
50+
51+
if !acq.Completed.Equal(completed) {
52+
t.Fatalf("Complete() changed Completed from %s to %s", completed, acq.Completed)
53+
}
54+
}
55+
56+
func TestStoreSecurelyUsesCurrentWorkingDirectory(t *testing.T) {
57+
cwd := t.TempDir()
58+
t.Chdir(cwd)
59+
writeTestAgeKey(t, cwd)
60+
61+
storagePath := filepath.Join(cwd, "test-acquisition")
62+
if err := os.Mkdir(storagePath, 0o755); err != nil {
63+
t.Fatalf("Mkdir(storagePath) error = %v", err)
64+
}
65+
if err := os.WriteFile(filepath.Join(storagePath, "data.txt"), []byte("evidence"), 0o600); err != nil {
66+
t.Fatalf("WriteFile(data.txt) error = %v", err)
67+
}
68+
69+
acq := &Acquisition{
70+
UUID: "test-acquisition",
71+
StoragePath: storagePath,
72+
}
73+
74+
if err := acq.StoreSecurely(); err != nil {
75+
t.Fatalf("StoreSecurely() error = %v", err)
76+
}
77+
78+
wantPath := filepath.Join(cwd, "test-acquisition.zip.age")
79+
if _, err := os.Stat(wantPath); err != nil {
80+
t.Fatalf("Stat(encrypted output) error = %v", err)
81+
}
82+
if _, err := os.Stat(storagePath); !os.IsNotExist(err) {
83+
t.Fatalf("storage path still exists or returned unexpected error: %v", err)
84+
}
85+
}

acquisition/encrypted_stream.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import (
2121
"time"
2222

2323
"filippo.io/age"
24-
saveRuntime "github.com/botherder/go-savetime/runtime"
2524
"github.com/mvt-project/androidqf/log"
2625
)
2726

@@ -55,11 +54,17 @@ func (hw *hashingWriter) Write(p []byte) (int, error) {
5554

5655
// NewEncryptedZipWriter creates a new encrypted zip writer if key.txt exists
5756
func NewEncryptedZipWriter(uuid string) (*EncryptedZipWriter, error) {
58-
cwd := saveRuntime.GetExecutableDirectory()
59-
keyFilePath := filepath.Join(cwd, "key.txt")
57+
cwd, err := os.Getwd()
58+
if err != nil {
59+
return nil, err
60+
}
6061

6162
// Check if key file exists
62-
if _, err := os.Stat(keyFilePath); os.IsNotExist(err) {
63+
keyFilePath, ok, err := findAgeKeyFile()
64+
if err != nil {
65+
return nil, err
66+
}
67+
if !ok {
6368
return nil, fmt.Errorf("key.txt not found, encrypted streaming not available")
6469
}
6570

acquisition/encrypted_stream_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,12 @@ import (
77
"encoding/csv"
88
"encoding/hex"
99
"io"
10+
"os"
11+
"path/filepath"
1012
"strings"
1113
"testing"
14+
15+
"filippo.io/age"
1216
)
1317

1418
func TestCreateHashListTracksPlaintextZipEntries(t *testing.T) {
@@ -93,6 +97,44 @@ func sha256Hex(content string) string {
9397
return hex.EncodeToString(sum[:])
9498
}
9599

100+
func writeTestAgeKey(t *testing.T, dir string) {
101+
t.Helper()
102+
103+
identity, err := age.GenerateX25519Identity()
104+
if err != nil {
105+
t.Fatalf("GenerateX25519Identity() error = %v", err)
106+
}
107+
108+
keyPath := filepath.Join(dir, "key.txt")
109+
if err := os.WriteFile(keyPath, []byte(identity.Recipient().String()), 0o600); err != nil {
110+
t.Fatalf("WriteFile(key.txt) error = %v", err)
111+
}
112+
}
113+
114+
func TestNewEncryptedZipWriterUsesCurrentWorkingDirectory(t *testing.T) {
115+
cwd := t.TempDir()
116+
t.Chdir(cwd)
117+
writeTestAgeKey(t, cwd)
118+
119+
ezw, err := NewEncryptedZipWriter("test-acquisition")
120+
if err != nil {
121+
t.Fatalf("NewEncryptedZipWriter() error = %v", err)
122+
}
123+
defer os.Remove(ezw.GetOutputPath())
124+
125+
if err := ezw.Close(); err != nil {
126+
t.Fatalf("Close() error = %v", err)
127+
}
128+
129+
wantPath := filepath.Join(cwd, "test-acquisition.zip.age")
130+
if ezw.GetOutputPath() != wantPath {
131+
t.Fatalf("output path = %q, want %q", ezw.GetOutputPath(), wantPath)
132+
}
133+
if _, err := os.Stat(wantPath); err != nil {
134+
t.Fatalf("Stat(output) error = %v", err)
135+
}
136+
}
137+
96138
func TestValidateZipEntryName(t *testing.T) {
97139
tests := []struct {
98140
name string

acquisition/key.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// androidqf - Android Quick Forensics
2+
// Copyright (c) 2021-2022 Claudio Guarnieri.
3+
// Use of this software is governed by the MVT License 1.1 that can be found at
4+
// https://license.mvt.re/1.1/
5+
6+
package acquisition
7+
8+
import (
9+
"os"
10+
"path/filepath"
11+
)
12+
13+
const keyFileName = "key.txt"
14+
15+
func findAgeKeyFile() (string, bool, error) {
16+
cwd, err := os.Getwd()
17+
if err != nil {
18+
return "", false, err
19+
}
20+
if keyPath, ok := findAgeKeyFileInDirs(cwd, ""); ok {
21+
return keyPath, true, nil
22+
}
23+
24+
exe, err := os.Executable()
25+
if err != nil {
26+
return "", false, err
27+
}
28+
29+
keyPath, ok := findAgeKeyFileInDirs(cwd, filepath.Dir(exe))
30+
return keyPath, ok, nil
31+
}
32+
33+
func findAgeKeyFileInDirs(cwd, executableDir string) (string, bool) {
34+
for _, dir := range []string{cwd, executableDir} {
35+
if dir == "" {
36+
continue
37+
}
38+
39+
keyPath := filepath.Join(dir, keyFileName)
40+
if _, err := os.Stat(keyPath); err == nil {
41+
return keyPath, true
42+
}
43+
}
44+
45+
return "", false
46+
}

0 commit comments

Comments
 (0)