From 2f2124102f6f0740b6a3bcfb609fbd65c654ab75 Mon Sep 17 00:00:00 2001 From: Sumit Vekariya Date: Sat, 24 Oct 2026 11:32:14 +0530 Subject: [PATCH] fix(did): accept legacy did:web documents without assertionMethod, temporarily Until 2026-06-30 the GAMI portal generated did:web documents without assertionMethod, with Ed25519VerificationKey2020 keys as bare publicKeyHex listed under authentication. Institutions still hosting such a document fail institutional_identity here, while the hosted verifier passes them under its legacy rule. Mirror that rule for did:web documents of exactly that shape: no assertionMethod at all, the record's key id listed under authentication, and a verificationMethod with that id holding exactly the record's embedded key as bare publicKeyHex. An assertionMethod that is present, even empty, keeps the strict rule. The result says the legacy rule was used. Remove it once the remaining institutions re-publish. --- src/domain/did-document.test.ts | 80 +++++++++++++++++++++++++++++++++ src/domain/did-document.ts | 74 +++++++++++++++++++++++++++--- 2 files changed, 147 insertions(+), 7 deletions(-) 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),