Skip to content

Device grants

A "Device grant" is the bundle of artefacts a successful pairing leaves on the Device. Each artefact has a different scope, lifetime, and revocation path. Treating them as one thing is the source of most pairing-security confusion, so this page enumerates them.

The four artefacts

ArtefactCarriesAudienceScopeLifetimeRevocation
Host Link recordhostLinkId, hostId, principalId, spaceId, authorityRoot, deviceSlug, scopes[]cloud-side system.auth, mirrored to local hivecast-link.jsonOne Device on one principal/SpaceUntil auth.hostLink.revoke or --all logoutauth.hostLink.revoke
Heartbeat tokenOpaque bearer token, only its SHA-256 stored cloud-sideUsed by host.control against /_auth/host-link/heartbeatOne Host LinkRotates on every auth.hostLink.credentials.refreshsuperseded by next rotate, or by revoke
Device-scoped NATS credentialsNATS user JWT + nkey seed, scoped under the principal's NATS accountUsed by the Host's NATS client to connect to HiveCast NATSOne Device, NATS user permissions onlyDEVICE_NATS_CREDENTIAL_TTL_SECONDS = 86_400 (24h); refresh via auth.hostLink.credentials.refreshsuperseded on rotate; revoked by deactivating the user JWT cloud-side
Scope setscopes[] on the Host Link recordRecorded for audit; consumers of bus token may inspectNames the capabilities granted to this DeviceLifetime of the Host Link(no per-scope revoke; revoke the link)

This is the durable cloud-side row in system.auth. Its TypeScript shape:

ts
// projects/matrix-3/packages/system-auth/src/host-auth.ts:94-113
export interface IHostLinkRecord {
  readonly id: string;                       // hostlink_<24 hex>
  readonly hostId: string;                   // host_<20 hex>
  readonly hostName?: string;                // mutable label
  readonly deviceSlug: string;               // stable management projection key
  readonly natsUserPublicKeys?: readonly string[];
  heartbeatTokenHash?: string;
  heartbeatTokenIssuedAt?: string;
  readonly principalId: string;
  readonly spaceId: string;
  readonly routeKey?: string;                // v1 alias for spacePath
  readonly publicNamespace?: string;         // typed namespace claim, e.g. space.alt.stories
  readonly authorityRoot: string;            // bus/security root
  readonly scopes: readonly string[];
  status: HostLinkStatus;                    // 'active' | 'revoked'
  readonly createdAt: string;
  updatedAt: string;
  lastRefreshedAt?: string;
  revokedAt?: string;
}

The id is hostlink_<24 hex> (generateHostLinkId() in host-auth.ts:1687). The hostId is host_<20 hex> (generateHostId() at host-auth.ts:1683).

The Device side mirrors the link as IHiveCastHostLinkRecord in mx-cli/src/utils/hivecast-link-store.ts:19-38.

Heartbeat token

The cloud never stores the bearer token in plaintext. It stores only the SHA-256 hash. From host-auth.ts:1691-1697:

ts
function generateHostLinkHeartbeatToken(): string {
  return `hlink_${randomBytes(32).toString('base64url')}`;
}

function hashHostLinkHeartbeatToken(token: string): string {
  return createHash('sha256').update(token).digest('base64url');
}

HostLinkStore.rotateHeartbeatToken() mints a fresh token, hashes it, records the hash on the Host Link row, and returns the plaintext token to the caller exactly once. The Device writes that plaintext to <host-home>/credentials/hivecast-host-link-token (one line, file mode 0o600).

Verification on heartbeat:

ts
// host-auth.ts:926-949
verifyHeartbeatToken(input: { token; id?; hostId?; principalId? }): IHostLinkRecord | null {
  // ...
  const presentedHash = hashHostLinkHeartbeatToken(token);
  if (!safeEqualStrings(presentedHash, record.heartbeatTokenHash)) return null;
  // ...mark refreshed
}

safeEqualStrings uses timingSafeEqual to avoid timing attacks. The op exposed on the bus is auth.hostLink.verifyHeartbeat; the HTTP route that actually receives heartbeats is /_auth/host-link/heartbeat in the gateway.

Device-scoped NATS credentials

Credentials are issued under the principal's NATS account. The Device gets a user JWT — not the account seed. The account seed stays server-side; the device user JWT is short-lived and revocable independently.

SystemAuthActor.onAuthHostLinkCredentialsRefresh in system-auth/src/index.ts calls into @open-matrix/nats-auth helpers (scopedJwtCredentialsFromAccountSeed, rootScopedNatsUserPermissions) to mint a per-device user with subject permissions scoped to the link's authorityRoot. The TTL constant is in system-auth/src/index.ts:

ts
const DEVICE_NATS_CREDENTIAL_TTL_SECONDS = 86_400;

Files written on the Device:

FileContentsMode
<host-home>/credentials/hivecast-nats.json{ jwt, seed, accountSeed?, … }0o600
<host-home>/credentials.jsonmirror, used by the Host's own NATS client0o600

Note: the field names in hivecast-nats.json mirror the IHiveCastNatsCredentials interface in mx-cli/src/utils/hivecast-link-store.ts:13-17.

Scopes

scopes[] on the Host Link is a sorted, de-duplicated list of capability names attached at pairing time. The default scope set is established by auth.device.start / auth.pair.start callers; today it is small and mostly aspirational. The scopes field is recorded for audit and may be consulted by future authorization checks. It is not itself a per-op permission gate today — that gate is the Host Link status (active vs revoked) and the bearer-token check.

Revoke surfaces

Three different revocation paths exist:

Revoke actionEffectOp
Revoke Host Linkstatus: 'revoked', revokedAt set, all subsequent heartbeat-token checks failauth.hostLink.revoke
Rotate heartbeat tokenOld token no longer matches heartbeatTokenHash (new hash recorded)auth.hostLink.credentials.refresh (also rotates NATS)
Local hivecast logout (default)Removes local credential files; cloud row stays active until heartbeats time outnone — local-only
hivecast logout --revoke-cloud-linkCalls cloud revoke endpointauth.hostLink.revoke

See also

Source: projects/matrix-3/packages/system-auth/src/host-auth.ts (IHostLinkRecord, HostLinkStore, generateHostLink*, verifyHeartbeatToken); projects/matrix-3/packages/mx-cli/src/utils/hivecast-link-store.ts (Device-side mirror).