Skip to content

Commit afdcfaf

Browse files
committed
fix: correct CESR encoding
1 parent aea2923 commit afdcfaf

6 files changed

Lines changed: 178 additions & 28 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@auths-dev/verify",
3-
"version": "0.2.5",
3+
"version": "0.2.7",
44
"description": "Drop-in <auths-verify> web component for decentralized commit verification",
55
"type": "module",
66
"main": "dist/auths-verify.mjs",

src/auths-verify.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ class AuthsVerify extends HTMLElement {
2323
#report: VerificationReport | null = null;
2424
#debounceTimer: ReturnType<typeof setTimeout> | null = null;
2525
#detailOpen = false;
26+
#verifying = false;
2627
#shadow: ShadowRoot;
2728

2829
constructor() {
@@ -56,8 +57,8 @@ class AuthsVerify extends HTMLElement {
5657
return;
5758
}
5859

59-
// Data attributes changed — re-verify if auto
60-
if (this.autoVerify && this.isConnected && this.#hasInput()) {
60+
// Data attributes changed — re-verify if auto (but not while already verifying)
61+
if (this.autoVerify && this.isConnected && !this.#verifying && this.#hasInput()) {
6162
this.#scheduleVerify();
6263
}
6364
}
@@ -148,6 +149,7 @@ class AuthsVerify extends HTMLElement {
148149
}
149150

150151
this.#setState('loading');
152+
this.#verifying = true;
151153

152154
try {
153155
// If repo is set but attestation data is missing, resolve from forge
@@ -207,6 +209,8 @@ class AuthsVerify extends HTMLElement {
207209
detail: { error: err instanceof Error ? err.message : String(err) },
208210
}),
209211
);
212+
} finally {
213+
this.#verifying = false;
210214
}
211215
}
212216

src/resolvers/did-utils.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -68,21 +68,22 @@ export function didKeyToPublicKeyHex(didKey: string): string {
6868
/**
6969
* Decode a CESR-encoded Ed25519 public key to hex.
7070
*
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.
71+
* CESR uses a 1-char type code prefix. For Ed25519 keys: prefix 'D', total 44 chars.
72+
* To decode: strip the prefix, base64url-decode the remaining 43 chars (with padding)
73+
* to get the raw 32-byte key. This matches the Rust `KeriPublicKey::parse` implementation.
7574
*/
7675
export function cesrToPublicKeyHex(cesr: string): string {
7776
if (cesr.length !== 44 || cesr[0] !== 'D') {
7877
throw new Error(`Expected 44-char CESR Ed25519 key (D prefix), got: ${cesr}`);
7978
}
80-
const b64url = 'A' + cesr.slice(1);
81-
const b64 = b64url.replace(/-/g, '+').replace(/_/g, '/');
79+
// Strip the 'D' prefix, decode the remaining 43 base64url chars
80+
const payload = cesr.slice(1);
81+
// Convert base64url to standard base64 and add padding (43 % 4 = 3 → 1 '=')
82+
const b64 = payload.replace(/-/g, '+').replace(/_/g, '/') + '=';
8283
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);
84+
const bytes = new Uint8Array(binary.length);
85+
for (let i = 0; i < binary.length; i++) {
86+
bytes[i] = binary.charCodeAt(i);
8687
}
8788
return bytesToHex(bytes);
8889
}

src/verifier-bridge.ts

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ let initPromise: Promise<void> | null = null;
1414
let wasmModule: WasmModule | null = null;
1515

1616
interface WasmModule {
17-
default: (input?: BufferSource | string) => Promise<void>;
18-
verifyAttestationWithResult(attestationJson: string, issuerPkHex: string): string;
19-
verifyChainJson(attestationsJsonArray: string, rootPkHex: string): string;
17+
default?: (input?: BufferSource | string) => Promise<void>;
18+
verifyAttestationWithResult(attestationJson: string, issuerPkHex: string): string | Promise<string>;
19+
verifyChainJson(attestationsJsonArray: string, rootPkHex: string): string | Promise<string>;
2020
}
2121

2222
function isInlined(): boolean {
@@ -27,17 +27,18 @@ async function loadWasm(wasmUrl?: string): Promise<void> {
2727
// Dynamic import of the WASM JS glue — path resolved by Vite alias
2828
const wasm: WasmModule = await import(/* @vite-ignore */ 'auths-verifier-wasm');
2929

30-
if (isInlined()) {
31-
// Decode base64-inlined WASM and initialize from buffer
32-
const binary = Uint8Array.from(atob(INLINE_WASM_BASE64!), c => c.charCodeAt(0));
33-
await wasm.default(binary);
34-
} else if (wasmUrl) {
35-
// Fetch WASM from explicit URL
36-
await wasm.default(wasmUrl);
37-
} else {
38-
// Default: let wasm-bindgen resolve the .wasm file relative to the JS glue
39-
await wasm.default();
30+
if (typeof wasm.default === 'function') {
31+
// Module requires explicit initialization (slim build or dev mode)
32+
if (isInlined()) {
33+
const binary = Uint8Array.from(atob(INLINE_WASM_BASE64!), c => c.charCodeAt(0));
34+
await wasm.default(binary);
35+
} else if (wasmUrl) {
36+
await wasm.default(wasmUrl);
37+
} else {
38+
await wasm.default();
39+
}
4040
}
41+
// If no .default export, WASM was auto-initialized by vite-plugin-wasm (ESM direct import)
4142

4243
wasmModule = wasm;
4344
}
@@ -66,7 +67,7 @@ export async function verifyAttestation(
6667
): Promise<VerificationResult> {
6768
await ensureInit();
6869
try {
69-
const resultJson = wasmModule!.verifyAttestationWithResult(attestationJson, issuerPublicKeyHex);
70+
const resultJson = await wasmModule!.verifyAttestationWithResult(attestationJson, issuerPublicKeyHex);
7071
return JSON.parse(resultJson) as VerificationResult;
7172
} catch (error) {
7273
return {
@@ -89,7 +90,7 @@ export async function verifyChain(
8990
);
9091

9192
try {
92-
const reportJson = wasmModule!.verifyChainJson(attestationsJson, rootPublicKeyHex);
93+
const reportJson = await wasmModule!.verifyChainJson(attestationsJson, rootPublicKeyHex);
9394
return JSON.parse(reportJson) as VerificationReport;
9495
} catch (error) {
9596
return {

tests/resolvers/did-utils.test.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { describe, it, expect } from 'vitest';
2-
import { didKeyToPublicKeyHex, sanitizeDidForRef } from '../../src/resolvers/did-utils';
2+
import {
3+
cesrToPublicKeyHex,
4+
didKeyToPublicKeyHex,
5+
sanitizeDidForRef,
6+
} from '../../src/resolvers/did-utils';
37

48
describe('didKeyToPublicKeyHex', () => {
59
it('should extract Ed25519 public key from did:key:z...', () => {
@@ -28,6 +32,32 @@ describe('didKeyToPublicKeyHex', () => {
2832
});
2933
});
3034

35+
describe('cesrToPublicKeyHex', () => {
36+
it('decodes CESR Ed25519 key matching Rust KeriPublicKey::parse', () => {
37+
// Real test vector from identity E6IXlw5-lnX88r3WZCt3u1qyN_Xlq7nQjtoTmuOfMIjI
38+
// CESR key from state.json current_keys[0]
39+
const cesr = 'D1P_LPk3v4aTOxFMeLJq55lsPL5-i_BhRfIn27APru2Q';
40+
// Expected: Rust KeriPublicKey::parse strips D, base64url-decodes 43 chars → 32 bytes
41+
const expected = 'd4ffcb3e4defe1a4cec4531e2c9ab9e65b0f2f9fa2fc18517c89f6ec03ebbb64';
42+
expect(cesrToPublicKeyHex(cesr)).toBe(expected);
43+
});
44+
45+
it('decodes all-zero key correctly', () => {
46+
// Rust: KeriPublicKey::parse("DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") → [0u8; 32]
47+
const cesr = 'DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
48+
const expected = '0'.repeat(64);
49+
expect(cesrToPublicKeyHex(cesr)).toBe(expected);
50+
});
51+
52+
it('rejects non-D prefix', () => {
53+
expect(() => cesrToPublicKeyHex('X' + 'A'.repeat(43))).toThrow('Expected 44-char CESR');
54+
});
55+
56+
it('rejects wrong length', () => {
57+
expect(() => cesrToPublicKeyHex('DAAA')).toThrow('Expected 44-char CESR');
58+
});
59+
});
60+
3161
describe('sanitizeDidForRef', () => {
3262
it('should replace colons with underscores', () => {
3363
expect(sanitizeDidForRef('did:keri:EOrg123')).toBe('did_keri_EOrg123');
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
3+
/**
4+
* Regression tests for verifier-bridge.
5+
*
6+
* These test the REAL verifier-bridge source against a mocked WASM module,
7+
* unlike verifier-bridge.test.ts which mocks the entire bridge.
8+
*
9+
* Covers:
10+
* - Bug #1: loadWasm crashes when WASM module has no .default() export
11+
* (auto-initialized by vite-plugin-wasm)
12+
* - Bug #2: WASM functions return Promises but were not awaited,
13+
* causing JSON.parse(Promise) → SyntaxError
14+
*/
15+
16+
const mockVerifyAttestationWithResult = vi.fn();
17+
const mockVerifyChainJson = vi.fn();
18+
19+
// Mock the underlying WASM module — NOT the bridge itself.
20+
// This exercises the real verifier-bridge logic.
21+
vi.mock('auths-verifier-wasm', () => ({
22+
// Explicitly set default to undefined — simulates vite-plugin-wasm
23+
// auto-initialized module that has no init function.
24+
default: undefined,
25+
verifyAttestationWithResult: mockVerifyAttestationWithResult,
26+
verifyChainJson: mockVerifyChainJson,
27+
}));
28+
29+
// Import the REAL verifier-bridge (it will dynamically import our mock above)
30+
import { ensureInit, verifyAttestation, verifyChain } from '../src/verifier-bridge';
31+
32+
describe('verifier-bridge regressions', () => {
33+
beforeEach(() => {
34+
vi.clearAllMocks();
35+
});
36+
37+
describe('Bug #1: WASM module without .default export', () => {
38+
it('ensureInit succeeds when module has no .default()', async () => {
39+
// vite-plugin-wasm auto-initializes WASM and produces a module
40+
// without .default(). Before the fix, this threw:
41+
// TypeError: wasm.default is not a function
42+
await expect(ensureInit()).resolves.toBeUndefined();
43+
});
44+
});
45+
46+
describe('Bug #2: async WASM functions must be awaited', () => {
47+
it('verifyChain awaits a Promise-returning verifyChainJson', async () => {
48+
// WASM functions compiled with async Rust return Promises via externref.
49+
// Before the fix, the Promise was passed directly to JSON.parse(),
50+
// which stringified it to "[object Promise]" and threw SyntaxError.
51+
mockVerifyChainJson.mockResolvedValue(
52+
JSON.stringify({
53+
status: { type: 'Valid' },
54+
chain: [{ issuer: 'did:keri:a', subject: 'did:key:b', valid: true }],
55+
warnings: [],
56+
}),
57+
);
58+
59+
const report = await verifyChain([{ test: true }], 'aabbccdd');
60+
expect(report.status.type).toBe('Valid');
61+
expect(report.chain).toHaveLength(1);
62+
});
63+
64+
it('verifyAttestation awaits a Promise-returning verifyAttestationWithResult', async () => {
65+
mockVerifyAttestationWithResult.mockResolvedValue(
66+
JSON.stringify({ valid: true }),
67+
);
68+
69+
const result = await verifyAttestation('{"test":true}', 'aabbccdd');
70+
expect(result.valid).toBe(true);
71+
});
72+
73+
it('verifyChain does not produce BrokenChain from un-awaited Promise', async () => {
74+
// The specific symptom: JSON.parse(Promise) throws, catch block returns
75+
// { status: { type: 'BrokenChain' } }. If this test gets BrokenChain
76+
// with a valid mock, the await is missing.
77+
mockVerifyChainJson.mockResolvedValue(
78+
JSON.stringify({
79+
status: { type: 'Valid' },
80+
chain: [],
81+
warnings: [],
82+
}),
83+
);
84+
85+
const report = await verifyChain([], 'aabb');
86+
expect(report.status.type).not.toBe('BrokenChain');
87+
expect(report.status.type).toBe('Valid');
88+
});
89+
90+
it('verifyChain still handles sync return values', async () => {
91+
// If WASM functions return a plain string (not a Promise),
92+
// await on a non-thenable just passes it through.
93+
mockVerifyChainJson.mockReturnValue(
94+
JSON.stringify({
95+
status: { type: 'Valid' },
96+
chain: [],
97+
warnings: [],
98+
}),
99+
);
100+
101+
const report = await verifyChain([], 'aabb');
102+
expect(report.status.type).toBe('Valid');
103+
});
104+
105+
it('verifyAttestation still handles sync return values', async () => {
106+
mockVerifyAttestationWithResult.mockReturnValue(
107+
JSON.stringify({ valid: true }),
108+
);
109+
110+
const result = await verifyAttestation('{"test":true}', 'aabb');
111+
expect(result.valid).toBe(true);
112+
});
113+
});
114+
});

0 commit comments

Comments
 (0)