Skip to content

Commit 14031a8

Browse files
Brooooooklynclaude
andcommitted
Drop the version marker from proxy tokens and keys
Tokens for generation 1 and above are now `<gen>.<mac>`. The dot still keeps them apart from the 43-character original token. The HKDF labels and signed messages carry the key's purpose instead of a version: the tenant key uses salt `void-proxy-token`, info `proxy-token`, and signs `void-proxy-token:<projectId>:<gen>`; the proxy internal key uses salt `void-proxy-internal`, info `proxy-internal`, and signs `void-proxy-internal\n<route>\n<projectId>`. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 4cfe2b9 commit 14031a8

5 files changed

Lines changed: 43 additions & 35 deletions

File tree

‎platform/packages/api/src/proxy-token.ts‎

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ import { isProjectId } from './id.ts';
88
*
99
* - legacy (generation 0): base64url(HMAC-SHA256(JWT_SECRET, projectId)),
1010
* 43 characters, the bytes `hmacSignState` produces;
11-
* - `v2.<gen>.<mac>`: the MAC is HMAC-SHA256 under a key derived from
12-
* JWT_SECRET with HKDF, over the project id and the generation.
11+
* - `<gen>.<mac>` (generation 1 and above): the MAC is HMAC-SHA256 under a
12+
* key derived from JWT_SECRET with HKDF, over the project id and the
13+
* generation. The dot keeps it apart from the 43-character legacy token.
1314
*
1415
* ROUTING `proxy-token:<projectId>` holds the accept floor `{"gen": F}`. A
1516
* token is accepted when its MAC is valid and its generation is at least F.
@@ -143,20 +144,26 @@ async function hkdfHmacKey(secret: string, salt: string, info: string): Promise<
143144
);
144145
}
145146

147+
// JWT_SECRET signs login JWTs, OAuth/link state and the legacy token as a raw
148+
// HMAC key. The two keys below are HKDF outputs with their own salt and info,
149+
// so they differ from that raw key and from each other: a tenant token never
150+
// verifies as a proxy internal value, and neither verifies as any raw-key MAC.
146151
function proxyTokenKey(secret: string): Promise<CryptoKey> {
147-
return cachedKey(proxyTokenKeys, secret, () => hkdfHmacKey(secret, 'void-proxy-token', 'v2'));
152+
return cachedKey(proxyTokenKeys, secret, () =>
153+
hkdfHmacKey(secret, 'void-proxy-token', 'proxy-token'),
154+
);
148155
}
149156

150157
function proxyInternalKey(secret: string): Promise<CryptoKey> {
151158
return cachedKey(proxyInternalKeys, secret, () =>
152-
hkdfHmacKey(secret, 'void-proxy-internal', 'v1'),
159+
hkdfHmacKey(secret, 'void-proxy-internal', 'proxy-internal'),
153160
);
154161
}
155162

156163
// --- Encoding ---
157164

158165
const MAC = /^[A-Za-z0-9_-]{43}$/;
159-
const V2_TOKEN = /^v2\.([1-9][0-9]{0,15})\.([A-Za-z0-9_-]{43})$/;
166+
const GENERATION_TOKEN = /^([1-9][0-9]{0,15})\.([A-Za-z0-9_-]{43})$/;
160167

161168
function encodeBase64url(bytes: ArrayBuffer): string {
162169
let binary = '';
@@ -181,7 +188,7 @@ async function verify(key: Promise<CryptoKey>, message: string, mac: string): Pr
181188
}
182189

183190
function proxyTokenMessage(projectId: string, gen: number): string {
184-
return `void-proxy-token:v2:${projectId}:${gen}`;
191+
return `void-proxy-token:${projectId}:${gen}`;
185192
}
186193

187194
// --- Tenant tokens ---
@@ -201,7 +208,7 @@ export async function deriveProxyToken(
201208
if (gen === 0) {
202209
return sign(legacyKey(jwtSecret), projectId);
203210
}
204-
return `v2.${gen}.${await sign(proxyTokenKey(jwtSecret), proxyTokenMessage(projectId, gen))}`;
211+
return `${gen}.${await sign(proxyTokenKey(jwtSecret), proxyTokenMessage(projectId, gen))}`;
205212
}
206213

207214
/** The token's generation when its MAC is valid for `projectId`, else null. No I/O. */
@@ -216,7 +223,7 @@ export async function proxyTokenGeneration(
216223
if (MAC.test(token)) {
217224
return (await verify(legacyKey(jwtSecret), projectId, token)) ? 0 : null;
218225
}
219-
const match = V2_TOKEN.exec(token);
226+
const match = GENERATION_TOKEN.exec(token);
220227
if (!match) {
221228
return null;
222229
}
@@ -343,7 +350,7 @@ export const PROXY_INTERNAL_AUTH_HEADER = 'x-void-proxy-internal';
343350
export type ProxyInternalRoute = 'resolve-worker' | 'resolve-resource' | 'verify-prerender-build';
344351

345352
function proxyInternalMessage(route: ProxyInternalRoute, projectId: string): string {
346-
return `void-proxy-internal:v1\n${route}\n${projectId}`;
353+
return `void-proxy-internal\n${route}\n${projectId}`;
347354
}
348355

349356
/**

‎platform/packages/api/test/integration/api-internal-project-token.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ describe('project token generations', () => {
216216
expect(run).not.toHaveBeenCalled();
217217
});
218218

219-
it('accepts the legacy and v2 tokens at /internal/revalidate while no floor is set', async () => {
219+
it('accepts the legacy and generation 1 tokens at /internal/revalidate while no floor is set', async () => {
220220
const project = await liveProject();
221221
for (const gen of [0, 1]) {
222222
const response = await internalRequest(

‎platform/packages/api/test/unit/proxy-token.test.ts‎

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,12 @@ const PROJECT = 'proj_000000000001';
3030
const OTHER_PROJECT = 'proj_000000000002';
3131

3232
const TOK_0 = 'ZMRzpVRsaOJSG_unVlA0oqSaz2kApOohvao_XGR8Q0c';
33-
const TOK_1 = 'v2.1.3W5_i2g79pUCXyzxWPQWmTqscSNtC_2vuHC84z775Y8';
34-
const TOK_2 = 'v2.2.Vx_qycCHPx2BoYvQrbQ7JrhbONKU7f0jNK23B0YUhOs';
33+
const TOK_1 = '1.tSB1ya3fUJXjnL2nfUht0aCBRh9d1pFCrH_rJIJGGWo';
34+
const TOK_2 = '2.Ko4ZEz_VQ8IZH70koONArHkURyEHZUwLJsqFsGJgwyE';
3535
const INTERNAL: Record<ProxyInternalRoute, string> = {
36-
'resolve-worker': 'fSljhl8vCcBSR-jWXklS-KDL4K8lFb_wPaRAu-aKPv0',
37-
'resolve-resource': 'gNOx85Zh_di7yHFjG8AlV0QQ8PbHYs1ipC2ukAl7i5Y',
38-
'verify-prerender-build': 'A7F1IjLUbEWq0atQH3W5dsR1DidJ6ZgqU6QQ3lehBbc',
36+
'resolve-worker': 'Phjba6joMrR31HPBsOamhNdtbT3__9Fg6zkd34dJiS0',
37+
'resolve-resource': 'qqnenNPCqY-C4RMAfPn0AUdXlvKS0tvkDcC8lyjTYck',
38+
'verify-prerender-build': 'L-_KXs8KdULytY5ibjHuH095R-1L3OM91NokMoj6828',
3939
};
4040
const ROUTES = Object.keys(INTERNAL) as Array<ProxyInternalRoute>;
4141

@@ -47,15 +47,15 @@ function nodeTok(gen: number, projectId = PROJECT): string {
4747
if (gen === 0) {
4848
return base64url(createHmac('sha256', SECRET).update(projectId).digest());
4949
}
50-
const key = Buffer.from(hkdfSync('sha256', SECRET, 'void-proxy-token', 'v2', 32));
51-
const mac = createHmac('sha256', key).update(`void-proxy-token:v2:${projectId}:${gen}`).digest();
52-
return `v2.${gen}.${base64url(mac)}`;
50+
const key = Buffer.from(hkdfSync('sha256', SECRET, 'void-proxy-token', 'proxy-token', 32));
51+
const mac = createHmac('sha256', key).update(`void-proxy-token:${projectId}:${gen}`).digest();
52+
return `${gen}.${base64url(mac)}`;
5353
}
5454

5555
function nodeInternal(route: ProxyInternalRoute, projectId = PROJECT): string {
56-
const key = Buffer.from(hkdfSync('sha256', SECRET, 'void-proxy-internal', 'v1', 32));
56+
const key = Buffer.from(hkdfSync('sha256', SECRET, 'void-proxy-internal', 'proxy-internal', 32));
5757
return base64url(
58-
createHmac('sha256', key).update(`void-proxy-internal:v1\n${route}\n${projectId}`).digest(),
58+
createHmac('sha256', key).update(`void-proxy-internal\n${route}\n${projectId}`).digest(),
5959
);
6060
}
6161

@@ -111,7 +111,7 @@ describe('vectors', () => {
111111
});
112112

113113
it('keeps internal values distinct from each other and from tenant tokens', () => {
114-
const values = [...Object.values(INTERNAL), TOK_0, TOK_1.slice('v2.1.'.length)];
114+
const values = [...Object.values(INTERNAL), TOK_0, TOK_1.slice('1.'.length)];
115115
expect(new Set(values).size).toBe(values.length);
116116
});
117117

@@ -193,7 +193,7 @@ describe('state table', () => {
193193
});
194194

195195
describe('verifyProxyToken', () => {
196-
it('accepts legacy and v2 tokens when no floor is set', async () => {
196+
it('accepts the legacy and generation tokens when no floor is set', async () => {
197197
expect(await verify(TOK_0).result).toBe(true);
198198
expect(await verify(TOK_1).result).toBe(true);
199199
});
@@ -218,14 +218,14 @@ describe('verifyProxyToken', () => {
218218
});
219219

220220
it('rejects a MAC relabelled with another generation', async () => {
221-
const relabelled = `v2.2.${TOK_1.slice('v2.1.'.length)}`;
221+
const relabelled = `2.${TOK_1.slice('1.'.length)}`;
222222
const { result, readState } = verify(relabelled);
223223
expect(await result).toBe(false);
224224
expect(readState).not.toHaveBeenCalled();
225225
});
226226

227227
it('rejects a bad MAC without reading the floor', async () => {
228-
const badMac = `v2.1.${'A'.repeat(43)}`;
228+
const badMac = `1.${'A'.repeat(43)}`;
229229
for (const token of [badMac, 'A'.repeat(43)]) {
230230
const { result, readState } = verify(token);
231231
expect(await result).toBe(false);
@@ -234,14 +234,15 @@ describe('verifyProxyToken', () => {
234234
});
235235

236236
it.each([
237-
['leading zero generation', `v2.01.${TOK_1.slice('v2.1.'.length)}`],
238-
['leading zero and short MAC', 'v2.01.x'],
237+
['generation zero in the dotted form', `0.${TOK_1.slice('1.'.length)}`],
238+
['leading zero generation', `01.${TOK_1.slice('1.'.length)}`],
239+
['leading zero and short MAC', '01.x'],
239240
['42-char MAC', TOK_0.slice(0, 42)],
240241
['44-char MAC', `${TOK_0}A`],
241242
['trailing padding', `${TOK_0}=`],
242243
['standard base64 alphabet', `+${TOK_0.slice(1)}`],
243244
['empty token', ''],
244-
['unsafe generation', `v2.9999999999999999.${TOK_1.slice('v2.1.'.length)}`],
245+
['unsafe generation', `9999999999999999.${TOK_1.slice('1.'.length)}`],
245246
])('rejects a malformed token (%s) without reading the floor', async (_name, token) => {
246247
const { result, readState } = verify(token);
247248
expect(await result).toBe(false);
@@ -416,7 +417,7 @@ describe('verifyProxyInternalAuth', () => {
416417
it.each([
417418
['42 chars', INTERNAL['resolve-worker'].slice(0, 42)],
418419
['44 chars', `${INTERNAL['resolve-worker']}A`],
419-
['a v2 token shape', 'v2.1.x'],
420+
['a generation token shape', '1.x'],
420421
['a tenant token', TOK_1],
421422
['the legacy token', TOK_0],
422423
['empty', ''],

‎platform/packages/proxy/README.md‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,10 @@ seconds; non-email routes return 413 for oversized bodies and 408 for timeouts.
4747
Each deployed Worker holds its project token in the `__VOID_PROXY_TOKEN`
4848
binding. Both forms are derived from `JWT_SECRET`:
4949

50-
| Form | Generation | Value |
51-
| ---------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
52-
| Legacy | 0 | base64url `HMAC-SHA256(JWT_SECRET, projectId)` |
53-
| `v2.<gen>.<mac>` | `<gen>` | `<mac>` = base64url `HMAC-SHA256(K, "void-proxy-token:v2:" + projectId + ":" + gen)`, `K` = HKDF of `JWT_SECRET` (salt `void-proxy-token`, info `v2`) |
50+
| Form | Generation | Value |
51+
| ------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
52+
| Legacy | 0 | base64url `HMAC-SHA256(JWT_SECRET, projectId)` |
53+
| `<gen>.<mac>` | 1 and up | `<mac>` = base64url `HMAC-SHA256(K, "void-proxy-token:" + projectId + ":" + gen)`, `K` = HKDF of `JWT_SECRET` (salt `void-proxy-token`, info `proxy-token`) |
5454

5555
The ROUTING key `proxy-token:{projectId}` holds the project's accept floor,
5656
`{"gen": N}`. A token is accepted when its MAC is valid and its generation is at
@@ -73,8 +73,8 @@ generation. It never sends a generation above the issued one.
7373

7474
The proxy authenticates its own `resolve-worker`, `resolve-resource`, and
7575
`verify-prerender-build` calls with `x-void-proxy-internal`, an HMAC under a
76-
separate HKDF key (salt `void-proxy-internal`, info `v1`) bound to the route and
77-
project. That value never leaves the platform. The proxy also sends the legacy
76+
separate HKDF key (salt `void-proxy-internal`, info `proxy-internal`) bound to
77+
the route and project. That value never leaves the platform. The proxy also sends the legacy
7878
`x-void-internal` header so an older API keeps working.
7979

8080
## Endpoints

‎platform/packages/proxy/test/unit/proxy.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1153,7 +1153,7 @@ describe('proxy worker', () => {
11531153
vi.restoreAllMocks();
11541154
});
11551155

1156-
it('accepts the legacy and v2 tokens while no floor is set', async () => {
1156+
it('accepts the legacy and generation 1 tokens while no floor is set', async () => {
11571157
for (const gen of [0, 1]) {
11581158
clearProxyTokenStateCache();
11591159
const env = createEnv();

0 commit comments

Comments
 (0)