Certificate issuance: host_binding and allowed_roles
Introduction
Section titled “Introduction”This document is addressed to the CA administrator: it explains which X.509
extensions to embed in a Tessera leaf certificate, and provides ready-made
openssl.cnf fragments from which the certificate is issued with a stock
openssl x509 -req.
There are five extensions. Two are mandatory: they encode the “in which role on which host” authorization and are checked by the PAM module at the authentication phase:
pam_cert_host_binding— on which hosts the certificate is valid;pam_cert_allowed_roles— which roles the certificate may activate.
These two extensions and they alone define the certificate’s scope. There is no
separate list of permitted accounts: the name of the login account IS the role,
so pam_cert_allowed_roles also answers the question “into which account is the
bearer admitted”. There is no device-side fallback — no mechanism by which the
device could permit a login on its own terms: the scope is assigned by the
issuer, not by the party being constrained.
The remaining three are optional, for specific capabilities; each is covered in its own section below:
pam_cert_max_integrity— the integrity-label ceiling on Astra МКЦ hosts;pam_cert_profile_version— the certificate format version (version-gate);pam_cert_delegation_constraints— the delegation envelope for an intermediate CA.
OID table
Section titled “OID table”| Extension name | Dotted OID | ASN.1 syntax | Criticality |
|---|---|---|---|
pam_cert_host_binding | 2.25.183976554325829274683049824615098 | extnValue ::= SEQUENCE OF UTF8String | non-critical |
pam_cert_allowed_roles | 2.25.185305973969816596290730578528098241367 | extnValue ::= SEQUENCE OF UTF8String | non-critical |
pam_cert_max_integrity | 2.25.273824307386008814506455310913083078403 | extnValue ::= SEQUENCE { level INTEGER, categories BIT STRING } | non-critical |
pam_cert_profile_version | 2.25.107983357797077476746994938370032043240 | extnValue ::= INTEGER | critical |
pam_cert_delegation_constraints | 2.25.242193075883906031821745064285793775511 | SEQUENCE { requireTags, allowRoles, maxLevel, maxTtl } | critical, CA=TRUE only |
The OIDs live in the unregistered 2.25.<UUID> branch (RFC 4530), which
guarantees uniqueness without consulting any external registry. These values are
fixed in the code (tessera_core::x509::oids) and are part of the on-the-wire
X.509 contract — they must not be changed.
Semantics
Section titled “Semantics”Each UTF8String entry in pam_cert_host_binding is interpreted as follows:
| Entry | Meaning |
|---|---|
* | allowed on any host |
sha256:<HEX> | allowed only on the host whose host_id_hash matches the given 64-character lowercase-hex value (case-insensitive) |
| Any other UTF-8 string | the string is interpreted as a “raw” machine_id and the comparison goes through SHA-256 of the string |
In pam_cert_allowed_roles an entry is a role_id that must match
^[a-z][a-z0-9-]{0,15}$. A * wildcard is not supported here: the role list is
always enumerated explicitly. For details see
The allowed_roles extension.
To authorize a certificate on a specific host and in a specific role account, at least one matching entry is required in each of the two extensions.
Scenario 1 — workstation: one host, one role
Section titled “Scenario 1 — workstation: one host, one role”A specific operator’s workplace. The certificate can be used only on the machine
with the known machine_id and only to log into the role account oper.
# openssl.cnf — fragment[ user_exts ]basicConstraints = critical,CA:FALSEkeyUsage = critical,digitalSignatureextendedKeyUsage = clientAuthsubjectAltName = email:[email protected]
# Host: SHA-256 of the operator workstation's machine-id2.25.183976554325829274683049824615098 = ASN1:SEQUENCE:hb_one# Role, which is also the login account: the single one2.25.185305973969816596290730578528098241367 = ASN1:SEQUENCE:ar_one
[ hb_one ]e0 = UTF8String:sha256:a1b2c3d4e5f6...64charsTotal...
[ ar_one ]e0 = UTF8String:operThe engineer’s identity (CN, subjectAltName) does not affect admission: it
serves the issuance journal and incident analysis, while the decision is made
from the two extensions above.
Issuance command:
openssl req -new -key user.key -subj "/CN=Иванов" \ -reqexts user_exts -config openssl.cnf -out user.csropenssl x509 -req -in user.csr -CA int.pem -CAkey int.key \ -CAcreateserial -days 365 -sha256 \ -extfile openssl.cnf -extensions user_exts -out user.pemScenario 2 — terminal operator: several hosts, one role
Section titled “Scenario 2 — terminal operator: several hosts, one role”[ hb_three_hosts ]e0 = UTF8String:sha256:1111111111111111111111111111111111111111111111111111111111111111e1 = UTF8String:sha256:2222222222222222222222222222222222222222222222222222222222222222e2 = UTF8String:sha256:3333333333333333333333333333333333333333333333333333333333333333
[ ar_operator ]e0 = UTF8String:operScenario 3 — mobile administrator: any host, exact role
Section titled “Scenario 3 — mobile administrator: any host, exact role”[ hb_any ]e0 = UTF8String:*
[ ar_admin ]e0 = UTF8String:admin* in host_binding lets the certificate work on any machine; in allowed_roles a
hard restriction on the role — that is, on the login account name — still
remains.
Verifying an issued certificate
Section titled “Verifying an issued certificate”openssl x509 -in user.pem -noout -textThe output must contain both lines with the dotted OIDs:
2.25.183976554325829274683049824615098: 0...sha256:a1b2c3d4...2.25.185305973969816596290730578528098241367: 0...operVerification table
Section titled “Verification table”| Entry | Matches… |
|---|---|
* (host_binding) | any host |
sha256:<HEX> | the host whose host_id_hash equals HEX (case-insensitive) |
<raw> (host_binding) | the host whose host_id_hash equals sha256(raw) |
<role> (allowed_roles) | a login into the role account with the exact name <role> |
| Extension absent | deny (HostExtensionMissing; for allowed_roles — the role is not covered) |
| Extension empty or DER-broken | deny (*ExtensionMalformed) |
| Entries present but none matched | deny (HostNotAllowed / the role is not covered) |
See also docs/configuration.md.
The MAX_INTEGRITY extension (Astra МКЦ, 0.3.0+)
Section titled “The MAX_INTEGRITY extension (Astra МКЦ, 0.3.0+)”MAX_INTEGRITY is a non-critical X.509 v3 extension encoding the maximum
integrity label (level, categories) up to which the certificate may be
admitted on an Astra SE host with strict-mode enabled.
OID: 2.25.273824307386008814506455310913083078403
Structure (DER):
IntegrityLabel ::= SEQUENCE { level INTEGER (-128..127), categories BIT STRING DEFAULT ''B}Server-side semantics:
- On
open_sessionthe PAM module picks the effective label asintersect(cert, runtime_caps, fallback?). cert_integrity = "required"→ a certificate without the extension is rejected.cert_integrity = "optional"→ the absence of the extension is allowed; if[mac.fallback_max_integrity]is set, it is applied.cert_integrity = "ignore"→ the extension is ignored.
See docs/configuration.md §“MAC integrity” and docs/threat-model.md
§“Privilege-escalation via MAC label”.
Ready-made openssl.cnf templates for test certificates:
tests/fixtures/leaf-{l2-c01,l1-empty,no-ext,l3,malformed,l0-fullcats}.cnf.
Generation — tests/fixtures/setup-mac-fixtures.sh.
Example line in openssl.cnf for level=2, categories={0}:
2.25.273824307386008814506455310913083078403 = DER:30:07:02:01:02:03:02:00:01The DER here is three TLVs: SEQUENCE (length 7), INTEGER 2,
BIT STRING '01'B. The extension is declared non-critical (see above); the
parser tolerates the critical flag, but issuance should be non-critical.
The allowed_roles extension (role selection at login)
Section titled “The allowed_roles extension (role selection at login)”pam_cert_allowed_roles is a non-critical X.509 v3 extension listing the
role_ids that the leaf certificate is entitled to activate at login. The
requested role is the login account name (ssh serv@device). The semantics
are authorization-oriented: a role is covered if its role_id is present in
the list.
This is the sole admission list. It answers “into which account is the holder
admitted” with the very same enumeration, because the login account name IS the
role: a certificate with allowed_roles = [oper] does not admit a login into
serv. There is no separate list of accounts — two lists over one string would
describe an unrealizable state, “admitted into serv, but not entitled to be
serv”.
OID: 2.25.185305973969816596290730578528098241367
Structure (DER) — the same as host_binding:
extnValue ::= SEQUENCE OF UTF8StringEach UTF8String is a role_id and must match ^[a-z][a-z0-9-]{0,15}$. The
list is parsed strictly fail-closed: on malformed DER or any string not
passing the role_id regex, the whole extension is considered malformed (not a
skip of a single string), the role list is empty → the requested role is not
covered → deny (audit cert_allowed_roles_parse_failed). The absence of the
extension = the certificate grants no roles, so it cannot be used to log in at
all: a role is always required and coverage is proven from this extension.
Server-side semantics: see docs/configuration.md §“roles” and the delta spec
role-selection. Extraction only from a verified certificate (VerifiedX509),
as with max_integrity.
openssl.cnf fragment via ASN1:SEQUENCE (two roles — oper, serv):
# Roles the cert may activate at login2.25.185305973969816596290730578528098241367 = ASN1:SEQUENCE:allowed_roles
[ allowed_roles ]e0 = UTF8String:opere1 = UTF8String:servEquivalent as a single DER string (SEQUENCE { UTF8String "oper", UTF8String "serv" }):
2.25.185305973969816596290730578528098241367 = DER:30:0c:0c:04:6f:70:65:72:0c:04:73:65:72:76The DER here: SEQUENCE (30 0c) → UTF8String "oper" (0c 04 6f 70 65 72) →
UTF8String "serv" (0c 04 73 65 72 76). The extension is non-critical (no
critical, prefix).
The profile_version extension (version-gate)
Section titled “The profile_version extension (version-gate)”pam_cert_profile_version is a critical X.509 v3 extension carrying the
integer version of the cert format. Engine knows
max_supported_profile_version (config [trust].max_supported_profile_version,
default 0); a cert at any link of the chain with a higher version →
reject the whole chain (fail-closed version-gate). This is the second layer of
protection against format evolution: an ununderstood critical OID is rejected by
RFC, and an understood but newer profile — by the version-gate.
OID: 2.25.107983357797077476746994938370032043240
Structure (DER):
extnValue ::= INTEGERExtraction only from a verified cert (VerifiedX509). Malformed (not an
INTEGER) or a negative value → reject (audit profile_version_rejected). The
absence of the extension = baseline (version 0), allowed.
The current format version is 0; no other version exists yet. issuer
without an explicit --profile-version issues certificates with version 0,
and that is the only version an Engine accepts without a configuration change.
A value above zero makes sense only for a fleet where
[trust].max_supported_profile_version has already been raised on every device
— otherwise the issued certificate is rejected at the very first
authentication.
openssl.cnf fragment for the current version (0):
2.25.107983357797077476746994938370032043240 = critical,ASN1:INTEGER:0Equivalent as a DER string (INTEGER 0): critical,DER:02:01:00.
The delegation_constraints extension (delegation envelope)
Section titled “The delegation_constraints extension (delegation envelope)”pam_cert_delegation_constraints is a critical X.509 v3 extension, valid
only on a cert with basicConstraints CA=TRUE (on a leaf → malformed →
reject). It declares the issuing CA’s delegation envelope: which device group
(by tags), which roles, the level ceiling, and the TTL it is entitled to issue.
The guarantee is checked on the device offline against its own signed tags, by a
logical AND/MIN over all CA links of the chain (a misissued child CA does
not break out of the parent envelope).
OID: 2.25.242193075883906031821745064285793775511
Structure (DER):
DelegationConstraints ::= SEQUENCE { requireTags SEQUENCE OF SEQUENCE { key UTF8String, value UTF8String }, allowRoles SEQUENCE OF UTF8String, -- each a valid role_id maxLevel INTEGER, -- МКЦ-level ceiling (-128..127) maxTtl INTEGER -- link lifetime ceiling, seconds}Device-side semantics: device.tags ⊇ requireTags (generic pair comparison, no
hardcoded key names); the requested role ∈ allowRoles; the requested level
≤ maxLevel; the child link lifetime ≤ maxTtl. Any violation → reject (audit
delegation_denied; the engineer sees a generic reason). Extraction only from
VerifiedX509; malformed or an invalid role_id → reject.
openssl.cnf fragment via ASN1:SEQUENCE (a CA for region=north, roles
oper/serv, level ≤ 5, TTL ≤ 14400 s):
# Only on a CA cert (basicConstraints CA:TRUE)2.25.242193075883906031821745064285793775511 = critical,ASN1:SEQUENCE:deleg
[ deleg ]field1 = SEQUENCE:require_tagsfield2 = SEQUENCE:allow_rolesfield3 = INTEGER:5 # maxLevelfield4 = INTEGER:14400 # maxTtl
[ require_tags ]t0 = SEQUENCE:tag_region
[ tag_region ]key = UTF8String:regionval = UTF8String:north
[ allow_roles ]r0 = UTF8String:operr1 = UTF8String:servEquivalent as a DER string:
2.25.242193075883906031821745064285793775511 = critical,DER:30:28:30:11:30:0f:0c:06:72:65:67:69:6f:6e:0c:05:6e:6f:72:74:68:30:0c:0c:04:6f:70:65:72:0c:04:73:65:72:76:02:01:05:02:02:38:40DER: SEQUENCE(30 28){ SEQUENCE(30 11) requireTags { SEQUENCE(30 0f){
UTF8String "region", UTF8String "north" } }, SEQUENCE(30 0c) allowRoles {
UTF8String "oper", UTF8String "serv" }, INTEGER 5(02 01 05),
INTEGER 14400(02 02 38 40) }.
Monotone narrowing. A child CA MUST issue an envelope ⊆ the parent’s (more
requireTags, a subset of allowRoles, no larger maxLevel/maxTtl) — for
early denial and clarity; but security does not depend on the links being
honest: Engine applies each CA’s envelope by AND, so a wider child envelope does
not broaden the permissions. Narrowing example: parent
requireTags{region:north}, allowRoles{oper,serv,admin}, maxLevel:7; child
region CA requireTags{region:north,site:hq}, allowRoles{oper,serv},
maxLevel:5.
Workflow for cloned images
Section titled “Workflow for cloned images”The full end-to-end runbook (reference → clone → flip → per-host issuance) is in docs/clone-image.md. Here — only the CA side: how to read the TSV dump and what goes into the issued certificate.
The TSV dump from the operator
Section titled “The TSV dump from the operator”After finish-bootstrap.sh the operator sends the CA admin the file
host-ids-<hostname>-<UTC>.tsv (over USB or through a secure channel). Columns:
source status hash_hex hash_prefix raw normalized active_under_current_config reasonOne row per known source (not only those configured in
[host_identity].sources): machine_id, dmi_board_serial,
dmi_system_uuid, dmi_system_serial, hostname, plus custom_command (if
configured).
The row with active_under_current_config=yes is the source the daemon is
using right now. Only its hash_hex goes into the certificate.
Issuing the per-host certificate
Section titled “Issuing the per-host certificate”The hash_hex is fed into the CA issuance tool (see
clone-image.md §6.1 — the CA tools are shipped separately, not
in this repository).
The cert receives pam_cert_host_binding = <hash_hex>,
pam_cert_allowed_roles = <role> and the standard
extendedKeyUsage = clientAuth, emailProtection (emailProtection is required
by the stock Astra validator — openssl CMS_verify; tessera itself does not
check this EKU). On a МКЦ workstation, additionally
pam_cert_max_integrity (see §“The MAX_INTEGRITY extension”).
The resulting .p12 is packed onto the same USB stick by the CA tool and
returned to the workstation.
Pre-flight checks
Section titled “Pre-flight checks”tessera dump-host-id (invoked inside finish-bootstrap.sh or by hand) exits
with a non-zero code if no source returned a non-empty value. This is an
unambiguous “do not issue the certificate until the login is fixed” signal —
typical causes: empty DMI fields in a VM, a cleared machine_id, a
non-working custom_command. See clone-image.md §8 —
troubleshooting.
Manual dump (without the script)
Section titled “Manual dump (without the script)”After a flip has already happened:
tessera dump-host-id --usb— to a USB stick;tessera dump-host-id --output /tmp/host.tsv— to a file;tessera dump-host-id(no flags) — to stdout.