On this page0
Unfamiliar word? See Glossary: words used in these docs. Looking for the tasks in order? Begin at Start Here.
Reference
Included in: All plans. Which Plan Includes What
Fleet Policy & Agent Signing
What It Does
The policy a device receives is a set of switches — turn on the firewall and, on a Mac, disable remote login or require a screen lock, and so on — worked out from the profiles that device is in scope for. Nothing is enforced by default, and where profiles overlap the strictest setting wins. There are two master switches, one for security settings and one for operating-system updates: while either is off, the agent is told nothing about the controls under it, which it reads as “do not touch this machine”. A master switch comes on for a device only when a profile covering it carries a control from that family, and only then does the agent start changing settings on the endpoint.
Off Means Hands Off
Reverts Are Re-Applied
If something here is not working as you expect, start with When something goes wrong.
The plain explanation is above. This part is for IT and developers: the exact settings, permission names, API addresses and data fields behind it. Closed: select the heading above to open it. While it is closed, your browser’s find-on-page (Ctrl+F or Cmd+F) does not search this part.
The per-OS hardening policy your instance publishes, and the exact ECDSA-P256 signature every agent heartbeat carries — specified so an inspector can reconstruct and verify it byte-for-byte.
Two Channels, One Direction Of Trust
Admins set hardening and update controls in configuration profiles (Devices → Profiles: a Baseline profile per platform, plus narrower scoped ones), and the per-platform agent settings — check-in interval, minimum versions — through /devices/policy. Both are authenticated dashboard paths. Unattended agents never touch them — they enroll once with a token, then post signed heartbeats over the public /agent route. The resulting policy for that one device rides down in the heartbeat response; the agent applies it and reports which settings were applied and which failed.
Signature
ECDSA · P-256 · SHA-256
Freshness Window
± 300 seconds
Replay Defence
One-Time Nonce
Per-OS Policy API
The per-platform agent settings are stored as one row per platform — macos, linux, windows. GET /devices/policy returns all three at once (keyed { macos, linux, windows }) so the UI can render one panel per OS. PUT /devices/policy targets a single platform via a platform field in the body (default macos for older callers); any unrecognised value folds to macos. Both endpoints reach device-service through the authenticated gateway path /user-management/devices/policy. Hardening and update controls are not written here any more: once an instance’s old settings have been moved into its Baseline profiles, GET returns profilesOnly: true and a PUT that would change one of those flags is refused with 409 (“managed in Profiles now”). The profile routes are in MDM & Device Operations.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /user-management/devices/policy | Every platform’s policy row (macos, linux, windows) plus hosted-binary + available-release status per OS. | view_devices |
| PUT | /user-management/devices/policy | Patch one platform’s agent settings (platform field selects the row): checkInIntervalMinutes, allowUserQuit, minOsVersion, minAgentVersion. Only fields present are written; a changed hardening/update flag returns 409 in profiles-only mode. | manage_devices |
| POST | /user-management/devices/linux-token | Rotate the tenant enrollment token (one token for all platforms, despite the path). Valid 90 days; the previous token is revoked. Returns expiresAt, never the token. | manage_devices |
The Compliance Key Set
These are the fields the heartbeat response carries; each is switched on by a profile payload rather than set directly. Every hardening control is a boolean gated behind manageCompliance. Some keys are shared across platforms; the agent for each OS deserialises only the fields it declares, so a key it does not use is simply ignored. The full set the source stores and serves:
macOS Hardening
complianceFirewall (+ stealth), complianceDisableSSH (Remote Login), complianceDisableScreenSharing, complianceDisableGuest, complianceDisableAutoLogin, complianceGatekeeper, complianceRequireScreenLock, and complianceScreenLockMinutes (integer, clamped 1–60).Linux Hardening
complianceFirewall (shared), complianceAutoUpdates, complianceAuditd, complianceTimeSync, complianceFail2ban.Windows Hardening
complianceFirewall (shared), complianceAutoUpdates (shared), complianceDefender, complianceBitlocker (report-only: the agent reports BitLocker state and does not turn it on).OS Updates + Cadence
manageUpdates: updateAutoCheck, updateAutoDownload, updateInstallSecurity, updateInstallMacOS, updateInstallApps. Independently: allowUserQuit and checkInIntervalMinutes (integer, clamped 5–1440).The Two Gates And What OFF Serves
This is the invariant to verify. The stored row always holds a concrete boolean for each control, but the heartbeat response masks them: when the master switch is off, the control ships as null. The agent treats null as “unmanaged” and does not touch that setting.
// device-service masks each control on the way out (POST /heartbeat response):
complianceFirewall: policy.manageCompliance ? policy.complianceFirewall : null,
complianceGatekeeper: policy.manageCompliance ? policy.complianceGatekeeper : null,
updateInstallSecurity: policy.manageUpdates ? policy.updateInstallSecurity : null,
// ...every hardening + update field follows the same `gate ? value : null` shape.
// manageCompliance / manageUpdates themselves are always sent as the real boolean.
Bounds Are Enforced Server-Side
checkInIntervalMinutes is clamped to 5–1440 and complianceScreenLockMinutes to 1–60 — an out-of-range value is silently clamped, not rejected. minOsVersion must be a dotted version number and minAgentVersion cannot be above the adopted agent version, or the PUT returns 400. agentPkgUrl, if present, must be https:// or the PUT returns 400. Booleans are only written when the body value is a real boolean, so a platform panel that omits a key never clobbers another OS’s value.
Worked Example — Set The macOS Check-In Interval And Minimum OS
// Sent with a personal API token; the caller needs manage_devices. This is the human path, not the agent path.
curl -X PUT https://<your-instance>-api.zonitix.com/api/v1/user-management/devices/policy \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $ZX_TOKEN" \
-d '{
"platform": "macos",
"checkInIntervalMinutes": 30,
"minOsVersion": "14.5"
}'
// 200 → returns that platform's policyView; the interval lands on each Mac's NEXT heartbeat.
// Adding \"complianceFirewall\": true to that body on a profiles-only instance →
// 409 { "error": "Endpoint hardening and software updates are managed in Profiles now …", "profilesOnly": true }
// To turn on the firewall and Gatekeeper, add those payloads to a macOS profile instead (MDM & Device Operations).
Drift: Desired vs Actual
Enforcement is not one-shot. The agent’s loop is collect → heartbeat → apply policy, so enforceCompliancePolicy runs on every cycle and re-imposes each enabled control, while the same cycle’s collector reports the machine’s real securityPosture. A user who disables the firewall between check-ins is corrected on the next interval, and the actual-vs-desired gap is observable in the reported inventory. Honest limit: enforcement is CLI-based (not an MDM profile), so a control the OS only guarantees via MDM is set best-effort. The Mac screen-lock setting is the case in point: the agent writes it for the signed-in user and no collector reads it back.
Agent Enrollment
An agent has no human session, so it cannot ride the authenticated devices path. It reaches device-service through the one unauthenticated gateway route, /agent, which whitelists exact (method, path) pairs and strips the /agent prefix before proxying. Trust is established downstream: /enroll requires a valid enrollment token; /heartbeat requires a signature.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| POST | /agent/enroll | Register a device: present a valid enrollment token + P-256 public key, receive the server device id. | Enrollment token (no session) |
| POST | /agent/heartbeat | Signed inventory report; response carries the fleet policy + any queued commands. | P-256 request signature |
POST /agent/enroll requires all three of enrollmentToken, serialNumber and publicKey — any missing field is a 400. The public key is imported and its curve checked: a non-P-256 key is a 400 “Public key must be P-256”, an unparseable one a 400 “Invalid public key”. A device row is keyed by the unique serialNumber (on Linux the agent sends a machine ID in that field), so one physical machine is always exactly one row; success returns 201 { deviceId }.
The other refusals. 400 also when serialNumber is longer than 255 characters or contains a NUL character; the serial is refused rather than shortened, so two different long serials can never land on one record. 401 for a bad token, with one of three messages: Invalid enrollment token, Invalid or expired enrollment token (also when the token is revoked or used up), or Enrollment is not configured on this tenant. 403 when the serial belongs to a retired device or a rejected enrollment: an admin must return it to service first. 409 for a changed signing key, described below. The 401, 403 and 409 refusals are audited as DEVICE_ENROLL_REJECTED. Two things happen on success that the status code does not show: a serial seen for the first time is created pending approval and is not trusted until an admin approves it, and a re-enrollment of a known serial cancels every command still queued for that device.
Extra Enrollment Tokens
In plain terms: the Linux and Windows install commands in Devices → Settings already hand out your instance’s reusable enrollment token (valid 90 days and replaced automatically in the last 14), so most administrators never issue a token by hand. These routes exist for the less common case where you want a separate, short-lived token with a fixed number of uses that you can revoke on its own.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| POST | /user-management/devices/enroll-tokens | Issue a token. Body {label, ttlHours, maxUses}. 201 {id, token, label, expiresAt, maxUses}. Audited | manage_devices |
| GET | /user-management/devices/enroll-tokens | The newest 200 tokens: id, label, expiresAt, maxUses, useCount, usedAt, revokedAt, createdAt, status. Never the token value | view_devices |
| POST | /user-management/devices/enroll-tokens/:id/revoke | Revoke one token. Returns {revoked} — false when it was already revoked or does not exist. Audited when it revokes | manage_devices |
label is optional and cut to 120 characters. ttlHours defaults to 24 and is clamped to 1–720 (30 days). maxUses defaults to 1 and is clamped to 1–500. Out-of-range values are clamped, not rejected. The token value is returned once, in the 201 response; only its SHA-256 is stored, so it cannot be shown again. At enrollment a use is reserved atomically, so two machines enrolling at the same moment cannot both spend the last use, and a token that is revoked, expired or used up is answered with 401. The list also shows the instance’s own reusable token, which has no use limit.
// Enroll — the public key may be an SPKI PEM, base64 SPKI DER, or the base64 raw
// X9.63 point (0x04 || X || Y, 65 bytes) the Secure Enclave exports. Curve MUST be P-256.
curl -X POST https://<your-instance>-api.zonitix.com/api/v1/agent/enroll \
-H 'Content-Type: application/json' \
-d '{
"enrollmentToken": "<tenant enrollment token>",
"serialNumber": "C02XY1234ABC",
"publicKey": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
}'
// 201 → { "deviceId": 42 }
Re-Enroll Guard — 409 On Key Rotation
The tenant enrollment token is reusable (it expires after 90 days and is replaced automatically before then), so an already-enrolled device presenting a different signing key for the same serial is refused with 409 until an admin approves it (Managed devices → Allow re-enrollment, which sets reEnrollAllowed). Presenting the same pinned key is an idempotent re-enroll; a serial with no key yet is a first enroll. The check fails closed — a null or blank presented key counts as “different” and can never silently overwrite a real pin. The approval is consumed on the next successful enroll.
Heartbeat Signature Spec
There is no bearer token on the heartbeat. Each request is signed with the device’s private key (held in the Secure Enclave on Apple silicon and T2 Macs when the build is signed for it; a software P-256 key in a root-only file on other Macs and on Linux, and in an administrator-only file under ProgramData on Windows — no OS keychain or TPM is used) over a canonical description of that exact request, and the server re-derives the same string and verifies it against the public key pinned at enrollment. The verifier is shared/utils/deviceAuth.js. The canonical string is six lines joined by \n, in this fixed order — the literal zx-agent-v1 tag is part of the contract:
// Byte-exact canonical string an inspector reconstructs (lines joined by a single \n):
zx-agent-v1
POST
/heartbeat
1723200000
a3f1c9... (the one-time nonce, verbatim)
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
// Line 2 = HTTP method, upper-cased. Line 3 = the RESOURCE path the SERVER sees,
// i.e. "/heartbeat" WITHOUT the /agent prefix the gateway strips (also "/enroll" is
// unsigned; the escrow route signs over "/escrow/recovery-key").
// Line 4 = unix SECONDS. Line 6 = lower-case hex SHA-256 of the RAW request body bytes
// (sha256 of an empty body is the e3b0c4... constant shown above).
// The server rebuilds it exactly like this (deviceAuth.canonicalString):
['zx-agent-v1',
String(method).toUpperCase(),
String(path),
String(timestamp),
String(nonce),
String(bodySha256)].join('\n')
// Signature = ECDSA over SHA-256 of this UTF-8 string, base64-encoded DER:
crypto.verify('sha256', Buffer.from(canonical,'utf8'), pinnedKey, sigDER)
The signature and its context travel in four namespaced headers on POST /agent/heartbeat. All four are required; the middleware requireDeviceSignature rejects a request missing any of them with 401 “Missing device signature headers”:
x-zx-device-id— the integerdeviceIdreturned at enrollment (must be a positive integer).x-zx-timestamp— the same unix-seconds value on line 4 of the canonical string.x-zx-nonce— the one-time nonce on line 5 (rejected as malformed if longer than 200 chars).x-zx-signature— base64 DER ECDSA/SHA-256 signature over the canonical string.
// A signed heartbeat on the wire (the URL is /agent/heartbeat; the SIGNED path is /heartbeat):
POST /api/v1/agent/heartbeat
X-Zx-Device-Id: 42
X-Zx-Timestamp: 1723200000
X-Zx-Nonce: a3f1c9d2b8...
X-Zx-Signature: MEUCIQD...== (base64 DER)
Content-Type: application/json
// body — signed over its EXACT bytes via the sha256 on line 6; re-serialising breaks it:
{ "platform": "macos", "serialNumber": "C02XY1234ABC",
"agentVersion": "1.4.0", "inventory": { ... } }
// 200 → { ok, deviceId, command, trustBundle, policy:{...}, update } plus, for current agents,
// commands[], commandResultsAccepted and policyHash — see the full contract below.
Heartbeat Body And Response In Full
In plain terms: besides its inventory, each check-in tells your instance what the agent is able to do, reports how the commands it was given turned out, and says which policy it has applied. The reply hands back the policy for that device, any commands waiting for it, and a newer agent build if there is one. An administrator sees the results as command history on the device and as installed, failed or pending against each profile.
// Request body — fields a current agent sends on top of platform, serialNumber, agentVersion:
{ "capabilities": ["commands_v2", "enforcement_v1", "software_v1"],
"commandResults": [ { "id": 81, "status": "succeeded", "detail": null } ],
"enforcement": { "policyHash": "<sha256 hex>", "applied": ["complianceFirewall"],
"failed": [ { "field": "complianceGatekeeper", "error": "..." } ] },
"inventory": { "installedSoftware": [ { "name": "...", "version": "...", "publisher": "...", "bundleId": "..." } ], ... } }
// Response to an agent that declared commands_v2:
{ "ok": true, "deviceId": 42, "command": null,
"commands": [ { "id": 82, "type": "restart", "args": null } ],
"commandResultsAccepted": true,
"policyHash": "<sha256 hex>", "policy": { ... }, "trustBundle": { ... },
"update": { "version": "...", "url": "...", "sha256": "...", "sig": "...", "kid": "..." } }
capabilities— what the agent build understands. Values are trimmed and lower-cased, and unknown ones are ignored. An agent that does not sendcommands_v2gets the older response shape with nocommandslist.commandResults— one entry per finished command:id,status(succeeded,failedorunsupported) and adetailstring kept to 500 characters. At most 50 are read per check-in. Reporting the same result twice is harmless: a command already settled is skipped.commandResultsAccepted— present only for an agent that declaredcommands_v2, and only when the request carried results.truemeans they were stored;falsemeans they could not be, and the agent sends them again at the next check-in.commands— up to 20 queued commands per check-in, each{ id, type, args }.commandis the older single slot, which can only carrylockoruninstall; it is set tonullwhen that same command is already incommands, so nothing is delivered twice.policyHash— lower-case hex SHA-256 of the canonical JSON of thepolicyobject in this response: object keys sorted at every level, no whitespace, arrays kept in order. It is sent to every agent.enforcement— the agent echoes the hash of the policy it applied, with the fields it applied and the ones that failed (up to 200 of each; field names kept to 64 characters, errors to 200). For each profile covering the device, a matching hash with no failed field of that profile is installed; a matching hash with a failed field is failed, with the agent’s error; a different hash is pending, the normal one-check-in lag after a policy change, never a failure. An agent too old to send the report has its profiles marked from the state the device itself reports instead.inventory.installedSoftware— structured software list, capped at 2,000 entries. The olderinventory.installedAppslist of strings is still accepted and is used when the structured list is absent.update—nullunless the version chosen for this device is strictly newer than theagentVersionit reported, so the server never offers a downgrade. The agent still checks the hash and the release signature before installing, and applies the newer-only rule itself.trustBundle— the current vendor trust bundle, which is how a release-signing key is revoked, ornullwhen none is stored. It is sent on every check-in. Both fields are specified under Release Signature And Trust Bundle, further down this page.
A failure while storing results, software or the enforcement report is logged and never fails the heartbeat itself: the agent still gets its 200 and its policy.
Worked Example: Sign And Send One Heartbeat
A shell version of what the agent does, for an inspector who wants to reproduce one check-in. It needs openssl and curl, a P-256 private key in PEM (here device.key) and the deviceId given at enrollment (here 42). The key must be one you generated and enrolled yourself: POST /agent/enroll takes the public half. The macOS agent keeps its key in the Secure Enclave where the Mac has one, and that key cannot be exported to a file. The host is written as on the API reference page. An agent has no session cookie, so no Origin header is involved.
This call is a real check-in, not a read. It sets the device’s last-seen time and address and overwrites the platform and agent version stored for it. A lock or uninstall waiting for that device is handed to this call and marked delivered, so the real agent never receives it; the same happens to every other queued command if the body declares commands_v2, which is why the body below leaves capabilities out. A device shown as uninstalling or uninstalled, with nothing waiting for it, goes back to active. Use a device you enrolled for the test, not one in service.
BODY='{"platform":"macos","hostname":"test-mac-01","agentVersion":"1.4.0"}'
TS=$(date +%s); NONCE=$(openssl rand -hex 16)
HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')
# Six lines joined by a newline, with no newline after the last. The signed path is /heartbeat, not /agent/heartbeat.
SIG=$(printf 'zx-agent-v1\nPOST\n/heartbeat\n%s\n%s\n%s' "$TS" "$NONCE" "$HASH" \
| openssl dgst -sha256 -sign device.key | openssl base64 -A)
curl -X POST https://<your-instance>-api.zonitix.com/api/v1/agent/heartbeat \
-H 'Content-Type: application/json' -H 'x-zx-device-id: 42' \
-H "x-zx-timestamp: $TS" -H "x-zx-nonce: $NONCE" -H "x-zx-signature: $SIG" \
--data-binary "$BODY"
# 200 (values illustrative):
{ "ok": true, "deviceId": 42, "command": null,
"policyHash": "<sha256 hex>", "policy": { ... }, "trustBundle": null, "update": null }
# 401 "Stale or invalid timestamp" when TS is more than 300 seconds from the server clock.
# 401 "Replay detected" when the same nonce is sent twice for one device, so run the whole block again for a second call.
# 401 "Signature verification failed" when the body sent differs by even one byte from the body hashed.
The Checks, In Order
requireDeviceSignature runs cheapest-first so a forged request never touches the database. Each stage that fails returns 401 with a distinct message an inspector can key on:
- Headers present. Missing device id / timestamp / nonce / signature →
Missing device signature headers. - Freshness. The timestamp must be within ±300 s (
TIMESTAMP_WINDOW_SEC) of server time, checked before any DB hit →Stale or invalid timestamp. A nonce over 200 chars →Malformed nonce. - Known device. The
deviceIdmust resolve to an enrolled row with a pinnedpublicKey→Unknown or un-enrolled device. - Signature. ECDSA/SHA-256 verify over the re-derived canonical string →
Signature verification failed. A malformed key or signature is a failed verification, never a 500. - Not retired. Checked by the heartbeat handler after the signature, so only the real device learns why: a retired device (or a rejected enrollment) →
Device is retired, until an admin returns it to service. - Replay. The
(deviceId, nonce)pair must be unseen — a unique index is the source of truth, so even two concurrent replays cannot both slip through →Replay detected.
Why The Raw Body Matters
Line 6 is the SHA-256 of the exact bytes the agent sent, so device-service captures the raw request buffer (express.json’s verify hook) and hashes that — it never re-serialises req.body. To verify a captured request yourself, hash the body bytes as-received; do not pretty-print or reorder the JSON first, or the digest — and the signature — will not match. This rides inside TLS (Cloudflare tunnel → gateway); the signature is defence in depth, not a replacement for transport security.
This Channel Is Separate From License Verification
The agent heartbeat is ECDSA-P256 asymmetric (private key on the endpoint, public key pinned server-side) and secures endpoint→your-instance. It is unrelated to the HMAC-SHA256 licensing heartbeat your instance sends up to the Zonitix store (see License Verification). Different keys, different direction, different purpose.
Release Signature And Trust Bundle
In plain terms: an agent installs a newer build of itself only when four things hold, and it checks them itself rather than taking your instance’s word. The build must be newer than the one running, it must arrive over HTTPS, its contents must match the published hash, and the hash must carry a signature made with a Zonitix release key the agent already trusts. The checks are the same on macOS, Linux and Windows. All four are checked before anything is installed. The first three catch an old, insecurely delivered or altered build; the fourth is what stops a build that someone other than Zonitix produced.
What The Agent Checks Before Installing
- Newer only.
update.versionmust be strictly greater than the version the agent is running. The comparison is numeric on major, minor and patch (a leadingvis ignored and a missing part counts as 0), so an equal or older version is refused even when its signature is valid. Your instance applies the same rule before it offers a build, as described underupdateabove; the agent does not rely on that. - HTTPS only. A download address that does not start with
https://is refused. - Hash. The SHA-256 of the downloaded bytes must equal
update.sha256, compared without regard to letter case. - Release signature.
update.sigmust verify against a release key in the agent’s own trust file that is not on its revoked list. A reply with no signature, or an agent with no trust file, is a refusal. The macOS agent uses exactly the key named byupdate.kidand refuses when that field is missing; the Linux and Windows agents try the key named bykidfirst and then their other trusted keys.
A refusal leaves the running agent in place, and the device keeps checking in. The reason is written to the agent’s own log, with one exception: the macOS agent skips a version that is not newer without writing a log line.
The Signature Scheme
- Algorithm. ECDSA on the P-256 curve with SHA-256, the same algorithm as the heartbeat signature but a different key: the device key signs heartbeats, a Zonitix release key signs builds.
- What is signed. Three lines joined by a single
\n, with no newline after the last: the literalzx-release-v1, the version exactly as sent inupdate.version, and the lower-case hex SHA-256 of the package. The version is inside the signed text, so a signature made for one build cannot be reused for another version or other bytes. - Encoding.
update.sigis the DER-encoded signature in base64url without padding. The heartbeat signature, by contrast, is standard base64. - Public keys. Each trusted key is an SPKI public key in PEM, stored with a key ID (
kid).
# Verify one build by hand. VERSION and SIG are update.version and update.sig; agent.pkg is the
# downloaded package; release.pub is one publicKeyPem value from release-keys.json, saved to a file.
SHA=$(openssl dgst -sha256 -hex agent.pkg | awk '{print $NF}')
# base64url without padding -> standard base64 -> DER
B64=$(printf '%s' "$SIG" | tr '_-' '/+'); while [ $(( ${#B64} % 4 )) -ne 0 ]; do B64="$B64="; done
printf '%s' "$B64" | openssl base64 -d -A > release.sig
printf 'zx-release-v1\n%s\n%s' "$VERSION" "$SHA" \
| openssl dgst -sha256 -verify release.pub -signature release.sig
# "Verified OK" when the key signed exactly this version and these bytes; "Verification failure" otherwise.
Where The Trusted Keys Live
Each agent reads its trust from one file, release-keys.json, in its own data folder: /etc/zonitix on Linux, ProgramData\Zonitix on Windows and /Library/Application Support/Zonitix on macOS. On Linux and Windows the install command from Devices → Settings writes the file before the agent first starts. It holds public keys only:
releaseKeys— the release keys the agent accepts, each withkidandpublicKeyPem. An entry in the file may also carryalg, which the agent does not read.revokedKids— key IDs the agent no longer accepts, even when the key is still listed.rootKey— one root public key,{ kid, publicKeyPem }. It signs nothing but trust bundles.trustVersion— a whole number that only goes up. A file without one counts as 0.
Your instance holds the same public keys and checks the same signature before it hosts a build: adopting a release downloads it, recomputes the hash and verifies the release signature, and refuses to host a build that fails either. See Update The Agent.
How The Trusted Keys Are Replaced
A release key is added or withdrawn on agents already installed by a trust bundle, the trustBundle object in the heartbeat reply: { trustVersion, rootKid, sig, body }. body is the complete new text of release-keys.json. Your instance does not write bundles. It stores the one it receives in the reply to its own license check-in and hands that same object to every agent on every heartbeat. An agent that already holds it refuses it as not newer and keeps its file; that refusal appears in the agent’s log on each check-in and is expected. An agent applies a bundle only when all of these hold:
- The agent has a root key in its trust file, and the bundle’s
rootKidequals that key’s ID. - The root signature verifies. Same algorithm and encoding as a release signature, over three lines joined by
\n: the literalzx-trust-v1,trustVersionas a decimal number, and the lower-case hex SHA-256 of the exact bytes ofbody. A bundle signed with a release key does not verify, so a release key cannot be used to change which keys are trusted. - It is newer.
trustVersionmust be strictly greater than the one the agent holds, so an older bundle cannot be replayed to bring back a withdrawn key. - It keeps the same root. The
rootKeyinsidebodymust carry the same key ID as the root the agent already holds. A bundle cannot replace the root key.
When every check passes, the agent writes body to a temporary file and renames it over release-keys.json, so the file is never half-written. When any check fails the existing file is left untouched and the reason is logged. A bundle is applied before the commands and before any update carried in the same reply, so a key withdrawn in a check-in cannot authorise an update delivered in that same check-in.