Commit Graph

17 Commits

Author SHA1 Message Date
r-zakarya 2fa093d49c feat(android): a vault born here can be given a repository
Until now the two situations never met: a local vault had no repository, a
pulled vault had no local container. The day both exist, the rule "the local
container is the authority" means saves never reach the repository - and the
two diverge in silence, which is the worst outcome available.

Sync now merges them once and then deletes the local container: the
repository becomes the authority and the device behaves like any other. That
makes divergence impossible rather than unlikely. Order matters - publish
first, delete after; the reverse would lose the whole vault if the publish
failed.

The remote is the base, never the local. It carries what the phone cannot
rebuild: attachment bodies, the avatar, folder colours, and whatever the
desktop adds next. Starting from the local would amputate all of it, and the
desktop would adopt the amputated version on its next pull.

Arbitration is the desktop's, term for term: last write wins on updated_at, a
tombstone beats an older entry but not one edited after it - that one was
revived on purpose. Timestamps are ISO 8601 UTC on both sides, milliseconds
included, so lexicographic order IS chronological order.

MergeSelfCheck asserts the four rules at startup in debug. The merge never
runs against a real vault before it has run there.
2026-08-21 17:47:58 +01:00
r-zakarya 9a32fe8881 perf(android): the derived key is kept in the cache
Opening from the cache derives nothing, so the key learned last session was
gone and the FIRST save of every session paid the 4.5 seconds again. It now
travels in the sealed blob, next to the plaintext vault and to the password
that opens the container - that neighbourhood is what makes it acceptable:
it exposes nothing the file does not already hold.

The three parts go together or not at all. A key without its salt does not
say what it fits, which makes it useless, or worse, used where it does not
belong. A cache written by an earlier version carries none: LoadVault
returns nil, one derivation happens, and the next write stores it.

A freshly registered vault still derives once - EncryptContainer does not
hand back the key it just derived.

The plan records what the desktop confirmed: the two entries created on the
phone imported with nothing to correct.
2026-08-21 14:54:05 +01:00
r-zakarya f32cde81fc feat(android): a local account, so the app serves without a repository
Until now the phone could only read a vault that came from elsewhere:
without a URL, a token and a sync password it showed nothing. It now
signs in like the desktop does - username and master password - and can
create its own vault, with no network at all.

The local container carries exactly what the repository would carry: same
format, same encryption under the password. Two reasons, not one. It is
the desktop's model, where the master password protects the vault at rest
rather than the hardware alone; and the format staying the repository's
means sync keeps working with nothing to convert. No Keystore on that
file - it is already encrypted under the password, and binding it to the
device would make it unrecoverable after a reinstall. vault.bin keeps
that role: it is the fast-open cache, and only it needs sealing.

The password opens the container - the decryption itself is the check, no
secret is ever compared - and the username, read from inside once open,
says whose vault it is.

The home screen now knows which of three situations it is in: nothing
here yet, a container without a cache, or a cache ready to open.

Verified on the device: with the cache set aside, the screen offers
username, master password and Create vault, alongside the download path.
Creating an account could not be exercised to the end - the device locked
its screen mid-run - and the vault that was set aside is back in place,
same size, still sealed under its code.
2026-08-21 14:07:39 +01:00
r-zakarya afc6119cf8 fix(android): the unlock code is asked for, and never undone in silence
Two defects, one feature made useless. The cache did seal under the code,
but nothing on the home screen asked for it: opening without one failed,
the failure was read as corruption, and the device offered to download a
vault it held perfectly well. And the next refresh rewrote the cache in
the old shape, so the code was undone seconds after being set, silently.

The code now lives on the home screen, not among the server settings: it
seals this device's cache, while the other three fields describe the
shared remote. Always visible - hiding it when none is set would remove
the only place to set one - the hint saying which case you are in. An
absent code is no longer attempted, a wrong one is no longer corruption,
and a sealed cache is never rewritten without its code.

The two panels each got an arrow, and only one opens at a time.

Measured end to end on the device: version byte 1, code typed, open, byte
4; cold start says "Unlock code required" and open without the code
leaves the byte at 4; correct code opens the vault, and the refresh that
follows leaves it at 4.
2026-08-21 13:04:48 +01:00
r-zakarya a41eeb8bfa feat(android): an unlock code that survives a screen-lock change
The reported pain: changing the pattern destroys the local cache every time.
That is Android doing exactly what it promises - a key created with
setUserAuthenticationRequired is invalidated the moment the screen lock
changes, so a new lock cannot open data sealed under the old one. No storage
format changes that, which is why SQLite would not have helped: the problem is
what the key is tied to, not what the file looks like.

So the cache gains a third sealing format, VER_PIN, with two layers:

  inner - AES-GCM under PBKDF2(code, 100k) : it takes the code
  outer - the Keystore AES key WITHOUT the auth constraint : it takes the device

The outer key is never invalidated, so the file survives any change to the
screen lock; the code restores the protection that removing the constraint
would otherwise cost. 100k iterations rather than 600k because a four to six
digit code carries little entropy - more rounds mostly slow the honest user,
and the real barrier is the outer layer requiring this phone.

The code lives in a fourth settings field and is NEVER stored: empty at every
launch, typed to open, and used to re-seal on every write. That is what
separates it from the token, whose persistence is a convenience with no
consequence since it only opens ciphertext.

Built but NOT installed - the phone went off the network.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 12:10:51 +01:00
r-zakarya d472dd4ef8 fix(android): encryption cannot see a dead key, so stop pretending it can
The retry I added yesterday never fired, and could not: the public key is
rebuilt through KeyFactory, so it is an ordinary RSA key with no link to the
Keystore. It encrypts perfectly well while its private half is permanently
invalidated. The re-seal therefore sealed the cache with a key nothing would
ever open again - the app asked for the sync password instead of the pattern.

Invalidation is observable at exactly one moment: reading with the private
key. LoadVault now drops the alias when the system names the cause, so the
next write mints a fresh pair instead of sealing another unopenable file.

And the caller that re-seals a plain cache asks for a fresh key outright:
protection reappearing on the device is precisely the event that invalidated
the old one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 18:21:50 +01:00
r-zakarya e4a50df64d fix(android): protection catches up with the cache, and a dead key gets replaced
After the screen lock was removed and put back, the app stopped asking for the
pattern. Two gaps, both mine.

The cache had been rewritten while the device had no lock, so under the plain
key - and nothing ever moves it back. Protection was decided at write time
only, and a write happens only when a new snapshot arrives; a 304 left the
file in the clear indefinitely. It is now re-sealed on open, where the
plaintext is already in hand.

And the invalidated RSA key was never replaced. Removing a lock, or enrolling
a new fingerprint, permanently invalidates a protected key while its alias
survives. EnsureWrapKey only created the pair when the alias was absent, so it
kept reusing a dead key: every encryption failed and every write fell back to
the plain key, forever. It now deletes and regenerates on a second attempt.

Measured on device: header 1 before, 3 after the first launch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 18:11:48 +01:00
r-zakarya bc89ec0010 i18n(android): the interface speaks English, like the desktop app
It was in French because the conversation is, and I never asked. The desktop
app the client mirrors says Add field, Attach file, Show previous versions -
someone moving between the two would notice the seam.

Every visible string moved: buttons, statuses, detail row captions, log lines
and the biometric prompt, plus the messages the modules surface through
LastStoreError and LastBiometricError.

Code comments stay in French. They address the author, not the user, and that
is already the language of CLAUDE.md and much of the repo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 17:59:31 +01:00
r-zakarya 49efccdd59 feat(android): settings live on the device, dark theme, and the app is called Coffre
URL and token are no longer constants. They sit in settings.bin, sealed with the no-auth Keystore AES key - deliberately weaker than the vault: they must be readable to rebuild a vault that no longer exists, and a bearer token only opens ENCRYPTED data, worthless without the sync password that stays in the guarded vault. Retyping 64 hex characters after every cache purge was the real risk: the risk of writing them down elsewhere. They are saved only after a fetch that worked - proven settings, not typed ones.

The token and sync password moved to the gitignored local include, as first-run defaults only. Tracked source now carries no secret, and leaving them empty is a viable configuration: the app asks.

Theme: Delphi's Dark.style, copied into the repo and linked as a resource rather than deployed as a file - this project's deployment puts nothing beside the app, which is the same hole that forces the manifest and dex staging. A failure to load is never fatal; the default look is ugly but works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 15:33:56 +01:00
r-zakarya c4d2bdde73 fix(android): OAEP masks with SHA-1, which is the only MGF1 the key allows
The read failed with IllegalBlockSizeException and, in logcat, KeymasterUtils status -28. setDigests() authorises the MESSAGE digest only; the MGF1 digest is a separate authorisation whose default is SHA-1 and stays SHA-1 unless setMgf1Digests is called (API 31+). Asking for MGF1-SHA-256 at decrypt time therefore hands the hardware a key that refuses.

Encryption did not complain, because the rebuilt public key is served by the software provider, which accepts any MGF1. So we produced perfectly valid ciphertext that the hardware would not read - the worst shape of bug, since it only surfaces on the next launch. Both sides now pass SHA-256 for the message and SHA-1 for the mask; SHA-1 there weakens nothing, a mask generation function needs no collision resistance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 14:17:26 +01:00
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 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 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 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