aboutsummaryrefslogtreecommitdiffstats
path: root/AGENTS.md
blob: 887fcb2a49984bb12d4fb38adb9a452eecb605ac (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
# AGENTS.md

Guidance for AI coding agents (Claude Code, opencode, etc) working in this repository.

## What this is

HardenedPass: a maintained fork of Android Password Store, an Android client for `pass`
(OpenPGP-encrypted files in a git repository). Upstream
(`android-password-store/Android-Password-Store`) was archived in October 2024; the fork
started in November 2026 from upstream's last commit. Kotlin, Gradle, about 18k lines. The
goal is to work flawlessly on GrapheneOS: no Google Play Services, no telemetry.

Modules: `app` (UI, git sync, autofill service), `autofill-parser`, `crypto:common`,
`crypto:pgpainless`, `format:common`, `passgen:diceware`, `passgen:random`, `ui:compose`,
`coroutine-utils`. Build conventions live in `build-logic/`.

`applicationId` is `xyz.danix.hardenedpass` (debug: `.debug` suffix, label "HardenedPass
Debug"), so it installs next to upstream's `app.passwordstore` and never updates over it. The
Kotlin package stays `app.passwordstore`; renaming it is churn with no user-visible effect.
Keep "GrapheneOS" out of the app name: it is that project's trademark.

## Repository layout and remotes

- `master` is the only branch. Upstream's `develop` was renamed; do not recreate it.
- `origin` fetches from the maintainer's own git server (the main repository) and pushes to it
  and to the GitHub fork (`danixland/Android-Password-Store`) in one `git push`.
  GitHub is a push mirror: never create commits on GitHub (bots, web edits), they would
  diverge from the main repository.
- `upstream` is fetch-only (push URL disabled).
- Work directly on `master`, no PRs.

## Build and test

Needs JDK 17 and the Android SDK. `local.properties` (gitignored) holds `sdk.dir`.

    ./gradlew :app:assembleDebug                # debug APK
    ./gradlew test -PslimTests                  # unit tests, as CI runs them
    ./gradlew spotlessApply                     # formatting (ktfmt); CI runs spotlessCheck
    ./gradlew lint                              # all modules and variants, as CI runs it

Prefix with `CI=1` to match CI: it turns Kotlin warnings into errors, so a deprecation warning
that passes locally fails CI. Before committing, run:

    CI=1 ./gradlew spotlessCheck test -PslimTests :app:assembleDebug :app:assembleRelease \
      lint metalavaCheckCompatibilityRelease

No product flavors: the app has no proprietary dependencies, no Sentry and no Play Services
(the upstream `nonFree` flavor was removed). Scope `assemble*` to `:app`; a bare
`assembleRelease` also verifies library modules in release mode, where `ui:compose` fails on a
drawable using an AppCompat attribute it does not depend on.

CI is `.github/workflows/pull_request.yml` (named "CI"), on push to `master`. Workflows use the
`setup-gradle` composite action from this fork at `@master`. It checks the repository out
itself, so it cannot be referenced with a local `./` path.

## End-to-end testing on an emulator

`scripts/e2e-env.sh up` starts a disposable git-over-SSH server in Docker
(`ssh://git@10.0.2.2:2222/srv/git/store.git` from the emulator) seeded with a small store
encrypted to a throwaway OpenPGP key. Keys are generated into `.e2e/` (gitignored), never
committed; `push-keys` copies them to the emulator's Download folder. `down` removes it.
Never drive tests against a real password store or with real keys.

Emulator: AVD `aps-test` (Pixel 6, AOSP Android 15 x86_64, no Google services), using the SDK
in `local.properties`:

    $ANDROID_HOME/emulator/emulator -avd aps-test -no-window -no-audio -no-boot-anim \
      -no-snapshot-save -gpu swiftshader_indirect &
    ./gradlew installDebug

Drive it with `adb shell input`, `uiautomator dump` and `screencap`. Decrypt screens set
`FLAG_SECURE`, so screenshots of them are black; read them from the `uiautomator` dump.
Fingerprint: `adb emu finger touch 1` after enrolling one in Settings (needs a lock PIN:
`adb shell locksettings set-pin 1234` on the emulator only). Disable animations
(`settings put global animator_duration_scale 0`, same for `transition_` and `window_`)
or `uiautomator dump` waits for an idle state that never comes. The OTP screen never goes idle
anyway (live countdown), so its code can only be checked by eye. A windowed emulator needs the
host's `libbsd`.

Autofill cannot be tested on the emulator image: Settings is denylisted in
`OreoAutofillService`, and the only browser (WebView Shell) is not a trusted browser. Use a
real device with a trusted browser (e.g. Firefox) instead:

- `adb reverse tcp:2222 tcp:2222` lets the device clone `ssh://git@127.0.0.1:2222/...` from the
  e2e server over USB; serve a local login page the same way (`python3 -m http.server` on
  127.0.0.1, `adb reverse` its port). Nothing is exposed on the network.
- Record `settings get secure autofill_service` and `global stay_on_while_plugged_in` first,
  restore both afterwards, then `adb uninstall xyz.danix.hardenedpass.debug`, delete the pushed
  keys from Download and `adb reverse --remove-all`.
- Set `svc power stayon usb` before driving: if the screen locks, taps land on the lock
  screen's PIN pad. Check `dumpsys window | grep mCurrentFocus` (and `isKeyguardShowing`)
  before tapping. Never type the owner's PIN.
- On a device with Gboard, fill suggestions are inline chips in the keyboard bar, localized
  (Italian: "Cerca la voce" is the app's "Search entry" chip).
- Only read screens of the debug app, the test page and the autofill UI; the device holds the
  owner's real data.

Last full device run (2026-11-02): clone over SSH, PGP import, decrypt, TOTP, create and push,
conflict recovery, hard reset, fingerprint unlock (emulator) and Firefox autofill (tablet) all
passed.

## Verified facts and gotchas

- Bouncy Castle: every `org.bouncycastle` module is forced to the catalog version by a
  resolution rule in the root `build.gradle.kts`. PGPainless and sshj pull different versions,
  and mixing them is unsupported.
- Bouncy Castle and PGPainless 1.7.8 decrypt GnuPG's OCB (LibrePGP AEAD) packets, the default
  since GnuPG 2.3. Older stacks rejected them as "non-standard AEAD"; that error path was
  removed. `crypto/pgpainless/src/test/resources/gnupg_ocb_*` are real GnuPG 2.5 fixtures.
- Test secret-key fixtures trip the maintainer's gitleaks pre-commit hook. A throwaway test key
  is allowlisted by fingerprint in `.gitleaksignore`
  (`<path>:private-key:<line>`). Never allowlist anything but generated test keys.
- `app/lint-baseline.xml` entries for library jars are keyed on the jar's cache path, version
  and hash included. A dependency bump that moves a baselined jar needs the path updated.
- `LaunchActivity` is exported and forwards `FILE_PATH` to `DecryptActivity` (not exported)
  only after `isFileInRepo` checks it canonically resolves inside the store.
- AGP 9 applies packaging excludes (`AndroidCommon`, e.g. `**/*.txt`) to unit-test java
  resources as well. A module whose tests load such a file must remove the exclude locally
  (see `passgen/diceware/build.gradle.kts`).
- Hilt runs through KSP (no kapt; AGP 9's built-in Kotlin does not support it). Dagger compiler
  options are `ksp { arg(...) }` in `app/build.gradle.kts`.
- JGit is pinned at 4.5.7 (2019). Newer JGit needs Java 11 APIs Android only partly desugars;
  upgrading it is a large, separate job.

## Maintenance tasks

Recurring jobs with no automation. When starting a session, check the "last run" dates; if a
task is overdue, remind the maintainer before other work. After running one, update its date
here in the same commit.

### Public suffix list (monthly, and before every release)

Autofill uses Mozilla's Public Suffix List (`autofill-parser/src/main/assets/publicsuffixes`)
to tell where a domain's registrable part starts. It was a weekly bot PR upstream; here it is
manual because bot commits on the GitHub mirror would diverge from the main repository.

    ./gradlew updatePSL
    git diff --stat                     # no change: just update the date below
    ./gradlew :autofill-parser:test -PslimTests
    git commit -am "autofill-parser: update public suffix list" && git push

Last run: 2026-11-02.

## Releases

Signed with the maintainer's own key (certificate `CN=HardenedPass`, SHA-256
`72:50:16:66:9E:48:2F:9A:C3:71:DE:E8:DF:19:A2:A1:B3:4F:A7:6D:1A:F0:5B:2A:23:78:47:25:B1:B4:9C:83`).
The master copy lives outside the repo, never in it. Losing it means installed copies can never
be updated again.

- Local: `keystore.jks` and `keystore.properties` at the repo root (gitignored, `/keystore.*`)
  make `./gradlew :app:assembleRelease` produce a signed `app-release.apk`.
- CI: `secrets/keystore.cipher` and `secrets/props.cipher` are those two files encrypted with
  `openssl enc -aes-256-cbc -md sha256 -pbkdf2`; the key is the `ENCRYPT_KEY` repository
  secret. `scripts/signing-setup.sh` decrypts them, `signing-cleanup.sh` deletes them.
- Pushing a `vX.Y.Z` tag runs `deploy_github_releases.yml`: it builds the signed APK and creates
  a draft GitHub Release with `HardenedPass_vX.Y.Z.apk` and its `.sha256`.
- Never print, log or commit the keystore password or `ENCRYPT_KEY`.