Public: anyone can read this; the crew can change it.
Removing your last passkey is refused when the account has no password, on Settings and through MCP alike #8049
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8043-removing-your-last-passkey-is-refused-wh → main 1 commit fork
5 files
changed +169
−5
src/actions/passkeys.ts added +27
| @@ -0,0 +1,27 @@ | ||
| 1 | /** | |
| 2 | * Removing one's own passkey, with the one rule both doors share (#8043): | |
| 3 | * Settings → Passkeys and MCP `whoami_can` refuse to remove the account's | |
| 4 | * last passkey when no password could sign it in instead. An account with | |
| 5 | * no passkey at all (edge sign-in) never reaches the rule: there is nothing | |
| 6 | * of its own to remove. | |
| 7 | */ | |
| 8 | import type { App } from "../app.ts"; | |
| 9 | import { listPasskeys, removePasskey } from "../core/passkeys.ts"; | |
| 10 | import { hasPassword, type User } from "../core/users.ts"; | |
| 11 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 12 | ||
| 13 | export const LAST_WAY_IN = "That is your only way to sign in. Add another passkey or set a password first."; | |
| 14 | ||
| 15 | export function removeOwnPasskey( | |
| 16 | app: App, | |
| 17 | user: User, | |
| 18 | id: string, | |
| 19 | ): Result<void, "not_found" | "last_way_in"> { | |
| 20 | // One transaction: two removals racing cannot each see the other's key. | |
| 21 | return app.db.transaction((): Result<void, "not_found" | "last_way_in"> => { | |
| 22 | const keys = listPasskeys(app.db, user.id); | |
| 23 | if (!keys.some((k) => k.id === id)) return err("not_found"); | |
| 24 | if (keys.length === 1 && !hasPassword(app.db, user.id)) return err("last_way_in"); | |
| 25 | return removePasskey(app.db, user.id, id) ? ok() : err("not_found"); | |
| 26 | })(); | |
| 27 | } |
src/core/users.ts +13
| @@ -520,6 +520,19 @@ export async function verifyPasswordThrottled( | ||
| 520 | 520 | return checked; |
| 521 | 521 | } |
| 522 | 522 | |
| 523 | /** | |
| 524 | * Whether some password could verify for this account: what `checkPassword` | |
| 525 | * would ever accept. An empty hash never parses, and an org or agent never | |
| 526 | * signs in with one whatever is stored. | |
| 527 | */ | |
| 528 | export function hasPassword(db: Database, userId: string): boolean { | |
| 529 | return ( | |
| 530 | db | |
| 531 | .query("SELECT 1 FROM user WHERE id = ?1 AND password_hash <> '' AND kind <> 'org' AND is_agent = 0") | |
| 532 | .get(userId) !== null | |
| 533 | ); | |
| 534 | } | |
| 535 | ||
| 523 | 536 | /** Constant-shape verification: always burns an argon2 verify, hit or miss. */ |
| 524 | 537 | async function checkPassword( |
| 525 | 538 | db: Database, |
src/http/mcp/tools/whoami_can.ts +5 −2
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { ABILITIES, roleOf, rowOf } from "../../../core/roles.ts"; |
| 2 | import { listPasskeys, removePasskey } from "../../../core/passkeys.ts"; | |
| 2 | import { LAST_WAY_IN, removeOwnPasskey } from "../../../actions/passkeys.ts"; | |
| 3 | import { listPasskeys } from "../../../core/passkeys.ts"; | |
| 3 | 4 | import { scopeAllows } from "../../../core/tokens.ts"; |
| 4 | 5 | import { good, bad, type Tool } from "../shared.ts"; |
| 5 | 6 | |
| @@ -28,7 +29,9 @@ export const tool: Tool = { | ||
| 28 | 29 | return bad("forbidden", "removing a passkey needs an admin-scope token"); |
| 29 | 30 | if (typeof remove !== "string" || remove === "" || remove.length > 64) |
| 30 | 31 | return bad("bad_args", "remove is a passkey id"); |
| 31 | if (!removePasskey(ctx.app.db, user.id, remove)) return bad("not_found", "No such passkey."); | |
| 32 | const gone = removeOwnPasskey(ctx.app, user, remove); | |
| 33 | if (!gone.ok) | |
| 34 | return gone.error === "last_way_in" ? bad("refused", LAST_WAY_IN) : bad("not_found", "No such passkey."); | |
| 32 | 35 | return good({ id: remove, says: "Passkey removed." }); |
| 33 | 36 | } |
| 34 | 37 | return good({ |
src/web/pages/passkeys.ts +5 −3
| @@ -15,11 +15,11 @@ import { | ||
| 15 | 15 | notePasskeyUsed, |
| 16 | 16 | passkeyIds, |
| 17 | 17 | passkeyRoom, |
| 18 | removePasskey, | |
| 19 | 18 | PASSKEY_NAME_MAX, |
| 20 | 19 | } from "../../core/passkeys.ts"; |
| 21 | 20 | import { RateLimiter } from "../../core/ratelimit.ts"; |
| 22 | 21 | import { createSession } from "../../core/sessions.ts"; |
| 22 | import { LAST_WAY_IN, removeOwnPasskey } from "../../actions/passkeys.ts"; | |
| 23 | 23 | import { recoveryAccount, requestRecovery, spendRecoveryLink } from "../../actions/verify.ts"; |
| 24 | 24 | import { getUserById, isActive } from "../../core/users.ts"; |
| 25 | 25 | import { crossOrigin, type Ctx } from "../../http/context.ts"; |
| @@ -157,11 +157,13 @@ export async function passkeyDelete(ctx: Ctx): Promise<Response> { | ||
| 157 | 157 | if (!ctx.auth.user) return signInFirst(ctx); |
| 158 | 158 | const form = await readForm(ctx, "admin"); |
| 159 | 159 | if (form instanceof Response) return form; |
| 160 | const gone = removePasskey(ctx.app.db, ctx.auth.user.id, ctx.params.id ?? ""); | |
| 160 | const gone = removeOwnPasskey(ctx.app, ctx.auth.user, ctx.params.id ?? ""); | |
| 161 | 161 | return flashTo( |
| 162 | 162 | ctx, |
| 163 | 163 | "/settings/passkeys", |
| 164 | gone ? { message: "Passkey removed." } : { tone: "danger", message: "No such passkey." }, | |
| 164 | gone.ok | |
| 165 | ? { message: "Passkey removed." } | |
| 166 | : { tone: "danger", message: gone.error === "last_way_in" ? LAST_WAY_IN : "No such passkey." }, | |
| 165 | 167 | ); |
| 166 | 168 | } |
| 167 | 169 |
test/passkeys-last-way-in.test.ts added +119
| @@ -0,0 +1,119 @@ | ||
| 1 | /** | |
| 2 | * An account with no password cannot remove its last passkey (#8043), on | |
| 3 | * Settings → Passkeys and through MCP `whoami_can` alike; a second passkey | |
| 4 | * or a password lets the removal through. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { LAST_WAY_IN } from "../src/actions/passkeys.ts"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { addPasskey, listPasskeys } from "../src/core/passkeys.ts"; | |
| 13 | import { createSession } from "../src/core/sessions.ts"; | |
| 14 | import { createToken } from "../src/core/tokens.ts"; | |
| 15 | import { createUser, type User } from "../src/core/users.ts"; | |
| 16 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 17 | ||
| 18 | describe("the last way in", () => { | |
| 19 | let dataDir: string; | |
| 20 | let app: App; | |
| 21 | let servers: Servers; | |
| 22 | let n = 0; | |
| 23 | let rpc = 1; | |
| 24 | ||
| 25 | beforeAll(async () => { | |
| 26 | dataDir = await mkdtemp(join(tmpdir(), "forge-last-way-in-")); | |
| 27 | app = createApp({ dataDir, port: 0, url: "https://forge.example" }); | |
| 28 | servers = await serve(app); | |
| 29 | }); | |
| 30 | afterAll(async () => { | |
| 31 | servers.stop(); | |
| 32 | app.close(); | |
| 33 | await rm(dataDir, { recursive: true, force: true }); | |
| 34 | }); | |
| 35 | ||
| 36 | const person = async (password?: string): Promise<User> => { | |
| 37 | const name = `p${n++}`; | |
| 38 | const made = await createUser(app.db, { username: name, email: `${name}@f.dev`, origin: "seed", trusted: true, ...(password ? { password } : {}) }); | |
| 39 | if (!made.ok) throw new Error(made.error); | |
| 40 | return made.value; | |
| 41 | }; | |
| 42 | const key = (user: User) => { | |
| 43 | const added = addPasskey(app.db, user.id, "phone", { id: `cred-${n++}`, alg: "ES256", key: { kty: "EC", crv: "P-256", x: "x", y: "y" }, signCount: 0 }); | |
| 44 | if (!added.ok) throw new Error(added.error); | |
| 45 | return added.value; | |
| 46 | }; | |
| 47 | const listed = (user: User, id: string) => listPasskeys(app.db, user.id).some((k) => k.id === id); | |
| 48 | ||
| 49 | const viaPage = async (user: User, id: string) => { | |
| 50 | const cookie = `forge_session=${createSession(app.db, user.id).token}`; | |
| 51 | const res = await fetch(`${servers.url}/settings/passkeys/${id}/delete`, { method: "POST", headers: { cookie }, redirect: "manual" }); | |
| 52 | expect(res.status).toBe(303); | |
| 53 | const carried = (res.headers.getSetCookie?.() ?? []).map((c) => c.split(";")[0]).join("; "); | |
| 54 | return (await fetch(`${servers.url}/settings/passkeys`, { headers: { cookie: `${cookie}; ${carried}` } })).text(); | |
| 55 | }; | |
| 56 | const viaMcp = async (user: User, id: string) => { | |
| 57 | const res = await fetch(`${servers.url}/mcp`, { | |
| 58 | method: "POST", | |
| 59 | headers: { | |
| 60 | "content-type": "application/json", | |
| 61 | accept: "application/json, text/event-stream", | |
| 62 | authorization: `Bearer ${createToken(app.db, user.id, "t", "admin").token}`, | |
| 63 | }, | |
| 64 | body: JSON.stringify({ jsonrpc: "2.0", id: rpc++, method: "tools/call", params: { name: "whoami_can", arguments: { kind: "passkeys", remove: id } } }), | |
| 65 | }); | |
| 66 | const body: any = await res.json(); | |
| 67 | return { isError: body.result.isError === true, text: body.result.content[0].text as string }; | |
| 68 | }; | |
| 69 | ||
| 70 | test("the page refuses a passkey-only account's single passkey", async () => { | |
| 71 | const user = await person(); | |
| 72 | const id = key(user); | |
| 73 | const page = await viaPage(user, id); | |
| 74 | expect(page).toContain(LAST_WAY_IN); | |
| 75 | expect(page).not.toContain("Passkey removed."); | |
| 76 | expect(listed(user, id)).toBe(true); | |
| 77 | }); | |
| 78 | ||
| 79 | test("MCP refuses a passkey-only account's single passkey", async () => { | |
| 80 | const user = await person(); | |
| 81 | const id = key(user); | |
| 82 | const res = await viaMcp(user, id); | |
| 83 | expect(res.isError).toBe(true); | |
| 84 | expect(res.text).toContain("refused"); | |
| 85 | expect(res.text).toContain(LAST_WAY_IN); | |
| 86 | expect(listed(user, id)).toBe(true); | |
| 87 | }); | |
| 88 | ||
| 89 | test("with a second passkey the removal goes through, page and MCP", async () => { | |
| 90 | const user = await person(); | |
| 91 | const a = key(user); | |
| 92 | const b = key(user); | |
| 93 | const c = key(user); | |
| 94 | expect(await viaPage(user, a)).toContain("Passkey removed."); | |
| 95 | expect(listed(user, a)).toBe(false); | |
| 96 | expect((await viaMcp(user, b)).isError).toBe(false); | |
| 97 | expect(listed(user, b)).toBe(false); | |
| 98 | // Now c is the last one, and it stays. | |
| 99 | expect((await viaMcp(user, c)).text).toContain(LAST_WAY_IN); | |
| 100 | expect(listed(user, c)).toBe(true); | |
| 101 | }); | |
| 102 | ||
| 103 | test("with a password the last passkey can go, page and MCP", async () => { | |
| 104 | const one = await person("a-long-passphrase"); | |
| 105 | const a = key(one); | |
| 106 | expect(await viaPage(one, a)).toContain("Passkey removed."); | |
| 107 | expect(listed(one, a)).toBe(false); | |
| 108 | const two = await person("a-long-passphrase"); | |
| 109 | const b = key(two); | |
| 110 | expect((await viaMcp(two, b)).isError).toBe(false); | |
| 111 | expect(listed(two, b)).toBe(false); | |
| 112 | }); | |
| 113 | ||
| 114 | test("an unknown passkey is still not found", async () => { | |
| 115 | const user = await person(); | |
| 116 | expect(await viaPage(user, "nope")).toContain("No such passkey."); | |
| 117 | expect((await viaMcp(user, "nope")).text).toContain("not_found"); | |
| 118 | }); | |
| 119 | }); |
new above
offline