Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/decoy-external-delivery.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 16 additions & 5 deletions docs/security-posture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
57 changes: 15 additions & 42 deletions src/controllers/decoyResponders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}`);

Expand All @@ -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) =>
Expand Down Expand Up @@ -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' });
Expand All @@ -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.' });
};

/**
Expand Down
10 changes: 0 additions & 10 deletions src/services/decoyPrincipal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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.
Expand Down
31 changes: 31 additions & 0 deletions tests/integration/authentication/decoyContinuation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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'],
Expand Down Expand Up @@ -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');

Expand Down
8 changes: 0 additions & 8 deletions tests/unit/services/decoyPrincipal.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';

import {
decoyCredentialIdFor,
decoyOtpFor,
decoyPrincipalForSubject,
decoySubjectFor,
} from '../../../src/services/decoyPrincipal.js';
Expand Down Expand Up @@ -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');

Expand Down
Loading