Skip to content
Open
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
22 changes: 20 additions & 2 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
94 changes: 49 additions & 45 deletions docs/manual/opentelemetry.md

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions docs/manual/send.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
14 changes: 7 additions & 7 deletions packages/fedify/src/federation/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.).
Expand Down Expand Up @@ -722,16 +722,16 @@ 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(
"activitypub.key.lookup.duration",
{
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
Expand Down
159 changes: 158 additions & 1 deletion packages/fedify/src/sig/key.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, CryptographicKey | Multikey | null> = {
[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<string, CryptographicKey | Multikey | null> = {};
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<string, CryptographicKey | Multikey | null> = {};
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<string, CryptographicKey | Multikey | null> = {};
Expand Down
Loading