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
18 changes: 11 additions & 7 deletions docs/manual/opentelemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.).

Expand All @@ -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
Expand All @@ -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;
Expand Down
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
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