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

Configure Feed

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

add passkey oauth login

zzstoatzz (May 27, 2026, 2:16 AM -0500) 38e6dabe bfa0ae48

+521 -2
+2 -2
build.zig.zon
··· 13 13 .hash = "zqlite-0.0.1-RWLaYz6bmAAT7E_jxopXf-j5Ea8VQldnxsd6TU8sa0Bb", 14 14 }, 15 15 .webauthn = .{ 16 - .url = "git+https://tangled.org/zzstoatzz.io/webauthn?ref=main#252f7f33ba63bfb21911edecb0be4773f6432883", 17 - .hash = "webauthn-0.0.1--JitMhxtAACEz8AIen39HWsjUPDMx1ync0TA99tpU2q5", 16 + .url = "git+https://tangled.org/zzstoatzz.io/webauthn?ref=main#3815415a0aa4fc280411ef0b9a795a35c02d3b05", 17 + .hash = "webauthn-0.0.1--JitMv-LAACZoE0TF1Y4poG3EUvlk8q_qbz8NbLMPXAI", 18 18 }, 19 19 }, 20 20 .paths = .{
+8
src/atproto/oauth.zig
··· 219 219 \\<input id="username" name="username" autocomplete="username" placeholder="bufo.uk" value="{s}" autofocus> 220 220 \\<label for="password">password</label> 221 221 \\<input id="password" name="password" type="password" autocomplete="current-password"> 222 + \\<button id="passkey-login" type="button">use passkey</button> 222 223 \\<div class="actions"> 223 224 \\<button class="deny" name="deny" value="1" formnovalidate>deny</button> 224 225 \\<button>authorize</button> ··· 228 229 \\</div> 229 230 \\<p class="foot">{s}</p> 230 231 \\</main> 232 + \\<script> 233 + \\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}}; 234 + \\const bufToB64=(buf)=>btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,''); 235 + \\const prepGet=(o)=>{{const p=o.publicKey;p.challenge=b64ToBuf(p.challenge);p.allowCredentials=(p.allowCredentials||[]).map(c=>({{...c,id:b64ToBuf(c.id)}}));return o}}; 236 + \\const serializeGet=(c)=>({{id:c.id,rawId:bufToB64(c.rawId),type:c.type,response:{{clientDataJSON:bufToB64(c.response.clientDataJSON),authenticatorData:bufToB64(c.response.authenticatorData),signature:bufToB64(c.response.signature),userHandle:c.response.userHandle?bufToB64(c.response.userHandle):null}}}}); 237 + \\document.querySelector('#passkey-login')?.addEventListener('click',async()=>{{const button=document.querySelector('#passkey-login');const username=document.querySelector('#username').value;button.disabled=true;const label=button.textContent;button.textContent='checking passkey...';try{{const request_uri=document.querySelector('input[name=request_uri]').value;const options=await fetch('/oauth/passkey/options',{{method:'POST',headers:{{'content-type':'application/json'}},body:JSON.stringify({{request_uri,identifier:username}})}}).then(async r=>{{if(!r.ok)throw new Error((await r.json()).error||'passkey unavailable');return r.json()}});const credential=await navigator.credentials.get(prepGet(options));const result=await fetch('/oauth/passkey/finish',{{method:'POST',headers:{{'content-type':'application/json'}},body:JSON.stringify({{request_uri,credential:serializeGet(credential)}})}}).then(async r=>{{if(!r.ok)throw new Error((await r.json()).error||'passkey login failed');return r.json()}});location.href=result.redirect_uri}}catch(err){{button.disabled=false;button.textContent=err.message||label;setTimeout(()=>button.textContent=label,2800)}}}}); 238 + \\</script> 231 239 \\</body> 232 240 \\</html> 233 241 , .{
+15
src/http/router.zig
··· 17 17 oauth_token, 18 18 oauth_introspect, 19 19 oauth_revoke, 20 + oauth_passkey_options, 21 + oauth_passkey_finish, 22 + passkeys_page, 23 + passkeys_register_options, 24 + passkeys_register_finish, 20 25 atproto_did, 21 26 describe_server, 22 27 reserve_signing_key, ··· 81 86 if (method == .POST and std.mem.eql(u8, path, "/oauth/token")) return .oauth_token; 82 87 if (method == .POST and std.mem.eql(u8, path, "/oauth/introspect")) return .oauth_introspect; 83 88 if (method == .POST and std.mem.eql(u8, path, "/oauth/revoke")) return .oauth_revoke; 89 + if (method == .POST and std.mem.eql(u8, path, "/oauth/passkey/options")) return .oauth_passkey_options; 90 + if (method == .POST and std.mem.eql(u8, path, "/oauth/passkey/finish")) return .oauth_passkey_finish; 91 + if (method == .GET and std.mem.eql(u8, path, "/passkeys")) return .passkeys_page; 92 + if (method == .POST and std.mem.eql(u8, path, "/passkeys/register/options")) return .passkeys_register_options; 93 + if (method == .POST and std.mem.eql(u8, path, "/passkeys/register/finish")) return .passkeys_register_finish; 84 94 if (method == .GET and std.mem.eql(u8, path, "/.well-known/atproto-did")) return .atproto_did; 85 95 if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.server.describeServer")) return .describe_server; 86 96 if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.reserveSigningKey")) return .reserve_signing_key; ··· 150 160 try std.testing.expectEqual(Route.oauth_par, route(.POST, "/oauth/par")); 151 161 try std.testing.expectEqual(Route.oauth_authorize, route(.GET, "/oauth/authorize?request_uri=x")); 152 162 try std.testing.expectEqual(Route.oauth_token, route(.POST, "/oauth/token")); 163 + try std.testing.expectEqual(Route.oauth_passkey_options, route(.POST, "/oauth/passkey/options")); 164 + try std.testing.expectEqual(Route.oauth_passkey_finish, route(.POST, "/oauth/passkey/finish")); 165 + try std.testing.expectEqual(Route.passkeys_page, route(.GET, "/passkeys")); 166 + try std.testing.expectEqual(Route.passkeys_register_options, route(.POST, "/passkeys/register/options")); 167 + try std.testing.expectEqual(Route.passkeys_register_finish, route(.POST, "/passkeys/register/finish")); 153 168 try std.testing.expectEqual(Route.atproto_did, route(.GET, "/.well-known/atproto-did")); 154 169 try std.testing.expectEqual(Route.describe_server, route(.GET, "/xrpc/com.atproto.server.describeServer")); 155 170 try std.testing.expectEqual(Route.reserve_signing_key, route(.POST, "/xrpc/com.atproto.server.reserveSigningKey"));
+6
src/http/server.zig
··· 9 9 const build_options = @import("build_options"); 10 10 const log = @import("../core/log.zig"); 11 11 const http_api = @import("api.zig"); 12 + const passkeys = @import("../internal/passkeys.zig"); 12 13 const landing = @import("landing/mod.zig"); 13 14 const router = @import("router.zig"); 14 15 ··· 100 101 .oauth_token => try atproto_oauth.token(request), 101 102 .oauth_introspect => try atproto_oauth.introspect(request), 102 103 .oauth_revoke => try atproto_oauth.revoke(request), 104 + .oauth_passkey_options => try passkeys.loginStart(request), 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), 103 109 .atproto_did => try atproto_server.atprotoDid(request), 104 110 .describe_server => try atproto_server.describeServer(request), 105 111 .reserve_signing_key => try atproto_server.reserveSigningKey(request),
+339
src/internal/passkeys.zig
··· 1 1 const std = @import("std"); 2 + const auth = @import("../auth/tokens.zig"); 3 + const config = @import("../core/config.zig"); 4 + const http_api = @import("../http/api.zig"); 5 + const store = @import("../storage/store.zig"); 2 6 const webauthn = @import("webauthn"); 7 + const zat = @import("zat"); 8 + 9 + const http = std.http; 10 + 11 + const request_uri_prefix = "urn:ietf:params:oauth:request_uri:"; 12 + const challenge_ttl_seconds: i64 = 300; 3 13 4 14 pub const CredentialKey = struct { 5 15 bytes: []const u8, ··· 24 34 defer allocator.free(message); 25 35 try webauthn.crypto.verifyWebAuthnEs256(credential_public_key, signature_der, message); 26 36 } 37 + 38 + pub fn setupPage(request: *http.Server.Request) !void { 39 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 40 + defer arena.deinit(); 41 + const allocator = arena.allocator(); 42 + const body = try std.fmt.allocPrint(allocator, 43 + \\<!doctype html> 44 + \\<html lang="en"> 45 + \\<head> 46 + \\<meta charset="utf-8"> 47 + \\<meta name="viewport" content="width=device-width, initial-scale=1"> 48 + \\<title>zds passkeys</title> 49 + \\<style> 50 + \\:root{{color-scheme:dark;--bg:#070807;--panel:#101510;--line:#263326;--text:#f1f2ec;--muted:#a7a299;--accent:#8fb0ff;--green:#37c978;--field:#101410;--bad:#ffb4a8}} 51 + \\@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}}}} 52 + \\*{{box-sizing:border-box}}body{{margin:0;min-height:100vh;background:radial-gradient(circle at 18% 0,color-mix(in srgb,var(--green) 20%,transparent),transparent 34%),var(--bg);color:var(--text);font:16px/1.55 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono",monospace}} 53 + \\.shell{{width:min(100%,760px);margin:0 auto;padding:28px 16px 42px}}a{{color:inherit}} 54 + \\.brand{{display:inline-flex;margin-bottom:58px;text-decoration:none;font-weight:800}}h1{{margin:0;font-size:clamp(46px,16vw,96px);line-height:.88;letter-spacing:0}}p{{color:var(--muted);margin:14px 0 0;max-width:54ch}}strong{{color:var(--text)}} 55 + \\.panel{{margin-top:30px;border:1px solid var(--line);border-radius:10px;background:linear-gradient(135deg,color-mix(in srgb,var(--green) 10%,transparent),transparent 46%),linear-gradient(315deg,color-mix(in srgb,var(--accent) 8%,transparent),transparent 44%),var(--panel);padding:18px}} 56 + \\label{{display:block;margin:15px 0 6px;font-weight:760}}input{{width:100%;font:inherit;color:var(--text);background:var(--field);border:1px solid var(--line);border-radius:9px;padding:12px}} 57 + \\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 + \\.status{{min-height:1.5em;color:var(--muted)}}.error{{color:var(--bad)}}.ok{{color:var(--green)}}code{{overflow-wrap:anywhere;color:var(--accent)}} 59 + \\</style> 60 + \\</head> 61 + \\<body> 62 + \\<main class="shell"> 63 + \\<a class="brand" href="/">zds</a> 64 + \\<h1>passkeys</h1> 65 + \\<p>Register a passkey for an account hosted on <strong>{s}</strong>. Password sign-in stays available; this adds a WebAuthn credential for the OAuth login page.</p> 66 + \\<section class="panel"> 67 + \\<form id="passkey-form"> 68 + \\<label for="identifier">account</label> 69 + \\<input id="identifier" name="identifier" autocomplete="username" required> 70 + \\<label for="password">password</label> 71 + \\<input id="password" name="password" type="password" autocomplete="current-password" required> 72 + \\<label for="friendly">passkey name</label> 73 + \\<input id="friendly" name="friendlyName" placeholder="this device"> 74 + \\<button>register passkey</button> 75 + \\</form> 76 + \\<p id="status" class="status"></p> 77 + \\</section> 78 + \\</main> 79 + \\<script>{s}</script> 80 + \\</body> 81 + \\</html> 82 + , .{ try htmlEscape(allocator, displayHost(config.publicUrl())), registrationScript }); 83 + try respondHtml(request, .ok, body); 84 + } 85 + 86 + pub fn registerStart(request: *http.Server.Request) !void { 87 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 88 + defer arena.deinit(); 89 + const allocator = arena.allocator(); 90 + const parsed = try readJson(request, allocator); 91 + const identifier = requiredString(parsed.value, "identifier") orelse return jsonError(request, .bad_request, "Missing identifier"); 92 + const password = requiredString(parsed.value, "password") orelse return jsonError(request, .bad_request, "Missing password"); 93 + const account = (try store.findAccount(allocator, identifier)) orelse return jsonError(request, .unauthorized, "Invalid identifier or password"); 94 + if (!auth.passwordMatches(account, password)) return jsonError(request, .unauthorized, "Invalid identifier or password"); 95 + 96 + const challenge = try newChallenge(allocator); 97 + try store.putWebAuthnChallenge(account.did, "registration", challenge, "{}", now() + challenge_ttl_seconds); 98 + const rp_id = try rpId(allocator); 99 + const user_id = try userId(allocator, account.did); 100 + const passkeys = try store.listPasskeys(allocator, account.did); 101 + const exclude = try credentialsJson(allocator, passkeys); 102 + const body = try std.fmt.allocPrint(allocator, 103 + \\{{"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":"preferred","userVerification":"required"}},"attestation":"none"}}}} 104 + , .{ 105 + std.json.fmt(rp_id, .{}), 106 + std.json.fmt(user_id, .{}), 107 + std.json.fmt(account.handle, .{}), 108 + std.json.fmt(account.handle, .{}), 109 + std.json.fmt(challenge, .{}), 110 + exclude, 111 + }); 112 + try http_api.json(request, .ok, body); 113 + } 114 + 115 + pub fn registerFinish(request: *http.Server.Request) !void { 116 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 117 + defer arena.deinit(); 118 + const allocator = arena.allocator(); 119 + const parsed = try readJson(request, allocator); 120 + const identifier = requiredString(parsed.value, "identifier") orelse return jsonError(request, .bad_request, "Missing identifier"); 121 + const password = requiredString(parsed.value, "password") orelse return jsonError(request, .bad_request, "Missing password"); 122 + const account = (try store.findAccount(allocator, identifier)) orelse return jsonError(request, .unauthorized, "Invalid identifier or password"); 123 + if (!auth.passwordMatches(account, password)) return jsonError(request, .unauthorized, "Invalid identifier or password"); 124 + const challenge = (try store.getWebAuthnChallenge(allocator, account.did, "registration")) orelse return jsonError(request, .bad_request, "Missing registration challenge"); 125 + if (challenge.expires_at < now()) return jsonError(request, .bad_request, "Registration challenge expired"); 126 + 127 + const credential = try registrationResponse(parsed.value); 128 + const verified = webauthn.registration.verify(allocator, credential, .{ 129 + .challenge = challenge.challenge, 130 + .origin = config.publicUrl(), 131 + .rp_id = try rpId(allocator), 132 + .user_verification = .required, 133 + }) catch return jsonError(request, .bad_request, "Invalid passkey registration response"); 134 + const friendly = requiredString(parsed.value, "friendlyName"); 135 + const id = try store.savePasskey(allocator, account.did, verified.credential_id, verified.credential_public_key, verified.sign_count, friendly); 136 + try store.deleteWebAuthnChallenge(account.did, "registration"); 137 + const body = try std.fmt.allocPrint(allocator, "{{\"ok\":true,\"id\":{f}}}", .{std.json.fmt(id, .{})}); 138 + try http_api.json(request, .ok, body); 139 + } 140 + 141 + pub fn loginStart(request: *http.Server.Request) !void { 142 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 143 + defer arena.deinit(); 144 + const allocator = arena.allocator(); 145 + const parsed = try readJson(request, allocator); 146 + const request_uri = requiredString(parsed.value, "request_uri") orelse return jsonError(request, .bad_request, "Missing request_uri"); 147 + const request_id = requestIdFromUri(request_uri) orelse return jsonError(request, .bad_request, "Invalid request_uri"); 148 + const oauth_request = (try store.getOAuthRequest(allocator, request_id)) orelse return jsonError(request, .bad_request, "Unknown request_uri"); 149 + if (oauth_request.expires_at < now()) return jsonError(request, .bad_request, "Expired request_uri"); 150 + const identifier = requiredString(parsed.value, "identifier") orelse oauth_request.login_hint orelse return jsonError(request, .bad_request, "Enter an account before using a passkey"); 151 + const account = (try store.findAccount(allocator, identifier)) orelse return jsonError(request, .not_found, "Account not found"); 152 + const passkeys = try store.listPasskeys(allocator, account.did); 153 + if (passkeys.len == 0) return jsonError(request, .bad_request, "No passkeys are registered for this account"); 154 + const challenge = try newChallenge(allocator); 155 + try store.putWebAuthnChallenge(account.did, "login", challenge, request_id, now() + challenge_ttl_seconds); 156 + const allow = try credentialsJson(allocator, passkeys); 157 + const body = try std.fmt.allocPrint(allocator, 158 + \\{{"publicKey":{{"challenge":{f},"rpId":{f},"allowCredentials":{s},"timeout":60000,"userVerification":"required"}}}} 159 + , .{ std.json.fmt(challenge, .{}), std.json.fmt(try rpId(allocator), .{}), allow }); 160 + try http_api.json(request, .ok, body); 161 + } 162 + 163 + pub fn loginFinish(request: *http.Server.Request) !void { 164 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 165 + defer arena.deinit(); 166 + const allocator = arena.allocator(); 167 + const parsed = try readJson(request, allocator); 168 + const request_uri = requiredString(parsed.value, "request_uri") orelse return jsonError(request, .bad_request, "Missing request_uri"); 169 + const request_id = requestIdFromUri(request_uri) orelse return jsonError(request, .bad_request, "Invalid request_uri"); 170 + const oauth_request = (try store.getOAuthRequest(allocator, request_id)) orelse return jsonError(request, .bad_request, "Unknown request_uri"); 171 + if (oauth_request.expires_at < now()) return jsonError(request, .bad_request, "Expired request_uri"); 172 + 173 + const credential = try assertionResponse(parsed.value); 174 + const credential_id = try webauthn.base64url.decodeAlloc(allocator, credential.raw_id); 175 + const passkey = (try store.getPasskeyByCredentialId(allocator, credential_id)) orelse return jsonError(request, .unauthorized, "Unknown passkey"); 176 + const challenge = (try store.getWebAuthnChallenge(allocator, passkey.did, "login")) orelse return jsonError(request, .bad_request, "Missing login challenge"); 177 + if (challenge.expires_at < now()) return jsonError(request, .bad_request, "Login challenge expired"); 178 + if (!std.mem.eql(u8, challenge.state_json, oauth_request.request_id)) return jsonError(request, .bad_request, "Login challenge does not match this request"); 179 + 180 + const assertion = webauthn.assertion.verify(allocator, credential, .{ 181 + .challenge = challenge.challenge, 182 + .origin = config.publicUrl(), 183 + .rp_id = try rpId(allocator), 184 + .credential_public_key = passkey.public_key, 185 + .known_sign_count = passkey.sign_count, 186 + .user_verification = .required, 187 + }) catch return jsonError(request, .unauthorized, "Invalid passkey assertion"); 188 + 189 + const code = try store.randomToken(allocator, "", 16); 190 + try store.updatePasskeyUse(passkey.id, assertion.recommended_sign_count); 191 + try store.deleteWebAuthnChallenge(passkey.did, "login"); 192 + try store.authorizeOAuthRequest(oauth_request.request_id, passkey.did, code); 193 + const redirect_uri = try authorizationRedirect(allocator, oauth_request.redirect_uri, code, oauth_request.state, oauth_request.response_mode); 194 + const body = try std.fmt.allocPrint(allocator, "{{\"redirect_uri\":{f}}}", .{std.json.fmt(redirect_uri, .{})}); 195 + try http_api.json(request, .ok, body); 196 + } 197 + 198 + fn readJson(request: *http.Server.Request, allocator: std.mem.Allocator) !std.json.Parsed(std.json.Value) { 199 + const body = try http_api.readBodyAlloc(request, allocator, 64 * 1024); 200 + return std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch { 201 + try jsonError(request, .bad_request, "Expected JSON body"); 202 + return error.InvalidJson; 203 + }; 204 + } 205 + 206 + fn requiredString(value: std.json.Value, key: []const u8) ?[]const u8 { 207 + return zat.json.getString(value, key); 208 + } 209 + 210 + fn registrationResponse(value: std.json.Value) !webauthn.registration.Response { 211 + const root = try objectValue(value); 212 + const credential = root.get("credential") orelse return error.MissingField; 213 + const credential_object = try objectValue(credential); 214 + const response = credential_object.get("response") orelse return error.MissingField; 215 + return .{ 216 + .id = zat.json.getString(credential, "id") orelse return error.MissingField, 217 + .raw_id = zat.json.getString(credential, "rawId") orelse return error.MissingField, 218 + .client_data_json = zat.json.getString(response, "clientDataJSON") orelse return error.MissingField, 219 + .attestation_object = zat.json.getString(response, "attestationObject") orelse return error.MissingField, 220 + }; 221 + } 222 + 223 + fn assertionResponse(value: std.json.Value) !webauthn.assertion.Response { 224 + const root = try objectValue(value); 225 + const credential = root.get("credential") orelse return error.MissingField; 226 + const credential_object = try objectValue(credential); 227 + const response = credential_object.get("response") orelse return error.MissingField; 228 + return .{ 229 + .id = zat.json.getString(credential, "id") orelse return error.MissingField, 230 + .raw_id = zat.json.getString(credential, "rawId") orelse return error.MissingField, 231 + .client_data_json = zat.json.getString(response, "clientDataJSON") orelse return error.MissingField, 232 + .authenticator_data = zat.json.getString(response, "authenticatorData") orelse return error.MissingField, 233 + .signature = zat.json.getString(response, "signature") orelse return error.MissingField, 234 + }; 235 + } 236 + 237 + fn objectValue(value: std.json.Value) !std.json.ObjectMap { 238 + return switch (value) { 239 + .object => |object| object, 240 + else => error.MissingField, 241 + }; 242 + } 243 + 244 + fn credentialsJson(allocator: std.mem.Allocator, passkeys: []const store.Passkey) ![]const u8 { 245 + var out: std.ArrayList(u8) = .empty; 246 + try out.append(allocator, '['); 247 + for (passkeys, 0..) |passkey, i| { 248 + if (i != 0) try out.append(allocator, ','); 249 + const id = try webauthn.base64url.encodeAlloc(allocator, passkey.credential_id); 250 + const item = try std.fmt.allocPrint(allocator, "{{\"type\":\"public-key\",\"id\":{f}}}", .{std.json.fmt(id, .{})}); 251 + try out.appendSlice(allocator, item); 252 + } 253 + try out.append(allocator, ']'); 254 + return out.toOwnedSlice(allocator); 255 + } 256 + 257 + fn newChallenge(allocator: std.mem.Allocator) ![]u8 { 258 + var bytes: [32]u8 = undefined; 259 + store.randomBytes(&bytes); 260 + return webauthn.base64url.encodeAlloc(allocator, &bytes); 261 + } 262 + 263 + fn userId(allocator: std.mem.Allocator, did: []const u8) ![]u8 { 264 + var hash: [32]u8 = undefined; 265 + std.crypto.hash.sha2.Sha256.hash(did, &hash, .{}); 266 + return webauthn.base64url.encodeAlloc(allocator, &hash); 267 + } 268 + 269 + fn rpId(allocator: std.mem.Allocator) ![]const u8 { 270 + const url = config.publicUrl(); 271 + const without_scheme = if (std.mem.startsWith(u8, url, "https://")) 272 + url["https://".len..] 273 + else if (std.mem.startsWith(u8, url, "http://")) 274 + url["http://".len..] 275 + else 276 + url; 277 + const host_end = std.mem.indexOfAny(u8, without_scheme, "/:") orelse without_scheme.len; 278 + return allocator.dupe(u8, without_scheme[0..host_end]); 279 + } 280 + 281 + fn displayHost(url: []const u8) []const u8 { 282 + const without_scheme = if (std.mem.startsWith(u8, url, "https://")) 283 + url["https://".len..] 284 + else if (std.mem.startsWith(u8, url, "http://")) 285 + url["http://".len..] 286 + else 287 + url; 288 + const end = std.mem.indexOfScalar(u8, without_scheme, '/') orelse without_scheme.len; 289 + return without_scheme[0..end]; 290 + } 291 + 292 + fn requestIdFromUri(request_uri: []const u8) ?[]const u8 { 293 + if (!std.mem.startsWith(u8, request_uri, request_uri_prefix)) return null; 294 + return request_uri[request_uri_prefix.len..]; 295 + } 296 + 297 + fn authorizationRedirect(allocator: std.mem.Allocator, redirect_uri: []const u8, code: []const u8, state: []const u8, response_mode: []const u8) ![]const u8 { 298 + const sep: u8 = if (std.mem.eql(u8, response_mode, "fragment")) '#' else if (std.mem.indexOfScalar(u8, redirect_uri, '?') == null) '?' else '&'; 299 + return std.fmt.allocPrint(allocator, "{s}{c}code={s}&iss={s}&state={s}", .{ 300 + redirect_uri, 301 + sep, 302 + try percentEncode(allocator, code), 303 + try percentEncode(allocator, config.publicUrl()), 304 + try percentEncode(allocator, state), 305 + }); 306 + } 307 + 308 + fn percentEncode(allocator: std.mem.Allocator, input: []const u8) ![]const u8 { 309 + var out: std.ArrayList(u8) = .empty; 310 + for (input) |c| { 311 + if ((c >= 'a' and c <= 'z') or (c >= 'A' and c <= 'Z') or (c >= '0' and c <= '9') or c == '-' or c == '_' or c == '.' or c == '~') { 312 + try out.append(allocator, c); 313 + } else { 314 + const encoded = try std.fmt.allocPrint(allocator, "%{X:0>2}", .{c}); 315 + try out.appendSlice(allocator, encoded); 316 + } 317 + } 318 + return out.toOwnedSlice(allocator); 319 + } 320 + 321 + fn now() i64 { 322 + var ts: std.posix.timespec = undefined; 323 + return switch (std.posix.errno(std.posix.system.clock_gettime(.REALTIME, &ts))) { 324 + .SUCCESS => ts.sec, 325 + else => 0, 326 + }; 327 + } 328 + 329 + fn jsonError(request: *http.Server.Request, status: http.Status, message: []const u8) !void { 330 + var buf: [512]u8 = undefined; 331 + const body = try std.fmt.bufPrint(&buf, "{{\"error\":{f}}}", .{std.json.fmt(message, .{})}); 332 + try http_api.json(request, status, body); 333 + } 334 + 335 + fn respondHtml(request: *http.Server.Request, status: http.Status, body: []const u8) !void { 336 + try request.respond(body, .{ 337 + .status = status, 338 + .extra_headers = &[_]http.Header{ 339 + .{ .name = "content-type", .value = "text/html; charset=utf-8" }, 340 + .{ .name = "access-control-allow-origin", .value = "*" }, 341 + .{ .name = "connection", .value = "close" }, 342 + }, 343 + }); 344 + } 345 + 346 + fn htmlEscape(allocator: std.mem.Allocator, value: []const u8) ![]const u8 { 347 + var out: std.ArrayList(u8) = .empty; 348 + for (value) |c| switch (c) { 349 + '&' => try out.appendSlice(allocator, "&amp;"), 350 + '<' => try out.appendSlice(allocator, "&lt;"), 351 + '>' => try out.appendSlice(allocator, "&gt;"), 352 + '"' => try out.appendSlice(allocator, "&quot;"), 353 + '\'' => try out.appendSlice(allocator, "&#39;"), 354 + else => try out.append(allocator, c), 355 + }; 356 + return out.toOwnedSlice(allocator); 357 + } 358 + 359 + const registrationScript = 360 + \\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}; 361 + \\const bufToB64=(buf)=>btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,''); 362 + \\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}; 363 + \\const serialize=(c)=>({id:c.id,rawId:bufToB64(c.rawId),type:c.type,response:{clientDataJSON:bufToB64(c.response.clientDataJSON),attestationObject:bufToB64(c.response.attestationObject)}}); 364 + \\document.querySelector('#passkey-form').addEventListener('submit',async(e)=>{e.preventDefault();const f=e.currentTarget;const status=document.querySelector('#status');status.className='status';status.textContent='asking your authenticator...';try{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()});const credential=await navigator.credentials.create(prepCreate(options));await fetch('/passkeys/register/finish',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({...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 registered.'}catch(err){status.className='status error';status.textContent=err.message||String(err)}}) 365 + ; 27 366 28 367 test "validates webauthn credential key through tangled dependency" { 29 368 const key = try webauthn.base64url.decodeAlloc(std.testing.allocator, "pQECAyYgASFYIDNDxl6djmZTEhKfw1B5jiSdcFUsTKuyPpks-4jTpA5aIlggF5oAEvUgwjYE6o0sPzL6G27d72m3lM2-yPAMOajmYoE");
+151
src/storage/store.zig
··· 88 88 revoked: bool, 89 89 }; 90 90 91 + pub const Passkey = struct { 92 + id: []const u8, 93 + did: []const u8, 94 + credential_id: []const u8, 95 + public_key: []const u8, 96 + sign_count: u32, 97 + friendly_name: ?[]const u8, 98 + created_at: i64, 99 + last_used: ?i64, 100 + }; 101 + 102 + pub const WebAuthnChallenge = struct { 103 + id: []const u8, 104 + did: []const u8, 105 + challenge: []const u8, 106 + kind: []const u8, 107 + state_json: []const u8, 108 + expires_at: i64, 109 + }; 110 + 91 111 pub const InviteCode = struct { 92 112 code: []const u8, 93 113 available: i64, ··· 836 856 \\SET revoked_at = unixepoch() 837 857 \\WHERE access_token = ? OR refresh_token = ? 838 858 , .{ token, token }); 859 + } 860 + 861 + pub fn putWebAuthnChallenge(did: []const u8, kind: []const u8, challenge: []const u8, state_json: []const u8, expires_at: i64) !void { 862 + db_mutex.lockUncancelable(store_io); 863 + defer db_mutex.unlock(store_io); 864 + try requireInitialized(); 865 + const id = try randomToken(std.heap.page_allocator, "wan-", 16); 866 + defer std.heap.page_allocator.free(id); 867 + try conn.exec("DELETE FROM webauthn_challenges WHERE did = ? AND kind = ?", .{ did, kind }); 868 + try conn.exec( 869 + \\INSERT INTO webauthn_challenges (id, did, challenge, kind, state_json, expires_at) 870 + \\VALUES (?, ?, ?, ?, ?, ?) 871 + , .{ id, did, challenge, kind, state_json, expires_at }); 872 + } 873 + 874 + pub fn getWebAuthnChallenge(allocator: std.mem.Allocator, did: []const u8, kind: []const u8) !?WebAuthnChallenge { 875 + db_mutex.lockUncancelable(store_io); 876 + defer db_mutex.unlock(store_io); 877 + try requireInitialized(); 878 + const row = try conn.row( 879 + \\SELECT id, did, challenge, kind, state_json, expires_at 880 + \\FROM webauthn_challenges 881 + \\WHERE did = ? AND kind = ? 882 + , .{ did, kind }); 883 + if (row == null) return null; 884 + defer row.?.deinit(); 885 + return .{ 886 + .id = try allocator.dupe(u8, row.?.text(0)), 887 + .did = try allocator.dupe(u8, row.?.text(1)), 888 + .challenge = try allocator.dupe(u8, row.?.text(2)), 889 + .kind = try allocator.dupe(u8, row.?.text(3)), 890 + .state_json = try allocator.dupe(u8, row.?.text(4)), 891 + .expires_at = row.?.int(5), 892 + }; 893 + } 894 + 895 + pub fn deleteWebAuthnChallenge(did: []const u8, kind: []const u8) !void { 896 + db_mutex.lockUncancelable(store_io); 897 + defer db_mutex.unlock(store_io); 898 + try requireInitialized(); 899 + try conn.exec("DELETE FROM webauthn_challenges WHERE did = ? AND kind = ?", .{ did, kind }); 900 + } 901 + 902 + pub fn savePasskey(allocator: std.mem.Allocator, did: []const u8, credential_id: []const u8, public_key: []const u8, sign_count: u32, friendly_name: ?[]const u8) ![]const u8 { 903 + db_mutex.lockUncancelable(store_io); 904 + defer db_mutex.unlock(store_io); 905 + try requireInitialized(); 906 + const id = try randomToken(allocator, "pk-", 16); 907 + try conn.exec( 908 + \\INSERT INTO passkeys (id, did, credential_id, public_key, sign_count, friendly_name) 909 + \\VALUES (?, ?, ?, ?, ?, ?) 910 + , .{ id, did, zqlite.blob(credential_id), zqlite.blob(public_key), @as(i64, @intCast(sign_count)), friendly_name }); 911 + return id; 912 + } 913 + 914 + pub fn getPasskeyByCredentialId(allocator: std.mem.Allocator, credential_id: []const u8) !?Passkey { 915 + db_mutex.lockUncancelable(store_io); 916 + defer db_mutex.unlock(store_io); 917 + try requireInitialized(); 918 + const row = try conn.row( 919 + \\SELECT id, did, credential_id, public_key, sign_count, friendly_name, created_at, last_used 920 + \\FROM passkeys 921 + \\WHERE credential_id = ? 922 + , .{zqlite.blob(credential_id)}); 923 + if (row == null) return null; 924 + defer row.?.deinit(); 925 + return try passkeyFromRow(allocator, row.?); 926 + } 927 + 928 + pub fn listPasskeys(allocator: std.mem.Allocator, did: []const u8) ![]Passkey { 929 + db_mutex.lockUncancelable(store_io); 930 + defer db_mutex.unlock(store_io); 931 + try requireInitialized(); 932 + var rows = try conn.rows( 933 + \\SELECT id, did, credential_id, public_key, sign_count, friendly_name, created_at, last_used 934 + \\FROM passkeys 935 + \\WHERE did = ? 936 + \\ORDER BY created_at DESC 937 + , .{did}); 938 + defer rows.deinit(); 939 + var out: std.ArrayList(Passkey) = .empty; 940 + while (rows.next()) |row| try out.append(allocator, try passkeyFromRow(allocator, row)); 941 + if (rows.err) |err| return err; 942 + return out.toOwnedSlice(allocator); 943 + } 944 + 945 + pub fn updatePasskeyUse(id: []const u8, sign_count: u32) !void { 946 + db_mutex.lockUncancelable(store_io); 947 + defer db_mutex.unlock(store_io); 948 + try requireInitialized(); 949 + try conn.exec( 950 + \\UPDATE passkeys 951 + \\SET sign_count = ?, last_used = unixepoch() 952 + \\WHERE id = ? 953 + , .{ @as(i64, @intCast(sign_count)), id }); 954 + } 955 + 956 + fn passkeyFromRow(allocator: std.mem.Allocator, row: zqlite.Row) !Passkey { 957 + return .{ 958 + .id = try allocator.dupe(u8, row.text(0)), 959 + .did = try allocator.dupe(u8, row.text(1)), 960 + .credential_id = try allocator.dupe(u8, row.blob(2)), 961 + .public_key = try allocator.dupe(u8, row.blob(3)), 962 + .sign_count = @intCast(row.int(4)), 963 + .friendly_name = if (row.nullableText(5)) |text| try allocator.dupe(u8, text) else null, 964 + .created_at = row.int(6), 965 + .last_used = row.nullableInt(7), 966 + }; 839 967 } 840 968 841 969 pub fn put( ··· 3305 3433 \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()) 3306 3434 \\) 3307 3435 , 3436 + \\CREATE TABLE IF NOT EXISTS passkeys ( 3437 + \\ id TEXT PRIMARY KEY, 3438 + \\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE, 3439 + \\ credential_id BLOB NOT NULL UNIQUE, 3440 + \\ public_key BLOB NOT NULL, 3441 + \\ sign_count INTEGER NOT NULL DEFAULT 0, 3442 + \\ friendly_name TEXT, 3443 + \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()), 3444 + \\ last_used INTEGER 3445 + \\) 3446 + , 3447 + "CREATE INDEX IF NOT EXISTS passkeys_did_idx ON passkeys (did)", 3448 + \\CREATE TABLE IF NOT EXISTS webauthn_challenges ( 3449 + \\ id TEXT PRIMARY KEY, 3450 + \\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE, 3451 + \\ challenge TEXT NOT NULL, 3452 + \\ kind TEXT NOT NULL, 3453 + \\ state_json TEXT NOT NULL, 3454 + \\ expires_at INTEGER NOT NULL, 3455 + \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()) 3456 + \\) 3457 + , 3458 + "CREATE INDEX IF NOT EXISTS webauthn_challenges_did_kind_idx ON webauthn_challenges (did, kind)", 3308 3459 \\CREATE TABLE IF NOT EXISTS invite_codes ( 3309 3460 \\ code TEXT PRIMARY KEY, 3310 3461 \\ available_uses INTEGER NOT NULL,