Skip to content

Issuer tooling (tessera_issuer)

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 for wasm32; 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.

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.

Under the fleet root, an organisation CA is issued with an assigned delegation envelope (roles, the МКЦ level ceiling, the TTL ceiling, required tags):

Terminal window
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:

OperationWhat --max-ttl boundsDefault
issue-rootthe organisation CA’s lifetime under the fleet root31536000 (a year)
issue-cathe shift-leaf’s lifetime under the organisation CA14400 (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.

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):

Terminal window
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.pem

CSR path (see The CSR flow):

Terminal window
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.

Terminal window
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.

Terminal window
issuer verify-journal --journal issuance.ndjson

Prints 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.

Operator result messages are localized (Russian/English). The locale resolves as --lang (ru/en) → TESSERA_ISSUER_LANGLANG → 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.

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:

Terminal window
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.pem

The 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”.

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.

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.

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.

Backend secrets — the token PIN for PKCS#11 and the key passphrase for the file backend — are requested through a single ladder of sources:

  1. A source named by a flag. --pinentry <path> for an external pinentry-compatible dialog; --pin-file <path> / --pin-stdin for the token PIN; --key-passphrase-file <path> / --key-passphrase-stdin for 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-file with --backend pkcs11, say): a source you named is never silently swapped for another.
  2. A pinentry found on PATH (pinentry, pinentry-mac, pinentry-gtk-2, pinentry-qt, pinentry-curses).
  3. Console input with the echo off, when the process is attached to a terminal.
  4. An environment variableTESSERA_ISSUER_PIN or TESSERA_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.

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/asn1 does not parse OID arcs larger than int64, and our extensions sit in the 2.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.

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:

Terminal window
# a new encrypted P-256 key
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 \
| openssl pkcs8 -topk8 -v2 aes-256-cbc -out ca-key.p8
chmod 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.p8

The 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 with 700 permissions. 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 --algorithm that does not match the key is an error. --key is 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).

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 N are 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.

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:

  1. an explicit setting — the --lang flag (ru/en);
  2. the TESSERA_ISSUER_LANG variable;
  3. the LANG variable;
  4. 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.

  • 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.