Two findings from checking Android against what the iPhone app fixed on 2026-09-25.
1. The parental PIN was the iPhone finding, line for line
ParentalControls.kt hashed the PIN with one round of SHA-256, salt beside it, in plaintext preferences, compared with ==. Four digits is ten thousand candidates, so whoever could read the file had the PIN.
Now stretched with AccountKdf.master: PBKDF2-SHA256 at 650,000 iterations, the account passphrase's function and the one the iPhone moved to.
Compared with MessageDigest.isEqual, constant time.
A PIN set before this still opens, and is rewritten the first time it is entered. That is the only moment the PIN is in hand to do it.
The rewrite changes only the three PIN fields. Not enable, which turns every gate back on, so a parent who had released a gate does not find it restored.
2. allowBackup="false" stopped doing what the manifest says
The manifest turns backup off because the vault's key lives in the Keystore and never travels, so restoring the encrypted records elsewhere makes a vault nothing can open, presenting as a corrupt install.
From Android 12 that is not enough. Android's documentation, for apps targeting 31 or higher: on some manufacturers' devices it "disables cloud-based backup and restore ... but doesn't disable device-to-device transfers for the app." This app targets 35. A phone-to-phone move could carry the vault records without their key — the exact failure the comment exists to prevent — and the plaintext preferences with them.
data_extraction_rules.xml now excludes every domain, device-protected ones included, from both cloud backup and device transfer. Both sections are spelled out because the rules fail open: "If there are no rules for a particular backup mode, such as if the <device-transfer> section is missing, that mode is fully enabled for all content."
No cross-platform-transfer: it exists from API 36.1, this compiles against 35, and it only sends to an iOS app named by bundle and team id, so naming none names no destination. The file says to revisit that at a 36.1 target rather than assume.
allowBackup stays; before Android 12 it is still the whole answer.
Tests
The PIN tests read the raw preferences rather than asking verify, since a verify that still hashed once would pass every right-PIN/wrong-PIN check. The rules test checks all nine domains in both sections and that nothing is included, rather than that the file exists.
Falsified three ways, each failing the test it should and nothing else: reverting to one SHA-256 (two PIN tests), routing the upgrade through enable (the gates test), deleting the real <device-transfer> element (the rules test).
The first attempt at that last one was invalid and is worth recording. The regex also matched <device-transfer> inside the file's own comment, produced broken XML, the build failed, and the harness read the previous run's result files as a pass. Result files are now deleted before every run, so a build that doesn't run can't report green.
App unit suite: 347 tests — the full count of @Test in app/src/test — no failures, errors or skips.
Not in this MR
This reaches people only in the next Android build. Whether and when to cut one is a separate call.
Two findings from checking Android against what the iPhone app fixed on 2026-09-25.
### 1. The parental PIN was the iPhone finding, line for line
`ParentalControls.kt` hashed the PIN with **one round of SHA-256**, salt beside it, in plaintext preferences, compared with `==`. Four digits is ten thousand candidates, so whoever could read the file had the PIN.
- Now stretched with `AccountKdf.master`: PBKDF2-SHA256 at **650,000** iterations, the account passphrase's function and the one the iPhone moved to.
- Compared with `MessageDigest.isEqual`, constant time.
- A PIN set before this still opens, and is rewritten the first time it is entered. That is the only moment the PIN is in hand to do it.
- The rewrite changes **only the three PIN fields**. Not `enable`, which turns every gate back on, so a parent who had released a gate does not find it restored.
### 2. `allowBackup="false"` stopped doing what the manifest says
The manifest turns backup off because the vault's key lives in the Keystore and never travels, so restoring the encrypted records elsewhere makes **a vault nothing can open, presenting as a corrupt install**.
From Android 12 that is not enough. [Android's documentation](https://developer.android.com/guide/topics/data/autobackup), for apps targeting 31 or higher: on some manufacturers' devices it *"disables cloud-based backup and restore ... but doesn't disable device-to-device transfers for the app."* This app targets **35**. A phone-to-phone move could carry the vault records without their key — the exact failure the comment exists to prevent — and the plaintext preferences with them.
`data_extraction_rules.xml` now excludes every domain, device-protected ones included, from **both** cloud backup and device transfer. Both sections are spelled out because the rules fail open: *"If there are no rules for a particular backup mode, such as if the `<device-transfer>` section is missing, that mode is fully enabled for all content."*
No `cross-platform-transfer`: it exists from API 36.1, this compiles against 35, and it only sends to an iOS app named by bundle and team id, so naming none names no destination. The file says to revisit that at a 36.1 target rather than assume.
`allowBackup` stays; before Android 12 it is still the whole answer.
### Tests
The PIN tests read the **raw preferences** rather than asking `verify`, since a `verify` that still hashed once would pass every right-PIN/wrong-PIN check. The rules test checks all nine domains in both sections and that nothing is included, rather than that the file exists.
Falsified three ways, each failing the test it should and nothing else: reverting to one SHA-256 (two PIN tests), routing the upgrade through `enable` (the gates test), deleting the real `<device-transfer>` element (the rules test).
The first attempt at that last one was invalid and is worth recording. The regex also matched `<device-transfer>` inside the file's own comment, produced broken XML, the build failed, and the harness read the previous run's result files as a pass. Result files are now deleted before every run, so a build that doesn't run can't report green.
**App unit suite: 347 tests** — the full count of `@Test` in `app/src/test` — no failures, errors or skips.
### Not in this MR
This reaches people only in the next Android build. Whether and when to cut one is a separate call.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Two findings from checking Android against what the iPhone app fixed on 2026-09-25.
1. The parental PIN was the iPhone finding, line for line
ParentalControls.kthashed the PIN with one round of SHA-256, salt beside it, in plaintext preferences, compared with==. Four digits is ten thousand candidates, so whoever could read the file had the PIN.AccountKdf.master: PBKDF2-SHA256 at 650,000 iterations, the account passphrase's function and the one the iPhone moved to.MessageDigest.isEqual, constant time.enable, which turns every gate back on, so a parent who had released a gate does not find it restored.2.
allowBackup="false"stopped doing what the manifest saysThe manifest turns backup off because the vault's key lives in the Keystore and never travels, so restoring the encrypted records elsewhere makes a vault nothing can open, presenting as a corrupt install.
From Android 12 that is not enough. Android's documentation, for apps targeting 31 or higher: on some manufacturers' devices it "disables cloud-based backup and restore ... but doesn't disable device-to-device transfers for the app." This app targets 35. A phone-to-phone move could carry the vault records without their key — the exact failure the comment exists to prevent — and the plaintext preferences with them.
data_extraction_rules.xmlnow excludes every domain, device-protected ones included, from both cloud backup and device transfer. Both sections are spelled out because the rules fail open: "If there are no rules for a particular backup mode, such as if the<device-transfer>section is missing, that mode is fully enabled for all content."No
cross-platform-transfer: it exists from API 36.1, this compiles against 35, and it only sends to an iOS app named by bundle and team id, so naming none names no destination. The file says to revisit that at a 36.1 target rather than assume.allowBackupstays; before Android 12 it is still the whole answer.Tests
The PIN tests read the raw preferences rather than asking
verify, since averifythat still hashed once would pass every right-PIN/wrong-PIN check. The rules test checks all nine domains in both sections and that nothing is included, rather than that the file exists.Falsified three ways, each failing the test it should and nothing else: reverting to one SHA-256 (two PIN tests), routing the upgrade through
enable(the gates test), deleting the real<device-transfer>element (the rules test).The first attempt at that last one was invalid and is worth recording. The regex also matched
<device-transfer>inside the file's own comment, produced broken XML, the build failed, and the harness read the previous run's result files as a pass. Result files are now deleted before every run, so a build that doesn't run can't report green.App unit suite: 347 tests — the full count of
@Testinapp/src/test— no failures, errors or skips.Not in this MR
This reaches people only in the next Android build. Whether and when to cut one is a separate call.
🤖 Generated with Claude Code
mentioned in commit
b75d6a948b