@bongos/core 1.21.136 → 1.21.137
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.
- package/.bongos-core.json +42 -27
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +12 -0
- package/clients/bongos-client/index.cjs +12 -0
- package/clients/bongos-client/index.d.ts +20 -0
- package/clients/bongos-client/index.mjs +12 -0
- package/docs/api/openapi.json +349 -3
- package/docs/api-reference.md +8 -2
- package/docs/copy-inventory.md +67 -51
- package/docs/copy-registry.json +199 -55
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-inventory.json +1 -0
- package/docs/page-readings.json +154 -149
- package/modules/hall-ui/public/settings-passkeys.js +113 -0
- package/modules/hall-ui/public/settings.html +20 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/bongos/passkeys.js +11 -1
- package/src/bongos/routes/my-passkeys.js +249 -0
- package/src/bongos/routes.js +2 -0
- package/src/module-api.js +1 -1
- package/tests/my_passkeys.mjs +108 -0
- package/tests/own_scope_guard.mjs +5 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// modules/hall-ui/public/settings-passkeys.js — the "Security cards" panel on
|
|
2
|
+
// /builders/settings (task 1004746, ADR 0382). List, add, rename, remove the
|
|
3
|
+
// signed-in builder's FIDO cards. All identity is the server's session; this file
|
|
4
|
+
// only drives the ceremony the browser library performs.
|
|
5
|
+
(() => {
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const API = '/api/bongos';
|
|
9
|
+
const { escapeHtml, $, fmtDate } = window.OTB;
|
|
10
|
+
const toast = (msg) => window.OTB.toast(msg);
|
|
11
|
+
const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
|
|
12
|
+
const toAuth = () => window.location.assign(`${API}/auth/web/start`);
|
|
13
|
+
|
|
14
|
+
const fail = (res) => new Error((res.data && res.data.message) || `HTTP ${res.status}`);
|
|
15
|
+
|
|
16
|
+
function rowHtml(c) {
|
|
17
|
+
const name = c.nickname || 'Unnamed card';
|
|
18
|
+
const used = c.last_used_at ? `last used ${escapeHtml(fmtDate(c.last_used_at))}` : 'never used';
|
|
19
|
+
return `<div class="sessions-row" data-id="${escapeHtml(c.credential_id)}">
|
|
20
|
+
<div class="sessions-row__main">
|
|
21
|
+
<strong>${escapeHtml(name)}</strong>
|
|
22
|
+
<span class="sessions-row__when">added ${escapeHtml(fmtDate(c.created_at))} · ${used}</span>
|
|
23
|
+
</div>
|
|
24
|
+
<button type="button" class="settings-btn settings-btn--quiet passkeys-rename">Rename</button>
|
|
25
|
+
<button type="button" class="settings-btn settings-btn--quiet passkeys-remove">Remove</button>
|
|
26
|
+
</div>`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function render() {
|
|
30
|
+
const res = await api.request('GET', `${API}/me/passkeys`);
|
|
31
|
+
if (res.status === 401) { toAuth(); return; }
|
|
32
|
+
if (!res.ok) throw fail(res);
|
|
33
|
+
const cards = (res.data && res.data.passkeys) || [];
|
|
34
|
+
const list = $('#passkeys-list');
|
|
35
|
+
list.innerHTML = cards.length ? cards.map(rowHtml).join('') : '<p class="settings-card__hint">No cards registered yet.</p>';
|
|
36
|
+
for (const row of list.querySelectorAll('.sessions-row')) {
|
|
37
|
+
const id = row.getAttribute('data-id');
|
|
38
|
+
row.querySelector('.passkeys-rename').addEventListener('click', () => rename(id, row));
|
|
39
|
+
row.querySelector('.passkeys-remove').addEventListener('click', () => remove(id));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function rename(id, row) {
|
|
44
|
+
const current = row.querySelector('strong').textContent;
|
|
45
|
+
const next = window.prompt('Name this card', current === 'Unnamed card' ? '' : current);
|
|
46
|
+
if (next === null) return;
|
|
47
|
+
try {
|
|
48
|
+
const res = await api.request('POST', `${API}/me/passkeys/rename`, { body: { credential_id: id, nickname: next } });
|
|
49
|
+
if (!res.ok) throw fail(res);
|
|
50
|
+
await render();
|
|
51
|
+
} catch (err) { toast(`Could not rename that card: ${err.message}`); }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function remove(id) {
|
|
55
|
+
if (!window.confirm('Remove this card? You can register it again later.')) return;
|
|
56
|
+
try {
|
|
57
|
+
const res = await api.request('POST', `${API}/me/passkeys/remove`, { body: { credential_id: id } });
|
|
58
|
+
if (!res.ok) throw fail(res);
|
|
59
|
+
toast('Card removed.');
|
|
60
|
+
await render();
|
|
61
|
+
} catch (err) { toast(`Could not remove that card: ${err.message}`); }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function loadBrowserLib() {
|
|
65
|
+
if (window.SimpleWebAuthnBrowser) return Promise.resolve(window.SimpleWebAuthnBrowser);
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
const s = document.createElement('script');
|
|
68
|
+
s.src = `${API}/me/passkeys/browser.js`;
|
|
69
|
+
s.onload = () => resolve(window.SimpleWebAuthnBrowser);
|
|
70
|
+
s.onerror = () => reject(new Error('could not load the card library'));
|
|
71
|
+
document.head.appendChild(s);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function add() {
|
|
76
|
+
const btn = $('#passkeys-add');
|
|
77
|
+
const status = $('#passkeys-status');
|
|
78
|
+
btn.disabled = true;
|
|
79
|
+
status.textContent = 'Waiting for your card…';
|
|
80
|
+
try {
|
|
81
|
+
const lib = await loadBrowserLib();
|
|
82
|
+
if (!lib.browserSupportsWebAuthn()) throw new Error('this browser does not support security cards');
|
|
83
|
+
const opt = await api.request('POST', `${API}/me/passkeys/register/options`, { body: {} });
|
|
84
|
+
if (opt.status === 401) { toAuth(); return; }
|
|
85
|
+
if (!opt.ok) throw fail(opt);
|
|
86
|
+
const attestation = await lib.startRegistration({ optionsJSON: opt.data.options });
|
|
87
|
+
const nickname = $('#passkeys-nickname').value;
|
|
88
|
+
const ver = await api.request('POST', `${API}/me/passkeys/register/verify`, { body: { response: attestation, nickname } });
|
|
89
|
+
if (!ver.ok) throw fail(ver);
|
|
90
|
+
$('#passkeys-nickname').value = '';
|
|
91
|
+
status.textContent = '';
|
|
92
|
+
toast('Card added.');
|
|
93
|
+
await render();
|
|
94
|
+
} catch (err) {
|
|
95
|
+
status.textContent = '';
|
|
96
|
+
toast(`Could not add a card: ${err.name === 'NotAllowedError' ? 'cancelled or timed out' : err.message}`);
|
|
97
|
+
} finally {
|
|
98
|
+
btn.disabled = false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function setup() {
|
|
103
|
+
try {
|
|
104
|
+
await render();
|
|
105
|
+
$('#passkeys-scroll').hidden = false;
|
|
106
|
+
$('#passkeys-add').addEventListener('click', add);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
toast(`Could not load your security cards: ${err.message}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
setup();
|
|
113
|
+
})();
|
|
@@ -171,6 +171,25 @@
|
|
|
171
171
|
<p class="section-note">Ending a session takes effect immediately; the token stops working on its very next request.</p>
|
|
172
172
|
</section>
|
|
173
173
|
|
|
174
|
+
<!-- SECURITY CARDS - register, rename and remove your FIDO hardware
|
|
175
|
+
cards (task 1004746, ADR 0382). Rendered by settings-passkeys.js
|
|
176
|
+
against /api/bongos/me/passkeys. -->
|
|
177
|
+
<section class="scroll" id="passkeys-scroll" aria-labelledby="passkeys-h" data-group="access" hidden>
|
|
178
|
+
<h2 id="passkeys-h" class="scroll__h">Security cards</h2>
|
|
179
|
+
<p class="scroll__lede">
|
|
180
|
+
Register a hardware security card (or a phone or laptop passkey) so it can
|
|
181
|
+
be asked for as a second check on sensitive actions. Registering a card
|
|
182
|
+
does not change how you sign in.
|
|
183
|
+
</p>
|
|
184
|
+
<div id="passkeys-list" class="sessions-list" aria-live="polite"></div>
|
|
185
|
+
<div class="settings-card">
|
|
186
|
+
<input type="text" id="passkeys-nickname" maxlength="60" placeholder="Name this card (optional)" aria-label="Card name">
|
|
187
|
+
<button type="button" class="settings-btn" id="passkeys-add">Add a card</button>
|
|
188
|
+
<span class="settings-status" id="passkeys-status" role="status" aria-live="polite"></span>
|
|
189
|
+
</div>
|
|
190
|
+
<p class="section-note">Hold the card to your reader or touch it when your browser asks. Keep more than one registered in case one is lost.</p>
|
|
191
|
+
</section>
|
|
192
|
+
|
|
174
193
|
<!-- YOUR AUTOBONGOS RUNNER — which goals your own unattended runner
|
|
175
194
|
works (task 1004501). Rendered by settings-autobongos.js against
|
|
176
195
|
GET /autonomy/fence (your effective fence) and PUT
|
|
@@ -467,6 +486,7 @@
|
|
|
467
486
|
<!-- task 1003341: the Active sessions panel. Its own file rather than more of
|
|
468
487
|
settings.js, which sits close to the 1500-line size ratchet. -->
|
|
469
488
|
<script src="/builders/settings-sessions.js?v=2026-08-29-sessions"></script>
|
|
489
|
+
<script src="/builders/settings-passkeys.js?v=2026-10-15-passkeys"></script>
|
|
470
490
|
<!-- task 1002514: the interaction-profile panel ("How Claude talks to you"), on
|
|
471
491
|
the same precedent and for the same reason — settings.js is against the
|
|
472
492
|
1500-line ratchet, and this panel shares nothing with the rest of the page. -->
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.137",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.21.
|
|
9
|
+
"version": "1.21.137",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.137",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -9818,5 +9818,11 @@
|
|
|
9818
9818
|
"id": "1004750",
|
|
9819
9819
|
"text": "Groundwork for hardware-card sign-in: the database tables and storage code that remember registered cards and the short-lived one-time challenges. Nothing is visible to builders yet."
|
|
9820
9820
|
}
|
|
9821
|
+
],
|
|
9822
|
+
"1.21.137": [
|
|
9823
|
+
{
|
|
9824
|
+
"id": "1004746",
|
|
9825
|
+
"text": "Builders can now register, list, rename and remove a hardware security card from Settings."
|
|
9826
|
+
}
|
|
9821
9827
|
]
|
|
9822
9828
|
}
|
package/src/bongos/passkeys.js
CHANGED
|
@@ -136,8 +136,18 @@ async function removeCredential(builderId, credentialId, { pool = defaultPool()
|
|
|
136
136
|
return r.rowCount > 0;
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
// Rename a card. Scoped to the builder in the WHERE clause, so another builder's
|
|
140
|
+
// credential id matches no row.
|
|
141
|
+
async function renameCredential(builderId, credentialId, nickname, { pool = defaultPool() } = {}) {
|
|
142
|
+
const r = await pool.query(
|
|
143
|
+
'UPDATE passkey_credentials SET nickname = $3 WHERE builder_id = $1 AND credential_id = $2',
|
|
144
|
+
[builderId, credentialId, nickname],
|
|
145
|
+
);
|
|
146
|
+
return r.rowCount > 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
139
149
|
module.exports = {
|
|
140
150
|
CHALLENGE_TTL_SECONDS, MAX_CHALLENGE_TTL_SECONDS, PURPOSES, clampTtlSeconds, counterAdvances,
|
|
141
151
|
createChallenge, consumeChallenge, sweepExpiredChallenges,
|
|
142
|
-
getOrCreateUserHandle, addCredential, getCredentialById, listCredentials, recordUse, removeCredential,
|
|
152
|
+
getOrCreateUserHandle, addCredential, getCredentialById, listCredentials, recordUse, removeCredential, renameCredential,
|
|
143
153
|
};
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// src/bongos/routes/my-passkeys.js — a signed-in builder's own hardware cards:
|
|
2
|
+
// register one, list them, rename one, remove one (task 1004746, ADR 0382 D3/D6).
|
|
3
|
+
//
|
|
4
|
+
// THE INVARIANT, as in my-sessions.js: the account is ALWAYS req.builder.id, taken
|
|
5
|
+
// from the session the caller presented. It is never read from the body, query or
|
|
6
|
+
// path, and every store call is keyed on it, so a credential id belonging to
|
|
7
|
+
// somebody else matches nothing.
|
|
8
|
+
//
|
|
9
|
+
// A card is a second factor added AFTER normal sign-in (D2/D3); this file only
|
|
10
|
+
// registers and manages cards. Nothing here switches a requirement on.
|
|
11
|
+
//
|
|
12
|
+
// Registration is two steps: `register/options` mints a single-use challenge bound
|
|
13
|
+
// to the builder, `register/verify` consumes it (a replay or someone else's
|
|
14
|
+
// challenge finds nothing) and stores the public half of the card. The private key
|
|
15
|
+
// never leaves the card.
|
|
16
|
+
|
|
17
|
+
const express = require('express');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const auth = require('../auth');
|
|
21
|
+
const { validateOrRespond, publicOrigin } = require('./_helpers');
|
|
22
|
+
const log = require('../logger').logger.child({ src: 'my-passkeys' });
|
|
23
|
+
|
|
24
|
+
const NICKNAME_MAX = 60;
|
|
25
|
+
const CREDENTIAL_ID_RE = /^[A-Za-z0-9_-]{1,1024}$/;
|
|
26
|
+
const MAX_CARDS_PER_BUILDER = 10;
|
|
27
|
+
|
|
28
|
+
// The RP ID and origin are configuration, set once by the owner (ADR 0382 D7):
|
|
29
|
+
// PASSKEY_ORIGIN and PASSKEY_RP_ID. Request headers are attacker-influenceable and
|
|
30
|
+
// origin pinning is WebAuthn's whole protection, so the request is trusted ONLY for a
|
|
31
|
+
// local development host; anywhere else, unset config means refuse, not guess.
|
|
32
|
+
const LOCAL_HOST_RE = /^(localhost|127\.0\.0\.1|\[::1\]|[a-z0-9-]+\.localhost)$/i;
|
|
33
|
+
function relyingParty(req) {
|
|
34
|
+
const rpName = process.env.PASSKEY_RP_NAME || 'Cloud Bongos';
|
|
35
|
+
let origin = process.env.PASSKEY_ORIGIN || null;
|
|
36
|
+
let rpID = process.env.PASSKEY_RP_ID || null;
|
|
37
|
+
if (!origin) {
|
|
38
|
+
const fromReq = publicOrigin(req);
|
|
39
|
+
let host = null;
|
|
40
|
+
try { host = new URL(fromReq).hostname; } catch { host = null; }
|
|
41
|
+
if (host && LOCAL_HOST_RE.test(host)) origin = fromReq;
|
|
42
|
+
}
|
|
43
|
+
if (!rpID && origin) { try { rpID = new URL(origin).hostname.replace(/^\[|\]$/g, ''); } catch { rpID = null; } }
|
|
44
|
+
return origin && rpID ? { origin, rpID, rpName } : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The vendored browser bundle, resolved once. The package's exports map hides its
|
|
48
|
+
// package.json, so walk up from the resolved entry to the package root instead.
|
|
49
|
+
let bundleFile;
|
|
50
|
+
function browserBundleFile() {
|
|
51
|
+
if (bundleFile !== undefined) return bundleFile;
|
|
52
|
+
bundleFile = null;
|
|
53
|
+
try {
|
|
54
|
+
let dir = path.dirname(require.resolve('@simplewebauthn/browser'));
|
|
55
|
+
for (let i = 0; i < 6 && !fs.existsSync(path.join(dir, 'dist', 'bundle')); i++) dir = path.dirname(dir);
|
|
56
|
+
const file = path.join(dir, 'dist', 'bundle', 'index.umd.min.js');
|
|
57
|
+
if (fs.existsSync(file)) bundleFile = file;
|
|
58
|
+
} catch { /* leave null */ }
|
|
59
|
+
return bundleFile;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function cleanNickname(raw) {
|
|
63
|
+
if (raw === undefined || raw === null) return { value: null };
|
|
64
|
+
if (typeof raw !== 'string') return { error: 'nickname must be text' };
|
|
65
|
+
const v = raw.replace(/\s+/g, ' ').trim();
|
|
66
|
+
if (v.length > NICKNAME_MAX) return { error: `nickname must be ${NICKNAME_MAX} characters or fewer` };
|
|
67
|
+
return { value: v || null };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function presentCard(row) {
|
|
71
|
+
return {
|
|
72
|
+
credential_id: row.credential_id,
|
|
73
|
+
nickname: row.nickname ?? null,
|
|
74
|
+
transports: row.transports || [],
|
|
75
|
+
created_at: row.created_at,
|
|
76
|
+
last_used_at: row.last_used_at ?? null,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Dependencies are injectable so the route is testable without Postgres or a real
|
|
81
|
+
// authenticator; production passes none.
|
|
82
|
+
module.exports = function buildMyPasskeysRouter({
|
|
83
|
+
store = require('../passkeys'),
|
|
84
|
+
webauthn = null,
|
|
85
|
+
requireBuilder = auth.requireBuilder,
|
|
86
|
+
} = {}) {
|
|
87
|
+
const router = express.Router();
|
|
88
|
+
const lib = () => webauthn || require('@simplewebauthn/server');
|
|
89
|
+
|
|
90
|
+
// GET /api/bongos/me/passkeys — the caller's registered cards.
|
|
91
|
+
// rank: any-builder (own identity only).
|
|
92
|
+
router.get('/me/passkeys', requireBuilder, async (req, res) => {
|
|
93
|
+
try {
|
|
94
|
+
const rows = await store.listCredentials(req.builder.id);
|
|
95
|
+
res.json({ passkeys: rows.map(presentCard) });
|
|
96
|
+
} catch (err) {
|
|
97
|
+
log.error({ err: err && err.message }, 'GET /me/passkeys failed');
|
|
98
|
+
res.fail('list_failed', { status: 500, message: 'internal error' });
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// GET /api/bongos/me/passkeys/browser.js — the vetted @simplewebauthn/browser
|
|
103
|
+
// bundle, served same-origin so the page needs no CDN and no build step.
|
|
104
|
+
// rank: any-builder (own identity only) — a vendored static file at a fixed
|
|
105
|
+
// path, served only to a signed-in builder like the rest of /me.
|
|
106
|
+
router.get('/me/passkeys/browser.js', requireBuilder, (_req, res) => {
|
|
107
|
+
const file = browserBundleFile();
|
|
108
|
+
if (!file) {
|
|
109
|
+
log.error('passkeys browser bundle missing');
|
|
110
|
+
return res.fail('bundle_missing', { status: 500, message: 'internal error' });
|
|
111
|
+
}
|
|
112
|
+
res.type('application/javascript').set('Cache-Control', 'public, max-age=3600').sendFile(file, (err) => {
|
|
113
|
+
if (err && !res.headersSent) res.fail('bundle_missing', { status: 500, message: 'internal error' });
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// POST /api/bongos/me/passkeys/register/options — start adding a card.
|
|
118
|
+
// rank: any-builder (own identity only).
|
|
119
|
+
router.post('/me/passkeys/register/options', requireBuilder, async (req, res) => {
|
|
120
|
+
if (validateOrRespond(req, res, {})) return;
|
|
121
|
+
try {
|
|
122
|
+
const rp = relyingParty(req);
|
|
123
|
+
if (!rp) return res.fail('passkeys_not_configured', { status: 503, message: 'security cards are not configured on this site yet' });
|
|
124
|
+
const { rpID, rpName } = rp;
|
|
125
|
+
const existing = await store.listCredentials(req.builder.id);
|
|
126
|
+
if (existing.length >= MAX_CARDS_PER_BUILDER) {
|
|
127
|
+
return res.fail('too_many_cards', { status: 409, message: `at most ${MAX_CARDS_PER_BUILDER} cards per account — remove one first` });
|
|
128
|
+
}
|
|
129
|
+
const handle = await store.getOrCreateUserHandle(req.builder.id);
|
|
130
|
+
const { challenge } = await store.createChallenge({ purpose: 'register', builderId: req.builder.id });
|
|
131
|
+
const options = await lib().generateRegistrationOptions({
|
|
132
|
+
rpName,
|
|
133
|
+
rpID,
|
|
134
|
+
userName: req.builder.github_login || req.builder.display_name || String(req.builder.id),
|
|
135
|
+
userDisplayName: req.builder.display_name || req.builder.github_login || undefined,
|
|
136
|
+
userID: Buffer.from(handle, 'base64url'),
|
|
137
|
+
challenge: Buffer.from(challenge, 'base64url'),
|
|
138
|
+
attestationType: 'none',
|
|
139
|
+
excludeCredentials: existing.map((c) => ({ id: c.credential_id, transports: c.transports })),
|
|
140
|
+
authenticatorSelection: { residentKey: 'preferred', userVerification: 'required' },
|
|
141
|
+
});
|
|
142
|
+
res.json({ options });
|
|
143
|
+
} catch (err) {
|
|
144
|
+
log.error({ err: err && err.message }, 'register/options failed');
|
|
145
|
+
res.fail('options_failed', { status: 500, message: 'internal error' });
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// POST /api/bongos/me/passkeys/register/verify — finish adding a card.
|
|
150
|
+
// Body: { response, nickname? } where response is the browser's attestation.
|
|
151
|
+
// rank: any-builder (own identity only).
|
|
152
|
+
router.post('/me/passkeys/register/verify', requireBuilder, async (req, res) => {
|
|
153
|
+
if (validateOrRespond(req, res, { response: {}, nickname: {} })) return;
|
|
154
|
+
const response = req.body && req.body.response;
|
|
155
|
+
if (!response || typeof response !== 'object') {
|
|
156
|
+
return res.fail('bad_response', { status: 400, message: 'response is required' });
|
|
157
|
+
}
|
|
158
|
+
const nick = cleanNickname(req.body.nickname);
|
|
159
|
+
if (nick.error) return res.fail('bad_nickname', { status: 400, message: nick.error });
|
|
160
|
+
try {
|
|
161
|
+
const rp = relyingParty(req);
|
|
162
|
+
if (!rp) return res.fail('passkeys_not_configured', { status: 503, message: 'security cards are not configured on this site yet' });
|
|
163
|
+
const { origin, rpID } = rp;
|
|
164
|
+
let verification;
|
|
165
|
+
try {
|
|
166
|
+
verification = await lib().verifyRegistrationResponse({
|
|
167
|
+
response,
|
|
168
|
+
// The library hands back the challenge the card signed. Consuming it here
|
|
169
|
+
// is what makes it single-use and bound to THIS builder and purpose.
|
|
170
|
+
expectedChallenge: async (c) => !!(await store.consumeChallenge(c, { expectPurpose: 'register', expectBuilderId: req.builder.id })),
|
|
171
|
+
expectedOrigin: origin,
|
|
172
|
+
expectedRPID: rpID,
|
|
173
|
+
requireUserVerification: true,
|
|
174
|
+
});
|
|
175
|
+
} catch (err) {
|
|
176
|
+
log.info({ err: err && err.message }, 'register/verify refused');
|
|
177
|
+
return res.fail('verification_failed', { status: 400, message: 'the card could not be verified' });
|
|
178
|
+
}
|
|
179
|
+
if (!verification.verified || !verification.registrationInfo) {
|
|
180
|
+
return res.fail('verification_failed', { status: 400, message: 'the card could not be verified' });
|
|
181
|
+
}
|
|
182
|
+
const { credential, aaguid } = verification.registrationInfo;
|
|
183
|
+
// Same answer whoever holds it: a global lookup must not reveal that a card
|
|
184
|
+
// belongs to another account.
|
|
185
|
+
if (await store.getCredentialById(credential.id)) {
|
|
186
|
+
return res.fail('already_registered', { status: 409, message: 'that card cannot be registered' });
|
|
187
|
+
}
|
|
188
|
+
// The cap is enforced here too: a challenge minted before a removal must not
|
|
189
|
+
// let a builder end up over it.
|
|
190
|
+
if ((await store.listCredentials(req.builder.id)).length >= MAX_CARDS_PER_BUILDER) {
|
|
191
|
+
return res.fail('too_many_cards', { status: 409, message: `at most ${MAX_CARDS_PER_BUILDER} cards per account — remove one first` });
|
|
192
|
+
}
|
|
193
|
+
await store.addCredential({
|
|
194
|
+
builderId: req.builder.id,
|
|
195
|
+
credentialId: credential.id,
|
|
196
|
+
publicKey: credential.publicKey,
|
|
197
|
+
signCount: credential.counter,
|
|
198
|
+
transports: credential.transports || response.response?.transports || [],
|
|
199
|
+
aaguid: aaguid || null,
|
|
200
|
+
nickname: nick.value,
|
|
201
|
+
});
|
|
202
|
+
res.json({ ok: true, credential_id: credential.id });
|
|
203
|
+
} catch (err) {
|
|
204
|
+
log.error({ err: err && err.message }, 'register/verify failed');
|
|
205
|
+
res.fail('register_failed', { status: 500, message: 'internal error' });
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// POST /api/bongos/me/passkeys/rename — Body: { credential_id, nickname }.
|
|
210
|
+
// rank: any-builder (own identity only) — the WHERE clause is the boundary.
|
|
211
|
+
router.post('/me/passkeys/rename', requireBuilder, async (req, res) => {
|
|
212
|
+
if (validateOrRespond(req, res, { credential_id: {}, nickname: {} })) return;
|
|
213
|
+
const id = req.body && req.body.credential_id;
|
|
214
|
+
if (typeof id !== 'string' || !CREDENTIAL_ID_RE.test(id)) {
|
|
215
|
+
return res.fail('bad_credential_id', { status: 400, message: 'credential_id must come from GET /me/passkeys' });
|
|
216
|
+
}
|
|
217
|
+
if (req.body.nickname === undefined) return res.fail('bad_nickname', { status: 400, message: 'nickname is required (send an empty string to clear it)' });
|
|
218
|
+
const nick = cleanNickname(req.body.nickname);
|
|
219
|
+
if (nick.error) return res.fail('bad_nickname', { status: 400, message: nick.error });
|
|
220
|
+
try {
|
|
221
|
+
const ok = await store.renameCredential(req.builder.id, id, nick.value);
|
|
222
|
+
if (!ok) return res.fail('not_found', { status: 404, message: 'no such card on your account' });
|
|
223
|
+
res.json({ ok: true });
|
|
224
|
+
} catch (err) {
|
|
225
|
+
log.error({ err: err && err.message }, 'rename failed');
|
|
226
|
+
res.fail('rename_failed', { status: 500, message: 'internal error' });
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// POST /api/bongos/me/passkeys/remove — Body: { credential_id }.
|
|
231
|
+
// rank: any-builder (own identity only) — the WHERE clause is the boundary.
|
|
232
|
+
router.post('/me/passkeys/remove', requireBuilder, async (req, res) => {
|
|
233
|
+
if (validateOrRespond(req, res, { credential_id: {} })) return;
|
|
234
|
+
const id = req.body && req.body.credential_id;
|
|
235
|
+
if (typeof id !== 'string' || !CREDENTIAL_ID_RE.test(id)) {
|
|
236
|
+
return res.fail('bad_credential_id', { status: 400, message: 'credential_id must come from GET /me/passkeys' });
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
const ok = await store.removeCredential(req.builder.id, id);
|
|
240
|
+
if (!ok) return res.fail('not_found', { status: 404, message: 'no such card on your account' });
|
|
241
|
+
res.json({ ok: true });
|
|
242
|
+
} catch (err) {
|
|
243
|
+
log.error({ err: err && err.message }, 'remove failed');
|
|
244
|
+
res.fail('remove_failed', { status: 500, message: 'internal error' });
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
return router;
|
|
249
|
+
};
|
package/src/bongos/routes.js
CHANGED
|
@@ -28,6 +28,7 @@ const buildMeRouter = require('./routes/me');
|
|
|
28
28
|
// task 1003341). Its own router rather than three more routes on the /me
|
|
29
29
|
// aggregator or the admin pair in routes/auth.js — see that file's header.
|
|
30
30
|
const buildMySessionsRouter = require('./routes/my-sessions');
|
|
31
|
+
const buildMyPasskeysRouter = require('./routes/my-passkeys');
|
|
31
32
|
const buildBuildersRouter = require('./routes/builders');
|
|
32
33
|
// The LIFECYCLE routes (tasks · claims · versions · done-when · goals ·
|
|
33
34
|
// dependencies · gate-approvals · analytics) carved into modules/lifecycle/
|
|
@@ -161,6 +162,7 @@ function buildGdsRouter() {
|
|
|
161
162
|
router.use(buildGithubRouter());
|
|
162
163
|
router.use(buildMeRouter());
|
|
163
164
|
router.use(buildMySessionsRouter());
|
|
165
|
+
router.use(buildMyPasskeysRouter());
|
|
164
166
|
router.use(buildBuildersRouter());
|
|
165
167
|
// tasks · claims · versions · done-when · goals · dependencies · analytics now
|
|
166
168
|
// mount via moduleLoader.mountModuleRoutes below (lifecycle module, BV1.R86) —
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.21.
|
|
78
|
+
const CORE_VERSION = '1.21.137'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// tests/my_passkeys.mjs — own-scoped card registration routes (task 1004746, ADR 0382).
|
|
2
|
+
// DB-free: a fake store and fake WebAuthn library behind the real router, over real HTTP.
|
|
3
|
+
// Pins: list/rename/remove are keyed on the CALLER's id; register/verify hands the
|
|
4
|
+
// library a challenge check that consumes via the store, bound to purpose 'register'
|
|
5
|
+
// and the caller; a failed verification stores nothing.
|
|
6
|
+
// Run: node tests/my_passkeys.mjs
|
|
7
|
+
import { strict as assert } from 'node:assert';
|
|
8
|
+
import { createRequire } from 'node:module';
|
|
9
|
+
import http from 'node:http';
|
|
10
|
+
delete process.env.PASSKEY_RP_ID; delete process.env.PASSKEY_ORIGIN;
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
const express = require('express');
|
|
13
|
+
const build = require('../src/bongos/routes/my-passkeys');
|
|
14
|
+
|
|
15
|
+
const creds = [];
|
|
16
|
+
const consumed = [];
|
|
17
|
+
const store = {
|
|
18
|
+
listCredentials: async (b) => creds.filter((c) => c.builder_id === b),
|
|
19
|
+
getOrCreateUserHandle: async () => 'aGFuZGxl',
|
|
20
|
+
createChallenge: async () => ({ challenge: 'Y2hhbGw' }),
|
|
21
|
+
consumeChallenge: async (c, o) => { consumed.push([c, o]); return { purpose: 'register', builderId: o.expectBuilderId }; },
|
|
22
|
+
getCredentialById: async (id) => creds.find((c) => c.credential_id === id) || null,
|
|
23
|
+
addCredential: async (c) => { creds.push({ credential_id: c.credentialId, builder_id: c.builderId, nickname: c.nickname, transports: c.transports, created_at: 'x' }); },
|
|
24
|
+
renameCredential: async (b, id, n) => { const c = creds.find((x) => x.builder_id === b && x.credential_id === id); if (c) c.nickname = n; return !!c; },
|
|
25
|
+
removeCredential: async (b, id) => { const i = creds.findIndex((x) => x.builder_id === b && x.credential_id === id); if (i < 0) return false; creds.splice(i, 1); return true; },
|
|
26
|
+
};
|
|
27
|
+
let verifyOk = true;
|
|
28
|
+
const webauthn = {
|
|
29
|
+
generateRegistrationOptions: async (o) => ({ rp: { id: o.rpID }, excludeCredentials: o.excludeCredentials }),
|
|
30
|
+
verifyRegistrationResponse: async (o) => {
|
|
31
|
+
assert.equal(o.requireUserVerification, true);
|
|
32
|
+
if (!(await o.expectedChallenge('Y2hhbGw'))) throw new Error('challenge');
|
|
33
|
+
if (!verifyOk) return { verified: false };
|
|
34
|
+
return { verified: true, registrationInfo: { aaguid: 'a', credential: { id: 'cred1', publicKey: new Uint8Array([1]), counter: 0, transports: ['nfc'] } } };
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
const app = express();
|
|
38
|
+
app.use(express.json());
|
|
39
|
+
app.use((req, res, next) => { res.fail = (code, { status = 400, message } = {}) => res.status(status).json({ error: code, message }); next(); });
|
|
40
|
+
app.use((req, res, next) => { req.builder = { id: '7', github_login: 'me' }; next(); });
|
|
41
|
+
app.use('/', build({ store, webauthn, requireBuilder: (q, r, n) => n() }));
|
|
42
|
+
const server = http.createServer(app).listen(0);
|
|
43
|
+
const port = server.address().port;
|
|
44
|
+
const call = (method, path, body) => new Promise((resolve) => {
|
|
45
|
+
const r = http.request({ port, path, method, headers: { 'content-type': 'application/json', host: 'localhost' } }, (res) => {
|
|
46
|
+
let d = ''; res.on('data', (c) => (d += c)); res.on('end', () => { let b = null; try { b = JSON.parse(d); } catch { /* not json */ } resolve({ status: res.statusCode, body: b }); });
|
|
47
|
+
});
|
|
48
|
+
if (body) r.write(JSON.stringify(body));
|
|
49
|
+
r.end();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
let n = 0;
|
|
53
|
+
const ok = (name) => { n++; console.log(`ok - ${name}`); };
|
|
54
|
+
|
|
55
|
+
let r = await call('POST', '/me/passkeys/register/options', {});
|
|
56
|
+
assert.equal(r.status, 200); assert.equal(r.body.options.rp.id, 'localhost'); ok('options issues for caller');
|
|
57
|
+
|
|
58
|
+
verifyOk = false;
|
|
59
|
+
r = await call('POST', '/me/passkeys/register/verify', { response: { id: 'x' } });
|
|
60
|
+
assert.equal(r.status, 400); assert.equal(creds.length, 0); ok('failed verification stores nothing');
|
|
61
|
+
|
|
62
|
+
verifyOk = true;
|
|
63
|
+
r = await call('POST', '/me/passkeys/register/verify', { response: { id: 'x' }, nickname: ' My card ' });
|
|
64
|
+
assert.equal(r.status, 200); assert.equal(creds[0].nickname, 'My card'); assert.equal(creds[0].builder_id, '7');
|
|
65
|
+
assert.deepEqual(consumed.at(-1)[1], { expectPurpose: 'register', expectBuilderId: '7' }); ok('verify stores; challenge bound to caller and purpose');
|
|
66
|
+
|
|
67
|
+
r = await call('POST', '/me/passkeys/register/verify', { response: { id: 'x' } });
|
|
68
|
+
assert.equal(r.status, 409); ok('duplicate card refused');
|
|
69
|
+
|
|
70
|
+
creds.push({ credential_id: 'other', builder_id: '9', nickname: 'theirs' });
|
|
71
|
+
r = await call('POST', '/me/passkeys/remove', { credential_id: 'other' });
|
|
72
|
+
assert.equal(r.status, 404); assert.equal(creds.length, 2); ok("cannot remove another builder's card");
|
|
73
|
+
r = await call('POST', '/me/passkeys/rename', { credential_id: 'other', nickname: 'mine now' });
|
|
74
|
+
assert.equal(r.status, 404); assert.equal(creds[1].nickname, 'theirs'); ok("cannot rename another builder's card");
|
|
75
|
+
|
|
76
|
+
r = await call('GET', '/me/passkeys');
|
|
77
|
+
assert.equal(r.body.passkeys.length, 1); assert.ok(!('public_key' in r.body.passkeys[0])); ok('list is own-only, no key material');
|
|
78
|
+
|
|
79
|
+
r = await call('POST', '/me/passkeys/rename', { credential_id: 'cred1', nickname: 'x'.repeat(61) });
|
|
80
|
+
assert.equal(r.status, 400); ok('long nickname refused');
|
|
81
|
+
r = await call('POST', '/me/passkeys/rename', { credential_id: 'cred1', nickname: 'Desk' });
|
|
82
|
+
assert.equal(r.status, 200); assert.equal(creds[0].nickname, 'Desk'); ok('rename');
|
|
83
|
+
r = await call('POST', '/me/passkeys/remove', { credential_id: 'cred1' });
|
|
84
|
+
assert.equal(r.status, 200); ok('remove');
|
|
85
|
+
|
|
86
|
+
r = await call('GET', '/me/passkeys/browser.js');
|
|
87
|
+
assert.equal(r.status, 200); ok('browser bundle is served');
|
|
88
|
+
|
|
89
|
+
r = await call('POST', '/me/passkeys/rename', { credential_id: 'x' });
|
|
90
|
+
assert.equal(r.status, 400); ok('rename without a nickname is refused');
|
|
91
|
+
|
|
92
|
+
// Past the cap: options and verify both refuse.
|
|
93
|
+
creds.length = 0;
|
|
94
|
+
for (let i = 0; i < 10; i++) creds.push({ credential_id: 'c' + i, builder_id: '7' });
|
|
95
|
+
r = await call('POST', '/me/passkeys/register/options', {});
|
|
96
|
+
assert.equal(r.status, 409);
|
|
97
|
+
r = await call('POST', '/me/passkeys/register/verify', { response: { id: 'x' } });
|
|
98
|
+
assert.equal(r.status, 409); assert.equal(creds.length, 10); ok('card cap enforced on options and verify');
|
|
99
|
+
|
|
100
|
+
// A non-local host with no configured RP is refused, not guessed.
|
|
101
|
+
r = await new Promise((resolve) => {
|
|
102
|
+
const q = http.request({ port, path: '/me/passkeys/register/options', method: 'POST', headers: { 'content-type': 'application/json', host: 'evil.example', 'x-forwarded-host': 'evil.example' } }, (res) => { res.resume(); resolve({ status: res.statusCode }); });
|
|
103
|
+
q.write('{}'); q.end();
|
|
104
|
+
});
|
|
105
|
+
assert.equal(r.status, 503); ok('unconfigured non-local host refused');
|
|
106
|
+
|
|
107
|
+
server.close();
|
|
108
|
+
console.log(`${n} passed`);
|
|
@@ -84,6 +84,11 @@ const OWN_SCOPED_SURFACES = [
|
|
|
84
84
|
why: "the caller's OWN auth sessions — list, revoke one, sign out everywhere (task 1003341, ADR 0206). Every statement carries builder_id = req.builder.id, so a session id belonging to somebody else matches no row. Kept OUT of routes/auth.js precisely so this whole file can be held to the own-scope rule: the admin cross-builder pair next door takes its target from the body, which is what makes it admin.",
|
|
85
85
|
crossOwner: [],
|
|
86
86
|
},
|
|
87
|
+
{
|
|
88
|
+
file: 'src/bongos/routes/my-passkeys.js',
|
|
89
|
+
why: "the caller's OWN hardware security cards — list, register, rename, remove (task 1004746, ADR 0382). Every store call is keyed on req.builder.id; a credential id belonging to somebody else matches no row. The one ungated route serves a vendored static library file.",
|
|
90
|
+
crossOwner: [],
|
|
91
|
+
},
|
|
87
92
|
{
|
|
88
93
|
file: 'modules/specialities/routes/specialities.js',
|
|
89
94
|
why: "role sub-specialities (task 1003855, ADR 0296). The own-scoped route here is GET /me/specialities — the CALLER's own adoptions, taken from req.builder.id with nothing read off the request. The rest of the router is /specialities/* rather than /me/*, and those are ownership-checked per row against req.builder.id after re-reading the speciality server-side, never from a client-supplied owner id.",
|