@saadnvd1/agent-os 0.3.561 → 0.3.565
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/app/api/devices/[id]/route.ts +3 -6
- package/app/api/devices/me/route.ts +2 -2
- package/app/api/presence/device-key/options/route.ts +36 -0
- package/app/api/presence/device-key/route.ts +55 -0
- package/app/api/presence/enroll/route.ts +7 -15
- package/app/api/presence/options/route.ts +20 -0
- package/app/api/presence/passkeys/[id]/route.ts +11 -9
- package/app/api/workspaces/[id]/orchestrator/asks/[askId]/route.ts +15 -10
- package/app/api/workspaces/[id]/orchestrator/pause/route.ts +12 -9
- package/app/api/workspaces/[id]/orchestrator/rules/[ruleId]/route.ts +3 -3
- package/app/api/workspaces/[id]/orchestrator/rules/presence.ts +10 -7
- package/app/api/workspaces/[id]/orchestrator/rules/route.ts +5 -5
- package/components/Devices/PasskeysSection.tsx +4 -2
- package/lib/db/migrations-legacy-checksums.json +90 -0
- package/lib/db/migrations.ts +97 -23
- package/lib/orchestrator/passkey-asks.ts +14 -0
- package/lib/orchestrator/rebase.ts +8 -1
- package/lib/orchestrator/repo.ts +5 -9
- package/lib/security/device-key-message.ts +27 -0
- package/lib/security/device-keys.ts +75 -0
- package/lib/security/passkeys.ts +11 -4
- package/lib/security/presence-http.ts +49 -1
- package/lib/security/presence.ts +184 -0
- package/lib/security/soft-authenticator.ts +21 -0
- package/lib/sidebar/shelves.ts +10 -5
- package/lib/tasks/branch-worktree.ts +5 -6
- package/lib/tasks/move-guard.ts +3 -0
- package/lib/worktree-add.ts +91 -0
- package/lib/worktrees.ts +6 -3
- package/package.json +1 -1
package/lib/db/migrations.ts
CHANGED
|
@@ -10,14 +10,43 @@ import {
|
|
|
10
10
|
import type Database from "better-sqlite3";
|
|
11
11
|
import { moveSchedulesToRecipes } from "./schedules-move";
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
// HOW TO ADD A MIGRATION: append an object with a timestamp `key`, never an
|
|
14
|
+
// integer `id`: `key: "YYYYMMDDTHHMM_snake_name"` (UTC, e.g.
|
|
15
|
+
// "20261014T0130_share_links"). Hand-picked numbers collide when two tasks run
|
|
16
|
+
// in parallel and the loser is silently skipped on databases that already
|
|
17
|
+
// applied the winner. Ids 1..88 are legacy: they run first, by number, and
|
|
18
|
+
// never change. Timestamp keys run after them, in lexical order. Never edit a
|
|
19
|
+
// migration that has shipped; add a new one. lib/db/migrations.test.ts
|
|
20
|
+
// enforces all of this.
|
|
21
|
+
interface LegacyMigration {
|
|
14
22
|
id: number;
|
|
23
|
+
key?: undefined;
|
|
15
24
|
name: string;
|
|
16
25
|
up: (db: Database.Database) => void;
|
|
17
26
|
}
|
|
27
|
+
interface KeyedMigration {
|
|
28
|
+
key: string;
|
|
29
|
+
id?: undefined;
|
|
30
|
+
name?: undefined;
|
|
31
|
+
up: (db: Database.Database) => void;
|
|
32
|
+
}
|
|
33
|
+
export type Migration = LegacyMigration | KeyedMigration;
|
|
34
|
+
|
|
35
|
+
export const migrationKey = (m: Migration): string => m.key ?? String(m.id);
|
|
36
|
+
const migrationName = (m: Migration): string =>
|
|
37
|
+
m.name ?? (m.key as string).replace(/^\d{8}T\d{4}_/, "");
|
|
38
|
+
|
|
39
|
+
// Legacy ids sort before every timestamp key.
|
|
40
|
+
function compareToUpTo(m: Migration, upTo: number | string): number {
|
|
41
|
+
if (typeof upTo === "number") {
|
|
42
|
+
return m.id === undefined ? 1 : m.id - upTo;
|
|
43
|
+
}
|
|
44
|
+
if (m.id !== undefined) return -1;
|
|
45
|
+
return m.key < upTo ? -1 : m.key > upTo ? 1 : 0;
|
|
46
|
+
}
|
|
18
47
|
|
|
19
48
|
// All migrations in order - never modify existing ones, only add new
|
|
20
|
-
const migrations: Migration[] = [
|
|
49
|
+
export const migrations: Migration[] = [
|
|
21
50
|
{
|
|
22
51
|
id: 1,
|
|
23
52
|
name: "add_group_path_to_sessions",
|
|
@@ -2137,12 +2166,34 @@ const migrations: Migration[] = [
|
|
|
2137
2166
|
`);
|
|
2138
2167
|
},
|
|
2139
2168
|
},
|
|
2169
|
+
{
|
|
2170
|
+
id: 89,
|
|
2171
|
+
name: "passkeys_device_keys",
|
|
2172
|
+
up: (db) => {
|
|
2173
|
+
// A paired device's own approval key (Secure Enclave, Face ID) sits in
|
|
2174
|
+
// the passkeys table beside the browser passkeys: type 'device', bound
|
|
2175
|
+
// to the device that made it. Additive; existing rows read as webauthn.
|
|
2176
|
+
const columns = db.prepare(`PRAGMA table_info(passkeys)`).all() as {
|
|
2177
|
+
name: string;
|
|
2178
|
+
}[];
|
|
2179
|
+
if (!columns.some((c) => c.name === "type"))
|
|
2180
|
+
db.exec(
|
|
2181
|
+
`ALTER TABLE passkeys ADD COLUMN type TEXT NOT NULL DEFAULT 'webauthn'`
|
|
2182
|
+
);
|
|
2183
|
+
if (!columns.some((c) => c.name === "device_id"))
|
|
2184
|
+
db.exec(`ALTER TABLE passkeys ADD COLUMN device_id TEXT`);
|
|
2185
|
+
db.exec(
|
|
2186
|
+
`CREATE INDEX IF NOT EXISTS idx_passkeys_device ON passkeys(device_id)`
|
|
2187
|
+
);
|
|
2188
|
+
},
|
|
2189
|
+
},
|
|
2140
2190
|
];
|
|
2141
2191
|
|
|
2142
|
-
// `upTo`: stop after this id
|
|
2192
|
+
// `upTo`: stop after this legacy id or timestamp key, inclusive (tests that
|
|
2193
|
+
// start from an older database).
|
|
2143
2194
|
export function runMigrations(
|
|
2144
2195
|
db: Database.Database,
|
|
2145
|
-
upTo
|
|
2196
|
+
upTo?: number | string
|
|
2146
2197
|
): void {
|
|
2147
2198
|
// Create migrations tracking table
|
|
2148
2199
|
db.exec(`
|
|
@@ -2152,31 +2203,59 @@ export function runMigrations(
|
|
|
2152
2203
|
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
2153
2204
|
)
|
|
2154
2205
|
`);
|
|
2206
|
+
// Timestamp-keyed rows get an auto id and carry their key here; legacy rows
|
|
2207
|
+
// (key NULL) keep meaning "applied" by their integer id.
|
|
2208
|
+
const cols = db.prepare(`PRAGMA table_info(_migrations)`).all() as {
|
|
2209
|
+
name: string;
|
|
2210
|
+
}[];
|
|
2211
|
+
if (!cols.some((c) => c.name === "key")) {
|
|
2212
|
+
try {
|
|
2213
|
+
db.exec(`ALTER TABLE _migrations ADD COLUMN key TEXT`);
|
|
2214
|
+
} catch (error) {
|
|
2215
|
+
if (!String(error).includes("duplicate column")) throw error;
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
db.exec(
|
|
2219
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS idx_migrations_key ON _migrations(key)`
|
|
2220
|
+
);
|
|
2155
2221
|
|
|
2156
|
-
// Get already applied migrations
|
|
2157
2222
|
const applied = new Set(
|
|
2158
|
-
(
|
|
2159
|
-
(
|
|
2160
|
-
|
|
2223
|
+
(
|
|
2224
|
+
db.prepare(`SELECT id, key FROM _migrations`).all() as {
|
|
2225
|
+
id: number;
|
|
2226
|
+
key: string | null;
|
|
2227
|
+
}[]
|
|
2228
|
+
).map((r) => r.key ?? String(r.id))
|
|
2161
2229
|
);
|
|
2162
2230
|
|
|
2163
2231
|
// Use INSERT OR IGNORE to handle concurrent workers
|
|
2164
|
-
const
|
|
2232
|
+
const insertLegacy = db.prepare(
|
|
2165
2233
|
`INSERT OR IGNORE INTO _migrations (id, name) VALUES (?, ?)`
|
|
2166
2234
|
);
|
|
2235
|
+
const insertKeyed = db.prepare(
|
|
2236
|
+
`INSERT OR IGNORE INTO _migrations (key, name) VALUES (?, ?)`
|
|
2237
|
+
);
|
|
2238
|
+
const record = (m: Migration) =>
|
|
2239
|
+
m.key === undefined
|
|
2240
|
+
? insertLegacy.run(m.id, m.name)
|
|
2241
|
+
: insertKeyed.run(m.key, migrationName(m));
|
|
2167
2242
|
|
|
2168
2243
|
for (const migration of migrations) {
|
|
2169
|
-
|
|
2244
|
+
const key = migrationKey(migration);
|
|
2245
|
+
const name = migrationName(migration);
|
|
2246
|
+
if (
|
|
2247
|
+
applied.has(key) ||
|
|
2248
|
+
(upTo !== undefined && compareToUpTo(migration, upTo) > 0)
|
|
2249
|
+
)
|
|
2250
|
+
continue;
|
|
2170
2251
|
|
|
2171
2252
|
try {
|
|
2172
2253
|
migration.up(db);
|
|
2173
|
-
const result =
|
|
2254
|
+
const result = record(migration);
|
|
2174
2255
|
if (result.changes > 0) {
|
|
2175
|
-
console.log(`Migration ${
|
|
2256
|
+
console.log(`Migration ${key}: ${name} applied`);
|
|
2176
2257
|
} else {
|
|
2177
|
-
console.log(
|
|
2178
|
-
`Migration ${migration.id}: ${migration.name} skipped (concurrent apply)`
|
|
2179
|
-
);
|
|
2258
|
+
console.log(`Migration ${key}: ${name} skipped (concurrent apply)`);
|
|
2180
2259
|
}
|
|
2181
2260
|
} catch (error) {
|
|
2182
2261
|
// Some migrations may fail if columns already exist (from old system or concurrent worker)
|
|
@@ -2186,15 +2265,10 @@ export function runMigrations(
|
|
|
2186
2265
|
errorMsg.includes("duplicate column") ||
|
|
2187
2266
|
errorMsg.includes("already exists")
|
|
2188
2267
|
) {
|
|
2189
|
-
|
|
2190
|
-
console.log(
|
|
2191
|
-
`Migration ${migration.id}: ${migration.name} skipped (already applied)`
|
|
2192
|
-
);
|
|
2268
|
+
record(migration);
|
|
2269
|
+
console.log(`Migration ${key}: ${name} skipped (already applied)`);
|
|
2193
2270
|
} else {
|
|
2194
|
-
console.error(
|
|
2195
|
-
`Migration ${migration.id}: ${migration.name} failed:`,
|
|
2196
|
-
error
|
|
2197
|
-
);
|
|
2271
|
+
console.error(`Migration ${key}: ${name} failed:`, error);
|
|
2198
2272
|
throw error;
|
|
2199
2273
|
}
|
|
2200
2274
|
}
|
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
// Declining a new passkey's ask revokes it.
|
|
4
4
|
|
|
5
5
|
import type { PasskeyRow } from "../security/passkeys";
|
|
6
|
+
import { revokeDeviceKeys } from "../security/device-keys";
|
|
7
|
+
import { revokeDevice } from "../security/devices";
|
|
6
8
|
import {
|
|
7
9
|
AskRefused,
|
|
10
|
+
resolvePasskeyAsks,
|
|
8
11
|
passkeySubject,
|
|
9
12
|
raiseAsk,
|
|
10
13
|
revokedPasskeySubject,
|
|
@@ -54,3 +57,14 @@ export function raisePasskeyAsks(
|
|
|
54
57
|
}
|
|
55
58
|
return n;
|
|
56
59
|
}
|
|
60
|
+
|
|
61
|
+
// Signing a device out takes its approval key with it, and the revoke goes
|
|
62
|
+
// on the asks list like any other.
|
|
63
|
+
export function revokeDeviceAndKeys(deviceId: string, place: string): boolean {
|
|
64
|
+
const changed = revokeDevice(deviceId);
|
|
65
|
+
for (const key of revokeDeviceKeys(deviceId)) {
|
|
66
|
+
resolvePasskeyAsks(key.id, "revoked");
|
|
67
|
+
raisePasskeyAsks(key, place, "revoked");
|
|
68
|
+
}
|
|
69
|
+
return changed;
|
|
70
|
+
}
|
|
@@ -29,6 +29,7 @@ import type { TaskPR } from "../tasks/state";
|
|
|
29
29
|
import { run } from "../tasks/gh";
|
|
30
30
|
import { isAllowedBase } from "../tasks/session";
|
|
31
31
|
import { git as runGit, gitFetch, revParse } from "../git-exec";
|
|
32
|
+
import { addWorktree } from "../worktree-add";
|
|
32
33
|
import { comparePatches } from "./patch";
|
|
33
34
|
import { outputOf, type Runner } from "../stacks/git";
|
|
34
35
|
import { restackedHeads, stackNotice } from "../stacks/restack";
|
|
@@ -289,7 +290,13 @@ async function rebaseAt(
|
|
|
289
290
|
): Promise<{ sha: string }> {
|
|
290
291
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "aos-rebase-"));
|
|
291
292
|
try {
|
|
292
|
-
await
|
|
293
|
+
await addWorktree(
|
|
294
|
+
(args, cwd) => deps.runner("git", args, cwd),
|
|
295
|
+
repo,
|
|
296
|
+
dir,
|
|
297
|
+
head,
|
|
298
|
+
["--detach"]
|
|
299
|
+
);
|
|
293
300
|
try {
|
|
294
301
|
await deps.runner("git", ["rebase", onto], dir);
|
|
295
302
|
} catch (error) {
|
package/lib/orchestrator/repo.ts
CHANGED
|
@@ -9,15 +9,9 @@ import type { Session } from "../db";
|
|
|
9
9
|
import { SKILL_DIRS } from "../agents/skill-dirs";
|
|
10
10
|
import { getDefaultBranch } from "../git";
|
|
11
11
|
import { getProject } from "../projects";
|
|
12
|
-
import {
|
|
13
|
-
git,
|
|
14
|
-
gitFetch,
|
|
15
|
-
gitOk,
|
|
16
|
-
isGitError,
|
|
17
|
-
worktreeAdd,
|
|
18
|
-
worktreeRemove,
|
|
19
|
-
} from "../git-exec";
|
|
12
|
+
import { git, gitFetch, gitOk, isGitError, worktreeRemove } from "../git-exec";
|
|
20
13
|
import { expandHome } from "../tasks/session";
|
|
14
|
+
import { addWorktree } from "../worktree-add";
|
|
21
15
|
|
|
22
16
|
export function repoOf(task: Session): string {
|
|
23
17
|
const project = task.project_id ? getProject(task.project_id) : null;
|
|
@@ -169,7 +163,9 @@ export async function checkout(repo: string, sha: string): Promise<string> {
|
|
|
169
163
|
const dir = fs.realpathSync(
|
|
170
164
|
fs.mkdtempSync(path.join(os.tmpdir(), "aos-review-"))
|
|
171
165
|
);
|
|
172
|
-
await
|
|
166
|
+
await addWorktree((args, cwd) => git(cwd, args), repo, dir, sha, [
|
|
167
|
+
"--detach",
|
|
168
|
+
]);
|
|
173
169
|
const staged = await git(dir, ["ls-files", "-s"]);
|
|
174
170
|
for (const line of staged.split("\n"))
|
|
175
171
|
if (line.startsWith("120000 "))
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// What a paired device signs to prove a person is there. Client-safe (no
|
|
2
|
+
// database, no node modules), so the app builds the same bytes the server
|
|
3
|
+
// checks. The server rebuilds it from its own record of the challenge; what
|
|
4
|
+
// the client says it signed is never used.
|
|
5
|
+
|
|
6
|
+
export const DEVICE_KEY_DOMAIN = "AgentOS device approval v1";
|
|
7
|
+
|
|
8
|
+
export interface DeviceKeyMessage {
|
|
9
|
+
challenge: string;
|
|
10
|
+
purpose: string;
|
|
11
|
+
binding: string;
|
|
12
|
+
deviceId: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function deviceKeyMessage(m: DeviceKeyMessage): string {
|
|
16
|
+
return [
|
|
17
|
+
DEVICE_KEY_DOMAIN,
|
|
18
|
+
m.challenge,
|
|
19
|
+
m.purpose,
|
|
20
|
+
m.binding,
|
|
21
|
+
m.deviceId,
|
|
22
|
+
].join("\n");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// A device key's relying-party id in the passkeys table. It can never equal
|
|
26
|
+
// a host name, so the browser flow never sees these keys.
|
|
27
|
+
export const deviceRp = (deviceId: string) => `device:${deviceId}`;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A paired device's approval key: P-256, made in the phone's Secure Enclave
|
|
3
|
+
* and unlocked by Face ID, Touch ID or the passcode. Only the public key is
|
|
4
|
+
* kept, in the passkeys table (type 'device', rp_id device:<id>).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import crypto from "crypto";
|
|
8
|
+
import { getDb } from "@/lib/db";
|
|
9
|
+
import type { PasskeyRow } from "./passkeys";
|
|
10
|
+
|
|
11
|
+
// The fixed prefix of a P-256 SubjectPublicKeyInfo; the 65-byte X9.63 point
|
|
12
|
+
// follows it.
|
|
13
|
+
const SPKI_P256_PREFIX = Buffer.from(
|
|
14
|
+
"3059301306072a8648ce3d020106082a8648ce3d030107034200",
|
|
15
|
+
"hex"
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
export const newDeviceKeyId = () =>
|
|
19
|
+
crypto.randomBytes(16).toString("base64url");
|
|
20
|
+
|
|
21
|
+
// The app sends the key as an uncompressed X9.63 point. Throws unless it's a
|
|
22
|
+
// real point on the curve.
|
|
23
|
+
export function spkiFromRawP256(raw: Buffer): Buffer {
|
|
24
|
+
if (raw.length !== 65 || raw[0] !== 0x04)
|
|
25
|
+
throw new Error("Not a P-256 public key");
|
|
26
|
+
const spki = Buffer.concat([SPKI_P256_PREFIX, raw]);
|
|
27
|
+
crypto.createPublicKey({ key: spki, format: "der", type: "spki" });
|
|
28
|
+
return spki;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// iOS signs with ecdsaSignatureMessageX962SHA256, which is DER.
|
|
32
|
+
export function verifyDeviceSignature(
|
|
33
|
+
spki: Buffer,
|
|
34
|
+
message: string,
|
|
35
|
+
signature: Buffer
|
|
36
|
+
): boolean {
|
|
37
|
+
try {
|
|
38
|
+
return crypto.verify(
|
|
39
|
+
"sha256",
|
|
40
|
+
Buffer.from(message),
|
|
41
|
+
{ key: spki, format: "der", type: "spki", dsaEncoding: "der" },
|
|
42
|
+
signature
|
|
43
|
+
);
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function activeDeviceKey(deviceId: string): PasskeyRow | null {
|
|
50
|
+
return (
|
|
51
|
+
(getDb()
|
|
52
|
+
.prepare(
|
|
53
|
+
`SELECT * FROM passkeys WHERE type = 'device' AND device_id = ? AND revoked_at IS NULL`
|
|
54
|
+
)
|
|
55
|
+
.get(deviceId) as PasskeyRow | undefined) ?? null
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Revokes every active key of this device; returns them so the caller can
|
|
60
|
+
// raise the "revoked" asks.
|
|
61
|
+
export function revokeDeviceKeys(deviceId: string): PasskeyRow[] {
|
|
62
|
+
const keys = getDb()
|
|
63
|
+
.prepare(
|
|
64
|
+
`SELECT * FROM passkeys WHERE type = 'device' AND device_id = ? AND revoked_at IS NULL`
|
|
65
|
+
)
|
|
66
|
+
.all(deviceId) as PasskeyRow[];
|
|
67
|
+
if (keys.length)
|
|
68
|
+
getDb()
|
|
69
|
+
.prepare(
|
|
70
|
+
`UPDATE passkeys SET revoked_at = datetime('now')
|
|
71
|
+
WHERE type = 'device' AND device_id = ? AND revoked_at IS NULL`
|
|
72
|
+
)
|
|
73
|
+
.run(deviceId);
|
|
74
|
+
return keys;
|
|
75
|
+
}
|
package/lib/security/passkeys.ts
CHANGED
|
@@ -19,12 +19,15 @@ export interface PasskeyRow {
|
|
|
19
19
|
created_at: string;
|
|
20
20
|
last_used_at: string | null;
|
|
21
21
|
revoked_at: string | null;
|
|
22
|
+
// 'webauthn' (a browser passkey) or 'device' (a paired device's own key).
|
|
23
|
+
type: "webauthn" | "device";
|
|
24
|
+
device_id: string | null;
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
export type PasskeyView = Omit<PasskeyRow, "public_key" | "counter">;
|
|
25
28
|
|
|
26
29
|
const VIEW = `id, transports, rp_id, name, registered_via, registered_from,
|
|
27
|
-
user_agent, created_at, last_used_at, revoked_at`;
|
|
30
|
+
user_agent, created_at, last_used_at, revoked_at, type, device_id`;
|
|
28
31
|
|
|
29
32
|
export function listPasskeys(): PasskeyView[] {
|
|
30
33
|
return getDb()
|
|
@@ -66,12 +69,14 @@ export function addPasskey(p: {
|
|
|
66
69
|
via: string;
|
|
67
70
|
from: string | null;
|
|
68
71
|
userAgent: string | null;
|
|
72
|
+
type?: "webauthn" | "device";
|
|
73
|
+
deviceId?: string | null;
|
|
69
74
|
}): PasskeyRow {
|
|
70
75
|
getDb()
|
|
71
76
|
.prepare(
|
|
72
77
|
`INSERT INTO passkeys (id, public_key, counter, transports, rp_id, name,
|
|
73
|
-
registered_via, registered_from, user_agent)
|
|
74
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
78
|
+
registered_via, registered_from, user_agent, type, device_id)
|
|
79
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
75
80
|
)
|
|
76
81
|
.run(
|
|
77
82
|
p.id,
|
|
@@ -82,7 +87,9 @@ export function addPasskey(p: {
|
|
|
82
87
|
p.name.trim().slice(0, 80) || "Passkey",
|
|
83
88
|
p.via,
|
|
84
89
|
p.from,
|
|
85
|
-
p.userAgent?.slice(0, 300) ?? null
|
|
90
|
+
p.userAgent?.slice(0, 300) ?? null,
|
|
91
|
+
p.type ?? "webauthn",
|
|
92
|
+
p.deviceId ?? null
|
|
86
93
|
);
|
|
87
94
|
return getPasskey(p.id)!;
|
|
88
95
|
}
|
|
@@ -5,7 +5,17 @@ import { NextResponse } from "next/server";
|
|
|
5
5
|
import { REMOTE_HEADER } from "./auth";
|
|
6
6
|
import type { Approver } from "./approver";
|
|
7
7
|
import { getDevice } from "./devices";
|
|
8
|
-
import {
|
|
8
|
+
import type { AuthenticationResponseJSON } from "@simplewebauthn/server";
|
|
9
|
+
import type { NextRequest } from "next/server";
|
|
10
|
+
import type { PasskeyRow } from "./passkeys";
|
|
11
|
+
import {
|
|
12
|
+
PresenceError,
|
|
13
|
+
relyingParty,
|
|
14
|
+
verifyDevicePresence,
|
|
15
|
+
verifyPresence,
|
|
16
|
+
type DeviceSignature,
|
|
17
|
+
type PresencePurpose,
|
|
18
|
+
} from "./presence";
|
|
9
19
|
|
|
10
20
|
export function placeOf(approver: Approver, headers: Headers): string {
|
|
11
21
|
if (approver.via === "loopback") return "this machine";
|
|
@@ -14,6 +24,44 @@ export function placeOf(approver: Approver, headers: Headers): string {
|
|
|
14
24
|
return getDevice(approver.deviceId ?? "")?.name ?? "a paired device";
|
|
15
25
|
}
|
|
16
26
|
|
|
27
|
+
// What a request sends to prove a person: a browser's passkey assertion, or
|
|
28
|
+
// a paired device's signature from its own key.
|
|
29
|
+
export interface PresenceProof {
|
|
30
|
+
assertion?: AuthenticationResponseJSON;
|
|
31
|
+
deviceSignature?: DeviceSignature;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A device signature counts only from the device whose key it is; anything
|
|
35
|
+
// else goes through the browser passkey check as before.
|
|
36
|
+
export function requirePresence(
|
|
37
|
+
request: NextRequest,
|
|
38
|
+
approver: Approver,
|
|
39
|
+
proof: PresenceProof | null | undefined,
|
|
40
|
+
purpose: PresencePurpose,
|
|
41
|
+
binding: string
|
|
42
|
+
): Promise<PasskeyRow> {
|
|
43
|
+
if (proof?.deviceSignature) {
|
|
44
|
+
if (approver.via !== "device" || !approver.deviceId)
|
|
45
|
+
return Promise.reject(
|
|
46
|
+
new PresenceError("Only a paired device can sign with its own key")
|
|
47
|
+
);
|
|
48
|
+
return Promise.resolve().then(() =>
|
|
49
|
+
verifyDevicePresence(
|
|
50
|
+
approver.deviceId!,
|
|
51
|
+
proof.deviceSignature,
|
|
52
|
+
purpose,
|
|
53
|
+
binding
|
|
54
|
+
)
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return verifyPresence(
|
|
58
|
+
relyingParty(request.headers),
|
|
59
|
+
proof?.assertion,
|
|
60
|
+
purpose,
|
|
61
|
+
binding
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
17
65
|
export function refusalResponse(error: unknown): NextResponse {
|
|
18
66
|
if (error instanceof PresenceError)
|
|
19
67
|
return NextResponse.json(
|
package/lib/security/presence.ts
CHANGED
|
@@ -23,6 +23,14 @@ import {
|
|
|
23
23
|
type RegistrationResponseJSON,
|
|
24
24
|
} from "@simplewebauthn/server";
|
|
25
25
|
import { getDb } from "@/lib/db";
|
|
26
|
+
import { deviceKeyMessage, deviceRp } from "./device-key-message";
|
|
27
|
+
import {
|
|
28
|
+
activeDeviceKey,
|
|
29
|
+
newDeviceKeyId,
|
|
30
|
+
revokeDeviceKeys,
|
|
31
|
+
spkiFromRawP256,
|
|
32
|
+
verifyDeviceSignature,
|
|
33
|
+
} from "./device-keys";
|
|
26
34
|
import {
|
|
27
35
|
activePasskeys,
|
|
28
36
|
markBootstrapped,
|
|
@@ -319,3 +327,179 @@ export async function verifyRegistration(
|
|
|
319
327
|
});
|
|
320
328
|
})();
|
|
321
329
|
}
|
|
330
|
+
|
|
331
|
+
// ---- A paired device's own key (Secure Enclave, Face ID) ----
|
|
332
|
+
|
|
333
|
+
export interface DeviceSignature {
|
|
334
|
+
keyId: string;
|
|
335
|
+
challenge: string;
|
|
336
|
+
signature: string;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export interface DeviceOptions {
|
|
340
|
+
challenge: string;
|
|
341
|
+
keyId: string;
|
|
342
|
+
message: string;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const newChallenge = () => crypto.randomBytes(32).toString("base64url");
|
|
346
|
+
|
|
347
|
+
// Options for a device-key assertion, or null when the device has no key.
|
|
348
|
+
export function deviceAssertionOptions(
|
|
349
|
+
deviceId: string,
|
|
350
|
+
purpose: PresencePurpose,
|
|
351
|
+
binding: string
|
|
352
|
+
): DeviceOptions | null {
|
|
353
|
+
const key = activeDeviceKey(deviceId);
|
|
354
|
+
if (!key) return null;
|
|
355
|
+
const challenge = newChallenge();
|
|
356
|
+
storeChallenge(challenge, "assert", purpose, binding, deviceRp(deviceId));
|
|
357
|
+
return {
|
|
358
|
+
challenge,
|
|
359
|
+
keyId: key.id,
|
|
360
|
+
message: deviceKeyMessage({ challenge, purpose, binding, deviceId }),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Checks a device's signature over exactly this challenge, purpose and
|
|
365
|
+
// binding, by that device's own active key. The message is rebuilt here
|
|
366
|
+
// from what the server issued.
|
|
367
|
+
export function verifyDevicePresence(
|
|
368
|
+
deviceId: string,
|
|
369
|
+
proof: DeviceSignature | null | undefined,
|
|
370
|
+
purpose: PresencePurpose,
|
|
371
|
+
binding: string
|
|
372
|
+
): PasskeyRow {
|
|
373
|
+
if (!proof?.keyId || !proof.challenge || !proof.signature)
|
|
374
|
+
throw new PresenceError("This needs your Face ID or Touch ID");
|
|
375
|
+
const key = getPasskey(proof.keyId);
|
|
376
|
+
if (
|
|
377
|
+
!key ||
|
|
378
|
+
key.type !== "device" ||
|
|
379
|
+
key.revoked_at ||
|
|
380
|
+
key.device_id !== deviceId
|
|
381
|
+
)
|
|
382
|
+
throw new PresenceError("Unknown passkey");
|
|
383
|
+
// Spent before the signature is checked: one try per challenge.
|
|
384
|
+
takeChallenge(
|
|
385
|
+
proof.challenge,
|
|
386
|
+
"assert",
|
|
387
|
+
purpose,
|
|
388
|
+
binding,
|
|
389
|
+
deviceRp(deviceId)
|
|
390
|
+
);
|
|
391
|
+
const message = deviceKeyMessage({
|
|
392
|
+
challenge: proof.challenge,
|
|
393
|
+
purpose,
|
|
394
|
+
binding,
|
|
395
|
+
deviceId,
|
|
396
|
+
});
|
|
397
|
+
if (
|
|
398
|
+
!verifyDeviceSignature(
|
|
399
|
+
key.public_key,
|
|
400
|
+
message,
|
|
401
|
+
Buffer.from(proof.signature, "base64url")
|
|
402
|
+
)
|
|
403
|
+
)
|
|
404
|
+
throw new PresenceError("Passkey check failed");
|
|
405
|
+
touchPasskey(key.id, key.counter);
|
|
406
|
+
return key;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Options for registering this device's key: a challenge the new key signs.
|
|
410
|
+
// Needs the same trust as a browser passkey (the free first one, or a code).
|
|
411
|
+
export function deviceRegistrationOptions(
|
|
412
|
+
deviceId: string,
|
|
413
|
+
enrollCode?: string
|
|
414
|
+
): { challenge: string; message: string } {
|
|
415
|
+
const binding = registrationBinding(enrollCode);
|
|
416
|
+
const challenge = newChallenge();
|
|
417
|
+
storeChallenge(
|
|
418
|
+
challenge,
|
|
419
|
+
"register",
|
|
420
|
+
"register",
|
|
421
|
+
binding,
|
|
422
|
+
deviceRp(deviceId)
|
|
423
|
+
);
|
|
424
|
+
return {
|
|
425
|
+
challenge,
|
|
426
|
+
message: deviceKeyMessage({
|
|
427
|
+
challenge,
|
|
428
|
+
purpose: "register",
|
|
429
|
+
binding,
|
|
430
|
+
deviceId,
|
|
431
|
+
}),
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export function verifyDeviceRegistration(
|
|
436
|
+
deviceId: string,
|
|
437
|
+
body: {
|
|
438
|
+
publicKey: string;
|
|
439
|
+
challenge: string;
|
|
440
|
+
signature: string;
|
|
441
|
+
name: string;
|
|
442
|
+
hardware?: string;
|
|
443
|
+
},
|
|
444
|
+
meta: { via: string; from: string | null; userAgent: string | null },
|
|
445
|
+
enrollCode?: string
|
|
446
|
+
): { key: PasskeyRow; replaced: PasskeyRow[] } {
|
|
447
|
+
const binding = registrationBinding(enrollCode);
|
|
448
|
+
let spki: Buffer;
|
|
449
|
+
try {
|
|
450
|
+
spki = spkiFromRawP256(Buffer.from(body.publicKey, "base64url"));
|
|
451
|
+
} catch {
|
|
452
|
+
throw new PresenceError("Not a usable key");
|
|
453
|
+
}
|
|
454
|
+
const message = deviceKeyMessage({
|
|
455
|
+
challenge: body.challenge,
|
|
456
|
+
purpose: "register",
|
|
457
|
+
binding,
|
|
458
|
+
deviceId,
|
|
459
|
+
});
|
|
460
|
+
if (
|
|
461
|
+
!verifyDeviceSignature(
|
|
462
|
+
spki,
|
|
463
|
+
message,
|
|
464
|
+
Buffer.from(body.signature, "base64url")
|
|
465
|
+
)
|
|
466
|
+
)
|
|
467
|
+
throw new PresenceError("The new key couldn't sign the request");
|
|
468
|
+
return getDb().transaction(() => {
|
|
469
|
+
takeChallenge(
|
|
470
|
+
body.challenge,
|
|
471
|
+
"register",
|
|
472
|
+
"register",
|
|
473
|
+
binding,
|
|
474
|
+
deviceRp(deviceId)
|
|
475
|
+
);
|
|
476
|
+
if (binding === "bootstrap") {
|
|
477
|
+
if (passkeysBootstrapped())
|
|
478
|
+
throw new PresenceError(
|
|
479
|
+
"A passkey was added meanwhile; get a code from it"
|
|
480
|
+
);
|
|
481
|
+
markBootstrapped();
|
|
482
|
+
} else {
|
|
483
|
+
const used = getDb()
|
|
484
|
+
.prepare(
|
|
485
|
+
`UPDATE passkey_enrollments SET used_at = ? WHERE code_hash = ? AND used_at IS NULL`
|
|
486
|
+
)
|
|
487
|
+
.run(now(), hash(enrollCode!)).changes;
|
|
488
|
+
if (used !== 1) throw new PresenceError("That code was already used");
|
|
489
|
+
}
|
|
490
|
+
// A reinstall makes a new key; the old one stops counting.
|
|
491
|
+
const replaced = revokeDeviceKeys(deviceId);
|
|
492
|
+
const hardware = body.hardware === "software" ? " (software key)" : "";
|
|
493
|
+
const key = addPasskey({
|
|
494
|
+
id: newDeviceKeyId(),
|
|
495
|
+
publicKey: spki,
|
|
496
|
+
counter: 0,
|
|
497
|
+
rpId: deviceRp(deviceId),
|
|
498
|
+
name: `${body.name.trim().slice(0, 60) || "Device"}${hardware}`,
|
|
499
|
+
type: "device",
|
|
500
|
+
deviceId,
|
|
501
|
+
...meta,
|
|
502
|
+
});
|
|
503
|
+
return { key, replaced };
|
|
504
|
+
})();
|
|
505
|
+
}
|
|
@@ -81,3 +81,24 @@ export function softAuthenticator(rpId = "localhost") {
|
|
|
81
81
|
},
|
|
82
82
|
};
|
|
83
83
|
}
|
|
84
|
+
|
|
85
|
+
// Tests only: a software stand-in for the app's Secure Enclave key. The
|
|
86
|
+
// public key is the uncompressed X9.63 point the app sends; sign() gives a
|
|
87
|
+
// DER signature, as iOS does.
|
|
88
|
+
export function softDeviceKey() {
|
|
89
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync("ec", {
|
|
90
|
+
namedCurve: "P-256",
|
|
91
|
+
});
|
|
92
|
+
const jwk = publicKey.export({ format: "jwk" }) as { x: string; y: string };
|
|
93
|
+
return {
|
|
94
|
+
publicKey: b64url(
|
|
95
|
+
Buffer.concat([
|
|
96
|
+
Buffer.from([0x04]),
|
|
97
|
+
Buffer.from(jwk.x, "base64url"),
|
|
98
|
+
Buffer.from(jwk.y, "base64url"),
|
|
99
|
+
])
|
|
100
|
+
),
|
|
101
|
+
sign: (message: string) =>
|
|
102
|
+
b64url(crypto.sign("sha256", Buffer.from(message), privateKey)),
|
|
103
|
+
};
|
|
104
|
+
}
|