Add the enrollment page, move scripts to per-PC credentials, and document the rollout
- Settings > Endpoints > Enrollment. Migration tab: readiness counts and progress, each PC's state with the reasons it is blocked, "Upgrade selected" and "Upgrade next 25", and live wave progress. Credentials tab: enrolled PCs with revoke, pending requests with approve and reject, and the shared key and auto-enroll switches. Turning the shared key off asks for confirmation and shows how many PCs are not enrolled. - The server certificate page shows the SPKI pin to put in the GE-Enforce pin entry. - The agent upgrade script and the asset and printer reporters send the PC's credential when it has one (over https, only to the issuing server), fall back to the shared key, check server pins, and log refusals clearly. - New Set-ShopDBServerPins.ps1 (GE-Enforce entry) and Get-ShopDBServerPin.ps1. - AGENT-CONTRACT.md documents per-PC credentials; the rollout runbook covers phases, gates, rollback, pin and wave entries, and every place the shared key lives and how to clear it at cutover. - Removed the 09-14 certificate design notes and dated session reports, and two VM notes that carried a live collector key and advised plain http. - Corrected the GE-Enforce collector note, which claimed a plain registry token is protected by ACL.
This commit is contained in:
@@ -12,7 +12,8 @@ schema changes, update this file in the same change.
|
||||
key is a UI-managed, endpoints-only service token minted from Settings ->
|
||||
Endpoint agent key (section 7). A bootstrap `.env` collector key still works
|
||||
until the first UI key is generated, then it is switched off (section 7). No
|
||||
querystring fallback.
|
||||
querystring fallback. Agents 1.20 and later enroll and then send
|
||||
`X-Endpoint-Credential` instead of the shared key (section 9).
|
||||
- Body: `application/json`, a single flat object (NOT an array). One POST per
|
||||
report per endpoint.
|
||||
- Identity field: `hostname` (required, case-insensitive).
|
||||
@@ -578,3 +579,130 @@ oversized payload rejected by the server returns nothing at all.
|
||||
Timed-out runs do not carry output: the script is still running and still holds
|
||||
the capture file open, so anything read at that moment would be misleading.
|
||||
|
||||
## 9. Per-PC credentials
|
||||
|
||||
Each endpoint authenticates with its OWN secret, readable only by SYSTEM on
|
||||
that PC. The server derives the calling asset from the secret. The shared fleet
|
||||
key (section 7) keeps working for PCs that have not enrolled until an admin
|
||||
switches it off, and is refused for any PC that has enrolled. The rollout is in
|
||||
`docs/PER-PC-CREDENTIALS-ROLLOUT.md`.
|
||||
|
||||
### 9.1 Headers
|
||||
|
||||
| State | Header | Value |
|
||||
|---|---|---|
|
||||
| Not enrolled | `X-API-Key` | the shared endpoints key, as today |
|
||||
| Enrolled | `X-Endpoint-Credential` | `sdbe_` + 43 chars base64url (32 random bytes) |
|
||||
|
||||
Never both on one request. The server stores only the SHA-256 of the full
|
||||
credential plus its first 12 characters (`credentialprefix`) for display.
|
||||
|
||||
### 9.2 Enroll
|
||||
|
||||
`POST {baseurl}/api/endpoints/enroll`, authenticated with `X-API-Key` while
|
||||
`plugin.endpoints.sharedkeyallowed` is true. With the shared key switched off
|
||||
the route takes no auth, but then only returns a credential for a request an
|
||||
admin has approved.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"hostname": "SHOP-PC-014", // 1-255
|
||||
"serialnumber": "5CD1234ABC", // 1-128, Win32_BIOS.SerialNumber
|
||||
"deviceid": null, // HKLM\SOFTWARE\GE\ShopDB DeviceId or null
|
||||
"enrollnonce": "<43 chars>", // 32-128 [A-Za-z0-9_-], generated once, persisted
|
||||
"agentversion": "1.20.0"
|
||||
}
|
||||
```
|
||||
|
||||
| Status | Body | Agent action |
|
||||
|---|---|---|
|
||||
| 201 | `{"status":"enrolled","credential":"sdbe_...","assetid":123}` | store, switch headers, no restart |
|
||||
| 202 | `{"status":"pending","requestid":45,"reason":"serial mismatch"}` | retry every 15 min with jitter |
|
||||
| 403 `ENROLL_NONCE_MISMATCH` | error envelope | an approved request belongs to a different nonce; keep retrying, an admin must reject and re-approve |
|
||||
| other | error envelope | back off 1, 5, 15 min (cap) |
|
||||
|
||||
Auto-issue happens only when `plugin.endpoints.autoenroll` is true, the shared
|
||||
key authenticated the call, 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 the reasons `unknown
|
||||
hostname`, `duplicate hostname`, `no serial on record`, `serial mismatch`,
|
||||
`already enrolled`, `awaiting approval`, `nonce changed`. The credential is in
|
||||
the 201 body exactly once.
|
||||
|
||||
### 9.3 Error codes
|
||||
|
||||
All use the standard error envelope (`data.error.code`).
|
||||
|
||||
| HTTP | Code | Meaning | Agent action |
|
||||
|---|---|---|---|
|
||||
| 401 | `CREDENTIAL_INVALID` | unknown or revoked credential | delete credential.bin, fall back to the shared key, wake enrollment |
|
||||
| 401 | `SHARED_KEY_REFUSED` | shared key sent, but this PC is enrolled or the shared key is switched off | wake enrollment; use the credential |
|
||||
| 403 | `ASSET_MISMATCH` | the credential's asset differs from the hostname/assetid named in the request | log; do not retry the same request |
|
||||
|
||||
Routes that authenticate this way: `POST /api/endpoints/collect` (same handler
|
||||
as `/api/collector/endpoints`), `GET /api/endpoints/monitors/resolved`,
|
||||
`GET /api/endpoints/commands/next`, `POST /api/endpoints/commands/<id>/result`,
|
||||
`GET /api/endpoints/apppackage/...`, `GET /api/endpoints/scriptbody/<id>`,
|
||||
`POST /api/endpoints/operatorreports`, `GET /api/endpoints/alerts`,
|
||||
`POST /api/endpoints/alerts/<id>/acknowledge`. With a credential, a missing
|
||||
hostname is allowed and the asset comes from the credential; an acknowledge with
|
||||
assetid 0 or absent means the caller. The core collector
|
||||
(`/api/collector/computers`, `/api/collector/printers`) accepts the same header
|
||||
for the SYSTEM reporter scripts.
|
||||
|
||||
### 9.4 Storage on the endpoint
|
||||
|
||||
- `%ProgramData%\ShopDB\EndpointAgent\credential\` is ACLed SYSTEM and
|
||||
Administrators only.
|
||||
- `credential.bin` = DPAPI `LocalMachine` of UTF-8 JSON
|
||||
`{"credential":"sdbe_...","assetid":123,"issuedat":"...","baseurl":"..."}`.
|
||||
SYSTEM scripts read it with
|
||||
`[System.Security.Cryptography.ProtectedData]::Unprotect(bytes, $null, 'LocalMachine')`
|
||||
and must not send the credential to a URL outside `baseurl`.
|
||||
- `enroll.json` = `{"enrollnonce":"...","createdat":"..."}`, plain, same folder.
|
||||
- The shared key in the registry and appsettings.json is never deleted by the
|
||||
agent; clearing it is a cutover step in the runbook.
|
||||
|
||||
### 9.5 Server pins
|
||||
|
||||
- `HKLM\SOFTWARE\GE\ShopDB` `ServerPins` (REG_MULTI_SZ, both registry views)
|
||||
merged with `serverpins` (string array) in appsettings.json. Legacy
|
||||
`servercertthumbprint` is still honoured.
|
||||
- A pin is the lowercase hex SHA-256 of the server leaf certificate's
|
||||
SubjectPublicKeyInfo. The Server Certificate settings page shows it
|
||||
(`spkisha256` on `GET /api/endpoints/settings/server-cert`);
|
||||
`client/Get-ShopDBServerPin.ps1` computes it from a URL or a file;
|
||||
`client/Set-ShopDBServerPins.ps1` writes it.
|
||||
- Any pin configured: accept the handshake if and only if the leaf key hash is
|
||||
in the set (chain and name errors ignored). No pin: require a clean Windows
|
||||
validation.
|
||||
|
||||
### 9.6 Tray pipe
|
||||
|
||||
The tray holds no secret and talks to the SYSTEM agent over
|
||||
`\\.\pipe\ShopDB.EndpointAgent.v1` (SYSTEM and Administrators full control,
|
||||
INTERACTIVE read/write, 8 instances). One UTF-8 JSON object per line, request
|
||||
then response, then close; request max 64 KB, read timeout 10 s.
|
||||
|
||||
| Request | Response |
|
||||
|---|---|
|
||||
| `{"op":"ping"}` | `{"ok":true,"agentversion":"1.20.0","enrolled":true,"assetid":123}` |
|
||||
| `{"op":"sendstatus"}` | `{"ok":true}` or `{"ok":false,"error":"..."}` |
|
||||
| `{"op":"sendreport","spoolpath":"C:\\Users\\<user>\\...\\report-xxxx"}` | `{"ok":true}` or `{"ok":false,"error":"..."}`; the path must be inside the caller's own profile and not a reparse point |
|
||||
| `{"op":"alerts"}` | `{"ok":true,"alerts":[ ...same objects as GET /alerts... ]}` |
|
||||
| `{"op":"ackalert","notificationid":42}` | `{"ok":true}` or `{"ok":false,"error":"..."}` |
|
||||
|
||||
The caller is identified from the pipe (impersonation user name and SID), never
|
||||
from the request body.
|
||||
|
||||
### 9.7 Admin API (WebUI side, for reference)
|
||||
|
||||
Read needs `endpoints.fleet`; writes need the admin role and are audited.
|
||||
|
||||
- `GET /api/endpoints/enrollment` (`page`, `perpage` <= 100, `state`, `search`)
|
||||
- `POST /api/endpoints/enrollment/requests/<id>/approve` `{"assetid": n}`
|
||||
- `POST /api/endpoints/enrollment/requests/<id>/reject`
|
||||
- `POST /api/endpoints/enrollment/assets/<assetid>/revoke` (un-enroll)
|
||||
- `PUT /api/endpoints/enrollment/settings` `{"sharedkeyallowed": bool, "autoenroll": bool}`
|
||||
- `GET /api/endpoints/enrollment/migration` (`page`, `perpage` <= 100, `state`, `search`)
|
||||
- `POST /api/endpoints/enrollment/migration/wave` `{"assetids": [...]}` or `{"next": n}` (n <= 100)
|
||||
|
||||
Reference in New Issue
Block a user