OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-identity commits / 508a2d7

web-auth: reusable DID web sign-in verify (atproto-native, the pgv3 method)
lib/web-auth.js — the VERIFY half of DID web sign-in, in the module that owns the atproto crypto (per the AtprotoProvider's "atproto crypto lives in one place" injection design; NOT in dep-light hyper-identity). All secp256k1/ES256K (signAssertion/verifyAssertion) — no keet, no anproto: - verifyChallengeSig({subKey, challenge, sig}) — the browser sub-key signed the exact challenge. - makeVerifyDelegation({resolveVerifyKey}) — the DID's #atproto key delegated the sub-key, anchored to the DID's PLC-published key (resolver injected). - makeAtprotoWebVerify({resolveVerifyKey}) — combined injected `verify` for AtprotoProvider; re-checks the delegation every login (no TOFU); returns { did, subKey, handle, didKey } or false. The atproto analog of pgv3's root-anchored multi-identity: DID = the root anchor, secp256k1 sub-keys = delegated devices (revoke by expiring/removing a delegation; DID untouched). Dep-free (tiny pure-JS hex helper — portable Node/Bare). test/web-auth.test.js: 3/3, 13 asserts (challenge-sig, delegation wrong-key/unknown-DID/wrong-signer/expired, full proof). Full suite 41/41. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
508a2d73098ca1c26dacf4c6af1bf64c503d577f
by plankatron <plankatron@users.noreply.github.com> · 2026-07-15T01:41:36.000Z

Changed files (2) +115 -0

lib/web-auth.js +60 -0
……@@ -0,0 +1,60 @@
1// atproto-identity/lib/web-auth.js — DID web sign-in verify (the atproto-native "pgv3 method").
2//
3// The reusable VERIFY half of DID web sign-in. A browser holds a secp256k1 SUB-KEY; the DID's
4// #atproto key DELEGATES it (a signed assertion, anchored to the DID's PLC-published key). Login: the
5// sub-key signs the fresh server challenge as an ES256K assertion. This is the atproto analog of
6// pgv3's root-anchored multi-identity — the DID is the ROOT anchor, sub-keys are the delegated DEVICES
7// (revoke a device by expiring/removing its delegation; the DID is untouched). Everything is
8// secp256k1/ES256K (signAssertion/verifyAssertion) — no keet, no anproto.
9//
10// This module owns ONLY the crypto verify. The challenge store (single-use/TTL) and the session live
11// in the consumer (e.g. favela wraps this + hyper-identity's AtprotoProvider). resolveVerifyKey(did) →
12// the DID's #atproto compressed pubkey is INJECTED (a PLC-directory resolver in prod, a mock in tests)
13// so this stays network-free + unit-testable. Fails closed on any bad/absent/expired input.
14import { verifyAssertion } from './auth.js'
15
16const hexToBytes = (h) => { const b = new Uint8Array(h.length >> 1); for (let i = 0; i < b.length; i++) b[i] = parseInt(h.substr(i * 2, 2), 16); return b }
17const toBytes = (k) => (typeof k === 'string' ? hexToBytes(k) : k)
18const toHex = (k) => (typeof k === 'string' ? k : Array.from(k).map((b) => b.toString(16).padStart(2, '0')).join(''))
19
20// The sub-key signed THIS challenge (proves live key control + anti-replay). sig = an ES256K assertion
21// signAssertion({ challenge, … }, subPrivKey). subKey is the compressed pubkey (hex or bytes).
22export function verifyChallengeSig ({ subKey, challenge, sig } = {}) {
23 if (!subKey || !challenge || !sig) return false
24 let p
25 try { p = verifyAssertion(sig, toBytes(subKey)) } catch { return false } // bad sig / expired → reject
26 return !!p && p.challenge === challenge
27}
28
29// The DID's #atproto key delegated this sub-key. delegation = an ES256K assertion
30// signAssertion({ iss: did, sub: <subKey hex>, exp }, atprotoPrivKey), minted once by the keyholder
31// (hpp). resolveVerifyKey(did) → the DID's #atproto compressed pubkey (PLC-published). Fails closed.
32export function makeVerifyDelegation ({ resolveVerifyKey } = {}) {
33 if (typeof resolveVerifyKey !== 'function') throw new Error('makeVerifyDelegation: resolveVerifyKey(did) required')
34 return async function verifyDelegation ({ did, subKey, delegation } = {}) {
35 if (!did || !subKey || !delegation) return false
36 const subHex = toHex(subKey)
37 let vk
38 try { vk = await resolveVerifyKey(did) } catch { return false }
39 if (!vk) return false
40 let p
41 try { p = verifyAssertion(delegation, vk) } catch { return false }
42 return !!p && p.iss === did && p.sub === subHex
43 }
44}
45
46// Combined login-proof verify — the injected `verify` for hyper-identity's AtprotoProvider.
47// proof = { did, subKey(hex), challenge(hex), sig, delegation, handle?, didKey? }
48// Checks the sub-key signed the challenge AND the DID's #atproto key delegated the sub-key (PLC-
49// anchored, re-verified every login — no TOFU). Returns the binding { did, subKey, handle, didKey } or
50// false. The challenge's single-use/freshness is the CALLER's job (consume it before/around this).
51export function makeAtprotoWebVerify ({ resolveVerifyKey } = {}) {
52 const verifyDelegation = makeVerifyDelegation({ resolveVerifyKey })
53 return async function verify (proof) {
54 if (!proof) return false
55 const { did, subKey, challenge, sig, delegation } = proof
56 if (!verifyChallengeSig({ subKey, challenge, sig })) return false
57 if (!(await verifyDelegation({ did, subKey, delegation }).catch(() => false))) return false
58 return { did, subKey: toHex(subKey), handle: proof.handle || null, didKey: proof.didKey || null }
59 }
60}
test/web-auth.test.js +55 -0
……@@ -0,0 +1,55 @@
1// DID web sign-in verify (atproto-native): a #atproto key delegates a browser sub-key; the sub-key
2// signs a challenge; makeAtprotoWebVerify checks both. resolveVerifyKey is mocked as the PLC directory
3// would return the DID's #atproto pubkey.
4import test from 'brittle'
5import { deriveAtprotoKey, generateAtprotoKey } from '../lib/atproto-identity.js'
6import { signAssertion } from '../lib/auth.js'
7import { makeAtprotoWebVerify, verifyChallengeSig, makeVerifyDelegation } from '../lib/web-auth.js'
8
9const DID = 'did:plc:webauthviewer222222222222'
10const hex = (u8) => Array.from(u8).map((b) => b.toString(16).padStart(2, '0')).join('')
11const nowSec = () => Math.floor(Date.now() / 1000)
12
13// The DID's #atproto signing key (the keyholder / hpp holds this), and the browser's fresh sub-key.
14const atproto = deriveAtprotoKey('legal winner thank year wave sausage worth useful legal winner thank yellow')
15const sub = generateAtprotoKey()
16const subHex = hex(sub.publicKey)
17// PLC mock: the DID resolves to its #atproto compressed pubkey; other DIDs → null.
18const resolveVerifyKey = async (did) => (did === DID ? atproto.publicKey : null)
19
20// The delegation (minted once by the keyholder): "did DID authorizes sub-key subHex".
21const delegation = signAssertion({ iss: DID, sub: subHex, iat: nowSec(), exp: nowSec() + 3600 }, atproto.privateKey)
22// A per-login challenge signature by the sub-key.
23const CHALLENGE = 'a'.repeat(64)
24const sig = signAssertion({ challenge: CHALLENGE, iat: nowSec(), exp: nowSec() + 300 }, sub.privateKey)
25
26test('verifyChallengeSig: the sub-key signed the exact challenge', (t) => {
27 t.ok(verifyChallengeSig({ subKey: subHex, challenge: CHALLENGE, sig }), 'valid challenge sig accepted')
28 t.absent(verifyChallengeSig({ subKey: subHex, challenge: 'b'.repeat(64), sig }), 'a sig for a different challenge is rejected')
29 const wrong = signAssertion({ challenge: CHALLENGE }, generateAtprotoKey().privateKey)
30 t.absent(verifyChallengeSig({ subKey: subHex, challenge: CHALLENGE, sig: wrong }), 'a sig by a different key is rejected')
31})
32
33test('verifyDelegation: the DID #atproto key delegated this sub-key (PLC-anchored)', async (t) => {
34 const verifyDelegation = makeVerifyDelegation({ resolveVerifyKey })
35 t.ok(await verifyDelegation({ did: DID, subKey: subHex, delegation }), 'valid delegation accepted')
36 t.absent(await verifyDelegation({ did: DID, subKey: hex(generateAtprotoKey().publicKey), delegation }), 'a delegation for a DIFFERENT sub-key is rejected')
37 t.absent(await verifyDelegation({ did: 'did:plc:someoneelse', subKey: subHex, delegation }), 'an unknown DID (no PLC key) is rejected')
38 const forged = signAssertion({ iss: DID, sub: subHex, exp: nowSec() + 3600 }, generateAtprotoKey().privateKey)
39 t.absent(await verifyDelegation({ did: DID, subKey: subHex, delegation: forged }), 'a delegation NOT signed by the #atproto key is rejected')
40 const expired = signAssertion({ iss: DID, sub: subHex, iat: nowSec() - 7200, exp: nowSec() - 3600 }, atproto.privateKey)
41 t.absent(await verifyDelegation({ did: DID, subKey: subHex, delegation: expired }), 'an expired delegation is rejected')
42})
43
44test('makeAtprotoWebVerify: full login proof (challenge sig + delegation)', async (t) => {
45 const verify = makeAtprotoWebVerify({ resolveVerifyKey })
46 const binding = await verify({ did: DID, subKey: subHex, challenge: CHALLENGE, sig, delegation, handle: 'viewer.test' })
47 t.ok(binding && binding.did === DID && binding.subKey === subHex, 'a full valid proof returns the binding')
48 t.is(binding.handle, 'viewer.test', 'handle carried through')
49 t.absent(await verify({ did: DID, subKey: subHex, challenge: CHALLENGE, sig }), 'missing delegation → false')
50 t.absent(await verify({ did: DID, subKey: subHex, challenge: CHALLENGE, delegation }), 'missing sig → false')
51 // a sub-key NOT delegated by this DID cannot log in even with a valid self-sig
52 const rogue = generateAtprotoKey()
53 const rogueSig = signAssertion({ challenge: CHALLENGE, exp: nowSec() + 300 }, rogue.privateKey)
54 t.absent(await verify({ did: DID, subKey: hex(rogue.publicKey), challenge: CHALLENGE, sig: rogueSig, delegation }), 'an undelegated sub-key is rejected (delegation is for a different key)')
55})