starfish-identities 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_identities-3.0.0a8/PKG-INFO +15 -0
- starfish_identities-3.0.0a8/README.md +187 -0
- starfish_identities-3.0.0a8/pyproject.toml +34 -0
- starfish_identities-3.0.0a8/setup.cfg +4 -0
- starfish_identities-3.0.0a8/starfish_identities/__init__.py +129 -0
- starfish_identities-3.0.0a8/starfish_identities/cap_mint.py +133 -0
- starfish_identities-3.0.0a8/starfish_identities/directory.py +242 -0
- starfish_identities-3.0.0a8/starfish_identities/identity.py +155 -0
- starfish_identities-3.0.0a8/starfish_identities/pairing.py +894 -0
- starfish_identities-3.0.0a8/starfish_identities/plugin.py +27 -0
- starfish_identities-3.0.0a8/starfish_identities/rendezvous.py +128 -0
- starfish_identities-3.0.0a8/starfish_identities/seal.py +164 -0
- starfish_identities-3.0.0a8/starfish_identities.egg-info/PKG-INFO +15 -0
- starfish_identities-3.0.0a8/starfish_identities.egg-info/SOURCES.txt +23 -0
- starfish_identities-3.0.0a8/starfish_identities.egg-info/dependency_links.txt +1 -0
- starfish_identities-3.0.0a8/starfish_identities.egg-info/requires.txt +11 -0
- starfish_identities-3.0.0a8/starfish_identities.egg-info/top_level.txt +1 -0
- starfish_identities-3.0.0a8/tests/test_cap_mint.py +93 -0
- starfish_identities-3.0.0a8/tests/test_client_cap_headers.py +190 -0
- starfish_identities-3.0.0a8/tests/test_directory.py +291 -0
- starfish_identities-3.0.0a8/tests/test_identity_v3.py +66 -0
- starfish_identities-3.0.0a8/tests/test_pairing.py +541 -0
- starfish_identities-3.0.0a8/tests/test_rendezvous.py +115 -0
- starfish_identities-3.0.0a8/tests/test_seal.py +172 -0
- starfish_identities-3.0.0a8/tests/test_sync_author_sig.py +115 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: starfish-identities
|
|
3
|
+
Version: 3.0.0a8
|
|
4
|
+
Summary: Starfish root + device identity extension (passphrase derivation, device cap-cert minting, pairing flows, device directory)
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: cryptography>=41.0
|
|
7
|
+
Requires-Dist: argon2-cffi>=25.1.0
|
|
8
|
+
Requires-Dist: starfish-protocol
|
|
9
|
+
Requires-Dist: starfish-sdk
|
|
10
|
+
Requires-Dist: starfish-keyring
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
13
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
14
|
+
Requires-Dist: respx>=0.23.1; extra == "dev"
|
|
15
|
+
Requires-Dist: starfish-server; extra == "dev"
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# starfish-identities
|
|
2
|
+
|
|
3
|
+
Starfish root + device identity extension for Python — passphrase-derived root identities, device cap-cert minting, multi-device pairing flows, and the per-user device directory.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pip install starfish-sdk starfish-keyring starfish-identities
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from starfish_identities import bootstrap_root_identity, mint_device_cap, scopes, add_device_entry, list_devices
|
|
15
|
+
|
|
16
|
+
me = bootstrap_root_identity("correct horse battery staple")
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Root identity derivation
|
|
20
|
+
|
|
21
|
+
`bootstrap_root_identity` / `derive_root_identity` derive the root keypairs
|
|
22
|
+
deterministically from the passphrase, so the same human re-derives them on any
|
|
23
|
+
device with no stored secret:
|
|
24
|
+
|
|
25
|
+
- **Argon2id** (memory-hard) stretches the passphrase into a 32-byte master —
|
|
26
|
+
`m=47104 KiB ≈ 46 MiB, t=3, p=1`, global salt `"starfish-v3-root"` (derivation
|
|
27
|
+
must be reproducible from the passphrase alone, so the salt cannot be per-user).
|
|
28
|
+
Locked as `ARGON2_PARAMS`.
|
|
29
|
+
- **HKDF-SHA256** expands the master into two domain-separated seeds: an Ed25519
|
|
30
|
+
signing key (`info="ed25519"`) and an X25519 KEM key (`info="x25519"`) — used for
|
|
31
|
+
exactly one of {sign, key-agreement} each, never both.
|
|
32
|
+
- `user_id = sha256(root_ed_pub)[0:32]`.
|
|
33
|
+
|
|
34
|
+
Byte-identical to the TypeScript derivation; locked by
|
|
35
|
+
[`tests/test-vectors/identity-derivation.json`](../../../tests/test-vectors/identity-derivation.json).
|
|
36
|
+
|
|
37
|
+
## One-way device provisioning (configurable caps + expiry)
|
|
38
|
+
|
|
39
|
+
The QR / server-relay flows are *two-way*: the new device generates its own
|
|
40
|
+
keypair and sends a request back. `provision_device` is the *one-way* alternative
|
|
41
|
+
— the root device plays both roles, producing a single hand-off blob — and it is
|
|
42
|
+
where you choose **what the new device may do** (`scope`) and **how long its cap
|
|
43
|
+
lives** (`ttl_sec`). The same knobs exist on the two-way flow via
|
|
44
|
+
`AssemblePairingBundleOpts(granted_scope=..., ttl_sec=...)`.
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import json
|
|
48
|
+
from starfish_identities import (
|
|
49
|
+
provision_device,
|
|
50
|
+
install_provisioned_device,
|
|
51
|
+
ProvisionDeviceOpts,
|
|
52
|
+
ProvisionedDevice,
|
|
53
|
+
scopes,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Root device: generate the new device, mint its cap with a chosen scope + exp.
|
|
57
|
+
provisioned = provision_device(
|
|
58
|
+
{"edPriv": me.device["edPriv"], "edPub": me.device["edPub"]},
|
|
59
|
+
ProvisionDeviceOpts(
|
|
60
|
+
scope=scopes.root_all(), # REQUIRED — or a narrower scope to bound the device
|
|
61
|
+
ttl_sec=7 * 24 * 3600, # optional, default 30 days
|
|
62
|
+
# current_epoch_by_collection={"notes": {"epoch": e, "cek": cek}}, # optional
|
|
63
|
+
),
|
|
64
|
+
)
|
|
65
|
+
setup_code = json.dumps(provisioned.to_dict()) # hand off out-of-band
|
|
66
|
+
|
|
67
|
+
# New device: install the blob (uses the keys carried inside it).
|
|
68
|
+
installed = install_provisioned_device(ProvisionedDevice.from_dict(json.loads(setup_code)))
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- **`scope` is required** — provisioning never silently grants root. Pass
|
|
72
|
+
`scopes.root_all()` for a full account clone, or a narrower scope to bound the
|
|
73
|
+
device. The server enforces it: a cap whose `ops` omit `write` synthesizes no
|
|
74
|
+
write role, so writes return 403.
|
|
75
|
+
- **`current_epoch_by_collection`** wraps existing CEKs into the bundle so the new
|
|
76
|
+
device can read existing ciphertext (it lives inside the opts here, whereas
|
|
77
|
+
`assemble_pairing_bundle` takes it as a positional argument).
|
|
78
|
+
- **Security:** the new device's **private keys are generated off-device** and
|
|
79
|
+
travel inside the result. Whoever reads the blob owns a full clone of the
|
|
80
|
+
device. Use one-way provisioning only over a channel you would trust with the
|
|
81
|
+
collection keys themselves; prefer the two-way QR / relay flow otherwise.
|
|
82
|
+
|
|
83
|
+
## QR-in / auto-return pairing (anonymous rendezvous)
|
|
84
|
+
|
|
85
|
+
For a device that **can't scan** (e.g. a laptop), keep the camera on the *root*
|
|
86
|
+
device: the new device shows its QR, the root scans it and pushes the assembled
|
|
87
|
+
bundle to a small **anonymous, TTL'd rendezvous slot**, and the new device fetches
|
|
88
|
+
it with a single trigger — no manual bundle-back, no polling. The new device is
|
|
89
|
+
credential-less (no cap-cert yet), so it reaches the public slot with an anonymous
|
|
90
|
+
client; this is safe because the bundle's CEKs are E2E-wrapped to the new device's
|
|
91
|
+
KEM and the channel needs only delivery.
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from starfish_identities import (
|
|
95
|
+
build_pairing_qr, parse_pairing_qr, assemble_pairing_bundle, install_pairing_bundle,
|
|
96
|
+
push_pairing_bundle, fetch_pairing_bundle, clear_pairing_bundle, generate_device_keys,
|
|
97
|
+
AssemblePairingBundleOpts, scopes,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# New device: show a QR (carries qr_nonce); anon_client has no cap_provider.
|
|
101
|
+
dev = generate_device_keys()
|
|
102
|
+
qr = build_pairing_qr(dev["edPub"], dev["kemPub"], requested_scope, qr_nonce=qr_nonce_bytes)
|
|
103
|
+
|
|
104
|
+
# Root device: parse, assemble, and push to the rendezvous slot.
|
|
105
|
+
parsed = parse_pairing_qr(qr)
|
|
106
|
+
bundle = assemble_pairing_bundle(
|
|
107
|
+
root_ed_key, parsed, {}, # third arg is per-collection CEKs, if any
|
|
108
|
+
opts=AssemblePairingBundleOpts(granted_scope=scopes.root_all()), # REQUIRED
|
|
109
|
+
)
|
|
110
|
+
await push_pairing_bundle(anon_client, parsed.qr_nonce, bundle)
|
|
111
|
+
|
|
112
|
+
# New device, on a single "Added from root" click — None means "not there yet".
|
|
113
|
+
bundle2 = await fetch_pairing_bundle(anon_client, qr_nonce)
|
|
114
|
+
if bundle2 is not None:
|
|
115
|
+
installed = install_pairing_bundle(
|
|
116
|
+
bundle2, dev,
|
|
117
|
+
expected_qr_nonce=qr_nonce,
|
|
118
|
+
expected_root_ed_pub=known_root_pub, # pin: rejects a bundle from a different root
|
|
119
|
+
)
|
|
120
|
+
await clear_pairing_bundle(anon_client, qr_nonce) # best-effort one-shot
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
- **`granted_scope` is required.** The QR-supplied `requested_scope` is
|
|
124
|
+
attacker-influenceable, so callers **must** pass an explicit `granted_scope` to
|
|
125
|
+
bound the delegated authority — `assemble_pairing_bundle` fails closed (raises
|
|
126
|
+
`ValueError`) without it rather than defaulting to the requested scope.
|
|
127
|
+
- **`expected_root_ed_pub`** pins the issuer so an attacker's own root can't answer
|
|
128
|
+
an open rendezvous and provision the device into *their* account. When the new
|
|
129
|
+
device doesn't know the root pubkey, show its fingerprint for the user to verify.
|
|
130
|
+
- The slot is keyed by `rendezvous_path_for(qr_nonce)` = `_pairing/<hex(qr_nonce)>`
|
|
131
|
+
— no new QR field; `qr_nonce` was never secret. The app/server provides the
|
|
132
|
+
collection: `encryption="none"`, `public` read/write, a short `ttl_ms`, a tight
|
|
133
|
+
body cap; one-shot is the new device overwriting the slot after install.
|
|
134
|
+
|
|
135
|
+
## Same identity on every device (the simplest multi-device model)
|
|
136
|
+
|
|
137
|
+
Pairing/provisioning give each device its own key pair (so you can revoke one device), but a
|
|
138
|
+
per-device key is **not** a recipient in rooms owned by *other* people — its bundle CEK is a
|
|
139
|
+
snapshot that goes stale on the owner's next epoch rotation, and a member can't enroll its own
|
|
140
|
+
device in someone else's keyring. The simplest way to give every device the **same** membership is
|
|
141
|
+
to skip pairing and **re-enter the passphrase**: `bootstrap_root_identity` is deterministic, so each
|
|
142
|
+
device re-derives the identical root identity and is the *same principal and KEM recipient*. It can
|
|
143
|
+
then present any cap minted to your root (including member caps), unwrap any CEK wrapped to your
|
|
144
|
+
root KEM, and **keep reading across the owner's rotations** (every device re-derives the same key).
|
|
145
|
+
The trade-off: the master key lives on every device, so there is **no per-device revocation** and
|
|
146
|
+
losing any device compromises the account. The member-cap JSON others issued you and the room list
|
|
147
|
+
are app-level state that must still travel to the new device. See
|
|
148
|
+
[`docs/ts/client/24-pairing.md` §5](../../../docs/ts/client/24-pairing.md).
|
|
149
|
+
|
|
150
|
+
## Sealing a setup code with a passphrase
|
|
151
|
+
|
|
152
|
+
A one-way setup code (or any secret blob) can be sealed under a user-chosen
|
|
153
|
+
PIN/passphrase so the code alone is useless if intercepted — send the PIN over a
|
|
154
|
+
**different channel** than the code:
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
from starfish_identities import seal_with_passphrase, open_with_passphrase, is_sealed_envelope
|
|
158
|
+
|
|
159
|
+
env = seal_with_passphrase(pin, setup_code_json.encode("utf-8")) # JSON-serialisable dict
|
|
160
|
+
# hand over json.dumps(env); transmit the PIN separately
|
|
161
|
+
|
|
162
|
+
if is_sealed_envelope(parsed):
|
|
163
|
+
data = open_with_passphrase(pin, parsed) # one generic ValueError on any failure
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`key = Argon2id(NFC(passphrase), random-salt)` → AES-256-GCM. `open_with_passphrase`
|
|
167
|
+
validates the KDF parameters **before** running Argon2id (so a hostile envelope
|
|
168
|
+
can't force a multi-GiB computation) and collapses every failure — wrong
|
|
169
|
+
passphrase, tampering, bad params — into one generic error. Strength is bounded by
|
|
170
|
+
passphrase entropy: a short numeric PIN is still offline-brute-forceable, so the
|
|
171
|
+
seal buys a revocation window, not permanent safety. Byte-identical to the
|
|
172
|
+
TypeScript `sealWithPassphrase`, locked by `tests/test-vectors/passphrase-seal.json`.
|
|
173
|
+
|
|
174
|
+
## Server plugin
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
from starfish_server import create_cap_cert_role_resolver
|
|
178
|
+
from starfish_identities import identities_server_plugin
|
|
179
|
+
|
|
180
|
+
resolver = create_cap_cert_role_resolver(
|
|
181
|
+
nonce_cache=nonce_cache,
|
|
182
|
+
revocation_store=revocation_store,
|
|
183
|
+
plugins=[identities_server_plugin],
|
|
184
|
+
)
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
See `docs/python/identities/` (and the TypeScript counterpart in `docs/ts/identities/`) for the full guide.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "starfish-identities"
|
|
7
|
+
version = "3.0.0a8"
|
|
8
|
+
description = "Starfish root + device identity extension (passphrase derivation, device cap-cert minting, pairing flows, device directory)"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"cryptography>=41.0",
|
|
12
|
+
"argon2-cffi>=25.1.0",
|
|
13
|
+
"starfish-protocol",
|
|
14
|
+
"starfish-sdk",
|
|
15
|
+
"starfish-keyring",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
dev = [
|
|
20
|
+
"pytest>=7.0",
|
|
21
|
+
"pytest-asyncio>=0.21",
|
|
22
|
+
"respx>=0.23.1",
|
|
23
|
+
"starfish-server",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[tool.uv.sources]
|
|
27
|
+
starfish-protocol = { path = "../protocol", editable = true }
|
|
28
|
+
starfish-sdk = { path = "../client", editable = true }
|
|
29
|
+
starfish-keyring = { path = "../keyring", editable = true }
|
|
30
|
+
starfish-server = { path = "../server", editable = true }
|
|
31
|
+
|
|
32
|
+
[tool.pytest.ini_options]
|
|
33
|
+
asyncio_mode = "auto"
|
|
34
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""``starfish-identities`` — root + device identity extension.
|
|
2
|
+
|
|
3
|
+
Public surface: passphrase-derived root identities, device cap-cert
|
|
4
|
+
minting, the ``scopes.root_all`` preset, all pairing flows (QR +
|
|
5
|
+
server-relay), the per-user device directory, and the server plugin.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from starfish_identities.identity import (
|
|
9
|
+
RootIdentity,
|
|
10
|
+
RootKeyPair,
|
|
11
|
+
derive_root_identity,
|
|
12
|
+
)
|
|
13
|
+
from starfish_identities.cap_mint import (
|
|
14
|
+
MintOpts,
|
|
15
|
+
ScopePreset,
|
|
16
|
+
mint_device_cap,
|
|
17
|
+
scopes,
|
|
18
|
+
)
|
|
19
|
+
from starfish_identities.pairing import (
|
|
20
|
+
AssemblePairingBundleOpts,
|
|
21
|
+
DeviceCredentials,
|
|
22
|
+
InstalledPairingResult,
|
|
23
|
+
PairingBundle,
|
|
24
|
+
PairingQrPayload,
|
|
25
|
+
PairingRequestEncrypted,
|
|
26
|
+
PairingResponseEncrypted,
|
|
27
|
+
ProvisionDeviceOpts,
|
|
28
|
+
ProvisionedDevice,
|
|
29
|
+
RecoveredCek,
|
|
30
|
+
WrappedCekEntry,
|
|
31
|
+
assemble_pairing_bundle,
|
|
32
|
+
bootstrap_root_identity,
|
|
33
|
+
build_pairing_qr,
|
|
34
|
+
build_pairing_request,
|
|
35
|
+
build_pairing_response,
|
|
36
|
+
derive_code_key,
|
|
37
|
+
generate_device_keys,
|
|
38
|
+
install_pairing_bundle,
|
|
39
|
+
install_provisioned_device,
|
|
40
|
+
parse_pairing_qr,
|
|
41
|
+
provision_device,
|
|
42
|
+
read_pairing_request,
|
|
43
|
+
read_pairing_response,
|
|
44
|
+
)
|
|
45
|
+
from starfish_identities.rendezvous import (
|
|
46
|
+
RENDEZVOUS_PREFIX,
|
|
47
|
+
clear_pairing_bundle,
|
|
48
|
+
fetch_pairing_bundle,
|
|
49
|
+
push_pairing_bundle,
|
|
50
|
+
rendezvous_path_for,
|
|
51
|
+
)
|
|
52
|
+
from starfish_identities.seal import (
|
|
53
|
+
is_sealed_envelope,
|
|
54
|
+
open_with_passphrase,
|
|
55
|
+
seal_with_passphrase,
|
|
56
|
+
)
|
|
57
|
+
from starfish_identities.directory import (
|
|
58
|
+
Directory,
|
|
59
|
+
DirectoryEntry,
|
|
60
|
+
ListDirectoryOpts,
|
|
61
|
+
add_device_entry,
|
|
62
|
+
devices_path_for,
|
|
63
|
+
list_devices,
|
|
64
|
+
remove_device_entry,
|
|
65
|
+
)
|
|
66
|
+
# Re-exported from the protocol layer: distinguishes a self-signed root-device
|
|
67
|
+
# cap from delegated devices/members (used by server-side root-only collections).
|
|
68
|
+
from starfish_protocol import is_root_device_cap
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def __getattr__(name: str):
|
|
72
|
+
"""Lazy import of ``identities_server_plugin`` so apps that only use the
|
|
73
|
+
client-side helpers don't pay the ``starfish_server`` import cost.
|
|
74
|
+
"""
|
|
75
|
+
if name == "identities_server_plugin":
|
|
76
|
+
from starfish_identities.plugin import identities_server_plugin as _p
|
|
77
|
+
return _p
|
|
78
|
+
raise AttributeError(f"module 'starfish_identities' has no attribute {name!r}")
|
|
79
|
+
|
|
80
|
+
__all__ = [
|
|
81
|
+
"RootIdentity",
|
|
82
|
+
"RootKeyPair",
|
|
83
|
+
"derive_root_identity",
|
|
84
|
+
"MintOpts",
|
|
85
|
+
"ScopePreset",
|
|
86
|
+
"mint_device_cap",
|
|
87
|
+
"scopes",
|
|
88
|
+
"AssemblePairingBundleOpts",
|
|
89
|
+
"DeviceCredentials",
|
|
90
|
+
"InstalledPairingResult",
|
|
91
|
+
"PairingBundle",
|
|
92
|
+
"PairingQrPayload",
|
|
93
|
+
"PairingRequestEncrypted",
|
|
94
|
+
"PairingResponseEncrypted",
|
|
95
|
+
"ProvisionDeviceOpts",
|
|
96
|
+
"ProvisionedDevice",
|
|
97
|
+
"RecoveredCek",
|
|
98
|
+
"WrappedCekEntry",
|
|
99
|
+
"assemble_pairing_bundle",
|
|
100
|
+
"bootstrap_root_identity",
|
|
101
|
+
"build_pairing_qr",
|
|
102
|
+
"build_pairing_request",
|
|
103
|
+
"build_pairing_response",
|
|
104
|
+
"derive_code_key",
|
|
105
|
+
"generate_device_keys",
|
|
106
|
+
"install_pairing_bundle",
|
|
107
|
+
"install_provisioned_device",
|
|
108
|
+
"parse_pairing_qr",
|
|
109
|
+
"provision_device",
|
|
110
|
+
"read_pairing_request",
|
|
111
|
+
"read_pairing_response",
|
|
112
|
+
"RENDEZVOUS_PREFIX",
|
|
113
|
+
"rendezvous_path_for",
|
|
114
|
+
"push_pairing_bundle",
|
|
115
|
+
"fetch_pairing_bundle",
|
|
116
|
+
"clear_pairing_bundle",
|
|
117
|
+
"seal_with_passphrase",
|
|
118
|
+
"open_with_passphrase",
|
|
119
|
+
"is_sealed_envelope",
|
|
120
|
+
"Directory",
|
|
121
|
+
"DirectoryEntry",
|
|
122
|
+
"ListDirectoryOpts",
|
|
123
|
+
"add_device_entry",
|
|
124
|
+
"devices_path_for",
|
|
125
|
+
"list_devices",
|
|
126
|
+
"remove_device_entry",
|
|
127
|
+
"identities_server_plugin",
|
|
128
|
+
"is_root_device_cap",
|
|
129
|
+
]
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Device cap-cert minting helpers (Python mirror of the TS
|
|
2
|
+
``identities/cap-mint`` module).
|
|
3
|
+
|
|
4
|
+
Higher-level convenience over :func:`starfish_protocol.sign_cap_cert`. The
|
|
5
|
+
mint helper does the boilerplate (build the unsigned cert, derive
|
|
6
|
+
``issUserId`` from the issuer pubkey, fill ``nbf``/``exp``, generate the
|
|
7
|
+
nonce, run the well-formedness check, and finally sign).
|
|
8
|
+
|
|
9
|
+
Member-side helpers (``mint_member_cap`` + ``scopes.read_only``/``writer``/
|
|
10
|
+
``admin``) live in ``starfish_sharing.cap_mint``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import hashlib
|
|
17
|
+
import os
|
|
18
|
+
import time
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Any, Optional, TypedDict
|
|
21
|
+
|
|
22
|
+
from starfish_protocol.cap import (
|
|
23
|
+
CapCert,
|
|
24
|
+
assert_cap_cert_well_formed,
|
|
25
|
+
sign_cap_cert,
|
|
26
|
+
)
|
|
27
|
+
from starfish_protocol.suites import Alg, DEFAULT_ALG, suite_has_separate_kem
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ScopePreset(TypedDict, total=False):
|
|
31
|
+
"""Operations + paths + collections a minted cap-cert authorizes."""
|
|
32
|
+
|
|
33
|
+
ops: list[str]
|
|
34
|
+
collections: list[str]
|
|
35
|
+
paths: list[str]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class MintOpts:
|
|
40
|
+
"""Optional knobs for the mint helpers."""
|
|
41
|
+
|
|
42
|
+
ttl_sec: Optional[int] = None
|
|
43
|
+
nbf: Optional[int] = None
|
|
44
|
+
nonce: Optional[bytes] = None
|
|
45
|
+
# Issuer's crypto suite (governs the cap signature). Defaults to system default.
|
|
46
|
+
alg: Alg = DEFAULT_ALG
|
|
47
|
+
# Subject's signing suite (governs sub + per-request sigs). Defaults to ``alg``.
|
|
48
|
+
sub_alg: Optional[Alg] = None
|
|
49
|
+
# Subject's KEM suite (governs subKem). Defaults to ``sub_alg``.
|
|
50
|
+
sub_kem_alg: Optional[Alg] = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class scopes:
|
|
54
|
+
"""Built-in scope presets — identities side."""
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
def root_all() -> ScopePreset:
|
|
58
|
+
"""Root-grade access to everything — used for device caps."""
|
|
59
|
+
return {
|
|
60
|
+
"ops": ["read", "list", "write"],
|
|
61
|
+
"paths": ["**"],
|
|
62
|
+
"collections": ["*"],
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
_DEFAULT_TTL_SEC = 30 * 24 * 3600
|
|
67
|
+
_NONCE_LEN = 16
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _user_id_from_pub_hex(pub_hex: str) -> str:
|
|
71
|
+
return hashlib.sha256(bytes.fromhex(pub_hex)).hexdigest()[:32]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _resolve_nbf_exp(opts: Optional[MintOpts]) -> tuple[int, int, bytes]:
|
|
75
|
+
nbf = opts.nbf if opts is not None and opts.nbf is not None else int(time.time())
|
|
76
|
+
ttl = opts.ttl_sec if opts is not None and opts.ttl_sec is not None else _DEFAULT_TTL_SEC
|
|
77
|
+
exp = nbf + ttl
|
|
78
|
+
nonce = opts.nonce if opts is not None and opts.nonce is not None else os.urandom(_NONCE_LEN)
|
|
79
|
+
return nbf, exp, nonce
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def mint_device_cap(
|
|
83
|
+
iss_ed_priv_hex: str,
|
|
84
|
+
iss_ed_pub_hex: str,
|
|
85
|
+
sub: dict[str, str],
|
|
86
|
+
scope: ScopePreset | dict[str, Any],
|
|
87
|
+
opts: Optional[MintOpts] = None,
|
|
88
|
+
) -> CapCert:
|
|
89
|
+
"""Mint a ``device`` cap-cert: the subject acts as a proxy for the issuer.
|
|
90
|
+
|
|
91
|
+
``sub`` must include ``edPubHex`` and ``kemPubHex``.
|
|
92
|
+
|
|
93
|
+
Raises :class:`ValueError` (with the well-formedness code as
|
|
94
|
+
``args[0]``) on malformed input.
|
|
95
|
+
"""
|
|
96
|
+
nbf, exp, nonce_bytes = _resolve_nbf_exp(opts)
|
|
97
|
+
iss_alg: Alg = opts.alg if opts is not None else DEFAULT_ALG
|
|
98
|
+
sub_alg: Alg = (opts.sub_alg if opts is not None and opts.sub_alg is not None else iss_alg)
|
|
99
|
+
sub_kem_alg: Alg = (
|
|
100
|
+
opts.sub_kem_alg if opts is not None and opts.sub_kem_alg is not None else sub_alg
|
|
101
|
+
)
|
|
102
|
+
# subKem is omitted only when the KEM key IS the signing key (same-suite
|
|
103
|
+
# single-key suite); otherwise it carries a distinct KEM pubkey of suite
|
|
104
|
+
# `sub_kem_alg`. The keyring now wraps under any suite's ECDH
|
|
105
|
+
# (``recipient_kem``), so every ``sub_kem_alg`` is mintable.
|
|
106
|
+
kem_key_is_sign_key = sub_kem_alg == sub_alg and not suite_has_separate_kem(sub_kem_alg)
|
|
107
|
+
unsigned: dict[str, Any] = {
|
|
108
|
+
"v": 1,
|
|
109
|
+
"kind": "device",
|
|
110
|
+
"issAlg": iss_alg,
|
|
111
|
+
"subAlg": sub_alg,
|
|
112
|
+
"iss": iss_ed_pub_hex,
|
|
113
|
+
"issUserId": _user_id_from_pub_hex(iss_ed_pub_hex),
|
|
114
|
+
"sub": sub["edPubHex"],
|
|
115
|
+
"scope": dict(scope),
|
|
116
|
+
"nbf": nbf,
|
|
117
|
+
"exp": exp,
|
|
118
|
+
"nonce": base64.b64encode(nonce_bytes).decode("ascii"),
|
|
119
|
+
}
|
|
120
|
+
if sub_kem_alg != sub_alg:
|
|
121
|
+
unsigned["subKemAlg"] = sub_kem_alg
|
|
122
|
+
if not kem_key_is_sign_key:
|
|
123
|
+
unsigned["subKem"] = sub["kemPubHex"]
|
|
124
|
+
assert_cap_cert_well_formed(unsigned)
|
|
125
|
+
return sign_cap_cert(unsigned, iss_ed_priv_hex) # type: ignore[return-value]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
__all__ = [
|
|
129
|
+
"MintOpts",
|
|
130
|
+
"ScopePreset",
|
|
131
|
+
"mint_device_cap",
|
|
132
|
+
"scopes",
|
|
133
|
+
]
|