@saadnvd1/agent-os 0.3.561 → 0.3.564
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/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/package.json +1 -1
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
revokeDevice,
|
|
5
|
-
setDeviceCanApprove,
|
|
6
|
-
} from "@/lib/security/devices";
|
|
2
|
+
import { renameDevice, setDeviceCanApprove } from "@/lib/security/devices";
|
|
3
|
+
import { revokeDeviceAndKeys } from "@/lib/orchestrator/passkey-asks";
|
|
7
4
|
import { requireLocalTrust } from "@/lib/security/route-guard";
|
|
8
5
|
import { DEVICE_HEADER } from "@/lib/security/auth";
|
|
9
6
|
import { demoMode } from "@/lib/security/demo";
|
|
@@ -43,7 +40,7 @@ export async function DELETE(request: NextRequest, { params }: Ctx) {
|
|
|
43
40
|
{ error: "Not available in the demo." },
|
|
44
41
|
{ status: 403 }
|
|
45
42
|
);
|
|
46
|
-
if (!
|
|
43
|
+
if (!revokeDeviceAndKeys(id, "Devices"))
|
|
47
44
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
48
45
|
return NextResponse.json({ ok: true });
|
|
49
46
|
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
-
import {
|
|
2
|
+
import { revokeDeviceAndKeys } from "@/lib/orchestrator/passkey-asks";
|
|
3
3
|
import { callingDevice, needsDevice } from "@/lib/security/device-me";
|
|
4
4
|
|
|
5
5
|
// DELETE /api/devices/me - the device signs itself out ("Forget machine")
|
|
6
6
|
export async function DELETE(request: NextRequest) {
|
|
7
7
|
const id = callingDevice(request);
|
|
8
8
|
if (!id) return needsDevice();
|
|
9
|
-
if (!
|
|
9
|
+
if (!revokeDeviceAndKeys(id, "the device itself"))
|
|
10
10
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
11
11
|
return NextResponse.json({ ok: true });
|
|
12
12
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { requireApprover } from "@/lib/security/approver";
|
|
3
|
+
import {
|
|
4
|
+
deviceRegistrationOptions,
|
|
5
|
+
PresenceError,
|
|
6
|
+
} from "@/lib/security/presence";
|
|
7
|
+
import { refusalResponse } from "@/lib/security/presence-http";
|
|
8
|
+
import { passkeysBootstrapped } from "@/lib/security/passkeys";
|
|
9
|
+
|
|
10
|
+
// A challenge for this paired device's new approval key to sign. The key is
|
|
11
|
+
// the device's own, so only a paired device asks; with passkeys already set
|
|
12
|
+
// up, it needs a code from one of them, like a browser passkey does.
|
|
13
|
+
export async function POST(request: NextRequest) {
|
|
14
|
+
const gate = requireApprover(request);
|
|
15
|
+
if (!gate.ok) return gate.response;
|
|
16
|
+
if (gate.approver.via !== "device" || !gate.approver.deviceId)
|
|
17
|
+
return NextResponse.json(
|
|
18
|
+
{ error: "Only a paired device has its own key" },
|
|
19
|
+
{ status: 403 }
|
|
20
|
+
);
|
|
21
|
+
try {
|
|
22
|
+
const { enrollCode } = (await request.json().catch(() => ({}))) as {
|
|
23
|
+
enrollCode?: string;
|
|
24
|
+
};
|
|
25
|
+
return NextResponse.json(
|
|
26
|
+
deviceRegistrationOptions(gate.approver.deviceId, enrollCode)
|
|
27
|
+
);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (error instanceof PresenceError && error.code === "enroll")
|
|
30
|
+
return NextResponse.json({
|
|
31
|
+
needsCode: true,
|
|
32
|
+
canBootstrap: !passkeysBootstrapped(),
|
|
33
|
+
});
|
|
34
|
+
return refusalResponse(error);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { requireApprover } from "@/lib/security/approver";
|
|
3
|
+
import { verifyDeviceRegistration } from "@/lib/security/presence";
|
|
4
|
+
import { placeOf, refusalResponse } from "@/lib/security/presence-http";
|
|
5
|
+
import { raisePasskeyAsks } from "@/lib/orchestrator/passkey-asks";
|
|
6
|
+
import { resolvePasskeyAsks } from "@/lib/orchestrator/asks";
|
|
7
|
+
|
|
8
|
+
// Stores the paired device's own approval key, and puts it on Saad's asks
|
|
9
|
+
// list to confirm. A key it replaces (a reinstall) is revoked and noted.
|
|
10
|
+
export async function POST(request: NextRequest) {
|
|
11
|
+
const gate = requireApprover(request);
|
|
12
|
+
if (!gate.ok) return gate.response;
|
|
13
|
+
if (gate.approver.via !== "device" || !gate.approver.deviceId)
|
|
14
|
+
return NextResponse.json(
|
|
15
|
+
{ error: "Only a paired device has its own key" },
|
|
16
|
+
{ status: 403 }
|
|
17
|
+
);
|
|
18
|
+
try {
|
|
19
|
+
const body = (await request.json().catch(() => ({}))) as {
|
|
20
|
+
publicKey?: string;
|
|
21
|
+
challenge?: string;
|
|
22
|
+
signature?: string;
|
|
23
|
+
name?: string;
|
|
24
|
+
hardware?: string;
|
|
25
|
+
enrollCode?: string;
|
|
26
|
+
};
|
|
27
|
+
if (!body.publicKey || !body.challenge || !body.signature)
|
|
28
|
+
return NextResponse.json({ error: "No key" }, { status: 400 });
|
|
29
|
+
const place = placeOf(gate.approver, request.headers);
|
|
30
|
+
const { key, replaced } = verifyDeviceRegistration(
|
|
31
|
+
gate.approver.deviceId,
|
|
32
|
+
{
|
|
33
|
+
publicKey: body.publicKey,
|
|
34
|
+
challenge: body.challenge,
|
|
35
|
+
signature: body.signature,
|
|
36
|
+
name: body.name || place,
|
|
37
|
+
hardware: body.hardware,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
via: gate.approver.via,
|
|
41
|
+
from: place,
|
|
42
|
+
userAgent: request.headers.get("user-agent"),
|
|
43
|
+
},
|
|
44
|
+
body.enrollCode
|
|
45
|
+
);
|
|
46
|
+
for (const old of replaced) {
|
|
47
|
+
resolvePasskeyAsks(old.id, "replaced");
|
|
48
|
+
raisePasskeyAsks(old, place, "revoked");
|
|
49
|
+
}
|
|
50
|
+
raisePasskeyAsks(key, place);
|
|
51
|
+
return NextResponse.json({ passkey: { id: key.id, name: key.name } });
|
|
52
|
+
} catch (error) {
|
|
53
|
+
return refusalResponse(error);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
-
import type { AuthenticationResponseJSON } from "@simplewebauthn/server";
|
|
3
2
|
import { requireApprover } from "@/lib/security/approver";
|
|
3
|
+
import { mintEnrollCode } from "@/lib/security/presence";
|
|
4
4
|
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
} from "@/lib/security/presence";
|
|
9
|
-
import { refusalResponse } from "@/lib/security/presence-http";
|
|
5
|
+
refusalResponse,
|
|
6
|
+
requirePresence,
|
|
7
|
+
type PresenceProof,
|
|
8
|
+
} from "@/lib/security/presence-http";
|
|
10
9
|
|
|
11
10
|
// A one-time code (10 minutes) for adding a passkey on another device,
|
|
12
11
|
// minted only on an assertion from an existing passkey.
|
|
@@ -14,15 +13,8 @@ export async function POST(request: NextRequest) {
|
|
|
14
13
|
const gate = requireApprover(request);
|
|
15
14
|
if (!gate.ok) return gate.response;
|
|
16
15
|
try {
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
};
|
|
20
|
-
await verifyPresence(
|
|
21
|
-
relyingParty(request.headers),
|
|
22
|
-
assertion,
|
|
23
|
-
"enroll",
|
|
24
|
-
"enroll"
|
|
25
|
-
);
|
|
16
|
+
const body = (await request.json().catch(() => ({}))) as PresenceProof;
|
|
17
|
+
await requirePresence(request, gate.approver, body, "enroll", "enroll");
|
|
26
18
|
return NextResponse.json({ code: mintEnrollCode() });
|
|
27
19
|
} catch (error) {
|
|
28
20
|
return refusalResponse(error);
|
|
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|
|
2
2
|
import { requireApprover } from "@/lib/security/approver";
|
|
3
3
|
import {
|
|
4
4
|
assertionOptions,
|
|
5
|
+
deviceAssertionOptions,
|
|
5
6
|
relyingParty,
|
|
6
7
|
type PresencePurpose,
|
|
7
8
|
} from "@/lib/security/presence";
|
|
@@ -21,8 +22,27 @@ export async function POST(request: NextRequest) {
|
|
|
21
22
|
askId?: number;
|
|
22
23
|
binding?: string;
|
|
23
24
|
passkeyId?: string;
|
|
25
|
+
method?: "device";
|
|
24
26
|
};
|
|
25
27
|
const binding = presenceBinding(body);
|
|
28
|
+
if (body.method === "device") {
|
|
29
|
+
if (gate.approver.via !== "device" || !gate.approver.deviceId)
|
|
30
|
+
return NextResponse.json(
|
|
31
|
+
{ error: "Only a paired device has its own key" },
|
|
32
|
+
{ status: 403 }
|
|
33
|
+
);
|
|
34
|
+
const device = deviceAssertionOptions(
|
|
35
|
+
gate.approver.deviceId,
|
|
36
|
+
body.purpose!,
|
|
37
|
+
binding
|
|
38
|
+
);
|
|
39
|
+
if (!device)
|
|
40
|
+
return NextResponse.json({
|
|
41
|
+
needsDeviceKey: true,
|
|
42
|
+
canBootstrap: !passkeysBootstrapped(),
|
|
43
|
+
});
|
|
44
|
+
return NextResponse.json({ device });
|
|
45
|
+
}
|
|
26
46
|
const rp = relyingParty(request.headers);
|
|
27
47
|
const options = await assertionOptions(rp, body.purpose!, binding);
|
|
28
48
|
if (!options)
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
-
import type { AuthenticationResponseJSON } from "@simplewebauthn/server";
|
|
3
2
|
import { requireApprover } from "@/lib/security/approver";
|
|
4
3
|
import { getPasskey, revokePasskey } from "@/lib/security/passkeys";
|
|
5
|
-
import {
|
|
6
|
-
|
|
4
|
+
import {
|
|
5
|
+
placeOf,
|
|
6
|
+
refusalResponse,
|
|
7
|
+
requirePresence,
|
|
8
|
+
type PresenceProof,
|
|
9
|
+
} from "@/lib/security/presence-http";
|
|
7
10
|
import { raisePasskeyAsks } from "@/lib/orchestrator/passkey-asks";
|
|
8
11
|
import { revokePresence } from "@/lib/orchestrator/presence-binding";
|
|
9
12
|
import { resolvePasskeyAsks } from "@/lib/orchestrator/asks";
|
|
@@ -21,12 +24,11 @@ export async function DELETE(request: NextRequest, { params }: Ctx) {
|
|
|
21
24
|
const key = getPasskey(id);
|
|
22
25
|
if (!key || key.revoked_at)
|
|
23
26
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
assertion,
|
|
27
|
+
const body = (await request.json().catch(() => ({}))) as PresenceProof;
|
|
28
|
+
await requirePresence(
|
|
29
|
+
request,
|
|
30
|
+
gate.approver,
|
|
31
|
+
body,
|
|
30
32
|
"revoke",
|
|
31
33
|
revokePresence(id)
|
|
32
34
|
);
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
-
import type { AuthenticationResponseJSON } from "@simplewebauthn/server";
|
|
3
2
|
import { requireApprover } from "@/lib/security/approver";
|
|
4
|
-
import {
|
|
5
|
-
|
|
3
|
+
import {
|
|
4
|
+
placeOf,
|
|
5
|
+
refusalResponse,
|
|
6
|
+
requirePresence,
|
|
7
|
+
type PresenceProof,
|
|
8
|
+
} from "@/lib/security/presence-http";
|
|
6
9
|
import { getPasskey } from "@/lib/security/passkeys";
|
|
7
10
|
import { raisePasskeyAsks } from "@/lib/orchestrator/passkey-asks";
|
|
8
11
|
import {
|
|
@@ -40,10 +43,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|
|
40
43
|
: requireApprover(request);
|
|
41
44
|
if (!gate.ok) return gate.response;
|
|
42
45
|
const { id, askId } = await params;
|
|
43
|
-
const body = (await request.json().catch(() => null)) as
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
const body = (await request.json().catch(() => null)) as
|
|
47
|
+
| ({
|
|
48
|
+
binding?: string;
|
|
49
|
+
} & PresenceProof)
|
|
50
|
+
| null;
|
|
47
51
|
const parsed = parseAnswer(body);
|
|
48
52
|
// A demo keeps no visitor's words where the next visitor would read them.
|
|
49
53
|
const answer =
|
|
@@ -73,9 +77,10 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|
|
73
77
|
{ status: 400 }
|
|
74
78
|
);
|
|
75
79
|
if (!demo && PRESENCE_KINDS.includes(ask.kind))
|
|
76
|
-
await
|
|
77
|
-
|
|
78
|
-
|
|
80
|
+
await requirePresence(
|
|
81
|
+
request,
|
|
82
|
+
gate.approver,
|
|
83
|
+
body,
|
|
79
84
|
"approve",
|
|
80
85
|
askPresence(ask.id, body.binding)
|
|
81
86
|
);
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
-
import type { AuthenticationResponseJSON } from "@simplewebauthn/server";
|
|
3
2
|
import { getWorkspace } from "@/lib/workspaces";
|
|
4
3
|
import { setPaused } from "@/lib/orchestrator/pause";
|
|
5
4
|
import { requireApprover } from "@/lib/security/approver";
|
|
6
|
-
import {
|
|
7
|
-
|
|
5
|
+
import {
|
|
6
|
+
refusalResponse,
|
|
7
|
+
requirePresence,
|
|
8
|
+
type PresenceProof,
|
|
9
|
+
} from "@/lib/security/presence-http";
|
|
8
10
|
import { resumePresence } from "@/lib/orchestrator/presence-binding";
|
|
9
11
|
|
|
10
12
|
type RouteParams = { params: Promise<{ id: string }> };
|
|
@@ -18,10 +20,10 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|
|
18
20
|
const { id } = await params;
|
|
19
21
|
if (!getWorkspace(id))
|
|
20
22
|
return NextResponse.json({ error: "Workspace not found" }, { status: 404 });
|
|
21
|
-
const
|
|
23
|
+
const body = (await request.json().catch(() => ({}))) as {
|
|
22
24
|
paused?: unknown;
|
|
23
|
-
|
|
24
|
-
};
|
|
25
|
+
} & PresenceProof;
|
|
26
|
+
const { paused } = body;
|
|
25
27
|
if (typeof paused !== "boolean")
|
|
26
28
|
return NextResponse.json(
|
|
27
29
|
{ error: "paused must be true or false" },
|
|
@@ -29,9 +31,10 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|
|
29
31
|
);
|
|
30
32
|
try {
|
|
31
33
|
if (!paused)
|
|
32
|
-
await
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
await requirePresence(
|
|
35
|
+
request,
|
|
36
|
+
gate.approver,
|
|
37
|
+
body,
|
|
35
38
|
"resume",
|
|
36
39
|
resumePresence(id)
|
|
37
40
|
);
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
-
import type {
|
|
2
|
+
import type { PresenceProof } from "@/lib/security/presence-http";
|
|
3
3
|
import { removeRule, updateRule } from "@/lib/orchestrator/rules";
|
|
4
4
|
import { provenAsSaad } from "../presence";
|
|
5
5
|
|
|
6
6
|
type RouteParams = { params: Promise<{ id: string; ruleId: string }> };
|
|
7
7
|
|
|
8
|
-
type Body = { text?: unknown
|
|
8
|
+
type Body = { text?: unknown } & PresenceProof;
|
|
9
9
|
|
|
10
10
|
// The rule, its body, and Saad's passkey on it; or the refusal.
|
|
11
11
|
async function target(
|
|
@@ -21,7 +21,7 @@ async function target(
|
|
|
21
21
|
response: NextResponse.json({ error: "Bad rule id" }, { status: 400 }),
|
|
22
22
|
};
|
|
23
23
|
const body = (await request.json().catch(() => ({}))) as Body;
|
|
24
|
-
const refused = await provenAsSaad(request, id, body
|
|
24
|
+
const refused = await provenAsSaad(request, id, body);
|
|
25
25
|
if (refused) return { response: refused };
|
|
26
26
|
return { workspaceId: id, ruleId: n, body };
|
|
27
27
|
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { NextRequest } from "next/server";
|
|
2
|
-
import type { AuthenticationResponseJSON } from "@simplewebauthn/server";
|
|
3
2
|
import { requireApprover } from "@/lib/security/approver";
|
|
4
|
-
import {
|
|
5
|
-
|
|
3
|
+
import {
|
|
4
|
+
refusalResponse,
|
|
5
|
+
requirePresence,
|
|
6
|
+
type PresenceProof,
|
|
7
|
+
} from "@/lib/security/presence-http";
|
|
6
8
|
import { rulesPresence } from "@/lib/orchestrator/presence-binding";
|
|
7
9
|
|
|
8
10
|
// A rule written here is Saad's, shown to the orchestrator as his word and
|
|
@@ -11,14 +13,15 @@ import { rulesPresence } from "@/lib/orchestrator/presence-binding";
|
|
|
11
13
|
export async function provenAsSaad(
|
|
12
14
|
request: NextRequest,
|
|
13
15
|
workspaceId: string,
|
|
14
|
-
|
|
16
|
+
proof: PresenceProof | undefined
|
|
15
17
|
): Promise<Response | null> {
|
|
16
18
|
const gate = requireApprover(request);
|
|
17
19
|
if (!gate.ok) return gate.response;
|
|
18
20
|
try {
|
|
19
|
-
await
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
await requirePresence(
|
|
22
|
+
request,
|
|
23
|
+
gate.approver,
|
|
24
|
+
proof,
|
|
22
25
|
"rules",
|
|
23
26
|
rulesPresence(workspaceId)
|
|
24
27
|
);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
-
import type {
|
|
2
|
+
import type { PresenceProof } from "@/lib/security/presence-http";
|
|
3
3
|
import { getWorkspace } from "@/lib/workspaces";
|
|
4
4
|
import { addRule, listRules } from "@/lib/orchestrator/rules";
|
|
5
5
|
import { provenAsSaad } from "./presence";
|
|
@@ -19,13 +19,13 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|
|
19
19
|
const { id } = await params;
|
|
20
20
|
if (!getWorkspace(id))
|
|
21
21
|
return NextResponse.json({ error: "Workspace not found" }, { status: 404 });
|
|
22
|
-
const
|
|
22
|
+
const body = (await request.json().catch(() => ({}))) as {
|
|
23
23
|
text?: unknown;
|
|
24
|
-
|
|
25
|
-
};
|
|
24
|
+
} & PresenceProof;
|
|
25
|
+
const { text } = body;
|
|
26
26
|
if (typeof text !== "string")
|
|
27
27
|
return NextResponse.json({ error: "text is required" }, { status: 400 });
|
|
28
|
-
const refused = await provenAsSaad(request, id,
|
|
28
|
+
const refused = await provenAsSaad(request, id, body);
|
|
29
29
|
if (refused) return refused;
|
|
30
30
|
try {
|
|
31
31
|
return NextResponse.json({ rule: addRule(id, text, "saad") });
|
|
@@ -25,7 +25,8 @@ const when = (s: string) =>
|
|
|
25
25
|
const fail = (e: Error) => toast.error(e.message);
|
|
26
26
|
|
|
27
27
|
// Passkeys prove it's you approving an ask or resuming an orchestrator.
|
|
28
|
-
//
|
|
28
|
+
// A browser passkey belongs to the host name it was made on; the app keeps its
|
|
29
|
+
// own key in the phone's Secure Enclave.
|
|
29
30
|
export function PasskeysSection({ open }: { open: boolean }) {
|
|
30
31
|
const { data } = usePasskeysQuery(open);
|
|
31
32
|
const add = useAddPasskey();
|
|
@@ -64,7 +65,8 @@ export function PasskeysSection({ open }: { open: boolean }) {
|
|
|
64
65
|
<div className="min-w-0 flex-1">
|
|
65
66
|
<p className="truncate text-sm font-medium">{k.name}</p>
|
|
66
67
|
<p className="text-muted-foreground truncate text-xs">
|
|
67
|
-
{k.rp_id} · added
|
|
68
|
+
{k.type === "device" ? "Device key (Face ID)" : k.rp_id} · added{" "}
|
|
69
|
+
{when(k.created_at)}
|
|
68
70
|
{k.registered_from ? ` from ${k.registered_from}` : ""} · used{" "}
|
|
69
71
|
{lastSeen(k.last_used_at)}
|
|
70
72
|
</p>
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
{
|
|
2
|
+
"1": "f6d5756a104c16ec",
|
|
3
|
+
"2": "3699f5f3cd215455",
|
|
4
|
+
"3": "dc4efbc29c2976ef",
|
|
5
|
+
"4": "d3db3af92f123a13",
|
|
6
|
+
"5": "d5937aba3b590fed",
|
|
7
|
+
"6": "0c4668cf9a58cecd",
|
|
8
|
+
"7": "7f535265ae1ad37f",
|
|
9
|
+
"8": "5c24f600f5f73145",
|
|
10
|
+
"9": "99496923fc7ff333",
|
|
11
|
+
"10": "22162670e68726c0",
|
|
12
|
+
"11": "21cd323c1654f9a9",
|
|
13
|
+
"12": "d66b982154dc3ee2",
|
|
14
|
+
"13": "ba1c8c4e2486a74d",
|
|
15
|
+
"14": "2f9575eed78d3284",
|
|
16
|
+
"15": "4c2293b3766c0d84",
|
|
17
|
+
"16": "8e798e52aed97875",
|
|
18
|
+
"17": "a743501f00312926",
|
|
19
|
+
"18": "cb84130b195bfa63",
|
|
20
|
+
"19": "b0ba5d304ca64aba",
|
|
21
|
+
"20": "ef3e125d28bfa506",
|
|
22
|
+
"21": "542664a9bef5f215",
|
|
23
|
+
"22": "195a6a7a87776269",
|
|
24
|
+
"23": "e4b50e60ac0821fa",
|
|
25
|
+
"24": "afb9644f31cc7602",
|
|
26
|
+
"25": "52bb47e1eed4e8d4",
|
|
27
|
+
"26": "9b5247315a01bdbe",
|
|
28
|
+
"27": "9c34c46d26c93cc4",
|
|
29
|
+
"28": "83d816f925aa0aba",
|
|
30
|
+
"29": "5ce556a828f7510b",
|
|
31
|
+
"30": "3d1bc245c30dcf49",
|
|
32
|
+
"31": "250433b280fd8df1",
|
|
33
|
+
"32": "95ec3c8125969681",
|
|
34
|
+
"33": "711226e6a027f6cd",
|
|
35
|
+
"34": "d8c917aa6f3e00b6",
|
|
36
|
+
"35": "7db35660583c2a50",
|
|
37
|
+
"36": "7ff8363149a3952c",
|
|
38
|
+
"37": "b7e72a18d3398da6",
|
|
39
|
+
"38": "cea02e00d1262601",
|
|
40
|
+
"39": "61c77d0f357d71d6",
|
|
41
|
+
"40": "6cedcf70268ddd25",
|
|
42
|
+
"41": "3d9af0548ae91946",
|
|
43
|
+
"42": "410ef4562a046ce6",
|
|
44
|
+
"43": "b0fca4e05ff6499e",
|
|
45
|
+
"44": "06b7f4dc636c9675",
|
|
46
|
+
"45": "73948f256c522959",
|
|
47
|
+
"46": "202f8fd102ef2581",
|
|
48
|
+
"47": "510435d6afa487cd",
|
|
49
|
+
"48": "10727869e8c8dea1",
|
|
50
|
+
"49": "0384a86395f6650a",
|
|
51
|
+
"50": "cc05c3663ca18b2b",
|
|
52
|
+
"51": "b381268fe096fb02",
|
|
53
|
+
"52": "281ad10453d9177a",
|
|
54
|
+
"53": "a7a53074ed49a566",
|
|
55
|
+
"54": "2358b106da3e7b68",
|
|
56
|
+
"55": "478ece72deb01953",
|
|
57
|
+
"56": "d04bafcaa2eda941",
|
|
58
|
+
"57": "0f9bc09a7a31f64d",
|
|
59
|
+
"58": "ccbf7eebccadfa3f",
|
|
60
|
+
"59": "1859ac87155d546b",
|
|
61
|
+
"60": "035e204a4b6c457e",
|
|
62
|
+
"61": "85d9bb65b051409a",
|
|
63
|
+
"62": "723d9c60994dec97",
|
|
64
|
+
"63": "77dd17570476ee17",
|
|
65
|
+
"64": "de5f368a88af93de",
|
|
66
|
+
"65": "081cc6eaf666581b",
|
|
67
|
+
"66": "08eedfefd3d3f3f9",
|
|
68
|
+
"67": "e37ead69c14e2d01",
|
|
69
|
+
"68": "9ab2551e258f1588",
|
|
70
|
+
"69": "55edb197ed55afe1",
|
|
71
|
+
"70": "d8a4faf39318bd1d",
|
|
72
|
+
"71": "45b9b9ff57d2083d",
|
|
73
|
+
"72": "51b77e2e1c5c8ae6",
|
|
74
|
+
"73": "2878b9f0762ab53d",
|
|
75
|
+
"74": "c94cd8f644a8518f",
|
|
76
|
+
"75": "e082428a046f9e6b",
|
|
77
|
+
"76": "17f3160630d658dc",
|
|
78
|
+
"77": "cd54bfd73fbcd168",
|
|
79
|
+
"78": "8ad44a982b1eb58d",
|
|
80
|
+
"79": "0b09d57d6ea8ce0a",
|
|
81
|
+
"80": "941d85f2ef8bd012",
|
|
82
|
+
"81": "ecd85de2bdf45ff1",
|
|
83
|
+
"82": "07f11cdac5f8444a",
|
|
84
|
+
"83": "eabf72b6a8616eac",
|
|
85
|
+
"84": "1ed4fb30b7435f28",
|
|
86
|
+
"85": "a85ac25c0e34f2fb",
|
|
87
|
+
"86": "50b5725a67286dee",
|
|
88
|
+
"87": "6024c62bcdbb186e",
|
|
89
|
+
"88": "bb58ec622ae398e5"
|
|
90
|
+
}
|
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
|
+
}
|
|
@@ -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
|
+
}
|
package/lib/sidebar/shelves.ts
CHANGED
|
@@ -267,11 +267,16 @@ export function buildShelves(input: ShelfInput): Shelves {
|
|
|
267
267
|
|
|
268
268
|
const top: SidebarRow[] = [];
|
|
269
269
|
for (const row of rows) {
|
|
270
|
-
// A quick change
|
|
271
|
-
//
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
270
|
+
// A quick change sits under the orchestrator that makes it: it's that
|
|
271
|
+
// orchestrator's to carry, never one that needs you. Once merged it
|
|
272
|
+
// leaves for the shelves, so Done can take it.
|
|
273
|
+
const finished =
|
|
274
|
+
(row.task && isFinished(row.task)) ||
|
|
275
|
+
row.session.task_status === "merged";
|
|
276
|
+
const owner =
|
|
277
|
+
row.session.quick_of && !finished
|
|
278
|
+
? byId.get(row.session.quick_of)
|
|
279
|
+
: undefined;
|
|
275
280
|
if (owner) {
|
|
276
281
|
(owner.quick ??= []).push(row);
|
|
277
282
|
continue;
|