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
80 changes: 80 additions & 0 deletions src/domain/did-document.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,86 @@
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<string, unknown> {
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<Record<string, unknown>>;
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({
Expand Down Expand Up @@ -142,7 +222,7 @@
const scid = "QmYwAPJzv5CZsnAzt8auVZRnGi2C19h1QSpL6Y6N7RZ6Z7";
const didWebvh = `did:webvh:${scid}:archive.example`;
const keyFragment = "Nm8goDK4";
const keyId = `${didWebvh}?versionId=1-${"a".repeat(64)}#${keyFragment}`;

Check warning on line 225 in src/domain/did-document.test.ts

View workflow job for this annotation

GitHub Actions / Node 24 / ubuntu-latest

eslint(no-shadow)

src/domain/did-document.test.ts:225:15: 'keyId' is already declared in the upper scope.

Check warning on line 225 in src/domain/did-document.test.ts

View workflow job for this annotation

GitHub Actions / Node 22 / ubuntu-latest

eslint(no-shadow)

src/domain/did-document.test.ts:225:15: 'keyId' is already declared in the upper scope.

Check warning on line 225 in src/domain/did-document.test.ts

View workflow job for this annotation

GitHub Actions / Node 20 / macos-latest

eslint(no-shadow)

src/domain/did-document.test.ts:225:15: 'keyId' is already declared in the upper scope.

Check warning on line 225 in src/domain/did-document.test.ts

View workflow job for this annotation

GitHub Actions / Node 20 / ubuntu-latest

eslint(no-shadow)

src/domain/did-document.test.ts:225:15: 'keyId' is already declared in the upper scope.

Check warning on line 225 in src/domain/did-document.test.ts

View workflow job for this annotation

GitHub Actions / linux / arm64

eslint(no-shadow)

src/domain/did-document.test.ts:225:15: 'keyId' is already declared in the upper scope.

Check warning on line 225 in src/domain/did-document.test.ts

View workflow job for this annotation

GitHub Actions / linux / x64

eslint(no-shadow)

src/domain/did-document.test.ts:225:15: 'keyId' is already declared in the upper scope.

Check warning on line 225 in src/domain/did-document.test.ts

View workflow job for this annotation

GitHub Actions / Node 20 / windows-latest

eslint(no-shadow)

src/domain/did-document.test.ts:225:15: 'keyId' is already declared in the upper scope.

Check warning on line 225 in src/domain/did-document.test.ts

View workflow job for this annotation

GitHub Actions / macos / arm64

eslint(no-shadow)

src/domain/did-document.test.ts:225:15: 'keyId' is already declared in the upper scope.

Check warning on line 225 in src/domain/did-document.test.ts

View workflow job for this annotation

GitHub Actions / windows / x64

eslint(no-shadow)

src/domain/did-document.test.ts:225:15: 'keyId' is already declared in the upper scope.

Check warning on line 225 in src/domain/did-document.test.ts

View workflow job for this annotation

GitHub Actions / macos / x64

eslint(no-shadow)

src/domain/did-document.test.ts:225:15: 'keyId' is already declared in the upper scope.
const document = {
id: didWebvh,
assertionMethod: [
Expand Down
74 changes: 67 additions & 7 deletions src/domain/did-document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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",
Expand All @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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),
Expand Down
Loading