From 6aba22a44f8cc6922988d71599e0116633376d6f Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 4 Jul 2026 12:27:36 +0900 Subject: [PATCH 1/3] Resolve local did:key verification Resolve Ed25519 did:key verification methods locally so Object Integrity Proof verification can work for portable ActivityPub objects without fetching the verification method as a remote JSON-LD document. Add vocab-runtime helpers for exporting and importing Ed25519 did:key DIDs, parsing did:key verification method DID URLs, and rejecting unsupported or malformed DID keys before they reach remote lookup paths. Document the new proof verification behavior and update the changelog for PR 915. Fixes https://github.com/fedify-dev/fedify/issues/827 https://github.com/fedify-dev/fedify/pull/915 Assisted-by: Codex:gpt-5.5 --- CHANGES.md | 22 +++- docs/manual/opentelemetry.md | 18 +-- docs/manual/send.md | 8 ++ packages/fedify/src/sig/key.test.ts | 159 ++++++++++++++++++++++++- packages/fedify/src/sig/key.ts | 72 ++++++++++- packages/fedify/src/sig/proof.test.ts | 109 ++++++++++++++++- packages/vocab-runtime/src/key.test.ts | 84 +++++++++++++ packages/vocab-runtime/src/key.ts | 118 ++++++++++++++++++ packages/vocab-runtime/src/mod.ts | 4 + 9 files changed, 577 insertions(+), 17 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index e6314c274..8f4a48cb1 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -8,6 +8,20 @@ Version 2.4.0 To be released. +### @fedify/fedify + + - Added local `did:key` verification method resolution for + [FEP-8b32] Object Integrity Proofs. `verifyProof()` can now verify + Ed25519 `eddsa-jcs-2022` proofs whose `verificationMethod` is a + `did:key:z...#z...` DID URL without fetching the verification method + as a remote JSON-LD document, which is required for [FEP-ef61] + portable objects. [[#827], [#915]] + +[FEP-8b32]: https://w3id.org/fep/8b32 +[FEP-ef61]: https://w3id.org/fep/ef61 +[#827]: https://github.com/fedify-dev/fedify/issues/827 +[#915]: https://github.com/fedify-dev/fedify/pull/915 + ### @fedify/vocab - Added support for [FEP-ef61] portable ActivityPub IRIs in generated @@ -16,12 +30,17 @@ To be released. serialization emits canonical `ap+ef61:` values with decoded DID authorities. [[#826], [#850]] -[FEP-ef61]: https://w3id.org/fep/ef61 [#826]: https://github.com/fedify-dev/fedify/issues/826 [#850]: https://github.com/fedify-dev/fedify/pull/850 ### @fedify/vocab-runtime + - Added helpers for Ed25519 `did:key` DIDs and verification method DID + URLs: `exportDidKey()` exports public keys to base58-btc `did:key` DIDs, + `importDidKey()` imports supported DIDs back to `CryptoKey`, and + `parseDidKeyVerificationMethod()` validates `did:key:z...#z...` + verification methods. [[#827], [#915]] + - Changed `getDocumentLoader()` to reject HTML and XHTML responses that do not advertise an ActivityPub alternate document with a `FetchError` instead of attempting to parse the HTML as JSON. This makes remote HTML @@ -7747,7 +7766,6 @@ Released on June 29, 2024. for Object Integrity Proofs. [[FEP-8b32], [#54]] [eddsa-jcs-2022]: https://codeberg.org/fediverse/fep/pulls/338 -[FEP-8b32]: https://w3id.org/fep/8b32 [#54]: https://github.com/fedify-dev/fedify/issues/54 [#71]: https://github.com/fedify-dev/fedify/issues/71 [#74]: https://github.com/fedify-dev/fedify/issues/74 diff --git a/docs/manual/opentelemetry.md b/docs/manual/opentelemetry.md index a25c1eec8..8d7d584ef 100644 --- a/docs/manual/opentelemetry.md +++ b/docs/manual/opentelemetry.md @@ -563,10 +563,10 @@ Fedify records the following OpenTelemetry metrics: (which may itself be backed by a remote store such as Redis or a database; the measurement reflects whatever round trip that backend incurs). - - `fetched`: the key was not in the cache and was loaded through - the document loader, returning a usable key. This typically - corresponds to a network fetch, but a custom document loader - that serves from a local store will also fall in this bucket. + - `fetched`: the key was not in the cache and returned a usable + key. This typically corresponds to loading the key through the + document loader, but local key resolution paths such as supported + `did:key` verification methods also fall in this bucket. - `error`: no usable key came back (HTTP failure, invalid response body, cached negative entry, thrown exception, etc.). @@ -585,8 +585,10 @@ Fedify records the following OpenTelemetry metrics: - `hit`: the key was served from the configured `KeyCache`, either a valid cached key or a cached negative entry recording a prior failed fetch. - - `fetched`: the key was not in the cache and was loaded through - the document loader, returning a usable key. + - `fetched`: the key was not in the cache and returned a usable + key. This is usually a document-loader lookup, but local key + resolution paths such as supported `did:key` verification methods + also use this result. - `not_found`: the remote responded with `404 Not Found` or `410 Gone`. Recorded together with `http.response.status_code`. - `invalid`: the remote responded with a payload Fedify could not @@ -600,7 +602,9 @@ Fedify records the following OpenTelemetry metrics: `activitypub.cache.enabled` is always present and is `true` when the caller passed a `KeyCache`, `false` otherwise. `activitypub.remote.host` - is the URL host of the key URL, including any non-default port. + is the URL host of the key URL, including any non-default port. It is + empty for key identifiers that do not have a URL host, such as `did:key` + DID URLs. `http.response.status_code` is present only when an HTTP response was observed. Key IDs, full key URLs, and actor IDs are deliberately excluded from these metrics; diff --git a/docs/manual/send.md b/docs/manual/send.md index c088f5be1..aca7806d2 100644 --- a/docs/manual/send.md +++ b/docs/manual/send.md @@ -1084,6 +1084,13 @@ set](./actor.md#public-keys-of-an-actor) and the actor has any Ed25519 key pair. If there are multiple key pairs, Fedify creates the number of integrity proofs equal to the number of Ed25519 key pairs. +When verifying incoming Object Integrity Proofs, Fedify can resolve Ed25519 +`did:key` verification methods locally. A proof whose `verificationMethod` +is a DID URL such as `did:key:z...#z...` does not require fetching that +verification method as a JSON-LD document. This is the key lookup mechanism +used by [FEP-ef61] portable objects; policy checks that relate a portable +object ID to its proof are handled separately from proof verification itself. + > [!TIP] > HTTPS Signatures, Linked Data Signatures, and Object Integrity Proofs can > coexist in an application and be used together for maximum compatibility. @@ -1100,6 +1107,7 @@ equal to the number of Ed25519 key pairs. > key pairs for each actor, and store them in the database. [FEP-8b32]: https://w3id.org/fep/8b32 +[FEP-ef61]: https://w3id.org/fep/ef61 Activity transformers diff --git a/packages/fedify/src/sig/key.test.ts b/packages/fedify/src/sig/key.test.ts index f48ab1f6a..6036ce220 100644 --- a/packages/fedify/src/sig/key.test.ts +++ b/packages/fedify/src/sig/key.test.ts @@ -5,10 +5,15 @@ import { test, } from "@fedify/fixture"; import { CryptographicKey, Multikey } from "@fedify/vocab"; -import { type DocumentLoader, FetchError } from "@fedify/vocab-runtime"; +import { + type DocumentLoader, + exportDidKey, + FetchError, +} from "@fedify/vocab-runtime"; import { assertEquals, assertRejects, assertThrows } from "@std/assert"; import { ed25519Multikey, + ed25519PublicKey, rsaPrivateKey2, rsaPublicKey1, rsaPublicKey2, @@ -459,6 +464,158 @@ test("fetchKeyDetailed() returns detailed fetch errors", async () => { assertEquals(detailedError.error, failure); }); +test("fetchKey() resolves did:key Multikeys without document loading", async () => { + const did = await exportDidKey(ed25519PublicKey.publicKey); + const keyId = `${did}#${did.slice("did:key:".length)}`; + const expectedKey = new Multikey({ + id: new URL(keyId), + controller: new URL(did), + publicKey: ed25519PublicKey.publicKey, + }) as Multikey & { publicKey: CryptoKey }; + const cache: Record = { + [keyId]: null, + }; + let documentLoaderCalls = 0; + const keyCache: KeyCache = { + get(keyId) { + return Promise.resolve(cache[keyId.href]); + }, + set(keyId, key) { + cache[keyId.href] = key; + return Promise.resolve(); + }, + }; + const options: FetchKeyOptions = { + documentLoader() { + documentLoaderCalls++; + throw new TypeError("did:key must not use the document loader"); + }, + contextLoader: mockDocumentLoader, + keyCache, + }; + + assertEquals(await fetchKey(keyId, Multikey, options), { + key: expectedKey, + cached: false, + }); + assertEquals(documentLoaderCalls, 0); + assertEquals(cache[keyId], expectedKey); + assertEquals(await fetchKey(keyId, Multikey, options), { + key: expectedKey, + cached: true, + }); + assertEquals(documentLoaderCalls, 0); +}); + +test("fetchKey() rejects unsupported did:key values locally", async () => { + const invalidKeyId = + "did:key:z6MksHj1MJnidCtDiyYW9ugNFftoX9fLK4bornTxmMZ6X7vq#z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"; + const cache: Record = {}; + let documentLoaderCalls = 0; + const [meterProvider, recorder] = createTestMeterProvider(); + const result = await fetchKey(invalidKeyId, Multikey, { + documentLoader() { + documentLoaderCalls++; + throw new TypeError("did:key must not use the document loader"); + }, + contextLoader: mockDocumentLoader, + meterProvider, + keyCache: { + get(keyId) { + return Promise.resolve(cache[keyId.href]); + }, + set(keyId, key) { + cache[keyId.href] = key; + return Promise.resolve(); + }, + } satisfies KeyCache, + }); + + assertEquals(result, { key: null, cached: false }); + assertEquals(documentLoaderCalls, 0); + assertEquals(cache, {}); + const counter = recorder.getMeasurement("activitypub.key.lookup"); + assertEquals(counter?.attributes["activitypub.lookup.result"], "invalid"); +}); + +test("fetchKeyDetailed() resolves did:key without fetchError", async () => { + const did = await exportDidKey(ed25519PublicKey.publicKey); + const keyId = `${did}#${did.slice("did:key:".length)}`; + const result = await fetchKeyDetailed(keyId, Multikey, { + documentLoader() { + throw new TypeError("did:key must not use the document loader"); + }, + contextLoader: mockDocumentLoader, + }); + + assertEquals(result.key instanceof Multikey, true); + assertEquals(result.cached, false); + assertEquals(result.fetchError, undefined); +}); + +test("fetchKeyDetailed() rejects invalid did:key without fetchError", async () => { + const invalidKeyId = + "did:key:z6MksHj1MJnidCtDiyYW9ugNFftoX9fLK4bornTxmMZ6X7vq#z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"; + const cache: Record = {}; + let documentLoaderCalls = 0; + const result = await fetchKeyDetailed(invalidKeyId, Multikey, { + documentLoader() { + documentLoaderCalls++; + throw new TypeError("did:key must not use the document loader"); + }, + contextLoader: mockDocumentLoader, + keyCache: { + get(keyId) { + return Promise.resolve(cache[keyId.href]); + }, + set(keyId, key) { + cache[keyId.href] = key; + return Promise.resolve(); + }, + } satisfies KeyCache, + }); + + assertEquals(result.key, null); + assertEquals(result.cached, false); + assertEquals(result.fetchError, undefined); + assertEquals(documentLoaderCalls, 0); + assertEquals(cache, {}); +}); + +test("fetchKey() does not resolve did:key as CryptographicKey", async () => { + const did = await exportDidKey(ed25519PublicKey.publicKey); + const keyId = `${did}#${did.slice("did:key:".length)}`; + let documentLoaderCalls = 0; + assertEquals( + await fetchKey(keyId, CryptographicKey, { + documentLoader() { + documentLoaderCalls++; + throw new TypeError("did:key must not use the document loader"); + }, + contextLoader: mockDocumentLoader, + }), + { key: null, cached: false }, + ); + assertEquals(documentLoaderCalls, 0); +}); + +test("fetchKey() records did:key lookup metrics", async () => { + const did = await exportDidKey(ed25519PublicKey.publicKey); + const keyId = `${did}#${did.slice("did:key:".length)}`; + const [meterProvider, recorder] = createTestMeterProvider(); + const result = await fetchKey(keyId, Multikey, { + documentLoader: mockDocumentLoader, + contextLoader: mockDocumentLoader, + meterProvider, + }); + assertEquals(result.cached, false); + + const counter = recorder.getMeasurement("activitypub.key.lookup"); + assertEquals(counter?.attributes["activitypub.lookup.result"], "fetched"); + assertEquals(counter?.attributes["activitypub.cache.enabled"], false); + assertEquals(counter?.attributes["activitypub.remote.host"], ""); +}); + test("fetchKey() records activitypub.key.lookup with hit on cached key", async () => { const [meterProvider, recorder] = createTestMeterProvider(); const cache: Record = {}; diff --git a/packages/fedify/src/sig/key.ts b/packages/fedify/src/sig/key.ts index c9a06dc77..a2b34ad89 100644 --- a/packages/fedify/src/sig/key.ts +++ b/packages/fedify/src/sig/key.ts @@ -1,13 +1,9 @@ -import { - CryptographicKey, - isActor, - type Multikey, - Object, -} from "@fedify/vocab"; +import { CryptographicKey, isActor, Multikey, Object } from "@fedify/vocab"; import { type DocumentLoader, FetchError, getDocumentLoader, + parseDidKeyVerificationMethod, } from "@fedify/vocab-runtime"; import { getLogger } from "@logtape/logtape"; import { @@ -432,6 +428,59 @@ async function clearFetchErrorMetadata( ); } +function isDidKeyUrl(keyId: URL): boolean { + return keyId.protocol === "did:" && keyId.pathname.startsWith("key:"); +} + +async function resolveDidKey( + cacheKey: URL, + cls: FetchableKeyClass, + keyCache: KeyCache | undefined, + logger: ReturnType, +): Promise | null> { + if (!isDidKeyUrl(cacheKey)) return null; + const keyId = cacheKey.href; + const cachedKey = await keyCache?.get(cacheKey); + if (cachedKey instanceof cls && cachedKey.publicKey != null) { + logger.debug("Key {keyId} found in cache.", { keyId }); + return { + key: cachedKey as T & { publicKey: CryptoKey }, + cached: true, + }; + } + const clsIsMultikey = cls === + (Multikey as unknown as FetchableKeyClass); + if (!clsIsMultikey) { + logger.debug( + "Failed to resolve did:key {keyId}; did:key verification methods " + + "are only supported as Multikey values.", + { keyId }, + ); + return { key: null, cached: false }; + } + let verificationMethod: Awaited< + ReturnType + >; + try { + verificationMethod = await parseDidKeyVerificationMethod(cacheKey); + } catch (error) { + logger.debug( + "Failed to resolve did:key verification method {keyId}: {error}", + { keyId, error }, + ); + return { key: null, cached: false }; + } + const key = new Multikey({ + id: verificationMethod.id, + controller: verificationMethod.controller, + publicKey: verificationMethod.publicKey, + }) as unknown as T & { publicKey: CryptoKey }; + await keyCache?.set(cacheKey, key); + await clearFetchErrorMetadata(cacheKey, keyCache); + logger.debug("Resolved did:key verification method {keyId}.", { keyId }); + return { key, cached: false }; +} + async function resolveFetchedKey( document: unknown, cacheKey: URL, @@ -577,6 +626,17 @@ async function fetchKeyWithResult< const logger = getLogger(["fedify", "sig", "key"]); const keyId = cacheKey.href; const keyCache = options.keyCache as FetchErrorMetadataCache | undefined; + const didKey = await resolveDidKey(cacheKey, cls, keyCache, logger); + if (didKey != null) { + outcome = { + result: didKey.key == null + ? "invalid" + : didKey.cached + ? "hit" + : "fetched", + }; + return didKey as TResult; + } const cached = await getCachedFetchKey( cacheKey, keyId, diff --git a/packages/fedify/src/sig/proof.test.ts b/packages/fedify/src/sig/proof.test.ts index d307c87f4..899388891 100644 --- a/packages/fedify/src/sig/proof.test.ts +++ b/packages/fedify/src/sig/proof.test.ts @@ -14,7 +14,13 @@ import { Place, PUBLIC_COLLECTION, } from "@fedify/vocab"; -import { decodeMultibase, importMultibaseKey } from "@fedify/vocab-runtime"; +import { + decodeMultibase, + exportDidKey, + exportMultibaseKey, + importMultibaseKey, + parseIri, +} from "@fedify/vocab-runtime"; import { assert, assertEquals, @@ -568,6 +574,107 @@ test("verifyProof()", async () => { assertFalse(contextLoaderCalls.includes("https://attacker.example/ctx")); }); +test("verifyProof() resolves did:key verification methods locally", async () => { + const multibaseKey = (await exportDidKey(ed25519PublicKey.publicKey)).slice( + "did:key:".length, + ); + const did = `did:key:${multibaseKey}`; + const keyId = new URL(`${did}#${multibaseKey}`); + const created = Temporal.Instant.from("2023-02-24T23:36:38Z"); + const note = new Note({ + id: parseIri(`ap://did:key:${multibaseKey}/objects/1`), + attribution: parseIri(`ap://did:key:${multibaseKey}/actor`), + content: "Portable hello", + }); + const proof = await createProof( + note, + ed25519PrivateKey, + keyId, + { + created, + contextLoader: mockDocumentLoader, + context: [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/data-integrity/v1", + ], + }, + ); + const jsonLd = await note.toJsonLd({ + format: "compact", + contextLoader: mockDocumentLoader, + context: [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/data-integrity/v1", + ], + }); + let documentLoaderCalls = 0; + const verifiedKey = await verifyProof(jsonLd, proof, { + contextLoader: mockDocumentLoader, + documentLoader() { + documentLoaderCalls++; + throw new TypeError("did:key must not use the document loader"); + }, + }); + assertEquals( + verifiedKey, + new Multikey({ + id: keyId, + controller: new URL(did), + publicKey: ed25519PublicKey.publicKey, + }), + ); + assertEquals(documentLoaderCalls, 0); + + const tampered = { ...(jsonLd as Record) }; + tampered.content = "Portable goodbye"; + assertEquals( + await verifyProof(tampered, proof, { + contextLoader: mockDocumentLoader, + documentLoader() { + throw new TypeError("did:key must not use the document loader"); + }, + }), + null, + ); + + const badProof = proof.clone({ + verificationMethod: new URL( + `${did}#z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK`, + ), + }); + assertEquals( + await verifyProof(jsonLd, badProof, { + contextLoader: mockDocumentLoader, + documentLoader() { + throw new TypeError("did:key must not use the document loader"); + }, + }), + null, + ); + + const unsupportedMultibaseKey = await exportMultibaseKey( + rsaPublicKey2.publicKey, + ); + const unsupportedDid = `did:key:${unsupportedMultibaseKey}`; + const unsupportedProof = proof.clone({ + verificationMethod: new URL( + `${unsupportedDid}#${unsupportedMultibaseKey}`, + ), + }); + let unsupportedDocumentLoaderCalls = 0; + assertEquals( + await verifyProof(jsonLd, unsupportedProof, { + contextLoader: mockDocumentLoader, + documentLoader() { + unsupportedDocumentLoaderCalls++; + throw new TypeError("did:key must not use the document loader"); + }, + }), + null, + ); + assertEquals(unsupportedDocumentLoaderCalls, 0); +}); + test("verifyProof() records verification duration metric", async (t) => { const jsonLd = { "@context": [ diff --git a/packages/vocab-runtime/src/key.test.ts b/packages/vocab-runtime/src/key.test.ts index 7c423b2db..196335465 100644 --- a/packages/vocab-runtime/src/key.test.ts +++ b/packages/vocab-runtime/src/key.test.ts @@ -2,13 +2,17 @@ import { deepStrictEqual, rejects } from "node:assert"; import { test } from "node:test"; import { exportJwk, importJwk } from "./jwk.ts"; import { + exportDidKey, exportMultibaseKey, exportSpki, + importDidKey, importMultibaseKey, importPem, importPkcs1, importSpki, + parseDidKeyVerificationMethod, } from "./key.ts"; +import { addMulticodecPrefix } from "./internal/multicodec.ts"; import { encodeMultibase } from "./multibase/mod.ts"; // cSpell: disable @@ -198,3 +202,83 @@ test("exportMultibaseKey()", async () => { // cSpell: enable ); }); + +test("exportDidKey() and importDidKey()", async () => { + const ed25519Key = await importJwk(ed25519Jwk, "public"); + const did = await exportDidKey(ed25519Key); + deepStrictEqual(did, `did:key:${ed25519Multibase}`); + deepStrictEqual(await exportJwk(await importDidKey(did)), ed25519Jwk); + deepStrictEqual( + await exportJwk(await importDidKey(new URL(did))), + ed25519Jwk, + ); + + const rsaKey = await importJwk(rsaJwk, "public"); + await rejects( + () => exportDidKey(rsaKey), + TypeError, + ); + await rejects( + () => importDidKey(`did:key:${rsaMultibase}`), + new TypeError("Unsupported did:key type: 0x1205"), + ); +}); + +test("parseDidKeyVerificationMethod()", async () => { + const verificationMethod = `did:key:${ed25519Multibase}#${ed25519Multibase}`; + const parsed = await parseDidKeyVerificationMethod(verificationMethod); + deepStrictEqual(parsed.id, new URL(verificationMethod)); + deepStrictEqual(parsed.controller, new URL(`did:key:${ed25519Multibase}`)); + deepStrictEqual(parsed.publicKeyMultibase, ed25519Multibase); + deepStrictEqual(await exportJwk(parsed.publicKey), ed25519Jwk); +}); + +test("did:key helpers reject malformed values", async () => { + const ed25519Key = await importJwk(ed25519Jwk, "public"); + const raw = new Uint8Array(await crypto.subtle.exportKey("raw", ed25519Key)); + const shortEd25519 = new TextDecoder().decode( + encodeMultibase( + "base58btc", + addMulticodecPrefix(0xed, raw.slice(0, 31)), + ), + ); + const base64UrlEd25519 = new TextDecoder().decode( + encodeMultibase( + "base64url", + addMulticodecPrefix(0xed, raw), + ), + ); + const malformedMulticodec = new TextDecoder().decode( + encodeMultibase("base58btc", Uint8Array.from([0x80])), + ); + + for ( + const value of [ + "did:web:example.com", + "did:key:", + `did:key:${ed25519Multibase}/path`, + `did:key:${ed25519Multibase}?query`, + `did:key:${ed25519Multibase}#${ed25519Multibase}`, + `did:key:${base64UrlEd25519}`, + `did:key:${shortEd25519}`, + `did:key:${malformedMulticodec}`, + ] + ) { + await rejects(() => importDidKey(value), TypeError); + } + + for ( + const value of [ + `did:key:${ed25519Multibase}`, + `did:key:${ed25519Multibase}#`, + `did:key:${ed25519Multibase}#z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK`, + `did:key:${ed25519Multibase}#${base64UrlEd25519}`, + `did:key:${rsaMultibase}#${rsaMultibase}`, + `did:key:${shortEd25519}#${shortEd25519}`, + `did:key:${malformedMulticodec}#${malformedMulticodec}`, + "https://example.com/key", + ] + ) { + await rejects(() => parseDidKeyVerificationMethod(value), TypeError); + } +}); diff --git a/packages/vocab-runtime/src/key.ts b/packages/vocab-runtime/src/key.ts index 3e7f4e749..6e3cf5265 100644 --- a/packages/vocab-runtime/src/key.ts +++ b/packages/vocab-runtime/src/key.ts @@ -22,6 +22,39 @@ const algorithms: Record< "1.3.101.112": "Ed25519", }; +const DID_KEY_PREFIX = "did:key:"; +const ED25519_PUBLIC_KEY_MULTICODEC = 0xed; +const ED25519_PUBLIC_KEY_LENGTH = 32; +const DID_KEY_PATTERN = /^did:key:([^/?#]+)$/; +const DID_KEY_VERIFICATION_METHOD_PATTERN = /^did:key:([^/?#]+)#([^/?#]+)$/; + +/** + * Parsed `did:key` verification method. + * + * @since 2.4.0 + */ +export interface DidKeyVerificationMethod { + /** + * The DID URL identifying the verification method. + */ + readonly id: URL; + + /** + * The controller DID. + */ + readonly controller: URL; + + /** + * The Ed25519 public key encoded as a Multibase Multikey value. + */ + readonly publicKeyMultibase: string; + + /** + * The Ed25519 public key. + */ + readonly publicKey: CryptoKey; +} + /** * Imports a PEM-SPKI formatted public key. * @param pem The PEM-SPKI formatted public key. @@ -93,6 +126,91 @@ export function importPem(pem: string): Promise { return PKCS1_HEADER.test(pem) ? importPkcs1(pem) : importSpki(pem); } +function decodeEd25519DidKeyMultibase(multibaseKey: string): Uint8Array { + if (!multibaseKey.startsWith("z")) { + throw new TypeError("did:key must use base58-btc Multibase encoding."); + } + const decoded = decodeMultibase(multibaseKey); + const { code } = getMulticodecPrefix(decoded); + if (code !== ED25519_PUBLIC_KEY_MULTICODEC) { + throw new TypeError("Unsupported did:key type: 0x" + code.toString(16)); + } + const content = removeMulticodecPrefix(decoded); + if (content.length !== ED25519_PUBLIC_KEY_LENGTH) { + throw new TypeError("Invalid Ed25519 did:key length."); + } + return content; +} + +/** + * Imports an Ed25519 `did:key` DID. + * + * @param did The `did:key` DID. + * @returns The imported Ed25519 public key. + * @throws {TypeError} If the DID is malformed or uses an unsupported key type. + * @since 2.4.0 + */ +export async function importDidKey(did: string | URL): Promise { + const didString = did instanceof URL ? did.href : did; + const match = didString.match(DID_KEY_PATTERN); + if (match == null) throw new TypeError("Invalid did:key DID."); + const content = decodeEd25519DidKeyMultibase(match[1]); + return await crypto.subtle.importKey( + "raw", + content.slice(), + "Ed25519", + true, + ["verify"], + ); +} + +/** + * Exports an Ed25519 public key as a `did:key` DID. + * + * @param key The Ed25519 public key. + * @returns The `did:key` DID. + * @throws {TypeError} If the key is invalid or unsupported. + * @since 2.4.0 + */ +export async function exportDidKey(key: CryptoKey): Promise { + if (key.algorithm.name !== "Ed25519") { + throw new TypeError( + "Unsupported key type: " + JSON.stringify(key.algorithm), + ); + } + return DID_KEY_PREFIX + await exportMultibaseKey(key); +} + +/** + * Parses an Ed25519 `did:key` verification method DID URL. + * + * @param didUrl The `did:key` DID URL. + * @returns The parsed verification method. + * @throws {TypeError} If the DID URL is malformed, unsupported, or its + * fragment does not identify the same key as the DID. + * @since 2.4.0 + */ +export async function parseDidKeyVerificationMethod( + didUrl: string | URL, +): Promise { + const didUrlString = didUrl instanceof URL ? didUrl.href : didUrl; + const match = didUrlString.match(DID_KEY_VERIFICATION_METHOD_PATTERN); + if (match == null) { + throw new TypeError("Invalid did:key verification method."); + } + const [, publicKeyMultibase, fragment] = match; + if (publicKeyMultibase !== fragment) { + throw new TypeError("Invalid did:key verification method fragment."); + } + const publicKey = await importDidKey(DID_KEY_PREFIX + publicKeyMultibase); + return { + id: new URL(didUrlString), + controller: new URL(DID_KEY_PREFIX + publicKeyMultibase), + publicKeyMultibase, + publicKey, + }; +} + /** * Imports a [Multibase]-encoded public key. * diff --git a/packages/vocab-runtime/src/mod.ts b/packages/vocab-runtime/src/mod.ts index 5208edfde..171855d2b 100644 --- a/packages/vocab-runtime/src/mod.ts +++ b/packages/vocab-runtime/src/mod.ts @@ -17,12 +17,16 @@ export { type RemoteDocument, } from "./docloader.ts"; export { + type DidKeyVerificationMethod, + exportDidKey, exportMultibaseKey, exportSpki, + importDidKey, importMultibaseKey, importPem, importPkcs1, importSpki, + parseDidKeyVerificationMethod, } from "./key.ts"; export { canParseDecimal, From fbe007a5486f1081ba842027bb43091dffe46aa6 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 4 Jul 2026 12:58:05 +0900 Subject: [PATCH 2/3] Share cached key hit handling Keep positive key cache hits in one helper so regular remote key lookups and local did:key resolution use the same class and public key checks. The did:key path still ignores stale negative cache entries because it can resolve supported keys locally and replace the old unavailable entry. https://github.com/fedify-dev/fedify/pull/915#pullrequestreview-4628819245 Assisted-by: Codex:gpt-5.5 --- packages/fedify/src/sig/key.ts | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/packages/fedify/src/sig/key.ts b/packages/fedify/src/sig/key.ts index a2b34ad89..99f92f855 100644 --- a/packages/fedify/src/sig/key.ts +++ b/packages/fedify/src/sig/key.ts @@ -402,18 +402,30 @@ async function getCachedFetchKey( ): Promise | null> { if (keyCache == null) return null; const cachedKey = await keyCache.get(cacheKey); + const hit = checkCachedKeyHit(cachedKey, keyId, cls, logger); + if (hit != null) return hit; + if (cachedKey === null) { + logger.debug( + "Entry {keyId} found in cache, but it is unavailable.", + { keyId }, + ); + return { key: null, cached: true }; + } + return null; +} + +function checkCachedKeyHit( + cachedKey: CryptographicKey | Multikey | null | undefined, + keyId: string, + cls: FetchableKeyClass, + logger: ReturnType, +): FetchKeyResult | null { if (cachedKey instanceof cls && cachedKey.publicKey != null) { logger.debug("Key {keyId} found in cache.", { keyId }); return { key: cachedKey as T & { publicKey: CryptoKey }, cached: true, }; - } else if (cachedKey === null) { - logger.debug( - "Entry {keyId} found in cache, but it is unavailable.", - { keyId }, - ); - return { key: null, cached: true }; } return null; } @@ -441,13 +453,8 @@ async function resolveDidKey( if (!isDidKeyUrl(cacheKey)) return null; const keyId = cacheKey.href; const cachedKey = await keyCache?.get(cacheKey); - if (cachedKey instanceof cls && cachedKey.publicKey != null) { - logger.debug("Key {keyId} found in cache.", { keyId }); - return { - key: cachedKey as T & { publicKey: CryptoKey }, - cached: true, - }; - } + const hit = checkCachedKeyHit(cachedKey, keyId, cls, logger); + if (hit != null) return hit; const clsIsMultikey = cls === (Multikey as unknown as FetchableKeyClass); if (!clsIsMultikey) { From fa0a1da0fd0b2b19f29c90cf4ee74fc50102c868 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sun, 5 Jul 2026 22:59:51 +0900 Subject: [PATCH 3/3] Clarify key lookup metric wording Update the key lookup metric descriptions so they no longer imply that a successful cold lookup always comes from a remote document fetch. Local resolution paths such as supported did:key verification methods are now covered explicitly in the API docs and manual. https://github.com/fedify-dev/fedify/pull/915#pullrequestreview-4631177460 Assisted-by: Codex:gpt-5.5 --- docs/manual/opentelemetry.md | 76 +++++++++++------------ packages/fedify/src/federation/metrics.ts | 14 ++--- 2 files changed, 45 insertions(+), 45 deletions(-) diff --git a/docs/manual/opentelemetry.md b/docs/manual/opentelemetry.md index 8d7d584ef..89beafe86 100644 --- a/docs/manual/opentelemetry.md +++ b/docs/manual/opentelemetry.md @@ -333,44 +333,44 @@ Instrumented metrics Fedify records the following OpenTelemetry metrics: -| Metric name | Instrument | Unit | Description | -| --------------------------------------------- | ------------- | ------------- | ----------------------------------------------------------------------------------------------- | -| `activitypub.delivery.sent` | Counter | `{attempt}` | Counts outgoing ActivityPub delivery attempts. | -| `activitypub.delivery.permanent_failure` | Counter | `{failure}` | Counts outgoing deliveries abandoned as permanent failures. | -| `activitypub.delivery.duration` | Histogram | `ms` | Measures outgoing ActivityPub delivery attempt duration. | -| `activitypub.circuit_breaker.state_change` | Counter | `{change}` | Counts queued outbox circuit breaker state changes per remote host. | -| `activitypub.inbox.activity` | Counter | `{activity}` | Classifies inbound activities by lifecycle outcome. | -| `activitypub.inbox.processing_duration` | Histogram | `ms` | Measures inbox listener processing duration. | -| `activitypub.outbox.activity` | Counter | `{activity}` | Classifies outbound activities by lifecycle outcome. | -| `activitypub.fanout.recipients` | Histogram | `{recipient}` | Records the recipient inbox count produced by a single fanout enqueue. | -| `activitypub.collection.request` | Counter | `{request}` | Counts ActivityPub collection and collection-page requests. | -| `activitypub.collection.dispatch.duration` | Histogram | `ms` | Measures collection dispatcher callback duration. | -| `activitypub.collection.page.items` | Histogram | `{item}` | Records item counts materialized for collection and collection-page responses. | -| `activitypub.collection.total_items` | Histogram | `{item}` | Records total item counts reported by collection counters. | -| `activitypub.signature.verification_failure` | Counter | `{failure}` | Counts failed signature verification for inbox requests. | -| `activitypub.signature.verification.duration` | Histogram | `ms` | Measures signature verification duration across HTTP, Linked Data, and Object Integrity Proofs. | -| `activitypub.signature.key_fetch.duration` | Histogram | `ms` | Measures public key lookup duration during signature verification. | -| `activitypub.key.lookup` | Counter | `{lookup}` | Counts public key lookups performed by `fetchKey()` / `fetchKeyDetailed()`. | -| `activitypub.key.lookup.duration` | Histogram | `ms` | Measures public key lookup duration, including cache hits and remote fetches. | -| `activitypub.document.fetch` | Counter | `{fetch}` | Counts remote JSON-LD document loader invocations made by Fedify-wrapped loaders. | -| `activitypub.document.fetch.duration` | Histogram | `ms` | Measures remote JSON-LD document loader invocation duration. | -| `activitypub.document.cache` | Counter | `{lookup}` | Counts KV-backed document loader cache lookups, classified as `hit` or `miss`. | -| `activitypub.object.lookup` | Counter | `{lookup}` | Counts `lookupObject()` calls, classified by whether the resolved value is an Actor. | -| `activitypub.actor.discovery` | Counter | `{discovery}` | Counts `getActorHandle()` actor handle discovery attempts. | -| `activitypub.actor.discovery.duration` | Histogram | `ms` | Measures `getActorHandle()` discovery duration. | -| `webfinger.lookup` | Counter | `{lookup}` | Counts outgoing WebFinger lookups performed by `lookupWebFinger()`. | -| `webfinger.lookup.duration` | Histogram | `ms` | Measures outgoing WebFinger lookup duration. | -| `webfinger.handle` | Counter | `{request}` | Counts inbound WebFinger requests handled by `Federation.fetch()`. | -| `webfinger.handle.duration` | Histogram | `ms` | Measures inbound WebFinger request handling duration. | -| `fedify.http.server.request.count` | Counter | `{request}` | Counts inbound HTTP requests handled by `Federation.fetch()`. | -| `fedify.http.server.request.duration` | Histogram | `ms` | Measures inbound HTTP request duration in `Federation.fetch()`. | -| `fedify.queue.task.enqueued` | Counter | `{task}` | Counts inbox, outbox, and fanout tasks Fedify enqueued. | -| `fedify.queue.task.started` | Counter | `{task}` | Counts queue tasks Fedify began processing as a worker. | -| `fedify.queue.task.completed` | Counter | `{task}` | Counts queue tasks Fedify finished processing without throwing. | -| `fedify.queue.task.failed` | Counter | `{task}` | Counts queue tasks Fedify abandoned because processing threw. | -| `fedify.queue.task.duration` | Histogram | `ms` | Measures queue task processing duration in Fedify workers. | -| `fedify.queue.task.in_flight` | UpDownCounter | `{task}` | Tracks queue tasks currently in flight in this Fedify process. | -| `fedify.queue.depth` | Gauge | `{message}` | Reports queued, ready, and delayed queue depth when the queue backend supports it. | +| Metric name | Instrument | Unit | Description | +| --------------------------------------------- | ------------- | ------------- | ------------------------------------------------------------------------------------------------- | +| `activitypub.delivery.sent` | Counter | `{attempt}` | Counts outgoing ActivityPub delivery attempts. | +| `activitypub.delivery.permanent_failure` | Counter | `{failure}` | Counts outgoing deliveries abandoned as permanent failures. | +| `activitypub.delivery.duration` | Histogram | `ms` | Measures outgoing ActivityPub delivery attempt duration. | +| `activitypub.circuit_breaker.state_change` | Counter | `{change}` | Counts queued outbox circuit breaker state changes per remote host. | +| `activitypub.inbox.activity` | Counter | `{activity}` | Classifies inbound activities by lifecycle outcome. | +| `activitypub.inbox.processing_duration` | Histogram | `ms` | Measures inbox listener processing duration. | +| `activitypub.outbox.activity` | Counter | `{activity}` | Classifies outbound activities by lifecycle outcome. | +| `activitypub.fanout.recipients` | Histogram | `{recipient}` | Records the recipient inbox count produced by a single fanout enqueue. | +| `activitypub.collection.request` | Counter | `{request}` | Counts ActivityPub collection and collection-page requests. | +| `activitypub.collection.dispatch.duration` | Histogram | `ms` | Measures collection dispatcher callback duration. | +| `activitypub.collection.page.items` | Histogram | `{item}` | Records item counts materialized for collection and collection-page responses. | +| `activitypub.collection.total_items` | Histogram | `{item}` | Records total item counts reported by collection counters. | +| `activitypub.signature.verification_failure` | Counter | `{failure}` | Counts failed signature verification for inbox requests. | +| `activitypub.signature.verification.duration` | Histogram | `ms` | Measures signature verification duration across HTTP, Linked Data, and Object Integrity Proofs. | +| `activitypub.signature.key_fetch.duration` | Histogram | `ms` | Measures public key lookup duration during signature verification. | +| `activitypub.key.lookup` | Counter | `{lookup}` | Counts public key lookups performed by `fetchKey()` / `fetchKeyDetailed()`. | +| `activitypub.key.lookup.duration` | Histogram | `ms` | Measures public key lookup duration, including cache hits, local resolutions, and remote fetches. | +| `activitypub.document.fetch` | Counter | `{fetch}` | Counts remote JSON-LD document loader invocations made by Fedify-wrapped loaders. | +| `activitypub.document.fetch.duration` | Histogram | `ms` | Measures remote JSON-LD document loader invocation duration. | +| `activitypub.document.cache` | Counter | `{lookup}` | Counts KV-backed document loader cache lookups, classified as `hit` or `miss`. | +| `activitypub.object.lookup` | Counter | `{lookup}` | Counts `lookupObject()` calls, classified by whether the resolved value is an Actor. | +| `activitypub.actor.discovery` | Counter | `{discovery}` | Counts `getActorHandle()` actor handle discovery attempts. | +| `activitypub.actor.discovery.duration` | Histogram | `ms` | Measures `getActorHandle()` discovery duration. | +| `webfinger.lookup` | Counter | `{lookup}` | Counts outgoing WebFinger lookups performed by `lookupWebFinger()`. | +| `webfinger.lookup.duration` | Histogram | `ms` | Measures outgoing WebFinger lookup duration. | +| `webfinger.handle` | Counter | `{request}` | Counts inbound WebFinger requests handled by `Federation.fetch()`. | +| `webfinger.handle.duration` | Histogram | `ms` | Measures inbound WebFinger request handling duration. | +| `fedify.http.server.request.count` | Counter | `{request}` | Counts inbound HTTP requests handled by `Federation.fetch()`. | +| `fedify.http.server.request.duration` | Histogram | `ms` | Measures inbound HTTP request duration in `Federation.fetch()`. | +| `fedify.queue.task.enqueued` | Counter | `{task}` | Counts inbox, outbox, and fanout tasks Fedify enqueued. | +| `fedify.queue.task.started` | Counter | `{task}` | Counts queue tasks Fedify began processing as a worker. | +| `fedify.queue.task.completed` | Counter | `{task}` | Counts queue tasks Fedify finished processing without throwing. | +| `fedify.queue.task.failed` | Counter | `{task}` | Counts queue tasks Fedify abandoned because processing threw. | +| `fedify.queue.task.duration` | Histogram | `ms` | Measures queue task processing duration in Fedify workers. | +| `fedify.queue.task.in_flight` | UpDownCounter | `{task}` | Tracks queue tasks currently in flight in this Fedify process. | +| `fedify.queue.depth` | Gauge | `{message}` | Reports queued, ready, and delayed queue depth when the queue backend supports it. | ### Metric attributes diff --git a/packages/fedify/src/federation/metrics.ts b/packages/fedify/src/federation/metrics.ts index 2c53ca86d..70de51948 100644 --- a/packages/fedify/src/federation/metrics.ts +++ b/packages/fedify/src/federation/metrics.ts @@ -163,10 +163,10 @@ export type SignatureVerificationResult = * `KeyCache` itself may be backed by a remote store such as Redis or a * database, in which case the measurement reflects whatever round trip * that backend incurs. - * - `fetched`: the public key was not in the cache and was loaded - * through the document loader, returning a usable key. This typically - * corresponds to a network fetch, but a custom document loader that - * serves from a local store will also fall in this bucket. + * - `fetched`: the public key was not in the cache and returned a usable + * key. This typically corresponds to a document loader lookup, but + * local key resolution paths such as supported `did:key` verification + * methods also fall in this bucket. * - `error`: the fetch attempt returned no usable key (HTTP failure, * invalid response body, cached negative entry, thrown exception, * etc.). @@ -722,8 +722,8 @@ class FederationMetrics { ); this.keyLookup = meter.createCounter("activitypub.key.lookup", { description: - "Public-key lookup attempts performed by Fedify, including both " + - "cache hits and remote fetches.", + "Public-key lookup attempts performed by Fedify, including cache " + + "hits, local resolutions, and remote fetches.", unit: "{lookup}", }); this.keyLookupDuration = meter.createHistogram( @@ -731,7 +731,7 @@ class FederationMetrics { { description: "Duration of public-key lookups performed by Fedify, including " + - "any remote fetch.", + "cache hits, local resolutions, and any remote fetch.", unit: "ms", advice: { // Reuse the OpenTelemetry HTTP server semantic-conventions buckets