fix(ios): App Attest key recovery, and automate the FTC complaint import - #24
Open
jbrahy wants to merge 2 commits into
Open
fix(ios): App Attest key recovery, and automate the FTC complaint import#24jbrahy wants to merge 2 commits into
jbrahy wants to merge 2 commits into
Conversation
A device hit DCError.invalidKey on every report submission and could not recover. The Keychain held a device token expired since 2026-08-28 and a key ID from an earlier install; because the token was present but stale, validToken() chose refresh(), which asked DeviceCheck to assert a Secure Enclave key that no longer existed in this install. Nothing ever called TokenStore.clear(), so every retry reused the same dead key ID and failed identically -- the app was permanently unable to report until it was deleted. The Secure Enclave key stops being usable whenever the install's App Attest identity changes (a reinstall, or a build switching the appattest-environment entitlement), but the Keychain items outlive that change. A stored identity therefore cannot be assumed valid forever. - AttestationProvider: add AttestationProviderError.keyUnusable and mapDeviceCheckError, translating ONLY DCError.invalidKey. Transient codes such as serverUnavailable pass through unchanged so an outage never discards a working identity. - EnrollmentService.validToken(): on keyUnusable, clear the stored identity and enrol once with a genuinely new key. A second failure propagates rather than looping against Apple's servers. Verified on device: the report that previously failed now submits. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBmEXErCWYt3WZCZKaS7m9
cmd/seed already imported FTC/FCC complaint CSVs, but nothing built it,
shipped it, or ran it, so the blocklist stayed empty of public data.
- build-linux-arm64 and scripts/deploy.sh now produce and ship
bin/hushield-seed alongside the server and recompute binaries.
- scripts/seed-ftc-daily.sh downloads the newest published FTC Do Not
Call CSV and imports it.
- deploy/hushield-seed-ftc.{service,timer} run that daily at 18:30 UTC,
mirroring the existing recompute unit pair.
Three things the script encodes that are not obvious:
www.ftc.gov answers curl's default user agent with HTTP 403, so a
browser agent is mandatory to fetch the file at all.
Dates are UTC and the FTC publishes per weekday, so "today" is routinely
a date that does not exist yet. The script searches back up to seven
days for the newest file that does. Seven is not arbitrary: a three-day
window on a Sunday evening UTC sees only Mon/Sun/Sat, finds nothing, and
would fail a run that is actually healthy. Seven always spans a
published weekday, even across Thanksgiving or Christmas.
Only the newest file is imported, not every file in the window. Imports
are idempotent, but each seeder run ends in a full RecomputeAllNumbers,
so N files would mean N recomputes for no benefit.
A 200 whose header lacks Company_Phone_Number is treated as fatal. That
is an HTML error page, and importing it would silently skip every row
while reporting success.
SEED_TRUST is 5.0 with category scam: 1.0 x 5.0 x 2.0 = 10.0, twice
BlockThreshold, so seeded numbers are BLOCKED on import rather than
labelled. This is a deliberate product decision and the unit file states
the trade-off plainly -- the FTC does not verify this data, and a
complaint records the caller ID that was displayed, which is frequently
spoofed.
Deployed by hand ahead of this commit: 10,290 FTC numbers and a
722,291-number FCC backfill are already loaded in production, and the
timer is enabled.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBmEXErCWYt3WZCZKaS7m9
jbrahy
force-pushed
the
fix/appattest-stale-key-recovery
branch
from
September 12, 2026 04:52
d472045 to
29f5ca5
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two independent changes. The iOS fix came first; the seeder work was added to the same branch at the maintainer's request. They touch disjoint files and can be reviewed separately.
1. fix(ios): recover from an unusable stored App Attest key
The bug
A device hit
DCError.invalidKeyon every report submission and could not recover. Captured from an instrumented build on hardware:The Keychain held a device token expired since 2026-08-28 and a key ID from an earlier install. Because the token was present but stale,
validToken()choserefresh(), which asked DeviceCheck to assert a Secure Enclave key that no longer existed in this install.Nothing ever called
TokenStore.clear(), so every retry reused the same dead key ID and failed identically. The app was permanently unable to report until deleted.Why the stored identity goes bad
The Secure Enclave key stops being usable whenever the install's App Attest identity changes -- a reinstall, or a build switching the
appattest-environmententitlement betweendevelopmentandproduction. The Keychain items outlive that change, so a stored identity cannot be assumed valid forever.The change
AttestationProvider: addAttestationProviderError.keyUnusableandmapDeviceCheckError, translating onlyDCError.invalidKey. Transient codes such asserverUnavailablepass through unchanged, so an outage never discards a working identity.EnrollmentService.validToken(): onkeyUnusable, clear the stored identity and enrol once with a genuinely new key. A second failure propagates rather than looping against Apple's servers.Verification
EnrollmentRecoveryTestscover: recovery via therefreshpath (the exact device state), recovery via theenrollpath, one-shot recovery that does not loop, and theDCErrormapping boundary.2. feat(seed): automate the FTC complaint import on a daily timer
cmd/seedalready imported FTC/FCC complaint CSVs, but nothing built it, shipped it, or ran it, so the blocklist stayed empty of public data.build-linux-arm64andscripts/deploy.shnow produce and shipbin/hushield-seedalongside the server and recompute binaries.scripts/seed-ftc-daily.shdownloads the newest published FTC Do Not Call CSV and imports it.deploy/hushield-seed-ftc.{service,timer}run that daily at 18:30 UTC, mirroring the existing recompute unit pair.Non-obvious things the script encodes
www.ftc.govanswers curl's default agent with HTTP 403.RecomputeAllNumbers, so N files would mean N recomputes for no benefit.Company_Phone_Numberin the header is fatal. That is an HTML error page, and importing it would silently skip every row while reporting success.Scoring decision
SEED_TRUST=5.0with categoryscam:1.0 x 5.0 x 2.0 = 10.0, twiceBlockThreshold, so seeded numbers are blocked on import rather than labelled. This is a deliberate product decision by the maintainer. The unit file states the trade-off plainly: the FTC does not verify this data, and a complaint records the caller ID that was displayed, which is frequently spoofed, so an innocent third party whose number was spoofed is blocked on one stranger's complaint. With the 30-day half-life a seeded number blocks for ~30 days, labels for ~40 more, and ages out near day 70.Already deployed
Applied to production by hand ahead of this PR: 10,290 FTC numbers loaded and blocked, a 722,291-number FCC backfill running, and the timer enabled. Running the seeder also applied migration 0006 (the
devices.trust_weightdefault from #23), which production had not yet picked up.CI is red, and it is not from this branch
mainhas been failing since52184d5(#23). That commit changed new-devicetrust_weightfrom 1.0 to 0.5 but did not updateinternal/api/e2e_test.go:72, which still asserts 3 scam reports block a number:Run
34516443014went red on main when #23 merged. This branch inherits that failure; no Go files are changed here.Worth noting the behavioural consequence the stale assertion was hiding: a number now needs 5 fresh-device scam reports to auto-block, up from 3.
SpamFilterUITests.testLookupFlowalso fails identically on a pristinemain. Both are pre-existing and untouched here.