Skip to content

Commit 8bb6ed6

Browse files
committed
Merge tag '1.10.8' into 2.0-maintenance
Fedify 1.10.8
2 parents c9fbb44 + 172af18 commit 8bb6ed6

4 files changed

Lines changed: 164 additions & 42 deletions

File tree

CHANGES.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,25 @@ Version 2.0.12
88

99
To be released.
1010

11+
### @fedify/fedify
12+
13+
- Fixed `Context.getActorKeyPairs()` assigning the same key ID to both
14+
the `CryptographicKey` (used for HTTP Signatures and Linked Data
15+
Signatures) and the `Multikey` (used for Object Integrity Proofs) within
16+
an `ActorKeyPair`. The `Multikey` now receives a distinct ID
17+
(`#multikey-1`, `#multikey-2`, …) so that the actor document no longer
18+
contains two objects sharing the same `id`, which was invalid JSON-LD.
19+
Object Integrity Proof signatures now reference the correct `Multikey` ID
20+
instead of the `CryptographicKey` ID. [[#663]]
21+
22+
- Object Integrity Proofs signing now takes place before activity fanout,
23+
so all recipients receive the same pre-signed activity. Previously, OIP
24+
signing was deferred until after fanout, meaning each fanout worker would
25+
re-sign independently with potentially different timestamps and the fanout
26+
message itself contained an unsigned activity.
27+
28+
[#663]: https://github.com/fedify-dev/fedify/issues/663
29+
1130
### @fedify/cfworkers
1231

1332
- Fixed a remaining TypeScript type mismatch for Cloudflare Workers users who
@@ -834,6 +853,29 @@ Released on February 22, 2026.
834853
[#351]: https://github.com/fedify-dev/fedify/issues/351
835854

836855

856+
Version 1.10.8
857+
--------------
858+
859+
Released on April 8, 2026.
860+
861+
### @fedify/fedify
862+
863+
- Fixed `Context.getActorKeyPairs()` assigning the same key ID to both
864+
the `CryptographicKey` (used for HTTP Signatures and Linked Data
865+
Signatures) and the `Multikey` (used for Object Integrity Proofs) within
866+
an `ActorKeyPair`. The `Multikey` now receives a distinct ID
867+
(`#multikey-1`, `#multikey-2`, …) so that the actor document no longer
868+
contains two objects sharing the same `id`, which was invalid JSON-LD.
869+
Object Integrity Proof signatures now reference the correct `Multikey` ID
870+
instead of the `CryptographicKey` ID. [[#663]]
871+
872+
- Object Integrity Proofs signing now takes place before activity fanout,
873+
so all recipients receive the same pre-signed activity. Previously, OIP
874+
signing was deferred until after fanout, meaning each fanout worker would
875+
re-sign independently with potentially different timestamps and the fanout
876+
message itself contained an unsigned activity.
877+
878+
837879
Version 1.10.7
838880
--------------
839881

@@ -1034,6 +1076,29 @@ Released on December 24, 2025.
10341076
- Implemented `list()` method in `WorkersKvStore`. [[#498], [#500]]
10351077

10361078

1079+
Version 1.9.9
1080+
-------------
1081+
1082+
Released on April 8, 2026.
1083+
1084+
### @fedify/fedify
1085+
1086+
- Fixed `Context.getActorKeyPairs()` assigning the same key ID to both
1087+
the `CryptographicKey` (used for HTTP Signatures and Linked Data
1088+
Signatures) and the `Multikey` (used for Object Integrity Proofs) within
1089+
an `ActorKeyPair`. The `Multikey` now receives a distinct ID
1090+
(`#multikey-1`, `#multikey-2`, …) so that the actor document no longer
1091+
contains two objects sharing the same `id`, which was invalid JSON-LD.
1092+
Object Integrity Proof signatures now reference the correct `Multikey` ID
1093+
instead of the `CryptographicKey` ID. [[#663]]
1094+
1095+
- Object Integrity Proofs signing now takes place before activity fanout,
1096+
so all recipients receive the same pre-signed activity. Previously, OIP
1097+
signing was deferred until after fanout, meaning each fanout worker would
1098+
re-sign independently with potentially different timestamps and the fanout
1099+
message itself contained an unsigned activity.
1100+
1101+
10371102
Version 1.9.8
10381103
-------------
10391104

packages/fedify/src/federation/context.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -876,7 +876,10 @@ export interface GetSignedKeyOptions {
876876
*/
877877
export interface ActorKeyPair extends CryptoKeyPair {
878878
/**
879-
* The URI of the public key, which is used for verifying HTTP Signatures.
879+
* The URI of the public key for {@link CryptographicKey}, which is used for
880+
* verifying HTTP Signatures and Linked Data Signatures. Note that this is
881+
* the ID of the {@link cryptographicKey}, not of the {@link multikey};
882+
* the {@link Multikey} instance has a distinct ID of its own.
880883
*/
881884
readonly keyId: URL;
882885

packages/fedify/src/federation/middleware.test.ts

Lines changed: 44 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@ test({
317317
owner: new URL("https://example.com/users/handle"),
318318
}),
319319
multikey: new vocab.Multikey({
320-
id: new URL("https://example.com/users/handle#main-key"),
320+
id: new URL("https://example.com/users/handle#multikey-1"),
321321
controller: new URL("https://example.com/users/handle"),
322322
publicKey: rsaPublicKey2.publicKey!,
323323
}),
@@ -331,7 +331,7 @@ test({
331331
owner: new URL("https://example.com/users/handle"),
332332
}),
333333
multikey: new vocab.Multikey({
334-
id: new URL("https://example.com/users/handle#key-2"),
334+
id: new URL("https://example.com/users/handle#multikey-2"),
335335
controller: new URL("https://example.com/users/handle"),
336336
publicKey: ed25519PublicKey.publicKey!,
337337
}),
@@ -2253,9 +2253,10 @@ test("ContextImpl.sendActivity()", async (t) => {
22532253
const keys = await ctx.getActorKeyPairs("1");
22542254
for (const key of keys) {
22552255
if (key.keyId.href === keyId.href) {
2256-
if (key.publicKey.algorithm.name === "Ed25519") {
2257-
return key.multikey;
2258-
} else return key.cryptographicKey;
2256+
return key.cryptographicKey;
2257+
}
2258+
if (key.multikey.id?.href === keyId.href) {
2259+
return key.multikey;
22592260
}
22602261
}
22612262
return undefined;
@@ -2492,31 +2493,45 @@ test("ContextImpl.sendActivity()", async (t) => {
24922493
activity,
24932494
{ fanout: "force" },
24942495
);
2495-
assertEquals(queue.messages, [
2496-
{
2497-
id: queue.messages[0].id,
2498-
type: "fanout",
2499-
activity: await activity.toJsonLd({
2500-
format: "compact",
2501-
contextLoader: documentLoader,
2502-
}),
2503-
activityId: "https://example.com/activity/1",
2504-
activityType: "https://www.w3.org/ns/activitystreams#Create",
2505-
baseUrl: "https://example.com",
2506-
collectionSync: undefined,
2507-
inboxes: {
2508-
"https://example.com/inbox": {
2509-
actorIds: [
2510-
"https://example.com/recipient",
2511-
],
2512-
sharedInbox: false,
2513-
},
2514-
},
2515-
keys: queue.messages[0].type === "fanout" ? queue.messages[0].keys : [],
2516-
orderingKey: undefined,
2517-
traceContext: {},
2496+
assertEquals(queue.messages.length, 1);
2497+
assert(queue.messages[0].type === "fanout");
2498+
const fanoutMsg = queue.messages[0];
2499+
assertEquals(fanoutMsg.activityId, "https://example.com/activity/1");
2500+
assertEquals(
2501+
fanoutMsg.activityType,
2502+
"https://www.w3.org/ns/activitystreams#Create",
2503+
);
2504+
assertEquals(fanoutMsg.baseUrl, "https://example.com");
2505+
assertEquals(fanoutMsg.collectionSync, undefined);
2506+
assertEquals(fanoutMsg.orderingKey, undefined);
2507+
assertEquals(fanoutMsg.inboxes, {
2508+
"https://example.com/inbox": {
2509+
actorIds: ["https://example.com/recipient"],
2510+
sharedInbox: false,
25182511
},
2519-
]);
2512+
});
2513+
// Regression test for <https://github.com/fedify-dev/fedify/issues/663>:
2514+
// The activity in the fanout message should be pre-signed with OIP before
2515+
// fanout, and the proof must reference the Multikey ID (#multikey-N),
2516+
// not the CryptographicKey ID (#main-key or #key-N):
2517+
const signedActivity = await vocab.Create.fromJsonLd(fanoutMsg.activity, {
2518+
contextLoader: documentLoader,
2519+
documentLoader: documentLoader,
2520+
});
2521+
assertEquals(signedActivity.id?.href, "https://example.com/activity/1");
2522+
let proofCount = 0;
2523+
for await (
2524+
const proof of signedActivity.getProofs({
2525+
contextLoader: documentLoader,
2526+
})
2527+
) {
2528+
assertEquals(
2529+
proof.verificationMethodId?.href,
2530+
"https://example.com/john#multikey-2",
2531+
);
2532+
proofCount++;
2533+
}
2534+
assertEquals(proofCount, 1);
25202535
});
25212536

25222537
queue.clear();

packages/fedify/src/federation/middleware.ts

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1073,14 +1073,23 @@ export class FederationImpl<TContextData>
10731073
validateCryptoKey(privateKey, "private");
10741074
if (rsaKey == null && privateKey.algorithm.name === "RSASSA-PKCS1-v1_5") {
10751075
rsaKey = { keyId, privateKey };
1076-
continue;
10771076
}
1078-
if (privateKey.algorithm.name === "Ed25519") {
1079-
activity = await signObject(activity, privateKey, keyId, {
1080-
contextLoader,
1081-
tracerProvider: this.tracerProvider,
1082-
});
1083-
proofCreated = true;
1077+
}
1078+
// If Object Integrity Proofs were already created before fanout (e.g., in
1079+
// sendActivityInternal()), skip signing to avoid duplicates.
1080+
for await (const _ of activity.getProofs({ contextLoader })) {
1081+
proofCreated = true;
1082+
break;
1083+
}
1084+
if (!proofCreated) {
1085+
for (const { keyId, privateKey } of keys) {
1086+
if (privateKey.algorithm.name === "Ed25519") {
1087+
activity = await signObject(activity, privateKey, keyId, {
1088+
contextLoader,
1089+
tracerProvider: this.tracerProvider,
1090+
});
1091+
proofCreated = true;
1092+
}
10841093
}
10851094
}
10861095
let jsonLd = await activity.toJsonLd({
@@ -1950,6 +1959,7 @@ export class ContextImpl<TContextData> implements Context<TContextData> {
19501959
}
19511960
const owner = this.getActorUri(identifier);
19521961
const result = [];
1962+
let i = 1;
19531963
for (const keyPair of keyPairs) {
19541964
const newPair: ActorKeyPair = {
19551965
...keyPair,
@@ -1959,12 +1969,13 @@ export class ContextImpl<TContextData> implements Context<TContextData> {
19591969
publicKey: keyPair.publicKey,
19601970
}),
19611971
multikey: new Multikey({
1962-
id: keyPair.keyId,
1972+
id: new URL(`#multikey-${i}`, owner),
19631973
controller: owner,
19641974
publicKey: keyPair.publicKey,
19651975
}),
19661976
};
19671977
result.push(newPair);
1978+
i++;
19681979
}
19691980
return result;
19701981
}
@@ -2208,6 +2219,7 @@ export class ContextImpl<TContextData> implements Context<TContextData> {
22082219
const logger = getLogger(["fedify", "federation", "outbox"]);
22092220
let keys: SenderKeyPair[];
22102221
let identifier: string | null = null;
2222+
let actorKeyPairs: ActorKeyPair[] | null = null;
22112223
if ("identifier" in sender || "username" in sender) {
22122224
if ("identifier" in sender) {
22132225
identifier = sender.identifier;
@@ -2231,12 +2243,19 @@ export class ContextImpl<TContextData> implements Context<TContextData> {
22312243
}
22322244
}
22332245
span.setAttribute("fedify.actor.identifier", identifier);
2234-
keys = await this.getKeyPairsFromIdentifier(identifier);
2235-
if (keys.length < 1) {
2246+
if (this.federation.actorCallbacks?.keyPairsDispatcher == null) {
2247+
throw new Error("No actor key pairs dispatcher registered.");
2248+
}
2249+
actorKeyPairs = await this.getActorKeyPairs(identifier);
2250+
if (actorKeyPairs.length < 1) {
22362251
throw new Error(
22372252
`No key pair found for actor ${JSON.stringify(identifier)}.`,
22382253
);
22392254
}
2255+
keys = actorKeyPairs.map((kp) => ({
2256+
keyId: kp.keyId,
2257+
privateKey: kp.privateKey,
2258+
}));
22402259
} else if (Array.isArray(sender)) {
22412260
if (sender.length < 1) {
22422261
throw new Error("The sender's key pairs are empty.");
@@ -2300,6 +2319,22 @@ export class ContextImpl<TContextData> implements Context<TContextData> {
23002319
"The activity to send must have at least one actor property.",
23012320
);
23022321
}
2322+
// Pre-sign with Object Integrity Proofs before fanout so that all
2323+
// recipients receive the same signed activity. Uses Multikey IDs so that
2324+
// verifiers can look up the correct key type in the actor document.
2325+
if (actorKeyPairs != null) {
2326+
const contextLoader = this.contextLoader;
2327+
for (const kp of actorKeyPairs) {
2328+
if (
2329+
kp.privateKey.algorithm.name !== "Ed25519" ||
2330+
kp.multikey.id == null
2331+
) continue;
2332+
activity = await signObject(activity, kp.privateKey, kp.multikey.id, {
2333+
contextLoader,
2334+
tracerProvider: this.tracerProvider,
2335+
});
2336+
}
2337+
}
23032338
const inboxes = extractInboxes({
23042339
recipients: expandedRecipients,
23052340
preferSharedInbox: options.preferSharedInbox,
@@ -2841,12 +2876,16 @@ export class InboxContextImpl<TContextData> extends ContextImpl<TContextData>
28412876
identifier = mapped;
28422877
}
28432878
}
2844-
keys = await this.getKeyPairsFromIdentifier(identifier);
2845-
if (keys.length < 1) {
2879+
const actorKeyPairs = await this.getActorKeyPairs(identifier);
2880+
if (actorKeyPairs.length < 1) {
28462881
throw new Error(
28472882
`No key pair found for actor ${JSON.stringify(identifier)}.`,
28482883
);
28492884
}
2885+
keys = actorKeyPairs.map((kp) => ({
2886+
keyId: kp.keyId,
2887+
privateKey: kp.privateKey,
2888+
}));
28502889
} else if (Array.isArray(forwarder)) {
28512890
if (forwarder.length < 1) {
28522891
throw new Error("The forwarder's key pairs are empty.");

0 commit comments

Comments
 (0)