Skip to content

Commit db4661b

Browse files
ralyodioclaude
andcommitted
feat(auth): prove an ordinary domain with a TXT record
The commodity half of "sign in with a name", and the last unblocked piece of the design. There is no key to sign with in the public root, so the proof is the one thing only the holder can do: publish the challenge under the domain. The record to create travels with the challenge response, so nothing has to describe it twice and drift from what is actually checked. Resolution goes to PUBLIC resolvers over DoH, never the system resolver. That is the point rather than an implementation detail: any box running the Moshpit DNS bridge answers for its own endings locally, so asking the machine we happen to be on whether a name exists in the public root asks the wrong authority. It is the same confusion that made moshcode.sh redirect into the pit. Other things worth keeping: - SERVFAIL is not NXDOMAIN. Only NOERROR and NXDOMAIN are answers; a resolver failing is an outage and answers 503, because reading a non-answer as absence refuses a good proof for the length of somebody else's outage. - The lookup runs BEFORE the burn. Publishing DNS is slow and people retry while it propagates, so spending the challenge on a lookup that ran too early would make "I just added it" the case that fails. - A challenge minted in one namespace cannot be spent in the other. - TXT character-strings are joined before comparing; a value crossing the 255-byte boundary would otherwise never match. - Domains take 2+ labels (example.co.uk), so the challenge route now normalizes per namespace. A bare IP is refused: whoever holds the address today would otherwise answer for it. Provisioning is extracted to lib/auth/name-account.js and shared with the Moshpit path rather than duplicated -- its 10 tests still pass unchanged. Migration 20260827182712 (applied; user_names was empty) makes pin_kind nullable and ties both pin columns to the proof: a pin-signature binding must carry a pin, a dns-txt binding must not. Without it every DNS binding would default pin_kind to 'tls' and quietly assert a key kind nobody checked. Suite 560 green, up from 534. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gSbLXLe3hMPnhcjpEtxwf
1 parent c33e2c9 commit db4661b

8 files changed

Lines changed: 1135 additions & 254 deletions

File tree

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/**
2+
* @fileoverview POST /api/auth/dns/verify — prove an ordinary domain, get a session.
3+
*
4+
* The commodity half of "sign in with a name". There is no key to sign with in
5+
* the public root, so the proof is the one thing only the holder can do:
6+
* publish the challenge under the domain. The record to create travels with the
7+
* challenge (see the challenge route), so nothing has to describe it twice.
8+
*
9+
* Weaker than the Moshpit path on purpose, and worth being clear about: whoever
10+
* controls DNS controls the identity, so a registrar seizure or a lapsed
11+
* renewal is an account takeover. `recheck_at` is what limits the window.
12+
*
13+
* Additive only: does NOT touch the phone/SMS, CoinPay, anon or Moshpit flows.
14+
*/
15+
16+
import { NextResponse } from 'next/server';
17+
import { normalizeDnsName, verifyDnsProof, DnsProofError } from '@/lib/auth/dns-name.js';
18+
import {
19+
createServiceClient,
20+
loadSpendableChallenge,
21+
burnChallenge,
22+
provisionNameAccount,
23+
mintSession,
24+
ProvisionError
25+
} from '@/lib/auth/name-account.js';
26+
27+
/**
28+
* POST /api/auth/dns/verify
29+
*
30+
* Body: { jti, name, publicKey, displayName? }
31+
* Returns: { success, session, user }
32+
*
33+
* @param {import('next/server').NextRequest} request
34+
*/
35+
export async function POST(request) {
36+
let body;
37+
try {
38+
body = await request.json();
39+
} catch {
40+
return NextResponse.json({ error: 'Invalid request format' }, { status: 400 });
41+
}
42+
43+
const { jti, publicKey, displayName } = body || {};
44+
const name = normalizeDnsName(body?.name);
45+
46+
if (!name) {
47+
return NextResponse.json({ error: 'Not a valid domain name' }, { status: 400 });
48+
}
49+
if (typeof jti !== 'string' || !jti) {
50+
return NextResponse.json({ error: 'A challenge id is required' }, { status: 400 });
51+
}
52+
const mlkemKey = typeof publicKey === 'string' ? publicKey.trim() : '';
53+
if (!mlkemKey) {
54+
return NextResponse.json({ error: 'A public key is required' }, { status: 400 });
55+
}
56+
57+
const serviceSupabase = createServiceClient();
58+
59+
// --- a) Load the challenge ---
60+
const loaded = await loadSpendableChallenge(serviceSupabase, jti, { name, namespace: 'dns' });
61+
if ('error' in loaded) {
62+
return NextResponse.json({ error: loaded.error }, { status: 400 });
63+
}
64+
const { challenge } = loaded;
65+
66+
// --- b) Look for the record BEFORE burning anything ---
67+
//
68+
// Publishing DNS is slow and people will retry while it propagates. Spending
69+
// the challenge on a lookup that simply ran too early would make the honest
70+
// case -- "I just added it" -- the one that fails.
71+
try {
72+
await verifyDnsProof({ name: challenge.name, nonce: challenge.nonce });
73+
} catch (error) {
74+
if (error instanceof DnsProofError) {
75+
return NextResponse.json(
76+
{ error: error.message, code: error.code },
77+
{ status: error.definite ? 400 : 503 }
78+
);
79+
}
80+
console.error('dns/verify: unexpected verification failure', error);
81+
return NextResponse.json({ error: 'Could not verify that proof' }, { status: 500 });
82+
}
83+
84+
try {
85+
// --- c) Burn the challenge, atomically ---
86+
if (!(await burnChallenge(serviceSupabase, jti))) {
87+
return NextResponse.json({ error: 'That challenge has already been used' }, { status: 400 });
88+
}
89+
90+
// No pin and no pin kind: there is no key in this path, and the schema
91+
// refuses a binding that claims otherwise.
92+
const { userRow, email } = await provisionNameAccount({
93+
serviceSupabase,
94+
name,
95+
namespace: 'dns',
96+
proof: 'dns-txt',
97+
boundPin: null,
98+
pinKind: null,
99+
mlkemKey,
100+
displayName
101+
});
102+
103+
const session = await mintSession(serviceSupabase, email);
104+
105+
return NextResponse.json({
106+
success: true,
107+
name,
108+
namespace: 'dns',
109+
session,
110+
user: {
111+
id: userRow.id,
112+
username: userRow.username,
113+
display_name: userRow.display_name,
114+
account_type: userRow.account_type
115+
}
116+
});
117+
} catch (error) {
118+
if (error instanceof ProvisionError) {
119+
return NextResponse.json({ error: error.message }, { status: error.status });
120+
}
121+
console.error('dns/verify: unexpected failure', error);
122+
return NextResponse.json({ error: 'Could not verify that proof' }, { status: 500 });
123+
}
124+
}
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
/**
2+
* @fileoverview Tests for POST /api/auth/dns/verify.
3+
*
4+
* The invariants that matter here are the same as the Moshpit path's, plus one
5+
* of its own: a lookup that ran before DNS propagated must not spend the
6+
* challenge, because "I just added the record" is the honest case.
7+
*/
8+
9+
import { beforeEach, describe, expect, it, vi } from 'vitest';
10+
11+
const mocks = vi.hoisted(() => ({
12+
verifyDnsProof: vi.fn(),
13+
createClient: vi.fn(),
14+
createSupabaseServerClient: vi.fn(),
15+
deriveUniqueUsername: vi.fn(async () => 'example')
16+
}));
17+
18+
vi.mock('@supabase/supabase-js', () => ({ createClient: mocks.createClient }));
19+
vi.mock('@/lib/supabase.js', () => ({
20+
createSupabaseServerClient: mocks.createSupabaseServerClient
21+
}));
22+
vi.mock('@/lib/auth/coinpay.js', () => ({ deriveUniqueUsername: mocks.deriveUniqueUsername }));
23+
vi.mock('@/lib/auth/dns-name.js', async (importOriginal) => {
24+
const actual = await importOriginal();
25+
return { ...actual, verifyDnsProof: mocks.verifyDnsProof };
26+
});
27+
28+
const NAME = 'example.com';
29+
const JTI = 'challenge-1';
30+
31+
const liveChallenge = (over = {}) => ({
32+
jti: JTI,
33+
name: NAME,
34+
namespace: 'dns',
35+
nonce: 'the-nonce',
36+
consumed_at: null,
37+
expires_at: new Date(Date.now() + 60_000).toISOString(),
38+
...over
39+
});
40+
41+
function supabaseStub({ challenge = liveChallenge(), burnRows = [{ jti: JTI }], onUpsert } = {}) {
42+
const table = (name) => ({
43+
select() {
44+
return this;
45+
},
46+
eq() {
47+
return this;
48+
},
49+
is() {
50+
return this;
51+
},
52+
ilike() {
53+
return this;
54+
},
55+
gte() {
56+
return this;
57+
},
58+
single: async () =>
59+
name === 'name_challenges'
60+
? { data: challenge, error: challenge ? null : { code: 'PGRST116' } }
61+
: { data: null, error: { code: 'PGRST116' } },
62+
update() {
63+
return {
64+
eq() {
65+
return this;
66+
},
67+
is() {
68+
return this;
69+
},
70+
select: async () => ({ data: burnRows, error: null })
71+
};
72+
},
73+
insert() {
74+
return {
75+
select: () => ({
76+
single: async () => ({
77+
data: { id: 'user-1', username: 'example', display_name: NAME, account_type: 'name' },
78+
error: null
79+
})
80+
})
81+
};
82+
},
83+
upsert: async (row) => {
84+
onUpsert?.(name, row);
85+
return { error: null };
86+
},
87+
delete() {
88+
return this;
89+
}
90+
});
91+
92+
return {
93+
from: (name) => table(name),
94+
auth: {
95+
admin: {
96+
listUsers: async () => ({ data: { users: [] }, error: null }),
97+
createUser: async () => ({ data: { user: { id: 'auth-1' } }, error: null }),
98+
generateLink: async () => ({ data: { properties: { hashed_token: 'tok' } }, error: null })
99+
}
100+
}
101+
};
102+
}
103+
104+
function dnsRequest(over = {}) {
105+
return new Request('https://example.com/api/auth/dns/verify', {
106+
method: 'POST',
107+
body: JSON.stringify({ jti: JTI, name: NAME, publicKey: 'ml-kem-public-key', ...over })
108+
});
109+
}
110+
111+
describe('dns/verify', () => {
112+
beforeEach(() => {
113+
vi.resetModules();
114+
vi.clearAllMocks();
115+
mocks.verifyDnsProof.mockResolvedValue({
116+
name: NAME,
117+
record: `_qryptchat-challenge.${NAME}`,
118+
resolver: 'https://cloudflare-dns.com/dns-query'
119+
});
120+
mocks.createSupabaseServerClient.mockResolvedValue({
121+
auth: {
122+
verifyOtp: async () => ({
123+
data: { session: { access_token: 'a', refresh_token: 'r', expires_at: 1 } },
124+
error: null
125+
})
126+
}
127+
});
128+
});
129+
130+
it('signs in a domain whose record carries the challenge', async () => {
131+
mocks.createClient.mockReturnValue(supabaseStub());
132+
const { POST } = await import('./route.js');
133+
const res = await POST(dnsRequest());
134+
const bodyJson = await res.json();
135+
expect(res.status).toBe(200);
136+
expect(bodyJson).toMatchObject({ success: true, name: NAME, namespace: 'dns' });
137+
});
138+
139+
it('binds with no pin, because this path has no key', async () => {
140+
// The schema refuses a dns-txt binding that claims a pin, so a regression
141+
// here would surface as a constraint violation in prod rather than here.
142+
const rows = [];
143+
mocks.createClient.mockReturnValue(
144+
supabaseStub({ onUpsert: (table, row) => rows.push([table, row]) })
145+
);
146+
const { POST } = await import('./route.js');
147+
await POST(dnsRequest());
148+
const [, binding] = rows.find(([t]) => t === 'user_names');
149+
expect(binding).toMatchObject({ namespace: 'dns', proof: 'dns-txt', bound_pin: null, pin_kind: null });
150+
});
151+
152+
it('will not spend the challenge when the record has not propagated', async () => {
153+
// "I just added it" is the honest case and must stay retryable.
154+
const { DnsProofError } = await import('@/lib/auth/dns-name.js');
155+
mocks.verifyDnsProof.mockRejectedValue(new DnsProofError('record_not_found', 'not there yet'));
156+
const stub = supabaseStub();
157+
const touched = vi.spyOn(stub, 'from');
158+
mocks.createClient.mockReturnValue(stub);
159+
const { POST } = await import('./route.js');
160+
const res = await POST(dnsRequest());
161+
expect(res.status).toBe(400);
162+
expect(touched.mock.calls.every(([t]) => t === 'name_challenges')).toBe(true);
163+
});
164+
165+
it('answers 503 when no public resolver could be reached', async () => {
166+
const { DnsProofError } = await import('@/lib/auth/dns-name.js');
167+
mocks.verifyDnsProof.mockRejectedValue(
168+
new DnsProofError('resolvers_unreachable', 'down', { definite: false })
169+
);
170+
mocks.createClient.mockReturnValue(supabaseStub());
171+
const { POST } = await import('./route.js');
172+
expect((await POST(dnsRequest())).status).toBe(503);
173+
});
174+
175+
it('refuses a challenge minted in the Moshpit namespace', async () => {
176+
// Otherwise a name proved cheaply in one namespace could be spent in the
177+
// other, which is the impersonation the namespace column exists to stop.
178+
mocks.createClient.mockReturnValue(
179+
supabaseStub({ challenge: liveChallenge({ namespace: 'moshpit' }) })
180+
);
181+
const { POST } = await import('./route.js');
182+
const res = await POST(dnsRequest());
183+
expect(res.status).toBe(400);
184+
expect((await res.json()).error).toMatch(/different namespace/i);
185+
});
186+
187+
it('refuses when it loses the burn race', async () => {
188+
mocks.createClient.mockReturnValue(supabaseStub({ burnRows: [] }));
189+
const { POST } = await import('./route.js');
190+
const res = await POST(dnsRequest());
191+
expect(res.status).toBe(400);
192+
expect((await res.json()).error).toMatch(/already been used/i);
193+
});
194+
195+
it('accepts a domain with more than two labels', async () => {
196+
mocks.createClient.mockReturnValue(
197+
supabaseStub({ challenge: liveChallenge({ name: 'example.co.uk' }) })
198+
);
199+
mocks.verifyDnsProof.mockResolvedValue({ name: 'example.co.uk', record: 'x', resolver: 'y' });
200+
const { POST } = await import('./route.js');
201+
const res = await POST(dnsRequest({ name: 'example.co.uk' }));
202+
expect(res.status).toBe(200);
203+
});
204+
205+
it('refuses a bare address', async () => {
206+
mocks.createClient.mockReturnValue(supabaseStub());
207+
const { POST } = await import('./route.js');
208+
expect((await POST(dnsRequest({ name: '192.168.1.1' }))).status).toBe(400);
209+
});
210+
});

0 commit comments

Comments
 (0)