Skip to content

Tessera architecture

This document is the single reference architecture for version 0.4.0. After reading it, an engineer should be able to answer correctly:

  • what happens when pam_sm_authenticate is called?
  • what lives in /run/tessera/?
  • what does monitord do on a udev REMOVE event?
  • how is the IPC serialized, and which messages flow between the PAM module and monitord?
  • Authenticates a local UNIX user with an X.509 certificate on a USB medium or a PKCS#11 token.
  • Binds the bearer to the machine and to the role account through the X.509 v3 extensions pam_cert_host_binding and pam_cert_allowed_roles, embedded in the leaf certificate itself.
  • Monitors the state of the USB medium throughout the session and reacts to its removal (lock / logout / hook / shutdown).
  • Handles suspend/resume correctly.
  • Delegates GOST cryptography to the certified gost-engine.
  • Does not implement its own cryptography (everything goes through OpenSSL and gost-engine).
  • Does not manage the CA lifecycle (certificate issuance/revocation is the job of an external CA).
  • Does not manage token PINs (that is the job of the administrator and the user).
  • Does not protect against compromise of the root account or the OS kernel (outside the TOE).
  • Performs network requests only on the revocation path in OCSP modes (mode ∈ {ocsp, crl_then_ocsp}): a synchronous HTTP POST to the config-supplied ocsp_responder_url, with a hard timeout and an on-disk cache. In the none/crl modes there is no network at all (offline CRL). Zero-egress environments (terminals) stay on none/crl.

The full description of the TOE boundaries is in docs/threat-model.md.

tessera is a workspace of six crates plus one OS integration (systemd, udev, logind). Below are the four runtime crates that run on the device. The other two belong to certificate issuance: tessera_ext — the shared definitions of Tessera’s X.509 extensions (OIDs, DER codecs), used by both the core and the issuance tools; tessera_issuer — the issuance tools, whose pure-Rust core also builds for wasm32 (the external web cabinet links it as its WASM core), described in issuer.md.

The synchronous core. It contains:

  • Loading and validation of the configuration (config::raw::RawConfigconfig::validated::ValidatedConfig).
  • X.509 parsing and verification (x509/).
  • Trust chains and CRL checking (trust/, crl/).
  • Challenge-response (challenge/).
  • GOST delegation via gost-engine (gost/).
  • PKCS#12 and PKCS#11 (pkcs12/, token/).
  • USB mount and the MountGuard RAII (usb/, mount/).
  • Hooks (hooks/).
  • Host identity chain (host_identity/).
  • Cert-scope verification — parsing of the pam_cert_host_binding / pam_cert_allowed_roles extensions and matching their entries against host_id_hash and the requested role, which is also pam_user (x509/, verify_cert_scope).
  • IPC client side (ipc/).

No tokio, no async. All operations are blocking — and this is justified: the PAM module is called from the synchronous libpam context.

The wire protocol for the IPC between the PAM module and the daemon. It contains:

  • ClientMessage and ServerMessage — the message variants (crates/tessera_proto/src/client.rs, .../server.rs).
  • WireError and the encode/decode functions (wire.rs).
  • framing::FramingError — NDJSON framing.
  • SessionTarget — encodes the tty/display/logind-id for a specific session.
  • PROTOCOL_VERSION — the current value is 2.

#![forbid(unsafe_code)] — the crate is purely safe.

2.3 pam_tessera (cdylib libpam_tessera.so)

Section titled “2.3 pam_tessera (cdylib libpam_tessera.so)”

The PAM service module. It contains:

  • The PAM entry points: pam_sm_authenticate, pam_sm_setcred, pam_sm_acct_mgmt, pam_sm_open_session, pam_sm_close_session (see crates/pam_tessera/src/entry.rs).
  • Panic guard (panic_guard.rs) — every C boundary is protected by catch_unwind; a panic maps to PAM_AUTHINFO_UNAVAIL.
  • DI wiring (di.rs) — assembles the core dependencies from the config.
  • Flow orchestrator (flow.rs) — the main authorization pipeline.
  • PAM conversation helpers (pam_conv.rs).
  • Persistent data between pam_sm_* calls (data_handle.rs).

It is built into /lib/security/pam_tessera.so (see debian/rules).

A multi-command CLI: tessera daemon (the long-running daemon, unit tessera.service with the launch line /usr/bin/tessera daemon --config /etc/tessera/config.toml), tessera check, tessera dump-host-id, tessera role, tessera tags, tessera enroll. The daemon owns:

  • The IPC socket (/run/tessera/monitord.sock).
  • udev monitoring of USB devices (udev_monitor.rs).
  • The D-Bus connection to systemd-logind (logind.rs).
  • The registry of active sessions (registry.rs, state.rs).

It is built on tokio multi-thread (see main.rs). It uses sd_notify to integrate with systemd Type=notify. It is built into /usr/bin/tessera and shipped by the unit tessera.service.

ComponentSourceTrust
libpam0gsystem, Astra/Debian repoyes
libssl3system, Astra/Debian repoyes
gost-engineAstra SE 1.7+ (FSB-certified CSP)yes (part of the certified OS)
librtpkcs11ecp.soRutoken, shipped separatelyyes (FSB-certified CSP)
libjcPKCS11.soJaCarta, shipped separatelyyes (FSB-certified CSP)
libudev1system, Astra/Debian repoyes
libdbus-1-3system, Astra/Debian repoyes
libsystemd0system, Astra/Debian repoyes

GOST and PKCS#11. Signing with GOST algorithms works only on the PKCS#12 path — through gost-engine in OpenSSL. On the PKCS#11 path (librtpkcs11ecp.so, libjcPKCS11.so), GOST mechanisms are not supported (the cryptoki crate does not cover GOST mechanisms); Rutoken/JaCarta over PKCS#11 are usable for RSA/ECDSA certificates. GOST-over-PKCS#11 support is the proposal openspec/changes/gost-pkcs11.

flowchart TD
libpam[libpam.so] --> cdylib[libpam_tessera.so]
cdylib --> core[tessera_core]
cdylib --> proto[tessera_proto]
monitord[tessera] --> proto
monitord --> core
cdylib -. "AF_UNIX SOCK_STREAM NDJSON" .-> monitord
core --> openssl[libssl3 + gost-engine]
core --> pkcs11[PKCS#11 module]
monitord --> udev[libudev]
monitord --> dbus[libdbus / logind]

The PAM stack makes several calls in the order auth → account → session. tessera handles all of them, but the real work is in pam_sm_authenticate. The rest read the stored AuthContext from PAM data.

  1. Unpack the module arguments (config=...).
  2. Load and validate config.toml (via tessera_core::config::load_validated_config). On error — PAM_AUTHINFO_UNAVAIL.
  3. Run self_check (engine, paths, hooks placeholders). On error — PAM_AUTHINFO_UNAVAIL.
  4. Read PAM_USER, PAM_SERVICE, PAM_TTY from libpam. The requested role is derived from PAM_USER: the login account name IS the role. There is no other source of the role, and PAM_USER is never rewritten. A name that does not match the role_id format refuses the login before the medium is touched.
  5. Assemble the DI graph via di::wire (mount, trust, token).
  6. Resolve host_id through the chain of sources from the config and compute host_id_hash = sha256(host_id).
  7. Run flow::authenticate(ctx):
    • mount the USB or open a PKCS#11 session;
    • find the certificate, verify the chain and revocation;
    • challenge-response with the private key;
    • extract the pam_cert_host_binding and pam_cert_allowed_roles extensions from the leaf certificate and match them against host_id_hash and the requested role via verify_cert_scope. pam_cert_allowed_roles is the sole source of admission into the account: PAM_USER IS the role, so one list also answers the question “into which account is the bearer admitted”. No fallback to the device configuration exists; an absent extension is a denial, not a switch to another mechanism (return codes — see §13).
  8. On success — build the AuthContext and store it via pam_set_data.
  9. Send Hello + SessionOpen to monitord (receive Ack).
  10. Return PAM_SUCCESS. Any error maps to PAM_AUTHINFO_UNAVAIL / PAM_PERM_DENIED / PAM_MAXTRIES / PAM_AUTH_ERR / PAM_SYSTEM_ERR per the semantics of flow::FlowError::pam_code (the table is in §13).

Does nothing beyond PAM_SUCCESS. Certificates are not placed in the user’s keyring.

Reads the AuthContext and checks that:

  • the certificate’s notAfter has not yet expired (with a tolerance of clock_skew_seconds; the value is taken from the config at the moment of pam_sm_authenticate and stored in the AuthContext).

On a mismatch it returns PAM_ACCT_EXPIRED.

Reads the AuthContext. Sends SessionOpen to monitord with the full payload (see client.rs::SessionOpenPayload):

  • session_id (UUID);
  • pam_user, pam_service;
  • target (Tty / Display / LogindSession);
  • usb_serial — the serial of the medium that authorized the session;
  • host_id_hash — hex SHA-256 of host_id;
  • opened_at — wall-clock unix time;
  • cert_cn, cert_serial;
  • engineer_ski — the lowercase-hex SubjectKeyIdentifier of the engineer’s certificate (v2; an empty string on frames from a v1 client);
  • engineer_cert_sha256 — the lowercase-hex SHA-256(cert DER) of the engineer’s leaf (v2);
  • uid — the Unix uid that the PAM module authenticated (v2; 0 when absent from a v1 client’s frame);
  • role, role_version — the role id and the version of the role snapshot the session was opened with (optional v2 NDJSON fields — optional for compatibility with frames from earlier versions; in the current version every login carries a role).

Monitord adds the session to the registry and starts monitoring the USB.

Sends SessionClose { session_id, closed_at }. Monitord removes the session from the registry and does not trigger on_usb_removed — the user explicitly ended the session.

What tessera keeps on disk while running, who writes and who reads each path. The diagram below is the overall map; the table gives the exact owners and permissions.

flowchart LR
etc["/etc/tessera/"] --> cfg[config.toml]
etc --> ca["ca/bundle.pem"]
etc --> crl["crl/*.pem"]
run["/run/tessera/"] --> sock[monitord.sock]
run --> sessions["sessions/sid/"]
run --> state[sessions.json]
run --> lock[daemon.lock]
var["/var/lib/tessera/ (root-owned policy)"] --> daemon["daemon/ (tessera-owned state)"]
daemon --> wp["wallpaper.orig.jpg"]
PathWritten byRead byPermissions
/etc/tessera/config.tomladministratorcdylib + monitord0640 root:root
/etc/tessera/ca/bundle.pemadministratorcdylib + monitord0640 root:root
/run/tessera/monitord.sockmonitordcdylib0660 tessera:tessera
/run/tessera/sessions/<sid>/cdylibremoved by MountGuard on drop0700 root:root
/run/tessera/sessions.jsonmonitordmonitord (between daemon restarts within a boot; tmpfs, volatile)0600 tessera:tessera
/run/tessera/daemon.lockmonitord (flock singleton; next to sessions.json, fallback /var/lib/tessera/daemon/)monitord
/var/lib/tessera/roles/*.toml, /var/lib/tessera/tags.tomladministratorcdylibroot-owned; no group/world write on the complete path
/var/lib/tessera/daemon/wallpaper.orig.jpgmonitordmonitorddaemon state under 0750 tessera:tessera
/var/cache/tessera/ocsp/*.dercdylib (auth path, OCSP cache)cdylib (with re-verification before use)0640 root:root (directory 0750 root:root)

/run/tessera/ and /var/lib/tessera/daemon/ are created by systemd through the RuntimeDirectory and nested StateDirectory directives of the unit. The package keeps /var/lib/tessera/ itself 0750 root:tessera, so the unprivileged daemon cannot replace the trusted role/tag trees. (see tessera.service and dist/tmpfiles/tessera.conf). /var/cache/tessera/ocsp/ is created by the package’s postinst (debian/postinst).

6. Sequence diagram — pam_sm_authenticate happy path with PKCS#11

Section titled “6. Sequence diagram — pam_sm_authenticate happy path with PKCS#11”
sequenceDiagram
participant U as User
participant L as libpam
participant P as cdylib
participant C as core
participant T as PKCS#11 module
participant M as monitord
U->>L: login attempt
L->>P: pam_sm_authenticate
P->>C: load_validated_config
P->>C: self_check
P->>C: resolve_host_identity
C->>T: C_OpenSession
P->>U: prompt for PIN
U->>P: PIN
P->>T: C_Login
T-->>P: cert + privkey handles
C->>C: build_chain + verify_chain
C->>C: revocation check
P->>U: prompt for challenge
P->>T: C_Sign(challenge)
T-->>P: signature
C->>C: verify(signature, pubkey, challenge)
C->>C: parse_cert_extensions + verify_cert_scope
P->>M: Hello(protocol_version=2)
M-->>P: HelloAck
P->>L: PAM_SUCCESS

7. Sequence diagram — pam_sm_open_session + IPC SessionOpen

Section titled “7. Sequence diagram — pam_sm_open_session + IPC SessionOpen”
sequenceDiagram
participant L as libpam
participant P as cdylib
participant M as monitord
L->>P: pam_sm_open_session
P->>P: read AuthContext from pam_data
P->>M: SessionOpen payload
M->>M: registry.insert(session_id)
M->>M: start udev watch for usb_serial
M-->>P: Ack
P->>L: PAM_SUCCESS

8. Sequence diagram — USB removal → grace → lock

Section titled “8. Sequence diagram — USB removal → grace → lock”
sequenceDiagram
participant K as kernel/udev
participant M as monitord
participant L as logind
participant U as User session
K->>M: udev REMOVE event
M->>M: lookup session by serial
M->>M: start grace timer
alt USB not returned within grace
M->>L: LockSession(id)
L->>U: lock screen
else USB returned
K->>M: udev ADD event
M->>M: cancel grace timer
M->>M: log removal cancelled
end

Behavior of on_usb_removed:

  • "lock"LockSession (default).
  • "logout"TerminateSession.
  • "hook" — runs the usb_removed hook.
  • "shutdown"PowerOff via D-Bus to logind.
sequenceDiagram
participant L as logind
participant M as monitord
L->>M: PrepareForSleep(true)
M->>M: snapshot active sessions
Note right of M: udev REMOVE events<br/>during suspend<br/>will be marked transient
L->>M: PrepareForSleep(false)
M->>M: arm suspend_grace timer
Note right of M: while the timer is active,<br/>any udev REMOVE<br/>with reinsertion within grace<br/>is ignored

With monitor_fail_mode = "strict", the cdylib waits for an Ack from monitord until the timeout; with "permissive" it survives brief unavailability.

  • AF_UNIX SOCK_STREAM.
  • Socket path: /run/tessera/monitord.sock.
  • Permissions: 0660 tessera:tessera (see tmpfiles + systemd RuntimeDirectory).
  • Peer authentication: SO_PEERCRED — monitord checks that uid == 0. Any other peer is closed.
  • Implementation: crates/tessera_cli/src/peercred.rs.

Newline-delimited JSON (NDJSON):

  • each frame is a single UTF-8 JSON line;
  • the terminator is a single \n byte;
  • the maximum frame size is MAX_FRAME_BYTES = 64 KiB (see crates/tessera_proto/src/wire.rs).

Rationale for choosing NDJSON:

  • standard tools (jq, the journalctl formatter) can process it without special support;
  • framing is trivial — the \n delimiter;
  • the cost of parsing JSON is justified by the low message rate (≤ 10 per second on a typical day).
  • PROTOCOL_VERSION: u32 = 2 (see crates/tessera_proto/src/version.rs). Version 2 added GetActiveSessionByUid / ActiveSession, the optional SessionOpen fields (engineer_ski, engineer_cert_sha256, uid, plus the optional role / role_version) and the error code NO_ACTIVE_SESSION (1200); frames from a v1 client without the new fields still deserialize.
  • The first frame on any connection is Hello { protocol_version }.
  • If protocol_version does not equal the server’s, monitord replies with Error { code: 1000 (PROTOCOL_MISMATCH) } and closes the connection.
  • Version semantics: a MAJOR mismatch → disconnect; MINOR (if any appear) — best-effort backward compatibility.

From crates/tessera_proto/src/client.rs:

{"type": "hello", "protocol_version": 2, "agent": "libpam_tessera/0.4.0"}
{"type": "session_open", "session_id": "1c5e8a90-3b6f-4a1d-9c2e-77f0b1c2d3e4", "pam_user": "alice", "pam_service": "sudo", "target": {"kind": "logind_session", "id": "12"}, "usb_serial": "RUTOKEN-001", "host_id_hash": "ee0bd4f3a3c8e21d4a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f", "opened_at": 1735689600, "cert_cn": "Alice", "cert_serial": "01a2b3c4d5e6f70809"}
{"type": "session_close", "session_id": "1c5e8a90-3b6f-4a1d-9c2e-77f0b1c2d3e4", "closed_at": 1735689700}
{"type": "ping"}

From crates/tessera_proto/src/server.rs:

{"type": "hello_ack", "server_version": "0.4.0", "protocol_version": 2}
{"type": "ack"}
{"type": "pong"}
{"type": "error", "code": 1000, "message": "protocol version mismatch"}

There are no separate per-frame timeouts. The client applies a single configurable monitor.timeout_ms (default 2000 ms, range 100..=60000 ms) to the whole connection — the value is set via set_read_timeout / set_write_timeout on the socket at MonitordClient::connect (see crates/tessera_core/src/ipc/client.rs) and covers both the handshake and all subsequent RPCs on that connection.

InitiatorMessageRecipientExpected responseAction on timeout
clientHelloserverHelloAck or Errorclose the connection
clientSessionOpenserverAck or Errorper monitor_fail_mode
clientSessionCloseserverAcklog + continue
clientPingserverPonglog + continue

From crates/tessera_proto/src/server.rs:

CodeNameSemanticscdylib action
1000PROTOCOL_MISMATCHThe protocol versions did not match.fail-closed
1001DEVICE_GONEThe USB device with usb_serial is absent.fail-closed
1003UNAUTHORIZEDThe peer is not uid=0 (per SO_PEERCRED).disconnect
1100BAD_REQUESTInvalid frame (schema violation).disconnect + log
1101PROTOCOL_VIOLATIONWire-protocol violation: an oversize frame, an idle timeout, etc. The server closes the connection after sending.disconnect + log
1200NO_ACTIVE_SESSIONNo active session for the requested uid (a v2 daemon’s reply to GetActiveSessionByUid).ordinary “not found”
1500INTERNALInternal daemon error.per monitor_fail_mode

DEVICE_GONE and UNAUTHORIZED are always fatal — they change the authentication verdict and are propagated even in permissive (ipc/failmode.rs). The other errors are governed by the monitor_fail_mode policy at the specific call-site; on the auth path a failure to notify monitord does not undo an authentication success that has already happened (see §13).

{
"title": "SessionOpenPayload",
"type": "object",
"properties": {
"session_id": {"type": "string", "format": "uuid"},
"pam_user": {"type": "string"},
"pam_service": {"type": "string"},
"target": {"type": "object"},
"usb_serial": {"type": ["string", "null"]},
"host_id_hash": {"type": "string", "pattern": "^[0-9a-f]{64}$"},
"opened_at": {"type": "integer"},
"cert_cn": {"type": "string"},
"cert_serial": {"type": "string", "pattern": "^[0-9a-f]+$"},
"engineer_ski": {"type": "string", "pattern": "^[0-9a-f]*$"},
"engineer_cert_sha256": {"type": "string", "pattern": "^[0-9a-f]*$"},
"uid": {"type": "integer"},
"role": {"type": ["string", "null"]},
"role_version": {"type": ["integer", "null"]}
},
"required": ["session_id", "pam_user", "pam_service", "target", "host_id_hash", "opened_at", "cert_cn", "cert_serial"]
}

The engineer_ski, engineer_cert_sha256, uid fields are from version 2: a v2 client always serializes them, while a v1 client’s frame without them deserializes to the defaults (empty string / 0), which is why they are not listed in required. role / role_version are present only when the session is opened with a role.

  • Fully synchronous, no tokio.
  • The connection to monitord is single per pam_sm_* call; it is closed after the response.
  • No shared mutable state: each PAM call has its own flow::Context.
  • tokio multi-thread runtime (the number of worker threads is tokio’s system default by default).
  • One dedicated task per incoming connection (server.rs::handle_connection).
  • The session registry is a Mutex<RegistryStore> (see registry.rs).
  • udev and logind have their own dedicated long-running tasks.
  • Writing /run/tessera/sessions.json is an atomic rename via a tempfile (no flock; a separate singleton lock daemon.lock guards against a double start of the daemon). The file lives on tmpfs (RuntimeDirectory=tessera), intentionally volatile: the registry is needed only between daemon restarts within a single boot — all the processes holding these sessions (sshd/login/sudo) die on reboot anyway.

11.3 Shared access to /run/tessera/sessions/

Section titled “11.3 Shared access to /run/tessera/sessions/”
  • The cdylib creates the <sid> directory via MountGuard::new (RAII).
  • It removes the directory in Drop (or in pam_sm_close_session).
  • monitord does not write to this directory directly — it only reads it for diagnostics.

host_id is computed at the moment of pam_sm_authenticate through a chain of sources from the [host_identity] section. The implementation is crates/tessera_core/src/host_identity/chain.rs.

The sources in order of preference:

  1. machine_id/etc/machine-id (stable across reboots, changes on reinstall).
  2. dmi_board_serial/sys/class/dmi/id/board_serial (stable at the hardware level, changes when the motherboard is replaced).
  3. dmi_system_uuid / dmi_system_serial — the system’s DMI identifiers (stability depends on the vendor/hypervisor).
  4. hostname/etc/hostname (unstable, easily spoofed; OK for tests).
  5. custom_command — an administrator’s script.
  6. override — a fixed value from the config (bootstrap/tests).

The chain is traversed in the order given in sources. The first non-empty result wins. If all sources are empty:

  • fallback = "deny"PAM_AUTH_ERR (production default);
  • fallback = "warn"PAM_SUCCESS with a warning log (test environment);
  • fallback = "allow"PAM_SUCCESS silently (dangerous, do not use).

The module is designed fail-closed: a failure of any certificate check (the trust chain, revocation, challenge-response, host/user binding) leads to a denied login, not a pass-through. The table below lists the conditions and the PAM codes the stack receives; the single deliberate exception — monitord being unavailable does not undo an authentication success that has already happened — is set out in “Principles” below the table.

#ConditionReturn
1panic in any pam_sm_*PAM_AUTHINFO_UNAVAIL (9)
2loading config.toml failedPAM_AUTHINFO_UNAVAIL (9)
3self_check failed (engine, paths, hooks)PAM_AUTHINFO_UNAVAIL (9)
4USB/mount/discovery produced no medium, the PKCS#11 module did not loadPAM_AUTHINFO_UNAVAIL (9)
5the certificate fails chain verificationPAM_PERM_DENIED (6)
6revocation check failed (crl: serial in the CRL, CRL absent/stale; ocsp/crl_then_ocsp: responder unreachable, timeout, status unknown/revoked, invalid response signature)PAM_PERM_DENIED (6)
7challenge-response did not matchPAM_PERM_DENIED (6)
8the requested role is not covered by pam_cert_allowed_roles — the extension is absent, invalid (the list is treated as empty), or does not contain the rolePAM_PERM_DENIED (6)
9PIN attempt limit exhausted (MaxTries, PinLocked)PAM_MAXTRIES (11)
10the pam_cert_host_binding extension is absent or invalidPAM_AUTH_ERR (7)
11host_id_hash is not among the pam_cert_host_binding entriesPAM_AUTH_ERR (7)
12a single PIN error, a PAM conversation error, a hook refusalPAM_AUTH_ERR (7)
13a violation of internal invariants (Internal)PAM_SYSTEM_ERR (4)
14an Error from monitord with code = DEVICE_GONE / UNAUTHORIZEDpropagated always, even in permissive

The full mapping table of FlowError → PAM code is the doc-comment on FlowError::pam_code in crates/pam_tessera/src/flow.rs.

Principles:

  • panics and infrastructure errors → PAM_AUTHINFO_UNAVAIL (which tells the PAM stack: “the next module may try”).
  • Failures of a cryptographic check (chain, revocation, challenge-response) and a role denial → PAM_PERM_DENIED; host-scope failures (pam_cert_host_binding) and other auth errors → PAM_AUTH_ERR; an exhausted PIN budget → PAM_MAXTRIES.
  • Monitord registration is part of the authentication verdict. With monitor_fail_mode = "strict", an IPC transport or registration failure denies the login because the session could not be placed under removal enforcement. With permissive, the FailModeWrapper absorbs transport failures, while DEVICE_GONE and UNAUTHORIZED remain fatal in every mode (crates/tessera_core/src/ipc/failmode.rs).

14. Logging: tracing → syslog / journald

Section titled “14. Logging: tracing → syslog / journald”

The cdylib pam_tessera.so logs to syslog, not to stderr: libpam discards the module’s stderr, so production diagnostics on stderr are unavailable. The tracing subscriber is built at the moment of the first pam_sm_* call and sends records to syslog through the LOG_AUTH facility with the ident pam_tessera. On systems with journald these lines are visible via journalctl -t pam_tessera and land in /var/log/auth.log (on a plain syslog stack) with the prefix pam_tessera[<pid>]:. (The switch from stderr to syslog happened in 0.1.1, see changelog.md (Russian).)

tessera uses tracing-journald and writes to journald through the native Type=notify channel. On SysV-init hosts without journald, tracing records go to the daemon’s stderr; where they end up next is determined by how the init script redirects stderr (in the standard distribution start-stop-daemon hands stderr to the system syslog via logger).

The full semantics of what is logged and at which level is in docs/operations.md §6.