A customer signs in with a registered passkey. Usernameless: they are never asked who they are, the browser offers whichever accounts it holds for this Relying Party, and the assertion says which credential answered. #38 requested discoverable credentials so this would work. That choice does more than improve the experience. This issue requires that failures not reveal whether an email has an account or has passkeys registered, and with no email ever sent to the endpoint there is nothing to reveal. The email-first alternative would have had to answer identically for a known and an unknown address, in every branch, forever. Session creation is shared rather than reimplemented, which is the requirement stated most sharply here: a second, subtly different session path is how auth bugs get in. setSessionCookie and createSession move to customerSession.ts and both paths import them. Two implementations that agree today are two that can be changed one at a time, and the one that would be forgotten is whichever is not the password path, because that is the one every manual test exercises. Social sign-in will use the same module when #332 lands. The signature counter policy #37 deferred is decided here, and both halves matter. Requiring an increase from every authenticator refuses synced passkeys, which report zero forever by design and are what most customers actually use. Requiring it from none discards the only signal that a hardware credential has been cloned. So zero against zero is accepted and anything else must strictly increase — and the asymmetry is deliberate, because an authenticator that has ever reported a real counter is held to the strict rule from then on and cannot downgrade itself to zero to escape it. A disabled account is refused, read from the same row as the credential rather than a second query that could disagree. Enforcing that only on the password path would have left passkeys as a way around it. Every refusal answers the same way. No such credential, a disabled account, a bad assertion and a stalled counter are all that did not work to the caller; saying which would turn the endpoint into an oracle for whether a credential exists and whether its account is in good standing. The stalled counter is logged, because the customer cannot act on it and the person who can is reading the logs. The challenge is spent by deleting it, with the expiry in the same statement, so a replay finds nothing to delete and a stale challenge fails the same way. It is passed to the library as a predicate rather than a value, which is what makes a usernameless flow possible at all — the challenge is not known until the assertion names it. That predicate is a named function rather than an inline callback, and it was inline first. routesAreWrapped.test.ts reads the text of each router.post looking for an async that no asyncRoute covers, and an async callback nested inside a wrapped handler looks exactly like an unwrapped one to it. The guard caught it, and hoisting the function out was the better fix: the code reads more clearly and the guard keeps its teeth rather than learning another exception. Verified: tsc clean for src and tests, lint back to the seven pre-existing warnings with none added, 521 unit tests across 36 suites — seven new, covering the counter policy in both directions. Not verified: the ceremony cannot be exercised without a browser and a real authenticator, which per #41 is a standing limitation of this feature rather than a gap here. CI can prove the routes exist, are wrapped, and refuse a caller with no credential. Closes #39 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
57 lines
2.2 KiB
TypeScript
57 lines
2.2 KiB
TypeScript
import { checkSignatureCounter } from '../../src/passkeys/signatureCounter';
|
|
|
|
/**
|
|
* The rule #37 deferred to the ceremony that enforces it.
|
|
*
|
|
* Both halves are load-bearing and they pull in opposite directions. Requiring
|
|
* an increase from every authenticator refuses the synced passkeys most people
|
|
* actually use, which report zero forever by design. Requiring it from none
|
|
* throws away the only signal that a hardware credential has been cloned, which
|
|
* is the entire reason the column exists.
|
|
*/
|
|
describe('checkSignatureCounter', () => {
|
|
describe('an authenticator that does not implement counters', () => {
|
|
it('accepts zero against zero, and keeps accepting it', () => {
|
|
// iCloud Keychain and Google Password Manager report this on every
|
|
// assertion. Refusing it would refuse most real customers.
|
|
expect(checkSignatureCounter(0, 0).ok).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('an authenticator that does', () => {
|
|
it('accepts a counter that advanced', () => {
|
|
expect(checkSignatureCounter(5, 6).ok).toBe(true);
|
|
expect(checkSignatureCounter(0, 1).ok).toBe(true);
|
|
});
|
|
|
|
it('refuses one that stalled', () => {
|
|
// Equal is not an increase. Two copies of a credential used alternately
|
|
// produce exactly this.
|
|
const verdict = checkSignatureCounter(7, 7);
|
|
|
|
expect(verdict.ok).toBe(false);
|
|
expect(verdict.reason).toMatch(/cloned/);
|
|
});
|
|
|
|
it('refuses one that went backwards', () => {
|
|
expect(checkSignatureCounter(9, 4).ok).toBe(false);
|
|
});
|
|
|
|
// The asymmetry that stops the zero rule being an escape hatch. An
|
|
// authenticator that has ever reported a real counter is held to the strict
|
|
// rule from then on, so a clone cannot report zero to look like a synced
|
|
// passkey and be waved through.
|
|
it('refuses a drop to zero from a counter that was real', () => {
|
|
const verdict = checkSignatureCounter(12, 0);
|
|
|
|
expect(verdict.ok).toBe(false);
|
|
expect(verdict.reason).toMatch(/did not advance/);
|
|
});
|
|
});
|
|
|
|
it('reports the numbers, because a refusal is only actionable with them', () => {
|
|
expect(checkSignatureCounter(12, 3).reason).toContain('stored 12');
|
|
expect(checkSignatureCounter(12, 3).reason).toContain('received 3');
|
|
});
|
|
});
|