starfish-sharing 3.0.0a8__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- starfish_sharing-3.0.0a8/PKG-INFO +13 -0
- starfish_sharing-3.0.0a8/README.md +224 -0
- starfish_sharing-3.0.0a8/pyproject.toml +32 -0
- starfish_sharing-3.0.0a8/setup.cfg +4 -0
- starfish_sharing-3.0.0a8/starfish_sharing/__init__.py +77 -0
- starfish_sharing-3.0.0a8/starfish_sharing/cap_mint.py +395 -0
- starfish_sharing-3.0.0a8/starfish_sharing/directory.py +320 -0
- starfish_sharing-3.0.0a8/starfish_sharing/evict.py +95 -0
- starfish_sharing-3.0.0a8/starfish_sharing/plugin.py +26 -0
- starfish_sharing-3.0.0a8/starfish_sharing/public_link.py +178 -0
- starfish_sharing-3.0.0a8/starfish_sharing.egg-info/PKG-INFO +13 -0
- starfish_sharing-3.0.0a8/starfish_sharing.egg-info/SOURCES.txt +17 -0
- starfish_sharing-3.0.0a8/starfish_sharing.egg-info/dependency_links.txt +1 -0
- starfish_sharing-3.0.0a8/starfish_sharing.egg-info/requires.txt +9 -0
- starfish_sharing-3.0.0a8/starfish_sharing.egg-info/top_level.txt +1 -0
- starfish_sharing-3.0.0a8/tests/test_cap_mint.py +315 -0
- starfish_sharing-3.0.0a8/tests/test_directory.py +349 -0
- starfish_sharing-3.0.0a8/tests/test_evict.py +211 -0
- starfish_sharing-3.0.0a8/tests/test_public_link.py +187 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: starfish-sharing
|
|
3
|
+
Version: 3.0.0a8
|
|
4
|
+
Summary: Starfish member-cap extension (member cap-cert minting, scope presets, member directory)
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: starfish-protocol
|
|
7
|
+
Requires-Dist: starfish-sdk
|
|
8
|
+
Requires-Dist: starfish-keyring
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
11
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
12
|
+
Requires-Dist: respx>=0.23.1; extra == "dev"
|
|
13
|
+
Requires-Dist: starfish-identities; extra == "dev"
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# starfish-sharing
|
|
2
|
+
|
|
3
|
+
Starfish member-cap extension for Python — issue scoped member capability certificates with `read_only` / `writer` / `admin` presets, and manage the per-collection `_members` directory.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pip install starfish-sdk starfish-keyring starfish-sharing
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from starfish_sharing import mint_member_cap, scopes, add_member_entry, list_members
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## How membership works: cryptographic & collection view
|
|
18
|
+
|
|
19
|
+
Membership has two orthogonal layers — **authorization** (a CA-signed capability cert, verified by
|
|
20
|
+
the server) and **key delivery** (the collection key wrapped to the member, only for
|
|
21
|
+
`encryption: "delegated"`). Onboarding sets both up; revoking tears both down. The server never reads
|
|
22
|
+
a member roster — access is decided purely from the presented cap-cert.
|
|
23
|
+
|
|
24
|
+
### Onboarding a member
|
|
25
|
+
|
|
26
|
+
**Cryptographic**
|
|
27
|
+
|
|
28
|
+
- `mint_member_cap` produces a **CA-signed bearer token**: the owner's root **Ed25519** key signs the
|
|
29
|
+
canonical cert (`kind: "member"`, `iss` = owner, `sub` = member Ed25519 pubkey,
|
|
30
|
+
`subKem` = member X25519 pubkey, `subUserId`, a single-collection `scope`, plus `nbf` / `exp` /
|
|
31
|
+
`nonce`). The cert carries the member's **public** keys only — it holds **no secret or wrapped key
|
|
32
|
+
material**.
|
|
33
|
+
- For `encryption: "delegated"` collections, key access is granted separately: the owner **wraps the
|
|
34
|
+
collection content-encryption key (CEK) to the member's X25519 `subKem`** via the keyring's
|
|
35
|
+
`add_recipient`. Mechanically: ephemeral X25519 ECDH → HKDF-SHA256 wrap key → `AES-256-GCM(cek)`,
|
|
36
|
+
appended as a `WrappedKeyEntry` (itself signed by the adder) to the keyring's current epoch.
|
|
37
|
+
- Authorization and key delivery are **independent**: a cap with no keyring entry lets the member
|
|
38
|
+
reach the collection but not decrypt it; a keyring entry with no cap lets them decrypt bytes they
|
|
39
|
+
can never fetch. Onboarding a reader of a delegated collection needs **both**.
|
|
40
|
+
- The signed cap is delivered to the member out-of-band (or via an ordinary collection). On each
|
|
41
|
+
request the member sends it in the `Authorization: Cap …` header plus a per-request Ed25519
|
|
42
|
+
signature; the **server verifies the signature, time window, revocation, and scope** — it does not
|
|
43
|
+
consult any roster.
|
|
44
|
+
|
|
45
|
+
**Collection (what gets written)**
|
|
46
|
+
|
|
47
|
+
| Path | Written by | Holds | Server reads it? |
|
|
48
|
+
|---|---|---|---|
|
|
49
|
+
| `<col>/_members` | `add_member_entry` (owner only) | Audit/UX roster: `{nonce, sub, subKem, subUserId, scope, nbf, exp, label?, addedBy?, addedAt}` | **No** — not an authority source |
|
|
50
|
+
| `<col>/_keyring` | `add_recipient` (delegated mode only) | The member's `WrappedKeyEntry` in the current epoch | **No** — stored opaquely; clients decrypt |
|
|
51
|
+
|
|
52
|
+
The cap-cert itself is **not** stored server-side as a membership record — it lives with the member.
|
|
53
|
+
`_members` is purely the owner's bookkeeping so they can list and later evict.
|
|
54
|
+
|
|
55
|
+
### Revoking a member
|
|
56
|
+
|
|
57
|
+
**Cryptographic**
|
|
58
|
+
|
|
59
|
+
- **Authorization kill-switch (revocation list).** The owner signs a generation-counted
|
|
60
|
+
`RevocationList` (Ed25519) naming the cap by `{sub, nonce, exp}` (or `revokedSubjects` for
|
|
61
|
+
incident response). The server's revocation store then rejects that cap **O(1) on every request**,
|
|
62
|
+
cutting off both reads and writes immediately. This is the only thing that stops an already-issued
|
|
63
|
+
cap — `exp` aside.
|
|
64
|
+
- **Forward secrecy (keyring rotation).** `remove_recipient` rotates the epoch: mints a **fresh CEK**,
|
|
65
|
+
re-wraps it for the **retained** recipients only, and bumps `current_epoch`. The evicted member's
|
|
66
|
+
X25519 key is absent from the new epoch, so they cannot decrypt content sealed afterward.
|
|
67
|
+
*Caveat:* earlier epochs are preserved, so content sealed **before** rotation stays decryptable to
|
|
68
|
+
anyone who already held that CEK — rotation is forward-secret, not retroactive.
|
|
69
|
+
- The two are independent: **revoke** stops the member acting (writes/auth); **rotate** stops them
|
|
70
|
+
reading future content. Doing only one is the footgun `evict_member` exists to prevent.
|
|
71
|
+
|
|
72
|
+
**Collection (what changes)**
|
|
73
|
+
|
|
74
|
+
- `<col>/_keyring` — rotation appends a new epoch whose `wrappedKeys` omit the evicted member.
|
|
75
|
+
- `<col>/_members` — `remove_member_entry` drops the member's entry by `nonce`.
|
|
76
|
+
|
|
77
|
+
**Ordering matters:** revoke **first** (so a still-valid cap can't squeeze a write in between the
|
|
78
|
+
rotate and the revoke), then rotate the keyring, then drop the directory entry. `evict_member`
|
|
79
|
+
composes all three behind explicit `rotate` / `revoke` flags — see below.
|
|
80
|
+
|
|
81
|
+
## Eviction
|
|
82
|
+
|
|
83
|
+
Removing a member from the keyring (`remove_recipient`) rotates the epoch for forward
|
|
84
|
+
secrecy but does **not** stop them writing — write authority is cap-based. Full eviction
|
|
85
|
+
is revoke-the-cap **and** rotate-the-keyring **and** drop the directory entry.
|
|
86
|
+
`evict_member` does all three in one call behind explicit `rotate` / `revoke` flags,
|
|
87
|
+
removing the footgun. It is transport- and ledger-agnostic — you supply how to submit the
|
|
88
|
+
signed `RevocationList` and the strictly-increasing per-issuer `generation`:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from starfish_sharing import evict_member
|
|
92
|
+
|
|
93
|
+
await evict_member(
|
|
94
|
+
client,
|
|
95
|
+
keyring_collection="shared-notes", # <coll> → <coll>/_keyring
|
|
96
|
+
members_collection="shared-notes", # <coll> → <coll>/_members
|
|
97
|
+
member={"sub": bob_ed_pub, "nonce": cap_nonce, "exp": cap_exp, "subKem": bob_kem_pub},
|
|
98
|
+
adder={"edPriv": owner_ed_priv, "edPub": owner_ed_pub, "kemPriv": owner_kem_priv},
|
|
99
|
+
trusted_adders=[owner_ed_pub],
|
|
100
|
+
iss_ed_pub_hex=owner_ed_pub,
|
|
101
|
+
iss_ed_priv_hex=owner_ed_priv,
|
|
102
|
+
generation=next_generation, # you track this (the store needs it to increase)
|
|
103
|
+
submit_revocation=submit, # async (list) -> None
|
|
104
|
+
prior_revoked=prior, # previously-revoked entries to carry forward
|
|
105
|
+
rotate=True,
|
|
106
|
+
revoke=True,
|
|
107
|
+
)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The signed list is built with `build_revocation_list` from `starfish-protocol`.
|
|
111
|
+
|
|
112
|
+
## Plaintext, cap-only sharing (no keyring)
|
|
113
|
+
|
|
114
|
+
The membership flow above is the **E2E-encrypted** option (`encryption: "delegated"`):
|
|
115
|
+
content is sealed under a per-collection keyring and members get a wrapped CEK. For data
|
|
116
|
+
that does **not** need E2E encryption there is a second option: a
|
|
117
|
+
**plaintext** (`encryption: "none"`) shared collection where access is authorized purely
|
|
118
|
+
by **signed member caps + expiry**, exactly like the device mechanism. There is **no
|
|
119
|
+
keyring and no wrapped keys**; the server enforces read/write from the presented cap. The
|
|
120
|
+
two options coexist and are selected by the collection's `encryption` field.
|
|
121
|
+
|
|
122
|
+
Why it's safe to hand caps around: a cap is **subject-bound**, not a bearer token — the
|
|
123
|
+
server verifies every request's signature against the cap's `sub`, so a cap is usable only
|
|
124
|
+
by the holder of that subject's private key.
|
|
125
|
+
|
|
126
|
+
Two delivery variants:
|
|
127
|
+
|
|
128
|
+
- **Stateless (out-of-band).** Mint the cap and deliver it out-of-band; nothing is
|
|
129
|
+
stored server-side (no `_keyring`, no `_members`). The owner keeps its own record of
|
|
130
|
+
`{sub, nonce, exp}` to revoke later.
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
cert = mint_member_cap(
|
|
134
|
+
owner_ed_priv, owner_ed_pub,
|
|
135
|
+
{"edPubHex": bob_ed_pub, "kemPubHex": bob_kem_pub, "userIdHex": bob_user_id},
|
|
136
|
+
"shared-board",
|
|
137
|
+
scopes.writer("shared-board"),
|
|
138
|
+
)
|
|
139
|
+
# → hand `cert` to Bob; he presents it as `Authorization: Cap …`.
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
- **Owner-published caps.** Instead of forwarding, publish each signed cap into the
|
|
143
|
+
single `<col>/_members` list; members fetch their own. Configure that collection
|
|
144
|
+
read-open + owner-only write.
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
from starfish_sharing import publish_member_cap, fetch_my_member_cap, unpublish_member_cap
|
|
148
|
+
|
|
149
|
+
# Owner publishes Bob's cap into the shared list:
|
|
150
|
+
await publish_member_cap(client, "shared-board", cert, label="Bob")
|
|
151
|
+
|
|
152
|
+
# Bob fetches his own cap (no forwarding) and uses it for content:
|
|
153
|
+
my_cap = await fetch_my_member_cap(client, "shared-board", bob_ed_pub)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
- **Public link (`audience` cap).** For a public share — a broadcast feed, a status page,
|
|
157
|
+
an "anyone in this list can post" board — use `create_public_link`. It mints an
|
|
158
|
+
**`audience` cap-cert** (which binds *no* single subject) and packs it into a URL
|
|
159
|
+
`#fragment`. Every redeemer signs requests with **their own** identity key (sent as the
|
|
160
|
+
`X-Starfish-Pub` header), so the link carries **no private key** and writes are
|
|
161
|
+
attributable per user. An optional `allowed_identities` list restricts who may redeem
|
|
162
|
+
(server-enforced); omit it and **any** identity may. Optional `expires_at` / `ttl_sec`
|
|
163
|
+
set expiry.
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
from starfish_sharing import create_public_link, parse_public_link, redeem_public_link, scopes
|
|
167
|
+
|
|
168
|
+
link = create_public_link(
|
|
169
|
+
owner_ed_priv, owner_ed_pub,
|
|
170
|
+
"broadcast", scopes.read_only("broadcast"),
|
|
171
|
+
allowed_identities=[bob_ed_pub, carol_ed_pub], # optional; omit ⇒ any identity
|
|
172
|
+
ttl_sec=7 * 24 * 3600, # optional; or expires_at=<unix seconds>
|
|
173
|
+
)
|
|
174
|
+
# Share f"https://app.example/#{link.fragment}".
|
|
175
|
+
|
|
176
|
+
# A redeemer (who already holds a Starfish identity) parses + signs as themselves:
|
|
177
|
+
parsed = parse_public_link(fragment)
|
|
178
|
+
headers = redeem_public_link(
|
|
179
|
+
parsed,
|
|
180
|
+
redeemer_ed_priv_hex=bob_ed_priv,
|
|
181
|
+
redeemer_ed_pub_hex=bob_ed_pub,
|
|
182
|
+
method="GET", path_and_query="/pull/broadcast/post-1", host="api.example.com",
|
|
183
|
+
)
|
|
184
|
+
# Send `headers` (Authorization: Cap …, X-Starfish-{Sig,Ts,Nonce,Pub}) with the request.
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
The server (with `sharing_server_plugin` wired) verifies the signature against
|
|
188
|
+
`X-Starfish-Pub`, checks it against the cap's `aud` when present (403 otherwise), and
|
|
189
|
+
binds `auth.identity` to that redeemer's own userId. Revocation is **whole-link**: revoke
|
|
190
|
+
the cap's nonce with `sub=""`, or re-mint with a trimmed `allowed_identities`. The
|
|
191
|
+
`_members` directory and `evict_member` are for single-subject member caps only.
|
|
192
|
+
|
|
193
|
+
**Revocation is CRL-only** — there is nothing encrypted to rotate. Call `evict_member`
|
|
194
|
+
with `rotate=False` and omit the keyring params; it revokes the cap and drops the
|
|
195
|
+
published entry:
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
await evict_member(
|
|
199
|
+
client,
|
|
200
|
+
members_collection="shared-board",
|
|
201
|
+
member={"sub": bob_ed_pub, "nonce": cap_nonce, "exp": cap_exp, "subKem": bob_kem_pub},
|
|
202
|
+
iss_ed_pub_hex=owner_ed_pub,
|
|
203
|
+
iss_ed_priv_hex=owner_ed_priv,
|
|
204
|
+
generation=next_generation,
|
|
205
|
+
submit_revocation=submit,
|
|
206
|
+
rotate=False,
|
|
207
|
+
revoke=True,
|
|
208
|
+
)
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## Server plugin
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
from starfish_server import create_cap_cert_role_resolver
|
|
215
|
+
from starfish_sharing import sharing_server_plugin
|
|
216
|
+
|
|
217
|
+
resolver = create_cap_cert_role_resolver(
|
|
218
|
+
nonce_cache=nonce_cache,
|
|
219
|
+
revocation_store=revocation_store,
|
|
220
|
+
plugins=[sharing_server_plugin],
|
|
221
|
+
)
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
See `docs/python/sharing/` (and the TypeScript counterpart in `docs/ts/sharing/`) for the full guide.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "starfish-sharing"
|
|
7
|
+
version = "3.0.0a8"
|
|
8
|
+
description = "Starfish member-cap extension (member cap-cert minting, scope presets, member directory)"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"starfish-protocol",
|
|
12
|
+
"starfish-sdk",
|
|
13
|
+
"starfish-keyring",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
dev = [
|
|
18
|
+
"pytest>=7.0",
|
|
19
|
+
"pytest-asyncio>=0.21",
|
|
20
|
+
"respx>=0.23.1",
|
|
21
|
+
"starfish-identities",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[tool.uv.sources]
|
|
25
|
+
starfish-protocol = { path = "../protocol", editable = true }
|
|
26
|
+
starfish-sdk = { path = "../client", editable = true }
|
|
27
|
+
starfish-keyring = { path = "../keyring", editable = true }
|
|
28
|
+
starfish-identities = { path = "../identities", editable = true }
|
|
29
|
+
|
|
30
|
+
[tool.pytest.ini_options]
|
|
31
|
+
asyncio_mode = "auto"
|
|
32
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""``starfish-sharing`` — member-cap extension.
|
|
2
|
+
|
|
3
|
+
Public surface: member cap-cert minting with the
|
|
4
|
+
``read_only``/``writer``/``admin`` scope presets, the per-collection
|
|
5
|
+
``_members`` directory, and the server plugin.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from starfish_sharing.cap_mint import (
|
|
9
|
+
AudienceMintOpts,
|
|
10
|
+
MintOpts,
|
|
11
|
+
ScopePreset,
|
|
12
|
+
assert_audience_cap_shape,
|
|
13
|
+
assert_member_cap_shape,
|
|
14
|
+
mint_audience_cap,
|
|
15
|
+
mint_member_cap,
|
|
16
|
+
scopes,
|
|
17
|
+
)
|
|
18
|
+
from starfish_sharing.public_link import (
|
|
19
|
+
ParsedPublicLink,
|
|
20
|
+
PublicLink,
|
|
21
|
+
create_public_link,
|
|
22
|
+
parse_public_link,
|
|
23
|
+
redeem_public_link,
|
|
24
|
+
)
|
|
25
|
+
from starfish_sharing.directory import (
|
|
26
|
+
Directory,
|
|
27
|
+
DirectoryEntry,
|
|
28
|
+
ListDirectoryOpts,
|
|
29
|
+
add_member_entry,
|
|
30
|
+
fetch_member_caps,
|
|
31
|
+
fetch_my_member_cap,
|
|
32
|
+
list_members,
|
|
33
|
+
members_path_for,
|
|
34
|
+
publish_member_cap,
|
|
35
|
+
remove_member_entry,
|
|
36
|
+
unpublish_member_cap,
|
|
37
|
+
)
|
|
38
|
+
from starfish_sharing.evict import evict_member
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def __getattr__(name: str):
|
|
42
|
+
"""Lazy import of ``sharing_server_plugin`` so apps that only use the
|
|
43
|
+
client-side helpers don't pay the ``starfish_server`` import cost.
|
|
44
|
+
"""
|
|
45
|
+
if name == "sharing_server_plugin":
|
|
46
|
+
from starfish_sharing.plugin import sharing_server_plugin as _p
|
|
47
|
+
return _p
|
|
48
|
+
raise AttributeError(f"module 'starfish_sharing' has no attribute {name!r}")
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"MintOpts",
|
|
52
|
+
"AudienceMintOpts",
|
|
53
|
+
"ScopePreset",
|
|
54
|
+
"assert_member_cap_shape",
|
|
55
|
+
"assert_audience_cap_shape",
|
|
56
|
+
"mint_member_cap",
|
|
57
|
+
"mint_audience_cap",
|
|
58
|
+
"scopes",
|
|
59
|
+
"PublicLink",
|
|
60
|
+
"ParsedPublicLink",
|
|
61
|
+
"create_public_link",
|
|
62
|
+
"parse_public_link",
|
|
63
|
+
"redeem_public_link",
|
|
64
|
+
"Directory",
|
|
65
|
+
"DirectoryEntry",
|
|
66
|
+
"ListDirectoryOpts",
|
|
67
|
+
"add_member_entry",
|
|
68
|
+
"list_members",
|
|
69
|
+
"members_path_for",
|
|
70
|
+
"remove_member_entry",
|
|
71
|
+
"publish_member_cap",
|
|
72
|
+
"fetch_member_caps",
|
|
73
|
+
"fetch_my_member_cap",
|
|
74
|
+
"unpublish_member_cap",
|
|
75
|
+
"evict_member",
|
|
76
|
+
"sharing_server_plugin",
|
|
77
|
+
]
|