Appearance
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
| Layer | Lives | Lifetime | Revocation |
|---|---|---|---|
| Principal credential (HiveCast OIDC) | Cloud system.auth credentials[] table; HostCredentialStore | Until principal is suspended or session is revoked | session revoke + principal suspend |
| Device-scoped NATS user JWT | Device <host-home>/credentials/hivecast-nats.json | 24h (DEVICE_NATS_CREDENTIAL_TTL_SECONDS) | rotate via auth.hostLink.credentials.refresh; or revoke link |
| Heartbeat bearer token | Device <host-home>/credentials/hivecast-host-link-token; cloud stores SHA-256 hash | rotates with NATS creds; no explicit TTL | rotate; revoke link |
| Local provider OAuth (Anthropic, Codex, …) | Device <matrix-home>/credentials.json (Factotum) | per-provider TTL on the token | credential.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 | nullThe two known credential families today:
- Federated identity claim records (Google, GitHub) —
provider: 'google'/'github',credentialType: 'oidc-id-token'or similar identifier,valueis 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()andgetNatsAccount()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 discardedAt 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
- Secrets / Factotum — the Layer 4 detail.
- Secrets / Credential references — using lease/materialize.
- Secrets / Secret rotation — rotation per layer.
- Authentication — how Layer 1 gets minted.
Source:
projects/matrix-3/packages/system-auth/src/host-auth.ts(Layer 1 /HostCredentialStore); pairing wire shape fromWORKSTREAMS/product-launch/HIVECAST-DEVICE-ENROLLMENT-SPEC.md(Layers 2 + 3);projects/matrix-3/packages/system-factotum/dist/index.js(Layer 4).