atproto pds in zig pds.zat.dev
pds atproto
25

Configure Feed

Select the types of activity you want to include in your feed.

Move passkey management behind security session

zzstoatzz (May 27, 2026, 12:11 PM -0500) 23012fb2 1cb136d8

+126 -54
+18 -9
src/http/router.zig
··· 20 20 oauth_passkey_options, 21 21 oauth_passkey_finish, 22 22 passkeys_page, 23 - passkeys_register_options, 24 - passkeys_register_finish, 25 - passkeys_delete, 23 + security_page, 26 24 atproto_did, 27 25 describe_server, 28 26 reserve_signing_key, ··· 31 29 create_invite_codes, 32 30 get_account_invite_codes, 33 31 list_app_passwords, 32 + start_passkey_registration, 33 + finish_passkey_registration, 34 + list_passkeys, 35 + delete_passkey, 36 + update_passkey, 34 37 create_session, 35 38 refresh_session, 36 39 get_session, ··· 90 93 if (method == .POST and std.mem.eql(u8, path, "/oauth/passkey/options")) return .oauth_passkey_options; 91 94 if (method == .POST and std.mem.eql(u8, path, "/oauth/passkey/finish")) return .oauth_passkey_finish; 92 95 if (method == .GET and std.mem.eql(u8, path, "/passkeys")) return .passkeys_page; 93 - if (method == .POST and std.mem.eql(u8, path, "/passkeys/register/options")) return .passkeys_register_options; 94 - if (method == .POST and std.mem.eql(u8, path, "/passkeys/register/finish")) return .passkeys_register_finish; 95 - if (method == .POST and std.mem.eql(u8, path, "/passkeys/delete")) return .passkeys_delete; 96 + if (method == .GET and std.mem.eql(u8, path, "/security")) return .security_page; 96 97 if (method == .GET and std.mem.eql(u8, path, "/.well-known/atproto-did")) return .atproto_did; 97 98 if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.server.describeServer")) return .describe_server; 98 99 if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.reserveSigningKey")) return .reserve_signing_key; ··· 101 102 if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.createInviteCodes")) return .create_invite_codes; 102 103 if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.server.getAccountInviteCodes")) return .get_account_invite_codes; 103 104 if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.server.listAppPasswords")) return .list_app_passwords; 105 + if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.startPasskeyRegistration")) return .start_passkey_registration; 106 + if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.finishPasskeyRegistration")) return .finish_passkey_registration; 107 + if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.server.listPasskeys")) return .list_passkeys; 108 + if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.deletePasskey")) return .delete_passkey; 109 + if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.updatePasskey")) return .update_passkey; 104 110 if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.createSession")) return .create_session; 105 111 if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.refreshSession")) return .refresh_session; 106 112 if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.server.getSession")) return .get_session; ··· 165 171 try std.testing.expectEqual(Route.oauth_passkey_options, route(.POST, "/oauth/passkey/options")); 166 172 try std.testing.expectEqual(Route.oauth_passkey_finish, route(.POST, "/oauth/passkey/finish")); 167 173 try std.testing.expectEqual(Route.passkeys_page, route(.GET, "/passkeys")); 168 - try std.testing.expectEqual(Route.passkeys_register_options, route(.POST, "/passkeys/register/options")); 169 - try std.testing.expectEqual(Route.passkeys_register_finish, route(.POST, "/passkeys/register/finish")); 170 - try std.testing.expectEqual(Route.passkeys_delete, route(.POST, "/passkeys/delete")); 174 + try std.testing.expectEqual(Route.security_page, route(.GET, "/security")); 171 175 try std.testing.expectEqual(Route.atproto_did, route(.GET, "/.well-known/atproto-did")); 172 176 try std.testing.expectEqual(Route.describe_server, route(.GET, "/xrpc/com.atproto.server.describeServer")); 173 177 try std.testing.expectEqual(Route.reserve_signing_key, route(.POST, "/xrpc/com.atproto.server.reserveSigningKey")); ··· 176 180 try std.testing.expectEqual(Route.create_invite_codes, route(.POST, "/xrpc/com.atproto.server.createInviteCodes")); 177 181 try std.testing.expectEqual(Route.get_account_invite_codes, route(.GET, "/xrpc/com.atproto.server.getAccountInviteCodes")); 178 182 try std.testing.expectEqual(Route.list_app_passwords, route(.GET, "/xrpc/com.atproto.server.listAppPasswords")); 183 + try std.testing.expectEqual(Route.start_passkey_registration, route(.POST, "/xrpc/com.atproto.server.startPasskeyRegistration")); 184 + try std.testing.expectEqual(Route.finish_passkey_registration, route(.POST, "/xrpc/com.atproto.server.finishPasskeyRegistration")); 185 + try std.testing.expectEqual(Route.list_passkeys, route(.GET, "/xrpc/com.atproto.server.listPasskeys")); 186 + try std.testing.expectEqual(Route.delete_passkey, route(.POST, "/xrpc/com.atproto.server.deletePasskey")); 187 + try std.testing.expectEqual(Route.update_passkey, route(.POST, "/xrpc/com.atproto.server.updatePasskey")); 179 188 try std.testing.expectEqual(Route.create_session, route(.POST, "/xrpc/com.atproto.server.createSession")); 180 189 try std.testing.expectEqual(Route.refresh_session, route(.POST, "/xrpc/com.atproto.server.refreshSession")); 181 190 try std.testing.expectEqual(Route.get_session, route(.GET, "/xrpc/com.atproto.server.getSession"));
+7 -4
src/http/server.zig
··· 103 103 .oauth_revoke => try atproto_oauth.revoke(request), 104 104 .oauth_passkey_options => try passkeys.loginStart(request), 105 105 .oauth_passkey_finish => try passkeys.loginFinish(request), 106 - .passkeys_page => try passkeys.setupPage(request), 107 - .passkeys_register_options => try passkeys.registerStart(request), 108 - .passkeys_register_finish => try passkeys.registerFinish(request), 109 - .passkeys_delete => try passkeys.deletePasskey(request), 106 + .passkeys_page => try passkeys.redirectToSecurity(request), 107 + .security_page => try passkeys.securityPage(request), 110 108 .atproto_did => try atproto_server.atprotoDid(request), 111 109 .describe_server => try atproto_server.describeServer(request), 112 110 .reserve_signing_key => try atproto_server.reserveSigningKey(request), ··· 115 113 .create_invite_codes => try atproto_server.createInviteCodes(request), 116 114 .get_account_invite_codes => try atproto_server.getAccountInviteCodes(request), 117 115 .list_app_passwords => try atproto_server.listAppPasswords(request), 116 + .start_passkey_registration => try passkeys.xrpcStartRegistration(request), 117 + .finish_passkey_registration => try passkeys.xrpcFinishRegistration(request), 118 + .list_passkeys => try passkeys.xrpcList(request), 119 + .delete_passkey => try passkeys.xrpcDelete(request), 120 + .update_passkey => try passkeys.xrpcUpdate(request), 118 121 .create_session => try atproto_server.createSession(request), 119 122 .refresh_session => try atproto_server.refreshSession(request), 120 123 .get_session => try atproto_server.getSession(request),
+90 -41
src/internal/passkeys.zig
··· 35 35 try webauthn.crypto.verifyWebAuthnEs256(credential_public_key, signature_der, message); 36 36 } 37 37 38 - pub fn setupPage(request: *http.Server.Request) !void { 38 + pub fn redirectToSecurity(request: *http.Server.Request) !void { 39 + try request.respond("", .{ 40 + .status = .see_other, 41 + .extra_headers = &[_]http.Header{ 42 + .{ .name = "location", .value = "/security" }, 43 + .{ .name = "connection", .value = "close" }, 44 + }, 45 + }); 46 + } 47 + 48 + pub fn securityPage(request: *http.Server.Request) !void { 39 49 var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 40 50 defer arena.deinit(); 41 51 const allocator = arena.allocator(); ··· 45 55 \\<head> 46 56 \\<meta charset="utf-8"> 47 57 \\<meta name="viewport" content="width=device-width, initial-scale=1"> 48 - \\<title>zds passkeys</title> 58 + \\<title>zds security</title> 49 59 \\<style> 50 60 \\:root{{color-scheme:dark;--bg:#070807;--panel:#101510;--line:#263326;--text:#f1f2ec;--muted:#a7a299;--accent:#8fb0ff;--green:#37c978;--field:#101410;--bad:#ffb4a8}} 51 61 \\@media (prefers-color-scheme:light){{:root{{--bg:#f6f4ed;--panel:#fffdf7;--line:#d8d0c0;--text:#161412;--muted:#625c53;--accent:#315dcb;--green:#1b7340;--field:#fffaf1;--bad:#9f1d1d;color-scheme:light}}}} ··· 57 67 \\button{{width:100%;margin-top:22px;border:1px solid color-mix(in srgb,var(--accent) 60%,var(--line));border-radius:10px;background:var(--accent);color:#061021;font:inherit;font-weight:800;padding:12px;cursor:pointer}} 58 68 \\.status{{min-height:1.5em;color:var(--muted)}}.error{{color:var(--bad)}}.ok{{color:var(--green)}}code{{overflow-wrap:anywhere;color:var(--accent)}} 59 69 \\.hint{{font-size:.92rem;color:var(--muted);margin:8px 0 0}} 60 - \\.passkey-list{{display:none;margin-top:18px;border-top:1px solid var(--line);padding-top:12px}}.passkey-list.on{{display:block}}.passkey-list h2{{font-size:1rem;margin:0 0 8px}} 70 + \\.account{{display:none;margin-top:18px}}.account.on{{display:block}}.login.off{{display:none}} 71 + \\.passkey-list{{margin-top:18px;border-top:1px solid var(--line);padding-top:12px}}.passkey-list h2{{font-size:1rem;margin:0 0 8px}} 61 72 \\.passkey-row{{display:flex;align-items:center;gap:10px;justify-content:space-between;border:1px solid var(--line);border-radius:9px;padding:10px 12px;margin:8px 0;background:color-mix(in srgb,var(--field) 76%,transparent)}} 62 - \\.passkey-row span{{overflow:hidden;text-overflow:ellipsis}}.passkey-row button{{width:auto;margin:0;padding:7px 10px;background:transparent;color:var(--bad);border-color:color-mix(in srgb,var(--bad) 45%,var(--line))}} 73 + \\.passkey-name{{font-weight:760;overflow:hidden;text-overflow:ellipsis}}.passkey-meta{{display:block;color:var(--muted);font-size:.82rem;font-weight:400}}.passkey-actions{{display:flex;gap:8px;flex:0 0 auto}}.passkey-row button{{width:auto;margin:0;padding:7px 10px;background:transparent;color:var(--text);border-color:var(--line)}}.passkey-row button.danger{{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 45%,var(--line))}} 63 74 \\</style> 64 75 \\</head> 65 76 \\<body> 66 77 \\<main class="shell"> 67 78 \\<a class="brand" href="/">zds</a> 68 - \\<h1>passkeys</h1> 69 - \\<p>Check an account, review its saved passkeys, and add another one for OAuth sign-in on <strong>{s}</strong>.</p> 79 + \\<h1>security</h1> 80 + \\<p>Sign in to manage passkeys for an account hosted on <strong>{s}</strong>.</p> 70 81 \\<section class="panel"> 71 - \\<form id="passkey-form"> 82 + \\<form id="login-form" class="login"> 72 83 \\<label for="identifier">account</label> 73 84 \\<input id="identifier" name="identifier" autocomplete="username" required> 74 85 \\<label for="password">password</label> 75 86 \\<input id="password" name="password" type="password" autocomplete="current-password" required> 76 - \\<label for="friendly">passkey name</label> 77 - \\<input id="friendly" name="friendlyName" placeholder="this device"> 78 - \\<button>check account</button> 79 - \\<p class="hint">Password sign-in stays available. Removing a passkey here forgets it on this PDS; your browser or password manager may still keep its copy.</p> 87 + \\<button>sign in</button> 80 88 \\</form> 81 89 \\<p id="status" class="status"></p> 82 - \\<section id="passkey-list" class="passkey-list" aria-live="polite"> 90 + \\<section id="account" class="account" aria-live="polite"> 91 + \\<p id="account-label"></p> 83 92 \\<h2>saved passkeys</h2> 84 93 \\<div id="passkey-items"></div> 94 + \\<label for="friendly">new passkey name</label> 95 + \\<input id="friendly" name="friendlyName" placeholder="this device"> 96 + \\<button id="add-passkey" type="button">add passkey</button> 97 + \\<p class="hint">Removing a passkey here forgets it on this PDS; your browser or password manager may still keep its copy.</p> 85 98 \\</section> 86 99 \\</section> 87 100 \\</main> 88 101 \\<script>{s}</script> 89 102 \\</body> 90 103 \\</html> 91 - , .{ try htmlEscape(allocator, displayHost(config.publicUrl())), registrationScript }); 104 + , .{ try htmlEscape(allocator, displayHost(config.publicUrl())), securityScript }); 92 105 try respondHtml(request, .ok, body); 93 106 } 94 107 95 - pub fn registerStart(request: *http.Server.Request) !void { 108 + pub fn xrpcStartRegistration(request: *http.Server.Request) !void { 96 109 var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 97 110 defer arena.deinit(); 98 111 const allocator = arena.allocator(); 112 + const account = requireBearerAccount(request, allocator) catch return; 99 113 const parsed = try readJson(request, allocator); 100 - const identifier = requiredString(parsed.value, "identifier") orelse return jsonError(request, .bad_request, "Missing identifier"); 101 - const password = requiredString(parsed.value, "password") orelse return jsonError(request, .bad_request, "Missing password"); 102 - const account = (try store.findAccount(allocator, identifier)) orelse return jsonError(request, .unauthorized, "Invalid identifier or password"); 103 - if (!auth.passwordMatches(account, password)) return jsonError(request, .unauthorized, "Invalid identifier or password"); 104 114 105 115 const challenge = try newChallenge(allocator); 106 116 try store.putWebAuthnChallenge(account.did, "registration", challenge, "{}", now() + challenge_ttl_seconds); ··· 108 118 const user_id = try userId(allocator, account.did); 109 119 const passkeys = try store.listPasskeys(allocator, account.did); 110 120 const exclude = try credentialsJson(allocator, passkeys); 111 - const existing = try passkeysMetadataJson(allocator, passkeys); 121 + const friendly = requiredString(parsed.value, "friendlyName") orelse account.handle; 112 122 const body = try std.fmt.allocPrint(allocator, 113 - \\{{"existingPasskeys":{s},"publicKey":{{"rp":{{"name":"zds","id":{f}}},"user":{{"id":{f},"name":{f},"displayName":{f}}},"challenge":{f},"pubKeyCredParams":[{{"type":"public-key","alg":-7}}],"timeout":60000,"excludeCredentials":{s},"authenticatorSelection":{{"residentKey":"required","requireResidentKey":true,"userVerification":"preferred"}},"attestation":"none"}}}} 123 + \\{{"options":{{"publicKey":{{"rp":{{"name":"zds","id":{f}}},"user":{{"id":{f},"name":{f},"displayName":{f}}},"challenge":{f},"pubKeyCredParams":[{{"type":"public-key","alg":-7}}],"timeout":60000,"excludeCredentials":{s},"authenticatorSelection":{{"residentKey":"required","requireResidentKey":true,"userVerification":"preferred"}},"attestation":"none"}}}}}} 114 124 , .{ 115 - existing, 116 125 std.json.fmt(rp_id, .{}), 117 126 std.json.fmt(user_id, .{}), 118 127 std.json.fmt(account.handle, .{}), 119 - std.json.fmt(account.handle, .{}), 128 + std.json.fmt(friendly, .{}), 120 129 std.json.fmt(challenge, .{}), 121 130 exclude, 122 131 }); 123 132 try http_api.json(request, .ok, body); 124 133 } 125 134 126 - pub fn registerFinish(request: *http.Server.Request) !void { 135 + pub fn xrpcFinishRegistration(request: *http.Server.Request) !void { 127 136 var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 128 137 defer arena.deinit(); 129 138 const allocator = arena.allocator(); 139 + const account = requireBearerAccount(request, allocator) catch return; 130 140 const parsed = try readJson(request, allocator); 131 - const identifier = requiredString(parsed.value, "identifier") orelse return jsonError(request, .bad_request, "Missing identifier"); 132 - const password = requiredString(parsed.value, "password") orelse return jsonError(request, .bad_request, "Missing password"); 133 - const account = (try store.findAccount(allocator, identifier)) orelse return jsonError(request, .unauthorized, "Invalid identifier or password"); 134 - if (!auth.passwordMatches(account, password)) return jsonError(request, .unauthorized, "Invalid identifier or password"); 135 141 const challenge = (try store.getWebAuthnChallenge(allocator, account.did, "registration")) orelse return jsonError(request, .bad_request, "Missing registration challenge"); 136 142 if (challenge.expires_at < now()) return jsonError(request, .bad_request, "Registration challenge expired"); 137 143 ··· 145 151 const friendly = requiredString(parsed.value, "friendlyName"); 146 152 const id = try store.savePasskey(allocator, account.did, verified.credential_id, verified.credential_public_key, verified.sign_count, friendly); 147 153 try store.deleteWebAuthnChallenge(account.did, "registration"); 148 - const body = try std.fmt.allocPrint(allocator, "{{\"ok\":true,\"id\":{f}}}", .{std.json.fmt(id, .{})}); 154 + const credential_id = try webauthn.base64url.encodeAlloc(allocator, verified.credential_id); 155 + const body = try std.fmt.allocPrint(allocator, "{{\"id\":{f},\"credentialId\":{f}}}", .{ std.json.fmt(id, .{}), std.json.fmt(credential_id, .{}) }); 156 + try http_api.json(request, .ok, body); 157 + } 158 + 159 + pub fn xrpcList(request: *http.Server.Request) !void { 160 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 161 + defer arena.deinit(); 162 + const allocator = arena.allocator(); 163 + const account = requireBearerAccount(request, allocator) catch return; 164 + const passkeys = try store.listPasskeys(allocator, account.did); 165 + const body = try std.fmt.allocPrint(allocator, "{{\"passkeys\":{s}}}", .{try passkeysMetadataJson(allocator, passkeys)}); 149 166 try http_api.json(request, .ok, body); 150 167 } 151 168 152 - pub fn deletePasskey(request: *http.Server.Request) !void { 169 + pub fn xrpcDelete(request: *http.Server.Request) !void { 153 170 var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 154 171 defer arena.deinit(); 155 172 const allocator = arena.allocator(); 173 + const account = requireBearerAccount(request, allocator) catch return; 156 174 const parsed = try readJson(request, allocator); 157 - const identifier = requiredString(parsed.value, "identifier") orelse return jsonError(request, .bad_request, "Missing identifier"); 158 - const password = requiredString(parsed.value, "password") orelse return jsonError(request, .bad_request, "Missing password"); 159 175 const id = requiredString(parsed.value, "id") orelse return jsonError(request, .bad_request, "Missing passkey id"); 160 - const account = (try store.findAccount(allocator, identifier)) orelse return jsonError(request, .unauthorized, "Invalid identifier or password"); 161 - if (!auth.passwordMatches(account, password)) return jsonError(request, .unauthorized, "Invalid identifier or password"); 162 176 try store.deletePasskey(account.did, id); 163 - try http_api.json(request, .ok, "{\"ok\":true}"); 177 + try http_api.json(request, .ok, "{}"); 178 + } 179 + 180 + pub fn xrpcUpdate(request: *http.Server.Request) !void { 181 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 182 + defer arena.deinit(); 183 + const allocator = arena.allocator(); 184 + const account = requireBearerAccount(request, allocator) catch return; 185 + const parsed = try readJson(request, allocator); 186 + const id = requiredString(parsed.value, "id") orelse return jsonError(request, .bad_request, "Missing passkey id"); 187 + const friendly_name = requiredString(parsed.value, "friendlyName") orelse return jsonError(request, .bad_request, "Missing friendlyName"); 188 + try store.updatePasskeyName(account.did, id, friendly_name); 189 + try http_api.json(request, .ok, "{}"); 164 190 } 165 191 166 192 pub fn loginStart(request: *http.Server.Request) !void { ··· 284 310 try out.append(allocator, '['); 285 311 for (passkeys, 0..) |passkey, i| { 286 312 if (i != 0) try out.append(allocator, ','); 287 - const name = passkey.friendly_name orelse "unnamed passkey"; 288 - const item = try std.fmt.allocPrint(allocator, "{{\"id\":{f},\"name\":{f},\"createdAt\":{d}}}", .{ 313 + const credential_id = try webauthn.base64url.encodeAlloc(allocator, passkey.credential_id); 314 + const friendly_name = passkey.friendly_name orelse ""; 315 + const last_used = if (passkey.last_used) |value| 316 + try std.fmt.allocPrint(allocator, "{d}", .{value}) 317 + else 318 + "null"; 319 + const item = try std.fmt.allocPrint(allocator, "{{\"id\":{f},\"credentialId\":{f},\"friendlyName\":{f},\"createdAt\":{d},\"lastUsed\":{s}}}", .{ 289 320 std.json.fmt(passkey.id, .{}), 290 - std.json.fmt(name, .{}), 321 + std.json.fmt(credential_id, .{}), 322 + std.json.fmt(friendly_name, .{}), 291 323 passkey.created_at, 324 + last_used, 292 325 }); 293 326 try out.appendSlice(allocator, item); 294 327 } ··· 374 407 try http_api.json(request, status, body); 375 408 } 376 409 410 + fn requireBearerAccount(request: *http.Server.Request, allocator: std.mem.Allocator) !auth.Account { 411 + return http_api.requireBearerAccount(request, allocator) catch |err| { 412 + switch (err) { 413 + error.AuthRequired => try http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"), 414 + error.InvalidToken => try http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"), 415 + } 416 + return error.HandledResponse; 417 + }; 418 + } 419 + 377 420 fn respondHtml(request: *http.Server.Request, status: http.Status, body: []const u8) !void { 378 421 try request.respond(body, .{ 379 422 .status = status, ··· 398 441 return out.toOwnedSlice(allocator); 399 442 } 400 443 401 - const registrationScript = 444 + const securityScript = 402 445 \\const b64ToBuf=(v)=>{const b64=v.replace(/-/g,'+').replace(/_/g,'/');const bin=atob(b64+'==='.slice((b64.length+3)%4));const out=new Uint8Array(bin.length);for(let i=0;i<bin.length;i++)out[i]=bin.charCodeAt(i);return out.buffer}; 403 446 \\const bufToB64=(buf)=>btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,''); 404 447 \\const prepCreate=(o)=>{const p=o.publicKey;p.challenge=b64ToBuf(p.challenge);p.user.id=b64ToBuf(p.user.id);p.excludeCredentials=(p.excludeCredentials||[]).map(c=>({...c,id:b64ToBuf(c.id)}));return o}; 405 448 \\const serialize=(c)=>({id:c.id,rawId:bufToB64(c.rawId),type:c.type,response:{clientDataJSON:bufToB64(c.response.clientDataJSON),attestationObject:bufToB64(c.response.attestationObject)}}); 406 - \\const showExisting=(items,payload)=>{const box=document.querySelector('#passkey-list'),root=document.querySelector('#passkey-items');root.textContent='';box.classList.add('on');if(!items.length){const empty=document.createElement('p');empty.className='hint';empty.textContent='No passkeys are saved for this account yet.';root.append(empty);return}for(const item of items){const row=document.createElement('div');row.className='passkey-row';const label=document.createElement('span');label.textContent=item.name||'unnamed passkey';const del=document.createElement('button');del.type='button';del.textContent='forget';del.addEventListener('click',async()=>{if(!confirm('Forget this passkey on this PDS? You may also need to remove it from your browser or password manager.'))return;await fetch('/passkeys/delete',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({...payload,id:item.id})}).then(async r=>{if(!r.ok)throw new Error((await r.json()).error||'remove failed')});row.remove();if(!root.children.length){const empty=document.createElement('p');empty.className='hint';empty.textContent='No passkeys are saved for this account yet.';root.append(empty)}});row.append(label,del);root.append(row)}}; 407 - \\let pending=null; 408 - \\document.querySelector('#passkey-form').addEventListener('submit',async(e)=>{e.preventDefault();const f=e.currentTarget;const status=document.querySelector('#status');const button=f.querySelector('button');status.className='status';try{if(!pending){button.disabled=true;status.textContent='checking account...';const payload={identifier:f.identifier.value,password:f.password.value,friendlyName:f.friendlyName.value};const options=await fetch('/passkeys/register/options',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload)}).then(async r=>{if(!r.ok)throw new Error((await r.json()).error||'request failed');return r.json()});showExisting(options.existingPasskeys||[],payload);pending={payload,options:prepCreate(options)};button.disabled=false;button.textContent='save a new passkey';status.textContent='Account checked. Save a new passkey only if you want another device or password manager to work here.';return}const current=pending;pending=null;button.textContent='check account';status.textContent='waiting for the browser passkey prompt...';const credential=await navigator.credentials.create(current.options);await fetch('/passkeys/register/finish',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({...current.payload,credential:serialize(credential)})}).then(async r=>{if(!r.ok)throw new Error((await r.json()).error||'registration failed')});status.className='status ok';status.textContent='Passkey saved. You can now use “use passkey” on OAuth login for this account.';button.textContent='check account'}catch(err){pending=null;button.disabled=false;button.textContent='check account';status.className='status error';status.textContent=(err.name?err.name+': ':'')+(err.message||String(err))}}) 449 + \\let accessJwt=null,handle=null; 450 + \\const status=document.querySelector('#status'),accountBox=document.querySelector('#account'),itemsRoot=document.querySelector('#passkey-items'),loginForm=document.querySelector('#login-form'),accountLabel=document.querySelector('#account-label'); 451 + \\const authed=(url,opts={})=>fetch(url,{...opts,headers:{...(opts.headers||{}),authorization:`Bearer ${accessJwt}`}}); 452 + \\const fail=async(r,msg)=>{if(r.ok)return r.json();let body={};try{body=await r.json()}catch{}throw new Error(body.message||body.error||msg)}; 453 + \\const when=(seconds)=>seconds?new Date(seconds*1000).toLocaleString():'never used'; 454 + \\const show=(items)=>{itemsRoot.textContent='';if(!items.length){const p=document.createElement('p');p.className='hint';p.textContent='No passkeys are saved for this account yet.';itemsRoot.append(p);return}for(const item of items){const row=document.createElement('div');row.className='passkey-row';const label=document.createElement('span');label.className='passkey-name';label.textContent=item.friendlyName||'unnamed passkey';const meta=document.createElement('span');meta.className='passkey-meta';meta.textContent=`last used: ${when(item.lastUsed)}`;label.append(meta);const actions=document.createElement('span');actions.className='passkey-actions';const rename=document.createElement('button');rename.type='button';rename.textContent='rename';rename.onclick=async()=>{const name=prompt('Passkey name',item.friendlyName||'');if(!name)return;await fail(await authed('/xrpc/com.atproto.server.updatePasskey',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({id:item.id,friendlyName:name})}),'rename failed');await load()};const del=document.createElement('button');del.type='button';del.className='danger';del.textContent='delete';del.onclick=async()=>{if(!confirm('Delete this passkey from this PDS? Your browser or password manager may still keep its copy.'))return;await fail(await authed('/xrpc/com.atproto.server.deletePasskey',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({id:item.id})}),'delete failed');await load()};actions.append(rename,del);row.append(label,actions);itemsRoot.append(row)}}; 455 + \\const load=async()=>{const data=await fail(await authed('/xrpc/com.atproto.server.listPasskeys'),'failed to load passkeys');show(data.passkeys||[])}; 456 + \\loginForm.addEventListener('submit',async(e)=>{e.preventDefault();status.className='status';try{const f=e.currentTarget;status.textContent='signing in...';const session=await fail(await fetch('/xrpc/com.atproto.server.createSession',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({identifier:f.identifier.value,password:f.password.value})}),'sign in failed');accessJwt=session.accessJwt;handle=session.handle;loginForm.classList.add('off');accountBox.classList.add('on');accountLabel.innerHTML=`signed in as <strong>${handle}</strong>`;status.textContent='';await load()}catch(err){status.className='status error';status.textContent=err.message||String(err)}}); 457 + \\document.querySelector('#add-passkey').addEventListener('click',async()=>{status.className='status';try{const friendlyName=document.querySelector('#friendly').value;status.textContent='opening browser passkey prompt...';const start=await fail(await authed('/xrpc/com.atproto.server.startPasskeyRegistration',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({friendlyName})}),'registration failed');const credential=await navigator.credentials.create(prepCreate(start.options));await fail(await authed('/xrpc/com.atproto.server.finishPasskeyRegistration',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({friendlyName,credential:serialize(credential)})}),'registration failed');status.className='status ok';status.textContent='Passkey saved.';await load()}catch(err){status.className='status error';status.textContent=(err.name?err.name+': ':'')+(err.message||String(err))}}); 409 458 ; 410 459 411 460 test "validates webauthn credential key through tangled dependency" {
+11
src/storage/store.zig
··· 960 960 try conn.exec("DELETE FROM passkeys WHERE did = ? AND id = ?", .{ did, id }); 961 961 } 962 962 963 + pub fn updatePasskeyName(did: []const u8, id: []const u8, friendly_name: []const u8) !void { 964 + db_mutex.lockUncancelable(store_io); 965 + defer db_mutex.unlock(store_io); 966 + try requireInitialized(); 967 + try conn.exec( 968 + \\UPDATE passkeys 969 + \\SET friendly_name = ? 970 + \\WHERE did = ? AND id = ? 971 + , .{ friendly_name, did, id }); 972 + } 973 + 963 974 fn passkeyFromRow(allocator: std.mem.Allocator, row: zqlite.Row) !Passkey { 964 975 return .{ 965 976 .id = try allocator.dupe(u8, row.text(0)),