OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity commits / 64a8df0

auth: add atproto (did:plc) identity provider + handle/did reverse indexes
Additive support for AT-Protocol identities, for the 7rnx atproto bridge (#85) and a future pear-git "Sign in with Bluesky". A seed-anchored binding (one BIP39 mnemonic -> keet Ed25519 + atproto secp256k1/did:plc; see atproto-bridge/identity) is persisted as a standard identity record with method 'atproto'. - providers/atproto.js: AtprotoProvider. Verification of did:plc control is injected (mirrors KeetIdentityProvider's IdentityKey injection) so this package stays dep-light; auto-registers identityId = did:plc with {did, handle, didKey, ed25519}. - identity-apply.js / identity-registry.js: new `atproto-did:` + `atproto-handle:` reverse indexes (write on REGISTER_IDENTITY, del on cascade — deletion story kept), plus getIdentityByDid / getIdentityByAtprotoHandle getters. The handle index is the multi-user lookup (handle -> bound identity), replacing the throwaway handles.json. Purely additive: new method branch only; existing ssh-key/noise-key/web/keet flows untouched. Verified: new test 4/4, full suite 99/99 (275 asserts), no regression. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
64a8df000b1be7162d771524e5877b52fabe1205
by plankatron <fleeky@gmail.com> · 2026-07-02T00:23:41.000Z

Changed files (4) +195 -0

lib/auth/identity-apply.js +12 -0
……@@ -157,6 +157,10 @@ export function createApplyFunction (opts = {}) {
157157 if (idRec.method === 'noise-key' && idRec.credentials && idRec.credentials.publicKey) {
158158 await b.del(`${prefix}noisekey:${idRec.credentials.publicKey}`)
159159 }
160 if (idRec.method === 'atproto' && idRec.credentials) {
161 if (idRec.credentials.did) await b.del(`${prefix}atproto-did:${idRec.credentials.did}`)
162 if (idRec.credentials.handle) await b.del(`${prefix}atproto-handle:${idRec.credentials.handle}`)
163 }
160164 await b.del(`${prefix}identity:${id}`)
161165 }
162166 }
……@@ -491,6 +495,14 @@ export function createApplyFunction (opts = {}) {
491495 if (identity.method === 'noise-key' && identity.credentials && identity.credentials.publicKey) {
492496 await b.put(`${prefix}noisekey:${identity.credentials.publicKey}`, { identityId: identity.identityId })
493497 }
498 if (identity.method === 'atproto' && identity.credentials) {
499 if (identity.credentials.did) {
500 await b.put(`${prefix}atproto-did:${identity.credentials.did}`, { identityId: identity.identityId })
501 }
502 if (identity.credentials.handle) {
503 await b.put(`${prefix}atproto-handle:${identity.credentials.handle}`, { identityId: identity.identityId })
504 }
505 }
494506 break
495507 }
496508 }
lib/auth/identity-registry.js +45 -0
……@@ -243,6 +243,14 @@ export class IdentityRegistry {
243243 if (identity.publicKey) {
244244 await this.db.put(`pubkey:${identity.publicKey}`, { identityId: identity.identityId })
245245 }
246 if (identity.method === 'atproto' && identity.credentials) {
247 if (identity.credentials.did) {
248 await this.db.put(`atproto-did:${identity.credentials.did}`, { identityId: identity.identityId })
249 }
250 if (identity.credentials.handle) {
251 await this.db.put(`atproto-handle:${identity.credentials.handle}`, { identityId: identity.identityId })
252 }
253 }
246254 } catch {
247255 // db is best-effort cache; failure here doesn't undo the autobase append
248256 }
……@@ -280,6 +288,43 @@ export class IdentityRegistry {
280288 return this.getIdentity(entry.value.identityId)
281289 }
282290
291 /**
292 * Get identity by atproto did:plc. View first, then db fallback.
293 * (The `atproto` provider registers identityId = did, so this also
294 * covers direct getIdentity(did); the index makes it explicit + future-proof.)
295 */
296 async getIdentityByDid (did) {
297 if (this._view) {
298 const idx = await this._view.get(`${this._prefix}atproto-did:${did}`).catch(() => null)
299 if (idx && idx.value && idx.value.identityId) {
300 const id = await this.getIdentity(idx.value.identityId)
301 if (id) return id
302 }
303 }
304 if (!this.db) return null
305 const entry = await this.db.get(`atproto-did:${did}`).catch(() => null)
306 if (!entry) return null
307 return this.getIdentity(entry.value.identityId)
308 }
309
310 /**
311 * Get identity by atproto handle (e.g. alice.7rnx.net). View first, then db.
312 * This is the multi-user-handle lookup: handle -> the bound identity.
313 */
314 async getIdentityByAtprotoHandle (handle) {
315 if (this._view) {
316 const idx = await this._view.get(`${this._prefix}atproto-handle:${handle}`).catch(() => null)
317 if (idx && idx.value && idx.value.identityId) {
318 const id = await this.getIdentity(idx.value.identityId)
319 if (id) return id
320 }
321 }
322 if (!this.db) return null
323 const entry = await this.db.get(`atproto-handle:${handle}`).catch(() => null)
324 if (!entry) return null
325 return this.getIdentity(entry.value.identityId)
326 }
327
283328 /**
284329 * Get identity by SSH key fingerprint. View first, then db fallback.
285330 */
lib/auth/providers/atproto.js +63 -0
……@@ -0,0 +1,63 @@
1import { BaseProvider } from '../base-provider.js'
2
3/**
4 * AtprotoProvider — AT-Protocol (did:plc) authentication + binding.
5 *
6 * Binds a user's atproto identity (did:plc + secp256k1 signing key + handle) to
7 * the registry. The binding is seed-anchored: one BIP39 mnemonic yields both the
8 * keet Ed25519 identity (our P2P world) and the atproto secp256k1 key (bsky world)
9 * — see atproto-bridge/identity.
10 *
11 * Verification of did:plc control is delegated to an injected `verify` fn (mirrors
12 * how KeetIdentityProvider injects IdentityKey), so this package stays dep-light and
13 * the atproto crypto lives in one place. `verify(proof)` must return a verified
14 * binding `{ did, handle, didKey, ed25519PublicKey }` (or throw/return false).
15 */
16export class AtprotoProvider extends BaseProvider {
17 constructor (identityRegistry, opts = {}) {
18 super('atproto')
19 this.registry = identityRegistry
20 this._verify = opts.verify || null
21 }
22
23 async extractCredentials (req) {
24 const raw = req.headers['x-atproto-proof']
25 if (!raw) return null
26 try {
27 return { proof: JSON.parse(raw) }
28 } catch {
29 return null
30 }
31 }
32
33 async authenticate (credentials) {
34 if (!this._verify) throw new Error('atproto verifier not configured')
35
36 const { proof } = credentials
37 const binding = await this._verify(proof)
38 if (!binding || !binding.did) throw new Error('Invalid atproto proof')
39
40 const { did, handle, didKey, ed25519PublicKey } = binding
41
42 let identity = await this.registry.getIdentityByDid(did)
43 if (!identity) {
44 // Auto-register. identityId = the did:plc (the atproto identity's own ID);
45 // the ed25519 binding link + handle live in credentials.
46 await this.registry.registerIdentity({
47 identityId: did,
48 method: 'atproto',
49 credentials: { did, handle, didKey, ed25519PublicKey },
50 createdAt: Date.now()
51 })
52 identity = { identityId: did, method: 'atproto' }
53 }
54
55 return {
56 identityId: identity.identityId,
57 method: 'atproto',
58 credentials: { did, handle, didKey }
59 }
60 }
61}
62
63export default AtprotoProvider
test/atproto-provider.test.js +75 -0
……@@ -0,0 +1,75 @@
1import test from 'brittle'
2import { createTestDb } from './helpers.js'
3import { IdentityRegistry } from '../lib/auth/identity-registry.js'
4import { AtprotoProvider } from '../lib/auth/providers/atproto.js'
5
6async function createRegistry () {
7 const { store, db, cleanup } = await createTestDb()
8 const registry = new IdentityRegistry(store, { db })
9 await registry.ready()
10 return { registry, cleanup: async () => { await registry.close(); await cleanup() } }
11}
12
13// A verified binding, as the injected atproto verifier would return it (from
14// atproto-bridge/identity). Values are a real did:plc derived from a test mnemonic.
15const BINDING = {
16 did: 'did:plc:yu4kgcktm7qtrwo5ofyiaf4c',
17 handle: 'alice.7rnx.net',
18 didKey: 'did:key:zQ3shExampleSecp256k1DidKeyForTestOnly000000000000',
19 ed25519PublicKey: 'de1ab94806ced05c64c0098de00ffadae052351b9fbdb6be4d645123ac9d8798'
20}
21
22test('atproto provider auto-registers a binding + reverse indexes resolve', async (t) => {
23 const { registry, cleanup } = await createRegistry()
24 const provider = new AtprotoProvider(registry, { verify: async (proof) => proof.binding })
25
26 const result = await provider.authenticate({ proof: { binding: BINDING } })
27 t.is(result.method, 'atproto')
28 t.is(result.identityId, BINDING.did, 'identityId is the did:plc')
29
30 const byDid = await registry.getIdentityByDid(BINDING.did)
31 t.ok(byDid, 'resolvable by did')
32 t.is(byDid.method, 'atproto')
33 t.is(byDid.credentials.handle, BINDING.handle, 'handle persisted')
34 t.is(byDid.credentials.ed25519PublicKey, BINDING.ed25519PublicKey, 'ed25519 binding link persisted')
35
36 const byHandle = await registry.getIdentityByAtprotoHandle(BINDING.handle)
37 t.ok(byHandle, 'resolvable by handle (the multi-user lookup)')
38 t.is(byHandle.identityId, BINDING.did, 'handle -> did')
39
40 await cleanup()
41})
42
43test('re-authenticate is idempotent (no duplicate identity)', async (t) => {
44 const { registry, cleanup } = await createRegistry()
45 const provider = new AtprotoProvider(registry, { verify: async (proof) => proof.binding })
46
47 const a = await provider.authenticate({ proof: { binding: BINDING } })
48 const b = await provider.authenticate({ proof: { binding: BINDING } })
49 t.is(a.identityId, b.identityId, 'same identityId on repeat')
50
51 const rec = await registry.getIdentityByDid(BINDING.did)
52 t.is(rec.credentials.did, BINDING.did)
53
54 await cleanup()
55})
56
57test('authenticate rejects an unverifiable proof', async (t) => {
58 const { registry, cleanup } = await createRegistry()
59 const provider = new AtprotoProvider(registry, { verify: async () => false })
60 await t.exception(() => provider.authenticate({ proof: {} }), /Invalid atproto proof/)
61 await cleanup()
62})
63
64test('existing methods are untouched (ssh-key still indexes/resolves)', async (t) => {
65 const { registry, cleanup } = await createRegistry()
66 await registry.registerIdentity({
67 identityId: 'ssh-user-1',
68 method: 'ssh-key',
69 credentials: { fingerprint: 'SHA256:abc123' }
70 })
71 const byFp = await registry.getIdentityBySSHKey('SHA256:abc123')
72 t.ok(byFp, 'ssh-key path unaffected by the atproto additions')
73 t.is(byFp.identityId, 'ssh-user-1')
74 await cleanup()
75})