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
159
160
161
162
163
164
165
166
167
168
169
170
|
# 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 long-lived branch. Upstream's `develop` was renamed; do not recreate it.
- Temporary exception: the Compose UI overhaul
(`docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md`) lives on `compose` until
it is complete; nothing is released from it. `master` stays releasable (public suffix list,
hotfixes). Remove this bullet when `compose` merges.
- `origin` fetches from the maintainer's own git server (the main repository) and pushes to it
and to the GitHub fork (`danixland/HardenedPass`) 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`, which is all CI and
releases need; a bare `assembleRelease` also builds every library module.
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`. The `aps-test` AVD also has upstream's debug app as its autofill
service, which grabs passphrase fields: `settings put secure autofill_service null` first. Debug
builds from the main checkout are signed with the local release keystore, builds from a clean
worktree with `~/.android/debug.keystore`, so an in-place upgrade between them needs `apksigner`
re-signing.
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`.
- Secrets (Git and proxy credentials, the PGP passphrase cache, wrapped Ed25519 SSH keys) are
AES-GCM encrypted under Android Keystore keys by `util/crypto/KeystorePreferences.kt`.
androidx security-crypto was removed; `removeSecurityCryptoData` in `Migrations.kt` deleted
its data once on upgrade from 0.1.x.
- JGit is on 5.13.x, the last line that needs only Java 8 APIs. JGit 6+ needs Java 11 APIs
Android only partly desugars; moving to it is a large, separate job. Window cache JMX is off
in `Application` and GC runs without JGit's PID lock, because Android has no JMX.
## 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`.
|