Skip to content

Commit baf8fb4

Browse files
committed
Merge tag '1.9.9' into 1.10-maintenance
Fedify 1.9.9
2 parents 3b21c7d + 8eba101 commit baf8fb4

4 files changed

Lines changed: 140 additions & 41 deletions

File tree

CHANGES.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,23 @@ Version 1.10.8
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+
1128

1229
Version 1.10.7
1330
--------------
@@ -209,6 +226,31 @@ Released on December 24, 2025.
209226
- Implemented `list()` method in `WorkersKvStore`. [[#498], [#500]]
210227

211228

229+
Version 1.9.9
230+
-------------
231+
232+
Released on April 8, 2026.
233+
234+
### @fedify/fedify
235+
236+
- Fixed `Context.getActorKeyPairs()` assigning the same key ID to both
237+
the `CryptographicKey` (used for HTTP Signatures and Linked Data
238+
Signatures) and the `Multikey` (used for Object Integrity Proofs) within
239+
an `ActorKeyPair`. The `Multikey` now receives a distinct ID
240+
(`#multikey-1`, `#multikey-2`, …) so that the actor document no longer
241+
contains two objects sharing the same `id`, which was invalid JSON-LD.
242+
Object Integrity Proof signatures now reference the correct `Multikey` ID
243+
instead of the `CryptographicKey` ID. [[#663]]
244+
245+
- Object Integrity Proofs signing now takes place before activity fanout,
246+
so all recipients receive the same pre-signed activity. Previously, OIP
247+
signing was deferred until after fanout, meaning each fanout worker would
248+
re-sign independently with potentially different timestamps and the fanout
249+
message itself contained an unsigned activity.
250+
251+
[#663]: https://github.com/fedify-dev/fedify/issues/663
252+
253+
212254
Version 1.9.8
213255
-------------
214256

packages/fedify/src/federation/context.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -873,7 +873,10 @@ export interface GetSignedKeyOptions {
873873
*/
874874
export interface ActorKeyPair extends CryptoKeyPair {
875875
/**
876-
* The URI of the public key, which is used for verifying HTTP Signatures.
876+
* The URI of the public key for {@link CryptographicKey}, which is used for
877+
* verifying HTTP Signatures and Linked Data Signatures. Note that this is
878+
* the ID of the {@link cryptographicKey}, not of the {@link multikey};
879+
* the {@link Multikey} instance has a distinct ID of its own.
877880
*/
878881
keyId: URL;
879882

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

Lines changed: 43 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,7 @@ test({
330330
owner: new URL("https://example.com/users/handle"),
331331
}),
332332
multikey: new Multikey({
333-
id: new URL("https://example.com/users/handle#main-key"),
333+
id: new URL("https://example.com/users/handle#multikey-1"),
334334
controller: new URL("https://example.com/users/handle"),
335335
publicKey: rsaPublicKey2.publicKey!,
336336
}),
@@ -344,7 +344,7 @@ test({
344344
owner: new URL("https://example.com/users/handle"),
345345
}),
346346
multikey: new Multikey({
347-
id: new URL("https://example.com/users/handle#key-2"),
347+
id: new URL("https://example.com/users/handle#multikey-2"),
348348
controller: new URL("https://example.com/users/handle"),
349349
publicKey: ed25519PublicKey.publicKey!,
350350
}),
@@ -1691,9 +1691,10 @@ test("ContextImpl.sendActivity()", async (t) => {
16911691
const keys = await ctx.getActorKeyPairs("1");
16921692
for (const key of keys) {
16931693
if (key.keyId.href === keyId.href) {
1694-
if (key.publicKey.algorithm.name === "Ed25519") {
1695-
return key.multikey;
1696-
} else return key.cryptographicKey;
1694+
return key.cryptographicKey;
1695+
}
1696+
if (key.multikey.id?.href === keyId.href) {
1697+
return key.multikey;
16971698
}
16981699
}
16991700
return undefined;
@@ -1927,30 +1928,44 @@ test("ContextImpl.sendActivity()", async (t) => {
19271928
activity,
19281929
{ fanout: "force" },
19291930
);
1930-
assertEquals(queue.messages, [
1931-
{
1932-
id: queue.messages[0].id,
1933-
type: "fanout",
1934-
activity: await activity.toJsonLd({
1935-
format: "compact",
1936-
contextLoader: fetchDocumentLoader,
1937-
}),
1938-
activityId: "https://example.com/activity/1",
1939-
activityType: "https://www.w3.org/ns/activitystreams#Create",
1940-
baseUrl: "https://example.com",
1941-
collectionSync: undefined,
1942-
inboxes: {
1943-
"https://example.com/inbox": {
1944-
actorIds: [
1945-
"https://example.com/recipient",
1946-
],
1947-
sharedInbox: false,
1948-
},
1949-
},
1950-
keys: queue.messages[0].type === "fanout" ? queue.messages[0].keys : [],
1951-
traceContext: {},
1931+
assertEquals(queue.messages.length, 1);
1932+
assert(queue.messages[0].type === "fanout");
1933+
const fanoutMsg = queue.messages[0];
1934+
assertEquals(fanoutMsg.activityId, "https://example.com/activity/1");
1935+
assertEquals(
1936+
fanoutMsg.activityType,
1937+
"https://www.w3.org/ns/activitystreams#Create",
1938+
);
1939+
assertEquals(fanoutMsg.baseUrl, "https://example.com");
1940+
assertEquals(fanoutMsg.collectionSync, undefined);
1941+
assertEquals(fanoutMsg.inboxes, {
1942+
"https://example.com/inbox": {
1943+
actorIds: ["https://example.com/recipient"],
1944+
sharedInbox: false,
19521945
},
1953-
]);
1946+
});
1947+
// Regression test for <https://github.com/fedify-dev/fedify/issues/663>:
1948+
// The activity in the fanout message should be pre-signed with OIP before
1949+
// fanout, and the proof must reference the Multikey ID (#multikey-N),
1950+
// not the CryptographicKey ID (#main-key or #key-N):
1951+
const signedActivity = await Create.fromJsonLd(fanoutMsg.activity, {
1952+
contextLoader: fetchDocumentLoader,
1953+
documentLoader: fetchDocumentLoader,
1954+
});
1955+
assertEquals(signedActivity.id?.href, "https://example.com/activity/1");
1956+
let proofCount = 0;
1957+
for await (
1958+
const proof of signedActivity.getProofs({
1959+
contextLoader: fetchDocumentLoader,
1960+
})
1961+
) {
1962+
assertEquals(
1963+
proof.verificationMethodId?.href,
1964+
"https://example.com/john#multikey-2",
1965+
);
1966+
proofCount++;
1967+
}
1968+
assertEquals(proofCount, 1);
19541969
});
19551970

19561971
queue.clear();

packages/fedify/src/federation/middleware.ts

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1021,14 +1021,23 @@ export class FederationImpl<TContextData>
10211021
validateCryptoKey(privateKey, "private");
10221022
if (rsaKey == null && privateKey.algorithm.name === "RSASSA-PKCS1-v1_5") {
10231023
rsaKey = { keyId, privateKey };
1024-
continue;
10251024
}
1026-
if (privateKey.algorithm.name === "Ed25519") {
1027-
activity = await signObject(activity, privateKey, keyId, {
1028-
contextLoader,
1029-
tracerProvider: this.tracerProvider,
1030-
});
1031-
proofCreated = true;
1025+
}
1026+
// If Object Integrity Proofs were already created before fanout (e.g., in
1027+
// sendActivityInternal()), skip signing to avoid duplicates.
1028+
for await (const _ of activity.getProofs({ contextLoader })) {
1029+
proofCreated = true;
1030+
break;
1031+
}
1032+
if (!proofCreated) {
1033+
for (const { keyId, privateKey } of keys) {
1034+
if (privateKey.algorithm.name === "Ed25519") {
1035+
activity = await signObject(activity, privateKey, keyId, {
1036+
contextLoader,
1037+
tracerProvider: this.tracerProvider,
1038+
});
1039+
proofCreated = true;
1040+
}
10321041
}
10331042
}
10341043
let jsonLd = await activity.toJsonLd({
@@ -1920,6 +1929,7 @@ export class ContextImpl<TContextData> implements Context<TContextData> {
19201929
}
19211930
const owner = this.getActorUri(identifier);
19221931
const result = [];
1932+
let i = 1;
19231933
for (const keyPair of keyPairs) {
19241934
const newPair: ActorKeyPair = {
19251935
...keyPair,
@@ -1929,12 +1939,13 @@ export class ContextImpl<TContextData> implements Context<TContextData> {
19291939
publicKey: keyPair.publicKey,
19301940
}),
19311941
multikey: new Multikey({
1932-
id: keyPair.keyId,
1942+
id: new URL(`#multikey-${i}`, owner),
19331943
controller: owner,
19341944
publicKey: keyPair.publicKey,
19351945
}),
19361946
};
19371947
result.push(newPair);
1948+
i++;
19381949
}
19391950
return result;
19401951
}
@@ -2192,6 +2203,7 @@ export class ContextImpl<TContextData> implements Context<TContextData> {
21922203
const logger = getLogger(["fedify", "federation", "outbox"]);
21932204
let keys: SenderKeyPair[];
21942205
let identifier: string | null = null;
2206+
let actorKeyPairs: ActorKeyPair[] | null = null;
21952207
if ("identifier" in sender || "username" in sender || "handle" in sender) {
21962208
if ("identifier" in sender) {
21972209
identifier = sender.identifier;
@@ -2225,12 +2237,19 @@ export class ContextImpl<TContextData> implements Context<TContextData> {
22252237
}
22262238
}
22272239
span.setAttribute("fedify.actor.identifier", identifier);
2228-
keys = await this.getKeyPairsFromIdentifier(identifier);
2229-
if (keys.length < 1) {
2240+
if (this.federation.actorCallbacks?.keyPairsDispatcher == null) {
2241+
throw new Error("No actor key pairs dispatcher registered.");
2242+
}
2243+
actorKeyPairs = await this.getActorKeyPairs(identifier);
2244+
if (actorKeyPairs.length < 1) {
22302245
throw new Error(
22312246
`No key pair found for actor ${JSON.stringify(identifier)}.`,
22322247
);
22332248
}
2249+
keys = actorKeyPairs.map((kp) => ({
2250+
keyId: kp.keyId,
2251+
privateKey: kp.privateKey,
2252+
}));
22342253
} else if (Array.isArray(sender)) {
22352254
if (sender.length < 1) {
22362255
throw new Error("The sender's key pairs are empty.");
@@ -2288,6 +2307,22 @@ export class ContextImpl<TContextData> implements Context<TContextData> {
22882307
"The activity to send must have at least one actor property.",
22892308
);
22902309
}
2310+
// Pre-sign with Object Integrity Proofs before fanout so that all
2311+
// recipients receive the same signed activity. Uses Multikey IDs so that
2312+
// verifiers can look up the correct key type in the actor document.
2313+
if (actorKeyPairs != null) {
2314+
const contextLoader = this.contextLoader;
2315+
for (const kp of actorKeyPairs) {
2316+
if (
2317+
kp.privateKey.algorithm.name !== "Ed25519" ||
2318+
kp.multikey.id == null
2319+
) continue;
2320+
activity = await signObject(activity, kp.privateKey, kp.multikey.id, {
2321+
contextLoader,
2322+
tracerProvider: this.tracerProvider,
2323+
});
2324+
}
2325+
}
22912326
const inboxes = extractInboxes({
22922327
recipients: expandedRecipients,
22932328
preferSharedInbox: options.preferSharedInbox,
@@ -2840,12 +2875,16 @@ export class InboxContextImpl<TContextData> extends ContextImpl<TContextData>
28402875
identifier = mapped;
28412876
}
28422877
}
2843-
keys = await this.getKeyPairsFromIdentifier(identifier);
2844-
if (keys.length < 1) {
2878+
const actorKeyPairs = await this.getActorKeyPairs(identifier);
2879+
if (actorKeyPairs.length < 1) {
28452880
throw new Error(
28462881
`No key pair found for actor ${JSON.stringify(identifier)}.`,
28472882
);
28482883
}
2884+
keys = actorKeyPairs.map((kp) => ({
2885+
keyId: kp.keyId,
2886+
privateKey: kp.privateKey,
2887+
}));
28492888
} else if (Array.isArray(forwarder)) {
28502889
if (forwarder.length < 1) {
28512890
throw new Error("The forwarder's key pairs are empty.");

0 commit comments

Comments
 (0)