Skip to content

exordos_nac_agent#

NAC endpoint agent: proves device identity with a key that lives in a TPM 2.0 / Secure Enclave and never leaves it.

System architecture and the broker contract live in exordos_nac: docs/architecture.md, docs/protocol.md.

What it does#

Command On which machine
join every managed device, once: setup and enrollment from one ticket
enroll, renew, reset, status every managed device
prove --loop every managed device: this is what keeps access alive
enforce the enforcement point of a network, and only there
selftest nobody's machine - the full cycle against an in-process stub

The device key lives in a TPM 2.0 on Linux and Windows and in the Secure Enclave on macOS. What each platform gives, and what the server can check rather than believe, is docs/macos.md for Apple and docs/checks.md for what is asserted and by what - including the rows nothing reaches yet.

Running it without a server side#

The full cycle needs no Postgres, no restalchemy and no nginx:

make selftest                       # enroll, renew, reset, break scenarios
bin/exordos-nac-agent selftest --scenario full --tpm-device swtpm:127.0.0.1:2321

Scenarios: full, no-hardware (degradation path), key-missing, tpm-cleared, re-enroll (a dead key, reset and a fresh token - the way back now that /v1/recovery is withdrawn, see key-loss recovery).

To drive the agent by hand, run the stub as a server:

bin/nac-broker-stub --listen 127.0.0.1:8443 --token dev-token --ca-file /tmp/ca.pem
bin/exordos-nac-agent configure --dir /tmp/agent --broker https://127.0.0.1:8443 --ca /tmp/ca.pem
bin/exordos-nac-agent enroll --dir /tmp/agent --broker https://127.0.0.1:8443 --token dev-token
bin/exordos-nac-agent status --dir /tmp/agent

The stub's CA is ephemeral: restarting it invalidates every certificate it issued, so a restart means re-enrolling.

For a real TPM without a physical one, point --tpm-device at swtpm:

swtpm socket --tpm2 --tpmstate dir=/tmp/swtpm --server type=tcp,port=2321 \
    --ctrl type=tcp,port=2322 --flags not-need-init &

What "cannot be impersonated" means today#

On Linux and Windows with their hardware backends, the key is non-exportable: there is nothing to steal off disk, and copying files to another machine does not move the identity. The ordinary macOS installation now has the same credential property. A signed .app runs the agent as a LaunchAgent under the existing logged-in user and stores a permanent P-256 private key in the Secure Enclave through the Data Protection Keychain. Agent state contains only the public certificate, SPKI hash and opaque device_uuid. Copying all of those files does not copy the key needed for mTLS or a fresh challenge.

The ordinary macOS configuration is fail-closed:

{
  "broker_url": "https://broker.example",
  "require_hardware": true
}

Setting up a machine#

exordos-nac-agent join      # paste the ticket, or pipe it in

One ticket from exordos-nac-broker token new --join carries the broker URL, a one-time enrollment token, and the fingerprint of the certificate this machine must trust. The agent connects, pins whichever certificate in the presented chain has that hash, writes config.json, and enrols - and writes nothing at all if the chain does not match, so a machine that fails to join is one that was never configured rather than one pointing at a broker it cannot authenticate.

This exists because the alternative was worse than tedious. Copying broker-ca.crt onto the machine by hand meant fetching it over a channel with no integrity, so whoever answered that download chose which broker the machine would trust for the rest of its life. The ticket carries a fingerprint rather than the certificate, beside the token, over the channel the token already needed. docs/protocol.md §0a has the format and the reasoning.

The ticket is a secret because the token in it is - hand it over the way a token is handed over, and pipe it rather than typing it where a shell history keeps it.

join leaves the machine finished. It enrols, and then it enables and starts the agent's own service - exordos-nac-agent-prove under systemd, the registered service on Windows, the LaunchAgent in the user's own session on a Mac - so nothing else has to be typed for the laptop to keep its access. Starting was a separate step for exactly as long as it took somebody to forget it: a device that enrolled and never started the loop looks healthy for fifteen minutes, because it has an identity and a proof behind it, and is then in quarantine with no way out - leaving quarantine requires proving. --start=false keeps the old behaviour, and join on a machine with no installed service says so rather than implying a loop that is running.

The other half of "finished" is the release key, and it needs nothing typed either: an agent installed from a release carries the key it checks offers against (see Being told about a release). join prints which of the two states this machine is in, just above what it did about the service.

configure and enroll remain, and are what an installer with the broker already baked in calls: msiexec /i ... BROKER_URL=https://broker.corp writes the config, and a token arrives separately. join is for a person setting up a machine.

Configuration lives in /etc/exordos-nac-agent/config.json on Linux, beside the state on Windows in %ProgramData%, and in /Library/Application Support/exordos_nac on macOS - machine-wide there, while the state beside it is per user. exordos-nac-agent configure --broker ... writes it, merging rather than replacing; a machine installed before the file moved keeps its copy in the state directory, which is still searched second.

require_hardware prevents both provisioning and read-only renewal from selecting a software key. The keychain item uses AfterFirstUnlockThisDeviceOnly, private-key-only access control and a stable application tag in the signed app's default access group. It therefore needs the user's Aqua bootstrap domain after first login/unlock; the System LaunchDaemon and SSH Background domains are deliberately unsupported. Removing the app does not bind the Mac to an organisation. An explicit agent reset/revocation deletes the exact key and device record.

This path requires a Developer ID signed and notarised app bundle with an embedded provisioning profile authorising its application identifier and Keychain Sharing group. That bundle is what make dist-macos-pkg builds and what the pkg installs, and packaging/macos/verify.sh refuses a release without it. A device-restricted development profile has passed the live hardware test; an all-device distribution profile is an Apple account artefact rather than a build step, and the copy-negative on a second physical Mac is still owed.

Each macOS account on one Mac is a separate device. The key belongs to a per-user LaunchAgent because the data protection keychain does, so the state directory is ~/Library/Application Support/exordos_nac while the configuration an administrator writes is machine-wide. Enrolment therefore runs in the user's own session, without sudo, and is refused as root - which would put the key in root's keychain, where the LaunchAgent that renews the lease could never load it.

The two protection columns remain separate. The ordinary path truthfully reports key_protection_claimed=hardware_bound, because the local backend checks the token attribute and rejects private-key export. macOS exposes no usable public attestation authority for this key, so the broker will never raise this device to attested. What it writes instead is key_protection_verified=hardware_bound, the ceiling of the secure-enclave root of trust an operator declared for the machine - not a level this Mac proved, but the highest one this hardware can hold. The server can prove continuity and possession of the enrolled, non-copyable credential; it cannot independently prove the Mac's Apple serial or UDID, or that the first-enrollment key was hardware-generated.

Apple's Managed Device Attestation would answer those, and it is not implemented: it needs MDM/ADE enrolment, an ACME endpoint on the broker and a managed identity on the device. The decision is that macOS claims hardware and is never attested, and the claimed/verified split is what keeps that honest.

The architecture distinguishes an agent claim from a server-verified fact; the agent must preserve that distinction.

A wrong clock says so. A machine days out of true fails TLS before any check this product makes, and the error names certificates rather than the thing that is actually wrong; the agent now prints what this machine thinks the time is next to the certificate's own dates. The broker does the other half from posture freshness (posture evaluation).

A device does not descend to a copyable key in silence. reset destroys key material best effort, so an unreachable TPM leaves the blob behind and the next enrollment would settle for a software key - correctly reported, narrowly granted, and unnoticed by everyone. Enrollment refuses that descent and names the way out (--accept-software-key); a machine that never held a hardware key is unaffected, and the decision, once taken, is remembered in key-history.json - the one file a reset does not remove.

Being told about a release, and taking it#

The broker can name the agent release this deployment is moving to, and the offer rides the answer to prove - the request this device already makes every few minutes over a connection its own key authenticated. The agent checks the offer, reports it, and - unless it was told otherwise - installs it.

An agent the broker can no longer talk to still finds its release. Whatever breaks a proof - a contract version this agent does not speak, a refusal that is not "try later", an answer it cannot decode - also takes away the offer that rides it, so on exactly those failures the loop asks GET /v1/device/update/ instead, at most once in fifteen minutes. That endpoint takes nothing but the device certificate and is frozen on the broker (exordos_nac docs/protocol.md §4d), and what it returns is checked and installed exactly like an offer that came with a proof. The one limit is the certificate itself: an agent that can neither prove nor renew has until it expires, sixty days, for a release to be opened to it.

The check is the point. Each artefact carries an Ed25519 signature made off the broker by a release key the broker does not hold, and this agent verifies it against the key it was built with. That key is inside the binary, injected at link time from the machine that builds releases (make RELEASE_PUBLIC_KEY=..., and the RELEASE_PUBLIC_KEY repository variable in the release workflow), so an agent installed from a release can act on the next one with nothing configured.

It is not a new thing to trust: the key arrives inside the artefact whose signature already vouched for the binary now running, and it arrives by the one path the broker is not on - which is the whole property. A file still wins where an operator names one, and that is how the key is rotated without waiting for a build:

exordos-nac-agent configure --release-key /etc/exordos-nac-agent/release.pub

So a broker somebody has taken over can withhold a release, or offer an old one, and cannot make this machine trust a binary the release key never signed. An agent with neither key - a development build, or one somebody built themselves - says exactly that: NOT TRUSTED: no release key: this agent was built without one and none is configured, rather than deciding for itself that an unverifiable offer is fine. The offer reaches status and last-decision.json either way, because an offer that failed to verify is a statement about the broker and must not be the thing that gets dropped quietly. status answers before any offer arrives, too, which is the only moment the answer can still be changed cheaply:

updates         installed when signed by the release key built into this agent
update offered  0.4.0 (deb, signature verified)

What is signed is the version, the platform and the digest - which is the whole of what a file is. Two things are deliberately outside it. How much of the fleet the release is open to moves several times during a rollout, and signing it would turn an offline key into an operational one. And so is the address: a broker is reached at several, and the one this agent is sent to is built from whichever it used - so a signed URL would be a release that installs on one network and cannot be downloaded on the other. Neither can change which bytes this agent keeps: it hashes what it downloaded and deletes anything that hashes to something else, naming both digests.

The exact bytes are in exordos_nac docs/protocol.md, and both implementations assert the same literal payload: a disagreement about a newline would read, at this end, as somebody having tampered with the broker.

Signing one#

cmd/release-sign writes the manifest a broker serves, and the tag build runs it - so the signature is made where the release is built rather than by hand afterwards, which is a step that gets skipped and is invisible when it is. It needs the private half in RELEASE_PRIVATE_KEY (the release environment's secret; the environment is what a required reviewer attaches to), refuses a dist/ holding a package from an earlier build or two for one platform, and the workflow reads the result back with the public half alone before publishing it.

make release-manifest        # dist/release.yaml

The manifest lists each Linux platform twice, as a deb and as an rpm. The agent reports which of the two installed it as the package_format signal. The deb and the rpm each write their own name to /usr/lib/exordos-nac-agent/package-format, and status prints it as package. The broker offers the matching artefact. An agent too old to report a format is offered the broker's [nac] default_package_format, deb by default, so a broker serving an rpm fleet still on such agents needs default_package_format = rpm before the first make release-push, or those machines spend their three attempts on a package they cannot install. An agent that is offered the other format leaves the offer alone and does not count it as an attempt, which covers a broker too old to choose.

Public release mirror#

CI publishes every GitHub Release asset to https://repo.exordos.com/exordos_nac/<tag>/, including the signed release.yaml, SHA256SUMS, installers and documentation archives. The GitHub latest release is also copied to https://repo.exordos.com/exordos_nac/latest/; its VERSION file names the tag and is uploaded after all assets. The signed manifest is copied unchanged. To retry publication or mirror an older release, run the release workflow with mirror_tag set to its tag. This mode downloads the published assets and checks their SHA-256 sums without rebuilding or signing. An older release is published only under its own tag and does not update latest.

Carrying one to a broker#

Run this from a machine that can reach both this repository and the broker. The repository is private, and a broker usually cannot reach GitHub at all.

make release-download RELEASE_TAG=0.4.0      # or leave the tag out for the latest
make release-push BROKER=root@broker SHARE=0.1 RELEASE_PUBLIC_KEY=release.pub

release-download downloads the manifests into release/ and then only the files they name. It checks each file against the digest in the manifest. release-push copies the packages to the broker's releases directory and runs exordos-nac-broker release check there, with the key if one was given. The manifest goes in place only after the packages, so no device is offered a file that has not arrived yet. The command ends with release status.

The shares are set on the way out. Without SHARE the pushed manifest says what the release says, which is zero, so pushing again closes a rollout that was already open. Releases made before the combined manifest published the rpm packages in a separate release-rpm.yaml, which RELEASE_MANIFEST=release-rpm.yaml pushes.

These variables change where things go on the broker:

Variable Default
BROKER_RELEASES /var/lib/exordos/exordos_nac/releases
BROKER_MANIFEST /etc/exordos_nac/release.yaml, which [nac] agent_release_file must name
BROKER_CLI exordos-nac-broker
BROKER_SUDO sudo; set it empty to connect as root without sudo

Installing it#

On by default, and the default is the decision. A fleet whose agents update only when somebody visits them is a fleet running whatever it was installed with, and the version an endpoint runs is a security property - so the setting that moves is the one you get without asking. Not installing is what gets written down:

exordos-nac-agent configure --apply-updates=false

The other half is not a stance at all. An offer that cannot be verified is never installed, so a device with no release key at all reports offers and applies none; status says which of the two it is, because "told not to" and "has nothing to check with" are a misconfiguration apart. A release build has the key in it, so the second state is a development build or a self-built agent - and it is still worth printing, because it used to be every laptop set up by hand.

The device it must not be on by default is the gateway - an enforcement point that updated on the endpoints' schedule takes the network with it when a release is bad - and that is not held here. The broker offers a release to devices tagged role:enforcer by a share of its own, zero unless an operator sets it, so the property does not depend on every gateway carrying the right config file (exordos_nac docs/deploy.md §8).

The platform's own installer does the installing. The agent fetches the artefact, checks it against the digest the release key signed, and hands the file to dpkg, rpm or msiexec. A fourth mechanism written into this binary would re-decide what those already know - replacing a running file, keeping the operator's config, being rolled back by the tools an administrator has - inside the one process whose failure mode is a fleet that cannot reach anything.

It happens right after a proof succeeded, and then the agent exits. The lease just renewed is the margin: the device keeps its access for another quarter of an hour while it downloads and restarts, and nothing has to be delivered for that to stay true. Restart=always and launchd's KeepAlive are what bring back the binary that is now on disk, which is also the only thing that makes the new version reported - an agent that installed and kept running would keep saying the old number, and that number is what a staged rollout is widened on.

Three rails, all local to one device:

  • an offer whose signature did not verify is never installed;
  • one version is tried three times, an hour apart at least, and then left alone until the manifest names a different one - an artefact that will not install is offered to the whole cohort every few minutes, and without the cap each machine would fetch it all day;
  • the attempt is written to disk before the installer runs, because the failure being guarded against is the one that takes the agent down with it.
  • an offer older than the running agent is not installed unless the release key signed it as a rollback from a version this agent has not passed. A manifest is a target rather than a minimum, so a machine updated ahead of the fleet by hand is offered the fleet's version; and a broker can serve any release ever signed, including the one with the hole the next one closed. status says not applied: the offer is older than this agent, and no attempt is spent on it.

Going back on purpose is release-sign -version 0.0.6 -rollback-from 0.0.7: it writes rollback_from: "0.0.7" into the manifest and a rollback_from=0.0.7 line into every signature, so the broker cannot set or widen it, and an agent older than the field refuses the manifest as not verifying rather than installing it as an upgrade. The version is the newest agent the rollback replaces: an agent on 0.0.8 refuses it, so a rollback signed today cannot take back a release that closes the old version's hole tomorrow. rpm is told --oldpackage; dpkg and pacman need nothing. The MSI refuses to replace a newer version, so a Windows machine reports the rollback as one it cannot install - that is an uninstall and an install by an administrator. Rolling forward - the old code under a new version number - needs none of this and is usually the better way.

status says what became of the offer, which is a different sentence from the offer itself:

update offered  0.4.0 (deb, signature verified)
update          failed, will try again, attempt 2 of 3: dpkg: dependency problems

On macOS a root helper does the installing. The agent there is a LaunchAgent in the user's Aqua session, because that is where the keychain holding the device key is - and installer -pkg -target / needs root, which a user agent has none of. So the agent downloads and verifies the offer as everywhere else, then leaves a request in /Library/Application Support/exordos_nac/updates/queue and exits; launchd starts the helper (io.exordos.nac.updater, the same binary's update-helper) because the queue is not empty, and the package's postinstall restarts the agent into what it installed.

The helper is a root job fed by an unprivileged one, so it trusts nothing in the request (internal/update/helper.go):

  • the signature is checked again, against the key built into the binary or one named by a config file and key file root owns and nobody else can write - never the user's own configuration, which on a Mac is a place the agent may read;
  • the version rule is the agent's: never older unless signed as a rollback from a version the installed agent has not passed;
  • a request is read only under the name the agent writes, uid-<n>.json, and only if account n owns it; anything else in the queue is removed unread, a directory with its contents and a flagged file with its flag cleared;
  • the file the request names is copied into a directory only root can write, the copy is hashed against the signed digest, and the copy is what installer gets - so no path, link or swap can put other bytes under it.

What a local account can make it do is install a release the release key signed, sooner than the broker would have - never one older than the installed agent that the key did not sign as a rollback from it. The queue is 1733: every account can leave a request, none can list or remove another's. The helper's last decision is in updates/last.json, which status prints as update helper; what it printed is in the unified log under category updater (see below), and /Library/Logs/exordos-nac-updater.log keeps only a crash and the error a run ended with.

A Mac installed from a package that predates the helper updates to one that has it by hand or through its MDM, once; status says the update helper is not installed until then. macOS shows a "background item added" notice for the helper on install, which an MDM can pre-approve for the fleet (com.apple.servicemanagement).

Stopping a rollout is still the broker's file: share: 0, or remove the manifest. Nothing here halts one on its own when upgraded devices stop proving.

An agent upgrading into this version starts installing offers: its config file says nothing about updates, and saying nothing means installing.

Running it as a service#

On Linux, two units in packaging/systemd, and they are two different roles on two different kinds of machine.

exordos-nac-agent-prove.service     # every managed device
exordos-nac-agent-enforce.service   # the enforcement point, and only there

On Windows the MSI registers exordos-nac-agent with the Service Control Manager, running the same prove --loop. The SCM is not a supervisor a process can ignore: it wants StartServiceCtrlDispatcher within thirty seconds and kills whatever does not answer with error 1053, which reads as a hung agent rather than as one that was never a service. internal/platform.RunService is that handshake, and it asks the operating system whether the SCM started this process rather than taking a flag - so the same binary typed into a console still behaves like every other platform.

A service has no console, so what the loop prints goes to %ProgramData%\exordos_nac\logs\prove.log, capped and rolled once.

The binary is C:\Program Files\Exordos NAC\exordos-nac-agent.exe, and the MSI appends that folder to the machine PATH, so exordos-nac-agent status works in any console opened after the install. Run it elevated: the state directory is readable by SYSTEM and Administrators only.

On a Mac launchd gives a job no stdout either, and the plists name no file for it (io.exordos.nac.agent.plist says why). Started by launchd, the agent and the update helper write what they print to the unified log instead, under subsystem io.exordos.nac and categories agent and updater:

log show --last 1h --predicate 'subsystem == "io.exordos.nac"'
log stream --predicate 'subsystem == "io.exordos.nac"'

Every line is logged at the default level, which macOS keeps on disk and rotates itself; Console.app and a sysdiagnose see the same. Typed into Terminal, the same commands print where they always did.

prove --loop is not optional, it is how a device keeps its access. A grant is a lease: the element in the enforcement point's map carries a timeout, the session behind it expires, and both are renewed by exactly one thing - proving again. A machine that proved itself once at install time is back in quarantine a quarter of an hour later and cannot leave, because leaving requires proving.

The cadence comes from the lease the broker answers with - three quarters of it, jittered down towards two thirds by a stable fraction derived from the device UUID. Both halves of that are decisions. Late rather than early, because what a device needs is a fast first proof when somebody plugs in, and the gap left before the lease ends is minutes of retries rather than one attempt. Jittered, because a fleet that came up together would otherwise prove in unison, and after a broker outage longer than a lease every machine is in quarantine at the same instant, each proof costing a TPM signature, an attestation check and a policy decision. A refusal from the broker backs off the same way, up to five minutes, so devices that failed together do not return together.

A broker that does not answer is watched for rather than retried. No route, a route from an address nothing enforces, or a path that drops what it is given all mean the same thing: the way to the broker is down, and it comes back on its own at a moment nothing announces - the far end of a tunnel restarting, a router upstream. So the loop stops proving and looks for it with a bare TCP connection to the broker's port: no TPM signature here, and nothing at all on a broker the packets are not reaching. It looks at once, half a second after a link, an address or a route on the machine changes - the operating system says so on every platform the agent ships for (internal/netwatch) - and then every two to three seconds for a minute. Outside that minute, and after the first minute of an outage, it looks once a minute: an outage that ends by itself is usually over in the first minute, and anything longer is a laptop at home, where looking every few seconds for hours is only a radio kept awake. The price is the recovery nobody on this machine can see, the far end of a tunnel coming back, which is noticed within a minute. When the port answers, each device waits a stable UUID-derived delay from zero up to three seconds, fixed locally in the agent, so a fleet that found the broker in the same instant does not prove in it, and then proves. A port that accepts the connection and then loses the request is backed off too, up to a minute, because looking again would only find the port open again. The journal hears when the outage began, every quarter of an hour that it goes on, and when it ended:

exordos-nac-agent: the broker cannot be reached: ... dial tcp 10.130.0.13:443: i/o timeout; looking for it every few seconds
exordos-nac-agent: no broker for 15m2s (35 looks); now: ... i/o timeout
broker reachable again after 21m40s (42 looks)

The same notice keeps a lease honest. The session is keyed to the address the proof came from, so a device that roams from the office wire onto the tunnel, or is handed another address, has no access, whatever its lease says. A change in the network is therefore a look at the attachment too, and a different one is a proof now rather than at three quarters of the lease. Waits end on the wall clock as well as the monotonic one, which stops while the machine sleeps: a lease that ran out with the lid closed is renewed on waking.

enforce on a gateway watches for its broker the same way: a bare TCP connection at once on a network change, every few seconds for a minute and once a minute after, rather than a question every thirty seconds that opens the device key and signs a TLS handshake each time. It asks the moment the port answers, without the share of a window a device waits - there are a handful of gateways, and each of them is a segment whose grants are running down.

Neither unit tears anything down when it stops. Access ends because the lease ends, on the enforcement point, without anything having to be delivered.

What the machine can tell the person using it#

Every proof is answered with the rule that decided it, the checks that failed and when the lease ends, and until recently the whole of that went to a log. The loop now publishes it as last-decision.json, and exordos-nac-agent status reads it without loading configuration, touching the device key or contacting the broker. It works without administrator rights. Use status --live for the previous full diagnostics: configuration, enrollment, key protection, fingerprint and broker view. Those checks need access to the device identity.

last proof     no answer, 2026-08-22 20:49:23 UTC (3m0s ago)
failing since  2026-08-22 20:43:23 UTC (4 attempts): dial tcp 10.0.0.1:8443: connection refused
proving again  2026-08-22 20:53:23 UTC (in 1m0s)
last answer    2026-08-22 20:43:23 UTC (9m0s ago)
granted        net:office, dns
posture        fail (failed: the disk is encrypted)
  fix          the disk is encrypted
lease expires  2026-08-22 20:58:23 UTC (in 6m0s)

The last decision and the current failure are carried at the same time, on purpose. That pair is the sentence an outage needs - granted until 20:58, no contact since 20:43 - and the alternative, dropping the decision as soon as an attempt fails, leaves a device that knows only that something is wrong. Which is what the log already said.

Default report paths:

  • Linux: /run/exordos-nac-agent/last-decision.json, mode 0644 in a 0755 directory.
  • Windows: %ProgramData%\exordos_nac_status\last-decision.json. The MSI gives standard users read access; SYSTEM and Administrators can write. Private state remains in %ProgramData%\exordos_nac. Installation and upgrades clear the saved report; the next proof publishes a new one.
  • macOS: ~/Library/Application Support/exordos_nac/last-decision.json, readable by that account. Run status as the enrolled user, without sudo. Reading the saved report also works over SSH without opening the Aqua-session keychain.

With the default state directory, NAC_RUNTIME_DIR overrides the report directory. A custom --dir takes precedence and uses that directory for reports. On Linux a reboot clears the report. Windows and macOS keep it across restarts: check its timestamp, next-proof time and lease expiry. Saved output does not establish that the service is running or the broker is currently reachable; expired leases are marked (expired).

It also carries the version that produced it, which is not the same fact as the version on disk: an update that installed and never started shows up here as a version that stopped changing. The fleet-wide half of that question is the broker's - every proof now writes the measured version back to the device record, and exordos-nac-broker device versions reads it.

It contains no secrets and nothing reads it back to decide anything: the file is informational, while access enforcement remains the gateway's responsibility. A notifier or tray item reading it is not implemented. Any such interface should report state transitions without offering a way to pause enforcement, asking the user to run commands, or opening server-supplied URLs. The broker response behind this file is described in session proof.

Build#

make            # fmt + vet + test + build
make build      # bin/exordos-nac-agent, bin/nac-broker-stub
make test
make cross      # check builds for linux/windows/darwin
make fixtures-check

Requires Go 1.26+. Linux and Windows agent builds are pure Go. A production macOS build needs cgo and Security.framework for both Secure Enclave backends. The CGO-free Darwin cross-build remains available for build coverage but cannot use either hardware path; require_hardware therefore fails closed in that build.

macOS is tested the same way as Windows - cross-build the test binaries, run them on the machine, no Go inside the VM:

GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go test -c -o /tmp/keystore.test ./internal/keystore
scp -P 2223 /tmp/keystore.test nac@127.0.0.1:/tmp/ && ssh -p 2223 nac@127.0.0.1 /tmp/keystore.test

The protocol tests read testdata/, so run that one from inside the package directory or it fails on missing fixtures rather than on anything real.

See testbed/macos/README.md for bringing that machine up.

The Windows backend is tested by cross-building the test binary and running it on the test machine, which needs no Go inside the VM:

GOOS=windows go test -c -o /tmp/keystore.test.exe ./internal/keystore
testbed/windows/run.sh --copy /tmp/keystore.test.exe
testbed/windows/run.sh 'C:\Users\nac\keystore.test.exe "-test.v=true"'

See testbed/windows/README.md for bringing that machine up.

Releases#

make dist                  # everything, with SHA256SUMS - no one machine can
make dist-portable         # bare binaries: linux and windows, from anywhere
make dist-linux-packages   # deb + rpm, needs nfpm
make dist-msi              # the Windows installer, on Windows, needs WiX
make dist-macos-pkg        # the macOS installer, on a Mac, with cgo
make dist-darwin           # bare macOS binaries, for working on a Mac

The macOS package is signed and notarised when the environment carries the credentials for it (APPLE_PROFILE, APPLE_APP_IDENTITY, APPLE_INSTALLER_IDENTITY, NOTARY_KEY, NOTARY_KEY_ID, NOTARY_ISSUER) and builds without them so a developer can still build it. Which of those a release is allowed to skip is none: packaging/macos/verify.sh asks the built package for the universal binary, the embedded provisioning profile, a profile that provisions all devices and is not about to expire, matching entitlements, both Developer ID certificates and a stapled notarisation ticket. --allow-unsigned drops everything but the first two, and is for the commit check.

make dist-darwin is not what a release ships. A bare binary on macOS is not a lesser install of the same agent: without a bundle there is no embedded provisioning profile, so the Secure Enclave is unreachable and, unsigned, the key goes in a file.

No single machine can run make dist, and that is not a packaging preference. The Secure Enclave is behind Security.framework, which is behind cgo, so a darwin binary cross-built from Linux is the file-key fallback wearing the same filename. WiX 5 is a .NET tool and still refuses to build off Windows - on Linux every Directory/@Name comes back as "is not a relative path". So a release is three runners, and make dist is what they add up to.

make checksums refuses to write a checksum file missing any of the three: no macOS package, no Linux package, or no MSI. A bare binary is not an installation - it has no units, no service, and nothing that fixes the ProgramData ACL the Windows key blob depends on - so a release that quietly shipped only binaries would be one somebody installs.

Pushing a v* tag runs the same build in CI across those runners and attaches the artefacts and their checksums to the release. The agent holds a device's identity, so there is a checksum file to verify against rather than a bare download.

What the release channel needs from this repository#

One repository variable, RELEASE_PUBLIC_KEY: the PEM public half of the Ed25519 key releases are signed with. Every build job writes it to a file and the Makefile links it into the binary, so a device installed from this release can check the next one.

A variable rather than a secret, because it is a public key and one nobody can read is one nobody can verify a release against by hand. The private half belongs on the machine that signs releases and must not be on the broker: a broker that could sign would be a broker that could replace every agent in the fleet.

The tag build fails without it, for the same reason the macOS gate does. A release built with no key produces devices that report every offer as unverifiable and install nothing, for as long as they live, with nothing failing to say so - and the only way back is a person at each machine. A build off a tag warns and continues, because that is a development build.

What macOS needs from Apple#

Four artefacts, from an Apple Developer account, kept as repository secrets. The release job builds without them and refuses to publish a tag without them, because an unsigned package is not a lesser install - every Mac taking it holds its device key in a file.

Secret What it is
APPLE_DEVELOPER_ID_APP_P12, APPLE_P12_PASSWORD a Developer ID Application certificate, exported as a .p12 and base64-encoded. Signs the bundle
APPLE_DEVELOPER_ID_INSTALLER_P12 a Developer ID Installer certificate, the same way. Signs the pkg; a different certificate, and Apple issues them separately
APPLE_PROVISIONING_PROFILE a Developer ID provisioning profile for io.exordos.nac, with Keychain Sharing enabled for the access group and ProvisionsAllDevices set. A development profile names the Macs it works on and cannot be a fleet delivery mechanism; the release gate refuses one
APPLE_NOTARY_KEY, APPLE_NOTARY_KEY_ID, APPLE_NOTARY_ISSUER an App Store Connect API key with the Developer ID role. Notarises the package. A key rather than an Apple ID and app-specific password, so that releases do not stop working when a person changes their password or leaves

Profiles expire, and this one expiring is not a degradation: macOS refuses to run a binary whose keychain entitlement no valid profile backs, so the whole fleet stops on a date certain. The build refuses a profile with fewer than ninety days left rather than letting a release carry it (--min-profile-days).

What Windows needs#

An Authenticode certificate. Today that is this organisation's own CA, and what it buys is worth being exact about: on a machine that has been given the root - a fleet where policy deploys it - the agent installs without a warning and an AppLocker or WDAC rule can admit it by publisher. On a machine that has not, Windows warns exactly as it would for an unsigned file. A publicly trusted certificate is still owed, and since June 2023 one may not live in a file at all, so it will arrive as a cloud signing account rather than as a better .pfx.

Secret What it is
WINDOWS_PFX (base64), WINDOWS_PFX_PASSWORD the PKCS#12 the internal CA issued for code signing
WINDOWS_SIGN_CA (base64) that CA's certificate. Not optional in practice: the build machine does not trust the CA, so without this the strongest thing it can say is that the signature is intact and ends somewhere it has never heard of - which is also what it would say about a release signed with the wrong key
WINDOWS_SIGN_ENDPOINT, WINDOWS_SIGN_ACCOUNT, WINDOWS_SIGN_CERT_PROFILE, AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET the other route, for when there is a public certificate: an Azure Trusted Signing account. The same signtool dlib mechanism takes DigiCert KeyLocker or SSL.com eSigner

Both the exe and the MSI are signed, and the order matters — the exe first, because the package seals what it was given. A signed installer carrying an unsigned binary passes every check anybody usually makes and then refuses to start under an AppLocker publisher rule.

Both must be timestamped, and verify.ps1 refuses a release where either is not: a signature without a countersignature stops verifying when the certificate expires, on the machines that installed the agent earliest.

verify.ps1 also checks the publisher against Genesis Corporation — the string every publisher rule would name — and builds the chain against WINDOWS_SIGN_CA for the Code Signing EKU, which is the question the fleet's machines will ask rather than the one a build runner's trust store happens to answer. Revocation is not checked there: an internal CA's CRL is reachable from inside the network, which is where the machines enforcing the signature are.

Broker contract#

internal/protocol/testdata/ is a copy of the golden fixtures from exordos_nac/docs/protocol/fixtures/. Both server-side implementations — the real broker and nac-broker-stub — are checked against the same files; drift is caught by TestFixturesDigest rather than by review.

Order of changes: edit exordos_nac/docs/protocol/fixtures, copy here, then refresh internal/protocol/testdata/fixtures.sha256 with tools/fixtures_digest.sh.

HTML documentation#

Install the documentation tools with python3 -m pip install -r requirements-docs.txt, then run make docs-build. Serve site/ with python3 -m http.server --directory site 8181 and open http://localhost:8181. The site includes this guide and the platform testbeds, with search, code highlighting and a dark theme. CI validates local links and anchors and uploads exordos-nac-agent-docs for pushes to master. Tagged releases include HTML archives for both the agent at the release tag and the broker from master, with checksums. Each archive records its source commit in SOURCE_REVISION.