Independent engineering project / Authentication Migration

Authentication Migration

A .NET authentication-coexistence lab covering Forms cookies, WS-Federation and OIDC. Examines claims mapping, rehash-on-login upgrades, downgrade controls and rollback assumptions.

Contribution
Independent design, implementation and evaluation
Languages
C#
Engineering focus
Auth stack coexistence / Rehash-on-login / Threat modelling

Scope. Self-directed modernization study measured in a local or simulated environment; not a customer engagement, and not evidence of production migration outcomes.

Implementation & evaluation

The project README is reproduced below, with links to the implementation, design records and operating instructions. Scenario narratives describe an independent project, not a customer deployment.

Read on GitHub

Authentication Coexistence

Three authentication stacks running at once, and the measurements that say which parts of "we'll migrate everyone by Q3" are false.

C# / .NET 10. Argon2id, PBKDF2, Blake2b, Forms tickets, WS-Federation and OIDC written from the specs. 284 tests, 6/6 mutations killed, no external dependencies.


The problem

Every authentication modernisation plan contains a date. On that date, everyone will be on the new identity provider, the old cookie will stop being minted, and the legacy authorization code will be deleted.

The date is never met, and the reason is not that the work is hard. It is that three separate things in the plan are arithmetically false, and nobody checks them because they are the kind of claim that sounds like a schedule rather than a claim:

  1. "We'll map the old roles to scopes." A role-to-scope table computes a union, which is monotone. The legacy policy has && !IsInRole(...) clauses in it, which make it non-monotone. A monotone function cannot agree everywhere with a non-monotone one — so no table of that shape can ever be correct, however carefully it is reviewed.

  2. "Rehash on login; it'll be done in a quarter." Logins are a Poisson process over a population with a long tail. A quarter gets you to about 90%. 95% arrives on day 657. Some fraction never logs in again at all.

  3. "Once everyone's migrated, we can turn the old path off." You never reach "everyone", and while the old path is open, a fully migrated account with a fresh Argon2id hash is still reachable through it. The good hash is irrelevant to the attack.

This project builds the three stacks, runs the migration, and measures all three.

The one-sentence finding

The dangerous failures of an authentication migration are the ones that are invisible in the direction people check.

592 of 4096 authorization decisions diverge under the naive mapping — and every single one is an escalation. Zero lockouts. A migration whose failures are all escalations passes UAT, because nobody files a ticket saying "I was allowed to do something I should not have been."

docs/results.md is the evidence. It is generated by the code, it makes fourteen predictions before measuring anything, and eleven of them were wrong.


What is in here

assembly what it is
Auth.Passwords Blake2b, Argon2d/i/id and PBKDF2 from RFC 7693, RFC 9106 and RFC 8018. Four stored hash formats and a migrating verifier.
Auth.Legacy ASP.NET Forms authentication tickets. Two protectors: one reproducing the 2010 design (a padding oracle), one hardened.
Auth.Modern JWT/JWS and an OIDC authorization-code + PKCE flow.
Auth.Bridge The legacy authorization switch, three claims transformations, and SessionBridge — the class that turns any credential into one principal.
Auth.Report The experiments and the report generator.
Auth.Tests 284 tests.

Nothing is mocked and nothing is stubbed. The crypto is validated against published vectors; the RFC 7636 PKCE pair and the RFC 6070 PBKDF2 vectors are pinned in the suite.

The findings

1. The claims transformation cannot be finished

transformation divergences (of 4096) escalations lockouts
naive role-to-scope table 592 592 0
with a deny channel 16 16 0
deny channel, one grant row corrected 0 0 0

72 counterexamples to monotonicity. Admin can write the ledger; Admin + Auditor cannot. Adding a role removes an ability, 72 different ways.

The split matters more than the totals: 576 of the 592 were structural — unreachable by any grant table, however carefully reviewed — and 16 were an ordinary data error. Identical symptoms. The remedy always proposed for both ("review the mapping more carefully") fixes only the 16, after which the team observes fewer bugs and concludes the approach is working. → ADR 002

2. Rehash-on-login does not converge

Typical population, 20,000 users:

milestone day
50% migrated 8
90% migrated 166
95% migrated 657
99% migrated never

Ceiling: 96.0%. Simulation and closed form agree to 0.1 points (93.7% vs 93.6% at one year), so the arithmetic is right; the remaining 4% simply never log in again.

3. The rollback window closes before the evidence arrives

The period during which fewer than 5% of users have been rehashed — the period in which rolling back is free — lasts until day 0. 17.9% are rehashed after one day; 61% by day 14.

The window closes fastest exactly where the migration is healthiest. The go/no-go decision therefore has to be made on pre-migration evidence, because the evidence that arrives during the migration arrives after the decision has expired.

4. A migrated account is only as strong as the weakest open door

A fully migrated user, Argon2id hash, moved to OIDC. A forged legacy Forms ticket authenticates them during coexistence and fails after the cutoff. The Argon2id hash is not part of the attack at any point.

And you cannot wait the legacy path out: with a 30-day sliding expiry, 9,454 of 20,000 sessions are still valid 180 days after the cutoff announcement. → ADR 003

5. The timing channel, and a mitigation that retires itself

Median verification: 0.026 ms (salted SHA-1) → 378.138 ms (Argon2id). A 611x spread. From one sample, a four-way classifier identifies the stored format with 98.3% accuracy against a 25% baseline — an enumeration oracle for precisely the accounts whose hashes are cheapest to crack.

Padding to a constant deadline takes it to 28.3% (chance 25.0%), and the binary "is this account unmigrated?" question to 66.7% against a base rate of 75% — worse than always answering yes.

The cost:

day fraction migrated padding overhead
1 17.9% 5.55x
30 74.0% 1.38x
365 93.7% 1.07x

The overhead is time-varying, not a constant, and it converges to free. This inverts the usual argument. "Defer the constant-time work until after the migration" schedules the mitigation for the moment it stops being needed, and skips it during the only window in which the oracle exists. → ADR 004

6. What hardening actually buys

Distinct rejection reasons observable from outside, over four tampered tickets: legacy 3 (BadPadding, BadMac, Malformed), hardened 1. The hardened protector returns BadMac for input that is not even hex — which looks like a bug and is the entire property.

17 of 17 token and assertion attacks rejected, including alg: none, algorithm confusion, duplicate-ID signature wrapping, wrapped-original and NotOnOrAfter tampering. → ADR 006

The scoreboard

14 predictions written before any experiment ran. 11 contradicted. The full table is at the end of docs/results.md.


Running it

.\build.ps1     # builds the five assemblies
.\test.ps1      # six stages, ~7 minutes
.\demo.ps1      # one account's journey from a 2010 cookie to an OIDC token
.\demo.ps1 -Report   # regenerates docs/results.md

test.ps1 stages: clean build → crypto against published vectors → full suite → the report is byte-identical when regenerated → six mutations → secrets scan.

Stage 5 is the one worth reading. Six single-token edits, each deleting exactly one check that a headline finding depends on — the segregation-of-duties clause, the deny row, deny-wins resolution, the missing-exp rejection, and the Argon2 variable-length hash off-by-one. All six are killed. A survivor would mean the corresponding sentence above is unverified.

Documents

Two defects the tests found in my own code

A missing exp claim was accepted. Written as if (claims["exp"] is { } exp && seconds >= exp.GetValue<long>()) — which reads as a careful null guard and behaves as an opt-out. Omit the claim, get a permanent token. The bug is shaped like defensive code, which is why it survived being read several times.

The prediction scoreboard silently emitted only four of fourteen rows, because the loop that scored them was bounded by the wrong array. Ten unscored predictions looked exactly like ten predictions that had never been made. The report now derives its header count from its own table, so the two cannot disagree.

Both are described in docs/portfolio/04-what-the-tests-caught.md.

Source & documentation

Continue into the implementation.

The source repository contains setup instructions, design decisions, evaluation guidance and the project's stated limitations.

Browse supporting documentation 14 documents / 6 decision records