Platform
ScaiWave ScaiGrid ScaiCore ScaiBot ScaiDrive ScaiKey Models Tools & Services
Solutions
Organisations Developers Internet Service Providers Managed Service Providers AI-in-a-Box
Resources
Support Documentation Blog Downloads
Company
About Research Careers Investment Opportunities Contact
Log in

Devices

ScaiKey provides a per-user device registry that holds long-lived asymmetric public key material. It's the identity substrate for end-to-end-encryption features — the initial driver is ScaiClip but the shape is protocol-agnostic and any ScaiLabs product that needs "encrypt something so only this specific device can open it" can consume the same registry.

Two key ideas make this different from anything else in ScaiKey:

  • A device is a first-class principal with its own long-lived keypairs, separate from sessions, from MFA authenticators, and from OAuth clients. A user's laptop, phone, and browser tabs each get their own device row.
  • Private keys never leave the enrolling device. The device generates both keypairs locally (Secure Enclave, TPM, Android Keystore, iOS Keychain where the OS allows) and publishes only the public halves. ScaiKey is a directory; it does no encryption itself.

Cryptographic shape#

Each device holds two public keys:

Key Purpose Curve / algorithm
kem_pub HPKE wraps — encrypt payloads to this device X25519 (RFC 7748) with HPKE (RFC 9180, DHKEM(X25519, HKDF-SHA256))
sig_pub Envelope signature verification — verify signed payloads from this device Ed25519 (RFC 8032)

Both are exactly 32 bytes on the wire. ScaiKey validates wire format at enrolment and rotation — wrong-length, all-zeros, and known small-order Curve25519 points are rejected with a 400 INVALID_PUBLIC_KEY response identifying which algorithm and reason.

The wire format inside JSON is base64. Both url-safe (A-Za-z0-9_-) and standard (A-Za-z0-9+/) alphabets are accepted on input, with or without padding. ScaiKey emits url-safe base64 without padding on output.

Lifecycle#

scdoc
1
enrol → (heartbeat, rotate, revoke)*
  • Enrol (POST /me/devices) — the device generates both keypairs, publishes public halves. Fires device.registered.
  • Rotate (PUT /me/devices/{id}/keys) — replace both public keys atomically. key_version bumps by one, keys_rotated_at updates. Fires device.keys.rotated. The old public keys are overwritten; ScaiKey does not maintain a history table because the client is authoritative for how to handle in-flight ciphertexts wrapped with the old key.
  • Heartbeat (POST /me/devices/{id}/heartbeat) — updates last_seen. No webhook fires; heartbeats are noisy by construction.
  • Revoke (DELETE /me/devices/{id} or POST /admin/users/{uid}/devices/{did}/revoke) — soft-delete. The row stays in the table so audit can reconstruct history; directory lookups filter it out. Fires device.revoked.

Revocation is soft-delete only; there is no hard-delete path. Devices retained under revoked_at are visible via include_revoked=true on the list endpoints and remain in audit history indefinitely.

Presence#

Presence is push-based. There are three heartbeat endpoints, differing in auth and shape:

  • POST /me/devices/{id}/heartbeat — session-authenticated, device pushes its own liveness.
  • POST /devices/heartbeat — service-authenticated, upstream reports a batch of device liveness in one call. This is the primary shape for high-fan-out gateways: one call per cadence carrying every observed device_id.
  • POST /devices/{id}/heartbeat — service-authenticated single-device, for tests and low-fan-out tools.

All three update last_seen. Consumers reading device records get an online: bool field computed server-side from last_seen and a configurable staleness threshold:

maple
1
online := now - last_seen  settings.devices.online_threshold_seconds

Default threshold is 90 seconds — 3× tolerance for a 30-second heartbeat cadence. Operators can override via the SCAIKEY_DEVICES__ONLINE_THRESHOLD_SECONDS environment variable. A device that has never heartbeated (last_seen: null) reports online: false, even if it was enrolled seconds ago.

There is no server-derived presence from session activity today. If your consumer needs stronger presence guarantees than heartbeat cadence supports, subscribe to the device.* webhooks and maintain your own state.

Directory queries#

Two query shapes serve the "look up the public keys for a set of principals" pattern:

Bulk by subjects — for channel-membership dispatch. Callers list up to 100 user subjects; ScaiKey returns all their live devices in one round trip.

scdoc
1
GET /tenants/{tenant_id}/devices?subjects=usr_alice,usr_bob,urn:scaikey:acme:usr:usr_carol

Subjects accept the stable usr_… ID (canonical, matches token subjects and webhook resource IDs) or the URN form urn:scaikey:{tenant_slug}:usr:{user_id} (compatibility shape for SCWP-native callers). Both resolve to the same user. Unknown or malformed subjects are surfaced in an unknown_subjects array alongside the successful data list, so a caller can distinguish "user has no devices" from "input was garbled."

Transitive by group — for team channels. Given a group, walk descendants via nested-group containment, return the union of every effective member's live devices.

scdoc
1
GET /tenants/{tenant_id}/groups/{group_id}/devices

The group graph is cycle-safe and capped at 1024 groups per walk. Response includes effective_group_count so a caller can eyeball whether the traversal found what they expected.

Both queries are same-tenant-only by default. Cross-tenant lookups return 403 CROSS_TENANT_DENIED unless the caller is a live super-admin — the same guard that gates the rest of the directory namespace. If you need cross-tenant channels, a directory:read:cross_tenant scope is on the roadmap.

Webhooks#

Three events, same envelope shape as the rest of the ScaiKey event bus:

  • device.registered — payload includes user_id, device_id, kem_pub, sig_pub, key_version, kind, platform.
  • device.keys.rotateduser_id, device_id, kem_pub, sig_pub, key_version, keys_rotated_at.
  • device.revokeduser_id, device_id, revoked_reason, revoked_at. The webhook envelope's actor_type field is user for self-revoke and admin for operator-initiated revocation — downstream consumers use this to distinguish an ordinary "lost laptop" revoke from an operator kill-switch that may want a wider rekey fan-out.

Subscribe to device.* to catch all three; the delivery worker handles wildcards.

What ScaiKey does NOT do#

  • No private-key storage. The two private keys never touch ScaiKey. Hardware-backed storage is the enrolling device's concern.
  • No HPKE seal / open. ScaiKey validates the wire format of public keys and stores them. All encrypt / decrypt happens on your side.
  • No channel keys, content keys, or session keys. Those are the consumer's concern (ScaiClip's channel-key ratcheting, for example).
  • No per-request device attestation. Devices identify to ScaiKey at enrolment via the user's session; requests stay user-authenticated. If your product needs per-request "prove you're still this device", raise it as a separate ask.
  • No cascade into sessions / refresh tokens on revoke. A revoked device's public keys stop appearing in directory queries; existing user sessions and OAuth refresh tokens are not touched. If you need "kill this device across all its trust relationships", subscribe to device.revoked and take that action on your side.

What's next#

  • Devices reference — endpoint schemas, error codes, exact request / response shapes.
  • Federation — for identity provenance; devices are per-user regardless of how the user authenticates.
Updated 2026-07-11 03:09:25 View source (.md) rev 4