atproto pds in zig pds.zat.dev
pds atproto
24

Configure Feed

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

add app password security UI

zzstoatzz (May 29, 2026, 4:12 PM -0500) 5ca7e422 905c52e2

+38 -13
+7
docs/account-security.md
··· 35 35 the app-password name. Revoking an app password deletes the credential and 36 36 revokes active sessions that were created with it. 37 37 38 + ## management UI 39 + 40 + `/security` is the local account-security surface. It signs in with the account 41 + password, then lists passkeys and app passwords for that account. App passwords 42 + can be created and revoked from the page; the generated password is displayed 43 + once and is not recoverable later because only the hash is stored. 44 + 38 45 ## references 39 46 40 47 - official PDS: app passwords are account-owned rows keyed by DID and name, and
+3 -2
docs/passkeys.md
··· 32 32 33 33 ## management 34 34 35 - Passkeys are managed from `/security`. That page signs in with the account 36 - password, lists saved passkeys, and supports add, rename, and delete. 35 + Passkeys are managed from `/security`, alongside app passwords. That page signs 36 + in with the account password, lists saved passkeys, and supports add, rename, 37 + and delete. 37 38 38 39 Deleting a passkey removes it from ZDS. The browser, OS, or password manager 39 40 may still keep its copy, but it can no longer be used with this PDS.
+28 -11
src/internal/passkeys.zig
··· 66 66 \\<meta charset="utf-8"> 67 67 \\<meta name="viewport" content="width=device-width, initial-scale=1"> 68 68 \\<title>zds security</title> 69 - \\<meta name="description" content="manage passkeys for accounts hosted on {s}"> 69 + \\<meta name="description" content="manage account security for accounts hosted on {s}"> 70 70 \\<link rel="canonical" href="{s}"> 71 71 \\<meta property="og:type" content="website"> 72 72 \\<meta property="og:site_name" content="zds"> 73 73 \\<meta property="og:title" content="zds security"> 74 - \\<meta property="og:description" content="manage passkeys for accounts hosted on {s}"> 74 + \\<meta property="og:description" content="manage account security for accounts hosted on {s}"> 75 75 \\<meta property="og:url" content="{s}"> 76 76 \\<meta property="og:image" content="{s}"> 77 77 \\<meta property="og:image:width" content="1200"> 78 78 \\<meta property="og:image:height" content="630"> 79 79 \\<meta name="twitter:card" content="summary_large_image"> 80 80 \\<meta name="twitter:title" content="zds security"> 81 - \\<meta name="twitter:description" content="manage passkeys for accounts hosted on {s}"> 81 + \\<meta name="twitter:description" content="manage account security for accounts hosted on {s}"> 82 82 \\<meta name="twitter:image" content="{s}"> 83 83 \\<link rel="icon" href="/favicon.svg" type="image/svg+xml"> 84 84 \\<style> ··· 93 93 \\.status{{min-height:1.5em;color:var(--muted)}}.error{{color:var(--bad)}}.ok{{color:var(--green)}}code{{overflow-wrap:anywhere;color:var(--accent)}} 94 94 \\.hint{{font-size:.92rem;color:var(--muted);margin:8px 0 0}} 95 95 \\.account{{display:none;margin-top:18px}}.account.on{{display:block}}.login.off{{display:none}} 96 - \\.passkey-list{{margin-top:18px;border-top:1px solid var(--line);padding-top:12px}}.passkey-list h2{{font-size:1rem;margin:0 0 8px}} 97 - \\.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)}} 98 - \\.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))}} 96 + \\.security-section{{margin-top:22px;border-top:1px solid var(--line);padding-top:16px}}.security-section h2{{font-size:1rem;margin:0 0 6px}}.security-section p{{margin-top:0}} 97 + \\.credential-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)}} 98 + \\.credential-name{{font-weight:760;overflow:hidden;text-overflow:ellipsis}}.credential-meta{{display:block;color:var(--muted);font-size:.82rem;font-weight:400}}.credential-actions{{display:flex;gap:8px;flex:0 0 auto}}.credential-row button{{width:auto;margin:0;padding:7px 10px;background:transparent;color:var(--text);border-color:var(--line)}}.credential-row button.danger{{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 45%,var(--line))}} 99 + \\.generated{{display:none;margin-top:14px;border:1px solid color-mix(in srgb,var(--green) 42%,var(--line));border-radius:9px;padding:12px;background:color-mix(in srgb,var(--green) 9%,transparent)}}.generated.on{{display:block}}.generated code{{display:block;margin-top:6px;font-size:1.1rem;color:var(--text);overflow-wrap:anywhere}}.inline{{display:flex;gap:10px;align-items:center}}.inline input{{flex:1}}.checkbox{{display:flex;gap:8px;align-items:center;margin:10px 0;color:var(--muted);font-weight:400}}.checkbox input{{width:auto}} 99 100 \\</style> 100 101 \\</head> 101 102 \\<body> 102 103 \\<main class="shell"> 103 104 \\<a class="brand" href="/">zds</a> 104 105 \\<h1>security</h1> 105 - \\<p>Sign in to manage passkeys for an account hosted on <strong>{s}</strong>.</p> 106 + \\<p>Sign in to manage passkeys and app passwords for an account hosted on <strong>{s}</strong>.</p> 106 107 \\<section class="panel"> 107 108 \\<form id="login-form" class="login"> 108 109 \\<label for="identifier">account</label> ··· 114 115 \\<p id="status" class="status"></p> 115 116 \\<section id="account" class="account" aria-live="polite"> 116 117 \\<p id="account-label"></p> 117 - \\<h2>saved passkeys</h2> 118 + \\<section class="security-section"> 119 + \\<h2>passkeys</h2> 120 + \\<p class="hint">Use a passkey to sign in without typing this account password.</p> 118 121 \\<div id="passkey-items"></div> 119 122 \\<label for="friendly">new passkey name</label> 120 123 \\<input id="friendly" name="friendlyName" placeholder="this device"> 121 124 \\<button id="add-passkey" type="button">add passkey</button> 122 125 \\<p class="hint">Removing a passkey here forgets it on this PDS; your browser or password manager may still keep its copy.</p> 126 + \\</section> 127 + \\<section class="security-section"> 128 + \\<h2>app passwords</h2> 129 + \\<p class="hint">Use an app password for clients that should not receive your main account password.</p> 130 + \\<div id="app-password-items"></div> 131 + \\<label for="app-password-name">new app password name</label> 132 + \\<input id="app-password-name" name="appPasswordName" placeholder="client or device name"> 133 + \\<label class="checkbox"><input id="app-password-privileged" type="checkbox"> privileged app password</label> 134 + \\<button id="create-app-password" type="button">create app password</button> 135 + \\<div id="generated-app-password" class="generated" aria-live="polite"></div> 136 + \\</section> 123 137 \\</section> 124 138 \\</section> 125 139 \\</main> ··· 525 539 \\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}; 526 540 \\const serialize=(c)=>({id:c.id,rawId:bufToB64(c.rawId),type:c.type,response:{clientDataJSON:bufToB64(c.response.clientDataJSON),attestationObject:bufToB64(c.response.attestationObject)}}); 527 541 \\let accessJwt=null,handle=null; 528 - \\const status=document.querySelector('#status'),accountBox=document.querySelector('#account'),itemsRoot=document.querySelector('#passkey-items'),loginForm=document.querySelector('#login-form'),accountLabel=document.querySelector('#account-label'); 542 + \\const status=document.querySelector('#status'),accountBox=document.querySelector('#account'),itemsRoot=document.querySelector('#passkey-items'),appPasswordRoot=document.querySelector('#app-password-items'),loginForm=document.querySelector('#login-form'),accountLabel=document.querySelector('#account-label'),generatedAppPassword=document.querySelector('#generated-app-password'); 529 543 \\const authed=(url,opts={})=>fetch(url,{...opts,headers:{...(opts.headers||{}),authorization:`Bearer ${accessJwt}`}}); 530 544 \\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)}; 531 545 \\const when=(seconds)=>seconds?new Date(seconds*1000).toLocaleString():'never used'; 532 - \\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)}}; 533 - \\const load=async()=>{const data=await fail(await authed('/xrpc/com.atproto.server.listPasskeys'),'failed to load passkeys');show(data.passkeys||[])}; 546 + \\const credentialRow=(name,meta)=>{const row=document.createElement('div');row.className='credential-row';const label=document.createElement('span');label.className='credential-name';label.textContent=name;const small=document.createElement('span');small.className='credential-meta';small.textContent=meta;label.append(small);const actions=document.createElement('span');actions.className='credential-actions';row.append(label,actions);return{row,actions}}; 547 + \\const showPasskeys=(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 view=credentialRow(item.friendlyName||'unnamed passkey',`last used: ${when(item.lastUsed)}`);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()};view.actions.append(rename,del);itemsRoot.append(view.row)}}; 548 + \\const showAppPasswords=(items)=>{appPasswordRoot.textContent='';if(!items.length){const p=document.createElement('p');p.className='hint';p.textContent='No app passwords are active for this account.';appPasswordRoot.append(p);return}for(const item of items){const meta=`created ${item.createdAt}${item.privileged?' · privileged':''}`;const view=credentialRow(item.name,meta);const revoke=document.createElement('button');revoke.type='button';revoke.className='danger';revoke.textContent='revoke';revoke.onclick=async()=>{if(!confirm(`Revoke app password "${item.name}"? Existing sessions created with it will be revoked too.`))return;await fail(await authed('/xrpc/com.atproto.server.revokeAppPassword',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:item.name})}),'revoke failed');generatedAppPassword.classList.remove('on');generatedAppPassword.textContent='';await load()};view.actions.append(revoke);appPasswordRoot.append(view.row)}}; 549 + \\const load=async()=>{const [passkeys,passwords]=await Promise.all([fail(await authed('/xrpc/com.atproto.server.listPasskeys'),'failed to load passkeys'),fail(await authed('/xrpc/com.atproto.server.listAppPasswords'),'failed to load app passwords')]);showPasskeys(passkeys.passkeys||[]);showAppPasswords(passwords.passwords||[])}; 534 550 \\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)}}); 535 551 \\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))}}); 552 + \\document.querySelector('#create-app-password').addEventListener('click',async()=>{status.className='status';try{const name=document.querySelector('#app-password-name').value.trim();const privileged=document.querySelector('#app-password-privileged').checked;if(!name)throw new Error('Choose a name for this app password.');status.textContent='creating app password...';const created=await fail(await authed('/xrpc/com.atproto.server.createAppPassword',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name,privileged})}),'create failed');generatedAppPassword.classList.add('on');generatedAppPassword.innerHTML='Copy this app password now. It will not be shown again.<code></code>';generatedAppPassword.querySelector('code').textContent=created.password;status.className='status ok';status.textContent='App password created.';document.querySelector('#app-password-name').value='';await load()}catch(err){status.className='status error';status.textContent=err.message||String(err)}}); 536 553 ; 537 554 538 555 test "validates webauthn credential key through tangled dependency" {