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..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 @@ -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/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 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..99f92f855 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 { @@ -406,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; } @@ -432,6 +440,54 @@ 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); + const hit = checkCachedKeyHit(cachedKey, keyId, cls, logger); + if (hit != null) return hit; + 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 +633,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,