11/**
2- * GitHub adapter — resolves auths identity data via GitHub REST API .
2+ * GitHub adapter — resolves auths attestations from GitHub Releases .
33 *
4- * Reads from refs/auths/registry — structured tree with:
5- * v1/identities/XX/YY/<prefix>/state.json (KERI identity state)
6- * v1/devices/XX/YY/<did>/attestation.json (device attestations)
4+ * Workflow:
5+ * 1. Fetch latest release via GitHub API
6+ * 2. Find the *.auths.json release asset
7+ * 3. Use device_public_key from the attestation as the verification key
8+ *
9+ * The attestation file is self-contained for device-only attestations
10+ * (no identity_signature): device_signature is verified against
11+ * device_public_key, both of which are in the file.
712 */
813
914import type { ForgeAdapter } from './adapter' ;
1015import type { ForgeConfig , RefEntry , ResolveResult } from './types' ;
11- import { cesrToPublicKeyHex } from './did-utils' ;
12-
13- const REGISTRY_REF = 'refs/auths/registry' ;
1416
15- async function githubFetch ( url : string ) : Promise < Response > {
17+ async function githubFetch ( url : string , accept ?: string ) : Promise < Response > {
1618 const res = await fetch ( url , {
17- headers : { Accept : 'application/vnd.github.v3+json' } ,
19+ headers : { Accept : accept || 'application/vnd.github.v3+json' } ,
1820 } ) ;
1921 if ( ! res . ok ) {
2022 throw new Error ( `GitHub API ${ res . status } : ${ res . statusText } (${ url } )` ) ;
@@ -23,103 +25,78 @@ async function githubFetch(url: string): Promise<Response> {
2325}
2426
2527export const githubAdapter : ForgeAdapter = {
26- async listAuthsRefs ( config : ForgeConfig ) : Promise < RefEntry [ ] > {
27- const url = `${ config . baseUrl } /repos/${ config . owner } /${ config . repo } /git/matching-refs/auths/` ;
28- const res = await githubFetch ( url ) ;
29- const data : Array < { ref : string ; object : { sha : string } } > = await res . json ( ) ;
30- return data . map ( ( entry ) => ( { ref : entry . ref , sha : entry . object . sha } ) ) ;
28+ /**
29+ * Not used in release-asset-based resolution.
30+ * GitHub adapter resolves from /releases/latest, not from Git refs.
31+ * Kept as stub for ForgeAdapter interface compatibility (Gitea uses these).
32+ */
33+ async listAuthsRefs ( _config : ForgeConfig ) : Promise < RefEntry [ ] > {
34+ return [ ] ;
3135 } ,
3236
33- async readBlob ( config : ForgeConfig , sha : string ) : Promise < string > {
34- const url = `${ config . baseUrl } /repos/${ config . owner } /${ config . repo } /git/blobs/${ sha } ` ;
35- const res = await githubFetch ( url ) ;
36- const data : { content : string ; encoding : string } = await res . json ( ) ;
37- if ( data . encoding === 'base64' ) {
38- return atob ( data . content . replace ( / \n / g, '' ) ) ;
39- }
40- return data . content ;
37+ /** @see listAuthsRefs — same rationale */
38+ async readBlob ( _config : ForgeConfig , _sha : string ) : Promise < string > {
39+ return '' ;
4140 } ,
4241
4342 async resolve ( config : ForgeConfig , identityFilter ?: string ) : Promise < ResolveResult > {
4443 try {
45- const refs = await this . listAuthsRefs ( config ) ;
46- if ( refs . length === 0 ) {
47- return { bundle : null , error : 'No auths refs found in this repository' } ;
44+ // Fetch latest release
45+ const releaseUrl = `${ config . baseUrl } /repos/${ config . owner } /${ config . repo } /releases/latest` ;
46+ const releaseRes = await githubFetch ( releaseUrl ) ;
47+ const release : { assets : Array < { id : number ; name : string ; browser_download_url : string } > } =
48+ await releaseRes . json ( ) ;
49+
50+ if ( ! release . assets || release . assets . length === 0 ) {
51+ return { bundle : null , error : 'No assets found in latest release' } ;
4852 }
4953
50- const registryRef = refs . find ( ( r ) => r . ref === REGISTRY_REF ) ;
51- if ( ! registryRef ) {
52- return { bundle : null , error : 'No registry ref found (refs/auths/registry)' } ;
54+ // Find *.auths.json asset
55+ const attestationAsset = release . assets . find ( ( a ) => a . name . endsWith ( '.auths.json' ) ) ;
56+ if ( ! attestationAsset ) {
57+ return { bundle : null , error : 'No .auths.json attestation found in latest release' } ;
5358 }
5459
55- // Get commit → tree SHA
56- const commitUrl = `${ config . baseUrl } /repos/${ config . owner } /${ config . repo } /git/commits/${ registryRef . sha } ` ;
57- const commitRes = await githubFetch ( commitUrl ) ;
58- const commit : { tree : { sha : string } } = await commitRes . json ( ) ;
59-
60- // Get full recursive tree
61- const treeUrl = `${ config . baseUrl } /repos/${ config . owner } /${ config . repo } /git/trees/${ commit . tree . sha } ?recursive=1` ;
62- const treeRes = await githubFetch ( treeUrl ) ;
63- const tree : { tree : Array < { path : string ; sha : string ; type : string } > } = await treeRes . json ( ) ;
64-
65- // Find identity state.json
66- const stateEntry = tree . tree . find (
67- ( e ) => e . type === 'blob' && / ^ v 1 \/ i d e n t i t i e s \/ [ ^ / ] { 2 } \/ [ ^ / ] { 2 } \/ [ ^ / ] + \/ s t a t e \. j s o n $ / . test ( e . path ) ,
68- ) ;
69- if ( ! stateEntry ) {
70- return { bundle : null , error : 'No identity state found in registry' } ;
71- }
72-
73- // Extract KERI prefix from path: v1/identities/XX/YY/<prefix>/state.json
74- const keriPrefix = stateEntry . path . split ( '/' ) [ 4 ] ;
75- const controllerDid = `did:keri:${ keriPrefix } ` ;
60+ // Try to download attestation via Contents API (works if file is committed to repo)
61+ // Fall back to asset API endpoint if not found (for repos with assets only)
62+ let attestation : {
63+ issuer : string ;
64+ subject : string ;
65+ device_public_key : string ;
66+ } ;
7667
77- if ( identityFilter && controllerDid !== identityFilter ) {
78- return {
79- bundle : null ,
80- error : `Identity ${ controllerDid } does not match filter ${ identityFilter } ` ,
81- } ;
68+ try {
69+ const contentsUrl = `${ config . baseUrl } /repos/${ config . owner } /${ config . repo } /contents/${ attestationAsset . name } ` ;
70+ const contentsRes = await githubFetch ( contentsUrl ) ;
71+ const contentsData : { content : string } = await contentsRes . json ( ) ;
72+ attestation = JSON . parse ( atob ( contentsData . content . replace ( / \n / g, '' ) ) ) ;
73+ } catch {
74+ // Fall back to asset API endpoint if file not in tree
75+ const assetUrl = `${ config . baseUrl } /repos/${ config . owner } /${ config . repo } /releases/assets/${ attestationAsset . id } ` ;
76+ const assetRes = await githubFetch ( assetUrl , 'application/octet-stream' ) ;
77+ attestation = await assetRes . json ( ) ;
8278 }
8379
84- // Read state.json to get current public key (CESR-encoded)
85- const stateBlob = await this . readBlob ( config , stateEntry . sha ) ;
86- const state = JSON . parse ( stateBlob ) ;
87- const currentKeyCesr : string | undefined = state . state ?. current_keys ?. [ 0 ] ;
88-
89- if ( ! currentKeyCesr ) {
90- return { bundle : null , error : 'No current key found in identity state' } ;
80+ if ( ! attestation . issuer || ! attestation . device_public_key ) {
81+ return { bundle : null , error : 'Attestation missing required fields (issuer, device_public_key)' } ;
9182 }
9283
93- let publicKeyHex : string ;
94- try {
95- publicKeyHex = cesrToPublicKeyHex ( currentKeyCesr ) ;
96- } catch ( err ) {
84+ if ( identityFilter && attestation . issuer !== identityFilter ) {
9785 return {
9886 bundle : null ,
99- error : `Failed to decode CESR key: ${ err instanceof Error ? err . message : String ( err ) } ` ,
87+ error : `Issuer ${ attestation . issuer } does not match filter ${ identityFilter } ` ,
10088 } ;
10189 }
10290
103- // Find all device attestation.json blobs
104- const attestationEntries = tree . tree . filter (
105- ( e ) => e . type === 'blob' && / ^ v 1 \/ d e v i c e s \/ [ ^ / ] { 2 } \/ [ ^ / ] { 2 } \/ [ ^ / ] + \/ a t t e s t a t i o n \. j s o n $ / . test ( e . path ) ,
106- ) ;
107-
108- const attestationChain : object [ ] = [ ] ;
109- for ( const entry of attestationEntries ) {
110- try {
111- const blob = await this . readBlob ( config , entry . sha ) ;
112- attestationChain . push ( JSON . parse ( blob ) ) ;
113- } catch {
114- // Skip unreadable attestations
115- }
116- }
117-
91+ // device_public_key is used as the root key.
92+ // For device-only attestations (no identity_signature), the verifier
93+ // skips the identity check and only verifies device_signature against
94+ // device_public_key — both present in the file.
11895 return {
11996 bundle : {
120- identity_did : controllerDid ,
121- public_key_hex : publicKeyHex ,
122- attestation_chain : attestationChain ,
97+ identity_did : attestation . issuer ,
98+ public_key_hex : attestation . device_public_key ,
99+ attestation_chain : [ attestation ] ,
123100 } ,
124101 } ;
125102 } catch ( err ) {
0 commit comments