Skip to content

Broker ↔ exordos-nac-agent protocol#

The source of truth for two implementations: the broker (exordos_nac, Python) and the agent (exordos_nac_agent, Go). Golden fixtures live in docs/protocol/fixtures/*.json; both sides are checked against them in tests, so drift is caught by a test rather than by review.

Contract version: v1; the broker accepts MIN_PROTOCOL_VERSION..PROTOCOL_VERSION (1..1). The four POST endpoints carry protocol in the body. The two versioned GET endpoints use X-NAC-Protocol; GET /v1/device/update/ is unversioned. Unsupported versions return protocol_unsupported (§0a). Transport: HTTP/1.1 + JSON over TLS. All endpoints except POST /v1/enroll/ use mTLS. Endpoints: POST /v1/enroll/, POST /v1/renew/, POST /v1/session/prove/, GET /v1/device/self/, GET /v1/dataplane/state/, POST /v1/dataplane/bindings/, GET /v1/device/update/.


0. Rules that must not be broken#

  1. device_uuid comes only from the CN of the verified client certificate, never from the request body. A body carrying device_uuid on an mTLS endpoint is 400 device_uuid_not_allowed — otherwise device A can act as device B (confused deputy).
  2. key_protection_claimed is a claim, not a fact. The broker must store it separately from key_protection_verified and never copy one into the other. verified is only ever written from evidence the server checked itself (§7); a device that sends none stays at software.
  3. Attestation evidence is optional. Enrollment includes attestation; proof requests include attestation_response and boot_attestation. Send null when a payload is unavailable; do not omit these fields (§1, §4a, §7).
  4. Key material formats: everything is PEM, keys are ECDSA P-256 (the only curve shared by Secure Enclave, TPM 2.0 and Windows PCP). Hashes are sha256:<hex>.
  5. A device has one NAC certificate. Its key is held by the configured keystore; hardware backends do not export private key material. NAC does not issue VPN or wireless credentials; network admission belongs to the existing infrastructure (system scope).
  6. An error is always JSON of the form {"error": "<code>", "message": "<human>", "retry_after": <sec|null>}. The code is machine-readable and comes from the list in §6.
  7. An agent ignores response fields it does not know, so a new field reaches a fleet that has not been updated as if it were absent. A field may be added to a response only if an agent that drops it still does the right thing. Anything that narrows what an existing answer permits — a revocation, a deny set, a new condition on a grant — needs a new X-NAC-Protocol version or a new endpoint: added as a field, it is silently ignored by every older gateway, which goes on enforcing the wider rule.
  8. GET /v1/device/update/ never changes. It is how an agent that can no longer talk to the rest of this API is replaced by one that can (§4d), so it takes nothing but the device certificate and ?version=, checks no protocol version, needs no policy, and answers {"agent_update": <offer|null>} in the shape of §4a's offer. A broker that changes any of that strands exactly the agents it exists for. Bumping the contract version is safe only because of this rule.

0a. Moving to a new contract version#

Upgrade the broker and agents in stages so gateways continue receiving responses in the protocol version they understand:

  1. Set PROTOCOL_VERSION to 2 and keep MIN_PROTOCOL_VERSION at 1. The broker must answer each request in the version it names.
  2. Roll out v2 agents by cohort. Update enforcement gateways manually; they are not in the device release channel (manual gateway upgrades).
  3. Check exordos-nac-broker device versions. Resolve or retire enrolled devices that have not checked in; their last reported version does not prove that they can use the recovery endpoint.
  4. Set MIN_PROTOCOL_VERSION to 2 only when every retained device supports unversioned GET /v1/device/update/ (§4d). Older agents without that path cannot recover from a version refusal.

1. POST /v1/enroll/#

Public, no IAM authentication. The gate is a one-time enroll_token. The broker records the owner for attribution; it does not resolve IAM groups for policy decisions. Policy matches operator-assigned device tags, and policy loading rejects owner_groups rules (policy model).

One CSR, one certificate. The certificate is valid for 60 days and is presented to the broker, which checks device status in its database on each request (system scope).

{
  "protocol": 1,
  "enroll_token": "1f3c…",            // one-time, TTL enforced by the broker
  "csr": "-----BEGIN CERTIFICATE REQUEST-----\n…",
  "hw_fingerprint": {
    "smbios_uuid": "4c4c4544-0…",     // null when unavailable
    "board_serial": "PF3M2K1S",
    "machine_id": "sha256:9ab…",      // /etc/machine-id, MachineGuid, IOPlatformUUID
    "ek_pub_sha256": null             // optional: the agent may report its pinned EK
  },
  "platform": {
    "os": "linux",                    // linux | windows | darwin
    "os_version": "24.04",
    "arch": "amd64",
    "hostname": "wks-1234",
    "agent_version": "0.1.0"
  },
  "key_protection_claimed": "hardware_bound",   // software | hardware_bound
  "degradation_reason": null,         // string when the backend fell back to software
  "attestation": null                 // §7: {"kind": "tpm2", …}; null when not attesting
}

Response 201:

{
  "device_uuid": "8f14e45f-ce4a-4c1e-9d1b-2a5c9f0e7b31",
  "cert": "-----BEGIN CERTIFICATE-----\n…",
  "ca_chain": ["-----BEGIN CERTIFICATE-----\n…"],
  "renew_after": "2026-07-27T02:00:00Z",   // one third of the remaining TTL
  "not_after": "2026-07-27T10:00:00Z",
  "credential_challenge": null              // §7, null when not attesting
}

The broker must: burn the token before issuing the certificate (a repeated enroll with the same token gets 409 token_already_used); verify the CSR is signed by the public key it carries; write an AuditEvent for every issuance.

CSR requirements: EC P-256 key, ecdsa-with-SHA256 signature; the CN is ignored (the broker assigns its own) and SANs in the CSR are ignored. Violations get 400 csr_invalid.


2. POST /v1/renew/#

mTLS with the current device certificate. The body never carries device_uuid (rule §0.1).

{
  "protocol": 1,
  "csr": "-----BEGIN CERTIFICATE REQUEST-----\n…",
  "hw_fingerprint": { … },                 // as in enroll
  "key_protection_claimed": "hardware_bound",
  "degradation_reason": null
}

Response 200 contains the renewed certificate, ca_chain, renew_after and not_after. It has no device_uuid or credential_challenge.

The device key does not change on renew: the agent sends a CSR for the same public key. Replacing a lost key requires fresh enrollment under a new identity (§3). The broker compares the CSR public key with the key of the presented mTLS certificate; a mismatch is 400 key_rotation_not_allowed.

Downgrading the level (hardware_bound → software) is allowed on renew and is not a refusal: TPMs break non-maliciously (key-loss recovery). The broker updates key_protection_claimed, writes an AuditEvent with degradation_reason, and issues the certificate. Cutting access down is the Policy Engine's decision, not the broker's.


3. Replacing a lost key#

Renewal requires the existing device key and certificate. If the key is lost, reset the agent state and enroll with a fresh token. The device receives a new device_uuid; block or delete the old record separately. hw_fingerprint is diagnostic data and does not authorize key replacement. See blocking and key loss.


4. GET /v1/device/self/#

mTLS, diagnostics. 200:

{
  "device_uuid": "8f14e45f-…",
  "status": "ACTIVE",                          // ACTIVE|BLOCKED
  "key_protection_claimed": "hardware_bound",
  "key_protection_verified": "software",       // software | attested | device_attested
  "cert_not_after": "2026-07-27T10:00:00Z",
  "renew_after": "2026-07-27T02:00:00Z"
}

key_protection_verified is exposed on purpose: the agent shows in status that the server has not confirmed the key is hardware-backed. Otherwise the gap between claimed and verified stays invisible until the first incident.


4a. POST /v1/session/prove/#

mTLS with the device certificate - the only credential this product issues. The software keystore can be copied.

{
  "protocol": 1,
  "posture_report": {
    "collected_at": "2026-08-14T12:00:00Z",       // required whenever signals are present
    "signals": { "disk_encryption": true, "secure_boot": true, "os_version": "24.04" }
  },
  "attestation_response": { … },                  // answer to the outstanding challenge, or null
  "boot_attestation": null                        // TPM quote, or null without TPM evidence
}

agent_update is the release this deployment is moving to, and null is the ordinary answer - a device already on the target, a cohort that has not opened, or a deployment rolling nothing out. It rides this response rather than an endpoint of its own because the proof already happens every few minutes over a connection the device key authenticated: a second channel would be a second thing to authenticate, to reach through a quarantine, and to be down.

The broker distributes; it does not authorise. The signature is Ed25519 over the payload below, made off the broker by a release key it does not hold, and the agent checks it against a key installed with the agent. A compromised broker can withhold a release or offer an old one; it cannot make a device trust a binary the release key never signed.

exordos-nac-agent-release/1
version=0.4.0
os=linux
arch=amd64
format=deb
sha256=9f2c…

Newline-joined key=value in exactly that order, with a trailing newline, rather than canonical JSON: two implementations in two languages have to produce the same bytes, and JSON canonicalisation is where that quietly stops being true.

What is signed identifies the bits and never says where they are kept or when they move. The share of the fleet a release is open to moves several times during a rollout, and putting it under the signature would turn an offline key into an operational one. url is outside it for a related reason: a broker answers on every address in broker_endpoints, and the one a device is sent to is the entry that device is talking to - so a signed URL would be a release that installs on one network and cannot be downloaded on the other. The broker therefore chooses where the bytes come from and never which bytes are accepted: the agent hashes what it downloaded and discards anything that is not the signed digest.

The broker also writes back what the report says about the machine itself - agent_version, platform, os_version, package_format - into the device's platform block, which enrollment had otherwise filled in once and never again. Still informational and still a claim: the posture policy reads the signal from the report, never from that row. What it buys is a fleet inventory that is not the day everybody was installed, which is where any staged agent rollout starts (exordos-nac-broker device versions).

package_format is deb or rpm: the package manager that installed the agent, absent when the agent was not installed from a package. A release can list the same platform once per format, and the device is offered the artefact in its own format and nothing if the release has none. A device that has never reported a format is offered [nac] default_package_format, or the only format the release has for its platform.

200:

{
  "session_uuid": "b7c1…",
  "address": "10.100.0.7",                     // observed, not claimed
  "state": "proven",
  "grants": ["base", "eng"],                   // resource set names, empty on a denial
  "rule": "engineering-full",                  // null when nothing matched
  "reason": "matched engineering-full",
  "key_protection_verified": "device_attested",
  "posture": "degraded",                       // the server's verdict, not the device's
  "posture_failed": ["the disk is encrypted"], // in the policy author's words
  "posture_reason": "failed: the disk is encrypted",
  "credential_challenge": { … },               // answered by the next proof
  "agent_update": {                            // null unless a rollout has opened
    "version": "0.4.0",
    "os": "linux", "arch": "amd64", "format": "deb",
    "url": "https://releases.corp/exordos-nac-agent_0.4.0_amd64.deb",
    "sha256": "9f2c…",
    "signature": "base64 Ed25519"
  },
  "lease_expires_at": "2026-08-14T12:15:00Z",
  "lease_seconds": 900,
  "enforced": true,                            // is anything responsible for this address
  "enforced_from": ["10.10.0.0/22"],           // where the fleet is enforced; empty when unstated
  "enforced_to": ["10.0.5.0/24"],              // what it is enforced towards
  "broker_endpoints": ["https://broker.corp"]  // every address this broker answers on
}

The answer says whether anything will act on it. enforced is false when no enrolled enforcement point is responsible for the address the broker observed: the proof succeeded, the grants are real, and no packet anywhere is treated differently. enforced_from is the union of the prefixes those enforcement points cover, and the agent keeps it to check its own source address before the next proof — which is how a laptop with two tunnels refuses to prove over the wrong one without anybody naming an interface. Empty means a single gateway responsible for everything, so there is no narrower plan to state and no check for a device to apply.

enforced_to is what the device checks its routing against, and broker_endpoints is how it reaches the broker at all. A deployment with one enforced network could take "which way is the broker" as the answer to "which attachment carries my traffic". With a tunnel and an office segment it cannot: the two are enforced by different gateways, out of different address pools, and they reach the same broker at different addresses. So the broker states both, and the agent asks the kernel which source address it would use for each enforced destination. All of them must agree, and the answer must be the address the broker observed - otherwise the grant lands on an attachment no packet uses, and every field in this response still says the device is healthy.

The endpoint list has three consumers on purpose: devices pick the one whose route leaves by the attachment they are enforced on, the policy compiler puts every one of them into the bootstrap path so an unproven device can reach the broker on whichever network it is attached to, and exordos-nac-broker policy routes prints them with the resource prefixes for the DHCP and VPN push lists. One list, because a second place to write the broker's address is a second place for it to be wrong - and being wrong there means a device that can never prove itself.

Recovery jitter is local to the agent. Before the first proof after rediscovering the broker, or after its network attachment changes, each device waits a stable UUID-derived delay from zero up to three seconds. This spreads requests from devices returning together. The window is fixed in the agent; the broker does not configure it through the protocol.

The request names no session. The broker keys its answer to the address the request arrived from, which it observes rather than being told — it lives inside the private network, so that is the address the network gave the device. A value the device carried could be borrowed; an address it is speaking from cannot. The same device proving again from the same address extends one session rather than opening another.

A lease that ran out closes its session. The device lost its grants when the lease expired, and the row is closed to say so: a proof arriving afterwards starts a new attachment rather than reviving the old one. Sessions belonging to the proving device are closed on this path; exordos-nac-broker session reap does the same for machines that stopped proving altogether.

Attestation is confirmed here, not at renewal. A certificate that lives sixty days makes renewal a seasonal event, and whether the TPM is still the same one is worth checking when access is asked for — which is every fifteen minutes. The answer to the previous challenge comes in, the next challenge goes out.

A device that cannot answer is not refused. It drops to the level it can prove and policy decides what that costs; refusing would take access away over a cleared TPM (key-loss recovery). That includes an answer to a challenge the broker no longer holds — which is what a machine returning from a long absence has.

A denial is a 200. An empty grants with the rule that produced it is an answer; the error codes below are for requests that could not be decided at all. "Proved and got nothing" and "has not proved yet" are different states to an operator looking at a stuck machine.

The device sends signals; the broker decides what they mean. There is no field for a verdict, because a machine that could send one would be grading itself. The rules live in the policy file (posture.require), so tightening them is a policy change rather than a fleet-wide agent release.

A signal the platform could not measure is absent, never false. A check that needs it fails: silence is not a pass. A report with no collected_at is refused, and one older than posture.max_age_seconds makes the posture unknown — which is a different answer from degraded, and usually a broken agent rather than a device somebody has to fix.

What failed comes back to the device, so its owner learns what to fix from their own machine. What a compromised agent can still do is lie about a signal; that is the honest limit of endpoint posture, and it is why posture stands next to key_protection, which the server verifies rather than reads.


4b. GET /v1/dataplane/state/#

mTLS with the device certificate, and only for a device carrying the role:enforcer tag. An enforcement point is an ordinary enrolled device: same enrollment, same key in the same TPM, and a tag only an operator can set when issuing the token (gateway enforcement).

200:

{
  "policy_version": "1296929d9a7efdfd",       // digest of the compiled ruleset
  "ruleset": "table inet nac {\n…",            // load when the version changes
  "lease_seconds": 900,
  "grants": [
    {
      "address": "10.100.0.7",
      "sets": ["base", "eng"],                // each is a granted_<set> in the ruleset
      "lease_expires_at": "2026-08-14T12:15:00Z",
      "device_uuid": "8f14e45f-…",
      "mac": "52:54:00:aa:bb:cc"          // the frame this address belongs to, or ""
    }
  ],
  "conflicts": [
    { "address": "10.0.0.5", "device_uuids": ["8f14…", "9a2e…"] }
  ]
}

Pull, not push. The broker answers questions from inside the private network rather than holding a delivery queue per gateway.

A conditional question, for the cadence that notices. The answer carries an ETag, and an enforcement point sends it back:

GET /v1/dataplane/state/
If-None-Match: "1755351600000000.4.1296929d9a7efdfd.51ab8e0d.d41d8cd98f00b204"

304 Not Modified
ETag: "1755351603000000.4.1296929d9a7efdfd.51ab8e0d.d41d8cd98f00b204"

304 means nothing this gateway installs has changed: no body, nothing to apply, and the elements already in its kernel keep their timeouts. The token is opaque and always the broker's — it names a moment on the broker's clock, so a gateway whose own clock is wrong cannot ask about a moment that never happened. Every answer re-issues it, 304 included, and a token this broker did not issue simply gets the full answer.

Two cadences, and only one of them is the lease. An enforcement point asks this question every few seconds, so a device that has just proven itself is enforced in seconds rather than at the next lease-cadence pull. On the lease cadence it asks without the header: rewriting elements is what renews them, and a broker answering 304 forever must not be able to keep that from happening.

What the broker compares is what a gateway installs — the grants, the conflicts, the ranges and the ruleset version — not the whole body. lease_expires_at moves every time a device proves itself and reaches nothing on the wire, so a renewal somewhere in the fleet is not a change here.

A grant is a list of resource sets, and the ruleset declares where they go. Access is the union of every policy rule the device matched, and each set in sets is a granted_<set> membership set in the ruleset the same answer carries. Only sets that ruleset declares are listed: a session decided under the previous policy can name one that is gone, and an enforcer told to fill a set that does not exist fails its whole transaction. The enforcer checks the same thing again before writing anything.

Every live grant is returned every time, changed or not. The set elements carry a timeout, so rewriting them is the lease renewal — an enforcer that stops polling lets access expire rather than freezing it.

A contested address is enforced for nobody. The grants are keyed on the address, so two devices claiming one would mean handing one machine's grants to the other; "the last one seen" would do it silently. Both are dropped and the contest is listed in conflicts, because an operator hunting a device with no access has nothing else to look at. A deployment that NATs before the enforcement point cannot be enforced per device, and this is where that shows up.

A blocked device is gone from the answer immediately, not when its lease expires, and its session is closed on the way.

mac is what keeps a binding alive across a restart. It is the frame an enforcement point once measured for that address, kept on the session rather than in one gateway's kernel: a gateway that reboots, or a second one that has never seen the address, installs this instead of learning it from whoever is holding the address at that moment - which is the machine the binding exists to refuse. Empty means nobody has bound it: a tunnel with no L2 at all, an address behind a router, or a grant installed before its neighbour was seen.


4c. POST /v1/dataplane/bindings/#

mTLS with the device certificate, role:enforcer, the same caller as 4b. What the enforcement point measured about who is answering for an address.

{
  "protocol": 1,
  "changed": [
    { "address": "10.20.0.7",
      "was": "52:54:00:aa:bb:cc",     // the MAC the grant was bound to
      "now": "52:54:00:de:ad:be" }    // the MAC answering for it now
  ],
  "bound": [
    // A pin this enforcement point measured itself, so the binding outlives its
    // kernel. Sent once, when it learns one; a binding installed from `mac` in
    // the state answer is already known and is not sent back.
    { "address": "10.30.0.9", "mac": "52:54:00:11:22:33" }
  ]
}

200:

{
  "closed": [
    { "address": "10.20.0.7", "session_uuid": "3f2a…", "device_uuid": "8f14e45f-…" }
  ],
  "bound": [
    { "address": "10.30.0.9", "mac": "52:54:00:11:22:33", "session_uuid": "9d1c…" }
  ]
}

The enforcer measures, the broker decides. The same split posture keeps and for the same reason: only the enforcement point can see the segment, and only the broker knows what a session is. What is reported is an observation, not a verdict.

A change is reported, an absence never is. A quiet machine's neighbour entry ages out constantly. Reading that as a handover would take access from devices that did nothing, which is how a control becomes the reason people disable it.

The local cut has already happened by the time this is called: the enforcer removed the element and flushed conntrack, because the evidence is on its side of the wire and a round trip is somebody else's access. This endpoint is what makes the broker close the session, so the grant stops being handed back on every fetch.

A binding is recorded only into a session that has none, and the recorded pins come back in bound - which is not the same list as the one sent. Never overwriting is the point: replacing a binding by reporting one is exactly how a machine that just took an address would inherit what the binding was refusing it. What replaces a binding is the session ending, and that is a handover.

A report naming no change is ignored — empty fields, or was equal to now. That shape is a defect in the enforcer, and acting on it would turn a bug over there into lost access over here.

What a compromised enforcer gains is closing sessions inside its own enforces range, and nothing outside it — that bound is why the range exists. Inside it the damage is denial of service, which an enforcement point could always cause by simply not enforcing the traffic it was trusted with.

The address and the MAC are parsed, not taken. Both are pasted into an nftables transaction on every other enforcement point, so a value that is not an address and a frame is refused here, and refused again by the agent before it reaches nft.


4d. GET /v1/device/update/#

mTLS with the device certificate, and nothing else: no X-NAC-Protocol check, no body, no policy. ?version= is the version the agent runs; without it the broker uses the one the device last reported. 200:

{
  "agent_update": {                  // null when there is nothing for this device
    "version": "0.4.0",
    "os": "linux", "arch": "amd64", "format": "deb",
    "url": "https://releases.corp/exordos-nac-agent_0.4.0_amd64.deb",
    "sha256": "9f2c…",
    "signature": "…"                 // release key, made off the broker
  }
}

An offer rides the answer to a proof, so anything that breaks proving takes the offer with it. A broker that has moved to a contract version the agent does not speak refuses the proof before it makes one; so does a request field it now requires, and an answer the agent cannot decode is lost whole. The agent's only way of being replaced by one that works would be gone, and each machine would wait in quarantine for somebody to reinstall it.

So this endpoint is the one thing an agent can always ask. The decision is offer_for, the same as on a proof - cohort, platform, no downgrade unless signed as a rollback - and the offer is the same signed object, which the agent verifies against its release key whatever brought it. The agent asks only when the broker answered and the two could not agree - protocol_unsupported, any other refusal that is not a "try later", a response it cannot decode - and not more than once in fifteen minutes; a fleet that proves normally never calls it.


5. Device certificate profile#

Field Value
Algorithm ECDSA P-256, ecdsa-with-SHA256 signature
TTL 60 days
Subject CN=<device_uuid>
SAN URI nac://device/<device_uuid>
EKU clientAuth
KU digitalSignature
CRL/OCSP none — revocation is the device's status, read from the broker's database on every request (blocking a device)
AKID the issuing key's identifier, so a verifier without the CA file can still build a path
Issuer device-CA. The bootstrap CA created by exordos-nac-broker init-ca is self-signed. An offline root above it (device keys and CA) grows the chain by one certificate

key_protection is not placed in the certificate: it is a posture dimension, it changes between issuances, and the certificate outlives any particular answer. The source of truth is the broker database.

The agent renews when now >= renew_after (one third of the lifetime left, i.e. after 40 days): that leaves twenty days of retries while the broker is unreachable, and a machine switched off for a month comes back to a certificate that still works.


6. Error codes#

HTTP Code When
400 csr_invalid not P-256, broken signature, not a CSR
400 device_uuid_not_allowed device_uuid in the body of an mTLS request
400 key_rotation_not_allowed renew CSR carries a different public key
400 protocol_unsupported unknown version
400 attestation_invalid evidence this broker could not verify — never a silent downgrade to an unattested level
401 token_invalid no such enrollment token, or expired
401 client_cert_required mTLS endpoint without a verified certificate
403 device_blocked status BLOCKED
403 not_an_enforcer the dataplane endpoints, from a device without role:enforcer. Its own code because a gateway missing its tag and a device somebody blocked are different problems, and one of them sends the reader looking for who blocked it
404 device_unknown no such device_uuid, or a certificate whose CN resolves to nothing
404 protocol_unsupported a path the contract does not name
405 protocol_unsupported a method this path does not serve
409 token_already_used repeated enroll with the same token
429 rate_limited with retry_after
503 ca_unavailable signing service unreachable
503 policy_unavailable the broker has no policy it can decide with; fail closed, because "denied" and "there is no policy" must not look the same

The agent must tell them apart: 4xx (except 429) is not retried and is shown to the user; 429/5xx gets exponential backoff with jitter, capped at 15 minutes.

The last two share protocol_unsupported with the 400 above rather than carrying codes of their own: all three say "you asked for something this contract does not have", and a code is a thing both implementations pin to a fixture. What matters is that they are 4xx at all. Both used to be 503 ca_unavailable with retry_after: 60 - a healthy broker telling an agent it was down, over a path or a method that will never exist, and sending whoever read the log to look at the CA.


7. Attestation#

Everything above works without attestation, and a device that never sends any keeps working — it simply stays at key_protection_verified: software. This section is what lets the server stop taking the agent's word.

Two claims are proven separately, because collapsing them would overstate what is known:

Evidence Establishes Level
TPM2_Certify by a restricted AK, plus fixedTPM\|fixedParent on the key a self-consistent chain: the certified name is the supplied key's, and that key is the CSR's attested
a pinned EK answered through MakeCredential/ActivateCredential the same EK as the one pinned at enrollment device_attested

Read the second column literally, because the chain is rooted in nothing. Vendor EK certificate chains are not validated (attestation limits); without one, every structure the broker checks arrives from the device: the AK's restricted bit, the fixedTPM|fixedParent on the key, and the EK itself are all bytes the caller wrote. The checks are real and each one closes a way of cheating with part of a chain; what none of them can do is establish that a TPM exists.

An agent-controlled device can synthesize consistent AK, EK and PCR evidence in software. The broker may accept it as device_attested, and the audit trail does not distinguish it from genuine TPM evidence. These levels establish continuity from the identity accepted at enrollment, not hardware authenticity; enrollment relies on a single-use token issued by an operator to a named owner.

What the levels do carry is continuity from the moment of enrollment, which is what pinning buys and what it is for: the disk copied to a machine with a different TPM, and the TPM swapped or cleared under an already-enrolled device, both stop here. A cloned VM does not (§7 below, and attestation limits). Whoever holds the device at enrollment decides what is being pinned, and that is the trade this design took knowingly in exchange for not validating five vendors' certificate chains.

7.1 Evidence payload#

Enrollment sends the attestation object below in attestation. Session proof answers the credential challenge in attestation_response (§7.3) and sends boot evidence separately in boot_attestation (§7.3a).

{
  "kind": "tpm2",
  "ek_pub":       "<base64 TPMT_PUBLIC>",   // endorsement key: pinned on first use
  "ak_pub":       "<base64 TPMT_PUBLIC>",   // restricted signing key that signed below
  "key_pub":      "<base64 TPMT_PUBLIC>",   // the device key's own public area
  "certify_info": "<base64 TPMS_ATTEST>",   // TPM2_Certify output
  "certify_sig":  "<base64 TPMT_SIGNATURE>"
}

All five are TPM wire structures, not plain keys: MakeCredential operates on a TPMT_PUBLIC, and a certify signature means nothing without the AK's public area.

key_pub is not redundant with the CSR, and leaving it out is the mistake that is easy to make. A certify structure names a key by the hash of its public area, so without it a server can verify a flawless attestation about some key and then sign a certificate for a different one. The broker therefore recomputes the name from key_pub, matches it against certify_info, and matches key_pub against the CSR public key. Both links, or neither means anything.

7.2 Where the nonce comes from#

Neither exchange adds a round trip.

  • Enrollment uses the enrollment token as qualifying data: sha256("nac-enroll-v1" || token). There is no server nonce yet at that point, but the token already is what a nonce has to be — server-issued, unpredictable, single use — so evidence bound to it is unusable once the token is spent.
  • Every later proof answers the challenge issued with the previous response. The TPM recovers the secret with its EK, and the broker checks the returned value against the challenge. This establishes continuity to the EK pinned at enrollment; it does not establish that the EK belongs to genuine hardware (§7).
  • The boot quote, when one is asked for, is nonced with sha256("nac-quote-v1" || secret) from the same secret.

The enrollment and quote derivations must match on both sides exactly, or the attestation is rejected for answering the wrong nonce.

7.3 Credential challenge#

Returned as credential_challenge by /v1/enroll/ and /v1/session/prove/; it is null when no challenge is issued:

{
  "kind": "tpm2",
  "credential_blob":  "<base64 TPM2B_ID_OBJECT>",
  "encrypted_secret": "<base64 TPM2B_ENCRYPTED_SECRET>"
}

Answered in the next request:

{
  "kind": "tpm2",
  "credential_secret": "<base64 secret recovered in the TPM>"
}

Send this object as attestation_response on the next /v1/session/prove/ request. It contains only the recovered secret; any boot quote is a separate boot_attestation field.

7.3a Boot attestation#

Sent as boot_attestation on /v1/session/prove/, alongside the credential response and nonced by the same challenge. Optional in the same sense the credential response is: it is asked for on the continuity cadence, and between those a proof carries neither.

{
  "kind": "tpm2",
  "ak_pub":    "<base64 TPMT_PUBLIC of the AK that signs the quote>",
  "pcrs":      [0, 1, 2, 3, 4, 5, 6, 7],
  "values":    { "7": "<base64 PCR value>" },
  "quoted":    "<base64 TPMS_ATTEST>",
  "signature": "<base64 TPMT_SIGNATURE>"
}

Two of those fields are not the device's to choose, and both are checked against the signed structure rather than taken as sent:

  • ak_pub is the AK the certify chain already proved at enrollment — the broker holds its fingerprint and refuses a quote signed by anything else. The pin used to be taken from the first quote instead, which meant the first one was verified against a key the device supplied; a software key and invented PCR values were accepted, and every quote afterwards was self-signed;
  • pcrs is fixed at 0–7 by this contract, and what the broker checks is the selection inside the signed quote, never this field. A device choosing its own registers gets a real TPM to sign a true statement about nothing: PCR 23 is resettable at runtime, so a chain pinned over it alone reads the same across a reboot into anything.

With those held, this is the one measurement an agent compromised after enrollment cannot forge - the TPM signs it, not the software - and the reason posture stands beside key_protection rather than instead of it. The qualifier is load-bearing: the pin it rests on is the AK from the certify chain, so a device that enrolled with a forged chain signs its own quotes for ever, with whatever PCR values it likes. The broker derives verified_boot_state from it and nothing else derives it: pinned for the chain first seen (trust on first use, like the EK), changed when the same device quotes a different one, invalid when evidence arrived and did not verify — which includes evidence that does not parse. A device that simply does not send it keeps what it last proved, because the cadence must not be a way to lose access.

What an agent must not do is report the boot state itself. There is no field for it, and a policy asking for verified_boot_state is asking about the TPM's statement.

7.4 What the broker does with a failure#

Situation Outcome
no evidence at all verified stays software; absence of proof is not proof
evidence that does not verify 400 attestation_invalid plus an audit event — never a silent downgrade to the case above
a device that attested before and stops renewal proceeds, verified drops to software, the change is audited
kind other than tpm2 refused as unverifiable, even though the schema already holds cng and apple

The third row is deliberate. TPMs get cleared and machines get rebuilt; refusing the renewal would take a device's access away over a hardware event, which is the failure this whole design exists to avoid. Dropping the level and recording it lets the Policy Engine decide what it costs.


8. Fixtures#

docs/protocol/fixtures/:

enroll_request_tpm.json        enroll_response.json
enroll_request_software.json   enroll_response_attested.json
enroll_request_attested.json   renew_response.json
renew_request.json             renew_response_attested.json
renew_request_attested.json    device_self_response.json
device_update_response.json    errors.json

Key material in the fixtures is a fixed placeholder ("-----BEGIN CERTIFICATE REQUEST-----\nFIXTURE\n-----END …"): the fixtures pin down the shape of a request, not signatures. Signature verification belongs to each side's unit tests.

The agent keeps a copy in internal/protocol/testdata/; CI in both repositories compares the directory sha256 and fails on a mismatch. To change the contract, edit the fixtures here first, then the copy in the agent.