Commit Graph

547 Commits

Author SHA1 Message Date
r-zakarya 8dc0840e03 fix(android): declare FMX's biometric activity, and stop LoadVault from deleting the cache
The prompt raised ActivityNotFoundException: FMX does not show BiometricPrompt from our NativeActivity, it launches its own BiometricFragmentActivity, which lives in fmx.dex.jar but has to be declared in the manifest. Given a translucent framework theme - it hosts the system dialog and draws nothing itself, and the generated resources contain no AppCompat.

Then the read failed and the cache vanished with no trace. LoadVault sorted 'key lost' from 'not authenticated' on the exception message and deleted the file when it guessed the former - so a wrong guess destroyed the cache and the only record of why. It now reports through LastStoreError and deletes nothing; the caller has the log and the context. OpenLocalNow shows the reason, after ShowUnlock rather than before, which was overwriting it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 14:11:20 +01:00
r-zakarya 1dc4f3bdb6 feat(android): wrap the cache key with an asymmetric Keystore pair
Measured on device: android.security.keystore.UserNotAuthenticatedException on every write. An AES key created with setUserAuthenticationRequired demands a recent authentication to ENCRYPT as well as to decrypt, and the write follows a silent sync with no prompt. The very first write only succeeded because the phone had just been unlocked - a test that passes, then stops passing.

Now an RSA pair: the public half wraps a throwaway AES key with no prompt, the private half - the guarded one - unwraps it. Authentication is required at read time and nowhere else. Two documented traps handled: the public key returned by the Keystore carries the private key's constraints, so it is rebuilt from its encoded form; and that rebuild changes provider, so the OAEP parameters are passed explicitly on both sides - the Keystore defaults MGF1 to SHA-1 where the transformation name suggests SHA-256.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 14:00:35 +01:00
r-zakarya 09048410e1 fix(android): report the biometric failure instead of destroying the cache
The launch-time auto-open received boUnavailable and wiped the cache, seconds after the write path had proved the device could authenticate. The diagnosis was wrong and the reaction irreversible. An automatic action now never clears the vault: it logs the cause and leaves the user on the unlock screen. Only an explicit tap on Open, followed by a clean refusal from the system, may clear it. PMA.Biometric keeps the reason in LastBiometricError, and a Java-layer exception now maps to boFailed rather than boUnavailable - an exception says nothing about the device. The log memo also wraps: it was cutting off exactly the end of the message, which is where the cause is named.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 13:42:07 +01:00
r-zakarya 5187da3070 docs(android): the adb reverse tunnel dies with the USB link 2026-08-15 13:00:28 +01:00
r-zakarya 57a295ad36 fix(android): set the biometric strengths before asking what the device can do
A pattern lock reported "no authentication here", so the cache was written
under the plain key.

FMX builds its authenticator mask from FBiometricStrengths, and that field
starts as the EMPTY SET - the constructor never initialises it. On API 30+
CanAuthenticate therefore calls canAuthenticate(0), which never answers
"available", whatever the device can actually do. Asking before setting the
strengths cannot succeed.

Both entry points now go through AcquireService, which sets Strong +
DeviceCredential and only then hands back the service. DeviceCredential is
the point: a phone with a pattern or a PIN and no enrolled fingerprint is
the common case, not an edge one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 12:48:43 +01:00
r-zakarya 4258e9b559 fix(android): USE_BIOMETRIC, and never lock the cache behind a door we cannot open
Opening the vault threw java.lang.SecurityException: USE_BIOMETRIC is not in
Delphi default permission list, and CanAuthenticate raises instead of
answering false. So the fault showed up neither at compile time nor at
startup, but on the first unlock - after the cache had already been written
under an auth-protected key.

The permission is the shallow half. The deep half is that SaveVault was
called with a hard-coded True: we asked for a key guarded by a prompt that
could not be shown, producing a file that was unreadable forever. It now
receives BiometricAvailable, evaluated on the main thread before the worker
starts.

BiometricAvailable and AuthenticateThen also swallow what comes up from the
Java layer and map it to boUnavailable. The caller has a path for every
TBioOutcome and none for an exception.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 12:09:29 +01:00
r-zakarya 6d31bf83d2 docs(android): the res tree comes from any IDE build, not the 32-bit one 2026-08-15 11:54:29 +01:00
r-zakarya 4805c9ac31 docs(android): building and installing the APK without the IDE 2026-08-15 11:52:44 +01:00
r-zakarya 9a4170c44f feat(android): folder filter, icons, sort, template, updated_at, FLAG_SECURE
Works through the plan's §2 and §3 items that need no hardware and no
platform decision.

- allowBackup="false". The private directory holds vault.bin; a cloud
  backup has no business in a password manager's path, even though what
  would leave is encrypted by a key that stays in the chip.
- FLAG_SECURE, Release builds only - in debug it also blocks development
  screenshots. Complementary to hiding on WillBecomeInactive rather than
  redundant: that one covers the recents preview, the flag also covers
  manual captures, screen recording and casting to an external display.
- List sorted by name (snapshot order is DB order, arbitrary to the eye),
  entry icons decoded once per vault rather than on every keystroke, and a
  folder dropdown whose selection survives a background refresh. No tag
  filter: text search already matches tags.
- template and updated_at read and shown in the detail. updated_at is also
  step 1 of the write path - nothing is arbitrable without it.

Android code is now type-checked without deploying: dccaarm64 reaches the
link step and fails only on the NDK's ldandroid.exe. A JNI declaration that
does not compile no longer waits for an APK to say so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 11:23:29 +01:00
r-zakarya 3bb6f6f903 feat(android): show first, sync after
The local vault now opens by itself at launch and the GET runs behind it,
silently. A 304 shows nothing; a network failure shows nothing either -
offline is a normal state, and an alert on every launch only teaches people
to dismiss alerts. Only a new snapshot manifests, by refreshing the list.

That silence is what puts the sync password inside the Keystore-encrypted
file next to the vault: without it a new snapshot cannot be opened, so every
edit made on the desktop would raise a password prompt at launch. The file
already holds every password in plaintext; this one adds almost nothing to
its exposure. A typed password still wins over the stored one, which is how
you fix one that no longer works.

Adopting a snapshot while the app is already open rebuilds the list in place
instead of navigating - mandatory even from the detail screen, since list
rows carry indices into the old vault and a tap would open the wrong entry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 11:14:44 +01:00
r-zakarya ba0d3c1d3c docs(android): plan of the remaining work 2026-08-15 10:56:49 +01:00
r-zakarya acf4b466e0 fix(android): breathing room under the last detail row 2026-08-15 10:50:07 +01:00
r-zakarya 20ba2438df feat(android): TOTP codes and custom fields in the detail screen
The detail screen showed site, username and password and silently dropped
everything else - custom fields, notes, 2FA. It is now built per entry
rather than from a fixed template: every row is a caption, a value, and
whether it is secret, so reveal and copy exist once instead of once per
kind of field.

Two rows stay special on purpose. A note's body is stored in the password
column, so it gets its own caption and no mask. The 2FA code is the only
live row - refreshed every second, placed first, since it is what you pull
a phone out for.

PMA.Totp takes bare base32 or an otpauth:// URI; both travel in snapshots
depending on how the entry was created. Checked against all six RFC 6238
vectors, including T=20000000000, which overflows 32 bits.

The rows hold plaintext, so clearing them is part of locking, not of
navigating away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 10:35:09 +01:00
r-zakarya 3232f6f163 fix(android): the ETag dies with the plaintext it describes
Unlock, lock, download again: the app sent If-None-Match, got 304, and had nothing to show - on Windows there is no local cache to fall back on, so the only way back in was restarting. FETag outlived the vault it belonged to; LockVault now clears it. The 304 branch also stopped re-reading the cache file (which can fail on an expired authentication) and serves the plaintext it already holds - in memory, or the copy read at the start of the call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 06:04:43 +01:00
r-zakarya 0ddfe5c5fd feat(crypto): wire AES-GCM and PBKDF2 to Windows CNG
The viewer compiled for Win64 but died on first use: AesGcmDecrypt existed
only as JNI, so the desktop build downloaded 27 KB and then refused it.
Windows has the same primitive in bcrypt.dll - no DLL to ship, no OpenSSL,
and BCryptDeriveKeyPBKDF2 takes bytes, so none of the char[] encoding doubt
that keeps the Pascal loop around on Android.

This exists so the client can be exercised on the desktop; deploying an APK
to watch a list redraw is a slow way to build a UI.

selftest.dpr replaces the crypto-vector button the viewer dropped: same
known-answer vector, console, exit code = failures. Measured here - Pascal
1670 ms, CNG 528 ms, identical keys on the non-ASCII password.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 05:59:36 +01:00
r-zakarya bf7aac478b feat(android): read-only viewer - unlock, search, detail
Replaces the crypto test bench with the three screens the client actually
needs. PMA.Vault holds the decrypted snapshot and answers the search;
mainApk builds unlock/list/detail in code rather than three .fmx files.

Two safety rules live in the UI because nowhere else can hold them: the
plaintext is dropped when the app backgrounds (screen hidden on
WillBecomeInactive, which precedes the recents-preview capture), and the
clipboard is cleared after 45 s only if it still holds what we put there.

The search deliberately ignores the password field.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:19:47 +01:00
r-zakarya 4effc3611d feat(android): prefill the sync password from a local, untracked include
Asked for, but it opens the whole vault once the container is downloaded, so it does not belong in a versioned file - even in a repo with no remote. It lives in PMA.LocalConfig.inc, gitignored, with a template checked in so a fresh clone knows what to create.
2026-08-15 01:04:08 +01:00
r-zakarya 3cbfd54c26 fix(android): KeyStore.load takes a LoadStoreParameter, not an Object
The one-argument overload is load(LoadStoreParameter). Declaring JObject made the bridge look for a method that does not exist - reported at runtime as 'method not found', with nothing at compile time. Same class of mistake as generateSecret earlier: the JNI signature is built from the declared types, so an approximate type is a silent break.
2026-08-15 00:56:22 +01:00
r-zakarya 60b806536a fix(android): the prompt field is CancelButtonText, not NegativeButtonText
NegativeButtonText is the Android API name; FMX's record exposes CancelButtonText.
2026-08-15 00:51:24 +01:00
r-zakarya 1e7477cdd6 fix(android): overload both Cipher.init declarations, restore CRLF line endings
Delphi refuses a second declaration when the first is not marked overload, so the three-argument init was reported as too many parameters. Also adds Androidapi.Jni to silence the non-inlined TJavaArray.Length hints.

My ASCII normalisation had rewritten the sources in LF, which RAD Studio flags on every unit; a .gitattributes rule now pins CRLF for Pascal sources so git does not undo it.
2026-08-15 00:49:37 +01:00
r-zakarya 9649dac7b2 feat(android): protect the local vault with biometric or device credential
The Keystore key now requires authentication, valid for 30 s afterwards - short enough that an unattended phone is not exploitable, long enough to decrypt and display. Time-bound rather than a per-use CryptoObject: BiometricPrompt's callback would need a Java class compiled into a jar, while FMX already bridges it.

Two aliases, because the auth constraint is fixed when the key is created and cannot be added later. The file header records which key wrote it, so a read knows whether to authenticate first.

A device with neither fingerprint nor lock screen cannot hold such a key, so SaveVault falls back to the plain key rather than giving up the cache. And a failed authentication no longer wipes the cache: the vault is intact, it is the user who cancelled - erasing it would cost a download and 4 s of derivation for a dismissed prompt.
2026-08-15 00:42:50 +01:00
r-zakarya 5fdb7f914e feat(android): keep the decrypted vault locally under an Android Keystore key
Opening a container costs ~4 s and the iteration count belongs to the desktop format, so the fix is to stop repeating it: derive once per snapshot, then read a local AES file which the hardware accelerates. The key is generated inside the Keystore and never leaves it - the file alone is useless, even rooted.

ETag and vault are encrypted together on purpose: an ETag outliving an unreadable vault would make the server answer 304 with nothing to show. A failed read wipes the cache rather than guessing.

Also normalises every Delphi source to pure ASCII: Delphi reads a BOM-less file as ANSI, so accented comments came back as mojibake, and each PowerShell rewrite added another layer.
2026-08-15 00:37:59 +01:00
r-zakarya 3c1525e46f feat(android): end-to-end read path validated on device, add the container diagnostic
GET in 80 ms, decryption in 3893 ms, entries and trash counted, second call answers 304. try-open.js opens a container with Node so a failure can be attributed: a GCM tag that does not verify means wrong password OR altered file, and the app cannot tell which.
2026-08-15 00:31:01 +01:00
r-zakarya a641669150 fix(android): keep the ETag only when the decryption that followed succeeded
The harness stored the ETag on download, so a first attempt with no password left a valid tag behind: the next call answered 304 and there was nothing to decrypt, precisely when the right password had just been typed. Sending If-None-Match without holding the matching plaintext trades a download for having no data - the real client needs the same rule, the tag belonging to the cached vault.

Also drops an em dash from an exception message: Delphi reads a BOM-less source as ANSI and it reached the device as mojibake.
2026-08-15 00:06:30 +01:00
r-zakarya ccab734d61 fix(android): re-enable the fetch button on every exit path, prefill the test parameters
The Exit in each error branch jumped past the re-enable at the end of the anonymous method, so one failure disabled the button for good. Wrapped in try/finally.

URL and token are now constants, with the port chosen per platform: 80 on Windows against Apache, 8080 on Android through adb reverse. The sync password is never prefilled - it opens the whole vault and this file is versioned.
2026-08-14 23:47:54 +01:00
r-zakarya d9cadf2cac fix(android): create the top-aligned controls in reverse so they appear in order
FMX stacks Align=Top controls in reverse creation order, so the URL field ended up under the buttons.
2026-08-14 23:41:41 +01:00
r-zakarya 8796bb341d feat(android): HTTP client for the sync endpoint, with ETag reuse
Fetches the opaque container and the ETag; a 304 skips both the download and the 4-second derivation, which is the whole point of keeping the tag between calls. Redirects are not followed - one to another host would send the bearer token there.

Local testing goes through adb reverse tcp:8080 tcp:80, which makes the phone reach the PC as 127.0.0.1 and satisfies the endpoint's loopback exemption. That needs usesCleartextTraffic in the manifest, marked for removal before shipping.
2026-08-14 23:37:06 +01:00
r-zakarya c684d9073f feat(android): adopt the platform PBKDF2, record the timing and what follows from it
Measured on device: Android derives the same key as WebCrypto on a non-ASCII password, so PBEKeySpec does encode as UTF-8 - the doubt that justified the Pascal implementation was unfounded. It is barely faster though, 3869 ms against 4139 ms: PBKDF2 gets no hardware acceleration.

So 4 seconds per container is incompressible, and the iteration count belongs to the desktop format. The answer is not to speed up the derivation but to stop repeating it: derive once per snapshot, then keep the vault locally under an Android Keystore key. Written up in android-client/README.md.
2026-08-14 23:30:15 +01:00
r-zakarya 307bbeae83 chore: ignore the generated project resource too 2026-08-14 23:23:28 +01:00
r-zakarya ffe203a1ef chore: untrack the Android build output
The APK, the .o files and the dex are rebuilt on every compile; versioning them adds 12 MB per build and nothing else.
2026-08-14 23:23:16 +01:00
r-zakarya 4cc79a5216 fix(android): declare KeySpec so the native PBKDF2 call resolves, and report why it fails
generateSecret takes a KeySpec; passing a JObject made the bridge emit a signature no method matches, and the call failed at runtime. Swallowed as 'unavailable', which hid my own mistake for a full test round - the failure now carries its exception message.

Measured on device: the Pascal PBKDF2 takes 6651 ms, unusable per unlock. AES-GCM through JNI works, plaintext and hash match the vector, so the container format is validated end to end.
2026-08-14 23:22:56 +01:00
r-zakarya c607e26c4a feat(android): measure the native PBKDF2 against the vector instead of guessing
The Pascal loop takes 1736 ms on a desktop CPU, so several seconds on a phone - too slow for every unlock. SecretKeyFactory is native and roughly an order of magnitude faster, but PBEKeySpec takes a char[] and the provider picks the encoding, which was the reason for avoiding it.

The non-ASCII vector settles it on a real device: same key means the provider uses UTF-8 and we adopt it; a different key means the Pascal implementation stays. DecryptContainer keeps using the Pascal one until that measurement says otherwise.
2026-08-14 18:33:56 +01:00
r-zakarya 386bf74a2a feat(android): crypto self-test harness on the new FMX project
Two stages tested separately, because a global failure says nothing about where: PBKDF2 against the known derived key - which also runs on Win64, so that stage validates in seconds without deploying to a phone - then AES-GCM against the plaintext hash, Android only.

The vector is embedded as a unit rather than read from a file: no network, no deployment, so a first-run failure can only come from the decryption itself. Work runs on a thread; 600k HMAC rounds on the UI thread would have Android kill the app for not responding.
2026-08-14 18:29:12 +01:00
r-zakarya 02d89c58d2 feat(android): container crypto unit plus a known-answer test vector
PBKDF2 is implemented in Pascal rather than through javax.crypto SecretKeyFactory: PBEKeySpec takes a char[] and the provider decides how those chars become bytes, so a non-ASCII sync password would derive a different key than the browser and fail as 'wrong password'. AES-GCM does go through JNI - one call, hardware-backed, and WebCrypto's tag-appended layout is exactly what Cipher expects.

The vector is generated with WebCrypto itself and uses a non-ASCII password on purpose; an ASCII-only vector would have hidden that bug. It records the derived key separately from the plaintext so a failure points at one stage instead of the whole chain.
2026-08-14 18:04:39 +01:00
r-zakarya 90ac75525a fix(sync-php): read Authorization via getallheaders and tolerate ETag forms
Apache commonly strips Authorization before PHP sees it, so every request looked unauthenticated on a stock XAMPP and answered 404.

If-None-Match is now compared on the bare hash: a validator that lost its quotes, or a weak W/ tag, would never match and each request would silently re-download the whole snapshot - expensive on mobile data and invisible in the logs, a 200 looking perfectly normal.
2026-08-14 17:58:48 +01:00
r-zakarya ec4fa433a9 feat(sync): read-only PHP endpoint serving the encrypted snapshot
Alternative to WebDAV, and the base for an Android client. The server never decrypts: it stores and serves opaque bytes, so a compromise yields ciphertext plus size and mtime.

GET only for now, with a strong ETag and If-None-Match; PUT answers 405 explicitly so a client fails loudly rather than believing it wrote. A bad token answers 404 like a missing snapshot, so an unauthenticated caller learns nothing. HTTPS is required - the bearer token rides a header on every call.
2026-08-14 17:41:34 +01:00
r-zakarya c74b708b39 fix(import): count the trash when deduplicating by uuid
The map was built from state.entries only, so a container entry whose uuid existed just in the trash looked fresh and inserted a second row sharing that uuid - the identity the sync engine relies on. Same blind spot as the resurrection bug in applyRemoteSnapshot.

Also documents F9 and warns that emptying the trash is the wrong way to clean duplicate uuids: the tombstone would kill the live twin on the other device.
2026-08-07 17:35:40 +01:00
r-zakarya ebcd75fd2e feat(backup): include the trash in exports and auto-backups
A backup that silently drops the trash is not a backup: export, empty the vault, import, and the trashed entries were gone for good with nothing said. Both containers now carry state.trashed with deleted/deleted_at, and deleted_at is preserved so a restore resumes the auto-purge clock instead of restarting it - hence its addition to the GET echo.

The sync snapshot deliberately does NOT: the trash is device-local, and re-publishing deleted rows is what produced the duplicate-uuid bug. The two identical export loops were folded into buildBackupEntry first, so the trash handling exists once.
2026-08-07 15:04:41 +01:00
r-zakarya 125cb11756 docs(tests): mark I6 validated — every scenario now passes 2026-08-07 14:55:47 +01:00
r-zakarya afe47259d7 fix(unlock): focus the username first when it is empty, on the quick-search path too
The autofill hotkey already picked whichever field still needed filling; quick search forced the password, so a first unlock or one without a remembered username landed the cursor in the second field.
2026-08-07 14:53:36 +01:00
r-zakarya 476d6660ad fix(hotkeys): a locked vault must not hand the foreground back
Both locked-vault paths cancelled the autofill with the default refocus, which returns the foreground to the captured target - right after Delphi had raised our window. Ctrl+Shift+Q flashed for a frame then Notepad took over; Ctrl+Shift+L moved the window twice because focusApp had to take it back.
2026-08-07 14:48:12 +01:00
r-zakarya d242f09c53 docs(tests): mark F1/F3/F4 validated, section F complete 2026-08-07 12:50:14 +01:00
r-zakarya c3c6bce406 fix(sync): a trashed entry is no longer resurrected as a duplicate
The trash is a soft delete: no tombstone is written and the rows are absent from state.entries, so the merge read their uuids as missing locally and POSTed them back as fresh live entries. Trashing 30 entries then syncing produced 30 live duplicates alongside the trashed originals - two rows sharing one uuid.

applyRemoteSnapshot now loads the trash and skips any uuid found there. Emptying the trash is what writes the tombstone that propagates the delete.
2026-08-07 12:38:07 +01:00
r-zakarya b9b098fe12 fix(editor): fire an input event when the generator fills the password
Assigning .value fires nothing, and the code called soDirtyCheck by hand - so the strength bar, which listens to input, stayed frozen on the previous password. Dispatching the event covers every listener on the field instead of one named handler.
2026-08-07 12:03:42 +01:00
r-zakarya 485dd3631e docs(tests): E5 validated by code inspection, distinguish it from E9
Web storage and process memory are different artefacts; the dump scan says nothing about the former. Also flags the removeItem('cryptoKey') cleanup as deliberate, not dead code.
2026-08-07 11:46:18 +01:00
r-zakarya c1e5d14eac docs(tests): mark H2-H6 validated 2026-08-07 11:02:05 +01:00
r-zakarya af1157f3b0 docs(tests): note that the trash is silently excluded from every backup path 2026-08-03 00:41:15 +01:00
r-zakarya 5236aa8b98 fix(busy): gate the document-level shortcuts too
inert removes elements from focus and hit testing, but shortcuts are listened for on the document and never traverse an element. Ctrl+A still selected every entry under the overlay, Ctrl+K would open the palette behind it, j/k moved the cursor - and Delete ran a batch delete while a sync was writing to the same rows.
2026-08-03 00:04:56 +01:00
r-zakarya a36164174e docs(tests): record the four secret-consuming paths and how to test them
Includes the authenticated WsgiDAV recipe (Basic only, digest off) and the change-event race that can produce a misleading 401.
2026-08-03 00:02:27 +01:00
r-zakarya c8518c806d docs(tests): mark F2 validated, note that the file existing proves nothing
A backup encrypted with an empty password looks perfectly valid on disk; only a successful import proves the stored password reached the encryption.
2026-08-02 23:55:04 +01:00