Skip to content

Preserve signed child JSON during typed compound serialization - #1051

Merged
dahlia merged 2 commits into
fedify-dev:mainfrom
dahlia:fep-ef61-preserve-signed-child-json
Sep 26, 2026
Merged

dahlia merged 2 commits into
fedify-dev:mainfrom
dahlia:fep-ef61-preserve-signed-child-json

Conversation

@dahlia

@dahlia dahlia commented Sep 22, 2026

Copy link
Copy Markdown
Member

Signing a Note with signObject(), assigning it to a typed Create, and serializing the parent rebuilt the child under the parent's JSON-LD context. The child kept its proofValue but lost its own document and proof contexts, so an extracted child no longer verified while the outer proof stayed valid over the altered child. #1041 shipped with a warning telling producers not to do this. This removes the warning.

Fixes #1044.

Capturing what was signed

Serializing the signed object a second time and keeping the result would only assume the stored bytes are the ones that were hashed, and the assumption breaks the moment anything touches the object afterwards. createProof() keeps the exact value it canonicalized instead, together with the serialized proof. The pair is retained only when its message digest, proof configuration digest, and proof value all reproduce the proof just created, so correctness follows from the construction rather than from a later check. Signing needs no extra serialization.

The stored value is a snapshot, not a view. clone() never carries it, because a clone may differ from the document the proof covers, and mutating a signed object in place does not change what gets embedded.

Getting it through compaction

A retained document cannot just be placed in the encoder's output, since jsonld.compact() re-expands whatever it finds there. Nested encoders emit a placeholder node reference, and the outermost toJsonLd() frame puts the document back once compaction is done. Both encoder paths use placeholders, including the compactable fast path that writes JSON directly: a Question holding a Note in oneOf compacts its whole subtree, so a value embedded verbatim by an inner frame is destroyed by an ancestor anyway.

Whether a placeholder can be recovered depends on the active context, and a compacted @nest wrapper is indistinguishable from an @id alias by inspection alone. Rather than guess, the context is vetted before any placeholder is emitted. A context that aliases @id under another term, declares @nest, uses an @id container, shadows the placeholder's urn scheme, or cannot be resolved offline turns retention off and gets the existing behavior. If replacement still fails, serialization throws instead of emitting the placeholder.

Normalization and fanout

Outgoing compatibility normalization rewrites scalar attachment values and public addressing across the whole document, embedded secured children included. A producer-only option now stops it at a nested self-contained secured document. verifyProof() does not pass that option, so its inbound fallback keeps its current digest behavior.

Fanout reparsed and reserialized the activity, discarding the snapshot stored on the typed object. It now reuses the document the activity was already serialized into. For an explicit Ed25519 sender the activity is signed before that serialization: signing in the delivery worker would cover a child that had already been rebuilt.

Limits

An object parsed with fromJsonLd() gets no snapshot, because parsing does not establish which representation was signed. The characterization test for that stays, as #1044 asks. An object that already carried a proof is not retained either, since proof sets are outside the map-local profile.

Signing a Note with signObject(), assigning it to a typed Create and
serializing the parent used to rebuild the child under the parent's
JSON-LD context.  The child kept its proofValue but lost its own
document and proof contexts, so the extracted child no longer verified,
while the outer proof stayed valid over the mangled child.  Producing a
FEP-ef61 compound document therefore meant assembling the JSON by hand.

signObject() now captures the secured JSON document its proof covers and
keeps it on the object it returns.  The document is not re-derived from
the vocabulary object: it is the value createProof() hashed, plus the
serialized proof, checked against the message digest, the proof
configuration digest and the proof value, so a captured document
verifies by construction.  Nested serialization emits a placeholder node
reference for a child that carries one, and the outermost toJsonLd()
frame puts the captured document back after compaction.  Placeholders
are used on both encoder paths, because an ancestor frame may still
compact a value that an inner frame embedded.

The captured document is a snapshot, independent of anything done to the
returned object afterwards.  clone() never carries it, since a clone may
differ from the document the proof covers.  Retention is skipped, and
ordinary serialization applies, for an object parsed with fromJsonLd(),
an object that already carried a proof, a document too large or too deep
to validate, and a toJsonLd() context that could hide the placeholder.
A placeholder that cannot be put back throws rather than reaching the
wire.

Two adjacent paths could still destroy a secured child:

- Outgoing JSON-LD compatibility normalization rewrote nested documents.
  It now leaves a nested self-contained secured document untouched under
  an opt-in option that only the producer path passes, so verifyProof()'s
  inbound fallback is unchanged.
- Fanout reparsed the activity and serialized it again.  It now reuses
  the document the activity was already serialized into, and an explicit
  Ed25519 sender's activity is signed before that serialization rather
  than by the delivery worker, while the typed child still carries its
  snapshot.  An activity that already has a proof keeps its own.

Fixes fedify-dev#1044
fedify-dev#288
fedify-dev#1041

Assisted-by: Claude Code:claude-opus-5
Assisted-by: OpenCode:deepseek-flash
Assisted-by: Codex:gpt-6-astra
Assisted-by: Claude Code:claude-fable-5-1
@dahlia dahlia added this to the Fedify 2.4 milestone Sep 22, 2026
@dahlia dahlia self-assigned this Sep 22, 2026
@dahlia dahlia added component/vocab Activity Vocabulary related component/signatures OIP or HTTP/LD Signatures related activitypub/interop Interoperability issues labels Sep 22, 2026
@netlify

netlify Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for fedify-json-schema canceled.

Name Link
🔨 Latest commit 5144fca
🔍 Latest deploy log https://app.netlify.com/projects/fedify-json-schema/deploys/6ab2b96a9d9cb8000880b3bf

@coderabbitai

coderabbitai Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Advanced

Run ID: e1512699-a146-4464-b9ce-af37fd2f601c

📥 Commits

Reviewing files that changed from the base of the PR and between de2e833 and 5144fca.

📒 Files selected for processing (1)
  • packages/vocab/src/signed-representation.test.ts

Included review availability: Your plan provides up to 4 included reviews per hour; 2 remain after this review.


📝 Walkthrough

Walkthrough

The change retains the secured JSON-LD document captured by signObject(), restores it during typed nested serialization, preserves complete secured children during outgoing normalization, and reuses serialized JSON-LD during fanout delivery.

Changes

Signed child preservation

Layer / File(s) Summary
Retained representation runtime
packages/vocab-runtime/..., packages/vocab-tools/..., packages/vocab/src/signed-representation.test.ts
Adds retained signed-document storage, marker scopes, context checks, placeholder restoration, generated encoder support, package exports, and tests.
Proof capture and compound verification
packages/fedify/src/sig/proof.ts, packages/fedify/src/sig/compound-proof.test.ts, docs/manual/send.md, CHANGES.md, changes.d/fedify/...
signObject() captures the secured document for new proofs and retains it on the signed object. Compound tests cover verification, tampering, cloning, mutation, and fallback cases.
Outgoing secured-document normalization
packages/fedify/src/compat/secured-document.ts, packages/fedify/src/compat/outgoing-jsonld.ts, packages/fedify/src/compat/public-audience.ts, packages/fedify/src/compat/*test.ts
Adds detection and option propagation so complete nested secured documents can bypass normalization. Incomplete and top-level documents remain normalized.
Fanout serialization reuse
packages/fedify/src/federation/middleware.ts, packages/fedify/src/federation/compound-fanout.test.ts, changes.d/fedify/...
Fanout reuses queued JSON-LD when possible, avoids duplicate proofs, and tests preservation and verification of nested and outer proofs.

Priority: ➖ Normal

Estimated code review effort: 4 (Complex) | ~60 minutes

Change: Bug fix · Severity of issue fixed: Medium

Sequence Diagram(s)

sequenceDiagram
  participant signObject
  participant ProofCapture
  participant TypedSerializer
  participant OutgoingNormalizer
  participant FanoutDelivery
  signObject->>ProofCapture: create proof and capture secured JSON-LD
  ProofCapture-->>signObject: retain secured representation
  TypedSerializer->>signObject: serialize signed child in typed parent
  signObject-->>TypedSerializer: return retained child document
  TypedSerializer->>OutgoingNormalizer: normalize parent JSON-LD
  OutgoingNormalizer-->>TypedSerializer: preserve nested secured child
  FanoutDelivery->>OutgoingNormalizer: send queued serialized activity
  OutgoingNormalizer-->>FanoutDelivery: reuse normalized JSON-LD
Loading

Merge Risk: ⚪ Minimal · up to 5144f

Signed child documents retain their original contexts and proofs through typed serialization and fanout, preserving compound-proof verification. The change is ready to merge with normal checks.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 36.96% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 46 functions across 16 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary change: preserving signed child JSON during typed compound serialization.
Description check ✅ Passed The description is directly related to the changeset and explains signed representation capture, nested serialization, normalization, fanout handling, and scope limits.
Linked Issues check ✅ Passed Issue #1044 coding requirements are implemented. signObject() captures and validates the exact secured JSON value, including its document context and proof, and retains it only for a newly created p…
Out of Scope Changes check ✅ Passed The changes stay within issue #1044. The runtime retention helpers, generated serializer changes, outgoing normalization option, fanout reuse, tests, documentation, and changelog entries all support p…
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
🧪 Generate unit tests (beta)
  • Create a new PR
🛠️ Fix failing CI checks 💡
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.36641% with 40 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
...ocab-runtime/src/internal/signed-representation.ts 92.13% 5 Missing and 20 partials ⚠️
packages/fedify/src/sig/proof.ts 84.53% 7 Missing and 8 partials ⚠️
Files with missing lines Coverage Δ
packages/fedify/src/compat/outgoing-jsonld.ts 85.00% <100.00%> (+1.59%) ⬆️
packages/fedify/src/compat/public-audience.ts 97.14% <100.00%> (+1.03%) ⬆️
packages/fedify/src/compat/secured-document.ts 100.00% <100.00%> (ø)
packages/fedify/src/federation/middleware.ts 84.06% <100.00%> (+0.08%) ⬆️
packages/vocab-tools/src/class.ts 99.05% <100.00%> (+0.02%) ⬆️
packages/vocab-tools/src/codec.ts 97.84% <100.00%> (+0.02%) ⬆️
packages/vocab-tools/src/constructor.ts 100.00% <ø> (ø)
packages/fedify/src/sig/proof.ts 88.80% <84.53%> (-0.42%) ⬇️
...ocab-runtime/src/internal/signed-representation.ts 92.13% <92.13%> (ø)
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

The new signed-representation tests imported @std/assert, which is a JSR
package that @fedify/vocab does not declare.  Deno resolves it through the
workspace import map, so the Deno suite passed, but the Node.js and Bun
suites run a tsdown bundle from dist-tests where the specifier is left
external and unresolvable:

    Cannot find package '@std/assert' imported from
    packages/vocab/dist-tests/signed-representation.test.mjs

Every other test in the package uses node:assert, which all three runtimes
resolve.  Switch to it.
@2chanhaeng

Copy link
Copy Markdown
Member

I think resolving #1026 should come first.

@sij411

sij411 commented Sep 24, 2026

Copy link
Copy Markdown
Member

I don't think there is a strong dependency tho this and #1026

@dahlia

dahlia commented Sep 25, 2026

Copy link
Copy Markdown
Member Author

@2chanhaeng I think #1026 can be fixed separately. Could you share what connects the two for you? I might be missing something.

@2chanhaeng

Copy link
Copy Markdown
Member

Errors occurred in the test task on my machine, so I thought maybe #1026 should be fixed first. But looking again, that's not a problem with this PR.

@dahlia
dahlia merged commit fcf88bf into fedify-dev:main Sep 26, 2026
39 of 40 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

activitypub/interop Interoperability issues component/signatures OIP or HTTP/LD Signatures related component/vocab Activity Vocabulary related

Development

Successfully merging this pull request may close these issues.

Preserve signed child JSON values during typed compound serialization

3 participants