Documentation embedded in this build.
Signing
Some actions require more than your certificate. Instead of the certificate alone authenticating the connection, you sign the exact request with your own key, and the server keeps that signature as evidence that you, specifically, took this action — evidence it can later hand to a third party who trusts nothing about the deployment except the math (plan 033).
One operation ships this way, and you will meet it if you ever ask for access. Accepting a mission-access assignment — POST /admin/v1/requests/{id}/accept — declares signature: required in the shipped specification, and always has (plan 061). The reason is what acceptance is: an approver assigns you a window, and nothing happens until you say yes. An acceptance the server merely recorded would be attributable only as far as "this connection, at this time, said yes"; your signature makes it your own key vouching for it, which survives even a compromise of the server, because the server never held the key that made it. Everything else in the shipped documents is TLS-authenticated and audited, which remains the default and remains right for most of the API; your administrator may mark more.
The quickest way to accept is authboxctl requests accept <id>, which signs and retries for you — see below. authboxclient sign is the general form, and the one to reach for when you want to read the statement before it goes.
The one rule that matters: read before you sign
This is sometimes called WYSIWYS — What You See Is What You Sign. authboxclient sign (and authbox-agent's signing prompt) display the canonical statement in full, byte for byte, before anything is signed. That statement — not a summary of it, not a button label — is what your signature covers. Confirm only after you have actually read it.
A browser has no way to reach the private key of your client certificate, so a console button that tried to make that signature could never work — and wherever this console would otherwise offer one, it tells you to use authboxclient sign or authbox-agent instead. That is a refusal to pretend, not a missing feature.
Where a deployment has enabled browser keys (below), the console can honour this rule, and does: the server hands the page the exact canonical statement, the page shows it to you before the ceremony starts, and your key signs those exact bytes. Read it before you touch the key.
Signing one action
authboxclient sign -server https://authbox.example:8443 -ca ca.pem \
-operation approveEnrollment -path /admin/v1/enrollments/7/approve
-operation and -path identify what you are signing; add -body-file body.json if the operation takes a request body. The tool prints the statement, waits for you to confirm, signs it with your enrolled credential, and submits the signed request.
The shortcut: authboxctl signs only when it must
authboxctl requests accept req-4f2a9c…
authboxctl tries the operation unsigned first. If the server refuses it specifically because a signature is required — a distinctive refusal that says so, rather than a bare "forbidden" — it fetches a challenge, signs a statement of that exact request with the same certificate and key it already opened the connection with, and retries. There is no second credential to manage: the actor's key is the client certificate's key.
Two things follow, and both are deliberate. Against a deployment that has not declared the requirement, the first attempt simply succeeds and nothing is signed — you pay nothing for a check you never hit. And the retry is safe because the refusal happens before the operation runs: nothing was changed by the attempt that was turned away.
What this shortcut does not do is show you the statement before signing it. Where that matters — an unfamiliar operation, or a body you did not compose yourself — use authboxclient sign above and read what you are about to sign.
Signing from a link
A web page cannot reach the private key of your certificate, and a smart card will never hand one over to a browser. So a portal that wants to offer you a button for an acceptance offers a link instead: clicking it opens the client you already have, which does exactly what the command below does — fetches the statement on your own authenticated connection, shows it to you, asks your key (prompting for the card's PIN if that is where the key lives), and submits.
What the link contains
authbox-sign://<deployment-host>/requests/<request-id>
The deployment and the request. That is the whole payload, and it is deliberately not more: no nonce and no statement ever travel in a link. The freshness challenge is minted by the deployment at the moment you sign, and the statement you read is the one the deployment tells your own client, over your own mutually-authenticated connection. A link that tries to carry either — a query string, a fragment, a second path segment, a user name before the host — is refused outright rather than having the extra part ignored.
What the client does, in order
authboxclient sign-link authbox-sign://authbox.example:8443/requests/r-4f2a9c
- Reads the link, and checks the deployment first. If the host is not one this client enrolled with, nothing at all is fetched — not from that host, not from any other — and the command exits 3, saying which deployment it did not recognise. A link is an instruction arriving from outside, and the first question about it is whether you have ever heard of the deployment.
- Finds your ticket. It asks
GET /self/v1/whoamion the credential enrolled with that deployment. A request that is not there, or one that is not waiting on you, is nothing to sign, and the command says exactly that and exits 3. - Fetches a challenge and builds the statement. The same statement the console shows, the same one
authboxctl requests acceptsigns, with the live challenge written in. - Shows you the exact bytes and asks. In full, byte for byte, exactly as
authboxclient signdoes — and it refuses to sign at all if what it built differs from what the deployment showed. Pass-yesto skip the question; the statement is still printed. - Signs with your key. Where the key is a file, this is silent. Where it is on a card or in an operating-system keystore, the PIN prompt is that module's own and appears wherever it puts it — this client neither asks for your PIN nor ever sees it.
- Submits, and tells you what the deployment said. On success, one line naming the request, the kind of key you signed with, and the grant your acceptance made live. On a refusal, the deployment's own words, unedited.
Steps 2 and 3 are the ticket's. An agreement link runs the same six steps against the portal instead: there is no ticket to find, because an agreement is open to whoever may read it, and the statement is not assembled here at all — the portal mints it whole, with the nonce already in it, and this client checks that it names the act, the agreement the link named, and this credential's own identity pair before it signs. See "The same three ways on a standing agreement" below.
Exit codes
sign-link uses this tool's ordinary exit codes — the same ones enroll, renew and sign use, and the same ones authboxctl uses — rather than a private table. A desktop that launched a handler has the code and one line to go on, and the line is what tells the two invalid cases apart:
| code | meaning |
|---|---|
| 0 | signed, and the deployment accepted it |
| 1 | could not ask — unreachable deployment, unreadable credential store, unusable key |
| 2 | the link, or the flags, could not be read |
| 3 | nothing to sign, or a link from a deployment you did not enrol with — the sentence says which |
| 4 | the deployment refused the signed acceptance; its own words are printed |
Registering the handler
authboxclient enroll registers this client for authbox-sign: links at the end of an enrolment and prints what it did; -no-handler skips that. You can also run it yourself, as often as you like — it changes nothing the second time:
authboxclient register-handler
authboxclient unregister-handler
`enroll` runs the registration for you at the end, with two exceptions it states: under
`-json` it registers nothing and prints nothing beyond the document (automation gets the
document and nothing else), and in a shell with no desktop session — no `DISPLAY`,
`WAYLAND_DISPLAY` or `XDG_CURRENT_DESKTOP` — it prints one line pointing here instead of
writing a desktop entry into a home that has no desktop. Run `register-handler` on the
desktop you actually use.
What that means depends on the platform, and the command says which of these it did:
- Linux. Writes
~/.local/share/applications/authbox-sign.desktopcarryingMimeType=x-scheme-handler/authbox-sign;and runsxdg-mime defaultto make it the handler for the scheme. Both are yours; neither needs root. The entry setsTerminal=true, because the statement has to be read and confirmed somewhere you can see it. Wherexdg-mimeis not installed, the command prints the one line to run elsewhere. - macOS. Cannot register, and says so. Launch Services binds a URL scheme to an application bundle, and a bare binary is not one. The command prints the
CFBundleURLTypesstanza a bundled build carries, so that whoever packages the client for your organisation can include it. - Windows. Prints the
.regtext for the four per-user values underHKCU\Software\Classes\authbox-signrather than editing your registry for you. Save it, apply it (double-click, orreg import), and the association is yours; it needs no administrator.
On a managed desktop
A locked-down machine may forbid a user-registered scheme handler altogether. Nothing breaks: the registration is a convenience, and the command underneath it always works.
An administrator has two ways to provide it for everybody — install the same .desktop entry under /usr/share/applications (Linux), deploy the bundled build or the same four registry values by policy (macOS, Windows) — and one way to provide nothing at all, which is also fine. A person who cannot click the link copies the request id from the portal and runs:
authboxctl requests accept r-4f2a9c
That path has always worked, needs no desktop integration of any kind, and produces the identical record: the same statement, signed by the same key, verified by the same verifier.
What your signature actually covers
- The literal bytes, not a re-encoding of them. Two payloads that mean the same thing but are not byte-identical produce different signatures — there is no "semantically equivalent" shortcut a forger could exploit.
- A single-use nonce the server issued for this request. Presenting the same signed envelope a second time succeeds at most once; it is not a reusable credential.
- This one action. A signature over
approveEnrollmentfor request 7 says nothing about request 8, and nothing about any other operation, however similar.
Your signature is verified against whatever certificate you sign with at the moment you sign — not against whichever certificate authority the deployment happens to trust now, at some later verification time. That is deliberate: a signature you made stays yours even across a CA rotation, revocation applied to a later position in the audit chain notwithstanding.
If a third party wants to check your signature later
authboxctl attest verify verifies an exported audit segment's hash chain, any anchor countersignatures, and every actor signature within it, entirely offline. Your signature is portable evidence: whoever runs that command needs the export and the relevant public keys, nothing else — not access to the deployment, not your cooperation, not any claim taken on trust.
Signing from a browser: a second key, never a login
Signature-required operations were CLI-only for one reason: no browser will hand a client certificate's private key to page script. A browser key — a WebAuthn credential, the same thing a security key or a platform authenticator holds — is exactly a key a browser can sign with, so a deployment may let you register one and use it as a second signer (plan 068).
It is not a login, and it cannot become one. You still authenticate with your certificate, on every single request, exactly as before. A browser key never identifies you and never grants you anything: it only adds your signature to an act the server has already authorised. Registering one gives you no access you did not have; deleting one takes none away. AuthBox declines passkeys as a credential and this does not change that.
It is subordinate to your certificate, permanently. Registering a browser key is itself a signature-required operation, signed by your certificate key. So a browser key exists only because your certificate key said it should, and it can never authorise anything your certificate could not. That is also why registration takes two steps.
Registering one
The key can only be created in a browser. The authorisation can only come from your certificate. So the ceremony is split, and a file carries it across:
- Open the console at
/ui/webauthnand press Create a browser key. Your authenticator prompts you; the page then prints a small JSON document. Nothing is registered yet — the key exists in your browser and the deployment has never heard of it. - Save that document and run:
`` authboxctl webauthn register -body-file registration.json ``
This signs a canonical statement of that exact document with the same certificate and key it already opens the connection with, and submits it. There is no second credential to manage.
Only you can do this for your own entity. An operator cannot register a browser key on your behalf, by design: a key someone else installed would sign in your name without you ever having agreed to it.
Attestation is accepted and not verified. Nothing about your authenticator's manufacture is checked, claimed, or recorded — the authorising signature is the trust. A deployment that wants hardware-proven browser keys is a follow-up, not a setting.
Using one
On a request page for an assignment you can accept, the console shows a button instead of a command. Pressing it fetches a challenge, shows you the exact statement, waits for your authenticator, and submits. The verbatim authboxctl command stays on the page whatever happens — that path always works, and nothing here replaces it.
Removing one
authboxctl webauthn remove <your-dn> <credential-id>
This needs no signature and no ceremony, and an operator can run it for you. That asymmetry is deliberate: adding a way to sign is your act alone, while removing one only ever narrows what is possible — and the case that matters is a lost laptop, where the person who can no longer use the key is exactly the person who cannot produce a ceremony to retire it.
Three ways to sign one statement
A signature-required act used to have exactly one way to perform it: read the statement at a terminal and run a command. Two more reach the same statement now (plan 145), and the thing to hold on to is that they are three ways to do one thing. Every one of them signs the bytes printed on the page, with the freshness challenge written in at the moment of signing; the verifier is the same verifier; the record is the same record. The only thing that differs afterwards is which kind of key made the signature, and that is written down because it is a fact about the evidence rather than about the act.
The order, and why it is fixed
Wherever the portal offers you a choice it offers it in the same order, and the order is a statement about who each way is for:
- Your security key, where you have registered one and the deployment accepts the kind. Nothing to install and nothing to copy — the fastest path for the population that has a key in a laptop or a phone.
- Your smart card, through the client on your own machine. The only way a card can ever sign, because no browser will hand a card's key to a page, and the population that holds one is exactly the population that cannot use the first way.
- The command, always, for everyone. It needs nothing from the page — no script, no desktop association, no registered key — which is why it is never hidden and why there is no setting that could hide it.
The statement sits above all three, because the signature is over those bytes and not over the button you pressed. A browser with script disabled shows the last two, unchanged.
1. Your security key, in the browser
The console is not the only door with the button. The portal — the page a person opens to see their own requests — offers the same act on an assigned ticket, beside the two ways that need no script at all, and offers the same three on a standing agreement where the deployment asks for one.
What the button actually does. It is enabled by one script, which is the only script this door has ever served: one file, compiled from this repository's own TypeScript, embedded in the binary and served from the portal's own origin at an address that carries the file's content hash. On a click it asks the portal for a fresh challenge; the portal asks the authority, which builds the canonical statement itself and returns it as text beside the digest your key will cover. The page then shows you those exact bytes — nonce line and all, which the page could not print before that moment — asks your authenticator, and posts the assertion back through the portal to AuthBox.
The script decides nothing. It builds no statement, hashes nothing, fetches nothing outside that origin, and holds no configuration of its own: every value it uses is written on the button by the server. On a ticket the portal forwards and the authority verifies; on an agreement the portal verifies, because it is the only holder of that record — and the script cannot tell the two apart, which is why the agreement needed no second bundle.
The policy. Every page the portal serves carries default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; connect-src 'self'; font-src 'self'; object-src 'self'; img-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'. script-src admits nothing inline and nothing evaluated, so an inline handler on this door is refused by your browser and not merely disapproved of in review; the style allowance is for the stylesheet the page carries, which cannot execute.
When the key is refused. Whatever goes wrong — the signature does not verify, the nonce expired, you cancelled at the authenticator, the deployment is unreachable — the portal renders the verifier's own status and sentence, unedited, in the block you pressed the button in, and the smart-card link and the command are exactly where they were. On a ticket that sentence is AuthBox's; on an agreement it is this door's, which is the door that checked. Nothing about a failed click takes a way to sign away from you.
With script off, the button is rendered disabled with a sentence saying it needs script, and the other two ways are untouched. That is the whole difference.
2. Your smart card, through the client
One link, Sign with your smart card, which opens authboxclient on your own machine. Everything about it — what the link may carry, what the client does in order, the exit codes, and how the association is registered on each platform — is "Signing from a link" above; nothing about it changes because the link came from the portal rather than from anywhere else, which is the point of the link being a grammar rather than a feature of one page.
The page never touches the card, and the portal is not in that ceremony at all: the client fetches the statement on your own authenticated connection, prompts through whatever module holds the key, and submits. A machine with no handler registered shows you its own "nothing opens this" rather than a wrong act, and the command underneath is untouched.
3. The command
Always present, on both kinds of act, needing nothing from the page:
# an assigned ticket
authboxctl requests accept r-4f2a9c
# a published agreement
authboxclient sign-link authbox-sign://agree.example/agreements/mou-acme
Two binaries because the two acts land at two different doors, and each command names the door that will verify it — see the next section for why an agreement's command is the sign-link one.
The same three ways on a standing agreement
A deployment may require that an acceptance of a published agreement be signed rather than resting on the handshake that authenticated you. Off — and off is the default — the agreement page is the one it has always been: read the document, press I Agree, and the ledger records your certificate name, the document's hash and the time. On, the button is replaced by the statement and the same three ways in the same order, and what is written down is a signature you made.
What you are signing is one revision of one document, and the statement says so in full:
{"operation":"acceptAgreement","resource":"/agreements/mou-acme",
"request_digest":"sha256:…","actor_dn":"cn=you,o=example,c=us",
"actor_issuer_dn":"cn=example ca,o=example,c=us","nonce":""}
The digest is over the published document's content hash, not over the form the page posts. That is deliberate and it is what makes the signature worth having: the hash is the version (The portal), so a signature made for one revision cannot accept another, and a person signing is never asked to put their name to a session token the page invented. The nonce line is empty until the moment you sign, exactly as it is on a ticket.
The link names the portal, not the deployment. A request is accepted at AuthBox and an agreement is accepted at the portal, which is the system of record for its own ledger — so the two links name two doors:
authbox-sign://authbox.example:8443/requests/r-4f2a9c
authbox-sign://agree.example/agreements/mou-acme
authbox-sign://agree.example/agreements/ops.overwatch/handling-rules
The third is a project's agreement, whose id is <project>/<name>. The link carries that slash as two plain path segments rather than as an escape, because a grammar that decoded %2F would be a grammar in which a smuggled path segment has to be thought about; the client refuses a link that tries to carry a third.
The command is the sign-link one. There is no authboxctl verb for accepting an agreement, and there is not going to be one: the act belongs to the portal, and the client is what holds your key. So the command printed on the page is the same link the card way opens, handed to the client:
authboxclient sign-link authbox-sign://agree.example/agreements/mou-acme
It checks what it can check before your key is touched — that the statement names acceptAgreement, that it names the agreement the link named, and that it names this credential's own identity pair — and it prints what it cannot check, which is the revision digest of a document it has not read. You clicked through from a page that prints that document's hash beside it; comparing them is a thing only you can do.
Where each way works. The portal verifies these signatures itself, because nobody else holds the record, and that puts one honest limit on the certificate way:
| How you signed | Directly at the portal | Through a front door |
|---|---|---|
| Your certificate's key (the client, or the card) | Works | Refused, by name |
| A registered security key | Works | Works |
Behind a front door the certificate on the connection is the door's: yours was verified one hop earlier and its public key is not there to check a signature against. The portal says exactly that rather than shrugging and checking the door's key — which would record the door's signature under your name — and points you at the way that does work there. Nothing about this affects the unsigned deployment, where the handshake is the record.
What a deployment declares
Nothing here is a permission and nothing here decides anything: a deployment declares which ways it offers, the authority declares which signer kinds it accepts, and a way is drawn only where both say yes and you hold what the ceremony needs. The portal asks rather than assumes, on every render, which is why a button never appears for a ceremony the authority would refuse.
At the AuthBox (webauthn.rp_id, from plan 068). Browser keys are off entirely unless the deployment declares a relying party id. With nothing declared, every assertion is refused, and the console and the portal render exactly what they rendered before this existed. There is no half-enabled state.
Two details about that one setting are worth knowing, because both produce a ceremony that starts and then fails at the authenticator rather than at load:
- It has to cover the portal's own origin. A credential is scoped to its relying party id for life, and a browser will only ask for an assertion where the page's host is that domain or a subdomain of it. So the id has to name a domain the portal's host sits under — usually the common suffix the console and the portal share. One naming only the console's host admits the console and refuses the portal, at the authenticator, before any request is made.
webauthn.origins, where a deployment narrows it, has to name the portal's origin too. Left empty it admitshttpsat the relying party id or any subdomain of it, which already covers the portal; a deployment that has listed origins has to add this one.
At the portal, in its own configuration file:
signing:
# portal.signing.webauthn — offer the security-key button at all. Default true.
webauthn: true
# portal.signing.client_link — offer the smart-card link at all. Default true.
client_link: true
# portal.signing.deployment_host — the AuthBox as YOU would name it, which is the name
# your client enrolled with. Empty derives it from the configured server address.
deployment_host: authbox.example:8443
# portal.signing.agreements — require a signature on an acceptance. Default FALSE.
agreements: true
# portal.signing.agreement_host — THIS door as your client would name it. Required
# whenever the option above is on, and never derived from anything.
agreement_host: agree.example
# portal.signing.origins — the exact origins this door will accept an assertion from,
# mirroring webauthn.origins at the authority. Empty is the rule a browser already
# enforces: https, at or under the authority's relying party id.
origins: []
The two hosts are two settings because they name two doors, and a single one would be wrong for one of them in every deployment where the portal and AuthBox are not the same name — which is every deployment with a front door. portal.signing.agreement_host is required at load when portal.signing.agreements is on, and is the one setting in this family that refuses rather than degrading: with signing on, an acceptance needs a key, the security-key way needs a key you may not have registered, and the way that always works is the client link, which cannot be spelled without the door's own name. A wrong portal.signing.deployment_host is safe by comparison — your client refuses the link by name, before it fetches anything, because it does not recognise the door.