starfish-identities
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.
Install
pip install starfish-sdk starfish-keyring starfish-identities
Usage
from starfish_identities import bootstrap_root_identity, mint_device_cap, scopes, add_device_entry, list_devices
me = bootstrap_root_identity("correct horse battery staple")
Root identity derivation
bootstrap_root_identity / derive_root_identity derive the root keypairs
deterministically from the passphrase, so the same human re-derives them on any
device with no stored secret:
- Argon2id (memory-hard) stretches the passphrase into a 32-byte master —
m=47104 KiB ≈ 46 MiB, t=3, p=1, global salt"starfish-v3-root"(derivation must be reproducible from the passphrase alone, so the salt cannot be per-user). Locked asARGON2_PARAMS. - HKDF-SHA256 expands the master into two domain-separated seeds: an Ed25519
signing key (
info="ed25519") and an X25519 KEM key (info="x25519") — used for exactly one of {sign, key-agreement} each, never both. user_id = sha256(root_ed_pub)[0:32].
Byte-identical to the TypeScript derivation; locked by
tests/test-vectors/identity-derivation.json.
One-way device provisioning (configurable caps + expiry)
The QR / server-relay flows are two-way: the new device generates its own
keypair and sends a request back. provision_device is the one-way alternative
— the root device plays both roles, producing a single hand-off blob — and it is
where you choose what the new device may do (scope) and how long its cap
lives (ttl_sec). The same knobs exist on the two-way flow via
AssemblePairingBundleOpts(granted_scope=..., ttl_sec=...).
import json
from starfish_identities import (
provision_device,
install_provisioned_device,
ProvisionDeviceOpts,
ProvisionedDevice,
scopes,
)
# Root device: generate the new device, mint its cap with a chosen scope + exp.
provisioned = provision_device(
{"edPriv": me.device["edPriv"], "edPub": me.device["edPub"]},
ProvisionDeviceOpts(
scope=scopes.root_all(), # REQUIRED — or a narrower scope to bound the device
ttl_sec=7 * 24 * 3600, # optional, default 30 days
# current_epoch_by_collection={"notes": {"epoch": e, "cek": cek}}, # optional
),
)
setup_code = json.dumps(provisioned.to_dict()) # hand off out-of-band
# New device: install the blob (uses the keys carried inside it).
installed = install_provisioned_device(ProvisionedDevice.from_dict(json.loads(setup_code)))
scopeis required — provisioning never silently grants root. Passscopes.root_all()for a full account clone, or a narrower scope to bound the device. The server enforces it: a cap whoseopsomitwritesynthesizes no write role, so writes return 403.current_epoch_by_collectionwraps existing CEKs into the bundle so the new device can read existing ciphertext (it lives inside the opts here, whereasassemble_pairing_bundletakes it as a positional argument).- Security: the new device's private keys are generated off-device and travel inside the result. Whoever reads the blob owns a full clone of the device. Use one-way provisioning only over a channel you would trust with the collection keys themselves; prefer the two-way QR / relay flow otherwise.
QR-in / auto-return pairing (anonymous rendezvous)
For a device that can't scan (e.g. a laptop), keep the camera on the root device: the new device shows its QR, the root scans it and pushes the assembled bundle to a small anonymous, TTL'd rendezvous slot, and the new device fetches it with a single trigger — no manual bundle-back, no polling. The new device is credential-less (no cap-cert yet), so it reaches the public slot with an anonymous client; this is safe because the bundle's CEKs are E2E-wrapped to the new device's KEM and the channel needs only delivery.
from starfish_identities import (
build_pairing_qr, parse_pairing_qr, assemble_pairing_bundle, install_pairing_bundle,
push_pairing_bundle, fetch_pairing_bundle, clear_pairing_bundle, generate_device_keys,
AssemblePairingBundleOpts, scopes,
)
# New device: show a QR (carries qr_nonce); anon_client has no cap_provider.
dev = generate_device_keys()
qr = build_pairing_qr(dev["edPub"], dev["kemPub"], requested_scope, qr_nonce=qr_nonce_bytes)
# Root device: parse, assemble, and push to the rendezvous slot.
parsed = parse_pairing_qr(qr)
bundle = assemble_pairing_bundle(
root_ed_key, parsed, {}, # third arg is per-collection CEKs, if any
opts=AssemblePairingBundleOpts(granted_scope=scopes.root_all()), # REQUIRED
)
await push_pairing_bundle(anon_client, parsed.qr_nonce, bundle)
# New device, on a single "Added from root" click — None means "not there yet".
bundle2 = await fetch_pairing_bundle(anon_client, qr_nonce)
if bundle2 is not None:
installed = install_pairing_bundle(
bundle2, dev,
expected_qr_nonce=qr_nonce,
expected_root_ed_pub=known_root_pub, # pin: rejects a bundle from a different root
)
await clear_pairing_bundle(anon_client, qr_nonce) # best-effort one-shot
granted_scopeis required. The QR-suppliedrequested_scopeis attacker-influenceable, so callers must pass an explicitgranted_scopeto bound the delegated authority —assemble_pairing_bundlefails closed (raisesValueError) without it rather than defaulting to the requested scope.expected_root_ed_pubpins the issuer so an attacker's own root can't answer an open rendezvous and provision the device into their account. When the new device doesn't know the root pubkey, show its fingerprint for the user to verify.- The slot is keyed by
rendezvous_path_for(qr_nonce)=_pairing/<hex(qr_nonce)>— no new QR field;qr_noncewas never secret. The app/server provides the collection:encryption="none",publicread/write, a shortttl_ms, a tight body cap; one-shot is the new device overwriting the slot after install.
Same identity on every device (the simplest multi-device model)
Pairing/provisioning give each device its own key pair (so you can revoke one device), but a
per-device key is not a recipient in rooms owned by other people — its bundle CEK is a
snapshot that goes stale on the owner's next epoch rotation, and a member can't enroll its own
device in someone else's keyring. The simplest way to give every device the same membership is
to skip pairing and re-enter the passphrase: bootstrap_root_identity is deterministic, so each
device re-derives the identical root identity and is the same principal and KEM recipient. It can
then present any cap minted to your root (including member caps), unwrap any CEK wrapped to your
root KEM, and keep reading across the owner's rotations (every device re-derives the same key).
The trade-off: the master key lives on every device, so there is no per-device revocation and
losing any device compromises the account. The member-cap JSON others issued you and the room list
are app-level state that must still travel to the new device. See
docs/ts/client/24-pairing.md §5.
Sealing a setup code with a passphrase
A one-way setup code (or any secret blob) can be sealed under a user-chosen PIN/passphrase so the code alone is useless if intercepted — send the PIN over a different channel than the code:
from starfish_identities import seal_with_passphrase, open_with_passphrase, is_sealed_envelope
env = seal_with_passphrase(pin, setup_code_json.encode("utf-8")) # JSON-serialisable dict
# hand over json.dumps(env); transmit the PIN separately
if is_sealed_envelope(parsed):
data = open_with_passphrase(pin, parsed) # one generic ValueError on any failure
key = Argon2id(NFC(passphrase), random-salt) → AES-256-GCM. open_with_passphrase
validates the KDF parameters before running Argon2id (so a hostile envelope
can't force a multi-GiB computation) and collapses every failure — wrong
passphrase, tampering, bad params — into one generic error. Strength is bounded by
passphrase entropy: a short numeric PIN is still offline-brute-forceable, so the
seal buys a revocation window, not permanent safety. Byte-identical to the
TypeScript sealWithPassphrase, locked by tests/test-vectors/passphrase-seal.json.
Server plugin
from starfish_server import create_cap_cert_role_resolver
from starfish_identities import identities_server_plugin
resolver = create_cap_cert_role_resolver(
nonce_cache=nonce_cache,
revocation_store=revocation_store,
plugins=[identities_server_plugin],
)
See docs/python/identities/ (and the TypeScript counterpart in docs/ts/identities/) for the full guide.