Threat Model — Account Merge
Threat matrix
| # | Threat | Impact | Mitigation |
|---|---|---|---|
| T1 | Confused Deputy (T3) — absorb another account with a stolen session | X's data mixed into A's data view | The caller is fixed as the survivor and the input email fixes the absorption direction. dual PoP (session + verified email OTP) |
| T2 | Replay — resend a past request/webhook | duplicate merge | no external surface for T1/T2. OTP 5 min + single-use. Webhook X-Logi-Timestamp ±5 min + event_id dedupe. idempotency_key |
| T3 | Race / concurrent merge | ambiguous canonical | [survivor, merged] ORDER BY id ASC FOR UPDATE. identity_links_no_cycle_trg. 2nd tx → :already_processed |
| T4 | Stale device_secret takeover (T1) | impersonated absorption | T1 requires verifying each SSO's fresh provider id_token. A device_secret alone cannot pass SSO |
| T5 | Cross-provider email squatting (T2) | absorption link on the victim's signup | T2 requires a verified email on both providers. Impossible unless the attacker controls the mailbox |
| T6 | Token takeover after merge | reuse of an absorbed token | within the merge tx, immediately revoke 9 categories of credentials + jti blocklist. RP sessions are cleaned up via the user.merged webhook |
| T7 | Cycle / chain corruption (A→B+B→C) | ambiguous canonical | DB BEFORE INSERT trigger rejects the cycle + app MergeService.guard_cycle! + resolver MAX_DEPTH=10 |
| T8 | OAuth Code Injection / Mix-Up (RFC 6819 §4.4.1.1, RFC 8252 §8.1) | code interception | PKCE enforced + downgrade defense (reject public client secret) + redirect URI exact-match + issuer validation + state echo |
| T9 | Refresh Token Reuse / Theft | token theft | RT rotation + reuse detection → revoke_chain! revokes the entire subsequent chain. HMAC-SHA256 digest stored. User-row lock |
| T10 | RP-to-RP user correlation | cross-RP profiling | Pairwise sub (OIDC Core §8.1): SHA256(sector_id ‖ user_id ‖ app.pairwise_salt)[0..32]. subject_types_supported:["pairwise"] |
| T11 | Audit Log Tampering | forged footprints | AuthenticationAuditLog SHA256 hash chain (previous_hash→current_hash) + verify_chain! + GENESIS_HASH. Limit: a DB superuser could recompute the whole chain → exporting to external immutable storage is recommended |
STRIDE mapping
| STRIDE | Threats |
|---|---|
| Spoofing | T1, T4, T5, T8 |
| Tampering | T7, T11 |
| Repudiation | T11 |
| Information Disclosure | T10 |
| Denial of Service | T3 |
| Elevation of Privilege | T1, T6, T9 |
Security controls
| Control | Threats addressed | Implementation |
|---|---|---|
| PKCE enforced (reject public client downgrade) | T8 | Oauth::AuthorizationsController, Oauth::ClientAuthentication L30-44 |
| Redirect URI exact-match | T8 | Oauth::RedirectUriValidator |
| Issuer validation | T8 | discovery issuer ↔ OIDC_ISSUER env |
| RT rotation + reuse chain revoke | T9 | Oauth::TokensController#rotate_refresh_token L98-101 |
| RT digest (HMAC-SHA256) | T9 | OauthAccessToken#refresh_token_digest |
| Pairwise sub | T10 | Oauth::SubjectIdentifier |
| Hash chain audit log | T11 | Authentication::AuditLogger |
| Merge canonical lock + cycle trigger | T3, T7 | MergeService, identity_links_no_cycle_trg, guard_cycle! |
| 9-category credential revoke on merge | T6 | merge transaction |
| Webhook HMAC + timestamp + event_id | T2 | RP webhook spec |
OTP 5 min + single-use (consumed_at) | T2 | T3 merge OTP |
| Fresh provider id_token verification | T1, T4 | SSO callback |
| Verified email on both providers | T5 | T2 trigger condition |
Metrics
logi_merge_attempts_total{trigger=}logi_merge_failures_total{reason=conflict|cycle|contention|user_in_purge|both_pop_required}logi_t3_merge_otp_dispatches_total{outcome=sent|invalid_target|rate_limited|unverified_email}logi_webhook_delivery_failures_total{rp=, retry_count=}
A spike in cycle / both_pop_required → escalate to an incident.