Skip to content

fix(ios): App Attest key recovery, and automate the FTC complaint import - #24

Open
jbrahy wants to merge 2 commits into
mainfrom
fix/appattest-stale-key-recovery
Open

fix(ios): App Attest key recovery, and automate the FTC complaint import#24
jbrahy wants to merge 2 commits into
mainfrom
fix/appattest-stale-key-recovery

Conversation

@jbrahy

@jbrahy jbrahy commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

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.invalidKey on every report submission and could not recover. Captured from an instrumented build on hardware:

validToken: storedToken=present expiry=2026-08-28 01:08:42 +0000 storedKeyID=present
validToken: -> refresh()
refresh: using stored keyID prefix=l6NI1Tyc
refresh: generateAssertion FAILED domain=com.apple.devicecheck.error code=3

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 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-environment entitlement between development and production. The Keychain items outlive that change, so a stored identity cannot be assumed valid forever.

The change

  • 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.

Verification

  • 116 unit tests, 1 skipped, 0 failures. Four new tests in EnrollmentRecoveryTests cover: recovery via the refresh path (the exact device state), recovery via the enroll path, one-shot recovery that does not loop, and the DCError mapping boundary.
  • The new tests go red first for the right reason, green after the fix.
  • Verified on device: the report that previously failed now submits.

2. feat(seed): automate the FTC complaint import on a daily timer

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.

Non-obvious things the script encodes

  • User agent is mandatory. www.ftc.gov answers curl's default agent with HTTP 403.
  • Seven-day search window. Dates are UTC and the FTC publishes per weekday, so "today" is routinely a date that does not exist yet. 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.
  • Newest file only. Imports are idempotent, but each seeder run ends in a full RecomputeAllNumbers, so N files would mean N recomputes for no benefit.
  • A 200 without Company_Phone_Number in 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.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 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_weight default from #23), which production had not yet picked up.


CI is red, and it is not from this branch

main has been failing since 52184d5 (#23). That commit changed new-device trust_weight from 1.0 to 0.5 but did not update internal/api/e2e_test.go:72, which still asserts 3 scam reports block a number:

3 x (BaseWeight 1.0 x trust 0.5 x scam 2.0) = 3.0  ->  "label", not "block"

Run 34516443014 went 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.testLookupFlow also fails identically on a pristine main. Both are pre-existing and untouched here.

@jbrahy jbrahy changed the title fix(ios): recover from an unusable stored App Attest key fix(ios): App Attest key recovery, and automate the FTC complaint import Sep 12, 2026
jbrahy and others added 2 commits September 11, 2026 21:51
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
jbrahy force-pushed the fix/appattest-stale-key-recovery branch from d472045 to 29f5ca5 Compare September 12, 2026 04:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant