zettelkasten.synapse.sharing¶
zettelkasten.synapse.sharing ¶
Encryption-first sharing for the committed .synapse link overlay.
The THIRD sharing bundle, alongside :mod:memory.sharing and
:mod:zettelkasten.sharing, but for cross-store SYNAPSE EDGES — the bipartite
memory <-> zk links in .synapse/links/links.json. Because an edge names
ids from BOTH stores, it is shared ONLY to recipients who can already open BOTH
endpoints (the intersection of the two stores' per-unit recipient maps); see
:func:edge_recipients. This is the whole id-secrecy property.
Crypto and identity are REUSED from :mod:memory.sharing unchanged — there is a
single firm identity registry (.memory/identities.yaml) and one local key
(~/.angelo/identity.yaml); this package does NOT fork them. Only the
edge-policy and bundle layers are synapse-specific.
Typical producer flow::
from memory import sharing as mem_sharing
from zettelkasten.synapse import sharing as syn_sharing
registry = mem_sharing.load_registry()
result = syn_sharing.build_bundle("/path/to/bundle-repo", registry=registry)
Typical consumer flow::
from zettelkasten.synapse import sharing as syn_sharing
syn_sharing.decrypt_bundle(
"/path/to/fetched-bundle-repo",
"/path/to/federation-cache/peer", # writes <peer>/.synapse/links/links.json
) # uses ~/.angelo/identity.yaml
IdentityRegistry
dataclass
¶
An id -> RegistryEntry map of publishable (public-key) identities.
Source code in memory/sharing/identity.py
member_ids ¶
public_key_for ¶
The public key for recipient_id, or None if unregistered.
signing_public_key_for ¶
The Ed25519 signing public key for recipient_id.
Returns None when the id is unregistered or when the registered
entry predates signing (legacy entry with no signing_public_key).
Source code in memory/sharing/identity.py
public_keys_for ¶
Resolve ids to public keys, silently dropping unregistered ids.
Source code in memory/sharing/identity.py
with_entry ¶
with_entry(entry: RegistryEntry) -> 'IdentityRegistry'
A copy of this registry with entry added/overwritten (immutably).
LocalIdentity
dataclass
¶
This machine's identity: a logical id plus its keypair(s).
Persisted (with the private keys) to ~/.angelo/identity.yaml. Carries both
the age X25519 encryption keypair and the Ed25519 signing keypair; the
signing fields are optional (default None) so a legacy identity file with
no signing key still loads.
Source code in memory/sharing/identity.py
to_pyrage_identity ¶
to_registry_entry ¶
to_registry_entry() -> RegistryEntry
The public-only :class:RegistryEntry for publishing to the registry.
Source code in memory/sharing/identity.py
RegistryEntry
dataclass
¶
One published identity: a logical id bound to a public key.
display_name and github are optional and used only for attribution /
discovery — never for access control (the public key is the anchor).
signing_public_key is the identity's Ed25519 signing public key (base64,
public part only); optional so legacy registries without it still parse.
Source code in memory/sharing/identity.py
BuildResult
dataclass
¶
Summary of a :func:build_bundle run (returned in memory, never written).
Source code in zettelkasten/synapse/sharing/bundle.py
BundleManifest
dataclass
¶
The cleartext manifest.json at the bundle root.
Source code in zettelkasten/synapse/sharing/bundle.py
BundleUnit
dataclass
¶
A pointer to one encrypted edge unit (kind + relative path).
Source code in zettelkasten/synapse/sharing/bundle.py
DecryptResult
dataclass
¶
StalePeerPolicyError ¶
Bases: RuntimeError
Raised by :func:build_bundle under the OPT-IN fail-closed staleness TTL.
When max_policy_age_hours is configured, a synapse publish REFUSES to ship
cross-producer edges whose peer recipient-index policy is missing or older than
the TTL — a "publish-requires-recent-sync" gate — rather than emitting a
possibly over-permissive (stale) edge set. It is raised BEFORE any bundle
output is written, so an existing bundle is left intact on failure.
stale_peers is a list of (peer_id, decrypted_at_iso_or_None) for
programmatic handling; None means the peer's freshness stamp was
missing/unparseable.
Source code in zettelkasten/synapse/sharing/bundle.py
SynapseSharingConfig
dataclass
¶
Parsed synapse sharing plumbing (no per-edge scopes — those are derived).
bundle_repo— producer-owned repo path the publisher writes into.producer_id— optional attribution id stamped in the bundle manifest.bundle_subscriptions— consumer subscription dicts (each anidplus a localpathor a gitremote), mirroring the memory/zk shape.recipient_index_max_age_hours— OPT-IN fail-closed staleness TTL (defaultNone= disabled). When set to a positive number,synapse publishREFUSES to publish if any cross-producer edge relies on a peer recipient-index policy that is missing or older than this many hours (a "publish-requires-recent-sync" gate). Left unset, publish keeps its default forward-only behaviour and only emits a non-fatal staleness warning.
Source code in zettelkasten/synapse/sharing/config.py
RecipientIndexBuildResult
dataclass
¶
Summary of a :func:build_recipient_index run.
Source code in zettelkasten/synapse/sharing/recipient_index.py
RecipientIndexDecryptResult
dataclass
¶
decrypt ¶
Decrypt ciphertext with private_key.
private_key may be an AGE-SECRET-KEY-... string or a pre-parsed
:class:pyrage.x25519.Identity. Raises :class:DecryptError if the
ciphertext was not encrypted to this key.
Source code in memory/sharing/crypto.py
encrypt ¶
Encrypt plaintext to every recipient in recipient_pubkeys.
Each key is an age X25519 recipient string (age1...). Any recipient
holding the matching private key can :func:decrypt the result. Returns the
binary age ciphertext.
Raises :class:ValueError if no recipients are given (encrypting to nobody
would produce an unopenable blob) or if a recipient string is malformed
(surfaced as :class:pyrage.RecipientError).
Source code in memory/sharing/crypto.py
create_identity ¶
create_identity(identity_id: str, *, display_name: str | None = None, github: str | None = None, path: Path | str | None = None, overwrite: bool = False) -> LocalIdentity
Generate a keypair, build a :class:LocalIdentity, and persist it.
Refuses to clobber an existing identity file unless overwrite=True — a
lost private key cannot be recovered, so overwriting is opt-in.
Source code in memory/sharing/identity.py
default_identity_path ¶
Path of this machine's private identity file: ~/.angelo/identity.yaml.
Under HOME (never the workspace) so the private key cannot be committed.
default_registry_path ¶
Default path of the shared identity registry (.memory/identities.yaml).
Anchored to the memory store so it is versioned alongside the tree. Contains public keys only, so committing it is safe. Callers may override the path.
Source code in memory/sharing/identity.py
load_local_identity ¶
load_local_identity(path: Path | str | None = None) -> LocalIdentity | None
Load this machine's identity, or None if the file is absent/invalid.
The public key is derived from the private key when the file omits it, so an
identity file carrying only id + private_key still loads.
Source code in memory/sharing/identity.py
load_registry ¶
load_registry(path: Path | str | None = None) -> IdentityRegistry
Load the shared identity registry, or an empty one if absent/invalid.
Expected YAML shape::
identities:
alice:
public_key: age1...
display_name: Alice
github: alice-gh
signing_public_key: ... # optional Ed25519 signing public key (base64)
bob: age1... # shorthand: id -> public key (no signing key)
Malformed rows are skipped rather than raising, so a partially-authored registry still loads the good rows.
Source code in memory/sharing/identity.py
save_registry ¶
save_registry(registry: IdentityRegistry, path: Path | str | None = None) -> Path
Write the identity registry (public keys only) to disk.
Source code in memory/sharing/identity.py
build_bundle ¶
build_bundle(output_dir: Path | str, *, registry: IdentityRegistry, memory_dir: Path | str | None = None, store_root: Path | str | None = None, overlay: Mapping[str, Any] | None = None, producer_id: str | None = None, federation_cache_root: Path | str | None = None, max_policy_age_hours: float | None = None) -> BuildResult
Filter synapse edges by both-ends grants, encrypt per recipient, write a bundle.
Pipeline (filtering happens BEFORE any encryption):
- Load the local
.synapseoverlay edges (overlayarg, else :func:zettelkasten.synapse.overlay.load_overlay). - Build the memory
entry_recipientsand ZKnote_recipientsmaps for the local workspace (reusing the two stores' sharing config loaders,firmexpanded to the registry members). For any FEDERATED edge, also load the referenced peer's decrypted recipient-index policy from the federation cache (federation_cache_root, default the workspace cache). - For each edge compute :func:
edge_recipients— the INTERSECTION of its two endpoints' recipient sets (the ZK end resolved locally for a same-producer edge, or from the peer policy for a federated one). OMIT the edge when it resolves to no recipient (either side missing/ungranted, a federated endpoint with no resolvable peer policy, or an empty intersection) or when none of its recipients is registered. Never encrypt-then-drop. - Encrypt each retained edge's placement-header envelope to its recipients'
public keys and write
units/<digest>.age. - Write the cleartext
manifest.jsonof{kind, path}pointers only.
This function OWNS output_dir/units and output_dir/manifest.json — the
units directory is rebuilt from scratch every call so stale/revoked units
never linger. Other files (e.g. .git) are left untouched. With no edges
or no sharing policy it writes an empty bundle.
max_policy_age_hours is the OPT-IN fail-closed staleness TTL (default
None = disabled). When set to a positive number and any cross-producer edge
is present, this raises :class:StalePeerPolicyError — BEFORE any output is
written — if a referenced peer's recipient policy is missing or older than the
TTL, refusing to publish a possibly over-permissive stale edge set. Left unset,
the default is unchanged: publish still ships (forward-only) and only emits a
non-fatal staleness warning.
Source code in zettelkasten/synapse/sharing/bundle.py
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 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 | |
decrypt_bundle ¶
decrypt_bundle(bundle_dir: Path | str, out_root: Path | str, *, identity: LocalIdentity | Any | None = None, private_key: str | None = None) -> DecryptResult
Decrypt every edge this key can open and REBUILD a .synapse overlay cache.
Edges are decrypted and their records collected, then a valid overlay file is
written to <out_root>/.synapse/links/links.json — a federation cache that
is ENTIRELY bundle-owned, so a full rebuild-from-decrypted is correct: a
revoked edge (dropped from a newer bundle, or no longer openable by this key)
simply does not reappear. The rebuild is atomic.
Durability: a MISSING/CORRUPT manifest is treated as an empty bundle (the overlay is rebuilt to zero edges — full revocation). An UNREADABLE manifest, or ANY transient I/O fault reading a unit, ABORTS the rebuild WITHOUT touching the existing cache — a transient blip must never corrupt or erase a good overlay; the next clean sync rebuilds normally.
Provide the key via identity (a :class:LocalIdentity / pyrage identity),
private_key (an AGE-SECRET-KEY-... string), or neither (to load
~/.angelo/identity.yaml).
Source code in zettelkasten/synapse/sharing/bundle.py
990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 | |
open_bundle ¶
open_bundle(bundle_dir: Path | str, out_root: Path | str, *, identity: LocalIdentity | Any | None = None, private_key: str | None = None) -> DecryptResult
Alias for :func:decrypt_bundle.
Source code in zettelkasten/synapse/sharing/bundle.py
read_manifest ¶
read_manifest(bundle_dir: Path | str) -> BundleManifest
Read and parse a bundle's cleartext manifest.json.
Source code in zettelkasten/synapse/sharing/bundle.py
edge_recipients ¶
edge_recipients(edge: Mapping[str, Any], entry_recipients_map: Mapping[str, set[str]], note_recipients_map: Mapping[str, set[str]], peer_note_recipients_map: Mapping[str, set[str]] | None = None) -> set[str]
Recipients who may see a synapse edge: the INTERSECTION of both endpoints.
An edge names a memory entry (memory_id) and a ZK note
(zk_source/zk_id). It reaches a recipient R iff R can open BOTH — so
the recipient set is entry_recipients_map[memory_id] ∩ (that ZK note's
recipients). This intersection is the whole id-secrecy property: an edge names
ids from two stores, so shipping it to anyone who lacks EITHER end would leak
an id they were never granted.
The ZK note's recipients come from one of two maps, keyed by
"<zk_source>/<zk_id>":
- SAME-PRODUCER edge (
zk_sourcehas no":") — the localnote_recipients_map, exactly as before.peer_note_recipients_mapis ignored, so same-producer behaviour is UNCHANGED. - CROSS-PRODUCER / FEDERATED edge (
zk_sourcecontains":", e.g."remote:alpha") — the ZK end lives in a peer repo, so its recipients are looked up in the suppliedpeer_note_recipients_map(a decrypted recipient-index export; see :mod:zettelkasten.synapse.sharing.recipient_index).
Returns the EMPTY set (→ the caller omits the edge) when:
memory_id,zk_idorzk_sourceis missing/blank;- the edge is federated and
peer_note_recipients_mapis absent/empty or does not contain the note — FAIL CLOSED (the default when no peer policy is supplied, preserving today's "omit federated" behaviour); - either endpoint is unresolvable (absent from its recipient map, i.e. private/ungranted); or
- the intersection itself is empty (no recipient sees both ends).
Source code in zettelkasten/synapse/sharing/config.py
parse_sharing_config ¶
parse_sharing_config(config: Mapping[str, Any] | None) -> SynapseSharingConfig
Parse the sharing block + bundle_subscriptions of a config dict.
config is the plain dict read from .synapse/config.yaml (via
:func:read_config). Missing/malformed blocks yield an empty config
(nothing to publish or subscribe) — never raises on a partial config. The
existing intersects: block is ignored here (owned by
:mod:zettelkasten.synapse.config).
Source code in zettelkasten/synapse/sharing/config.py
read_config ¶
Read the synapse config, returning {} when missing or malformed.
Source code in zettelkasten/synapse/config.py
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.