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.
Files changed (25) hide show
  1. starfish_identities-3.0.0a8/PKG-INFO +15 -0
  2. starfish_identities-3.0.0a8/README.md +187 -0
  3. starfish_identities-3.0.0a8/pyproject.toml +34 -0
  4. starfish_identities-3.0.0a8/setup.cfg +4 -0
  5. starfish_identities-3.0.0a8/starfish_identities/__init__.py +129 -0
  6. starfish_identities-3.0.0a8/starfish_identities/cap_mint.py +133 -0
  7. starfish_identities-3.0.0a8/starfish_identities/directory.py +242 -0
  8. starfish_identities-3.0.0a8/starfish_identities/identity.py +155 -0
  9. starfish_identities-3.0.0a8/starfish_identities/pairing.py +894 -0
  10. starfish_identities-3.0.0a8/starfish_identities/plugin.py +27 -0
  11. starfish_identities-3.0.0a8/starfish_identities/rendezvous.py +128 -0
  12. starfish_identities-3.0.0a8/starfish_identities/seal.py +164 -0
  13. starfish_identities-3.0.0a8/starfish_identities.egg-info/PKG-INFO +15 -0
  14. starfish_identities-3.0.0a8/starfish_identities.egg-info/SOURCES.txt +23 -0
  15. starfish_identities-3.0.0a8/starfish_identities.egg-info/dependency_links.txt +1 -0
  16. starfish_identities-3.0.0a8/starfish_identities.egg-info/requires.txt +11 -0
  17. starfish_identities-3.0.0a8/starfish_identities.egg-info/top_level.txt +1 -0
  18. starfish_identities-3.0.0a8/tests/test_cap_mint.py +93 -0
  19. starfish_identities-3.0.0a8/tests/test_client_cap_headers.py +190 -0
  20. starfish_identities-3.0.0a8/tests/test_directory.py +291 -0
  21. starfish_identities-3.0.0a8/tests/test_identity_v3.py +66 -0
  22. starfish_identities-3.0.0a8/tests/test_pairing.py +541 -0
  23. starfish_identities-3.0.0a8/tests/test_rendezvous.py +115 -0
  24. starfish_identities-3.0.0a8/tests/test_seal.py +172 -0
  25. 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ ]