Skip to content

Commit e78a165

Browse files
committed
Reuse resolved contexts for proof aliases
Record remote contexts loaded during portable object expansion and replay only those responses when identifying proof property aliases. This keeps caller-defined aliases verifiable without letting digest construction fetch any new attacker-controlled context. #968 (comment) Assisted-by: Codex:gpt-5.6-sol
1 parent 1f21b47 commit e78a165

2 files changed

Lines changed: 76 additions & 8 deletions

File tree

packages/fedify/src/sig/proof.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1119,6 +1119,43 @@ test("verifyPortableObjectProof()", async (t) => {
11191119
}
11201120
});
11211121

1122+
await t.step(
1123+
"verifies proof aliases from caller-loaded contexts",
1124+
async () => {
1125+
const contextUrl = "https://context.example/security";
1126+
const proofAlias = "integrityProof";
1127+
const proofIri = "https://w3id.org/security#proof";
1128+
let contextLoads = 0;
1129+
const contextLoader = async (url: string) => {
1130+
if (url !== contextUrl) return await mockDocumentLoader(url);
1131+
contextLoads++;
1132+
return {
1133+
contextUrl: null,
1134+
documentUrl: url,
1135+
document: {
1136+
"@context": { [proofAlias]: proofIri },
1137+
},
1138+
};
1139+
};
1140+
const document = {
1141+
...unsignedObject,
1142+
"@context": [...portableContext, contextUrl],
1143+
};
1144+
const { proof, ...signed } = await signPortableJsonLd(document);
1145+
const result = await verifyPortableObjectProof({
1146+
...signed,
1147+
[proofAlias]: proof,
1148+
}, {
1149+
...options,
1150+
contextLoader,
1151+
});
1152+
assert(result.verified);
1153+
assertEquals(result.keys.length, 1);
1154+
assertEquals(result.keys[0].id, portableKeyId);
1155+
assertEquals(contextLoads, 1);
1156+
},
1157+
);
1158+
11221159
await t.step("verifies portable actors and activities by shape", async () => {
11231160
for (
11241161
const document of [

packages/fedify/src/sig/proof.ts

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
getFe34Origin,
1111
haveSameFe34Origin,
1212
parseIri,
13+
type RemoteDocument,
1314
} from "@fedify/vocab-runtime";
1415
import jsonld from "@fedify/vocab-runtime/jsonld";
1516
import { getLogger } from "@logtape/logtape";
@@ -493,6 +494,7 @@ interface ProofMessageDigests {
493494

494495
interface ProofMessageDigestCache {
495496
value?: Promise<ProofMessageDigests>;
497+
proofContextLoader?: DocumentLoader;
496498
}
497499

498500
function expandContextPropertyIri(
@@ -519,11 +521,12 @@ function expandContextPropertyIri(
519521

520522
async function getProofPropertyNames(
521523
jsonLd: Record<string, unknown>,
524+
documentLoader: DocumentLoader = preloadedOnlyDocumentLoader,
522525
): Promise<Set<string>> {
523526
const names = new Set(["proof", SECURITY_PROOF]);
524527
if (jsonLd["@context"] == null) return names;
525528
try {
526-
const options = { documentLoader: preloadedOnlyDocumentLoader };
529+
const options = { documentLoader };
527530
let activeContext = await jsonld.processContext(null, null, options);
528531
activeContext = await jsonld.processContext(
529532
activeContext,
@@ -570,13 +573,16 @@ async function getProofPropertyNames(
570573

571574
async function createProofMessageDigests(
572575
jsonLd: unknown,
576+
proofContextLoader?: DocumentLoader,
573577
): Promise<ProofMessageDigests> {
574578
const msg = { ...(jsonLd as Record<string, unknown>) };
575579
// `verifyProof()` promises to ignore existing proofs on the input;
576580
// strip every top-level property that the active JSON-LD context maps to
577581
// the security proof predicate so its bytes are not folded into the JCS
578582
// message digest.
579-
for (const property of await getProofPropertyNames(msg)) {
583+
for (
584+
const property of await getProofPropertyNames(msg, proofContextLoader)
585+
) {
580586
delete msg[property];
581587
}
582588
const encoder = new TextEncoder();
@@ -722,7 +728,10 @@ async function verifyProofInternal(
722728
);
723729
};
724730
const messageDigests = await (
725-
messageDigestCache.value ??= createProofMessageDigests(jsonLd)
731+
messageDigestCache.value ??= createProofMessageDigests(
732+
jsonLd,
733+
messageDigestCache.proofContextLoader,
734+
)
726735
);
727736
if (await verifyCandidate(messageDigests.onWire)) return publicKey;
728737
const normalizedDigest = await messageDigests.normalized();
@@ -832,18 +841,37 @@ function classifyFep2277CoreType(
832841
async function expandPortableObjectRoot(
833842
jsonLd: unknown,
834843
contextLoader: DocumentLoader | undefined,
835-
): Promise<Record<string, unknown>> {
844+
): Promise<{
845+
root: Record<string, unknown>;
846+
proofContextLoader: DocumentLoader;
847+
}> {
836848
if (!isJsonLdNode(jsonLd)) {
837849
throw new TypeError("Expected a single JSON-LD object.");
838850
}
851+
const loadedContexts = new Map<string, RemoteDocument>();
852+
const loader = getNormalizationContextLoader(contextLoader);
853+
const recordingLoader: DocumentLoader = async (url, options) => {
854+
const remoteDocument = await loader(url, options);
855+
const key = URL.canParse(url) ? new URL(url).href : url;
856+
loadedContexts.set(key, structuredClone(remoteDocument));
857+
return remoteDocument;
858+
};
839859
const expanded = await jsonld.expand(jsonLd, {
840-
documentLoader: getNormalizationContextLoader(contextLoader),
860+
documentLoader: recordingLoader,
841861
keepFreeFloatingNodes: true,
842862
});
843863
if (expanded.length !== 1 || !isJsonLdNode(expanded[0])) {
844864
throw new TypeError("Expected a single JSON-LD object.");
845865
}
846-
return expanded[0];
866+
return {
867+
root: expanded[0],
868+
proofContextLoader: async (url, options) => {
869+
const key = URL.canParse(url) ? new URL(url).href : url;
870+
const remoteDocument = loadedContexts.get(key);
871+
if (remoteDocument != null) return structuredClone(remoteDocument);
872+
return await preloadedOnlyDocumentLoader(url, options);
873+
},
874+
};
847875
}
848876

849877
/**
@@ -869,7 +897,10 @@ export async function verifyPortableObjectProof(
869897
jsonLd: unknown,
870898
options: VerifyPortableObjectProofOptions = {},
871899
): Promise<VerifyPortableObjectProofResult> {
872-
const root = await expandPortableObjectRoot(jsonLd, options.contextLoader);
900+
const { root, proofContextLoader } = await expandPortableObjectRoot(
901+
jsonLd,
902+
options.contextLoader,
903+
);
873904
const id = root["@id"];
874905
if (
875906
typeof id !== "string" ||
@@ -990,7 +1021,7 @@ export async function verifyPortableObjectProof(
9901021
}
9911022

9921023
const keys: Multikey[] = [];
993-
const messageDigestCache: ProofMessageDigestCache = {};
1024+
const messageDigestCache: ProofMessageDigestCache = { proofContextLoader };
9941025
for (let proofIndex = 0; proofIndex < proofs.length; proofIndex++) {
9951026
const key = await verifyProofWithMessageDigestCache(
9961027
jsonLd,

0 commit comments

Comments
 (0)