Verified against FEP-8b32 (codeberg, 2026-10-15); W3C vc-di-eddsa (eddsa-jcs-2022 test vector), VC Data Integrity 1.0, Quantum-Resistant Cryptosuites WD 2026-06-16; Mastodon v4.7.3 source and docs; GitHub advisories; code tested in examples/verify-requests on 2026-10-15.
Object integrity proofs
An HTTP signature proves who delivered a request. It says nothing about an object that arrives through someone else: a relay, inbox forwarding, or a copy embedded in another server's activity. For those cases you can either refetch the object from its origin, or check a signature that travels with the object.
FEP-8b32: Object Integrity Proofs defines that signature. It is still a DRAFT, but Mastodon 4.7 verifies it, and the FEP lists Mitra, Fedify, Hubzilla, Streams, Vervis, tootik and others as implementations.
What a proof looks like
The proof is a DataIntegrityProof object under proof, as defined by Verifiable Credential Data Integrity 1.0. This is the FEP's own example:
{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/data-integrity/v2"
],
"id": "https://server.example/objects/1",
"type": "Note",
"attributedTo": "https://server.example/users/alice",
"content": "Hello world",
"proof": {
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/data-integrity/v2"
],
"type": "DataIntegrityProof",
"cryptosuite": "eddsa-jcs-2022",
"verificationMethod": "https://server.example/users/alice#ed25519-key",
"proofPurpose": "assertionMethod",
"proofValue": "...",
"created": "2023-02-24T23:36:38Z"
}
}
The FEP's rules:
typeMUST beDataIntegrityProofandproofPurposeMUST beassertionMethod.verificationMethodpoints at a key in the signer'sassertionMethod, published as a FEP-521a Multikey.- The actor that controls the key MUST be the object's owner under FEP-fe34: the
actorof an activity, theattributedToof an object. - Objects identified by fragment ids SHOULD NOT carry proofs, and an object SHOULD NOT carry more than one.
- If the key can't be resolved, the cryptosuite isn't supported, or the proof has
expiresin the past, ignore the proof and authenticate some other way (an HTTP signature, or a refetch). - For a delivered activity, a top-level proof MUST take precedence over the HTTP signature.
Cryptosuites
eddsa-jcs-2022 (recommended)
FEP-8b32 recommends eddsa-jcs-2022:
- Canonicalization: the JSON Canonicalization Scheme (RFC 8785). Sort object keys, serialize numbers and strings exactly as JavaScript's
JSON.stringifydoes. - Hashing: SHA-256 of the canonical proof options (the proof without
proofValue), followed by SHA-256 of the canonical document (withoutproof). The signature covers those 64 bytes. - Signature: Ed25519.
proofValueis the 64-byte signature, base58btc multibase (z…).
One subtlety in verification: if the proof has an @context, the document's @context must start with it, and the proof's @context then replaces the document's before hashing.
JCS signs the JSON you received, not an RDF graph. That makes it simple and fast. It also means the proof does not survive JSON-LD expansion, compaction or framing, as Mastodon's docs point out. Verify the JSON exactly as received, then process that same JSON.
mldsa44-jcs-2024 (post-quantum)
FEP-8b32 notes that eddsa-jcs-2022 is not secure against quantum computers. Quantum-resistant suites are being standardized in the W3C Quantum-Resistant Cryptosuites v1.0 Working Draft (2026-06-16). mldsa44-jcs-2024 uses the same JCS canonicalization with an ML-DSA-44 signature.
The two suites build the proof configuration differently. For mldsa44-jcs-2024 the proof options take the document's @context, and there is no prefix check. Mastodon's source flags this as "inconsistent with eddsa-jcs-2022 but described in" the Working Draft (object_integrity_proof.rb). Since that spec is still a draft, check it again before relying on this detail.
What Mastodon 4.7 does
Mastodon 4.7.0 (2026-08-20) added verification of FEP-8b32 proofs for both suites. mldsa44-jcs-2024 needs OpenSSL 3.5 or later. The same release added support for expires on proofs (CHANGELOG, #39530). It verifies proofs but doesn't create them.
When Mastodon checks a proof, from process_activity_service.rb:
- Only for relayed activities. A proof is checked only when the HTTP signer is not the activity's
actor. Otherwise the HTTP signature already authenticates it. - After LD Signatures. Mastodon "currently prefers Linked Data Signatures and will only attempt to verify an Object Integrity Proof if the Linked Data Signature is absent or unverifiable" (Mastodon docs). This differs from FEP-8b32, which gives a top-level proof precedence.
- Proof-only activities aren't forwarded. A source comment says extending the forwarding rules to them "is not worth it right now since FEP-8b32 is not widely supported".
- Keys:
eddsa-jcs-2022needs an Ed25519 key andmldsa44-jcs-2024an ML-DSA-44 key. Both are fetched as FEP-521a keys, and a revoked or expired key is refused.
So proofs are worth adding to the activities you send, especially ones that get relayed or forwarded. Just don't drop HTTP signatures in favour of them.
Why not Linked Data Signatures
Before FEP-8b32, the way to sign an object was a Linked Data Signature: signature with type: "RsaSignature2017". Mastodon still sends and accepts these, for relays and for tootctl self-destruct deletes. Its documentation is blunt: the implementation is "outdated", the spec "has been superseded by the Verifiable Credential Data Integrity 1.0 specification", and "it is not advised to implement support for LD Signatures" (Mastodon docs).
The deeper problem showed up in May 2026. An LD Signature signs the canonical RDF graph, and JSON-LD offers many ways to write the same graph. Attackers could rearrange a signed document so that ActivityPub software, which reads the JSON tree, sees something different, while the signature still verifies. The Fedify advisory describes moving the top-level activity into @graph and promoting its embedded object, and using @reverse or @included to the same effect:
| Advisory | Project | Published | Impact | Fixed in |
|---|---|---|---|---|
| CVE-2026-46349 | Mastodon | 2026-05-20 | Rearranging a third party's signed activity. For Mastodon-issued activities, "limited to re-issuing retracted boosts" | 4.5.10, 4.4.17, 4.3.23 |
| CVE-2026-48028 | Mastodon | 2026-05-20 | Removing JSON entries from a third party's signed activity, so parts of it can be hidden | 4.5.10, 4.4.17, 4.3.23 |
| CVE-2026-42462 | Fedify | 2026-05-20 | The same named-graph restructuring, rated high | 1.9.11, 1.10.10, 2.0.18, 2.1.14, 2.2.3 |
The attacker needs a copy of the original signed activity. Inbox forwarding and relays provide one.
The lesson for any signature scheme: what you verify must be exactly what you process. JCS-based proofs give you that if you verify the received JSON and then use it without re-shaping it.
If you still want legacy compatibility, FEP-8b32 says to add the integrity proof before the LD signature, and to remove signature before verifying the proof.
The code
Tested in examples/verify-requests. It reuses the key resolution from Verifying incoming requests, so the key must be listed in its controller's actor document, and that document must be served from its own origin.
import { verifyObjectProof, safeFetchJson } from './index.js';
// An activity arrived through a relay: the HTTP signer is the relay,
// not activity.actor. Check the proof instead.
const { owner } = await verifyObjectProof(activity, (url) => safeFetchJson(url));
// owner === activity.actor is guaranteed here; verifyObjectProof throws
// ProofError (or KeyResolutionError) otherwise. Embedded objects owned by
// someone else still go through the origin checks.
// FEP-8b32 Object Integrity Proofs with the eddsa-jcs-2022 cryptosuite.
//
// https://codeberg.org/fediverse/fep/src/branch/main/fep/8b32/fep-8b32.md
// https://www.w3.org/TR/vc-di-eddsa/#eddsa-jcs-2022
//
// A proof travels with the object, so it can be checked after relaying or
// inbox forwarding, when the HTTP signature belongs to someone else.
import crypto from 'node:crypto';
import { resolveKey, base58btcDecode } from './keys.js';
import { ownerOf } from './origin.js';
export class ProofError extends Error {}
const B58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
export function base58btcEncode(bytes) {
let n = BigInt(`0x${Buffer.from(bytes).toString('hex') || '0'}`);
let out = '';
while (n > 0n) { out = B58[Number(n % 58n)] + out; n /= 58n; }
for (const b of bytes) { if (b === 0) out = `1${out}`; else break; }
return out;
}
/**
* JSON Canonicalization Scheme (RFC 8785) for values produced by JSON.parse.
* JSON.stringify already serialises strings and numbers the way RFC 8785
* requires; JCS adds key ordering by UTF-16 code units (the default sort).
*/
export function jcs(value) {
if (Array.isArray(value)) return `[${value.map(jcs).join(',')}]`;
if (value && typeof value === 'object') {
return `{${Object.keys(value).sort()
.filter((k) => value[k] !== undefined)
.map((k) => `${JSON.stringify(k)}:${jcs(value[k])}`).join(',')}}`;
}
return JSON.stringify(value);
}
const sha256 = (s) => crypto.createHash('sha256').update(s, 'utf8').digest();
function without(obj, key) {
const { [key]: _removed, ...rest } = obj;
return rest;
}
// proofConfigHash || transformedDocumentHash (vc-di-eddsa §3.3.4)
function hashData(unsecured, proofOptions) {
return Buffer.concat([sha256(jcs(proofOptions)), sha256(jcs(unsecured))]);
}
// vc-di-eddsa §3.3.2 steps 4.1-4.2: the proof's @context must be a prefix of
// the document's, and then replaces it.
function applyProofContext(unsecured, proofOptions) {
if (proofOptions['@context'] === undefined) return unsecured;
const docCtx = Array.isArray(unsecured['@context']) ? unsecured['@context'] : [unsecured['@context']];
const proofCtx = Array.isArray(proofOptions['@context']) ? proofOptions['@context'] : [proofOptions['@context']];
if (proofCtx.some((c, i) => jcs(c) !== jcs(docCtx[i]))) {
throw new ProofError('Proof @context is not a prefix of the document @context');
}
return { ...unsecured, '@context': proofOptions['@context'] };
}
/** Low-level check: does `document.proof` verify against `publicKey`? */
export function verifyEddsaJcs2022(document, publicKey) {
const proof = document.proof;
if (!proof || proof.type !== 'DataIntegrityProof' || proof.cryptosuite !== 'eddsa-jcs-2022') {
throw new ProofError('Not an eddsa-jcs-2022 DataIntegrityProof');
}
if (typeof proof.proofValue !== 'string' || !proof.proofValue.startsWith('z')) {
throw new ProofError('proofValue must be base58btc multibase');
}
const proofOptions = without(proof, 'proofValue');
const unsecured = applyProofContext(without(document, 'proof'), proofOptions);
const signature = base58btcDecode(proof.proofValue.slice(1));
return crypto.verify(null, hashData(unsecured, proofOptions), publicKey, signature);
}
/** Add an eddsa-jcs-2022 proof (for outbound objects and for tests). */
export function signEddsaJcs2022(document, { privateKey, verificationMethod, created }) {
const proofOptions = {
type: 'DataIntegrityProof',
cryptosuite: 'eddsa-jcs-2022',
verificationMethod,
proofPurpose: 'assertionMethod',
created,
};
if (document['@context'] !== undefined) proofOptions['@context'] = document['@context'];
const unsecured = without(document, 'proof');
const signature = crypto.sign(null, hashData(unsecured, proofOptions), privateKey);
return { ...unsecured, proof: { ...proofOptions, proofValue: `z${base58btcEncode(signature)}` } };
}
/**
* Verify an object's FEP-8b32 proof and return its authenticated owner.
*
* - drops a legacy LD `signature` first (FEP-8b32 "Backward compatibility")
* - requires proofPurpose assertionMethod and an unexpired proof
* - resolves verificationMethod through the controller's actor document,
* which must list it under assertionMethod (FEP-521a) — never trust the
* key document alone
* - requires the key's controller to be the object's owner (FEP-fe34)
*
* @param {object} object - the received JSON (activity or object)
* @param {(url: string) => Promise<{url: string, json: object}>} fetchJson - SSRF-safe fetcher
* @returns {Promise<{owner: string, keyId: string}>}
*/
export async function verifyObjectProof(object, fetchJson, { now = Date.now() } = {}) {
const document = without(object, 'signature');
const proof = document.proof;
if (!proof || typeof proof !== 'object' || Array.isArray(proof)) {
throw new ProofError('Missing proof (or more than one)');
}
if (proof.proofPurpose !== 'assertionMethod') throw new ProofError('proofPurpose must be assertionMethod');
if (proof.expires && Date.parse(proof.expires) <= now) throw new ProofError('Proof has expired');
if (typeof proof.verificationMethod !== 'string') throw new ProofError('Missing verificationMethod');
const { actorId, publicKey } = await resolveKey(proof.verificationMethod, fetchJson);
if (!verifyEddsaJcs2022(document, publicKey)) throw new ProofError('Proof verification failed');
const owner = ownerOf(document);
if (owner !== actorId) {
throw new ProofError(`Proof was made by ${actorId}, but the object belongs to ${owner}`);
}
return { owner, keyId: proof.verificationMethod };
}
The tests (test/proof.test.js) check:
- the JCS output against the hashes published in the W3C eddsa-jcs-2022 test vector
- that the W3C vector's proof verifies, and fails once the document is modified
- that a proof
@contextwhich isn't a prefix of the document's is rejected - a signed
Note, and a signedCreaterelayed by someone else - that a legacy LD
signaturenext to the proof is ignored
and that these are rejected:
- a modified object
- a key the actor doesn't list
- a valid proof by an actor who doesn't own the object
- an expired proof
- the wrong
proofPurpose
Only eddsa-jcs-2022 is implemented here. Node.js can verify ML-DSA where its OpenSSL supports it, but this module doesn't attempt mldsa44-jcs-2024 while its spec is a Working Draft.
See also
- Keys and identity: publishing Ed25519 keys with FEP-521a
- Origin-based security: what an authenticated owner is allowed to do
- Known vulnerability classes