---
title: Devices
path: concepts/devices
status: published
---

# 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](https://www.scailabs.ai/docs/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

```
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:

```
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.

```
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.

```
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.rotated`** — `user_id`, `device_id`, `kem_pub`, `sig_pub`, `key_version`, `keys_rotated_at`.
- **`device.revoked`** — `user_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](/docs/scaikey/reference/api/devices) — endpoint schemas, error codes, exact request / response shapes.
- [Federation](/docs/scaikey/reference/api/federation) — for identity provenance; devices are per-user regardless of how the user authenticates.
