What happened
During Group D's device-hardware row 6 testing (key/pin survival, revoke-then-re-pair), a paired E2EE device was revoked server-side (revoked_at set). The app correctly surfaced the break — "Unauthorized — the server rejected the API key for /api/info", no crash, no plaintext fallback — good behavior on that front.
Streamer design.md §4.4 states the intended recovery path explicitly:
Re-pairing a revoked device is allowed and clears revoked_at ... The same D_priv still identifies the device, so the existing row is restored rather than duplicated.
The streamer side implements exactly this: devices.repository.ts's register() looks up the row by e2ee_static_pub (byE2eeStaticPubStmt) and, when it finds one, runs repairStmt — an UPDATE that clears revoked_at on the existing row rather than inserting a new one.
The gap
The app has no way to reach that recovery path.
- Re-scanning the same pairing URL while the revoked server is still locally listed is blocked client-side with "Server already added — delete that server first if you want to add it again," regardless of the server's revoked state.
- The only reachable UI path is delete → re-add. But
removeServer (stores/servers.ts) calls clearDeviceStaticKey(serverId), wiping D_priv from SecureStore. editServer does the same (stores/servers.ts:487-488).
- So a re-add after delete always presents a fresh, random static key —
serverIdFromUrl-keyed storage means the phone can never present the same e2ee_static_pub again for that server, and the streamer's repairStmt path is therefore structurally unreachable from this app.
Evidence (reproduced on hardware)
Cabled physical iPhone, streamer 1.70.6, scratch-isolated rig. Revoked a paired device (60d2ed51...) server-side, confirmed via GET /api/devices. Deleted the server in-app, re-added via a fresh pairing link to the identical URL. Server-side result: a genuinely new device row (668b05df..., revokedAt: null) was created; the original revoked row stays revoked forever, orphaned (compounds threadbase-streamer#744's dead-row accumulation — every revoke-then-recover attempt through the app leaves one more permanently-dead row rather than reusing one).
Full trace: tb-e2ee-program/tracks/D/PLAN-D.md §13, row 6 (Group D, device evidence track).
Suggested follow-up (not scoped here)
A re-authorize affordance that keeps D_priv: re-scanning an existing (or revoked) server's QR/link should present the stored static key rather than routing through the destructive delete flow, so the app can actually exercise the streamer's own §4.4 recovery path. clearDeviceStaticKey should probably stay reserved for an explicit "forget this device" action distinct from ordinary remove/edit, or the block/gate on re-scanning an already-known URL needs a "revoked — re-authorize?" branch instead of a hard "already added" refusal.
Filed per Group D / D1's "no code changes — file an issue and report" rule.
What happened
During Group D's device-hardware row 6 testing (key/pin survival, revoke-then-re-pair), a paired E2EE device was revoked server-side (
revoked_atset). The app correctly surfaced the break — "Unauthorized — the server rejected the API key for /api/info", no crash, no plaintext fallback — good behavior on that front.Streamer design.md §4.4 states the intended recovery path explicitly:
The streamer side implements exactly this:
devices.repository.ts'sregister()looks up the row bye2ee_static_pub(byE2eeStaticPubStmt) and, when it finds one, runsrepairStmt— anUPDATEthat clearsrevoked_aton the existing row rather than inserting a new one.The gap
The app has no way to reach that recovery path.
removeServer(stores/servers.ts) callsclearDeviceStaticKey(serverId), wipingD_privfrom SecureStore.editServerdoes the same (stores/servers.ts:487-488).serverIdFromUrl-keyed storage means the phone can never present the samee2ee_static_pubagain for that server, and the streamer'srepairStmtpath is therefore structurally unreachable from this app.Evidence (reproduced on hardware)
Cabled physical iPhone, streamer
1.70.6, scratch-isolated rig. Revoked a paired device (60d2ed51...) server-side, confirmed viaGET /api/devices. Deleted the server in-app, re-added via a fresh pairing link to the identical URL. Server-side result: a genuinely new device row (668b05df...,revokedAt: null) was created; the original revoked row stays revoked forever, orphaned (compounds threadbase-streamer#744's dead-row accumulation — every revoke-then-recover attempt through the app leaves one more permanently-dead row rather than reusing one).Full trace:
tb-e2ee-program/tracks/D/PLAN-D.md§13, row 6 (Group D, device evidence track).Suggested follow-up (not scoped here)
A re-authorize affordance that keeps
D_priv: re-scanning an existing (or revoked) server's QR/link should present the stored static key rather than routing through the destructive delete flow, so the app can actually exercise the streamer's own §4.4 recovery path.clearDeviceStaticKeyshould probably stay reserved for an explicit "forget this device" action distinct from ordinary remove/edit, or the block/gate on re-scanning an already-known URL needs a "revoked — re-authorize?" branch instead of a hard "already added" refusal.Filed per Group D / D1's "no code changes — file an issue and report" rule.