···3535the app-password name. Revoking an app password deletes the credential and
3636revokes active sessions that were created with it.
37373838+## management UI
3939+4040+`/security` is the local account-security surface. It signs in with the account
4141+password, then lists passkeys and app passwords for that account. App passwords
4242+can be created and revoked from the page; the generated password is displayed
4343+once and is not recoverable later because only the hash is stored.
4444+3845## references
39464047- official PDS: app passwords are account-owned rows keyed by DID and name, and
+3-2
docs/passkeys.md
···32323333## management
34343535-Passkeys are managed from `/security`. That page signs in with the account
3636-password, lists saved passkeys, and supports add, rename, and delete.
3535+Passkeys are managed from `/security`, alongside app passwords. That page signs
3636+in with the account password, lists saved passkeys, and supports add, rename,
3737+and delete.
37383839Deleting a passkey removes it from ZDS. The browser, OS, or password manager
3940may still keep its copy, but it can no longer be used with this PDS.
+28-11
src/internal/passkeys.zig
···6666 \\<meta charset="utf-8">
6767 \\<meta name="viewport" content="width=device-width, initial-scale=1">
6868 \\<title>zds security</title>
6969- \\<meta name="description" content="manage passkeys for accounts hosted on {s}">
6969+ \\<meta name="description" content="manage account security for accounts hosted on {s}">
7070 \\<link rel="canonical" href="{s}">
7171 \\<meta property="og:type" content="website">
7272 \\<meta property="og:site_name" content="zds">
7373 \\<meta property="og:title" content="zds security">
7474- \\<meta property="og:description" content="manage passkeys for accounts hosted on {s}">
7474+ \\<meta property="og:description" content="manage account security for accounts hosted on {s}">
7575 \\<meta property="og:url" content="{s}">
7676 \\<meta property="og:image" content="{s}">
7777 \\<meta property="og:image:width" content="1200">
7878 \\<meta property="og:image:height" content="630">
7979 \\<meta name="twitter:card" content="summary_large_image">
8080 \\<meta name="twitter:title" content="zds security">
8181- \\<meta name="twitter:description" content="manage passkeys for accounts hosted on {s}">
8181+ \\<meta name="twitter:description" content="manage account security for accounts hosted on {s}">
8282 \\<meta name="twitter:image" content="{s}">
8383 \\<link rel="icon" href="/favicon.svg" type="image/svg+xml">
8484 \\<style>
···9393 \\.status{{min-height:1.5em;color:var(--muted)}}.error{{color:var(--bad)}}.ok{{color:var(--green)}}code{{overflow-wrap:anywhere;color:var(--accent)}}
9494 \\.hint{{font-size:.92rem;color:var(--muted);margin:8px 0 0}}
9595 \\.account{{display:none;margin-top:18px}}.account.on{{display:block}}.login.off{{display:none}}
9696- \\.passkey-list{{margin-top:18px;border-top:1px solid var(--line);padding-top:12px}}.passkey-list h2{{font-size:1rem;margin:0 0 8px}}
9797- \\.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)}}
9898- \\.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))}}
9696+ \\.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}}
9797+ \\.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)}}
9898+ \\.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))}}
9999+ \\.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}}
99100 \\</style>
100101 \\</head>
101102 \\<body>
102103 \\<main class="shell">
103104 \\<a class="brand" href="/">zds</a>
104105 \\<h1>security</h1>
105105- \\<p>Sign in to manage passkeys for an account hosted on <strong>{s}</strong>.</p>
106106+ \\<p>Sign in to manage passkeys and app passwords for an account hosted on <strong>{s}</strong>.</p>
106107 \\<section class="panel">
107108 \\<form id="login-form" class="login">
108109 \\<label for="identifier">account</label>
···114115 \\<p id="status" class="status"></p>
115116 \\<section id="account" class="account" aria-live="polite">
116117 \\<p id="account-label"></p>
117117- \\<h2>saved passkeys</h2>
118118+ \\<section class="security-section">
119119+ \\<h2>passkeys</h2>
120120+ \\<p class="hint">Use a passkey to sign in without typing this account password.</p>
118121 \\<div id="passkey-items"></div>
119122 \\<label for="friendly">new passkey name</label>
120123 \\<input id="friendly" name="friendlyName" placeholder="this device">
121124 \\<button id="add-passkey" type="button">add passkey</button>
122125 \\<p class="hint">Removing a passkey here forgets it on this PDS; your browser or password manager may still keep its copy.</p>
126126+ \\</section>
127127+ \\<section class="security-section">
128128+ \\<h2>app passwords</h2>
129129+ \\<p class="hint">Use an app password for clients that should not receive your main account password.</p>
130130+ \\<div id="app-password-items"></div>
131131+ \\<label for="app-password-name">new app password name</label>
132132+ \\<input id="app-password-name" name="appPasswordName" placeholder="client or device name">
133133+ \\<label class="checkbox"><input id="app-password-privileged" type="checkbox"> privileged app password</label>
134134+ \\<button id="create-app-password" type="button">create app password</button>
135135+ \\<div id="generated-app-password" class="generated" aria-live="polite"></div>
136136+ \\</section>
123137 \\</section>
124138 \\</section>
125139 \\</main>
···525539 \\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};
526540 \\const serialize=(c)=>({id:c.id,rawId:bufToB64(c.rawId),type:c.type,response:{clientDataJSON:bufToB64(c.response.clientDataJSON),attestationObject:bufToB64(c.response.attestationObject)}});
527541 \\let accessJwt=null,handle=null;
528528- \\const status=document.querySelector('#status'),accountBox=document.querySelector('#account'),itemsRoot=document.querySelector('#passkey-items'),loginForm=document.querySelector('#login-form'),accountLabel=document.querySelector('#account-label');
542542+ \\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');
529543 \\const authed=(url,opts={})=>fetch(url,{...opts,headers:{...(opts.headers||{}),authorization:`Bearer ${accessJwt}`}});
530544 \\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)};
531545 \\const when=(seconds)=>seconds?new Date(seconds*1000).toLocaleString():'never used';
532532- \\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)}};
533533- \\const load=async()=>{const data=await fail(await authed('/xrpc/com.atproto.server.listPasskeys'),'failed to load passkeys');show(data.passkeys||[])};
546546+ \\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}};
547547+ \\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)}};
548548+ \\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)}};
549549+ \\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||[])};
534550 \\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)}});
535551 \\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))}});
552552+ \\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)}});
536553;
537554538555test "validates webauthn credential key through tangled dependency" {