Appearance
The device credential, claim by claim
WIN-DLP-EPIC-003 names five things: a one-time token, a device-bound key pair, a server-issued scoped credential, rotation, and revocation. Its reason is the one to keep in mind while reading: the device credential is what makes an event attributable, and a weak enrolment makes every incident arguable.
Four of the five are built and now tested. The fifth is half-built, and the half that exists is the half nobody can reach.
1. The one-time token
Issued from the console, held only as a hash, and validated in a single SQL predicate: not revoked, not expired, uses remaining, right tenant.
| Claim | Proved by |
|---|---|
| A token enrols exactly once | tests/enrollment_lifecycle.sh §1, and tests/e2e_vertical_slice.sh |
| An unknown token is refused | tests/enrollment_lifecycle.sh §2 |
| A revoked token is refused | as above |
| An expired token is refused | as above |
| All four refusals say the same thing | as above - asserted by comparing the messages, not by reading them |
That last row is the one worth having. The uniformity was a deliberate choice in enrollment.rs and was previously only a comment; an error that distinguished expired from revoked from never existed would let anyone holding a list of guesses learn which ones had once been real.
The refusals are probed through the agent binary, not with a hand-rolled HTTP body. Enrolment carries a real PKCS#10 CSR and the public key the endpoint actually holds (Ed25519 for software storage or ECDSA P-256 for TPM), and a shell script forging that body would be testing its own JSON - and would go on passing after the contract changed underneath it.
2. The device-bound key pair
Generated on the endpoint. The private key never leaves it, and the server never sees anything but the public half.
| Claim | Proved by |
|---|---|
| A verified Platform Crypto Provider key is selected as TPM-backed; an unavailable provider falls back; an unusable existing key is never overwritten | device_signer::tests::provider_selection_falls_back_only_when_no_existing_key_is_at_risk plus the Windows CNG path |
| The TPM key name is deterministic and an existing verified key is reopened, not replaced | device_signer::tests::the_persisted_key_name_is_stable_for_the_same_machine; NCryptCreatePersistedKey/NTE_EXISTS path |
| The TPM P-256 CSR proves possession of the exact enrolled key | device_signer::tests::tpm_csr_proves_possession_of_the_enrolled_p256_key |
| The key is wrapped by the platform keystore - DPAPI machine scope on Windows, a restricted file on Unix | keystore::tests::protection_round_trips_on_every_platform |
| An existing software/DPAPI identity keeps its key across an ordinary upgrade | identity::tests::an_existing_software_identity_is_not_silently_replaced_by_tpm |
| A restored TPM identity with no usable reference fails closed | identity::tests::a_restored_tpm_identity_without_its_platform_key_reference_fails_closed |
| An identity wrapped on another platform fails loudly rather than silently re-enrolling | keystore::tests::an_identity_from_another_platform_fails_loudly |
| A keystore failure is never the expected state | keystore::tests::a_keystore_failure_is_not_the_expected_state_anywhere |
| The identity file is not world-readable | identity::tests::the_identity_file_is_not_world_readable, and tests/e2e_vertical_slice.sh checks the mode is 600 on a real enrolment |
| A corrupt identity is an error, not a silent re-enrolment | identity::tests::a_corrupt_identity_is_an_error_not_a_silent_reenrollment |
| A missing identity is "not enrolled", not an error | identity::tests::a_missing_identity_is_none_not_an_error |
| The hardware id is stable and opaque | identity::tests::hardware_id_is_stable_and_opaque |
The pair at the end is the interesting one: corrupt and absent must lead to different places. An agent that re-enrolled itself because a file would not parse would quietly abandon the device record every incident is attributed to.
3. The scoped credential
A client certificate issued by a per-tenant device CA, its subject encoding the tenant and the device.
| Claim | Proved by |
|---|---|
| A certificate presented for another device is refused, even in observe mode | mtls::tests::a_certificate_for_another_device_is_refused_even_while_observing |
| The three modes - off, observe, required - behave as a table rather than as a chain of conditions | mtls tests, 14 of them over the 12-cell table |
| Every agent request is signed over a canonical string including the body hash, using the algorithm pinned at enrollment | dlp-crypto request_auth tests for Ed25519 and ECDSA P-256 |
| A replayed nonce is refused | tests/e2e_vertical_slice.sh §15 |
mTLS is Off by default. The certificate is issued and stored from the first enrolment regardless, so turning it on is a configuration change rather than a re-enrolment of the fleet.
4. Revocation
| Claim | Proved by |
|---|---|
| The console can revoke a device, and a reason is required | tests/enrollment_lifecycle.sh §4 |
| A revoked device can no longer check in | as above |
| The device's own row records it | as above |
| The revocation reaches the audit log | as above |
Before this audit, none of that was tested. authenticate_agent gates on status = 'active' and nothing exercised the clause, so a regression that dropped it would have passed every test in the repository while leaving every revoked endpoint able to report events as itself - which is exactly the "arguable incident" the epic is written against.
5. Rotation - built on the server, unreachable from the agent
POST /api/v1/agent/certificate works: it issues a fresh certificate from the tenant's device CA, updates the row, and writes an agent.certificate_rotated audit entry.
Nothing calls it. There is no request to that path anywhere in agent/core. The agent keeps the certificate it was issued at enrolment until it expires, and in Required mode that is the day the endpoint goes quiet.
device_cert_days is configuration, so somebody will set it to 90 and every endpoint in the fleet will lose its credential on the same day - three months after deployment, which is also the day the fleet is least able to say why.
Filed as DLP-261, with the shape it needs: rotate on a fraction of the lifetime remaining rather than on an expiry date, sign the request with the credential being rotated, keep the old certificate until the new one has worked once, and never let a failed rotation be fatal. An endpoint that bricked its own credential trying to renew it would be worse than one that expired.
6. What is not covered
- No unit tests in
enrollment.rs. Every claim above about the server side is proved by a shell test against a real server and a real database. That is the right level for SQL predicates and issuance, and it does mean the module has no test that runs incargo test. - Re-enrolment of a known machine - the
legacy_hardware_idmigration path - is covered bytests/hardware_id_migration.shand is not re-audited here. - Uninstall (
POST /api/v1/agent/uninstall) is a different lifecycle event and belongs withDLP-154, not here. - CI has not run it.
tests/enrollment_lifecycle.shis wired into thee2ejob, and no GitHub-hosted job ran from 2026-09-15 to 2026-10-01 (DLP-1215), and none of the Windows jobs has passed since (Windows CI status); the suite has run where a session ran it by hand.