zettelkasten.synapse.sharing.recipient_index¶
zettelkasten.synapse.sharing.recipient_index ¶
Firm-scoped recipient-index policy export — the enabler for CROSS-PRODUCER synapse edges.
A cross-producer synapse edge is one whose ZK endpoint lives in a DIFFERENT
producer's repo: a FEDERATED endpoint whose zk_source contains ":" (e.g.
"remote:alpha"). Such an edge cannot be shared by the same-producer synapse
path because the edge author (repo A) cannot compute the peer note's recipient
set — A's federation cache only carries content peer B already filtered FOR A,
never B's grant graph. The fix is this recipient-index export: a small policy
artifact B publishes ALONGSIDE its ZK bundle that names, for exactly the notes B
ships, which firm recipients may open each one. A then intersects that peer note
policy with its own memory-entry recipients to decide who a cross-producer edge
may reach (see :func:zettelkasten.synapse.sharing.config.edge_recipients and
:func:zettelkasten.synapse.sharing.bundle.build_bundle).
Wire format (ONE place — this module)¶
The export is a sibling of the ZK bundle's manifest.json inside the SAME
producer-owned bundle repo::
<bundle_root>/
manifest.json # existing ZK bundle manifest (untouched)
units/<digest>.age # existing ZK bundle units (untouched)
recipient-index.json # cleartext structural manifest (NO ids)
recipient-index/<digest>.age # ONE firm-encrypted ciphertext
The cleartext recipient-index.json carries only structural fields
(format/format_version/created_at/optional producer_id and the
opaque unit path) — never a note id or a recipient id. The mapping itself
({"<box>/<note_id>": [sorted recipient_ids], ...}) lives INSIDE the single
age ciphertext, mirroring the privacy-first manifest discipline of the
memory/zk/synapse bundles. The unit filename is a fixed sha256 digest so it
is stable across republishes and opaque to anyone who does not already hold the
bundle.
Scope + encryption¶
The export covers EXACTLY the notes B ships in its shared ZK bundle (the tightest
scope B can compute for itself — "notes B already shares"), reusing the per-note
recipient sets already resolved by
:func:zettelkasten.sharing.bundle.build_bundle. The whole mapping is encrypted
firm-wide — to the public keys of ALL registered firm members — so any firm
member's angelo federation sync can decrypt it. This is a DELIBERATE,
ACCEPTED membership leak: a firm member who can open the export learns which
recipients are granted a note even if that member cannot open the note itself.
The leak is bounded to the firm (a non-member / unregistered key cannot decrypt
the export at all).
Forward-only revocation¶
Both sides rebuild from scratch each run. The producer removes and rewrites the
recipient-index artifacts on every publish, so a note dropped from B's shared
set simply stops appearing in the next export. The consumer rewrites (or, when
the peer stops exporting / the key can no longer open it, REMOVES) the decrypted
policy each sync. As with the parent bundles this is FORWARD-ONLY: a bundle
repo's git history still retains old ciphertexts, so a leaked past export cannot
be un-published — only superseded.
Revocation is bounded by the republish -> resync latency¶
A synapse build_bundle reads the DECRYPTED policy from the federation cache; it
does NOT itself re-fetch B's export. So for cross-producer revocation to take
effect, synapse publish MUST be preceded by angelo federation sync — the
sync is what re-decrypts B's latest export (or removes a stranded one) into the
cache. The window in which A may still ship a federated edge to a recipient B has
revoked is therefore bounded to the combined producer republish -> consumer
resync latency (i.e. A's sync cadence), never unbounded. Two mechanisms keep it
bounded in practice (both driven by angelo federation sync):
- the ZK
bundle_subscriptionssync loop re-decrypts each subscribed peer's recipient-index every run (revocation for a still-subscribed peer propagates on the next sync); and - the sync then REMOVES a stranded
_policy/note-recipients.jsonfor any peer NOT in the ZKbundle_subscriptionsset — i.e. the keep-set is exactly the REFRESHED set, the peers the ZK loop above re-decrypts. A peer's policy is refreshed by, and only by, that ZK loop, so retaining a policy for a peer the loop never touches would ship a stale (revoked) grant unboundedly. See :func:remove_recipient_indexand theangelo federation syncCLI path.
Required precondition: a federated intersects peer MUST also be a ZK subscription¶
Because the ZK bundle_subscriptions loop is the SOLE path that refreshes (or
removes) a peer's recipient-index, a synapse intersects peer that is NOT also a
ZK bundle_subscription is never refreshed. Its cross-producer edges therefore
FAIL CLOSED (its stale policy is cleaned by the cleanup step above, so
:func:load_recipient_index returns None and the edge is omitted). This is
intended, not a bug: it is the fail-closed direction for an unrefreshable peer. To
SHIP a cross-producer edge to a peer, that peer must appear in BOTH the synapse
intersects scope AND the ZK bundle_subscriptions. angelo federation sync
emits an operator WARNING (never a failure) when a peer is in intersects but not
in the ZK subscriptions, so the operator learns why its edges are silently omitted.
Id-agreement precondition (ZK sub id == federated_repos id == zk_source prefix)¶
The decrypted policy is WRITTEN under the ZK subscription id (the
bundle_subscriptions entry's id, used as the federation-cache out_root
dir), but LOADED under peer_id = split_namespace(zk_source)[0] — the
federated_repos / intersects repo id derived from an edge's zk_source.
For the lookup to hit, these three ids must be BYTE-IDENTICAL: the ZK subscription
id, the federated_repos repo id, and the zk_source namespace prefix. If
they diverge (e.g. a peer subscribed as alpha but intersected as alpha-notes)
the load silently misses and the edge fails closed. This is an operational
precondition the module cannot enforce; the intersects-not-subscribed WARNING
above is the cheap detectable case (an intersects repo id with no matching ZK
subscription id).
The stamp (:data:POLICY_STAMP_FILENAME) records the producer generation and the
consumer decrypt time so this staleness is observable. Behaviour stays
FAIL-CLOSED throughout: a missing/unresolvable policy omits the edge.
Accepted trust-model boundaries¶
Two properties are DELIBERATE and NOT defended cryptographically:
- The recipient-index is a TRUSTED assertion by the peer producer. Because
agehides recipient identities, consumer A cannot independently verify B's recipient claims from the ciphertext. A malicious or compromised producer could OVERSTATE a note's grants and cause A to ship a federated edge to a firm member who cannot actually open the peer note — a both-ends id-secrecy break. This is bounded: the peer note id is already firm-visible via the firm-wide export itself, and a producer can always leak its own content/relationships anyway. There is no cryptographic binding to add here — with age's anonymous recipients it is infeasible — so the export is treated as trusted producer input, exactly like the accepted body-leak class of the parent bundles. - Registry-consistency precondition. Cross-producer edges assume a single,
byte-consistent firm
.memory/identities.yamlacross repos, so thatfirmexpansion and id -> pubkey mapping agree on both sides. If the registries diverge (e.g. a member added in one repo but not another), an edge can be mis-delivered or omitted. Keeping the firm registry consistent across repos is an operational precondition, not something this module can enforce.
RecipientIndexSignatureError ¶
Bases: ValueError
Raised by :func:decrypt_recipient_index when signature enforcement fails.
Under the fail-closed verification matrix (a registry is provided AND the
producer's signing public key is registered), this is raised when either the
attached signature does not verify against the decrypted canonical mapping
bytes (TAMPER) or the manifest carries NO signature at all (DOWNGRADE). It is
a :class:ValueError subclass so existing except ValueError callers keep
catching it.
Source code in zettelkasten/synapse/sharing/recipient_index.py
RecipientIndexBuildResult
dataclass
¶
Summary of a :func:build_recipient_index run.
Source code in zettelkasten/synapse/sharing/recipient_index.py
RecipientIndexDecryptResult
dataclass
¶
build_recipient_index ¶
build_recipient_index(output_dir: Path | str, note_recipients: Mapping[str, set[str]], registry: IdentityRegistry, *, producer_id: str | None = None, signing_private_key: str | None = None) -> RecipientIndexBuildResult
Write the firm-encrypted recipient-index export as a ZK-bundle sibling.
note_recipients maps a "<box>/<note_id>" key to the recipient id set
for EXACTLY the notes shipped in the ZK bundle (pass the already-resolved
per-note recipients — do NOT invent a new scope). The mapping is encrypted
once to ALL registered firm members (registry.member_ids()), so any firm
member can decrypt it (the accepted, firm-bounded membership leak).
Owns output_dir/recipient-index.json and output_dir/recipient-index/:
both are removed first and rewritten from scratch, so a revoked note never
lingers. With an empty scope (no shared notes) or no registered members, the
artifacts are simply left removed and written=False is returned.
Signing (tamper-evident, non-repudiable binding to the producer):
when signing_private_key is given, the EXACT canonical plaintext bytes
that get encrypted (json.dumps(mapping, sort_keys=True).encode()) are
Ed25519-signed and the base64 signature is attached to the cleartext
manifest alongside producer_id (the verifier resolves the producer's
signing pubkey by producer_id). With no signing key the export is built
UNSIGNED — a warning is logged, but the publish never fails (backward-compat).
Source code in zettelkasten/synapse/sharing/recipient_index.py
307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 | |
decrypt_recipient_index ¶
decrypt_recipient_index(bundle_dir: Path | str, out_root: Path | str, *, identity: LocalIdentity | Any | None = None, private_key: str | None = None, registry: IdentityRegistry | None = None, expected_producer_id: str | None = None) -> RecipientIndexDecryptResult
Decrypt a peer's recipient-index export into its federation cache.
Reads <bundle_dir>/recipient-index.json + its ciphertext, decrypts with
the local firm key, defensively validates the (untrusted) mapping, and writes
<out_root>/.zettelkasten/_policy/note-recipients.json.
Signature verification (fail-closed, opt-in via registry):
when registry is provided the Ed25519 signature over the decrypted
canonical plaintext bytes is enforced against the producer's registered
signing public key. expected_producer_id (the subscription/peer id the
consumer believes it is syncing) binds the manifest to that peer:
expected_producer_idgiven (the CLIfederation syncpath): the manifest'sproducer_idMUST equalexpected_producer_id(a missing or relabelled producer_id is rejected as TAMPER), and the signing key is resolved byexpected_producer_id— never the manifest's self-asserted value. Then: registered key + signature -> must verify (else TAMPER); registered key + NO signature -> :class:RecipientIndexSignatureError(DOWNGRADE); NO registered key -> accept UNSIGNED (weaker mode, logged).expected_producer_idNone (API back-compat): the signing key is resolved by the manifest's ownproducer_id(a no-op when the manifest carries none), preserving the pre-P1 behaviour for existingregistry=callers.registry is None-> verification is SKIPPED entirely (pure backward compat; existing callers are unaffected).
A verification failure (TAMPER/DOWNGRADE/relabel) is a HARD failure: the
:class:RecipientIndexSignatureError propagates AND any stale policy for this
peer is REMOVED first, so a rejected index never leaves a stale/partial policy
file behind (the edge then fails closed).
signing.verify() itself never raises (returns False); this function
raises on a False result so a tampered/downgraded export fails closed.
Fail-safe / forward-only revocation:
- MISSING manifest (peer no longer exports) or a mapping this key cannot open / that validates empty -> the stale policy file (and its stamp) is REMOVED (revocation).
- a corrupt/unsafe manifest, a bad unit path, or a missing unit -> stale policy removed (nothing valid to trust).
- a TRANSIENT I/O fault reading the manifest/unit -> the existing policy is PRESERVED untouched and retried next sync.
On success the policy is (over)written AND a freshness stamp sidecar
(:data:POLICY_STAMP_FILENAME) is written carrying the producer's manifest
created_at/producer_id and this decrypt's timestamp, so a downstream
caller can observe how old the policy it trusts is.
Source code in zettelkasten/synapse/sharing/recipient_index.py
611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 | |
load_recipient_index ¶
Load a peer's DECRYPTED recipient-index policy from the federation cache.
Reads <cache_root>/<peer_id>/.zettelkasten/_policy/note-recipients.json,
re-validates it defensively (belt-and-suspenders over the decrypt-time
validation), and returns a {"<box>/<note_id>": {recipient_ids}} map — or
None when absent/unreadable/empty/invalid (the caller then FAILS CLOSED,
omitting the cross-producer edge).
peer_id is derived from an edge's zk_source and is ID_RE-validated
before it is turned into a path (defence-in-depth over :func:safe_join); a
malformed peer id fails closed with None.
Source code in zettelkasten/synapse/sharing/recipient_index.py
load_recipient_index_stamp ¶
Load a peer's recipient-index freshness stamp, or None if absent/bad.
Returns the parsed :data:POLICY_STAMP_FILENAME sidecar (a dict carrying the
producer generation created_at/producer_id and the consumer
decrypted_at) so a caller can observe how stale the policy it trusts is.
peer_id is ID_RE-validated exactly as in :func:load_recipient_index.
Source code in zettelkasten/synapse/sharing/recipient_index.py
remove_recipient_index ¶
Remove a peer's decrypted recipient-index policy (+ stamp). Returns True if present.
Cleanup-on-unsubscribe: the recipient-index is refreshed ONLY for peers in the
ZK bundle_subscriptions set (its sole refresh path). When a peer leaves that
set it is no longer refreshed, so its cached policy would otherwise linger and
keep shipping a revoked federated edge unboundedly. Removing it bounds
cross-producer revocation to the config change (fail-closed thereafter). A peer
that is only in the synapse intersects scope but NOT ZK-subscribed is
likewise never refreshed and so is cleaned here (its edges then fail closed).
ONLY the _policy/note-recipients.json file and its stamp sidecar are
touched — never the peer's .memory/.zettelkasten/.synapse caches or
anything else in the federation cache. peer_id is ID_RE-validated first.