Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 61 additions & 84 deletions src/resolvers/github.ts
Original file line number Diff line number Diff line change
@@ -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/<prefix>/state.json (KERI identity state)
* v1/devices/XX/YY/<did>/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<Response> {
async function githubFetch(url: string, accept?: string): Promise<Response> {
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})`);
Expand All @@ -23,103 +25,78 @@ async function githubFetch(url: string): Promise<Response> {
}

export const githubAdapter: ForgeAdapter = {
async listAuthsRefs(config: ForgeConfig): Promise<RefEntry[]> {
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<RefEntry[]> {
return [];
},

async readBlob(config: ForgeConfig, sha: string): Promise<string> {
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<string> {
return '';
},

async resolve(config: ForgeConfig, identityFilter?: string): Promise<ResolveResult> {
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/<prefix>/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) {
Expand Down
Loading
Loading