Skip to content

Commit 066eced

Browse files
committed
feat: resolve identity from refs/auths/registry format
The CLI writes identity data under a single refs/auths/registry ref with a structured tree (v1/identities/XX/YY/<prefix>/state.json, v1/devices/XX/YY/<did>/attestation.json). Updated both GitHub and Gitea adapters to read this format. - Add cesrToPublicKeyHex() to decode CESR-encoded Ed25519 keys - Read identity public key from state.json current_keys - Extract KERI prefix from registry tree paths - Find device attestations via v1/devices/ tree pattern - Updated all unit tests and E2E tests for registry format
1 parent 4466b67 commit 066eced

7 files changed

Lines changed: 260 additions & 258 deletions

File tree

e2e/widget.spec.ts

Lines changed: 34 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,40 @@
11
import { test, expect, type Page } from '@playwright/test';
22

33
// ---------------------------------------------------------------------------
4-
// Mock data — matches the structure the GitHub adapter expects
4+
// Mock data — matches the registry format (refs/auths/registry)
55
// ---------------------------------------------------------------------------
66

7-
const TEST_DID = 'did:key:z6MkiTBz1ymuepAQ4HEHYSF1H8quG5GLVVQR3djdX3mDooWp';
7+
const TEST_KERI_PREFIX = 'EXrBYxo2ovC9iZIKgXZhbiDvD21eAVwoLnlziitHeTiM';
8+
const TEST_CESR_KEY = 'DQIS37c2Ar3CzozrmU9KpbUWBYWMJhBWPV-wN50i-RGI';
9+
10+
const STATE_JSON = JSON.stringify({
11+
version: 1,
12+
state: {
13+
prefix: TEST_KERI_PREFIX,
14+
current_keys: [TEST_CESR_KEY],
15+
sequence: 0,
16+
},
17+
});
818

9-
const IDENTITY_JSON = JSON.stringify({ controller_did: TEST_DID });
1019
const ATTESTATION_JSON = JSON.stringify({
1120
version: 1,
12-
rid: 'test-rid',
13-
issuer: TEST_DID,
21+
rid: '.auths',
22+
issuer: `did:keri:${TEST_KERI_PREFIX}`,
1423
subject: 'did:key:z6MkDev1Device',
15-
iat: '2025-01-01T00:00:00Z',
16-
signature: 'deadbeef',
24+
device_public_key: 'abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234',
25+
identity_signature: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef',
26+
device_signature: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef',
27+
timestamp: '2025-01-01T00:00:00Z',
1728
});
1829

19-
const IDENTITY_B64 = btoa(IDENTITY_JSON);
30+
const STATE_B64 = btoa(STATE_JSON);
2031
const ATTESTATION_B64 = btoa(ATTESTATION_JSON);
2132

2233
// ---------------------------------------------------------------------------
23-
// Route handler: mocks the GitHub REST API for forge adapter
34+
// Route handler: mocks the GitHub REST API for forge adapter (registry format)
2435
// ---------------------------------------------------------------------------
2536

2637
async function mockGitHubAPI(page: Page) {
27-
// Intercept all requests to api.github.com
2838
await page.route('https://api.github.com/**', async (route) => {
2939
const url = route.request().url();
3040

@@ -34,8 +44,7 @@ async function mockGitHubAPI(page: Page) {
3444
status: 200,
3545
contentType: 'application/json',
3646
body: JSON.stringify([
37-
{ ref: 'refs/auths/identity', object: { sha: 'commit-id-1' } },
38-
{ ref: 'refs/auths/keys/dev1/signatures', object: { sha: 'commit-att-1' } },
47+
{ ref: 'refs/auths/registry', object: { sha: 'commit-registry' } },
3948
]),
4049
});
4150
}
@@ -50,49 +59,35 @@ async function mockGitHubAPI(page: Page) {
5059
}
5160

5261
// 2. Get commit → tree SHA
53-
if (url.includes('git/commits/commit-id-1')) {
54-
return route.fulfill({
55-
status: 200,
56-
contentType: 'application/json',
57-
body: JSON.stringify({ tree: { sha: 'tree-identity' } }),
58-
});
59-
}
60-
61-
if (url.includes('git/commits/commit-att-1')) {
62+
if (url.includes('git/commits/commit-registry')) {
6263
return route.fulfill({
6364
status: 200,
6465
contentType: 'application/json',
65-
body: JSON.stringify({ tree: { sha: 'tree-attestation' } }),
66+
body: JSON.stringify({ tree: { sha: 'tree-registry' } }),
6667
});
6768
}
6869

69-
// 3. Get tree → blob entries
70-
if (url.includes('git/trees/tree-identity')) {
70+
// 3. Get recursive tree → all blobs in registry
71+
if (url.includes('git/trees/tree-registry')) {
7172
return route.fulfill({
7273
status: 200,
7374
contentType: 'application/json',
7475
body: JSON.stringify({
75-
tree: [{ path: 'identity.json', sha: 'blob-identity' }],
76-
}),
77-
});
78-
}
79-
80-
if (url.includes('git/trees/tree-attestation')) {
81-
return route.fulfill({
82-
status: 200,
83-
contentType: 'application/json',
84-
body: JSON.stringify({
85-
tree: [{ path: 'attestation.json', sha: 'blob-attestation' }],
76+
tree: [
77+
{ path: `v1/identities/EX/rB/${TEST_KERI_PREFIX}/state.json`, sha: 'blob-state', type: 'blob' },
78+
{ path: `v1/devices/z6/Mk/did_key_z6MkDev1Device/attestation.json`, sha: 'blob-attestation', type: 'blob' },
79+
{ path: 'v1/metadata.json', sha: 'blob-meta', type: 'blob' },
80+
],
8681
}),
8782
});
8883
}
8984

9085
// 4. Read blobs
91-
if (url.includes('git/blobs/blob-identity')) {
86+
if (url.includes('git/blobs/blob-state')) {
9287
return route.fulfill({
9388
status: 200,
9489
contentType: 'application/json',
95-
body: JSON.stringify({ content: IDENTITY_B64, encoding: 'base64' }),
90+
body: JSON.stringify({ content: STATE_B64, encoding: 'base64' }),
9691
});
9792
}
9893

@@ -136,11 +131,11 @@ test.describe('auths-verify widget E2E', () => {
136131
await page.goto('/e2e/fixture.html');
137132
});
138133

139-
test('badge mode: resolves identity from mocked GitHub API and reaches terminal state', async ({ page }) => {
134+
test('badge mode: resolves identity from mocked registry and reaches terminal state', async ({ page }) => {
140135
await waitForState(page, '#badge-repo');
141136

142137
const state = await page.getAttribute('#badge-repo', 'data-state');
143-
// The widget fetched refs, read identity.json, attempted WASM verification.
138+
// The widget fetched registry, read state.json, attempted WASM verification.
144139
// With fake crypto data the result is either 'verified', 'invalid', or 'error'
145140
// — any of these proves the pipeline ran end-to-end.
146141
expect(['verified', 'invalid', 'error']).toContain(state);
@@ -197,7 +192,6 @@ test.describe('auths-verify widget E2E', () => {
197192
});
198193

199194
test('events: widget emits auths-verified or auths-error', async ({ page }) => {
200-
// Collect events from the badge-repo widget
201195
const events = await page.evaluate(() => {
202196
return new Promise<{ type: string; detail: unknown }[]>((resolve) => {
203197
const collected: { type: string; detail: unknown }[] = [];
@@ -211,7 +205,6 @@ test.describe('auths-verify widget E2E', () => {
211205
collected.push({ type: 'auths-error', detail: (e as CustomEvent).detail });
212206
});
213207

214-
// Wait for event (widget auto-verifies on connect)
215208
setTimeout(() => resolve(collected), 10_000);
216209
});
217210
});
@@ -245,5 +238,4 @@ test.describe('auths-verify widget E2E', () => {
245238
});
246239
expect(expanded).toBe('false');
247240
});
248-
249241
});

src/resolvers/did-utils.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,28 @@ export function didKeyToPublicKeyHex(didKey: string): string {
6565
return bytesToHex(decoded.slice(2, 34));
6666
}
6767

68+
/**
69+
* Decode a CESR-encoded Ed25519 public key to hex.
70+
*
71+
* CESR uses a 1-char type code prefix that replaces base64 padding.
72+
* For Ed25519 keys: prefix 'D', total 44 chars.
73+
* To decode: replace prefix with 'A' (zero byte), base64url-decode → 33 bytes,
74+
* skip first byte, remaining 32 bytes are the raw key.
75+
*/
76+
export function cesrToPublicKeyHex(cesr: string): string {
77+
if (cesr.length !== 44 || cesr[0] !== 'D') {
78+
throw new Error(`Expected 44-char CESR Ed25519 key (D prefix), got: ${cesr}`);
79+
}
80+
const b64url = 'A' + cesr.slice(1);
81+
const b64 = b64url.replace(/-/g, '+').replace(/_/g, '/');
82+
const binary = atob(b64);
83+
const bytes = new Uint8Array(32);
84+
for (let i = 0; i < 32; i++) {
85+
bytes[i] = binary.charCodeAt(i + 1);
86+
}
87+
return bytesToHex(bytes);
88+
}
89+
6890
/**
6991
* Sanitize a DID for use in Git ref paths.
7092
* Matches Rust: layout.rs:247-251 — replace non-alphanumeric with '_'

src/resolvers/gitea.ts

Lines changed: 42 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,18 @@
11
/**
22
* Gitea adapter — resolves auths identity data via Gitea REST API.
33
*
4-
* Mirrors the GitHub adapter with Gitea-specific API paths:
5-
* /api/v1/repos/{owner}/{repo}/git/...
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)
67
*
78
* Base URL is configurable for self-hosted instances.
89
*/
910

1011
import type { ForgeAdapter } from './adapter';
1112
import type { ForgeConfig, RefEntry, ResolveResult } from './types';
12-
import { didKeyToPublicKeyHex } from './did-utils';
13+
import { cesrToPublicKeyHex } from './did-utils';
1314

14-
// Git ref constants — mirrors auths-id/src/storage/layout.rs
15-
const IDENTITY_REF = 'refs/auths/identity';
16-
const DEVICE_PREFIX = 'refs/auths/keys';
17-
const IDENTITY_BLOB = 'identity.json';
18-
const ATTESTATION_BLOB = 'attestation.json';
15+
const REGISTRY_REF = 'refs/auths/registry';
1916

2017
async function giteaFetch(url: string): Promise<Response> {
2118
const res = await fetch(url, {
@@ -29,11 +26,9 @@ async function giteaFetch(url: string): Promise<Response> {
2926

3027
export const giteaAdapter: ForgeAdapter = {
3128
async listAuthsRefs(config: ForgeConfig): Promise<RefEntry[]> {
32-
// Gitea API: GET /api/v1/repos/{owner}/{repo}/git/refs/auths
3329
const url = `${config.baseUrl}/api/v1/repos/${config.owner}/${config.repo}/git/refs/auths`;
3430
const res = await giteaFetch(url);
3531
const data: Array<{ ref: string; object: { sha: string } }> = await res.json();
36-
// Gitea may return a single object or array depending on version
3732
const entries = Array.isArray(data) ? data : [data];
3833
return entries.map((entry) => ({ ref: entry.ref, sha: entry.object.sha }));
3934
},
@@ -55,55 +50,65 @@ export const giteaAdapter: ForgeAdapter = {
5550
return { bundle: null, error: 'No auths refs found in this repository' };
5651
}
5752

58-
// Find and read identity ref
59-
const identityRef = refs.find((r) => r.ref === IDENTITY_REF);
60-
if (!identityRef) {
61-
return { bundle: null, error: 'No identity ref found (refs/auths/identity)' };
53+
const registryRef = refs.find((r) => r.ref === REGISTRY_REF);
54+
if (!registryRef) {
55+
return { bundle: null, error: 'No registry ref found (refs/auths/registry)' };
6256
}
6357

64-
// Follow commit → tree → identity.json blob
65-
const identityBlob = await resolveTreeBlob(config, identityRef.sha, IDENTITY_BLOB);
66-
if (!identityBlob) {
67-
return { bundle: null, error: 'Could not read identity.json from identity ref' };
68-
}
58+
const commitUrl = `${config.baseUrl}/api/v1/repos/${config.owner}/${config.repo}/git/commits/${registryRef.sha}`;
59+
const commitRes = await giteaFetch(commitUrl);
60+
const commit: { tree: { sha: string } } = await commitRes.json();
6961

70-
const identity = JSON.parse(identityBlob);
71-
const controllerDid: string = identity.controller_did ?? identity.identity_did;
62+
const treeUrl = `${config.baseUrl}/api/v1/repos/${config.owner}/${config.repo}/git/trees/${commit.tree.sha}?recursive=1`;
63+
const treeRes = await giteaFetch(treeUrl);
64+
const tree: { tree: Array<{ path: string; sha: string; type: string }> } = await treeRes.json();
7265

73-
if (!controllerDid) {
74-
return { bundle: null, error: 'No controller_did found in identity.json' };
66+
const stateEntry = tree.tree.find(
67+
(e) => e.type === 'blob' && /^v1\/identities\/[^/]{2}\/[^/]{2}\/[^/]+\/state\.json$/.test(e.path),
68+
);
69+
if (!stateEntry) {
70+
return { bundle: null, error: 'No identity state found in registry' };
7571
}
7672

73+
const keriPrefix = stateEntry.path.split('/')[4];
74+
const controllerDid = `did:keri:${keriPrefix}`;
75+
7776
if (identityFilter && controllerDid !== identityFilter) {
7877
return {
7978
bundle: null,
8079
error: `Identity ${controllerDid} does not match filter ${identityFilter}`,
8180
};
8281
}
8382

84-
// Extract public key from DID
83+
const stateBlob = await this.readBlob(config, stateEntry.sha);
84+
const state = JSON.parse(stateBlob);
85+
const currentKeyCesr: string | undefined = state.state?.current_keys?.[0];
86+
87+
if (!currentKeyCesr) {
88+
return { bundle: null, error: 'No current key found in identity state' };
89+
}
90+
8591
let publicKeyHex: string;
86-
if (controllerDid.startsWith('did:key:z')) {
87-
publicKeyHex = didKeyToPublicKeyHex(controllerDid);
88-
} else {
92+
try {
93+
publicKeyHex = cesrToPublicKeyHex(currentKeyCesr);
94+
} catch (err) {
8995
return {
9096
bundle: null,
91-
error: `Cannot extract public key from ${controllerDid}. Only did:key is supported for auto-resolve.`,
97+
error: `Failed to decode CESR key: ${err instanceof Error ? err.message : String(err)}`,
9298
};
9399
}
94100

95-
// Discover device attestation refs
96-
const deviceRefs = refs.filter((r) => r.ref.startsWith(DEVICE_PREFIX + '/'));
97-
const attestationChain: object[] = [];
101+
const attestationEntries = tree.tree.filter(
102+
(e) => e.type === 'blob' && /^v1\/devices\/[^/]{2}\/[^/]{2}\/[^/]+\/attestation\.json$/.test(e.path),
103+
);
98104

99-
for (const deviceRef of deviceRefs) {
105+
const attestationChain: object[] = [];
106+
for (const entry of attestationEntries) {
100107
try {
101-
const attestBlob = await resolveTreeBlob(config, deviceRef.sha, ATTESTATION_BLOB);
102-
if (attestBlob) {
103-
attestationChain.push(JSON.parse(attestBlob));
104-
}
108+
const blob = await this.readBlob(config, entry.sha);
109+
attestationChain.push(JSON.parse(blob));
105110
} catch {
106-
// Skip unreadable device refs
111+
// Skip unreadable attestations
107112
}
108113
}
109114

@@ -122,22 +127,3 @@ export const giteaAdapter: ForgeAdapter = {
122127
}
123128
},
124129
};
125-
126-
async function resolveTreeBlob(
127-
config: ForgeConfig,
128-
commitSha: string,
129-
blobName: string,
130-
): Promise<string | null> {
131-
const commitUrl = `${config.baseUrl}/api/v1/repos/${config.owner}/${config.repo}/git/commits/${commitSha}`;
132-
const commitRes = await giteaFetch(commitUrl);
133-
const commit: { tree: { sha: string } } = await commitRes.json();
134-
135-
const treeUrl = `${config.baseUrl}/api/v1/repos/${config.owner}/${config.repo}/git/trees/${commit.tree.sha}`;
136-
const treeRes = await giteaFetch(treeUrl);
137-
const tree: { tree: Array<{ path: string; sha: string }> } = await treeRes.json();
138-
139-
const blobEntry = tree.tree.find((t) => t.path === blobName);
140-
if (!blobEntry) return null;
141-
142-
return giteaAdapter.readBlob(config, blobEntry.sha);
143-
}

0 commit comments

Comments
 (0)