Skip to main content
Version: v0.1.0-rc.1

๐Ÿ“š Audit log format

The node writes and chains its audit log, and cryptosctl audit list and audit verify read and check it (see Check the audit log). Exporting the raw files for verification off the node is not available; see What you can't do yet.

How the tamper-evident log is structured and verified.

Every CryptOS node keeps an audit log: one entry for every call to its API except the status polls, each entry signed and chained to the one before it. A changed, removed or reordered entry breaks the chain from that point on. For the reasoning behind the design, see How the audit log proves integrity. The FleetOS Manager keeps a separate log of its own, described at the end of this page.

๐Ÿ“ What gets recordedโ€‹

  • Every gRPC call on the node's mTLS listener and on its local socket, one entry per call, written when the call finishes, whatever its outcome, except GetStatus and GetIdentity (below).
  • Streaming calls (for example StartCeremony and StageImage) get one entry for the whole stream, with no request digest.
  • SCEP enrolment decisions, one entry per PKIOperation, with the method scep/PKCSReq, scep/RenewalReq, scep/CertPoll, scep/GetCert or scep/GetCRL and the device's signer certificate as the actor.

These are not recorded:

WhatWhy, and where it shows instead
GetStatus and GetIdentity, from any callerThe node console polls both every 2 seconds and the FleetOS Manager calls them whenever it shows a node. They change nothing and return only what the node publishes anyway (its status and its CA certificate), so recording them would fill the log with reads. Every other read is recorded, including GetConfig, ListIssued, GetIssuedCertificate, ExportCAKey, ListAuditEvents and VerifyAuditChain.
Maintenance mode, including the installer's first ApplyConfigThere is no state partition to write to yet, so the install is recorded nowhere. The log starts on the installed node's first boot.
ACME, EST, CRL and OCSP requests, and SCEP GetCACert and GetCACapsThey run on their own HTTP listeners, not the gRPC API. Every certificate ACME or EST issues is in the node's issued set (cryptosctl ca list-issued). Protocol events go to the node's kernel log, which is held in memory only and lost at reboot.
A client whose certificate fails the TLS handshakeIt never reaches an API call.

๐Ÿ“‚ Where it livesโ€‹

The log is a directory of files on the node's encrypted state partition, one file per UTC day:

/var/lib/cryptos/audit/2026-09-30.log
/var/lib/cryptos/audit/2026-10-01.log

The directory is mode 0700 and each file 0600. Entries are only ever appended, and each write is flushed to disk before the next. After a reboot the node reads the newest file and carries on from the last sequence number and hash, so the chain continues across boots and across days.

๐Ÿงพ Line formatโ€‹

Each line is one entry:

<entry as protobuf JSON> <signature>
  • The entry is an AuditEvent message (from the API's audit.proto) encoded as protobuf JSON, on one line.
  • One space separates it from the signature.
  • The signature is an Ed25519 signature over the entry's JSON bytes exactly as written, encoded as base64 with no padding.

Three real entries, as the node writes them:

{"seq":"1", "ts":"2026-09-30T14:02:11.482Z", "actorSubject":"CN=admin", "rpcMethod":"/cryptos.node.v1.NodeService/StartCeremony", "outcome":"OUTCOME_OK", "prevEntrySha256":"47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="} 1onlVZIwAQNwMLAHuzJr0KRHmTluVSJN+ojWHpWIZTa0aI1IVCnprmMYqD57rt79NkhSVQ0ywmAmA2eFzBnVDg
{"seq":"2", "ts":"2026-09-30T14:03:11.482Z", "actorSubject":"CN=admin", "rpcMethod":"/cryptos.node.v1.NodeService/ApplyConfig", "requestDigestSha256":"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", "outcome":"OUTCOME_OK", "prevEntrySha256":"JLpgGDBdvilYnnOnzr008RfLokhuE1XRv7POYwYBKyc=", "details":{"config_digest_sha256":"9f2c4e1a7b3d5f6082a4c6e8f0b2d4f6a8c0e2f4b6d8fa1c3e5a7c9eb1d3f5a7", "config_generation":"2", "requires_reboot":"false"}} JMsxHOhrnEsiQxmqQPcAAJrywl3mP0iOt9w68aUFYoCZh6M7v6BzlvOp7YvHfu7gdvaFCxJ+29OVm1bNRVCyDA
{"seq":"3", "ts":"2026-09-30T14:04:11.482Z", "actorSubject":"CN=admin", "rpcMethod":"/cryptos.node.v1.NodeService/IssueLeaf", "requestDigestSha256":"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", "outcome":"OUTCOME_OK", "prevEntrySha256":"X2seRmi7bktrRE7SSz7Oaj0Pj6lVAQ32GUkPruCAfCc=", "details":{"request_dns_names":"web.example.org"}} m1TlQGyzX8MVFlppOCtxzuLE7ZTJY7NtUlaicdXwFWA6Z8YTx24P6DTj2uv+0uu5X3v+H0h/W6mEIUkKxPkIBA

The request digests and the config digest here are placeholder patterns; a real one is the SHA-256 of a real request or config.

๐Ÿ“‹ Fieldsโ€‹

Fields that are empty are left out of the JSON, as protobuf JSON does for default values.

JSON keyProto fieldMeaning
seqseq (uint64)The entry's sequence number: 1 for the first entry, then up by exactly one. Written as a JSON string, as protobuf JSON does for 64-bit integers. A gap means entries are missing.
tsts (Timestamp)When the call was recorded, in RFC 3339 UTC.
actorSubjectactor_subjectThe subject DN of the verified client certificate, for example CN=admin. Empty (left out) on the local socket, which has no TLS.
rpcMethodrpc_methodThe full gRPC method, for example /cryptos.node.v1.NodeService/IssueLeaf.
requestDigestSha256request_digest_sha256The SHA-256 of the request message, in deterministic protobuf binary encoding, as standard base64. It binds every field of the request without storing it. Left out for streaming calls.
outcomeoutcomeOUTCOME_OK on success, OUTCOME_DENIED when the call was refused with PermissionDenied, OUTCOME_ERROR for any other failure.
prevEntrySha256prev_entry_sha256The SHA-256 of the previous entry's JSON bytes, as standard base64. See the chain rules below.
detailsdetails (map)A few facts recorded in the clear for a reader of the log. Absent on most entries.

The details keys the node writes today:

KeyWritten byValue
serial_hexRevokeCertificateThe serial the call named, in the node's form: lower-case hex with no leading zeros. Recorded once the caller is authorized, whether or not the revocation succeeds.
request_dns_namesIssueLeaf, when the caller passes DNS names (cryptosctl ca issue-leaf --dns)The names, comma-separated. Recorded once the caller is authorized, whether or not the signer accepts them.
requested_not_afterIssueLeaf or SignSubordinateCSR, when the certificate was capped at the issuer's notAfterThe notAfter the profile asked for, RFC 3339 UTC.
effective_not_afterSameThe notAfter the certificate received, RFC 3339 UTC.
config_generationApplyConfig, when the apply succeedsThe config generation the apply produced, in decimal.
config_digest_sha256SameThe SHA-256 of the applied config, in lower-case hex.
requires_rebootSametrue when part of the change takes effect only at the next boot, otherwise false.
reboot_kindRebootreboot or power_off, as the caller asked. Recorded on a refused call too.
transaction_id, pki_status, remote_addr, fail_info, reason, profile, authorized_by, challenge_id, serial_hex, namesSCEP PKIOperation entriesThe decision on one SCEP request. The challenge itself never appears, only the ID of the one consumed.

The details are part of the entry's JSON, so the signature and the chain cover them like every other field.

๐Ÿ”— Chain and signature rulesโ€‹

A verifier checks every file in date order (the file names sort by date) and every line in order:

  1. Split the line at its last space into the JSON bytes and the signature. A line that doesn't split, or whose signature isn't valid base64 without padding, is malformed.
  2. Check the signature: Ed25519 over the JSON bytes, with the node's audit public key.
  3. Check the sequence: the first entry across all files is 1, and each entry is one more than the last.
  4. Check the link: prevEntrySha256 must equal the SHA-256 of the previous line's JSON bytes. For the very first entry it's the SHA-256 of empty input, which is 47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU= in base64 (e3b0c442...b855 in hex).

Stop at the first failure and note its file and line: every entry after it is unproven. cryptosctl audit verify runs these checks on the node and reports the first failure's sequence number (the one the failing entry holds, or the one expected at its place when the line can't be read), its file and line, and the reason.

Hash the bytes as written, never a re-encoded copy

Protobuf JSON output isn't byte-stable: the spacing and order can differ between encoder versions and runs. The signature and the chain cover the exact bytes on the line. A verifier that parses an entry and encodes it again will get different bytes and report a false mismatch.

The signing keyโ€‹

The audit key is an Ed25519 key derived, not stored. The node generates a 32-byte master seed at first boot and keeps it at /var/lib/cryptos/seed on the encrypted state partition. The audit key is HKDF-SHA256 over that seed, with no salt and the info string cryptos.dev/audit-signer/v1. The key is separate from the CA key, so audit signing never touches the key that issues certificates. The ceremony manifest is signed by a second key from the same seed, with the info string cryptos.dev/ceremony-signer/v1.

๐Ÿšง What you can't do yetโ€‹

Verification runs on the node

ListAuditEvents returns each entry and its SHA-256, and VerifyAuditChain checks the chain on the node. Neither returns the entry signatures or the audit public key, and the node doesn't ship entries to a SIEM, so you can't verify the log independently off the node in this release.

A failed audit write doesn't fail the call

If the node can't append an entry, the API call still returns its normal result to the client. The audit write error is dropped rather than turned into a failure the caller sees.

The log is never trimmed

The node keeps one file per UTC day and never trims or deletes them, so the log grows for as long as the node runs. Keep an eye on the state partition's free space on a busy issuing CA.

A reset destroys the audit log

cryptosctl reset erases the state partition's key material, and the audit log lives on that partition, so a reset takes the node's audit history with it. cryptosctl audit list --all -o json saves a readable copy first, but that copy can't be verified off the node.

๐Ÿงญ The FleetOS Manager audit logโ€‹

The FleetOS Manager records its own actions (approving an enrolment, applying a profile, issuing, revoking, and so on) in its database. It's hash-chained too, but its format differs from the node's.

ListAudit returns the entries as cryptos.fleet.v1.AuditEvent messages:

FieldMeaning
idThe entry's ID, aud- followed by 32 hex characters.
atWhen it happened, RFC 3339 UTC.
kindWhat happened: approval-approved, approval-decide-refused, approval-denied, approval-requested, approval-used, config-applied, enroll-approved, enroll-rejected, issued, mcp-key-created, mcp-key-first-used, mcp-key-rejected, mcp-key-revoked, profile-applied, profile-created, profile-deleted, profile-updated, protocol-toggled, rekeyed, renewed or revoked.
summaryA one-line human description.
target_kind, target_pathWhat it acted on: approval, cert, enrollment, mcp-key, node, profile or protocol, and which one.
actor_kindcert for an operator client certificate, mcp_key for an MCP agent key.
actor_cn, actor_serialThe CN and hex serial of the operator certificate that acted, or that the MCP key is bound to.
key_idThe MCP key's ID when actor_kind is mcp_key.
via, toolThe surface the action came through (web, mcp or api), and the MCP tool name when via is mcp.
request_digestLowercase hex SHA-256 of the canonical request.
outcomeok, denied, pending or error.
approval_id, approver_serialThe step-up approval an MCP call ran under, and the serial of the operator certificate that decided it. Empty when no approval was involved.

Entries recorded before the manager captured actors have the actor fields empty. The chain hash of each row is the lowercase hex SHA-256 of the previous row's hash and the row's fields, each field length-prefixed. The manager stores the hash and previous hash with each row, but ListAudit doesn't return them. For the RPC itself, see the gRPC API reference.