gestalt-mobile 0.9.0 → 0.11.0
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/README.md +83 -14
- package/dist/client/assets/{index-DrULMr7l.css → index-Bkm5GDCs.css} +1 -1
- package/dist/client/assets/index-DviggEIS.js +19 -0
- package/dist/client/index.html +2 -2
- package/dist/server/server/app.js +52 -3
- package/dist/server/server/cli.js +6 -0
- package/dist/server/server/composition.js +212 -150
- package/dist/server/server/config.js +64 -2
- package/dist/server/server/features/auth/application/ports.js +8 -0
- package/dist/server/server/features/auth/devices/application.js +26 -0
- package/dist/server/server/features/auth/devices/list/endpoint.js +12 -0
- package/dist/server/server/features/auth/devices/rename/endpoint.js +37 -0
- package/dist/server/server/features/auth/devices/revoke/endpoint.js +48 -0
- package/dist/server/server/features/auth/domain/authorization.js +101 -0
- package/dist/server/server/features/auth/domain/device-nickname.js +13 -0
- package/dist/server/server/features/auth/domain/errors.js +7 -0
- package/dist/server/server/features/auth/domain/identifiers.js +45 -0
- package/dist/server/server/features/auth/enrollment-tickets/cancel/endpoint.js +14 -0
- package/dist/server/server/features/auth/enrollment-tickets/endpoint.js +17 -0
- package/dist/server/server/features/auth/enrollment-tickets/status/endpoint.js +13 -0
- package/dist/server/server/features/auth/login/options/endpoint.js +44 -0
- package/dist/server/server/features/auth/login/verify/endpoint.js +88 -0
- package/dist/server/server/features/auth/logout/endpoint.js +16 -0
- package/dist/server/server/features/auth/register/options/endpoint.js +34 -0
- package/dist/server/server/features/auth/register/verify/endpoint.js +56 -0
- package/dist/server/server/features/auth/status/endpoint.js +24 -0
- package/dist/server/server/features/sessions/get-history/endpoint.js +23 -1
- package/dist/server/server/features/sessions/restore-session/endpoint.js +24 -4
- package/dist/server/server/features/sessions/restore-session/use-case.js +20 -0
- package/dist/server/server/features/sessions/session-events/endpoint.js +7 -2
- package/dist/server/server/platform/auth/simple-webauthn-adapter.js +75 -0
- package/dist/server/server/platform/auth/sqlite-authorization-store.js +478 -0
- package/dist/server/server/platform/codex/json-rpc-client.js +36 -2
- package/dist/server/server/platform/codex/session-runtime.js +64 -22
- package/dist/server/server/platform/events/websocket-heartbeat.js +5 -1
- package/dist/server/server/platform/http/authorization-boundary.js +60 -0
- package/dist/server/server/platform/plans/filesystem-plan-status-source.js +30 -11
- package/dist/server/shared/contracts/plan-signal.js +17 -0
- package/package.json +8 -2
- package/dist/client/assets/index-D7-LoAAR.js +0 -12
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
5
|
*/
|
|
6
6
|
import { idempotencyKey } from '../../../platform/http/idempotency.js';
|
|
7
|
+
import { RelaySession } from '../model/relay-session.js';
|
|
7
8
|
import { canRestore } from './use-case.js';
|
|
8
9
|
export function registerRestoreSession(app, deps) {
|
|
9
10
|
app.post('/api/sessions/:id/restore', async (request, reply) => {
|
|
@@ -18,10 +19,29 @@ export function registerRestoreSession(app, deps) {
|
|
|
18
19
|
return reply.code(404).send({ code: 'SESSION_NOT_FOUND' });
|
|
19
20
|
if (!canRestore(session))
|
|
20
21
|
return reply.code(409).send({ code: 'SESSION_CANNOT_RESTORE' });
|
|
21
|
-
const
|
|
22
|
-
deps.save(
|
|
22
|
+
const recovering = RelaySession.rehydrate(session).beginRecovery(new Date().toISOString()).snapshot;
|
|
23
|
+
deps.save(recovering);
|
|
24
|
+
let restored;
|
|
25
|
+
try {
|
|
26
|
+
restored = await deps.restore(session);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// The pre-I/O recovery marker must never strand a saved session: keep
|
|
30
|
+
// its original thread and inactive state available for a later Open.
|
|
31
|
+
deps.save(session);
|
|
32
|
+
return reply.code(502).send({ code: 'RESTORE_FAILED' });
|
|
33
|
+
}
|
|
34
|
+
const response = 'session' in restored
|
|
35
|
+
? {
|
|
36
|
+
...restored.session,
|
|
37
|
+
...(restored.replacementCreated
|
|
38
|
+
? { recovery: { historyUnavailable: true, replacementCreated: true } }
|
|
39
|
+
: {}),
|
|
40
|
+
}
|
|
41
|
+
: restored;
|
|
42
|
+
deps.save(response);
|
|
23
43
|
if (key)
|
|
24
|
-
deps.idempotency?.put(scope, key, 200, JSON.stringify(
|
|
25
|
-
return reply.send(
|
|
44
|
+
deps.idempotency?.put(scope, key, 200, JSON.stringify(response));
|
|
45
|
+
return reply.send(response);
|
|
26
46
|
});
|
|
27
47
|
}
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
5
|
*/
|
|
6
6
|
import { RelaySession } from '../model/relay-session.js';
|
|
7
|
+
import { threadId } from '../model/value-objects.js';
|
|
8
|
+
import { isMissingCodexThreadRollout } from '../../../platform/codex/json-rpc-client.js';
|
|
7
9
|
export function canRestore(session) {
|
|
8
10
|
return (session.threadId !== null &&
|
|
9
11
|
(session.state === 'stopped' ||
|
|
@@ -15,3 +17,21 @@ export function restore(session, now) {
|
|
|
15
17
|
throw new Error('SESSION_CANNOT_RESTORE');
|
|
16
18
|
return RelaySession.rehydrate(session).beginRecovery(now).restore(now).snapshot;
|
|
17
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* The runtime may replace a thread only after a failed resume is confirmed to
|
|
22
|
+
* be the historical-rollout case. This pure policy leaves persistence to the
|
|
23
|
+
* caller, so the original thread remains durable until thread/start succeeds.
|
|
24
|
+
*/
|
|
25
|
+
export function canRebindMissingRollout(session, error) {
|
|
26
|
+
return canRestore(session) && isMissingCodexThreadRollout(error);
|
|
27
|
+
}
|
|
28
|
+
export function rebindMissingRollout(session, error, replacementThreadId, now) {
|
|
29
|
+
if (!canRebindMissingRollout(session, error))
|
|
30
|
+
throw new Error('CODEX_ROLLOUT_REBIND_NOT_ALLOWED');
|
|
31
|
+
threadId(replacementThreadId);
|
|
32
|
+
return {
|
|
33
|
+
session: RelaySession.rehydrate(session).bindThread(replacementThreadId, now).snapshot,
|
|
34
|
+
historyUnavailable: true,
|
|
35
|
+
replacementCreated: true,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -13,7 +13,12 @@ export function registerSessionEvents(app, deps) {
|
|
|
13
13
|
const url = new URL(request.url ?? '/', 'http://relay.invalid');
|
|
14
14
|
const match = /^\/api\/sessions\/([^/]+)\/events$/.exec(url.pathname);
|
|
15
15
|
const after = Number(url.searchParams.get('after') ?? '0');
|
|
16
|
-
if (!match ||
|
|
16
|
+
if (!match ||
|
|
17
|
+
!Number.isSafeInteger(after) ||
|
|
18
|
+
after < 0 ||
|
|
19
|
+
(deps.publicOrigin !== undefined && request.headers.origin !== deps.publicOrigin) ||
|
|
20
|
+
(deps.authorized !== undefined && !deps.authorized(request.headers.cookie)) ||
|
|
21
|
+
!deps.exists(match[1])) {
|
|
17
22
|
socket.destroy();
|
|
18
23
|
return;
|
|
19
24
|
}
|
|
@@ -33,7 +38,7 @@ export function registerSessionEvents(app, deps) {
|
|
|
33
38
|
}
|
|
34
39
|
replay.events.forEach(send);
|
|
35
40
|
const unsubscribe = deps.subscribe(sessionId, send);
|
|
36
|
-
const removeHeartbeat = installWebSocketHeartbeat(connection);
|
|
41
|
+
const removeHeartbeat = installWebSocketHeartbeat(connection, deps.heartbeatIntervalMs ?? 25_000, () => deps.authorized?.(request.headers.cookie) ?? true);
|
|
37
42
|
connection.on('close', () => {
|
|
38
43
|
removeHeartbeat();
|
|
39
44
|
unsubscribe();
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Dyne.org foundation
|
|
3
|
+
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
|
+
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server';
|
|
7
|
+
import { PasskeyVerificationError } from '../../features/auth/application/ports.js';
|
|
8
|
+
import { webAuthnCredentialId } from '../../features/auth/domain/identifiers.js';
|
|
9
|
+
const transports = new Set(['ble', 'hybrid', 'internal', 'nfc', 'usb']);
|
|
10
|
+
/** Translates SimpleWebAuthn DTOs at the platform boundary. */
|
|
11
|
+
export class SimpleWebAuthnAdapter {
|
|
12
|
+
async registrationOptions(input) {
|
|
13
|
+
return generateRegistrationOptions({
|
|
14
|
+
rpName: input.rpName,
|
|
15
|
+
rpID: input.rpId,
|
|
16
|
+
userName: 'local-owner',
|
|
17
|
+
userDisplayName: 'Local owner',
|
|
18
|
+
userID: new Uint8Array(input.userHandle),
|
|
19
|
+
challenge: new Uint8Array(input.challenge),
|
|
20
|
+
attestationType: 'none',
|
|
21
|
+
excludeCredentials: input.excludeCredentialIds.map((id) => ({ id })),
|
|
22
|
+
authenticatorSelection: { residentKey: 'required', requireResidentKey: true, userVerification: 'required' },
|
|
23
|
+
supportedAlgorithmIDs: [-7, -257],
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
async authenticationOptions(input) {
|
|
27
|
+
return generateAuthenticationOptions({
|
|
28
|
+
rpID: input.rpId,
|
|
29
|
+
challenge: new Uint8Array(input.challenge),
|
|
30
|
+
allowCredentials: [],
|
|
31
|
+
userVerification: 'required',
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
async verifyRegistration(input) {
|
|
35
|
+
let result;
|
|
36
|
+
try {
|
|
37
|
+
result = await verifyRegistrationResponse({
|
|
38
|
+
response: input.response,
|
|
39
|
+
expectedChallenge: Buffer.from(input.challenge).toString('base64url'),
|
|
40
|
+
expectedOrigin: input.expectedOrigin,
|
|
41
|
+
expectedRPID: input.rpId,
|
|
42
|
+
requireUserVerification: true,
|
|
43
|
+
supportedAlgorithmIDs: [-7, -257],
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw new PasskeyVerificationError('PASSKEY_VERIFICATION_FAILED');
|
|
48
|
+
}
|
|
49
|
+
if (!result.verified || !result.registrationInfo?.userVerified)
|
|
50
|
+
throw new PasskeyVerificationError('PASSKEY_VERIFICATION_FAILED');
|
|
51
|
+
const credential = result.registrationInfo.credential;
|
|
52
|
+
return {
|
|
53
|
+
credentialId: webAuthnCredentialId(credential.id),
|
|
54
|
+
publicKey: new Uint8Array(credential.publicKey),
|
|
55
|
+
counter: credential.counter,
|
|
56
|
+
userVerified: result.registrationInfo.userVerified,
|
|
57
|
+
transports: (credential.transports ?? []).filter((value) => transports.has(value)),
|
|
58
|
+
deviceType: result.registrationInfo.credentialDeviceType,
|
|
59
|
+
backedUp: result.registrationInfo.credentialBackedUp,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
async verifyAuthentication(input) {
|
|
63
|
+
const value = input;
|
|
64
|
+
let result;
|
|
65
|
+
try {
|
|
66
|
+
result = await verifyAuthenticationResponse({ response: value.response, expectedChallenge: Buffer.from(value.challenge).toString('base64url'), expectedOrigin: value.expectedOrigin, expectedRPID: value.rpId, requireUserVerification: true, credential: { id: value.credential.id, publicKey: new Uint8Array(value.credential.publicKey), counter: value.credential.counter, transports: [...value.credential.transports] } });
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
throw new PasskeyVerificationError('PASSKEY_VERIFICATION_FAILED');
|
|
70
|
+
}
|
|
71
|
+
if (!result.verified || !result.authenticationInfo?.userVerified)
|
|
72
|
+
throw new PasskeyVerificationError('PASSKEY_VERIFICATION_FAILED');
|
|
73
|
+
return { credentialId: webAuthnCredentialId(value.response.id), counter: result.authenticationInfo.newCounter, userVerified: result.authenticationInfo.userVerified };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Dyne.org foundation
|
|
3
|
+
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
|
+
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { mkdirSync, chmodSync } from 'node:fs';
|
|
8
|
+
import { dirname, join, resolve } from 'node:path';
|
|
9
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
10
|
+
import { authorizedDeviceId, localOwnerId, passkeyCeremonyId, webAuthnCredentialId, } from '../../features/auth/domain/identifiers.js';
|
|
11
|
+
import { deviceNickname } from '../../features/auth/domain/device-nickname.js';
|
|
12
|
+
const OWNER_ID = localOwnerId('local-owner');
|
|
13
|
+
const SCHEMA_VERSION = 1;
|
|
14
|
+
const SQLITE_BUSY_TIMEOUT_MS = 5_000;
|
|
15
|
+
const SQLITE_STARTUP_BUSY_TIMEOUT_MS = 250;
|
|
16
|
+
const STARTUP_LOCK_DEADLINE_MS = 2_000;
|
|
17
|
+
const STARTUP_LOCK_RETRY_DELAY_MS = 25;
|
|
18
|
+
const startupLockWaiter = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
|
|
19
|
+
export function authorizationDatabasePath(homeDirectory) {
|
|
20
|
+
return join(resolve(homeDirectory), '.codex-gestalt', 'gestalt-mobile', 'auth.sqlite');
|
|
21
|
+
}
|
|
22
|
+
/** SQLite adapter for the auth bounded context. No SQLite types escape this file. */
|
|
23
|
+
export class SqliteAuthorizationStore {
|
|
24
|
+
path;
|
|
25
|
+
db;
|
|
26
|
+
relyingParty;
|
|
27
|
+
constructor(homeDirectory, relyingParty) {
|
|
28
|
+
this.path = authorizationDatabasePath(homeDirectory);
|
|
29
|
+
const parent = dirname(this.path);
|
|
30
|
+
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
31
|
+
chmodSync(parent, 0o700);
|
|
32
|
+
this.relyingParty = relyingParty;
|
|
33
|
+
this.db = new DatabaseSync(this.path);
|
|
34
|
+
try {
|
|
35
|
+
// journal_mode can take an exclusive lock. Install the bounded busy handler first so
|
|
36
|
+
// simultaneous first opens wait for the winner instead of failing during startup.
|
|
37
|
+
this.db.exec(`PRAGMA busy_timeout = ${SQLITE_STARTUP_BUSY_TIMEOUT_MS}`);
|
|
38
|
+
this.initializeSchema();
|
|
39
|
+
this.db.exec(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`);
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
this.db.close();
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
initializeSchema() {
|
|
47
|
+
const deadline = Date.now() + STARTUP_LOCK_DEADLINE_MS;
|
|
48
|
+
for (;;) {
|
|
49
|
+
try {
|
|
50
|
+
this.initializeSchemaAttempt();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
const remaining = deadline - Date.now();
|
|
55
|
+
if (!isLocked(error) || remaining <= 0)
|
|
56
|
+
throw error;
|
|
57
|
+
Atomics.wait(startupLockWaiter, 0, 0, Math.min(STARTUP_LOCK_RETRY_DELAY_MS, remaining));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
initializeSchemaAttempt() {
|
|
62
|
+
this.db.exec('PRAGMA journal_mode = WAL;');
|
|
63
|
+
this.db.exec('PRAGMA foreign_keys = ON;');
|
|
64
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
65
|
+
try {
|
|
66
|
+
this.db
|
|
67
|
+
.exec(`CREATE TABLE IF NOT EXISTS auth_settings (singleton INTEGER PRIMARY KEY CHECK (singleton = 1), schema_version INTEGER NOT NULL, rp_id TEXT NOT NULL, user_handle BLOB NOT NULL);
|
|
68
|
+
CREATE TABLE IF NOT EXISTS auth_devices (id TEXT PRIMARY KEY, credential_id TEXT NOT NULL UNIQUE, public_key BLOB NOT NULL, counter INTEGER NOT NULL, transports_json TEXT NOT NULL, device_type TEXT NOT NULL, backed_up INTEGER NOT NULL CHECK(backed_up IN (0,1)), nickname TEXT NOT NULL, created_at TEXT NOT NULL, last_used_at TEXT, version INTEGER NOT NULL DEFAULT 0);
|
|
69
|
+
CREATE TABLE IF NOT EXISTS auth_ceremonies (token_hash TEXT PRIMARY KEY, purpose TEXT NOT NULL, challenge BLOB NOT NULL, expected_origin TEXT NOT NULL, rp_id TEXT NOT NULL, expires_at TEXT NOT NULL, consumed_at TEXT, ticket_hash TEXT);
|
|
70
|
+
CREATE TABLE IF NOT EXISTS auth_tickets (token_hash TEXT PRIMARY KEY, expires_at TEXT NOT NULL, consumed_at TEXT, creator_session_hash TEXT);
|
|
71
|
+
CREATE TABLE IF NOT EXISTS auth_sessions (token_hash TEXT PRIMARY KEY, device_id TEXT REFERENCES auth_devices(id) ON DELETE SET NULL, expires_at TEXT NOT NULL, revoked_at TEXT);`);
|
|
72
|
+
const ceremonyColumns = this.db
|
|
73
|
+
.prepare("SELECT name FROM pragma_table_info('auth_ceremonies')")
|
|
74
|
+
.all();
|
|
75
|
+
if (!ceremonyColumns.some((column) => column.name === 'ticket_hash'))
|
|
76
|
+
this.db.exec('ALTER TABLE auth_ceremonies ADD COLUMN ticket_hash TEXT');
|
|
77
|
+
const ticketColumns = this.db
|
|
78
|
+
.prepare("SELECT name FROM pragma_table_info('auth_tickets')")
|
|
79
|
+
.all();
|
|
80
|
+
if (!ticketColumns.some((column) => column.name === 'creator_session_hash'))
|
|
81
|
+
this.db.exec('ALTER TABLE auth_tickets ADD COLUMN creator_session_hash TEXT');
|
|
82
|
+
const settings = this.db
|
|
83
|
+
.prepare('SELECT rp_id FROM auth_settings WHERE singleton = 1')
|
|
84
|
+
.get();
|
|
85
|
+
if (settings && settings.rp_id !== this.relyingParty.rpId && this.deviceCount() > 0)
|
|
86
|
+
throw new Error('WebAuthn RP ID hostname changed while credentials exist');
|
|
87
|
+
this.db.exec('COMMIT');
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
try {
|
|
91
|
+
this.db.exec('ROLLBACK');
|
|
92
|
+
}
|
|
93
|
+
catch { }
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
initializeOwner(userHandle) {
|
|
98
|
+
if (userHandle.length === 0)
|
|
99
|
+
throw new Error('Local owner handle must not be empty');
|
|
100
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
101
|
+
try {
|
|
102
|
+
const current = this.db
|
|
103
|
+
.prepare('SELECT user_handle, rp_id FROM auth_settings WHERE singleton = 1')
|
|
104
|
+
.get();
|
|
105
|
+
if (!current)
|
|
106
|
+
this.db
|
|
107
|
+
.prepare('INSERT INTO auth_settings (singleton, schema_version, rp_id, user_handle) VALUES (1, ?, ?, ?)')
|
|
108
|
+
.run(SCHEMA_VERSION, this.relyingParty.rpId, userHandle);
|
|
109
|
+
else if (current.rp_id !== this.relyingParty.rpId && this.deviceCount() > 0)
|
|
110
|
+
throw new Error('WebAuthn RP ID hostname changed while credentials exist');
|
|
111
|
+
else if (current.rp_id !== this.relyingParty.rpId)
|
|
112
|
+
this.db
|
|
113
|
+
.prepare('UPDATE auth_settings SET rp_id = ? WHERE singleton = 1')
|
|
114
|
+
.run(this.relyingParty.rpId);
|
|
115
|
+
this.db.exec('COMMIT');
|
|
116
|
+
return { id: OWNER_ID, userHandle: new Uint8Array(current?.user_handle ?? userHandle) };
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
this.db.exec('ROLLBACK');
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
readOwner() {
|
|
124
|
+
const row = this.db
|
|
125
|
+
.prepare('SELECT user_handle FROM auth_settings WHERE singleton = 1')
|
|
126
|
+
.get();
|
|
127
|
+
return row ? { id: OWNER_ID, userHandle: new Uint8Array(row.user_handle) } : null;
|
|
128
|
+
}
|
|
129
|
+
listAuthorizedDevices() {
|
|
130
|
+
return this.db.prepare('SELECT * FROM auth_devices ORDER BY created_at, id').all().map(toDevice);
|
|
131
|
+
}
|
|
132
|
+
claimFirstDevice(owner, device) {
|
|
133
|
+
if (owner.id !== OWNER_ID || !this.readOwner())
|
|
134
|
+
throw new Error('Local owner is not initialized');
|
|
135
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
136
|
+
try {
|
|
137
|
+
if (this.deviceCount()) {
|
|
138
|
+
this.db.exec('COMMIT');
|
|
139
|
+
return 'alreadyClaimed';
|
|
140
|
+
}
|
|
141
|
+
this.insertDevice(device);
|
|
142
|
+
this.db.exec('COMMIT');
|
|
143
|
+
return 'claimed';
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
this.db.exec('ROLLBACK');
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
authorizeDevice(device) {
|
|
151
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
152
|
+
try {
|
|
153
|
+
if (!this.readOwner() || !this.deviceCount()) {
|
|
154
|
+
this.db.exec('COMMIT');
|
|
155
|
+
return 'notAuthorized';
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
this.insertDevice(device);
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
if (String(error).includes('UNIQUE constraint failed: auth_devices.credential_id')) {
|
|
162
|
+
this.db.exec('ROLLBACK');
|
|
163
|
+
return 'duplicateCredential';
|
|
164
|
+
}
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
this.db.exec('COMMIT');
|
|
168
|
+
return 'authorized';
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
try {
|
|
172
|
+
this.db.exec('ROLLBACK');
|
|
173
|
+
}
|
|
174
|
+
catch { }
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
findDeviceByCredentialId(id) {
|
|
179
|
+
const row = this.db.prepare('SELECT * FROM auth_devices WHERE credential_id = ?').get(id);
|
|
180
|
+
return row ? toDevice(row) : null;
|
|
181
|
+
}
|
|
182
|
+
findDevice(id) {
|
|
183
|
+
const row = this.db.prepare('SELECT * FROM auth_devices WHERE id = ?').get(id);
|
|
184
|
+
return row ? toDevice(row) : null;
|
|
185
|
+
}
|
|
186
|
+
renameDevice(id, expectedVersion, nickname) {
|
|
187
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
188
|
+
try {
|
|
189
|
+
const result = this.db
|
|
190
|
+
.prepare('UPDATE auth_devices SET nickname = ?, version = version + 1 WHERE id = ? AND version = ?')
|
|
191
|
+
.run(nickname, id, expectedVersion);
|
|
192
|
+
if (result.changes) {
|
|
193
|
+
this.db.exec('COMMIT');
|
|
194
|
+
return 'renamed';
|
|
195
|
+
}
|
|
196
|
+
const outcome = this.findDevice(id) ? 'stale' : 'notFound';
|
|
197
|
+
this.db.exec('COMMIT');
|
|
198
|
+
return outcome;
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
this.db.exec('ROLLBACK');
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
advanceCounter(id, expected, expectedVersion, next, usedAt) {
|
|
206
|
+
const zeroSynced = this.db
|
|
207
|
+
.prepare("UPDATE auth_devices SET last_used_at = ?, version = version + 1 WHERE id = ? AND counter = ? AND version = ? AND ? = 0 AND device_type = 'multiDevice'")
|
|
208
|
+
.run(usedAt, id, expected, expectedVersion, next).changes === 1;
|
|
209
|
+
return (zeroSynced ||
|
|
210
|
+
this.db
|
|
211
|
+
.prepare('UPDATE auth_devices SET counter = ?, last_used_at = ?, version = version + 1 WHERE id = ? AND counter = ? AND version = ? AND ? > counter')
|
|
212
|
+
.run(next, usedAt, id, expected, expectedVersion, next).changes === 1);
|
|
213
|
+
}
|
|
214
|
+
revokeDevice(id, revokedAt) {
|
|
215
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
216
|
+
try {
|
|
217
|
+
if (!this.findDevice(id)) {
|
|
218
|
+
this.db.exec('COMMIT');
|
|
219
|
+
return 'notFound';
|
|
220
|
+
}
|
|
221
|
+
if (this.deviceCount() < 2) {
|
|
222
|
+
this.db.exec('COMMIT');
|
|
223
|
+
return 'finalDevice';
|
|
224
|
+
}
|
|
225
|
+
this.db
|
|
226
|
+
.prepare('UPDATE auth_sessions SET revoked_at = ? WHERE device_id = ? AND revoked_at IS NULL')
|
|
227
|
+
.run(revokedAt, id);
|
|
228
|
+
this.db.prepare('DELETE FROM auth_devices WHERE id = ?').run(id);
|
|
229
|
+
this.db.exec('COMMIT');
|
|
230
|
+
return 'revoked';
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
this.db.exec('ROLLBACK');
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
saveCeremony(token, ceremony) {
|
|
238
|
+
this.db
|
|
239
|
+
.prepare('INSERT INTO auth_ceremonies (token_hash, purpose, challenge, expected_origin, rp_id, expires_at, ticket_hash) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
|
240
|
+
.run(hash(token), ceremony.purpose, ceremony.challenge, ceremony.expectedOrigin, ceremony.rpId, ceremony.expiresAt, ceremony.enrollmentTicket ? hash(ceremony.enrollmentTicket) : null);
|
|
241
|
+
}
|
|
242
|
+
consumeCeremony(token, now) {
|
|
243
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
244
|
+
try {
|
|
245
|
+
const row = this.db
|
|
246
|
+
.prepare('UPDATE auth_ceremonies SET consumed_at = ? WHERE token_hash = ? AND consumed_at IS NULL AND expires_at > ? RETURNING purpose, challenge, expected_origin, rp_id, expires_at')
|
|
247
|
+
.get(now, hash(token), now);
|
|
248
|
+
this.db.exec('COMMIT');
|
|
249
|
+
return row
|
|
250
|
+
? {
|
|
251
|
+
id: passkeyCeremonyId(token),
|
|
252
|
+
purpose: row.purpose,
|
|
253
|
+
challenge: new Uint8Array(row.challenge),
|
|
254
|
+
expectedOrigin: row.expected_origin,
|
|
255
|
+
rpId: row.rp_id,
|
|
256
|
+
expiresAt: row.expires_at,
|
|
257
|
+
}
|
|
258
|
+
: null;
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
this.db.exec('ROLLBACK');
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
readCeremony(token, now) {
|
|
266
|
+
const row = this.db
|
|
267
|
+
.prepare('SELECT purpose, challenge, expected_origin, rp_id, expires_at FROM auth_ceremonies WHERE token_hash = ? AND consumed_at IS NULL AND expires_at > ?')
|
|
268
|
+
.get(hash(token), now);
|
|
269
|
+
return row
|
|
270
|
+
? {
|
|
271
|
+
id: passkeyCeremonyId(token),
|
|
272
|
+
purpose: row.purpose,
|
|
273
|
+
challenge: new Uint8Array(row.challenge),
|
|
274
|
+
expectedOrigin: row.expected_origin,
|
|
275
|
+
rpId: row.rp_id,
|
|
276
|
+
expiresAt: row.expires_at,
|
|
277
|
+
}
|
|
278
|
+
: null;
|
|
279
|
+
}
|
|
280
|
+
saveTicket(token, ticket) {
|
|
281
|
+
this.db
|
|
282
|
+
.prepare('INSERT INTO auth_tickets (token_hash, expires_at, consumed_at) VALUES (?, ?, NULL)')
|
|
283
|
+
.run(hash(token), ticket.expiresAt);
|
|
284
|
+
}
|
|
285
|
+
issueEnrollmentTicket(token, creatorSession, expiresAt) {
|
|
286
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
287
|
+
try {
|
|
288
|
+
const creator = hash(creatorSession);
|
|
289
|
+
this.db.prepare('DELETE FROM auth_tickets WHERE creator_session_hash = ? AND consumed_at IS NULL').run(creator);
|
|
290
|
+
this.db.prepare('INSERT INTO auth_tickets (token_hash, expires_at, consumed_at, creator_session_hash) VALUES (?, ?, NULL, ?)').run(hash(token), expiresAt, creator);
|
|
291
|
+
this.db.exec('COMMIT');
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
this.db.exec('ROLLBACK');
|
|
295
|
+
throw error;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
enrollmentTicketStatus(creatorSession, now) {
|
|
299
|
+
const row = this.db
|
|
300
|
+
.prepare('SELECT expires_at, consumed_at FROM auth_tickets WHERE creator_session_hash = ? ORDER BY rowid DESC LIMIT 1')
|
|
301
|
+
.get(hash(creatorSession));
|
|
302
|
+
if (!row)
|
|
303
|
+
return 'none';
|
|
304
|
+
if (row.consumed_at)
|
|
305
|
+
return 'used';
|
|
306
|
+
return row.expires_at > now ? 'pending' : 'expired';
|
|
307
|
+
}
|
|
308
|
+
cancelEnrollmentTicket(creatorSession, now) {
|
|
309
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
310
|
+
try {
|
|
311
|
+
const result = this.db
|
|
312
|
+
.prepare('DELETE FROM auth_tickets WHERE rowid = (SELECT rowid FROM auth_tickets WHERE creator_session_hash = ? AND consumed_at IS NULL AND expires_at > ? ORDER BY rowid DESC LIMIT 1)')
|
|
313
|
+
.run(hash(creatorSession), now);
|
|
314
|
+
this.db.exec('COMMIT');
|
|
315
|
+
return result.changes === 1;
|
|
316
|
+
}
|
|
317
|
+
catch (error) {
|
|
318
|
+
this.db.exec('ROLLBACK');
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
consumeTicket(token, now) {
|
|
323
|
+
return this.consume('auth_tickets', token, now);
|
|
324
|
+
}
|
|
325
|
+
ticketAvailable(token, now) {
|
|
326
|
+
return Boolean(this.db
|
|
327
|
+
.prepare('SELECT 1 FROM auth_tickets t WHERE t.token_hash = ? AND t.consumed_at IS NULL AND t.expires_at > ? AND (t.creator_session_hash IS NULL OR EXISTS (SELECT 1 FROM auth_sessions s WHERE s.token_hash = t.creator_session_hash AND s.revoked_at IS NULL AND s.expires_at > ?))')
|
|
328
|
+
.get(hash(token), now, now));
|
|
329
|
+
}
|
|
330
|
+
completeRegistration(input) {
|
|
331
|
+
if (input.session.deviceId !== input.device.id)
|
|
332
|
+
throw new Error('AUTHORIZATION_SESSION_DEVICE_MISMATCH');
|
|
333
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
334
|
+
try {
|
|
335
|
+
const ceremony = this.db
|
|
336
|
+
.prepare('SELECT ticket_hash FROM auth_ceremonies WHERE token_hash = ? AND purpose = ? AND consumed_at IS NULL AND expires_at > ?')
|
|
337
|
+
.get(hash(input.ceremony), 'registration', input.now);
|
|
338
|
+
if (!ceremony) {
|
|
339
|
+
this.db.exec('COMMIT');
|
|
340
|
+
return 'ceremonyUnavailable';
|
|
341
|
+
}
|
|
342
|
+
if (ceremony.ticket_hash) {
|
|
343
|
+
const result = this.db
|
|
344
|
+
.prepare('UPDATE auth_tickets SET consumed_at = ? WHERE token_hash = ? AND consumed_at IS NULL AND expires_at > ? AND (creator_session_hash IS NULL OR EXISTS (SELECT 1 FROM auth_sessions WHERE token_hash = auth_tickets.creator_session_hash AND revoked_at IS NULL AND expires_at > ?))')
|
|
345
|
+
.run(input.now, ceremony.ticket_hash, input.now, input.now);
|
|
346
|
+
if (result.changes !== 1) {
|
|
347
|
+
this.db.exec('COMMIT');
|
|
348
|
+
return 'ticketUnavailable';
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
else if (this.deviceCount() !== 0) {
|
|
352
|
+
this.db.exec('COMMIT');
|
|
353
|
+
return 'bootstrapAlreadyClaimed';
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
this.insertDevice(input.device);
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
if (String(error).includes('UNIQUE constraint failed: auth_devices.credential_id')) {
|
|
360
|
+
this.db.exec('ROLLBACK');
|
|
361
|
+
return 'duplicateCredential';
|
|
362
|
+
}
|
|
363
|
+
throw error;
|
|
364
|
+
}
|
|
365
|
+
this.db
|
|
366
|
+
.prepare('INSERT INTO auth_sessions (token_hash, device_id, expires_at) VALUES (?, ?, ?)')
|
|
367
|
+
.run(hash(input.session.id), input.session.deviceId, input.session.expiresAt);
|
|
368
|
+
this.db
|
|
369
|
+
.prepare('UPDATE auth_ceremonies SET consumed_at = ? WHERE token_hash = ?')
|
|
370
|
+
.run(input.now, hash(input.ceremony));
|
|
371
|
+
this.db.exec('COMMIT');
|
|
372
|
+
return 'registered';
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
try {
|
|
376
|
+
this.db.exec('ROLLBACK');
|
|
377
|
+
}
|
|
378
|
+
catch { }
|
|
379
|
+
throw error;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
completeAuthentication(input) {
|
|
383
|
+
if (input.session.deviceId !== input.device.id)
|
|
384
|
+
throw new Error('AUTHORIZATION_SESSION_DEVICE_MISMATCH');
|
|
385
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
386
|
+
try {
|
|
387
|
+
const ceremony = this.db
|
|
388
|
+
.prepare('SELECT 1 FROM auth_ceremonies WHERE token_hash = ? AND purpose = ? AND consumed_at IS NULL AND expires_at > ?')
|
|
389
|
+
.get(hash(input.ceremony), 'authentication', input.now);
|
|
390
|
+
if (!ceremony) {
|
|
391
|
+
this.db.exec('COMMIT');
|
|
392
|
+
return false;
|
|
393
|
+
}
|
|
394
|
+
const updated = this.advanceCounter(input.device.id, input.device.counter, input.device.version ?? 0, input.nextCounter, input.now);
|
|
395
|
+
if (!updated) {
|
|
396
|
+
this.db.exec('COMMIT');
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
this.db
|
|
400
|
+
.prepare('INSERT INTO auth_sessions (token_hash, device_id, expires_at) VALUES (?, ?, ?)')
|
|
401
|
+
.run(hash(input.session.id), input.session.deviceId, input.session.expiresAt);
|
|
402
|
+
this.db
|
|
403
|
+
.prepare('UPDATE auth_ceremonies SET consumed_at = ? WHERE token_hash = ?')
|
|
404
|
+
.run(input.now, hash(input.ceremony));
|
|
405
|
+
this.db.exec('COMMIT');
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
408
|
+
catch (error) {
|
|
409
|
+
try {
|
|
410
|
+
this.db.exec('ROLLBACK');
|
|
411
|
+
}
|
|
412
|
+
catch { }
|
|
413
|
+
throw error;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
saveSession(token, session) {
|
|
417
|
+
this.db
|
|
418
|
+
.prepare('INSERT INTO auth_sessions (token_hash, device_id, expires_at) VALUES (?, ?, ?)')
|
|
419
|
+
.run(hash(token), session.deviceId, session.expiresAt);
|
|
420
|
+
}
|
|
421
|
+
sessionDevice(token, now) {
|
|
422
|
+
const row = this.db
|
|
423
|
+
.prepare('SELECT device_id FROM auth_sessions WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > ?')
|
|
424
|
+
.get(hash(token), now);
|
|
425
|
+
return row?.device_id ? authorizedDeviceId(row.device_id) : null;
|
|
426
|
+
}
|
|
427
|
+
revokeSession(token, now) {
|
|
428
|
+
return (this.db
|
|
429
|
+
.prepare('UPDATE auth_sessions SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL')
|
|
430
|
+
.run(now, hash(token)).changes === 1);
|
|
431
|
+
}
|
|
432
|
+
close() {
|
|
433
|
+
this.db.close();
|
|
434
|
+
}
|
|
435
|
+
insertDevice(device) {
|
|
436
|
+
this.db
|
|
437
|
+
.prepare('INSERT INTO auth_devices (id, credential_id, public_key, counter, transports_json, device_type, backed_up, nickname, created_at, last_used_at, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
|
438
|
+
.run(device.id, device.credentialId, device.publicKey, device.counter, JSON.stringify(device.transports), device.deviceType, device.backedUp ? 1 : 0, device.nickname, device.createdAt, device.lastUsedAt ?? null, device.version ?? 0);
|
|
439
|
+
}
|
|
440
|
+
consume(table, token, now) {
|
|
441
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
442
|
+
try {
|
|
443
|
+
const changes = this.db
|
|
444
|
+
.prepare(`UPDATE ${table} SET consumed_at = ? WHERE token_hash = ? AND consumed_at IS NULL AND expires_at > ?`)
|
|
445
|
+
.run(now, hash(token), now).changes;
|
|
446
|
+
this.db.exec('COMMIT');
|
|
447
|
+
return changes === 1;
|
|
448
|
+
}
|
|
449
|
+
catch (error) {
|
|
450
|
+
this.db.exec('ROLLBACK');
|
|
451
|
+
throw error;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
deviceCount() {
|
|
455
|
+
return this.db.prepare('SELECT COUNT(*) AS count FROM auth_devices').get().count;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
function toDevice(row) {
|
|
459
|
+
return {
|
|
460
|
+
id: authorizedDeviceId(row.id),
|
|
461
|
+
credentialId: webAuthnCredentialId(row.credential_id),
|
|
462
|
+
publicKey: new Uint8Array(row.public_key),
|
|
463
|
+
counter: row.counter,
|
|
464
|
+
transports: JSON.parse(row.transports_json),
|
|
465
|
+
deviceType: row.device_type,
|
|
466
|
+
backedUp: row.backed_up === 1,
|
|
467
|
+
nickname: deviceNickname(row.nickname),
|
|
468
|
+
createdAt: row.created_at,
|
|
469
|
+
...(row.last_used_at ? { lastUsedAt: row.last_used_at } : {}),
|
|
470
|
+
version: row.version,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
function hash(value) {
|
|
474
|
+
return createHash('sha256').update(value).digest('hex');
|
|
475
|
+
}
|
|
476
|
+
function isLocked(error) {
|
|
477
|
+
return error instanceof Error && /database is locked/i.test(error.message);
|
|
478
|
+
}
|