nebula-notebook 0.2.44 → 0.2.46

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.
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.resolveNebulaDir = resolveNebulaDir;
37
+ const os = __importStar(require("os"));
38
+ const path = __importStar(require("path"));
39
+ /**
40
+ * Directory holding Nebula's auth material (auth.json, passkeys.json).
41
+ * `NEBULA_AUTH_DIR` relocates it — a test seam and a way to run a throwaway
42
+ * server without touching the real `~/.nebula`. Resolved at call time so a
43
+ * process that sets the variable before first use is honored.
44
+ */
45
+ function resolveNebulaDir() {
46
+ return process.env.NEBULA_AUTH_DIR || path.join(os.homedir(), '.nebula');
47
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Passkeys (WebAuthn) — biometric / security-key login that AUGMENTS TOTP.
3
+ *
4
+ * A verified assertion mints the same session JWT the TOTP path mints; TOTP
5
+ * stays as the fallback and as the way to authenticate before enrolling the
6
+ * first passkey. Credentials are stored per rpID (per hostname): a passkey
7
+ * enrolled at `localhost` (the ssh-tunnel case) works wherever the browser
8
+ * reaches Nebula as `localhost`, one enrolled at a tunnel domain works there.
9
+ *
10
+ * Storage: `<nebula dir>/passkeys.json`, mode 0600, next to auth.json.
11
+ */
12
+ import type { IncomingHttpHeaders } from 'http';
13
+ import type { AuthenticatorTransportFuture, PublicKeyCredentialCreationOptionsJSON, PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/server';
14
+ export interface PasskeyCredential {
15
+ /** Credential ID (base64url) — also what the authenticator presents at login. */
16
+ id: string;
17
+ /** COSE public key, base64url. Never leaves the server. */
18
+ publicKey: string;
19
+ counter: number;
20
+ transports: AuthenticatorTransportFuture[];
21
+ /** Hostname the credential was enrolled at; a passkey is only offered there. */
22
+ rpID: string;
23
+ label: string;
24
+ createdAt: string;
25
+ lastUsedAt: string | null;
26
+ }
27
+ export interface PasskeyStore {
28
+ /** Stable opaque user handle (base64url) shared by every credential. */
29
+ userId: string | null;
30
+ credentials: PasskeyCredential[];
31
+ }
32
+ /** What clients may see: everything except the public key. */
33
+ export interface PublicPasskey {
34
+ id: string;
35
+ rpID: string;
36
+ label: string;
37
+ createdAt: string;
38
+ lastUsedAt: string | null;
39
+ }
40
+ export declare const PASSKEYS_FILENAME = "passkeys.json";
41
+ export declare const MAX_LABEL_LENGTH = 60;
42
+ export declare function passkeysFilePath(): string;
43
+ export declare function toPublicPasskey(c: PasskeyCredential): PublicPasskey;
44
+ /** Load the store; a missing or unreadable file is an empty store. */
45
+ export declare function loadPasskeyStore(): PasskeyStore;
46
+ /**
47
+ * Persist the store, private to the user (dir 0700, file 0600) and atomically
48
+ * (tmp + rename) so a failed write never leaves a truncated credential list.
49
+ */
50
+ export declare function savePasskeyStore(store: PasskeyStore): void;
51
+ export type ChallengeType = 'login' | 'register';
52
+ export declare const CHALLENGE_TTL_MS = 120000;
53
+ /**
54
+ * Outstanding WebAuthn challenges: in-memory, single-use, 120 s TTL, typed
55
+ * (a login challenge cannot complete a registration) and bound to the rpID
56
+ * they were issued for. The opaque token handed to the client is the map key.
57
+ */
58
+ export declare class PasskeyChallenges {
59
+ private readonly now;
60
+ private readonly ttlMs;
61
+ private readonly entries;
62
+ constructor(now?: () => number, ttlMs?: number);
63
+ put(type: ChallengeType, rpID: string, challenge: string): string;
64
+ /** Consume a challenge. Returns null (and burns the token) on any mismatch. */
65
+ take(token: unknown, type: ChallengeType, rpID: string): string | null;
66
+ get size(): number;
67
+ private sweep;
68
+ }
69
+ export interface RpInfo {
70
+ /** Hostname only (no port) — what the browser scopes the credential to. */
71
+ rpID: string;
72
+ /** `${proto}://${host[:port]}` as the browser will report it in clientDataJSON. */
73
+ origin: string;
74
+ }
75
+ export declare class PasskeyRpError extends Error {
76
+ readonly code = "invalid_rp_id";
77
+ }
78
+ /**
79
+ * Derive rpID and origin from the request's own Host (or X-Forwarded-Host).
80
+ * The rpID is the hostname with the port stripped; the origin keeps the port.
81
+ * Protocol honors X-Forwarded-Proto and otherwise defaults to https — except
82
+ * for localhost, which browsers treat as a secure context over plain http and
83
+ * which is the common case (ssh tunnel to localhost:3000).
84
+ *
85
+ * WebAuthn forbids IP-literal rpIDs, so `127.0.0.1` is rejected with advice
86
+ * to use `http://localhost:PORT` instead.
87
+ */
88
+ export declare function deriveRpInfo(headers: IncomingHttpHeaders): RpInfo;
89
+ export type OptionsResult<T> = {
90
+ ok: true;
91
+ token: string;
92
+ options: T;
93
+ } | {
94
+ ok: false;
95
+ error: string;
96
+ };
97
+ export type LoginResult = {
98
+ ok: true;
99
+ credential: PasskeyCredential;
100
+ } | {
101
+ ok: false;
102
+ error: string;
103
+ };
104
+ export type RegisterResult = {
105
+ ok: true;
106
+ passkey: PublicPasskey;
107
+ } | {
108
+ ok: false;
109
+ error: string;
110
+ };
111
+ export declare class PasskeyService {
112
+ readonly challenges: PasskeyChallenges;
113
+ constructor(challenges?: PasskeyChallenges);
114
+ /** Credentials enrolled at this rpID. */
115
+ credentialsFor(rpID: string, store?: PasskeyStore): PasskeyCredential[];
116
+ /** Unauthenticated: a login challenge, or `ok:false` when nothing is enrolled here. */
117
+ loginOptions(rp: RpInfo): Promise<OptionsResult<PublicKeyCredentialRequestOptionsJSON>>;
118
+ /**
119
+ * Unauthenticated: verify an assertion against a stored credential. On
120
+ * success the counter and lastUsedAt are persisted. The caller mints the
121
+ * session and applies rate limiting.
122
+ */
123
+ login(rp: RpInfo, body: unknown): Promise<LoginResult>;
124
+ /** Authenticated: a registration challenge for this rpID. */
125
+ registerOptions(rp: RpInfo): Promise<OptionsResult<PublicKeyCredentialCreationOptionsJSON>>;
126
+ /** Authenticated: verify an attestation and store the new credential. */
127
+ register(rp: RpInfo, body: unknown): Promise<RegisterResult>;
128
+ /** Authenticated: every enrolled passkey, public fields only. */
129
+ list(): PublicPasskey[];
130
+ /** Authenticated: remove a credential by id. Returns whether one was removed. */
131
+ delete(id: string): boolean;
132
+ }
133
+ export declare const passkeyService: PasskeyService;
@@ -0,0 +1,374 @@
1
+ "use strict";
2
+ /**
3
+ * Passkeys (WebAuthn) — biometric / security-key login that AUGMENTS TOTP.
4
+ *
5
+ * A verified assertion mints the same session JWT the TOTP path mints; TOTP
6
+ * stays as the fallback and as the way to authenticate before enrolling the
7
+ * first passkey. Credentials are stored per rpID (per hostname): a passkey
8
+ * enrolled at `localhost` (the ssh-tunnel case) works wherever the browser
9
+ * reaches Nebula as `localhost`, one enrolled at a tunnel domain works there.
10
+ *
11
+ * Storage: `<nebula dir>/passkeys.json`, mode 0600, next to auth.json.
12
+ */
13
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
14
+ if (k2 === undefined) k2 = k;
15
+ var desc = Object.getOwnPropertyDescriptor(m, k);
16
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
17
+ desc = { enumerable: true, get: function() { return m[k]; } };
18
+ }
19
+ Object.defineProperty(o, k2, desc);
20
+ }) : (function(o, m, k, k2) {
21
+ if (k2 === undefined) k2 = k;
22
+ o[k2] = m[k];
23
+ }));
24
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
25
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
26
+ }) : function(o, v) {
27
+ o["default"] = v;
28
+ });
29
+ var __importStar = (this && this.__importStar) || (function () {
30
+ var ownKeys = function(o) {
31
+ ownKeys = Object.getOwnPropertyNames || function (o) {
32
+ var ar = [];
33
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
34
+ return ar;
35
+ };
36
+ return ownKeys(o);
37
+ };
38
+ return function (mod) {
39
+ if (mod && mod.__esModule) return mod;
40
+ var result = {};
41
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
42
+ __setModuleDefault(result, mod);
43
+ return result;
44
+ };
45
+ })();
46
+ Object.defineProperty(exports, "__esModule", { value: true });
47
+ exports.passkeyService = exports.PasskeyService = exports.PasskeyRpError = exports.PasskeyChallenges = exports.CHALLENGE_TTL_MS = exports.MAX_LABEL_LENGTH = exports.PASSKEYS_FILENAME = void 0;
48
+ exports.passkeysFilePath = passkeysFilePath;
49
+ exports.toPublicPasskey = toPublicPasskey;
50
+ exports.loadPasskeyStore = loadPasskeyStore;
51
+ exports.savePasskeyStore = savePasskeyStore;
52
+ exports.deriveRpInfo = deriveRpInfo;
53
+ const fs = __importStar(require("fs"));
54
+ const path = __importStar(require("path"));
55
+ const net = __importStar(require("net"));
56
+ const crypto_1 = require("crypto");
57
+ const server_1 = require("@simplewebauthn/server");
58
+ const nebula_dir_1 = require("./nebula-dir");
59
+ exports.PASSKEYS_FILENAME = 'passkeys.json';
60
+ exports.MAX_LABEL_LENGTH = 60;
61
+ function passkeysFilePath() {
62
+ return path.join((0, nebula_dir_1.resolveNebulaDir)(), exports.PASSKEYS_FILENAME);
63
+ }
64
+ function toPublicPasskey(c) {
65
+ return { id: c.id, rpID: c.rpID, label: c.label, createdAt: c.createdAt, lastUsedAt: c.lastUsedAt ?? null };
66
+ }
67
+ function sanitizeCredential(raw) {
68
+ if (!raw || typeof raw !== 'object')
69
+ return null;
70
+ const c = raw;
71
+ if (typeof c.id !== 'string' || typeof c.publicKey !== 'string' || typeof c.rpID !== 'string')
72
+ return null;
73
+ return {
74
+ id: c.id,
75
+ publicKey: c.publicKey,
76
+ counter: Number.isFinite(Number(c.counter)) ? Number(c.counter) : 0,
77
+ transports: Array.isArray(c.transports) ? c.transports.filter((t) => typeof t === 'string') : [],
78
+ rpID: c.rpID,
79
+ label: typeof c.label === 'string' ? c.label : `passkey · ${c.rpID}`,
80
+ createdAt: typeof c.createdAt === 'string' ? c.createdAt : new Date(0).toISOString(),
81
+ lastUsedAt: typeof c.lastUsedAt === 'string' ? c.lastUsedAt : null,
82
+ };
83
+ }
84
+ /** Load the store; a missing or unreadable file is an empty store. */
85
+ function loadPasskeyStore() {
86
+ try {
87
+ const parsed = JSON.parse(fs.readFileSync(passkeysFilePath(), 'utf8'));
88
+ const credentials = Array.isArray(parsed?.credentials)
89
+ ? parsed.credentials.map(sanitizeCredential).filter(Boolean)
90
+ : [];
91
+ return { userId: typeof parsed?.userId === 'string' ? parsed.userId : null, credentials };
92
+ }
93
+ catch {
94
+ return { userId: null, credentials: [] };
95
+ }
96
+ }
97
+ /**
98
+ * Persist the store, private to the user (dir 0700, file 0600) and atomically
99
+ * (tmp + rename) so a failed write never leaves a truncated credential list.
100
+ */
101
+ function savePasskeyStore(store) {
102
+ const file = passkeysFilePath();
103
+ const dir = path.dirname(file);
104
+ if (!fs.existsSync(dir))
105
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
106
+ const tmp = `${file}.${process.pid}.tmp`;
107
+ try {
108
+ fs.writeFileSync(tmp, JSON.stringify(store, null, 2), { mode: 0o600 });
109
+ fs.chmodSync(tmp, 0o600); // mode above is subject to umask; be explicit
110
+ fs.renameSync(tmp, file);
111
+ }
112
+ catch (err) {
113
+ try {
114
+ fs.unlinkSync(tmp);
115
+ }
116
+ catch { /* nothing partial to clean */ }
117
+ throw err;
118
+ }
119
+ }
120
+ exports.CHALLENGE_TTL_MS = 120_000;
121
+ /**
122
+ * Outstanding WebAuthn challenges: in-memory, single-use, 120 s TTL, typed
123
+ * (a login challenge cannot complete a registration) and bound to the rpID
124
+ * they were issued for. The opaque token handed to the client is the map key.
125
+ */
126
+ class PasskeyChallenges {
127
+ now;
128
+ ttlMs;
129
+ entries = new Map();
130
+ constructor(now = Date.now, ttlMs = exports.CHALLENGE_TTL_MS) {
131
+ this.now = now;
132
+ this.ttlMs = ttlMs;
133
+ }
134
+ put(type, rpID, challenge) {
135
+ this.sweep();
136
+ const token = (0, crypto_1.randomBytes)(16).toString('hex');
137
+ this.entries.set(token, { challenge, type, rpID, expiresAt: this.now() + this.ttlMs });
138
+ return token;
139
+ }
140
+ /** Consume a challenge. Returns null (and burns the token) on any mismatch. */
141
+ take(token, type, rpID) {
142
+ const key = typeof token === 'string' ? token : '';
143
+ const entry = this.entries.get(key);
144
+ this.entries.delete(key);
145
+ if (!entry || entry.type !== type || entry.rpID !== rpID || entry.expiresAt < this.now())
146
+ return null;
147
+ return entry.challenge;
148
+ }
149
+ get size() {
150
+ this.sweep();
151
+ return this.entries.size;
152
+ }
153
+ sweep() {
154
+ const now = this.now();
155
+ for (const [k, v] of this.entries) {
156
+ if (v.expiresAt < now)
157
+ this.entries.delete(k);
158
+ }
159
+ }
160
+ }
161
+ exports.PasskeyChallenges = PasskeyChallenges;
162
+ class PasskeyRpError extends Error {
163
+ code = 'invalid_rp_id';
164
+ }
165
+ exports.PasskeyRpError = PasskeyRpError;
166
+ function firstHeader(value) {
167
+ const raw = Array.isArray(value) ? value[0] : value;
168
+ return String(raw ?? '').split(',')[0].trim();
169
+ }
170
+ /**
171
+ * Derive rpID and origin from the request's own Host (or X-Forwarded-Host).
172
+ * The rpID is the hostname with the port stripped; the origin keeps the port.
173
+ * Protocol honors X-Forwarded-Proto and otherwise defaults to https — except
174
+ * for localhost, which browsers treat as a secure context over plain http and
175
+ * which is the common case (ssh tunnel to localhost:3000).
176
+ *
177
+ * WebAuthn forbids IP-literal rpIDs, so `127.0.0.1` is rejected with advice
178
+ * to use `http://localhost:PORT` instead.
179
+ */
180
+ function deriveRpInfo(headers) {
181
+ const hostHeader = firstHeader(headers['x-forwarded-host']) || firstHeader(headers.host);
182
+ let hostname = hostHeader;
183
+ let port = '';
184
+ if (hostHeader.startsWith('[')) {
185
+ // IPv6 literal, e.g. [::1]:3000 — rejected below, parsed only for the message.
186
+ const end = hostHeader.indexOf(']');
187
+ hostname = end > 0 ? hostHeader.slice(1, end) : hostHeader;
188
+ port = end > 0 ? hostHeader.slice(end + 1).replace(/^:/, '') : '';
189
+ }
190
+ else {
191
+ const m = /^([^:]*)(?::(\d+))?$/.exec(hostHeader);
192
+ if (m) {
193
+ hostname = m[1];
194
+ port = m[2] ?? '';
195
+ }
196
+ }
197
+ hostname = hostname.toLowerCase() || 'localhost';
198
+ if (net.isIP(hostname)) {
199
+ const suggested = `http://localhost${port ? `:${port}` : ''}`;
200
+ throw new PasskeyRpError(`Passkeys cannot be used at an IP address (${hostname}) — WebAuthn requires a hostname. ` +
201
+ `Open ${suggested} instead of ${hostname}.`);
202
+ }
203
+ const isLocal = hostname === 'localhost' || hostname.endsWith('.localhost');
204
+ const forwardedProto = firstHeader(headers['x-forwarded-proto']).toLowerCase();
205
+ const proto = forwardedProto === 'http' || forwardedProto === 'https' ? forwardedProto : (isLocal ? 'http' : 'https');
206
+ const defaultPort = proto === 'https' ? '443' : '80';
207
+ const originHost = port && port !== defaultPort ? `${hostname}:${port}` : hostname;
208
+ return { rpID: hostname, origin: `${proto}://${originHost}` };
209
+ }
210
+ const RP_NAME = 'Nebula Notebook';
211
+ const USER_NAME = 'nebula';
212
+ function asObject(body) {
213
+ return body && typeof body === 'object' ? body : {};
214
+ }
215
+ class PasskeyService {
216
+ challenges;
217
+ constructor(challenges = new PasskeyChallenges()) {
218
+ this.challenges = challenges;
219
+ }
220
+ /** Credentials enrolled at this rpID. */
221
+ credentialsFor(rpID, store = loadPasskeyStore()) {
222
+ return store.credentials.filter((c) => c.rpID === rpID);
223
+ }
224
+ /** Unauthenticated: a login challenge, or `ok:false` when nothing is enrolled here. */
225
+ async loginOptions(rp) {
226
+ const creds = this.credentialsFor(rp.rpID);
227
+ if (creds.length === 0) {
228
+ return { ok: false, error: `No passkeys enrolled for ${rp.rpID}` };
229
+ }
230
+ const options = await (0, server_1.generateAuthenticationOptions)({
231
+ rpID: rp.rpID,
232
+ userVerification: 'preferred',
233
+ allowCredentials: creds.map((c) => ({
234
+ id: c.id,
235
+ transports: c.transports.length ? c.transports : undefined,
236
+ })),
237
+ });
238
+ return { ok: true, token: this.challenges.put('login', rp.rpID, options.challenge), options };
239
+ }
240
+ /**
241
+ * Unauthenticated: verify an assertion against a stored credential. On
242
+ * success the counter and lastUsedAt are persisted. The caller mints the
243
+ * session and applies rate limiting.
244
+ */
245
+ async login(rp, body) {
246
+ const { token, response } = asObject(body);
247
+ const challenge = this.challenges.take(token, 'login', rp.rpID);
248
+ const assertion = response;
249
+ const store = loadPasskeyStore();
250
+ const cred = assertion?.id
251
+ ? store.credentials.find((c) => c.id === assertion.id && c.rpID === rp.rpID)
252
+ : undefined;
253
+ if (!challenge || !cred) {
254
+ return { ok: false, error: 'Passkey not recognized' };
255
+ }
256
+ let verified = false;
257
+ let newCounter = cred.counter;
258
+ try {
259
+ const verification = await (0, server_1.verifyAuthenticationResponse)({
260
+ response: assertion,
261
+ expectedChallenge: challenge,
262
+ expectedOrigin: rp.origin,
263
+ expectedRPID: rp.rpID,
264
+ credential: {
265
+ id: cred.id,
266
+ publicKey: new Uint8Array(Buffer.from(cred.publicKey, 'base64url')),
267
+ counter: cred.counter,
268
+ transports: cred.transports,
269
+ },
270
+ requireUserVerification: false,
271
+ });
272
+ verified = verification.verified;
273
+ newCounter = verification.authenticationInfo?.newCounter ?? cred.counter;
274
+ }
275
+ catch {
276
+ verified = false;
277
+ }
278
+ if (!verified) {
279
+ return { ok: false, error: 'Passkey verification failed' };
280
+ }
281
+ cred.counter = newCounter;
282
+ cred.lastUsedAt = new Date().toISOString();
283
+ savePasskeyStore(store);
284
+ return { ok: true, credential: cred };
285
+ }
286
+ /** Authenticated: a registration challenge for this rpID. */
287
+ async registerOptions(rp) {
288
+ const store = loadPasskeyStore();
289
+ if (!store.userId) {
290
+ store.userId = (0, crypto_1.randomBytes)(16).toString('base64url');
291
+ savePasskeyStore(store); // the user handle must survive restarts
292
+ }
293
+ const options = await (0, server_1.generateRegistrationOptions)({
294
+ rpName: RP_NAME,
295
+ rpID: rp.rpID,
296
+ userID: new Uint8Array(Buffer.from(store.userId, 'base64url')),
297
+ userName: USER_NAME,
298
+ userDisplayName: `Nebula @ ${rp.rpID}`,
299
+ attestationType: 'none',
300
+ excludeCredentials: this.credentialsFor(rp.rpID, store).map((c) => ({
301
+ id: c.id,
302
+ transports: c.transports.length ? c.transports : undefined,
303
+ })),
304
+ authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred' },
305
+ });
306
+ return { ok: true, token: this.challenges.put('register', rp.rpID, options.challenge), options };
307
+ }
308
+ /** Authenticated: verify an attestation and store the new credential. */
309
+ async register(rp, body) {
310
+ const { token, response, label } = asObject(body);
311
+ const challenge = this.challenges.take(token, 'register', rp.rpID);
312
+ if (!challenge) {
313
+ return { ok: false, error: 'Challenge expired — try again' };
314
+ }
315
+ const attestation = response;
316
+ if (!attestation?.id) {
317
+ return { ok: false, error: 'Missing attestation response' };
318
+ }
319
+ let registered;
320
+ try {
321
+ const verification = await (0, server_1.verifyRegistrationResponse)({
322
+ response: attestation,
323
+ expectedChallenge: challenge,
324
+ expectedOrigin: rp.origin,
325
+ expectedRPID: rp.rpID,
326
+ requireUserVerification: false,
327
+ });
328
+ registered = verification.verified ? verification.registrationInfo?.credential : undefined;
329
+ }
330
+ catch {
331
+ registered = undefined;
332
+ }
333
+ if (!registered) {
334
+ return { ok: false, error: 'Attestation verification failed' };
335
+ }
336
+ const store = loadPasskeyStore();
337
+ const existing = store.credentials.find((c) => c.id === registered.id);
338
+ if (existing) {
339
+ return { ok: true, passkey: toPublicPasskey(existing) };
340
+ }
341
+ const cleanLabel = typeof label === 'string' ? label.trim().slice(0, exports.MAX_LABEL_LENGTH) : '';
342
+ const cred = {
343
+ id: registered.id,
344
+ publicKey: Buffer.from(registered.publicKey).toString('base64url'),
345
+ counter: Number(registered.counter) || 0,
346
+ transports: registered.transports ?? attestation.response?.transports ?? [],
347
+ rpID: rp.rpID,
348
+ label: cleanLabel || `passkey · ${rp.rpID}`,
349
+ createdAt: new Date().toISOString(),
350
+ lastUsedAt: null,
351
+ };
352
+ store.credentials.push(cred);
353
+ savePasskeyStore(store);
354
+ console.log(`[Auth] Passkey enrolled for ${rp.rpID} (${store.credentials.length} total)`);
355
+ return { ok: true, passkey: toPublicPasskey(cred) };
356
+ }
357
+ /** Authenticated: every enrolled passkey, public fields only. */
358
+ list() {
359
+ return loadPasskeyStore().credentials.map(toPublicPasskey);
360
+ }
361
+ /** Authenticated: remove a credential by id. Returns whether one was removed. */
362
+ delete(id) {
363
+ const store = loadPasskeyStore();
364
+ const before = store.credentials.length;
365
+ store.credentials = store.credentials.filter((c) => c.id !== id);
366
+ if (store.credentials.length === before)
367
+ return false;
368
+ savePasskeyStore(store);
369
+ console.log(`[Auth] Passkey removed (${store.credentials.length} remaining)`);
370
+ return true;
371
+ }
372
+ }
373
+ exports.PasskeyService = PasskeyService;
374
+ exports.passkeyService = new PasskeyService();
@@ -81,6 +81,7 @@ const mock_scheduler_1 = require("./scheduler/mock-scheduler");
81
81
  const arch_1 = require("./scheduler/arch");
82
82
  // Import auth
83
83
  const auth_2 = require("./auth");
84
+ const auth_middleware_1 = require("./auth/auth-middleware");
84
85
  const fs_service_1 = require("./fs/fs-service");
85
86
  const update_check_1 = require("./update-check");
86
87
  const environment_1 = require("./environment");
@@ -357,20 +358,16 @@ async function createApp() {
357
358
  }
358
359
  return reply.send({ status: 'ready' });
359
360
  });
360
- // Auth routes (public - no auth required)
361
+ // Auth routes. Login endpoints are public; passkey management
362
+ // (enroll/list/delete) lives under the same prefix but needs a session.
361
363
  await fastify.register(auth_1.default, { prefix: '/api' });
362
364
  // Auth middleware - protect all other API routes
363
- // Applied as an onRequest hook for /api/* routes (excluding public ones)
365
+ // Applied as an onRequest hook for /api/* routes (excluding public ones).
366
+ // The public list (health, ready, TOTP + passkey login) and the non-API
367
+ // static paths are decided in one place: isPublicRoute().
364
368
  fastify.addHook('onRequest', async (request, reply) => {
365
369
  const pathname = request.url.split('?')[0];
366
- // Skip health, ready, and auth routes (they are public)
367
- if (pathname === '/api/health' ||
368
- pathname === '/api/ready' ||
369
- pathname.startsWith('/api/auth/')) {
370
- return;
371
- }
372
- // Skip non-API routes (static files etc)
373
- if (!pathname.startsWith('/api/')) {
370
+ if ((0, auth_middleware_1.isPublicRoute)(pathname)) {
374
371
  return;
375
372
  }
376
373
  // Apply auth middleware
@@ -1,5 +1,16 @@
1
1
  /**
2
- * Auth Routes - API endpoints for 2FA authentication
2
+ * Auth Routes - API endpoints for 2FA (TOTP) and passkey (WebAuthn) login
3
+ *
4
+ * Public (no session required): /auth/status, /auth/verify,
5
+ * /auth/passkeys/login-options, /auth/passkeys/login.
6
+ * Everything else under /auth/passkeys requires a valid session — the auth
7
+ * middleware enforces that (see PUBLIC_ROUTES in auth-middleware.ts).
3
8
  */
4
9
  import { FastifyInstance } from 'fastify';
10
+ /**
11
+ * Session length flag. Absent → long (30-day) session; only an explicit
12
+ * `false` (or "false"/0) asks for the 24 h one. Accepts both the historical
13
+ * `trustBrowser` key the UI sends and the shorter `trusted`.
14
+ */
15
+ export declare function parseTrusted(body: unknown): boolean;
5
16
  export default function authRoutes(fastify: FastifyInstance): Promise<void>;