Issuer tooling (tessera_issuer)
Overview
Section titled “Overview”Tessera verifies certificates on the device, but something has to issue them.
The issuer tooling covers that side: a single Rust core assembles a
TBSCertificate with the Tessera extensions, checks the monotonic narrowing of
the delegation envelope before signing, and signs the result with the key of
the selected backend — a token/HSM (PKCS#11), Vault Transit, or a local PKCS#8
file. With the PKCS#11 and Vault backends the tool is not a custodian: no
private key material passes through the issuing code; the file backend is a
deliberate trade-off with the key resident in the issuance process memory (see
threat-model.md §11).
Components:
- Core (
tessera_issuer, a library) — assembly of the shift-leaf and organisation-CA TBS, the envelope checks, random 128-bit serials, CRL issuance, and the issuance journal. The core is pure Rust and also builds forwasm32; the signing adapters sit behind feature flags. - CLI
issuer— the issuing code for automation (ticketing systems, scripts) and hands-on use. No check is re-implemented in the CLI: a request the core would refuse, the CLI refuses identically.
Browser-based issuance — a local signing agent and a web cabinet driving the same core from a browser — is delivered separately, as part of the commercial tooling (see “Browser-based issuance”). The open repository ships the CLI.
The role model comes from the parent certificate presented: any CA with a delegation envelope (the fleet root or an organisation CA) issues both subordinate organisation CAs and engineers’ shift-leaves — strictly inside its delegation envelope, which can only narrow at every step. There is no separate “mode by job title”.
The semantics of the extensions themselves (host_binding,
allowed_roles, max_integrity, profile_version, delegation_constraints)
and their OIDs are in cert-issuance.md — this document
describes the tool, not the format. The attack surface of the issuer tooling is
covered in threat-model.md §11 and is not duplicated here.
CLI quick start
Section titled “CLI quick start”Every issuing subcommand selects a signing backend with --backend (pkcs11 is
the default, vault, file), --key (the CA key label; optional for file,
defaulting to the key file’s name) and --algorithm (ecdsa-p256 is the
default, ecdsa-p384, rsa-sha256; for file the algorithm is derived from
the key itself and the flag acts as a cross-check). Times
(--not-before, --not-after, --this-update, …) are Unix seconds. Inputs
(--parent, --spki, --csr, --issuer) are accepted as PEM or DER (the
format is detected from the content). Output is PEM, or DER with --der.
The token PIN is never a command-line argument: the tool prompts for it for the duration of the operation, following the ladder of sources — see Secret sources.
Issue an organisation CA
Section titled “Issue an organisation CA”Under the fleet root, an organisation CA is issued with an assigned delegation envelope (roles, the МКЦ level ceiling, the TTL ceiling, required tags):
issuer issue-ca \ --backend pkcs11 --module /usr/lib/x86_64-linux-gnu/opensc-pkcs11.so \ --key tessera-root --algorithm ecdsa-p256 \ --parent root.pem \ --spki org-ca.spki.der \ --subject "CN=Org North CA,O=Org" \ --not-before 1750000000 --not-after 1900000000 \ --allow-role oper --allow-role serv \ --max-level 5 --max-ttl 14400 \ --require-tag region=north \ --journal issuance.ndjson \ --out org-ca.pem--allow-role and --require-tag repeat for several values. The issued CA’s
envelope must be ⊆ the parent’s; otherwise the core refuses before signing,
naming the dimension (see the monotonic narrowing in
cert-issuance.md).
Mandatory envelope: roles and the TTL ceiling
Section titled “Mandatory envelope: roles and the TTL ceiling”--allow-role is required for both issue-ca and issue-root: the role list
in a delegation envelope is a closed whitelist, and an empty list allows not
“any role” but none at all. There can be no default here: role names belong to
the particular deployment, so any substituted value would either repeat the same
dead end or silently widen the envelope beyond what the operator named.
--max-ttl bounds the lifetime of the child link, so the two operations
give it different meanings and different defaults:
| Operation | What --max-ttl bounds | Default |
|---|---|---|
issue-root | the organisation CA’s lifetime under the fleet root | 31536000 (a year) |
issue-ca | the shift-leaf’s lifetime under the organisation CA | 14400 (4 hours) |
An explicit --max-ttl 0 is refused at argument parsing: a zero ceiling demands
a zero lifetime from the child link, so no issuable certificate passes under
such a CA.
Both restrictions close the same class of error: a certificate with an empty or zero envelope dimension looks valid, lands in the issuance journal and raises no warning, yet authentication under it always fails — and that is discovered only on the device.
Issue a shift-leaf
Section titled “Issue a shift-leaf”The leaf’s public key comes from an explicit --spki (then --subject is
required) or from --csr (then the subject and key are taken from the
request). The two flags are mutually exclusive.
Direct path (SPKI):
issuer issue-leaf \ --backend pkcs11 --module /usr/lib/.../opensc-pkcs11.so \ --key org-north-ca \ --parent org-ca.pem \ --spki ivanov.spki.der \ --subject "CN=ivanov,O=Org" \ --host "sha256:<host_id_hash>" \ --role oper \ --not-before 1750000000 --not-after 1750086400 \ --max-integrity-level 2 --max-integrity-categories 0x1 \ --journal issuance.ndjson \ --out ivanov.pemCSR path (see The CSR flow):
issuer issue-leaf \ --backend pkcs11 --module /usr/lib/.../opensc-pkcs11.so \ --key org-north-ca \ --parent org-ca.pem \ --csr ivanov.csr.pem \ --host "sha256:<host_id_hash>" --role oper \ --not-before 1750000000 --not-after 1750086400 \ --journal issuance.ndjson \ --out ivanov.pem--host and --role repeat. --max-integrity-level is optional
(without it no integrity ceiling is set); --max-integrity-categories (a
bitmask) is honoured only together with a level.
Admission is carried by two extensions of the issued leaf, and both are built
from these flags: pam_cert_host_binding (--host) — the devices on which the
credential is accepted; pam_cert_allowed_roles (--role) — the roles its
holder may activate. The login account name is the role, so the second list
also determines which role accounts the holder is admitted into: there is no
separate per-account admission list.
Issue a CRL
Section titled “Issue a CRL”issuer issue-crl \ --backend pkcs11 --module /usr/lib/.../opensc-pkcs11.so \ --key org-north-ca \ --issuer org-ca.pem \ --this-update 1750000000 --next-update 1750604800 \ --crl-number 7 --last-crl-number 6 \ --revoke 2a:1750000500:1 \ --revoke 3b:1750000600 \ --journal issuance.ndjson \ --out org-ca.crl--crl-number must be strictly greater than --last-crl-number (the monotonic
crlNumber in the CA’s state); otherwise the operation is refused. Each
--revoke is serial_hex:unix_date[:reason_code], where reason_code is an
RFC 5280 reason code (0–6) and is optional; the flag repeats.
Verify the journal
Section titled “Verify the journal”issuer verify-journal --journal issuance.ndjsonPrints one of three states: the chain is intact and fully signed; intact but
with an unsigned tail (with the seq it starts at); broken (with the position
of the first invalid record — then a non-zero exit code). See
The issuance journal.
Message language
Section titled “Message language”Operator result messages are localized (Russian/English). The locale resolves as
--lang (ru/en) → TESSERA_ISSUER_LANG → LANG → English by default.
Matching is by language prefix: any value beginning with ru selects Russian.
Technical identifiers (an RFC 4514 subject, an OID, crlNumber, serials) are not
translated. See Localization.
The CSR flow
Section titled “The CSR flow”A CSR (PKCS#10) is a peer to the direct SPKI source for the leaf key. It removes the need to hand the tool a public key separately and provides proof of possession: the engineer generates the key on their own token and signs the request with it.
Engineer side — build a CSR with the token key:
issuer csr \ --backend pkcs11 --module /usr/lib/.../opensc-pkcs11.so \ --key ivanov-token-key --algorithm ecdsa-p256 \ --subject "CN=ivanov,O=Org" \ --spki ivanov.spki.der \ --out ivanov.csr.pemThe tool is signing-only: the engineer’s public key (--spki) is supplied
explicitly, and the request is signed with the token key that --key addresses.
Proof of possession holds only when that token key matches --spki — the
engineer’s responsibility, since the tool does not generate keys.
Operator side — issue-leaf --csr (above). What matters:
- The core verifies the CSR’s self-signature (P-256/RSA, pure Rust) before issuing; a broken self-signature → refused before signing. The CLI also prints the CSR subject and self-signature status before issuing.
- The subject and public key are taken from the CSR. The scope (envelope, bindings, roles) is set exclusively by the operator via flags — CSR attributes do not influence the extensions. Otherwise a CSR would become a channel for “the engineer requested a wider scope for themselves”.
Browser-based issuance
Section titled “Browser-based issuance”Issuing from a browser — a local signing agent bound to 127.0.0.1 that bridges
the browser to the token/HSM, plus a web cabinet (an SPA over the same WASM core)
that assembles the TBS client-side and shows the operator a summary to confirm —
is delivered separately, as part of the commercial tooling (tessera-enterprise).
It is not built from this repository. For contact, see
LICENSE.commercial.
The open repository ships the issuer CLI, which drives the same core and the
same signing backends and enforces the same pre-signing checks. Everything below
— the signing backends and the issuance journal — applies to the CLI.
Signing backends
Section titled “Signing backends”The core does not know where the key is: signing a built TBS goes behind a single
interface, and no key material passes through it. The backend is chosen with
--backend.
PKCS#11 (token and HSM)
Section titled “PKCS#11 (token and HSM)”The default backend, one code path for hardware tokens and HSMs. Flags:
--module (path to the .so/.dylib/.dll — required), --token-label
(select a token when there are several), --key (the CA key’s CKA_LABEL),
--pinentry (the pinentry program explicitly).
The PIN is requested for the duration of the operation (Secret + zeroize,
never in logs or argv) following the ladder of sources — see
Secret sources. For a non-interactive run: --pin-file <path>
or --pin-stdin.
For trials and CI, SoftHSM works as a software PKCS#11 module. GOST tokens work through the same adapter when the token exposes the required PKCS#11 mechanism.
Secret sources
Section titled “Secret sources”Backend secrets — the token PIN for PKCS#11 and the key passphrase for the file backend — are requested through a single ladder of sources:
- A source named by a flag.
--pinentry <path>for an external pinentry-compatible dialog;--pin-file <path>/--pin-stdinfor the token PIN;--key-passphrase-file <path>/--key-passphrase-stdinfor the file backend’s key passphrase. A named source is used without consulting any other. Naming two at once is an argument-parsing error. So is naming a flag that belongs to another backend (--key-passphrase-filewith--backend pkcs11, say): a source you named is never silently swapped for another. - A pinentry found on
PATH(pinentry,pinentry-mac,pinentry-gtk-2,pinentry-qt,pinentry-curses). - Console input with the echo off, when the process is attached to a terminal.
- An environment variable —
TESSERA_ISSUER_PINorTESSERA_ISSUER_KEY_PASSPHRASE— as the last resort, with a warning: the value is visible to child processes and lands in memory dumps.
graph TD
A["A secret is needed"] --> B{"source named by a flag?"}
B -->|--pinentry| C["external dialog"]
B -->|--pin-file / --pin-stdin| D["read from a file or stream"]
B -->|no| E{"pinentry on PATH?"}
E -->|yes| C
E -->|no| F{"a terminal?"}
F -->|yes| G["console input, echo off"]
F -->|no| H["environment variable<br/>+ warning"]
Installing GnuPG or Gpg4win is required on no platform: without pinentry the tool prompts for the secret itself. No flag takes a secret by value: arguments are visible in the process list.
Secret files. On Linux and macOS the file must be unreadable by group and
others (chmod 600), or the backend refuses before reading its contents; the
check runs on the already-open file, so the path cannot be swapped between the
check and the read. Windows does not express file permissions in a comparable
way and the check does not run there: the file is accepted and a warning
says so on stderr. Protection then rests on the directory the file lives in —
keep it somewhere only its owner can enter.
Standard input. On Linux and macOS the secret is read into a wiped buffer,
bypassing the standard library’s own. Elsewhere that bypass is unavailable and
the secret stays in the standard-input buffer for the life of the process. Where
that matters, prefer --pin-file and --key-passphrase-file: the file source
leaves no such residue.
This source is meant for a pipe or a terminal. Redirecting standard input from a
file that has been partly consumed is not a supported way to name a secret:
/dev/stdin differs between Unixes — on Linux it resolves through
/proc/self/fd/0 and reopening a regular file starts at offset zero, while on
macOS the descriptor is duplicated along with its current position. So
exec 0<secrets.txt, one line read away, then issuer --pin-stdin takes the
file’s first line on Linux and the next unread one on macOS. To read a secret
from a file, use --pin-file, which does it predictably.
A secret is bounded at 4096 bytes: a source that gives no line break within that length is refused, and the error names the source and the bound.
Vault / OpenBao Transit
Section titled “Vault / OpenBao Transit”Signs a built TBS through the Transit HTTP API. Flags: --vault-addr (e.g.
https://vault.example:8200 — required), --mount (the Transit mount, default
transit), --vault-key (the Transit key name; defaults to --key),
--ca-bundle (a PEM CA bundle to trust instead of the platform store — for
private Vault CAs), --prehashed (send a locally computed digest with
prehashed=true — for keys configured for pre-hashed input).
The Vault token is read from the VAULT_TOKEN environment variable (empty/unset
→ refused), sent in the X-Vault-Token header, and never logged. For ECDSA the
adapter requests marshaling_algorithm=asn1 (Vault returns a DER signature).
Transit only, not Vault PKI. The Vault PKI engine is unusable for Tessera: Go’s
encoding/asn1does not parse OID arcs larger thanint64, and our extensions sit in the2.25.<UUID>arc — such a certificate cannot be issued through Vault PKI. Transit therefore signs the TBS we assemble, rather than building a certificate.
Transit does not check what it signs — all issuance checks run before
signing, in the core, on the client; who may call sign is constrained by Vault
policy.
Key in a file
Section titled “Key in a file”The file backend signs with a CA key from a local file: --key-file <path>.
The format is PKCS#8 (PEM or DER), including encrypted (ENCRYPTED PRIVATE KEY, PBES2); the key types are ECDSA P-256/P-384 and RSA. GOST keys are not
supported by the file backend — for GOST, PKCS#11 remains the path. Other
formats convert with stock tooling:
# a new encrypted P-256 keyopenssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 \ | openssl pkcs8 -topk8 -v2 aes-256-cbc -out ca-key.p8chmod 600 ca-key.p8
# converting an existing key (SEC1/PKCS#1 → PKCS#8)openssl pkcs8 -topk8 -v2 aes-256-cbc -in old-key.pem -out ca-key.p8The backend’s rules:
- The key file must not be accessible to the group or to others (
chmod 600) — otherwise the backend refuses before reading the contents. File ownership and directory permissions are not checked — keep the key in your own directory with700permissions. On Windows file permissions are not checked at all, same as for secret files (see Secret sources): the key is accepted and a warning says so on stderr. - The passphrase of an encrypted key is prompted through the ladder of sources (see Secret sources); the passphrase never appears in command-line arguments or logs, and memory is zeroized.
- An unencrypted key is accepted, but with a warning on every start; the recommendation is encrypted PKCS#8.
- The signing algorithm is derived from the key itself; an
--algorithmthat does not match the key is an error.--keyis optional (defaulting to the file name) and serves as the key identifier in the issuance journal.
A key in a file is a deliberate trade-off for test benches, CI and small installations: on host compromise it is extractable, unlike a token/HSM/Vault key. For production, PKCS#11 or Vault Transit are recommended (see threat-model.md §11).
The issuance journal
Section titled “The issuance journal”Every operation (issuing a leaf, a CA, a CRL) is a record in an NDJSON journal
linked into a hash chain: a monotonic seq, the hash of the previous record, a
fixed genesis. The journal is fail-closed: the record is written before
the artifact is emitted, and if the journal is unavailable the operation is
refused (no certificate is issued without a record). The path is set by the
--journal flag of each issuing subcommand.
The head of the chain is periodically signed through the same signing interface
(at the end of a session and on command). issuer verify-journal distinguishes
three states:
- intact, tail fully signed — all good;
- intact, unsigned tail from seq N — the chain is not broken, but records
from
Nare not yet covered by a head signature; - broken at position N — a break/substitution/reordering at record
N(a non-zero exit code).
The journal is secondary: the primary truth is the login audit on the devices themselves; the journal serves issuance inventory and incident review.
Localization
Section titled “Localization”The tool’s operator surfaces (the operation summary rendered from a TBS and the CLI output) are localized to Russian and English without an i18n framework (a compact string table). For the CLI the locale is resolved once at startup:
- an explicit setting — the
--langflag (ru/en); - the
TESSERA_ISSUER_LANGvariable; - the
LANGvariable; - the fallback — English.
Matching is by language prefix, case-insensitive: ru_RU.UTF-8 and RU yield
Russian, en_GB English; an unrecognized value simply falls through to the next
source. Only field captions are translated — the technical data (an RFC 4514
subject, an OID, a role_id, serials, crlNumber, timestamps) is reproduced
byte-for-byte in every locale.
See also
Section titled “See also”- cert-issuance.md — the Tessera extensions, their OIDs and semantics, and the monotonic narrowing of the delegation envelope.
- threat-model.md §11 — the attack surface of the issuer tooling, damage containment, and residual risks.