@bongos/core 1.19.1017 → 1.19.1018

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,6 +13,9 @@
13
13
  import { strict as assert } from 'node:assert';
14
14
  import { createRequire } from 'node:module';
15
15
  import crypto from 'node:crypto';
16
+ // ESM import so the knip ratchet credits these test-facing exports (a
17
+ // createRequire() import does not credit a CJS export).
18
+ import { applicationVouchIdentity, verifyHubApplicationVouch } from '../src/bongos/auth-idp.js';
16
19
 
17
20
  const require = createRequire(import.meta.url);
18
21
  const auth = require('../src/bongos/auth.js');
@@ -194,5 +197,176 @@ t('a logout jti is fresh on first use, seen on replay, and evicted after expiry'
194
197
  assert.equal(auth.logoutTokenJtiSeenAndRecord(undefined, exp, now), false, 'missing jti is never "seen"');
195
198
  });
196
199
 
200
+ // --- Application vouch (ADR 0201 D2, ADR 0208 D2, task 1002285) -------------
201
+ // The hub's join relay carries a THIRD token kind to the target project: an
202
+ // application vouch — identity AND purpose 'application'. ADR 0208 D2 found the
203
+ // hole this closes: verifyHubAssertion never looked at `purpose`, so any
204
+ // identity payload verified AS A LOGIN, and a captured vouch would have been
205
+ // replayable into POST /auth/device/exchange as a device sign-in.
206
+
207
+ function mintVouch(overrides = {}, opts = {}) {
208
+ return hubKeys.mintHubApplicationVouch(
209
+ { clientId: 'client-x', githubId: 42, githubLogin: 'octo', handle: 'octo', ...overrides },
210
+ { signingKey, ...opts },
211
+ );
212
+ }
213
+
214
+ // Sign an ARBITRARY payload with the real hub key — the only way to ask "what
215
+ // does a verifier do with a shape no minter emits today?", which is exactly the
216
+ // window ADR 0208 D2 says to close.
217
+ function signRaw(payload) {
218
+ const b64u = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
219
+ const input = `${b64u({ alg: 'EdDSA', typ: 'JWT' })}.${b64u(payload)}`;
220
+ return `${input}.${crypto.sign(null, Buffer.from(input), signingKey).toString('base64url')}`;
221
+ }
222
+ const nowS = () => Math.floor(Date.now() / 1000);
223
+
224
+ console.log('\nhub application vouch mint → instance verify (roundtrip):');
225
+
226
+ t('a valid vouch verifies, carrying identity + purpose + handle and NO rank', () => {
227
+ const p = verifyHubApplicationVouch(mintVouch(), { publicKey, clientId: 'client-x' });
228
+ assert.equal(p.sub, '42');
229
+ assert.equal(p.applicant_login, 'octo');
230
+ assert.equal(p.github_login, undefined,
231
+ 'the login rides as applicant_login: every login verifier any core ever shipped demands github_login, '
232
+ + 'so a core that predates the purpose refusal still cannot take a vouch as a device sign-in');
233
+ assert.equal(p.handle, 'octo');
234
+ assert.equal(p.purpose, 'application');
235
+ assert.equal(p.aud, 'client-x');
236
+ assert.ok(p.jti, 'a vouch always carries a jti — single use is chosen, not optional');
237
+ assert.equal(p.rank, undefined, 'a vouch is identity, never authority (ADR 0016)');
238
+ assert.equal(p.display_name, undefined, 'and not the login assertion’s profile fields either');
239
+ });
240
+
241
+ console.log('\nadversarial (vouch) — every one of these MUST be rejected:');
242
+
243
+ t('wrong audience (a vouch for project A cannot vouch at project B)', () => {
244
+ assert.throws(() => verifyHubApplicationVouch(mintVouch(), { publicKey, clientId: 'other-client' }), /audience/);
245
+ });
246
+
247
+ t('NO audience configured never verifies — the envelope would otherwise skip the aud check', () => {
248
+ assert.throws(() => verifyHubApplicationVouch(mintVouch(), { publicKey, clientId: null }), /audience/);
249
+ });
250
+
251
+ t('expired vouch (minted 10 min ago; TTL is ~120s)', () => {
252
+ const stale = mintVouch({}, { nowMs: Date.now() - 10 * 60 * 1000 });
253
+ assert.throws(() => verifyHubApplicationVouch(stale, { publicKey, clientId: 'client-x' }), /expired/);
254
+ });
255
+
256
+ t('tampered payload (swap sub to another github_id) fails the signature', () => {
257
+ const [h, p, s] = mintVouch().split('.');
258
+ const payload = JSON.parse(Buffer.from(p, 'base64url').toString('utf8'));
259
+ payload.sub = '999';
260
+ const forged = `${h}.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.${s}`;
261
+ assert.throws(() => verifyHubApplicationVouch(forged, { publicKey, clientId: 'client-x' }), /signature/);
262
+ });
263
+
264
+ t('a different signing key, alg "none", malformed and a missing public key are all rejected', () => {
265
+ const otherPub = crypto.generateKeyPairSync('ed25519').publicKey;
266
+ assert.throws(() => verifyHubApplicationVouch(mintVouch(), { publicKey: otherPub, clientId: 'client-x' }), /signature/);
267
+ const [h, p, s] = mintVouch().split('.');
268
+ const hdr = JSON.parse(Buffer.from(h, 'base64url').toString('utf8'));
269
+ hdr.alg = 'none';
270
+ const confused = `${Buffer.from(JSON.stringify(hdr)).toString('base64url')}.${p}.${s}`;
271
+ assert.throws(() => verifyHubApplicationVouch(confused, { publicKey, clientId: 'client-x' }), /alg|signature/);
272
+ assert.throws(() => verifyHubApplicationVouch('not-a-jwt', { publicKey, clientId: 'client-x' }), /malformed/);
273
+ assert.throws(() => verifyHubApplicationVouch(mintVouch(), { publicKey: null, clientId: 'client-x' }), /public key/);
274
+ });
275
+
276
+ t('a vouch with no jti is rejected — one vouch, one application needs something to count', () => {
277
+ const noJti = signRaw({ iss: 'cloudbongos-hub', aud: 'client-x', sub: '42', applicant_login: 'octo', purpose: 'application', iat: nowS(), exp: nowS() + 60 });
278
+ assert.throws(() => verifyHubApplicationVouch(noJti, { publicKey, clientId: 'client-x' }), /jti/);
279
+ });
280
+
281
+ t('a vouch with no applicant identity is rejected by the verifier itself, not only by the login match', () => {
282
+ for (const drop of ['sub', 'applicant_login']) {
283
+ const claims = { iss: 'cloudbongos-hub', aud: 'client-x', sub: '42', applicant_login: 'octo', purpose: 'application', iat: nowS(), exp: nowS() + 60, jti: `no-${drop}` };
284
+ delete claims[drop];
285
+ assert.throws(() => verifyHubApplicationVouch(signRaw(claims), { publicKey, clientId: 'client-x' }), /identity/, `missing ${drop}`);
286
+ }
287
+ });
288
+
289
+ t('mintHubApplicationVouch refuses without a signing key', () => {
290
+ assert.throws(() => hubKeys.mintHubApplicationVouch({ clientId: 'c', githubId: 1, githubLogin: 'x' }, {}), /signing key/);
291
+ });
292
+
293
+ console.log('\nlogin / logout / vouch are mutually NON-interchangeable:');
294
+
295
+ t('an APPLICATION VOUCH is NOT accepted as a login assertion — the replay ADR 0208 D2 found open', () => {
296
+ assert.throws(() => auth.verifyHubAssertion(mintVouch(), { publicKey, clientId: 'client-x' }), /identity|purpose/);
297
+ // And the purpose refusal holds on its own: a vouch-shaped token that ALSO carried
298
+ // github_login (the shape a bolted-on purpose would have produced) still fails.
299
+ const both = signRaw({ iss: 'cloudbongos-hub', aud: 'client-x', sub: '42', github_login: 'octo', applicant_login: 'octo', purpose: 'application', iat: nowS(), exp: nowS() + 60, jti: 'both' });
300
+ assert.throws(() => auth.verifyHubAssertion(both, { publicKey, clientId: 'client-x' }), /purpose/);
301
+ });
302
+
303
+ t('verifyHubAssertion refuses ANY purpose on an identity payload, not only this one', () => {
304
+ for (const purpose of ['application', 'anything', '', null]) {
305
+ const token = signRaw({ iss: 'cloudbongos-hub', aud: 'client-x', sub: '42', github_login: 'octo', purpose, iat: nowS(), exp: nowS() + 60, jti: `p-${purpose}` });
306
+ assert.throws(() => auth.verifyHubAssertion(token, { publicKey, clientId: 'client-x' }), /purpose/,
307
+ `purpose=${JSON.stringify(purpose)} must not verify as a login`);
308
+ }
309
+ });
310
+
311
+ t('a LOGIN assertion is NOT accepted as an application vouch (no purpose)', () => {
312
+ assert.throws(() => verifyHubApplicationVouch(mint(), { publicKey, clientId: 'client-x' }), /application vouch/);
313
+ });
314
+
315
+ t('a LOGOUT token is NOT accepted as an application vouch, nor a vouch as a logout', () => {
316
+ assert.throws(() => verifyHubApplicationVouch(mintLogout(), { publicKey, clientId: 'client-x' }), /application vouch/);
317
+ assert.throws(() => auth.verifyHubLogoutToken(mintVouch(), { publicKey, clientId: 'client-x' }), /logout/);
318
+ });
319
+
320
+ console.log('\napplicationVouchIdentity — what a project may take from a vouch:');
321
+
322
+ const vouchOpts = (githubLogin, extra = {}) => ({ githubLogin, publicKey, clientId: 'client-x', ...extra });
323
+
324
+ t('a vouch for the login on the request yields its github id and handle (login case-insensitive)', () => {
325
+ assert.deepEqual(applicationVouchIdentity(mintVouch(), vouchOpts('Octo')), { githubId: '42', handle: 'octo' });
326
+ });
327
+
328
+ t('a vouch naming a DIFFERENT account than the request is no vouch at all', () => {
329
+ assert.equal(applicationVouchIdentity(mintVouch(), vouchOpts('someone-else')), null,
330
+ 'otherwise one account’s vouch could be stapled to a request naming another');
331
+ });
332
+
333
+ t('one vouch vouches ONE application — the same token twice is vouched once', () => {
334
+ const token = mintVouch();
335
+ assert.ok(applicationVouchIdentity(token, vouchOpts('octo')));
336
+ assert.equal(applicationVouchIdentity(token, vouchOpts('octo')), null, 'the replay is unvouched');
337
+ });
338
+
339
+ t('an instance with no hub public key or no client id vouches nothing', () => {
340
+ assert.equal(applicationVouchIdentity(mintVouch(), vouchOpts('octo', { publicKey: null })), null);
341
+ assert.equal(applicationVouchIdentity(mintVouch(), vouchOpts('octo', { clientId: null })), null);
342
+ });
343
+
344
+ t('absent, garbage, forged and login-shaped tokens are null — never a throw', () => {
345
+ for (const token of [undefined, null, '', 'garbage', mint(), mintLogout()]) {
346
+ assert.equal(applicationVouchIdentity(token, vouchOpts('octo')), null);
347
+ }
348
+ });
349
+
350
+ t('LOCKSTEP: every handle the hub can hold survives the instance’s check, and nothing else does', () => {
351
+ // normalizeHandle is the hub's own rule (platform-identity.js); the instance keeps a
352
+ // boundary-forced mirror of it. Drive both over the same inputs so a change to
353
+ // either side that the other does not follow reds here, instead of handles
354
+ // silently arriving as null.
355
+ const { normalizeHandle } = require('../modules/platform-identity/platform-identity.js');
356
+ const inputs = ['octo', 'a', '9', 'a-b', 'builder-123456', 'x'.repeat(39), 'x'.repeat(40), '-a', 'a-', 'a_b', 'A', 'o.o', ''];
357
+ for (const raw of inputs) {
358
+ const hubHolds = normalizeHandle(raw) === raw;
359
+ const got = applicationVouchIdentity(mintVouch({ handle: raw }), vouchOpts('octo'));
360
+ assert.equal(got.handle, hubHolds ? raw : null, `handle ${JSON.stringify(raw)}: hub holds=${hubHolds}`);
361
+ }
362
+ });
363
+
364
+ t('a handle outside the canonical charset rides as null — the identity still stands', () => {
365
+ for (const handle of ['<b>octo</b>', 'Octo Cat', 'x'.repeat(60), 42, null]) {
366
+ assert.deepEqual(applicationVouchIdentity(mintVouch({ handle }), vouchOpts('octo')), { githubId: '42', handle: null },
367
+ `handle=${JSON.stringify(handle)}`);
368
+ }
369
+ });
370
+
197
371
  console.log(`\nidp_device_exchange: ${passed} passed, ${failed} failed`);
198
372
  if (failed > 0) process.exit(1);
@@ -47,6 +47,7 @@ const PUBLISHED_SURFACE = [
47
47
  'projectJoinDoor', // added by task 1002331 (ADR 0182 D6 / ADR 0247): the COMPOSED join/apply door — visibility × join grant × joinability, one answer, so a module reading a single knob cannot re-open a door the other two closed
48
48
  'artistGate', // added by task 1003575 (ADR 0241): the artist gate — off | advisory | strict, plus the pure verdict helpers over it. Two modules ask it two different questions ("file this review?" and "does an open one hold the deploy?") and both must resolve the knob through this one reader, never branding().project
49
49
  'readApplicantProfileFromHub', // added by task 1002972 (privacy spec D7): the reviewer queue's LIVE per-render read of one applicant's hub profile view. NARROW by design — the underlying call is authenticated with this instance's hub client secret, and loadIdpConfig stays OFF the doorway so no module ever holds the credential that speaks for the whole project
50
+ 'readApplicationVouch', // added by task 1002285 (ADR 0201 D2): the public access-request write's check of the hub's application vouch. NARROW for the same reason — core supplies the hub key and this instance's client_id, so a module passes a token and a login and can neither swap the key nor re-address the audience
50
51
  'enabledDisciplines', // BV1.R81: instance offered-disciplines (1.9.0; onboarding restock backstop)
51
52
  'isModuleEnabled', // task 1003881: is ANOTHER module present? onboarding's Discord approve/decline control is only actionable when the `discord` module's reaction handler is loaded, so the posting half has to be able to ask
52
53
  'registerProvider', 'hasProvider', 'resolve', 'resolveOptional', 'listPorts', 'verifyPortsSatisfied',