diff --git a/src/resolvers/github.ts b/src/resolvers/github.ts index 2d74b79..5422175 100644 --- a/src/resolvers/github.ts +++ b/src/resolvers/github.ts @@ -1,20 +1,22 @@ /** - * GitHub adapter — resolves auths identity data via GitHub REST API. + * GitHub adapter — resolves auths attestations from GitHub Releases. * - * Reads from refs/auths/registry — structured tree with: - * v1/identities/XX/YY//state.json (KERI identity state) - * v1/devices/XX/YY//attestation.json (device attestations) + * Workflow: + * 1. Fetch latest release via GitHub API + * 2. Find the *.auths.json release asset + * 3. Use device_public_key from the attestation as the verification key + * + * The attestation file is self-contained for device-only attestations + * (no identity_signature): device_signature is verified against + * device_public_key, both of which are in the file. */ import type { ForgeAdapter } from './adapter'; import type { ForgeConfig, RefEntry, ResolveResult } from './types'; -import { cesrToPublicKeyHex } from './did-utils'; - -const REGISTRY_REF = 'refs/auths/registry'; -async function githubFetch(url: string): Promise { +async function githubFetch(url: string, accept?: string): Promise { const res = await fetch(url, { - headers: { Accept: 'application/vnd.github.v3+json' }, + headers: { Accept: accept || 'application/vnd.github.v3+json' }, }); if (!res.ok) { throw new Error(`GitHub API ${res.status}: ${res.statusText} (${url})`); @@ -23,103 +25,78 @@ async function githubFetch(url: string): Promise { } export const githubAdapter: ForgeAdapter = { - async listAuthsRefs(config: ForgeConfig): Promise { - const url = `${config.baseUrl}/repos/${config.owner}/${config.repo}/git/matching-refs/auths/`; - const res = await githubFetch(url); - const data: Array<{ ref: string; object: { sha: string } }> = await res.json(); - return data.map((entry) => ({ ref: entry.ref, sha: entry.object.sha })); + /** + * Not used in release-asset-based resolution. + * GitHub adapter resolves from /releases/latest, not from Git refs. + * Kept as stub for ForgeAdapter interface compatibility (Gitea uses these). + */ + async listAuthsRefs(_config: ForgeConfig): Promise { + return []; }, - async readBlob(config: ForgeConfig, sha: string): Promise { - const url = `${config.baseUrl}/repos/${config.owner}/${config.repo}/git/blobs/${sha}`; - const res = await githubFetch(url); - const data: { content: string; encoding: string } = await res.json(); - if (data.encoding === 'base64') { - return atob(data.content.replace(/\n/g, '')); - } - return data.content; + /** @see listAuthsRefs — same rationale */ + async readBlob(_config: ForgeConfig, _sha: string): Promise { + return ''; }, async resolve(config: ForgeConfig, identityFilter?: string): Promise { try { - const refs = await this.listAuthsRefs(config); - if (refs.length === 0) { - return { bundle: null, error: 'No auths refs found in this repository' }; + // Fetch latest release + const releaseUrl = `${config.baseUrl}/repos/${config.owner}/${config.repo}/releases/latest`; + const releaseRes = await githubFetch(releaseUrl); + const release: { assets: Array<{ id: number; name: string; browser_download_url: string }> } = + await releaseRes.json(); + + if (!release.assets || release.assets.length === 0) { + return { bundle: null, error: 'No assets found in latest release' }; } - const registryRef = refs.find((r) => r.ref === REGISTRY_REF); - if (!registryRef) { - return { bundle: null, error: 'No registry ref found (refs/auths/registry)' }; + // Find *.auths.json asset + const attestationAsset = release.assets.find((a) => a.name.endsWith('.auths.json')); + if (!attestationAsset) { + return { bundle: null, error: 'No .auths.json attestation found in latest release' }; } - // Get commit → tree SHA - const commitUrl = `${config.baseUrl}/repos/${config.owner}/${config.repo}/git/commits/${registryRef.sha}`; - const commitRes = await githubFetch(commitUrl); - const commit: { tree: { sha: string } } = await commitRes.json(); - - // Get full recursive tree - const treeUrl = `${config.baseUrl}/repos/${config.owner}/${config.repo}/git/trees/${commit.tree.sha}?recursive=1`; - const treeRes = await githubFetch(treeUrl); - const tree: { tree: Array<{ path: string; sha: string; type: string }> } = await treeRes.json(); - - // Find identity state.json - const stateEntry = tree.tree.find( - (e) => e.type === 'blob' && /^v1\/identities\/[^/]{2}\/[^/]{2}\/[^/]+\/state\.json$/.test(e.path), - ); - if (!stateEntry) { - return { bundle: null, error: 'No identity state found in registry' }; - } - - // Extract KERI prefix from path: v1/identities/XX/YY//state.json - const keriPrefix = stateEntry.path.split('/')[4]; - const controllerDid = `did:keri:${keriPrefix}`; + // Try to download attestation via Contents API (works if file is committed to repo) + // Fall back to asset API endpoint if not found (for repos with assets only) + let attestation: { + issuer: string; + subject: string; + device_public_key: string; + }; - if (identityFilter && controllerDid !== identityFilter) { - return { - bundle: null, - error: `Identity ${controllerDid} does not match filter ${identityFilter}`, - }; + try { + const contentsUrl = `${config.baseUrl}/repos/${config.owner}/${config.repo}/contents/${attestationAsset.name}`; + const contentsRes = await githubFetch(contentsUrl); + const contentsData: { content: string } = await contentsRes.json(); + attestation = JSON.parse(atob(contentsData.content.replace(/\n/g, ''))); + } catch { + // Fall back to asset API endpoint if file not in tree + const assetUrl = `${config.baseUrl}/repos/${config.owner}/${config.repo}/releases/assets/${attestationAsset.id}`; + const assetRes = await githubFetch(assetUrl, 'application/octet-stream'); + attestation = await assetRes.json(); } - // Read state.json to get current public key (CESR-encoded) - const stateBlob = await this.readBlob(config, stateEntry.sha); - const state = JSON.parse(stateBlob); - const currentKeyCesr: string | undefined = state.state?.current_keys?.[0]; - - if (!currentKeyCesr) { - return { bundle: null, error: 'No current key found in identity state' }; + if (!attestation.issuer || !attestation.device_public_key) { + return { bundle: null, error: 'Attestation missing required fields (issuer, device_public_key)' }; } - let publicKeyHex: string; - try { - publicKeyHex = cesrToPublicKeyHex(currentKeyCesr); - } catch (err) { + if (identityFilter && attestation.issuer !== identityFilter) { return { bundle: null, - error: `Failed to decode CESR key: ${err instanceof Error ? err.message : String(err)}`, + error: `Issuer ${attestation.issuer} does not match filter ${identityFilter}`, }; } - // Find all device attestation.json blobs - const attestationEntries = tree.tree.filter( - (e) => e.type === 'blob' && /^v1\/devices\/[^/]{2}\/[^/]{2}\/[^/]+\/attestation\.json$/.test(e.path), - ); - - const attestationChain: object[] = []; - for (const entry of attestationEntries) { - try { - const blob = await this.readBlob(config, entry.sha); - attestationChain.push(JSON.parse(blob)); - } catch { - // Skip unreadable attestations - } - } - + // device_public_key is used as the root key. + // For device-only attestations (no identity_signature), the verifier + // skips the identity check and only verifies device_signature against + // device_public_key — both present in the file. return { bundle: { - identity_did: controllerDid, - public_key_hex: publicKeyHex, - attestation_chain: attestationChain, + identity_did: attestation.issuer, + public_key_hex: attestation.device_public_key, + attestation_chain: [attestation], }, }; } catch (err) { diff --git a/tests/resolvers/github.test.ts b/tests/resolvers/github.test.ts index d53c9f9..f1704e3 100644 --- a/tests/resolvers/github.test.ts +++ b/tests/resolvers/github.test.ts @@ -9,28 +9,26 @@ const config: ForgeConfig = { repo: 'auths', }; -// CESR-encoded Ed25519 key for registry format -const TEST_CESR_KEY = 'DQIS37c2Ar3CzozrmU9KpbUWBYWMJhBWPV-wN50i-RGI'; -const TEST_KERI_PREFIX = 'EXrBYxo2ovC9iZIKgXZhbiDvD21eAVwoLnlziitHeTiM'; - -const STATE_JSON = JSON.stringify({ - version: 1, - state: { - prefix: TEST_KERI_PREFIX, - current_keys: [TEST_CESR_KEY], - sequence: 0, - }, -}); +const RELEASE_MOCK = { + assets: [ + { + id: 42, + name: 'hello.tar.gz.auths.json', + browser_download_url: + 'https://github.com/bordumb/auths/releases/download/v0.0.1/hello.tar.gz.auths.json', + }, + ], +}; -const ATTESTATION_JSON = JSON.stringify({ +const ATTESTATION = { version: 1, - rid: '.auths', - issuer: `did:keri:${TEST_KERI_PREFIX}`, + rid: 'sha256:abc123', + issuer: 'did:keri:EXrBYxo2ovC9iZIKgXZhbiDvD21eAVwoLnlziitHeTiM', subject: 'did:key:z6MkDev1', - device_public_key: 'abcd1234', + device_public_key: 'abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234', identity_signature: 'sig1', device_signature: 'sig2', -}); +}; function mockFetch(responses: Record) { return vi.fn(async (url: string) => { @@ -50,23 +48,14 @@ function mockFetch(responses: Record) { }); } -/** Standard mock for a registry with one identity and one device */ -function registryMock() { +/** Mock releases/latest + contents/ for the happy path */ +function releaseMock() { return mockFetch({ - 'matching-refs/auths/': [ - { ref: 'refs/auths/registry', object: { sha: 'commit-reg' } }, - ], - 'git/commits/commit-reg': { tree: { sha: 'tree-reg' } }, - [`git/trees/tree-reg?recursive=1`]: { - tree: [ - { path: `v1/identities/EX/rB/${TEST_KERI_PREFIX}/state.json`, sha: 'blob-state', type: 'blob' }, - { path: `v1/devices/z6/Mk/did_key_z6MkDev1/attestation.json`, sha: 'blob-att', type: 'blob' }, - { path: `v1/identities/EX/rB/${TEST_KERI_PREFIX}`, sha: 'tree-id', type: 'tree' }, - { path: 'v1/metadata.json', sha: 'blob-meta', type: 'blob' }, - ], + 'releases/latest': RELEASE_MOCK, + [`contents/${RELEASE_MOCK.assets[0].name}`]: { + content: btoa(JSON.stringify(ATTESTATION)), + encoding: 'base64', }, - 'git/blobs/blob-state': { content: btoa(STATE_JSON), encoding: 'base64' }, - 'git/blobs/blob-att': { content: btoa(ATTESTATION_JSON), encoding: 'base64' }, }); } @@ -75,102 +64,87 @@ describe('githubAdapter', () => { vi.restoreAllMocks(); }); - it('should list auths refs', async () => { - global.fetch = mockFetch({ - 'matching-refs/auths/': [ - { ref: 'refs/auths/registry', object: { sha: 'abc123' } }, - ], - }); + it('should resolve identity from latest release', async () => { + global.fetch = releaseMock(); - const refs = await githubAdapter.listAuthsRefs(config); - expect(refs).toHaveLength(1); - expect(refs[0]).toEqual({ ref: 'refs/auths/registry', sha: 'abc123' }); + const result = await githubAdapter.resolve(config); + expect(result.bundle).not.toBeNull(); + expect(result.bundle!.identity_did).toBe(ATTESTATION.issuer); + expect(result.bundle!.public_key_hex).toBe(ATTESTATION.device_public_key); + expect(result.bundle!.attestation_chain).toHaveLength(1); }); - it('should read a blob with base64 decoding', async () => { - const content = JSON.stringify({ test: true }); + it('should return error when no assets in release', async () => { global.fetch = mockFetch({ - 'git/blobs/': { content: btoa(content), encoding: 'base64' }, + 'releases/latest': { assets: [] }, }); - const blob = await githubAdapter.readBlob(config, 'sha123'); - expect(blob).toBe(content); - }); - - it('should resolve identity from registry', async () => { - global.fetch = registryMock(); - const result = await githubAdapter.resolve(config); - expect(result.bundle).not.toBeNull(); - expect(result.bundle!.identity_did).toBe(`did:keri:${TEST_KERI_PREFIX}`); - expect(result.bundle!.public_key_hex).toMatch(/^[0-9a-f]{64}$/); - expect(result.bundle!.attestation_chain).toHaveLength(1); + expect(result.bundle).toBeNull(); + expect(result.error).toContain('No assets found'); }); - it('should return error when no auths refs exist', async () => { + it('should return error when no .auths.json asset found', async () => { global.fetch = mockFetch({ - 'matching-refs/auths/': [], + 'releases/latest': { + assets: [{ id: 1, name: 'README.md', browser_download_url: 'https://example.com' }], + }, }); const result = await githubAdapter.resolve(config); expect(result.bundle).toBeNull(); - expect(result.error).toContain('No auths refs found'); + expect(result.error).toContain('No .auths.json'); }); - it('should return error when registry ref is missing', async () => { + it('should fall back to asset API when contents API 404s', async () => { + // releases/latest OK, contents/ will 404 (not in responses), asset API returns attestation global.fetch = mockFetch({ - 'matching-refs/auths/': [ - { ref: 'refs/auths/something-else', object: { sha: 'abc' } }, - ], + 'releases/latest': RELEASE_MOCK, + [`releases/assets/${RELEASE_MOCK.assets[0].id}`]: ATTESTATION, }); const result = await githubAdapter.resolve(config); - expect(result.bundle).toBeNull(); - expect(result.error).toContain('No registry ref found'); + expect(result.bundle).not.toBeNull(); + expect(result.bundle!.identity_did).toBe(ATTESTATION.issuer); + expect(result.bundle!.public_key_hex).toBe(ATTESTATION.device_public_key); + expect(result.bundle!.attestation_chain).toHaveLength(1); }); - it('should return error when registry has no identity state', async () => { + it('should return error when attestation missing required fields', async () => { global.fetch = mockFetch({ - 'matching-refs/auths/': [ - { ref: 'refs/auths/registry', object: { sha: 'commit-reg' } }, - ], - 'git/commits/commit-reg': { tree: { sha: 'tree-reg' } }, - [`git/trees/tree-reg?recursive=1`]: { - tree: [ - { path: 'v1/metadata.json', sha: 'blob-meta', type: 'blob' }, - ], + 'releases/latest': RELEASE_MOCK, + [`contents/${RELEASE_MOCK.assets[0].name}`]: { + content: btoa(JSON.stringify({ version: 1 })), + encoding: 'base64', }, }); const result = await githubAdapter.resolve(config); expect(result.bundle).toBeNull(); - expect(result.error).toContain('No identity state found'); + expect(result.error).toContain('missing required fields'); }); it('should apply identity filter', async () => { - global.fetch = registryMock(); + global.fetch = releaseMock(); const result = await githubAdapter.resolve(config, 'did:keri:EDifferentPrefix'); expect(result.bundle).toBeNull(); expect(result.error).toContain('does not match filter'); }); - it('should resolve with zero attestations', async () => { - global.fetch = mockFetch({ - 'matching-refs/auths/': [ - { ref: 'refs/auths/registry', object: { sha: 'commit-reg' } }, - ], - 'git/commits/commit-reg': { tree: { sha: 'tree-reg' } }, - [`git/trees/tree-reg?recursive=1`]: { - tree: [ - { path: `v1/identities/EX/rB/${TEST_KERI_PREFIX}/state.json`, sha: 'blob-state', type: 'blob' }, - ], - }, - 'git/blobs/blob-state': { content: btoa(STATE_JSON), encoding: 'base64' }, - }); + it('should return error when releases API fails (no releases)', async () => { + global.fetch = mockFetch({}); const result = await githubAdapter.resolve(config); - expect(result.bundle).not.toBeNull(); - expect(result.bundle!.attestation_chain).toHaveLength(0); + expect(result.bundle).toBeNull(); + expect(result.error).toContain('GitHub API 404'); + }); + + it('should return stub values for listAuthsRefs and readBlob', async () => { + const refs = await githubAdapter.listAuthsRefs(config); + expect(refs).toEqual([]); + + const blob = await githubAdapter.readBlob(config, 'any-sha'); + expect(blob).toBe(''); }); });