# Collector authentication **This page is the one authoritative description of how a fleet client proves who it is.** Every other page links here rather than restating it. There are two credentials, and which one a PC uses is a property of that PC, not of the site: | | Per-PC credential | Shared collector key | |---|---|---| | Status | **current** | **legacy - still works, do not build on it** | | Header | `X-Endpoint-Credential` | `X-API-Key` (or `Authorization: Bearer`) | | Identifies | this one PC | any holder of the key | | Issued by | enrollment, once per PC | an admin, once per fleet | | Revocable | per PC | only fleet-wide | | Blast radius if it leaks | one PC's report | every PC's report | A PC that has enrolled sends its credential and nothing else. A PC that has not enrolled sends the shared key, exactly as the whole fleet used to. **Never both on one request.** Per-PC credentials need the `endpoints` plugin on the server and an endpoint agent (or reporter script) that knows how to enroll. A site running older clients is on the shared key and keeps working; that is the point of keeping both. > **Both halves are in this tree.** `plugins/endpoints/api/agentauth.py` resolves > the credential and decides the shared-key fall-through, > `plugins/endpoints/api/enrollment.py` carries enroll, approve, reject, revoke, > allow-reenroll and the migration waves, > `plugins/endpoints/models/credential.py` holds the three tables, and > `shopdb/core/api/collector.py` reads the header on the generic collector > route. The C# agent sends it > (`agent/ShopDbEndpointAgent.Core/AgentHttpFactory.cs`) and both shipped > PowerShell reporters fall back to the shared key on their own. The decision > record is [ADR-019](adr/ADR-019-per-pc-endpoint-credentials.md); the wire > contract is `plugins/endpoints/AGENT-CONTRACT.md` section 9, and the fleet > rollout order is `plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md`. --- ## 1. Which endpoints take which | Endpoint | Per-PC credential | Shared key | |---|---|---| | `POST /api/collector/computers` | yes | yes | | `POST /api/collector/printers` | yes | yes | | `POST /api/collector/endpoints` (and `/api/endpoints/collect`) | yes | yes | | `POST /api/collector/` | only if that plugin's schema declares `acceptsagentcredential` | yes | | `POST /api/collector/pc`, `/apps`, `/heartbeat`, `/bulk` | no | yes, see section 5 | | `GET /api/collector/status` | no | yes | | `GET /api/collector/_schemas` | no | no - JWT only, it is for humans | A plugin's own unattended routes are outside this table. They authenticate with a scoped managed service token, or with a source-IP allowlist, and they accept neither a per-PC credential nor a collector key. That is the distinction people get wrong: the collector credential says WHICH PC is writing a report, a service token says WHAT an integration is allowed to do. ## 2. How the server decides For a collector endpoint whose plugin schema declares `acceptsagentcredential`: 1. `X-Endpoint-Credential` present? Resolve it to an asset. Unknown or revoked is `401 CREDENTIAL_INVALID`. The asset the credential resolves to is the asset the request acts for, whatever hostname the body carries. 2. Otherwise fall through to shared-key auth (section 4). 3. If the shared key authenticated but the named PC **has enrolled**, the request is refused `401 SHARED_KEY_REFUSED`. A PC with its own secret must use it; a shared-key call in its name is an older agent or somebody else. 4. A payload that names a different asset than the credential is `403 ASSET_MISMATCH`. A PC that has NEVER enrolled has no credential row, is never refused by step 3, and keeps reporting on the shared key indefinitely. That is what carries an un-migrated fleet. ### Error codes | HTTP | Code | Meaning | What the client does | |---|---|---|---| | 401 | `CREDENTIAL_INVALID` | unknown or revoked credential | delete the stored credential, retry once with the shared key, wake enrollment | | 401 | `SHARED_KEY_REFUSED` | shared key sent for an enrolled PC, or the shared key is switched off | use the credential; if there is none, enroll | | 403 | `ASSET_MISMATCH` | the credential's asset is not the one the request names | log and stop. Do not retry the same request | | 401 | `Invalid API key` | the shared key is wrong or missing | fix the key | | 500 | `Collector API key not configured` | server-side: no env key set and no managed token presented | a SERVER config gap, not a client problem | `ASSET_MISMATCH` also covers a hostname that is on no computer record at all. The code and status are the same because nothing changes for the client, but the message says which of the two it is and names the hostname. An admin fixes it by setting the hostname on the enrolled computer. ## 3. Per-PC credentials (current) ### Shape and storage A credential is `sdbe_` plus 43 base64url characters (32 random bytes). The server stores only its SHA-256 plus the first 12 characters for display; the full secret exists exactly once, in the enrollment response. On the PC it lives in `%ProgramData%\ShopDB\EndpointAgent\credential\credential.bin`, DPAPI `LocalMachine`, in a folder ACL'd to SYSTEM and Administrators only. The file records the `baseurl` it was issued for, and the credential must never be sent anywhere else. `enroll.json` beside it holds the enrollment nonce in plain text. ### Enrolling `POST {baseurl}/api/endpoints/enroll` with `hostname` and `serialnumber` (required), plus an optional `deviceid`, an optional `agentversion`, and a persisted `enrollnonce` of 32 to 128 characters from `A-Za-z0-9_-`. The route never refuses a call for its authentication. A PC installed without the shared key still records a request an admin can approve. The shared key only decides whether a credential is issued on the spot. Because it takes no auth and writes rows, it carries the two bounds an open door needs: a per-IP fixed window of 300 calls a minute, sized for a whole shop floor behind one address rather than for one PC, and a cap on how many PENDING rows may exist at once (`plugin.endpoints.maxpendingenrollments`, default 500). The cap turns away only a hostname and serial never seen before, and it drops abandoned rows before it refuses a real PC, so neither bound can lock out a machine already in the queue. | Status | Body | What it means | |---|---|---| | 201 | `{"status":"enrolled","credential":"sdbe_...","assetid":123}` | issued. Store it and switch headers; no restart | | 202 | `{"status":"pending","requestid":45,"reason":"serial mismatch"}` | queued for an admin. Retry on a backoff | | 403 | `ENROLL_NONCE_MISMATCH` | the approved request belongs to a different nonce. An admin must reject and re-approve | A credential is auto-issued only when ALL of these hold: the shared key authenticated the call, `plugin.endpoints.autoenroll` is on, the hostname matches exactly one computer, the serial on record matches (trimmed, case-insensitive), and the asset has no active credential. Otherwise the request is pending with one of: `unknown hostname`, `duplicate hostname`, `no serial on record`, `serial mismatch`, `already enrolled`, `awaiting approval`, `nonce changed`, `rejected`, `reenroll blocked`. There is one request row per (hostname, serial number); repeated calls update it. The nonce is what an approval binds to, so a second machine claiming the same hostname and serial cannot collect the approved credential. A REJECTED row binds to the identity instead of to the nonce: any nonce from that hostname and serial stays rejected, so a reinstall cannot argue its way back into the queue, and approving the request is the only way out of a rejection. ### Revoking, rotating, rolling back Revoking sets `revokedat` on the asset's one credential row. Re-issuing updates that same row, so an asset never carries two credentials. By default a revoked PC **falls back to the shared key**. That is deliberate: a revoke is also how an admin rotates a credential, and how a PC is rolled back to an older agent that only knows the shared key. Denying by default would silence that PC instead of rolling it back. - `blockreenroll` on the revoke stops the PC enrolling again until an admin calls `allow-reenroll`. That is what makes a revoke stick for a compromised PC. - `plugin.endpoints.revokeddeniessharedkey` (default **off**) makes a revoke a real eviction: the PC reports nothing until it enrolls again. Turn it on only once the fleet is off the shared key, because it also closes the rollback path. Admin API, for reference: `GET /api/endpoints/enrollment` (needs `endpoints.fleet`), and the audited admin-role writes `.../requests//approve`, `.../requests//reject`, `.../assets//revoke`, `.../assets//allow-reenroll`. ### Server pins A pinned agent accepts the TLS handshake if and only if the leaf certificate's SubjectPublicKeyInfo SHA-256 is in its pin set; with no pins it requires a clean Windows chain validation. `HKLM:\SOFTWARE\GE\ShopDB` `ServerPins` (REG_MULTI_SZ, either registry view) is authoritative whenever the value exists, **even when empty** - an empty value means "registry authoritative, no pins". Only when it is absent from both views do the `serverpins` entries in the agent's `appsettings.json` apply. The agent logs which source is in effect at startup and on every change, and re-reads the set within about a minute with no restart. The Server Certificate settings page shows the pin; the client kit has `Get-ShopDBServerPin.ps1` to compute one and `Set-ShopDBServerPins.ps1` to write the set (`-None` for an empty authoritative value, `-Clear` to delete it). ## 4. The shared collector key (legacy) ### What counts as one - A **managed API token** (`shopdb_pat_...`) scoped to ONLY `collector.ingest`, or to `collector.ingest.` for one collector. This is the form to use. It can be minted, rotated and revoked from Settings > API Tokens, and its use shows in `lastusedat` and the audit log. - An **environment key**: `COLLECTOR_API_KEY_` (per plugin, uppercased) or `COLLECTOR_API_KEY` (shared fallback). Resolution is per-plugin first, then shared. These stay supported as a bootstrap and legacy path. An unscoped token, or one scoped to some other permission, is NOT a collector credential and is rejected. `collector.ingest` gates no normal route and is denied on every role-gated endpoint, so a scoped token that leaks can submit collector payloads and do nothing else - not even with an admin owner, because scoping suspends the admin bypass. ### Header only, fail-closed The key travels in `X-API-Key` (Bearer also accepted for a managed token). **The `?api_key=` querystring form was removed on every collector endpoint** - querystring keys leak into access logs, proxy history and browser history. A caller still putting it in the URL gets 401. If neither an env key nor a managed token is available server-side, every request is refused. An unconfigured server never silently accepts unauthenticated data. ### Minting one 1. Settings > API Tokens > New Token. 2. Check **Restrict permissions**, tick only **Submit collector payloads (fleet reporting)** (`collector.ingest`). Name it, optionally set an expiry, Create. 3. Copy the `shopdb_pat_...` secret, shown once, and deliver it to the fleet (section 6). If you prefer a non-admin owner, create a low-privilege user whose role holds only `collector.ingest` and let it mint its own token; the scope ceiling then caps anything it mints. The admin-minted route is simpler and equally contained. ### Rotating one 1. Mint the replacement. 2. Deliver it (section 6) - one value per site. 3. Watch the new token's `lastusedat` climb and the old one's go stale. 4. Revoke the old token. Revocation is immediate. The endpoint agent's key has its own kill switch: Settings > Endpoint agent key generates a key scoped to `collector.ingest.endpoints`, revokes any previous one in the same request, and switches off the `.env` fallback for endpoints (`plugin.endpoints.envkeyfallback`). Revoking it stops all endpoint-agent traffic without touching the computers or printers collectors. ### Switching it off `plugin.endpoints.sharedkeyallowed` (default on) gates the shared key for the endpoints collector. Turning it off is the last step of a migration, not the first: every PC that has not enrolled stops reporting the moment it flips. ## 5. The four legacy core routes `POST /api/collector/pc`, `/apps`, `/heartbeat` and `/bulk` predate both the generic collector contract and per-PC credentials. They take their target hostname from the body and rewrite that PC's logged-in user, boot time, serial number and installed apps, so the shared key is an **any-host write key** on them and no per-PC credential narrows it. `plugin.endpoints.sharedkeyallowed` does not reach them. `collector.legacysharedkeyrefused` (default **false**, today's behaviour) closes them to the shared key; a collector-scoped managed token still works. Every shared-key use on these routes is logged whether the gate is on or off, so read the "Legacy collector route used with the shared key" warnings for a while first - whatever still calls them stops working the moment it is on. Neither shipped reporter is affected; they post to `/api/collector/computers` and `/api/collector/printers`. Per ADR-006 the legacy `/pc` path is deprecated and slated for removal before v1.0. New integrations target `POST /api/collector/`. ## 6. Delivering the shared key to a PC Do NOT hardcode a key in a script that lives on a file share: a literal key there is published to everyone with share read. In order of preference: 1. **`HKLM:\SOFTWARE\GE\ShopDB` value `CollectorKey`**, written at imaging time, by DSC, or by an Intune platform script. This is where both shipped reporters look. Restrict the key's ACL to SYSTEM and Administrators so an interactive or kiosk user cannot read it. 2. **A per-site config value** the machine account already reads - `collectorApiKey` in `C:\Enrollment\site-config.json`. Rotation is one value per site, no re-image and no script redeploy. 3. **A machine-local file** staged at imaging (`C:\Enrollment\collector-api-key.txt`), ACL'd to SYSTEM. Tighter blast radius - the key never sits on the share at all - but rotation means touching every PC. Never put a token in a scheduled task's `-Argument` string: task XML is world-readable. Never put one in a manifest: manifests sync broadly. A per-PC credential needs none of this. It is issued to the PC by enrollment and never leaves it.