Skip to content

Credentials model

There are four kinds of credentials in this system. They live in different stores, have different lifetimes, and have different revocation paths. Mixing them together is the most common security mistake; this page is the canonical separation.

The four layers

LayerLivesLifetimeRevocation
Principal credential (HiveCast OIDC)Cloud system.auth credentials[] table; HostCredentialStoreUntil principal is suspended or session is revokedsession revoke + principal suspend
Device-scoped NATS user JWTDevice <host-home>/credentials/hivecast-nats.json24h (DEVICE_NATS_CREDENTIAL_TTL_SECONDS)rotate via auth.hostLink.credentials.refresh; or revoke link
Heartbeat bearer tokenDevice <host-home>/credentials/hivecast-host-link-token; cloud stores SHA-256 hashrotates with NATS creds; no explicit TTLrotate; revoke link
Local provider OAuth (Anthropic, Codex, …)Device <matrix-home>/credentials.json (Factotum)per-provider TTL on the tokencredential.delete; per-provider revoke at provider

Layer 1 — Principal credential

The principal-level credentials in HostCredentialStore (in host-auth.ts:432-490):

ts
store(principalId, provider, credentialType, value): void
list(principalId): Array<{ provider, credentialType, createdAt }>
storeNatsAccount(principalId, account: IHostNatsAccountCredential): void
getNatsAccount(principalId, root): IHostNatsAccountCredential | null

The two known credential families today:

  • Federated identity claim records (Google, GitHub) — provider: 'google' / 'github', credentialType: 'oidc-id-token' or similar identifier, value is the verified claim record. Used to recognize returning users.
  • Principal NATS account credentials (provider: 'nats', credentialType: 'nats-account-<root>') — the principal's per-authority-root NATS account seed. This is server-side material; Devices never see it. storeNatsAccount() and getNatsAccount() are the typed accessors.

list() returns metadata (provider, credentialType, createdAt) without the secret value. value is only retrievable via the typed accessor pattern, never via a generic fetch.

Layer 2 — Device-scoped NATS user JWT

When auth.device.exchange succeeds, system-auth mints a NATS user under the principal's account, scoped to the link's authorityRoot. The Device receives:

json
{
  "jwt":  "<NATS user JWT>",
  "seed": "<nkey seed>",
  ...
}

written to <host-home>/credentials/hivecast-nats.json with mode 0o600. The Host's NATS client uses these credentials to connect.

The user JWT has a 24-hour exp. After expiry, the connection drops and reconnect requires fresh credentials (rotation via auth.hostLink.credentials.refresh, or re-pair).

Layer 3 — Heartbeat bearer token

The hlink_<base64url> bearer token issued at exchange time, persisted to <host-home>/credentials/hivecast-host-link-token (single line, mode 0o600). The cloud stores only the SHA-256 hash on the Host Link record (heartbeatTokenHash field).

Verification is constant-time (safeEqualStrings, timingSafeEqual). Rotation via auth.hostLink.credentials.refresh mints a new token, hashes it, stores the new hash, and returns the new plaintext exactly once.

Layer 4 — Local provider OAuth (Factotum)

Stored on each Device by system.factotum. The on-disk file is <matrix-home>/credentials.json (mode 0o600). Each provider has its own credential record:

ts
{
  type: 'oauth' | 'api-key' | 'local',
  accessToken?: string,
  refreshToken?: string,
  apiKey?: string,
  baseUrl?: string,
  expiresAt?: number,
  scopes?: string[],
  source: 'manual' | 'oauth' | 'import-claude' | 'import-codex' | 'env',
}

The credential layer has oauth (OAuth tokens with refresh), api-key (static keys), and local (Ollama base URLs).

Importantly: provider OAuth tokens ALWAYS originate via local-loopback OAuth or by importing from a CLI tool's local config (.credentials.json, auth.json). They never travel from the cloud to the Device. See No raw OAuth tokens on local Host.

How the layers compose during a typical request

A user clicks "Send" in Chat:

text
1. Browser: HTTP request to local gateway with mx_session cookie
   (Layer 1: principal session)
2. Gateway → bus: opens a request-reply with the principal's bus token
   (Layer 1 derived: bus token JWT)
3. Chat actor: receives the envelope, calls system.inference
4. system.inference: needs a provider key
   - calls system.factotum.credential.lease({ provider: 'anthropic' })
   - receives a leaseId (NOT the raw token)
5. system.inference passes leaseId to the provider adapter
6. Provider adapter: calls system.factotum.credential.materialize({ leaseId })
   - receives { headers: { 'x-api-key': '...' } } (Layer 4)
7. Provider adapter: HTTPS to api.anthropic.com with those headers
   (the only place the raw token exists in plaintext)
8. Response flows back; lease is consumed once and discarded

At no point does the raw provider token cross the bus. At no point does the principal-level token (Layer 1) leave HiveCast.

File-mode discipline

Every credential file is written with chmod 0o600. The writePrivateJson() and writePrivateText() helpers in mx-cli/src/utils/hivecast-link-store.ts:196-214 enforce this:

ts
function writePrivateJson(filePath: string, payload: object): void {
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
  fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
  try {
    fs.chmodSync(filePath, 0o600);
  } catch {
    // chmod is best-effort on filesystems that don't support it.
  }
}

On filesystems that support POSIX modes (Linux, macOS, WSL), the file is owner-readable only. On Windows NTFS without POSIX semantics, mode bits are ignored — the security control is the user account boundary instead.

See also

Source: projects/matrix-3/packages/system-auth/src/host-auth.ts (Layer 1 / HostCredentialStore); pairing wire shape from WORKSTREAMS/product-launch/HIVECAST-DEVICE-ENROLLMENT-SPEC.md (Layers 2 + 3); projects/matrix-3/packages/system-factotum/dist/index.js (Layer 4).