diff --git a/src/domain/did-document.test.ts b/src/domain/did-document.test.ts index fc6ec83..64ca082 100644 --- a/src/domain/did-document.test.ts +++ b/src/domain/did-document.test.ts @@ -42,6 +42,86 @@ function base58(bytes: Uint8Array): string { return output; } +// Shape of the did:web documents the GAMI portal generated before 2026-06-30 +// (some institutions still host one): no assertionMethod, bare publicKeyHex. +function legacyDocument(hex = keyHex): Record { + return { + "@context": ["https://www.w3.org/ns/did/v1"], + id: did, + verificationMethod: [ + { id: keyId, type: "Ed25519VerificationKey2020", controller: did, publicKeyHex: hex }, + ], + authentication: [keyId], + }; +} + +describe("authorizeDidKey temporary legacy rule", () => { + it("accepts a document without assertionMethod when its listed key matches the record", () => { + expect(authorizeDidKey(legacyDocument(), keyId, keyHex)).toMatchObject({ + status: "passed", + publicKeyHex: keyHex, + message: expect.stringContaining("temporary legacy rule"), + }); + }); + + it("still rejects a legacy document whose key differs from the record's key", () => { + expect(authorizeDidKey(legacyDocument("00".repeat(32)), keyId, keyHex)).toMatchObject({ + status: "failed", + message: expect.stringContaining("embedded GPR key"), + }); + }); + + it("needs the record's embedded key to apply the legacy rule", () => { + expect(authorizeDidKey(legacyDocument(), keyId)).toMatchObject({ + status: "failed", + message: expect.stringContaining("legacy rule needs the record's embedded key"), + }); + }); + + it("rejects a legacy document that does not list the record's key id", () => { + expect(authorizeDidKey(legacyDocument(), `${did}#key-2`, keyHex)).toMatchObject({ + status: "failed", + message: expect.stringContaining("is not listed under authentication"), + }); + }); + + it("rejects a legacy document whose key is not listed under authentication", () => { + const document = { ...legacyDocument(), authentication: [] }; + expect(authorizeDidKey(document, keyId, keyHex)).toMatchObject({ + status: "failed", + message: expect.stringContaining("is not listed under authentication"), + }); + }); + + it("accepts no other key encoding under the legacy rule", () => { + const document = legacyDocument(); + const [method] = document.verificationMethod as Array>; + method!.type = "JsonWebKey2020"; + method!.publicKeyJwk = { + kty: "OKP", + crv: "Ed25519", + x: Buffer.from(keyHex, "hex").toString("base64url"), + }; + expect(authorizeDidKey(document, keyId, keyHex)).toMatchObject({ + status: "failed", + message: expect.stringContaining("accepts only an Ed25519VerificationKey2020"), + }); + }); + + it("keeps the strict rule once a document declares assertionMethod, even an empty one", () => { + const document = { ...legacyDocument(), assertionMethod: [] }; + expect(authorizeDidKey(document, keyId, keyHex)).toMatchObject({ + status: "failed", + message: expect.stringContaining("not authorized by assertionMethod"), + }); + }); + + it("does not accept a bare publicKeyHex key outside a legacy document", () => { + const document = { ...legacyDocument(), assertionMethod: [keyId] }; + expect(authorizeDidKey(document, keyId, keyHex)).toMatchObject({ status: "failed" }); + }); +}); + describe("authorizeDidKey", () => { it("authorizes the exact assertionMethod JWK and matches the GPR key", () => { expect(authorizeDidKey(jwkDocument(), keyId, keyHex)).toMatchObject({ diff --git a/src/domain/did-document.ts b/src/domain/did-document.ts index 7768cb2..f52cc02 100644 --- a/src/domain/did-document.ts +++ b/src/domain/did-document.ts @@ -52,6 +52,51 @@ function decodeBase58(value: string): Uint8Array { return Uint8Array.from(bytes, (_, index) => bytes[bytes.length - 1 - index]!); } +// TEMPORARY legacy rule, mirroring the hosted GAMI verifier (gami-app +// src/lib/did-resolve.ts isAuthorizedForAssertion). did:web documents the GAMI +// portal generated before 2026-06-30 declare no assertionMethod and carry +// Ed25519VerificationKey2020 keys as bare publicKeyHex, listed under +// authentication. For exactly that shape, the record's key counts as authorized +// when the verificationMethod with that id holds exactly the record's embedded +// key. Remove it, with its tests, once the remaining institutions re-publish +// their documents from the portal. +function isLegacyDocument(document: JsonObject): boolean { + return document.assertionMethod === undefined || document.assertionMethod === null; +} + +function decodeLegacyKey(method: JsonObject): Uint8Array { + if ( + method.type !== "Ed25519VerificationKey2020" || + method.publicKeyMultibase !== undefined || + method.publicKeyJwk !== undefined + ) + throw new Error( + "the legacy rule accepts only an Ed25519VerificationKey2020 key given as bare publicKeyHex", + ); + if (typeof method.publicKeyHex !== "string" || !/^[0-9a-f]{64}$/i.test(method.publicKeyHex)) + throw new Error("publicKeyHex must contain exactly 32 bytes of hexadecimal data"); + return hexToBytes(method.publicKeyHex); +} + +function findLegacyMethod( + document: JsonObject, + did: string, + keyId: string, +): JsonObject | undefined { + const matches = (Array.isArray(document.verificationMethod) ? document.verificationMethod : []) + .filter(isObject) + .filter( + (method) => typeof method.id === "string" && resolveDidUrl(method.id, did) === keyId, + ); + if (matches.length > 1) + throw new Error(`${keyId} must have exactly one verificationMethod definition`); + const authentication = Array.isArray(document.authentication) ? document.authentication : []; + const listed = authentication.some( + (entry) => typeof entry === "string" && resolveDidUrl(entry, did) === keyId, + ); + return listed ? matches[0] : undefined; +} + function decodeVerificationKey(method: JsonObject): Uint8Array { const finish = (key: Uint8Array): Uint8Array => { if (method.publicKeyHex !== undefined) { @@ -182,9 +227,21 @@ export function authorizeDidKey( evidenceSource, }; } + const legacy = parsed.method === "web" && isLegacyDocument(document); + if (legacy && !embeddedPublicKeyHex && !overriddenPublicKeyHex) { + return { + status: "failed", + message: `${documentKeyId} cannot be authorized: the DID document declares no assertionMethod, and the legacy rule needs the record's embedded key`, + did: parsed.did, + keyId, + evidenceSource, + }; + } let method: JsonObject | undefined; try { - method = findAuthorizedMethod(document, parsed.did, documentKeyId); + method = legacy + ? findLegacyMethod(document, parsed.did, documentKeyId) + : findAuthorizedMethod(document, parsed.did, documentKeyId); } catch (error) { return { status: "failed", @@ -197,7 +254,9 @@ export function authorizeDidKey( if (!method) { return { status: "failed", - message: `${documentKeyId} is not authorized by assertionMethod`, + message: legacy + ? `${documentKeyId} is not listed under authentication in the DID document` + : `${documentKeyId} is not authorized by assertionMethod`, did: parsed.did, keyId, evidenceSource, @@ -219,7 +278,7 @@ export function authorizeDidKey( let key: Uint8Array; try { - key = decodeVerificationKey(method); + key = legacy ? decodeLegacyKey(method) : decodeVerificationKey(method); } catch (error) { const message = error instanceof Error ? error.message : "Unsupported DID verification key"; return { @@ -249,10 +308,11 @@ export function authorizeDidKey( } return { status: "passed", - message: - evidenceSource === "resolved-history" - ? `${documentKeyId} is authorized for assertion by the resolved did:webvh history at ${parsed.versionId}` - : `${documentKeyId} is authorized for assertion by the ${evidenceSource === "resolved-current" ? "live" : "supplied"} current did:web document`, + message: legacy + ? `${documentKeyId} is accepted under the temporary legacy rule: the ${evidenceSource === "resolved-current" ? "live" : "supplied"} did:web document declares no assertionMethod, and its listed key matches the record's key. The institution should re-publish its DID document` + : evidenceSource === "resolved-history" + ? `${documentKeyId} is authorized for assertion by the resolved did:webvh history at ${parsed.versionId}` + : `${documentKeyId} is authorized for assertion by the ${evidenceSource === "resolved-current" ? "live" : "supplied"} current did:web document`, did: parsed.did, keyId, publicKeyHex: bytesToHex(key),