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.
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.
statussaysnot 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 accountnowns 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
installergets - 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 touching the network - which is the only way it can be read at the moment
it is wanted, since the question is asked by someone whose access has just
stopped.
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.
Where it goes is /run/exordos-nac-agent on Linux and beside the state on
Windows and macOS: 0644 in a 0755 directory, because the person at the keyboard
is who it is for and the state directory is 0700 root for the key. On Linux a
reboot clears it, and that is correct - a device that has not proved since it
came up has no access, and a report from before the reboot would say otherwise.
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.