Skip to content

Commit b092487

Browse files
authored
fix(login): answer a decoy under external delivery with nothing to mail (#322)
Under external delivery the decoy responders for an OTP send and a magic link request fabricated a delivery block for shape parity, addressed to the decoy's synthetic @example.invalid email. The SDK mailed it, the domain never resolves, and the adopter's provider retried for fourteen hours and then bounced it against the adopter's sending identity. Every sign-in attempt for an unknown address was a guaranteed bounce on traffic the adopter does not control. The block is only readable with a service token, so omitting it discloses nothing to a stranger, and the SDK already sends nothing when it is absent. The magic link responder still runs the redirect check for its refusal and discards the URL. decoyOtpFor had no other reason to exist and is removed. Closes #321 Co-authored-by: Brandon Corbett <Bccorb@users.noreply.github.com>
1 parent 951551e commit b092487

6 files changed

Lines changed: 80 additions & 65 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'seamless-auth-api': patch
3+
---
4+
5+
A decoy continuation under external delivery no longer hands the SDK a message to send.
6+
7+
When an adopter runs external delivery, the decoy responders for an OTP send and a magic
8+
link request answered with a `delivery` block like a real account's, addressed to the
9+
decoy's synthetic `@example.invalid` email. The SDK mailed it, the domain never resolves,
10+
and the adopter's mail provider retried for hours and then bounced it against the
11+
adopter's sending identity. Every sign-in attempt for an unknown address was a guaranteed
12+
bounce, fourteen hours later, on traffic the adopter does not control (#321).
13+
14+
The responders now omit the block. It is only readable by a caller holding a service
15+
token, so a stranger sees the same answer as before, and the SDK's `deliverAuthMessage`
16+
already sends nothing when the block is absent. `decoyOtpFor` had no other reason to
17+
exist and is removed. `docs/security-posture.md` says why parity at the SDK's edge was
18+
not worth a bounce per probe.

‎docs/security-posture.md‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -165,11 +165,22 @@ key there means a coordinated release across both SDKs for an operational tuning
165165
understand what happened. Accepted, unchanged.
166166
- **A malformed identifier** answers `400`. This does not depend on whether any account
167167
exists, so it is not an enumeration signal.
168-
- **External delivery mode** returns a fabricated code and the decoy's synthetic address
169-
rather than the identifier the caller supplied, so a caller comparing the two can tell.
170-
That mode requires a valid internal service token, which makes the caller a trusted
171-
backend that can enumerate through the admin API anyway. Accepted, and the reason it is
172-
acceptable is the service token, not the fabrication.
168+
- **External delivery mode** answers a real OTP or magic link request with a `delivery`
169+
block, the address and the code for the SDK to send, and answers a decoy with none. A
170+
caller that can read the block can tell. That mode requires a valid internal service
171+
token, which makes the caller a trusted backend that can enumerate through the admin
172+
API anyway. Accepted, and the reason it is acceptable is the service token.
173+
174+
The block used to be fabricated for a decoy instead, for shape parity. It was addressed
175+
to the decoy's synthetic `@example.invalid` email, and the SDK mailed it, because
176+
mailing whatever it is handed is the SDK's job in that mode. The domain never resolves,
177+
so every probe of an unknown identifier became a message the adopter's mail provider
178+
retried for hours and then bounced against the adopter's sending identity (#321).
179+
Parity at the SDK's edge is what the fabrication bought, and it is not worth a bounce
180+
per probe: a decoy skipping the send answers faster than a real account, but that is
181+
the same difference direct delivery already carries on these endpoints, where the real
182+
handler's send is in-process and the decoy's is not.
183+
173184
- **A deleted or revoked account** mid-flow is answered as a decoy rather than with a
174185
distinguishable `401`. That is the intended behaviour, and it means such a user sees a
175186
continuation that quietly never succeeds rather than a clear rejection.

‎src/controllers/decoyResponders.ts‎

Lines changed: 15 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,10 @@ import { generateAuthenticationOptions } from '@simplewebauthn/server';
88
import { Request, Response } from 'express';
99

1010
import { getSystemConfig } from '../config/getSystemConfig.js';
11-
import { canReturnExternalDelivery } from '../lib/externalDelivery.js';
1211
import { signEphemeralToken } from '../lib/token.js';
1312
import { buildPrfAuthenticationExtensions } from '../lib/webauthnPrf.js';
1413
import { AuthEventService } from '../services/authEventService.js';
15-
import {
16-
decoyCredentialIdFor,
17-
decoyOtpFor,
18-
decoyPrincipalForSubject,
19-
} from '../services/decoyPrincipal.js';
14+
import { decoyCredentialIdFor, decoyPrincipalForSubject } from '../services/decoyPrincipal.js';
2015
import {
2116
getLoginPolicy,
2217
isLoginMethodEnabled,
@@ -45,6 +40,12 @@ import { hashDeviceFingerprint } from '../utils/utils.js';
4540
* records is the exception, and it is the one every request already writes.
4641
* - **No real handler.** `defineRoute` dispatches here instead of the controller, so the
4742
* stand-in principal never reaches code that could persist it.
43+
* - **No delivery block.** In external delivery mode a real send answers with the
44+
* address and the code for the SDK to mail. A decoy never does, whatever the header
45+
* says. The block is only readable by a caller holding a service token, so omitting it
46+
* discloses nothing to a stranger, and the alternative is the SDK mailing a synthetic
47+
* `@example.invalid` address for real: every one bounces, against the adopter's
48+
* sending reputation, fourteen hours after the probe.
4849
*
4950
* Policy-dependent branches are reproduced rather than skipped. A deployment with
5051
* `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
102103
async function respondOtpSent(req: Request, res: Response, kind: 'otp_email' | 'otp_sms') {
103104
const authReq = req as AuthenticatedRequest;
104105
const subject = decoySubject(req);
105-
const useExternalDelivery = await canReturnExternalDelivery(req);
106106

107107
await logDecoy(req, `otp:${kind}`);
108108

@@ -115,19 +115,7 @@ async function respondOtpSent(req: Request, res: Response, kind: 'otp_email' | '
115115

116116
const token = await signEphemeralToken(subject, authReq.attemptId);
117117

118-
return res.status(200).json({
119-
message: 'success',
120-
token,
121-
...(useExternalDelivery
122-
? {
123-
delivery: {
124-
kind,
125-
to: kind === 'otp_email' ? authReq.user.email : authReq.user.phone,
126-
token: decoyOtpFor(subject),
127-
},
128-
}
129-
: {}),
130-
});
118+
return res.status(200).json({ message: 'success', token });
131119
}
132120

133121
export const decoySendEmailOtp = (req: Request, res: Response) =>
@@ -187,22 +175,19 @@ export const decoyRequestMagicLink = async (req: Request, res: Response) => {
187175
return;
188176
}
189177

190-
const authReq = req as AuthenticatedRequest;
191-
const useExternalDelivery = await canReturnExternalDelivery(req);
192-
193178
await logDecoy(req, 'magic_link:request');
194179

195-
const rawToken = decoyCredentialIdFor(decoySubject(req));
196-
197180
// Both checks below answer 400 for a real account before anything is stored, and both
198181
// are reachable by choice: a caller picks the redirect it sends, and omitting a
199182
// User-Agent header is enough to trip the second. A decoy that skipped them would
200183
// answer 200 where a real account answers 400, which is a working oracle for the price
201-
// of one deliberately bad request.
202-
let magicLinkUrl: string;
203-
184+
// of one deliberately bad request. The URL the first one builds goes nowhere, since
185+
// nothing is mailed; the call is made for its refusal.
204186
try {
205-
magicLinkUrl = await resolveMagicLinkUrl(rawToken, req.query.redirectUri as string | undefined);
187+
await resolveMagicLinkUrl(
188+
decoyCredentialIdFor(decoySubject(req)),
189+
req.query.redirectUri as string | undefined,
190+
);
206191
} catch (error) {
207192
if (error instanceof MagicLinkRedirectNotAllowedError) {
208193
return res.status(400).json({ error: 'Redirect URI is not allowed' });
@@ -217,19 +202,7 @@ export const decoyRequestMagicLink = async (req: Request, res: Response) => {
217202
return res.status(400).json({ error: 'Invalid device data' });
218203
}
219204

220-
return res.json({
221-
message: 'If an account exists, a login link has been sent.',
222-
...(useExternalDelivery
223-
? {
224-
delivery: {
225-
kind: 'magic_link_email',
226-
to: authReq.user.email,
227-
token: rawToken,
228-
magicLinkUrl,
229-
},
230-
}
231-
: {}),
232-
});
205+
return res.json({ message: 'If an account exists, a login link has been sent.' });
233206
};
234207

235208
/**

‎src/services/decoyPrincipal.ts‎

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ import { User } from '../models/users.js';
3333
const DECOY_SUBJECT_INFO = 'seamless-auth/decoy-subject';
3434
const DECOY_EMAIL_INFO = 'seamless-auth/decoy-email';
3535
const DECOY_PHONE_INFO = 'seamless-auth/decoy-phone';
36-
const DECOY_OTP_INFO = 'seamless-auth/decoy-otp';
3736
const DECOY_CREDENTIAL_INFO = 'seamless-auth/decoy-credential';
3837
const DECOY_SHAPE_INFO = 'seamless-auth/decoy-shape';
3938

@@ -116,15 +115,6 @@ function decoyPhoneFor(subject: string) {
116115
return `+1555${digits.toString().padStart(7, '0')}`;
117116
}
118117

119-
/**
120-
* The code a decoy's OTP "is". Nothing ever compares against it, since a decoy
121-
* verification always fails, but external delivery mode returns the generated code in
122-
* the response body and a decoy has to put something of the right shape there.
123-
*/
124-
export function decoyOtpFor(subject: string) {
125-
return (derive(DECOY_OTP_INFO, subject).readUInt32BE(0) % 1_000_000).toString().padStart(6, '0');
126-
}
127-
128118
/**
129119
* A credential id for the fabricated allow-list a decoy's WebAuthn challenge offers.
130120
* Base64url of 32 bytes, matching what a real credential id looks like on the wire.

‎tests/integration/authentication/decoyContinuation.spec.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import { signEphemeralToken } from '../../../src/lib/token.js';
5757
import { WebAuthnChallenge } from '../../../src/models/webauthnChallenges.js';
5858
import { generateEmailOTP, generatePhoneOTP } from '../../../src/utils/otp.js';
5959
import { hashDeviceFingerprint } from '../../../src/utils/utils.js';
60+
import { mintInternalServiceToken } from '../../factories/serviceTokenFactory.js';
6061

6162
let app: Application;
6263

@@ -121,6 +122,25 @@ describe('decoy continuation: OTP', () => {
121122
expect(generatePhoneOTP).not.toHaveBeenCalled();
122123
});
123124

125+
it.each([['/otp/generate-email-otp'], ['/otp/generate-login-email-otp']])(
126+
'hands the SDK nothing to mail for %s under external delivery',
127+
async (path) => {
128+
// A real send in this mode answers with the address and the code for the SDK to
129+
// mail. A decoy's address is `@example.invalid`, so a block here would be a real
130+
// message the SDK sends and SES bounces fourteen hours later, against the
131+
// adopter's identity. The block is only readable with a service token, so leaving
132+
// it out discloses nothing to a stranger.
133+
const res = await request(app)
134+
.get(path)
135+
.set('x-seamless-auth-delivery-mode', 'external')
136+
.set('x-seamless-service-token', await mintInternalServiceToken());
137+
138+
expect(res.status).toBe(200);
139+
expect(res.body).toEqual({ message: 'success', token: 'decoy-token' });
140+
expect(res.body).not.toHaveProperty('delivery');
141+
},
142+
);
143+
124144
it.each([
125145
['/otp/verify-email-otp'],
126146
['/otp/verify-phone-otp'],
@@ -156,6 +176,17 @@ describe('decoy continuation: magic link', () => {
156176
expect(res.body.message).toBe('If an account exists, a login link has been sent.');
157177
});
158178

179+
it('hands the SDK nothing to mail under external delivery', async () => {
180+
const res = await request(app)
181+
.get('/magic-link')
182+
.set('x-seamless-auth-delivery-mode', 'external')
183+
.set('x-seamless-service-token', await mintInternalServiceToken());
184+
185+
expect(res.status).toBe(200);
186+
expect(res.body).toEqual({ message: 'If an account exists, a login link has been sent.' });
187+
expect(res.body).not.toHaveProperty('delivery');
188+
});
189+
159190
it('polls as an unclicked link forever', async () => {
160191
const res = await request(app).get('/magic-link/check');
161192

‎tests/unit/services/decoyPrincipal.spec.ts‎

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
22

33
import {
44
decoyCredentialIdFor,
5-
decoyOtpFor,
65
decoyPrincipalForSubject,
76
decoySubjectFor,
87
} from '../../../src/services/decoyPrincipal.js';
@@ -121,13 +120,6 @@ describe('decoy principal', () => {
121120
});
122121

123122
describe('fabricated secrets', () => {
124-
it('produces a six digit OTP', () => {
125-
const subject = decoySubjectFor('nobody@example.com', 'email');
126-
127-
expect(decoyOtpFor(subject)).toMatch(/^\d{6}$/);
128-
expect(decoyOtpFor(subject)).toBe(decoyOtpFor(subject));
129-
});
130-
131123
it('produces a credential id shaped like a real one', () => {
132124
const subject = decoySubjectFor('nobody@example.com', 'email');
133125

0 commit comments

Comments
 (0)