diff --git a/.changeset/decoy-external-delivery.md b/.changeset/decoy-external-delivery.md new file mode 100644 index 0000000..aec93d5 --- /dev/null +++ b/.changeset/decoy-external-delivery.md @@ -0,0 +1,18 @@ +--- +'seamless-auth-api': patch +--- + +A decoy continuation under external delivery no longer hands the SDK a message to send. + +When an adopter runs external delivery, the decoy responders for an OTP send and a magic +link request answered with a `delivery` block like a real account's, addressed to the +decoy's synthetic `@example.invalid` email. The SDK mailed it, the domain never resolves, +and the adopter's mail provider retried for hours and then bounced it against the +adopter's sending identity. Every sign-in attempt for an unknown address was a guaranteed +bounce, fourteen hours later, on traffic the adopter does not control (#321). + +The responders now omit the block. It is only readable by a caller holding a service +token, so a stranger sees the same answer as before, and the SDK's `deliverAuthMessage` +already sends nothing when the block is absent. `decoyOtpFor` had no other reason to +exist and is removed. `docs/security-posture.md` says why parity at the SDK's edge was +not worth a bounce per probe. diff --git a/docs/security-posture.md b/docs/security-posture.md index 64c3d39..3fd8231 100644 --- a/docs/security-posture.md +++ b/docs/security-posture.md @@ -165,11 +165,22 @@ key there means a coordinated release across both SDKs for an operational tuning understand what happened. Accepted, unchanged. - **A malformed identifier** answers `400`. This does not depend on whether any account exists, so it is not an enumeration signal. -- **External delivery mode** returns a fabricated code and the decoy's synthetic address - rather than the identifier the caller supplied, so a caller comparing the two can tell. - That mode requires a valid internal service token, which makes the caller a trusted - backend that can enumerate through the admin API anyway. Accepted, and the reason it is - acceptable is the service token, not the fabrication. +- **External delivery mode** answers a real OTP or magic link request with a `delivery` + block, the address and the code for the SDK to send, and answers a decoy with none. A + caller that can read the block can tell. That mode requires a valid internal service + token, which makes the caller a trusted backend that can enumerate through the admin + API anyway. Accepted, and the reason it is acceptable is the service token. + + The block used to be fabricated for a decoy instead, for shape parity. It was addressed + to the decoy's synthetic `@example.invalid` email, and the SDK mailed it, because + mailing whatever it is handed is the SDK's job in that mode. The domain never resolves, + so every probe of an unknown identifier became a message the adopter's mail provider + retried for hours and then bounced against the adopter's sending identity (#321). + Parity at the SDK's edge is what the fabrication bought, and it is not worth a bounce + per probe: a decoy skipping the send answers faster than a real account, but that is + the same difference direct delivery already carries on these endpoints, where the real + handler's send is in-process and the decoy's is not. + - **A deleted or revoked account** mid-flow is answered as a decoy rather than with a distinguishable `401`. That is the intended behaviour, and it means such a user sees a continuation that quietly never succeeds rather than a clear rejection. diff --git a/src/controllers/decoyResponders.ts b/src/controllers/decoyResponders.ts index e7eabb6..37a96c7 100644 --- a/src/controllers/decoyResponders.ts +++ b/src/controllers/decoyResponders.ts @@ -8,15 +8,10 @@ import { generateAuthenticationOptions } from '@simplewebauthn/server'; import { Request, Response } from 'express'; import { getSystemConfig } from '../config/getSystemConfig.js'; -import { canReturnExternalDelivery } from '../lib/externalDelivery.js'; import { signEphemeralToken } from '../lib/token.js'; import { buildPrfAuthenticationExtensions } from '../lib/webauthnPrf.js'; import { AuthEventService } from '../services/authEventService.js'; -import { - decoyCredentialIdFor, - decoyOtpFor, - decoyPrincipalForSubject, -} from '../services/decoyPrincipal.js'; +import { decoyCredentialIdFor, decoyPrincipalForSubject } from '../services/decoyPrincipal.js'; import { getLoginPolicy, isLoginMethodEnabled, @@ -45,6 +40,12 @@ import { hashDeviceFingerprint } from '../utils/utils.js'; * records is the exception, and it is the one every request already writes. * - **No real handler.** `defineRoute` dispatches here instead of the controller, so the * stand-in principal never reaches code that could persist it. + * - **No delivery block.** In external delivery mode a real send answers with the + * address and the code for the SDK to mail. A decoy never does, whatever the header + * says. The block is only readable by a caller holding a service token, so omitting it + * discloses nothing to a stranger, and the alternative is the SDK mailing a synthetic + * `@example.invalid` address for real: every one bounces, against the adopter's + * sending reputation, fourteen hours after the probe. * * Policy-dependent branches are reproduced rather than skipped. A deployment with * `email_otp` disabled answers `403 login_method_disabled` for every identifier, so a @@ -102,7 +103,6 @@ async function rejectDisabledMethod(method: LoginMethod, req: Request, res: Resp async function respondOtpSent(req: Request, res: Response, kind: 'otp_email' | 'otp_sms') { const authReq = req as AuthenticatedRequest; const subject = decoySubject(req); - const useExternalDelivery = await canReturnExternalDelivery(req); await logDecoy(req, `otp:${kind}`); @@ -115,19 +115,7 @@ async function respondOtpSent(req: Request, res: Response, kind: 'otp_email' | ' const token = await signEphemeralToken(subject, authReq.attemptId); - return res.status(200).json({ - message: 'success', - token, - ...(useExternalDelivery - ? { - delivery: { - kind, - to: kind === 'otp_email' ? authReq.user.email : authReq.user.phone, - token: decoyOtpFor(subject), - }, - } - : {}), - }); + return res.status(200).json({ message: 'success', token }); } export const decoySendEmailOtp = (req: Request, res: Response) => @@ -187,22 +175,19 @@ export const decoyRequestMagicLink = async (req: Request, res: Response) => { return; } - const authReq = req as AuthenticatedRequest; - const useExternalDelivery = await canReturnExternalDelivery(req); - await logDecoy(req, 'magic_link:request'); - const rawToken = decoyCredentialIdFor(decoySubject(req)); - // Both checks below answer 400 for a real account before anything is stored, and both // are reachable by choice: a caller picks the redirect it sends, and omitting a // User-Agent header is enough to trip the second. A decoy that skipped them would // answer 200 where a real account answers 400, which is a working oracle for the price - // of one deliberately bad request. - let magicLinkUrl: string; - + // of one deliberately bad request. The URL the first one builds goes nowhere, since + // nothing is mailed; the call is made for its refusal. try { - magicLinkUrl = await resolveMagicLinkUrl(rawToken, req.query.redirectUri as string | undefined); + await resolveMagicLinkUrl( + decoyCredentialIdFor(decoySubject(req)), + req.query.redirectUri as string | undefined, + ); } catch (error) { if (error instanceof MagicLinkRedirectNotAllowedError) { return res.status(400).json({ error: 'Redirect URI is not allowed' }); @@ -217,19 +202,7 @@ export const decoyRequestMagicLink = async (req: Request, res: Response) => { return res.status(400).json({ error: 'Invalid device data' }); } - return res.json({ - message: 'If an account exists, a login link has been sent.', - ...(useExternalDelivery - ? { - delivery: { - kind: 'magic_link_email', - to: authReq.user.email, - token: rawToken, - magicLinkUrl, - }, - } - : {}), - }); + return res.json({ message: 'If an account exists, a login link has been sent.' }); }; /** diff --git a/src/services/decoyPrincipal.ts b/src/services/decoyPrincipal.ts index a36bc7c..5f3981d 100644 --- a/src/services/decoyPrincipal.ts +++ b/src/services/decoyPrincipal.ts @@ -33,7 +33,6 @@ import { User } from '../models/users.js'; const DECOY_SUBJECT_INFO = 'seamless-auth/decoy-subject'; const DECOY_EMAIL_INFO = 'seamless-auth/decoy-email'; const DECOY_PHONE_INFO = 'seamless-auth/decoy-phone'; -const DECOY_OTP_INFO = 'seamless-auth/decoy-otp'; const DECOY_CREDENTIAL_INFO = 'seamless-auth/decoy-credential'; const DECOY_SHAPE_INFO = 'seamless-auth/decoy-shape'; @@ -116,15 +115,6 @@ function decoyPhoneFor(subject: string) { return `+1555${digits.toString().padStart(7, '0')}`; } -/** - * The code a decoy's OTP "is". Nothing ever compares against it, since a decoy - * verification always fails, but external delivery mode returns the generated code in - * the response body and a decoy has to put something of the right shape there. - */ -export function decoyOtpFor(subject: string) { - return (derive(DECOY_OTP_INFO, subject).readUInt32BE(0) % 1_000_000).toString().padStart(6, '0'); -} - /** * A credential id for the fabricated allow-list a decoy's WebAuthn challenge offers. * Base64url of 32 bytes, matching what a real credential id looks like on the wire. diff --git a/tests/integration/authentication/decoyContinuation.spec.ts b/tests/integration/authentication/decoyContinuation.spec.ts index 2ea95e8..ed45347 100644 --- a/tests/integration/authentication/decoyContinuation.spec.ts +++ b/tests/integration/authentication/decoyContinuation.spec.ts @@ -57,6 +57,7 @@ import { signEphemeralToken } from '../../../src/lib/token.js'; import { WebAuthnChallenge } from '../../../src/models/webauthnChallenges.js'; import { generateEmailOTP, generatePhoneOTP } from '../../../src/utils/otp.js'; import { hashDeviceFingerprint } from '../../../src/utils/utils.js'; +import { mintInternalServiceToken } from '../../factories/serviceTokenFactory.js'; let app: Application; @@ -121,6 +122,25 @@ describe('decoy continuation: OTP', () => { expect(generatePhoneOTP).not.toHaveBeenCalled(); }); + it.each([['/otp/generate-email-otp'], ['/otp/generate-login-email-otp']])( + 'hands the SDK nothing to mail for %s under external delivery', + async (path) => { + // A real send in this mode answers with the address and the code for the SDK to + // mail. A decoy's address is `@example.invalid`, so a block here would be a real + // message the SDK sends and SES bounces fourteen hours later, against the + // adopter's identity. The block is only readable with a service token, so leaving + // it out discloses nothing to a stranger. + const res = await request(app) + .get(path) + .set('x-seamless-auth-delivery-mode', 'external') + .set('x-seamless-service-token', await mintInternalServiceToken()); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ message: 'success', token: 'decoy-token' }); + expect(res.body).not.toHaveProperty('delivery'); + }, + ); + it.each([ ['/otp/verify-email-otp'], ['/otp/verify-phone-otp'], @@ -156,6 +176,17 @@ describe('decoy continuation: magic link', () => { expect(res.body.message).toBe('If an account exists, a login link has been sent.'); }); + it('hands the SDK nothing to mail under external delivery', async () => { + const res = await request(app) + .get('/magic-link') + .set('x-seamless-auth-delivery-mode', 'external') + .set('x-seamless-service-token', await mintInternalServiceToken()); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ message: 'If an account exists, a login link has been sent.' }); + expect(res.body).not.toHaveProperty('delivery'); + }); + it('polls as an unclicked link forever', async () => { const res = await request(app).get('/magic-link/check'); diff --git a/tests/unit/services/decoyPrincipal.spec.ts b/tests/unit/services/decoyPrincipal.spec.ts index 9bb3533..685f585 100644 --- a/tests/unit/services/decoyPrincipal.spec.ts +++ b/tests/unit/services/decoyPrincipal.spec.ts @@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { decoyCredentialIdFor, - decoyOtpFor, decoyPrincipalForSubject, decoySubjectFor, } from '../../../src/services/decoyPrincipal.js'; @@ -121,13 +120,6 @@ describe('decoy principal', () => { }); describe('fabricated secrets', () => { - it('produces a six digit OTP', () => { - const subject = decoySubjectFor('nobody@example.com', 'email'); - - expect(decoyOtpFor(subject)).toMatch(/^\d{6}$/); - expect(decoyOtpFor(subject)).toBe(decoyOtpFor(subject)); - }); - it('produces a credential id shaped like a real one', () => { const subject = decoySubjectFor('nobody@example.com', 'email');