Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

ASVS 5.0 Assessment

A self-assessment of acme-proxy against OWASP ASVS 5.0, whose text is vendored under rfc/asvs-5.0/ in this repository.

  • Assessed at: Level 2. Every L1 and L2 requirement in scope is given a status; L3 requirements are listed too, as information rather than as a bar being claimed.
  • Assessed against: the tree at release 0.6.0.
  • Method: source review. The evidence column names a file, not a promise — for a control requirement, the documentation on this site is context and the code is the evidence.

This is a self-assessment and not a certification. ASVS is explicit that a verification claim means an assessor performed the work; nobody outside the project has performed it here. Read this page as the maintainers’ own answer to “which recognised controls does this meet, and where does it fall short”, which is a useful thing to have written down and a different thing from an audit.

What was assessed

Three surfaces, kept apart because their controls genuinely differ:

SurfaceWhat it isWhere
ACME listenerUnauthenticated by design, authenticated per request by JWS. Carries the filter chain, admission control and the nonce middleware.crates/server/src/, crates/protocol/src/handlers/, crates/protocol/src/extractors/, crates/protocol/src/middlewares/
Web adminThe only session-based, browser-facing surface. Off by default, loopback by default.crates/admin/src/webadmin/, crates/admin/src/admin/
CLI and processAnswers to a shell on the host and holds no session.src/cli/, src/main.rs, crates/core/src/config/

Most of V3, V6 and V7 apply only to the web admin. When it is disabled — which is the default — those chapters have no surface to apply to at all.

Chapter disposition

ChapterIn scopeNote
V1 Encoding and Sanitizationyes
V2 Validation and Business Logicyes
V3 Web Frontend SecurityyesWeb admin only
V4 API and Web ServiceyesGraphQL and WebSocket sections are n/a
V5 File HandlingpartlyThere is no upload feature; the sections that assume one are n/a
V6 AuthenticationyesWeb admin and the CLI credential lifecycle
V7 Session ManagementyesWeb admin only
V8 Authorizationyes
V9 Self-contained TokensyesThe self-contained token here is the ACME JWS, not a session JWT
V10 OAuth and OIDCnoNo OAuth, no OIDC, no external identity provider, no token endpoint. Nothing in the chapter has a subject
V11 Cryptographyyes
V12 Secure Communicationyes
V13 Configurationyes
V14 Data Protectionyes
V15 Secure Coding and Architectureyes
V16 Security Logging and Error Handlingyes
V17 WebRTCnoNo WebRTC, no media, no signalling

Summary

Counts are of the requirements enumerated in the per-chapter tables below; every requirement of every in-scope chapter is present, so the totals match ASVS 5.0’s own counts. L1+L2 is the bar being assessed; the L3 column is reported for information.

ChapterL1+L2 metpartialgapn/aL3 (met / short / n/a)
V1 Encoding and Sanitization171092 / 0 / 1
V2 Validation and Business Logic110000 / 1 / 1
V3 Web Frontend Security161026 / 4 / 2
V4 API and Web Service40066 / 0 / 0
V5 File Handling40050 / 0 / 4
V6 Authentication261088 / 1 / 3
V7 Session Management151020 / 1 / 0
V8 Authorization70004 / 2 / 0
V9 Self-contained Tokens70000 / 0 / 0
V11 Cryptography112015 / 2 / 3
V12 Secure Communication61020 / 2 / 1
V13 Configuration94005 / 3 / 0
V14 Data Protection90002 / 1 / 1
V15 Secure Coding and Architecture111018 / 0 / 0
V16 Security Logging and Error Handling151001 / 0 / 0
Total1681303647 / 17 / 16

The short version. There is no L1 or L2 gap. The four password-policy requirements that used to sit here — V6.2.4 at L1, and V6.1.2 / V6.2.11 / V6.2.12 at L2 — were one missing control seen from four angles, and closed as one: check_password_policy now refuses a password that names this deployment, or that appears in a compiled-in corpus of common passwords. V6.2.2 and V6.2.3, both L1, closed the same way: one self-service password-change route on the account page, gated the way the second-factor routes already were, rather than two separate fixes.

Everything else that falls short of met is either a partial — a control that exists but does not reach everywhere the requirement asks — or a documented deviation, where the project has knowingly chosen otherwise and argued the choice already. Both have their own sections below.

Two chapters deserve a note on their shape. V6 Authentication carries the most n/a rows because the web admin has one authentication pathway and no out-of-band, biometric or federated factors — most of the chapter has no subject here. V16 Security Logging is the only chapter with no L1 requirements at all and is met almost entirely, which is what you would hope for in a certificate authority: the audit trail is the product.

V1 Encoding and Sanitization

#RequirementLStatusEvidence
1.1.1Decode into canonical form once, before processing2metThe JWS protected header and payload are base64url-decoded exactly once in crates/protocol/src/extractors/acme.rs, before any check reads them; a DNS identifier passes normalize_dns_name (crates/protocol/src/acme/rules.rs) once, before storage and before the filter sees it
1.1.2Output encoding as the final step, or by the interpreter2metminijinja escapes at render time. The rule is per template name: .html auto-escapes, .j2 does not — see crates/core/src/templating.rs
1.2.1Context-correct output encoding for HTTP/HTML1metEvery panel template is .html and therefore auto-escaped; auto_escaping_is_on_for_pages_and_off_for_notify pins both directions
1.2.2Encode untrusted data in dynamically built URLs; safe protocols only1metPanel URLs are built from server-side ids. The one place an untrusted URL is followed — an http-01 redirect — is checked against a scheme allowlist in Http01Validator::redirect_allowed (crates/net/src/challenge/http_01.rs)
1.2.3Encode when building JavaScript or JSON1metAll JSON is produced by serde_json; no template writes into a <script> block
1.2.4Parameterized database queries1metEvery statement in crates/store/src/ is a runtime sqlx::query with .bind(). No query is assembled with format!
1.2.5Protection against OS command injection1metScriptHook::run uses Command::new(path) with an argv vector and no shell (crates/core/src/script_hook.rs); payloads go to stdin as JSON
1.2.6LDAP injection2n/aNo LDAP client
1.2.7XPath injection2n/aNo XPath
1.2.8LaTeX injection2n/aNo LaTeX
1.2.9Escape special characters in regular expressions2metcompile_anchored and the glob translation both run regex::escape over everything that is not the wildcard (crates/policy/src/filter/mod.rs)
1.2.10CSV and formula injection3n/aNo CSV or spreadsheet export; the CLI emits text or JSON
1.3.1Sanitize untrusted HTML from editors1n/aNo rich-text input anywhere
1.3.2Avoid eval() and dynamic code execution1metNo dynamic code execution. The one place operator-supplied code runs is a custom script hook, which is a configured executable, not evaluated input
1.3.3Sanitize before a dangerous context; trim over-long input2metContacts reject control characters, User-Agent is truncated to 256 characters before storage (crates/core/src/audit/mod.rs), identifier lists are capped by order.max_identifiers
1.3.4Sanitize user-supplied SVG2n/aNo user-supplied images
1.3.5Sanitize user-supplied scriptable or template content2n/atemplate_dir overrides are operator-supplied files on the host, not user input
1.3.6SSRF protection by allowlist of protocols, domains, paths, ports2partialScheme, port and hop count are allowlisted for http-01 redirects; destination addresses deliberately are not. See Documented deviations
1.3.7No templates built from untrusted input2metTemplate sources come only from the embedded table or template_dir; untrusted values are only ever bound as context
1.3.8JNDI injection2n/aNo JNDI
1.3.9Sanitize before memcache2n/aNo memcache
1.3.10Sanitize format strings2metRust format strings are compile-time literals; a runtime string can never become one
1.3.11Sanitize before mail systems (SMTP/IMAP injection)2metcontact_shape_error rejects control characters, hfields and multiple addresses before a contact can reach a notify template (crates/protocol/src/acme/rules.rs)
1.3.12Regular expressions free from exponential backtracking3metThe regex crate has no backtracking and guarantees linear time; patterns are operator configuration, not request input
1.4.1Memory-safe strings and copies2metSafe Rust. The unsafe blocks in the tree are std::env::set_var in tests and one PKCS#11 Send impl (crates/signer/src/local_ca/pkcs11.rs)
1.4.2Prevent integer overflow2metTime and TTL arithmetic uses saturating_add/saturating_sub throughout crates/store/src/; release builds are not built with overflow checks disabled beyond the default
1.4.3Release memory and resources; no dangling pointers2metOwnership and Drop. Script hooks additionally set kill_on_drop so a timed-out child is reaped
1.5.1Restrictive XML parser configuration (XXE)1n/aNo XML parser in the dependency graph
1.5.2Safe deserialization of untrusted data2metserde into concrete structs. No polymorphic or client-chosen types; crates/core/src/jws/mod.rs deliberately does not use deny_unknown_fields because RFC 8555 §6.2 allows extra header parameters, and every field it acts on is named
1.5.3Consistent parsers for one data type3metOne JSON parser (serde_json) and one URL parser (url) in the tree

V2 Validation and Business Logic

#RequirementLStatusEvidence
2.1.1Documented input validation rules1metIdentifier syntax and normalisation are specified in Filters; the ACME wire formats are RFC 8555’s and the deviations are listed in Protocol Support
2.1.2Documented rules for combined data items2metThe order/CSR consistency rule — the CSR may name only what the order named — is stated in Filters and enforced at finalize
2.1.3Documented business logic limits2metorder.max_identifiers, server.max_body_bytes, nonce TTL, order and authorization lifetimes and the admission limiter are all in Configuration Reference with their defaults
2.2.1Validate input against expectations1metIdentifiers are normalised and type-checked, wildcards are refused when dns-01 is off, contacts are shape-checked, the CSR is parsed and compared against the order
2.2.2Validation enforced at a trusted service layer1metEvery check is server-side. The panel’s only client-side code is htmx attribute dispatch
2.2.3Combinations of related data items are reasonable2meta_wildcard_identifier_is_rejected_when_dns_01_is_disabled and a_csr_requesting_ca_powers_yields_a_leaf_without_them in tests/security.rs are two of the pinned cases
2.3.1Business logic flows only in the expected step order1metThe order state machine refuses out-of-sequence transitions: an_order_missing_an_authorization_never_becomes_ready, an_expired_order_cannot_be_finalized, a_deactivated_account_cannot_finalize_a_ready_order (tests/security.rs)
2.3.2Business logic limits implemented as documented2metan_order_naming_more_identifiers_than_the_limit_is_refused (tests/security.rs)
2.3.3Transactions succeed in full or roll back2metMulti-row writes run inside pool.begin()/commit() — order creation with its authorizations (crates/protocol/src/handlers/order.rs), challenge validation (crates/protocol/src/handlers/authz.rs), session promotion (crates/store/src/admin_session.rs)
2.3.4Locking prevents double-booking of limited resources2metSingle-use resources are claimed by UPDATE … WHERE … AND <unused> and decided by rows_affected == 1, never by read-then-write: nonces, recovery codes (crates/store/src/admin_recovery_code.rs), the TOTP replay step (crates/store/src/admin_user.rs) and session promotion
2.3.5Multi-user approval for high-value flows3gapIssuance and revocation are single-actor operations. There is no second-operator approval, and no plan to add one — a CA that needs a quorum to sign is a different product
2.4.1Anti-automation on expensive functions2metThe ACME listener carries an admission limiter with a queue budget and a request deadline (crates/protocol/src/middlewares/admission.rs); the admin login path is rate-limited per address — an IPv6 client per /64 — counted from the moment an attempt starts, and the KDF runs on the blocking pool (LoginLimiter, crates/admin/src/webadmin/session.rs)
2.4.2Business flows require realistic human timing3n/aEvery consumer of the ACME API is a machine; timing gates would break the protocol

V3 Web Frontend Security

Applies to the web admin. With admin.enabled = false, the default, none of this is exposed at all.

#RequirementLStatusEvidence
3.1.1Documented expected browser security features3partialWeb Admin states the cookie and CSRF requirements and the Secure-over-plain-HTTP failure mode; there is no statement of what the panel does when a browser lacks a feature
3.2.1Prevent content being rendered in the wrong context1metdefault-src 'none' plus X-Content-Type-Options: nosniff on every response; the API is nested under /api with its own JSON fallback so a page path never returns an API body
3.2.2Text rendered as text, not HTML1metAuto-escaping templates; no innerHTML outside htmx’s own fragment swap of server-rendered HTML
3.2.3Avoid DOM clobbering3metThe panel ships no application JavaScript — htmx is the only script, and everything is driven by hx-* attributes
3.3.1Secure attribute and a __Secure-/__Host- prefix1met__Host-acme_admin_session, which browsers accept only with Secure, Path=/ and no Domain (crates/admin/src/webadmin/session.rs)
3.3.2SameSite set according to purpose2metSameSite=Strict on both the session cookie and its clearing form
3.3.3__Host- prefix unless shared with other hosts2metSame as 3.3.1
3.3.4HttpOnly for values scripts must not read2metHttpOnly is set; the CSRF token travels in the page and the x-csrf-token request header, never in a readable cookie
3.3.5Cookie name and value under 4096 bytes3metA 32-byte token, base64url-encoded, plus a fixed name
3.4.1HSTS on all responses, ≥ 1 year, includeSubDomains for L21metmax-age=31536000; includeSubDomains, applied by the shared security_headers() constructor to both listeners (crates/protocol/src/router.rs). Emitted unconditionally: a browser ignores it over plain HTTP (RFC 6797 §7.2), so gating it on TLS would remove only a header that is already inert. includeSubDomains makes the host in admin.base_url load-bearing — see give the panel its own host name
3.4.2CORS Access-Control-Allow-Origin fixed or allowlisted1metNo CORS layer exists on either listener, so no Access-Control-Allow-Origin is ever emitted
3.4.3CSP with object-src 'none' and base-uri 'none'2metdefault-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none' — no unsafe-inline, no unsafe-eval (crates/admin/src/webadmin/mod.rs). object-src falls back to default-src 'none'
3.4.4X-Content-Type-Options: nosniff2metsecurity_headers()
3.4.5Referrer policy2metReferrer-Policy: same-origin on the admin listener
3.4.6CSP frame-ancestors on every response2metframe-ancestors 'none', with X-Frame-Options: DENY alongside for older clients
3.4.7CSP reports a violation-reporting location3gapNo report-to/report-uri. For a single-origin panel with no inline script, the report channel would have no consumer
3.4.8Cross-Origin-Opener-Policy on document responses3gapNot set. frame-ancestors 'none' covers framing but not shared Window access from a popup
3.5.1Anti-forgery tokens or non-safelisted header fields1metA per-session CSRF token in the x-csrf-token header on every unsafe method, plus an Origin check against admin.base_url — the module doc in crates/admin/src/webadmin/session.rs explains why SameSite=Strict alone is not enough here
3.5.2Functionality cannot be called without a preflight1n/aThe panel does not rely on CORS preflight; it uses the token in 3.5.1
3.5.3Sensitive functionality uses unsafe HTTP methods1metEvery mutating route is POST/DELETE; GET routes are read-only. mutating_endpoints() and mutating_page_endpoints() are the lists that make this checkable
3.5.4Separate applications on different hostnames2partialThe ACME and admin surfaces are separate sockets with separate TLS and separate defaults, and the admin binds loopback unless TLS is on. They are usually two ports on one host, and cookies are not port-scoped — see Documented deviations
3.5.5Validate postMessage origins2n/aNo postMessage
3.5.6No JSONP3metNone
3.5.7No authorized data in script resources3metThe only script served is a static, unauthenticated copy of htmx
3.5.8Authenticated resources embeddable only when intended3metSec-Fetch is not inspected, but frame-ancestors 'none', SameSite=Strict and the CSRF token together mean no cross-origin embed carries the session
3.6.1SRI for externally hosted client assets3metNothing is externally hosted. htmx is vendored under crates/admin/src/webadmin/static/ and served from the same origin
3.7.1Only supported, secure client-side technologies2metHTML, CSS and htmx. No plugins
3.7.2Automatic redirects only to allowlisted hosts2metThe panel’s redirects are fixed relative paths (/ui/, the sign-in page); there is no next= parameter and no open-redirect surface
3.7.3Notify before redirecting outside the application3n/aThe panel never redirects off-origin
3.7.4HSTS preload3n/aThe panel is an internal service with an operator-chosen hostname; preloading is a decision for the operator’s domain, not this software
3.7.5Documented behaviour on browsers lacking security features3gapSee 3.1.1

V4 API and Web Service

#RequirementLStatusEvidence
4.1.1Accurate Content-Type with charset1metACME responses are application/json or application/problem+json; panel pages are text/html; charset=utf-8 via axum::response::Html; the two static assets set their own type with an explicit charset (crates/admin/src/webadmin/pages/assets.rs)
4.1.2Only user-facing endpoints redirect HTTP to HTTPS2metNeither listener redirects. server.tls.enabled makes the socket speak TLS instead of cleartext, not alongside it (crates/net/src/tls.rs)
4.1.3Intermediary-set header fields cannot be overridden by the user2metA forwarded-for header is believed only from a hop in filter.trusted_proxies; with the list empty the header is ignored entirely and the peer address is used (crates/core/src/client.rs). The admin listener has its own list, admin.filter.trusted_proxies, empty by default (crates/admin/src/webadmin/filter.rs)
4.1.4Only supported HTTP methods are usable3metaxum routes declare their methods and answer 405 otherwise; both routers carry an explicit method_not_allowed_fallback so the refusal is a proper problem document rather than an empty body
4.1.5Per-message digital signatures for highly sensitive requests3metEvery state-changing ACME request is a JWS signed by the account key, verified against a nonce and the request URL (RFC 8555 §6.2) — this is the protocol’s own design, not an addition
4.2.1Correct HTTP message framing (request smuggling)2methyper performs the framing and rejects conflicting Content-Length/Transfer-Encoding; the application never parses framing itself
4.2.2Generated Content-Length matches the body3metResponse bodies are axum types; the length is computed, never asserted
4.2.3No connection-specific header fields over HTTP/2 or HTTP/33methyper enforces this. No handler sets Transfer-Encoding
4.2.4Reject CR/LF in HTTP/2 and HTTP/3 header fields3methttp::HeaderValue rejects control bytes on construction, in both directions
4.2.5Avoid generating over-long URIs or header fields3metOutbound URLs are built from configuration plus a bounded token or id; the http-01 validator additionally caps redirect hops
4.3.1GraphQL query cost limiting2n/aNo GraphQL
4.3.2GraphQL introspection disabled2n/aNo GraphQL
4.4.1WebSocket over TLS1n/aNo WebSocket
4.4.2WebSocket handshake Origin check2n/aNo WebSocket
4.4.3Dedicated WebSocket session tokens2n/aNo WebSocket
4.4.4WebSocket tokens obtained through the authenticated session2n/aNo WebSocket

V5 File Handling

There is no file upload feature. The only client-supplied structured input is a CSR inside a signed JWS, which is assessed under V2 and V11 rather than here. The two paths that touch the filesystem on a request’s behalf are the embedded static-asset allowlist and the certificate-chain download.

#RequirementLStatusEvidence
5.1.1Documented permitted file types, extensions and sizes2n/aNo upload feature to document
5.2.1Only accept files of a processable size1metserver.max_body_bytes (128 KiB) and admin.max_body_bytes (64 KiB) bound every request body, applied as DefaultBodyLimit at each router root
5.2.2Extension matches content1n/aNo uploads
5.2.3Compressed-file limits2n/aNothing is decompressed
5.2.4Per-user file quota3n/aNo uploads
5.2.5Reject symlinks in archives3n/aNo archives
5.2.6Reject over-large images3n/aNo images
5.3.1Untrusted files in a public folder are not executed1n/aNothing untrusted is written to a served directory
5.3.2File paths built from trusted data, not user filenames1metGET /ui/static/{file} is a two-arm match, not a filesystem lookup — tower-http’s fs feature is deliberately off (crates/admin/src/webadmin/pages/assets.rs). The http-01 responder looks a token up in an in-memory store and touches no path
5.3.3Ignore user path information when decompressing3n/aNothing is decompressed
5.4.1Validate or ignore user filenames; set Content-Disposition2metThe chain download names the file from the stored order id, not the path segment, and sets attachment; filename="…" (crates/admin/src/webadmin/pages/orders.rs)
5.4.2Served filenames are encoded or sanitized2metSame: a generated identifier, so there is nothing to encode
5.4.3Antivirus scanning of files from untrusted sources2n/aNo files are accepted from untrusted sources

V6 Authentication

Applies to the web admin and to the CLI commands that mint and rotate operator credentials. The ACME listener authenticates keys, not people; that is assessed under V9.

#RequirementLStatusEvidence
6.1.1Documented anti-automation and lockout behaviour1metWeb Admin and the login_max_attempts / login_window_seconds entries in Configuration Reference. The limiter is keyed on the peer address, never the username, so no attacker can lock an operator out by guessing at them
6.1.2Documented list of context-specific words barred from passwords2metDerived and documented: Password policy
6.1.3Multiple authentication pathways documented together2metThere are two — a panel session and a shell on the host — and Users & Sessions states which operations belong to which and why create and passwd stay on the host
6.2.1Passwords at least 8 characters1metMIN_PASSWORD_LEN = 12, counted in characters rather than bytes (crates/admin/src/admin/password.rs)
6.2.2Users can change their password1metThe panel’s own account page carries a password card (POST /ui/account/password, POST /api/account/password) beside acme-proxy admin user passwd, so an operator with no shell can rotate their own → Users & Sessions
6.2.3Password change requires current and new password1methandlers::mfa::verify_current_password (crates/admin/src/webadmin/handlers/mfa.rs) checks the current password before admin::users::change_own_password writes a new one — unconditionally, unlike the second-factor step-up it was split out of, since this requirement has no “nothing yet to protect” exemption. admin user passwd still takes only the new one, answering as it does to a process that can already rewrite the row
6.2.4Check against the top 3000 passwords1met13 918 entries compiled in (crates/admin/src/admin/corpus/); every shorter entry is already refused on length
6.2.5No composition rules1metDeliberately none. The three rules are about length, this deployment’s own words and known-common passwords — none dictates shape (crates/admin/src/admin/password.rs)
6.2.6Password fields use type=password1mettemplates/login.html, the step-up fields in templates/account/_card.html, templates/account/_contact.html and templates/operators/_card.html, and the two fields in templates/account/_password_card.html
6.2.7Paste and password managers permitted1metStandard inputs with autocomplete="username" / "current-password"; nothing blocks paste
6.2.8Password verified exactly as received1metverify_password hashes the bytes as received: no trimming, no case folding, and an over-long password is rejected rather than truncated. The policy check folds a copy to compare against the corpus and the word list, and never touches what is stored
6.2.9Passwords of at least 64 characters permitted2metMAX_PASSWORD_LEN = 1024 bytes, a denial-of-service bound rather than a policy
6.2.10No forced periodic rotation2metNothing expires a password. The stored form is self-describing, so raising the KDF cost re-encodes a row on its owner’s next login instead of forcing a change
6.2.11Context-specific word list used2metPasswordContext in crates/admin/src/admin/password.rs, matched as a substring
6.2.12Check against breached passwords2metSame corpus: breach-derived (xato-net), filtered to the reachable length range
6.3.1Credential-stuffing and brute-force controls1metLoginLimiter refuses over the limit before the 600 000-iteration KDF runs, which makes it an availability control as much as a credential one; an attempt counts from the moment it starts, so a parallel burst cannot outrun it (crates/admin/src/webadmin/session.rs)
6.3.2No default accounts1metThe admin_users migration seeds no rows and there is no sign-up page; the first operator is created by admin user create on the host
6.3.3MFA or a combination of single factors2partialTOTP with recovery codes is implemented and admin.require_mfa enforces it for every operator — but it defaults to false, so a stock deployment is single-factor. Hardening tells operators to turn it on. For L3 this would need a hardware factor; see Documented deviations
6.3.4No undocumented pathways; consistent strength2metThe panel and API share one session layer, and every mutating route passes through AuthenticatedWrite, PageSessionWrite or EnrolWrite. The host CLI is the second pathway and is documented as such
6.3.5Notify users of suspicious authentication attempts3metA completed sign-in from an address not among the operator’s recent ones (admin_users.known_login_ips, last five), a correct password then a refused second factor, and a per-session second-factor lockout each send an admin_sign_in notification to the operator’s own contact_email, through [admin.notify] (crates/admin/src/webadmin/handlers/session.rs, crates/jobs/src/notify/)
6.3.6Email not used as an authentication factor3metIt is not
6.3.7Notify after changes to authentication details3metA password change, a second-factor enrol/disable, a recovery-code regeneration, a notification-address change and a colleague-admin second-factor reset send an admin_credential_changed notification — from the panel and from the host CLI alike (admin user passwd, contact, totp reset, totp recovery-codes), the CLI queuing the delivery for the running server’s worker
6.3.8Valid users not deducible from failed challenges3metAn unknown username still pays the KDF, against password::dummy_hash(), and every failure returns one invalid_credentials whatever the real cause (crates/admin/src/admin/users.rs)
6.4.1Initial passwords and activation codes are random, policy-compliant and short-lived1n/aNothing generates an initial password; the operator supplies one on stdin or in --password-file
6.4.2No password hints or secret questions1metNeither exists
6.4.3Secure forgotten-password reset that does not bypass MFA2metReset is admin user passwd on the host. It revokes every session the operator held and leaves the enrolled factor untouched, so the next sign-in still needs it
6.4.4Lost MFA factor requires enrolment-level identity proofing2metEither a single-use recovery code, or admin user totp reset on the host — the second being a strictly stronger proof than the live session plus password that enrolment took
6.4.5Renewal reminders before an authenticator expires3n/aNo authentication factor expires
6.4.6Administrators can reset but not choose a user’s password3gapadmin user passwd sets the password, so whoever runs it knows it. This is a host-root operation on a machine that already holds the hashes
6.5.1Lookup secrets and TOTPs usable only once2metAdminUser::claim_totp_step is an UPDATE … WHERE totp_last_step IS NULL OR totp_last_step < ? decided by rows_affected, so a code resubmitted inside its own 30-second window is refused (crates/store/src/admin_user.rs); recovery codes are consumed by UPDATE … WHERE id = ? AND used_at IS NULL
6.5.2Sub-112-bit lookup secrets hashed with an approved KDF and a 32-bit salt2metRecovery codes carry 50 bits and are stored through admin::password — PBKDF2-HMAC-SHA256, 600 000 iterations, a 128-bit per-row salt
6.5.3Seeds and codes from a CSPRNG2metring::rand::SystemRandom for the TOTP secret (crates/admin/src/admin/totp.rs) and every recovery code (crates/admin/src/admin/recovery.rs)
6.5.4Lookup secrets have at least 20 bits of entropy2metTen characters from a 32-symbol alphabet: 50 bits, with zero modulo bias because 256 is a multiple of 32
6.5.5Defined lifetime for codes and TOTPs2met30-second step with RFC 6238 §5.2’s one step of permitted skew either way (SKEW_STEPS = 1). A half-authenticated session additionally dies after PENDING_MFA_TTL, five minutes
6.5.6Any factor can be revoked3metadmin user totp reset, admin user disable, admin session revoke [--user <u> [--session <id>] | --all], and recovery codes are superseded as a set on re-enrolment
6.5.7Biometrics only as a secondary factor3n/aNo biometrics
6.5.8TOTP checked against a trusted time source3metThe server’s own clock; no client-supplied time reaches totp::verify
6.6.1PSTN OTP restrictions2n/aNo SMS or voice factor
6.6.2Out-of-band codes bound to their originating request2n/aNo out-of-band factor. The equivalent binding for TOTP is that the code is only accepted against the pending_mfa session that the password created
6.6.3Rate-limit code-based out-of-band mechanisms2n/aNo out-of-band factor. TOTP guessing is bounded twice — mfa_attempts on the pending row and the five-minute PENDING_MFA_TTL
6.6.4Rate-limit push notifications3n/aNo push factor
6.7.1Certificates verifying authentication assertions protected from modification3metAccount public keys live in accounts under the database’s file mode; a modified key is a key that no longer verifies its own account’s requests
6.7.2Challenge nonce at least 64 bits and unique3met256 bits from ring::rand::SystemRandom, unique by primary key and single-use by rows_affected (crates/store/src/nonce.rs)
6.8.1Identity cannot be spoofed across identity providers2n/aNo identity provider
6.8.2Signatures on authentication assertions validated2n/aNo external assertions. The equivalent for ACME JWS is V9.1.1
6.8.3SAML assertions processed once2n/aNo SAML
6.8.4Authentication strength verified from the IdP2n/aNo identity provider

V7 Session Management

Applies to the web admin. The ACME listener holds no sessions: every request carries its own signature and its own nonce.

#RequirementLStatusEvidence
7.1.1Documented inactivity timeout and absolute lifetime2metsession_ttl_seconds (12 h, never extended by activity) and session_idle_timeout_seconds (1 h) in Configuration Reference, restated in Web Admin
7.1.2Documented concurrent-session policy2partialThe behaviour is definite — sessions are unlimited per operator, and admin session revoke (--all, one operator’s, or one session with --session <id>) is the lever — but no page states the limit as a policy
7.1.3Federated session coordination documented2n/aNo federation
7.2.1Session verification at a trusted backend1metEvery request resolves hex(SHA-256(token)) against admin_sessions and re-checks state, expiry, idleness and the owner’s status (crates/admin/src/webadmin/session.rs)
7.2.2Dynamically generated tokens, not static secrets1metmint_token per sign-in; there are no API keys on this listener
7.2.3Reference tokens unique, CSPRNG, ≥ 128 bits1met256 bits from ring::rand::SystemRandom, base64url-encoded
7.2.4New token on authentication, old one terminated1metSign-in deletes whatever session the request carried; completing MFA is a rotation — AdminSession::promote deletes the pending_mfa row and inserts a new one with a new token and a new CSRF token, in one transaction (crates/store/src/admin_session.rs)
7.3.1Inactivity timeout2metsession_idle_timeout_seconds, checked per request and swept by the reaper
7.3.2Absolute maximum session lifetime2metexpires_at is set at creation and never advanced
7.4.1Terminated sessions cannot be reused1metSessions are reference tokens in a table; sign-out deletes the row
7.4.2All sessions terminated when an account is disabled or deleted1metset_status("disabled") and set_password both call AdminSession::delete_for_user; the liveness check also refuses a session whose owner is no longer active
7.4.3Option to terminate other sessions after a factor changes2metconfirm_totp_enrolment and disable_totp both call revoke_other_sessions; a password change revokes every session unconditionally
7.4.4Visible logout on every authenticated page2metA “Sign out” control in templates/layout.html, which every page extends
7.4.5Administrators can terminate sessions individually or globally2metadmin session list/revoke on the host terminates globally (--all), one operator’s (--user <u>), or one session (--user <u> --session <id>, the id being the fingerprint the listing prints); the panel’s Operators page does the individual form over HTTP — GET /ui/operators/{username} lists another operator’s sessions and POST /ui/operators/{username}/sessions/{id}/revoke ends one, gated by verify_current_password
7.5.1Full re-authentication before changing authentication attributes2metcheck_step_up demands the password again before any change to an existing second factor, and the module doc explains the blast radius that makes it necessary (crates/admin/src/webadmin/handlers/mfa.rs)
7.5.2Users can view and terminate their own sessions2metThe account page’s Sessions card (GET /api/account/sessions, /ui/account) lists every one of the caller’s own live sessions and terminates one individually (POST /api/account/sessions/{id}/revoke) or all at once (“Sign out everywhere”) — closing the gap between nothing and everything the panel used to leave → Sessions
7.5.3Further authentication before highly sensitive operations3partialSecond-factor changes are gated by check_step_up, and the whole /operators colleague-management surface by verify_current_password, which re-prompts even for an operator with no factor. Certificate revocation and account deletion require at least the operator role (admin_users.role), but for an operator holding it a live session is still sufficient authority — no password re-prompt on the CA mutations
7.6.1Federated re-authentication behaviour2n/aNo federation
7.6.2Session creation requires explicit user action2metA session exists only after a submitted sign-in form

V8 Authorization

#RequirementLStatusEvidence
8.1.1Documented function-level and data-specific rules1metSecurity Model states the four issuance gates; Filters specifies the policy engine; Web Admin states that every route needs a session
8.1.2Documented field-level rules2metThe ACME object shapes are RFC 8555’s, and Audit Trail states which fields are recorded and that none of them reach an ACME object
8.1.3Documented environmental and contextual attributes3metAddress, reverse name, IPAM ownership, request path and EAB identity are each documented under Filters, and the trust placed in a forwarded address under Allowed IP
8.1.4Documented use of contextual factors in decisions3metThe policy expression language, including how an or over an address check weakens a conjunction, is written out in Hardening
8.2.1Function-level access restricted to explicit permissions1metACME: POST-as-GET with a kid resolving to the owning account. Admin: three extractors every mutating route passes through
8.2.2Data-specific access restricted (IDOR/BOLA)1metOrder, authorization and certificate reads check the requesting account owns the object; accounts and orders are additionally isolated per profile, so a kid naming another profile does not resolve (crates/protocol/src/extractors/acme.rs)
8.2.3Field-level access restricted (BOPLA)2metResponses are built from explicit serializer functions, never by serializing a row
8.2.4Adaptive controls from contextual attributes3metThe filter chain evaluates per request, not per session, so a change of address is re-evaluated on the next call
8.3.1Authorization enforced at a trusted service layer1metExtractors and middleware, server-side. No decision depends on anything the client sends unsigned
8.3.2Authorization changes applied immediately3metSessions are reference tokens read from the database each request, so a disabled operator or a revoked session stops working on the next call. filter reload applies policy without a restart
8.3.3Access based on the originating subject3partialWith the relay signer, one upstream account is deliberately multiplexed across every local client — that is the feature. The local gates decide, and the upstream sees only this server. See Documented deviations
8.4.1Cross-tenant controls2metProfiles are the tenancy boundary: accounts, orders, nonces and EAB credentials are scoped to one, and a kid from another profile fails the prefix check
8.4.2Administrative access uses more than network location3partialPassword plus optional TOTP plus a session, with the bind address and TLS as further layers, and a per-operator role (admin/operator/viewer) scoping what a session may do. There is no device posture assessment and no contextual risk analysis

V9 Self-contained Tokens

The self-contained token in this system is the ACME JWS on every state-changing request (RFC 8555 §6.2), not a session JWT — the admin session is a reference token and is assessed under V7.

#RequirementLStatusEvidence
9.1.1Signature validated before the contents are accepted1metverify_jws verifies the signature over the protected header and payload before any handler sees the body (crates/protocol/src/extractors/acme.rs, crates/core/src/jws/signature.rs)
9.1.2Algorithm allowlist, no none1metExactly ES256 on P-256 and RS256 are accepted; anything else is Unsupported algorithm. The alg must additionally agree with the key type and the named curve, so alg alone never selects the verifier (crates/core/src/jws/signature.rs)
9.1.3Key material from trusted pre-configured sources1metA kid resolves to a stored account key whose URL prefix must match this profile’s base_url; a jwk is the key being registered and is only ever trusted for newAccount/revokeCert as RFC 8555 §6.2 defines. jwk and kid together are refused, and a crit header is refused outright
9.2.1Validity time span honoured1metThe equivalent is the nonce: single-use, and refused past nonce.ttl_seconds. Unknown, consumed and expired are made indistinguishable on purpose (crates/store/src/nonce.rs)
9.2.2Token type checked against the intended purpose2metThe protected header must carry exactly the fields RFC 8555 §6.2 defines for the request kind; newAccount requires a jwk, revokeCert takes either, and everything else a kid — an embedded jwk elsewhere is 400 malformed
9.2.3Audience restriction2metThe JWS url must equal profile.base_url plus the request path, byte for byte (RFC 8555 §6.4). A signature captured from one profile does not verify against another
9.2.4Same key across audiences carries an audience restriction2metSame mechanism: the audience is in the signed url, and the kid prefix pins the profile

V11 Cryptography

#RequirementLStatusEvidence
11.1.1Documented key management policy and lifecycle2metSecurity Model names every secret, what its compromise buys and how it is stored; Secret Rotation is the lifecycle half — a recommended interval and the early-rotation triggers for each — and Hardening covers the CA key specifically
11.1.2Cryptographic inventory maintained2metThe table in Security Model, plus the per-algorithm rationale carried in the module docs of crates/admin/src/admin/password.rs, crates/admin/src/admin/totp.rs and crates/core/src/eab.rs
11.1.3Cryptographic discovery mechanisms3metOne backend: ring, plus rustls for TLS and rcgen for certificate construction. grep -rn ring src/ is the discovery mechanism, and cargo deny fails the build on an unlisted crypto dependency
11.1.4Inventory includes a post-quantum migration path3gapNo PQC migration plan. The ACME wire algorithms are RFC 8555’s to change first
11.2.1Industry-validated implementations2metring (BoringSSL-derived) for hashing, HMAC, PBKDF2, signature verification and the RNG; rustls for TLS. Nothing hand-rolls a primitive — crates/admin/src/admin/totp.rs composes ring::hmac per RFC 4226 and is checked against the RFC’s published test vectors
11.2.2Crypto agility2metPassword hashes are stored self-describing (pbkdf2-sha256$600000$…), so the algorithm or cost can change with a new branch in verify_password and needs_rehash re-encodes each row at its owner’s next login — no migration. The signer backend, the CA key type and the key source (file or PKCS#11) are all configuration
11.2.3Minimum 128 bits of security2partialECDSA P-256, SHA-256, HMAC-SHA-256 and 256-bit secrets are all at or above the bar. RSA is accepted from 2048 bits (RSA_PKCS1_2048_8192_SHA256), which is about 112 — see Documented deviations
11.2.4Constant-time cryptographic operations3metring::constant_time::verify_slices_are_equal under the hood, and subtle::ConstantTimeEq for the TOTP comparison (crates/admin/src/admin/totp.rs)
11.2.5Cryptographic modules fail securely3metA verification failure is a refusal, never a fallback. A corrupt stored password hash is deliberately not folded into “wrong password” — it refuses and logs admin_password_hash_unreadable (crates/admin/src/admin/users.rs)
11.3.1No insecure block modes or weak padding1partialNothing in the tree encrypts. RS256 is RSASSA-PKCS1-v1_5, which RFC 8555 requires — a signature scheme, not the padding oracle this requirement targets. See Documented deviations
11.3.2Only approved ciphers and modes1metTransport encryption is rustls with safe defaults; the application encrypts nothing itself
11.3.3Encrypted data protected against modification2n/aNo application-layer encryption
11.3.4Single-use numbers not reused across key/data pairs3n/aNo application-layer encryption. ACME nonces are single-use by construction
11.3.5Encrypt-then-MAC3n/aNo application-layer encryption
11.4.1Approved hash functions1metSHA-256 throughout. The one SHA-1 is HMAC_SHA1_FOR_LEGACY_USE_ONLY inside TOTP, which RFC 6238 §1.2 specifies and which every authenticator app assumes — the module doc in crates/admin/src/admin/totp.rs is the argument for not “fixing” it
11.4.2Passwords stored with an approved, expensive KDF2metPBKDF2-HMAC-SHA256 at 600 000 iterations with a 128-bit per-row salt — OWASP’s current recommendation for the non-Argon2 case. See Documented deviations for why not Argon2id
11.4.3Collision-resistant hashes of adequate length in signatures2metSHA-256 for every signature and every integrity use. HMAC-SHA-1’s security rests on the PRF property, not collision resistance
11.4.4Approved KDF with key-stretching for password-derived keys2metSame PBKDF2 parameters; recovery codes go through the identical path
11.5.1Non-guessable values from a CSPRNG with ≥ 128 bits2metSession tokens, CSRF tokens, EAB secrets, challenge tokens and ACME replay nonces are all 256 bits from ring::rand::SystemRandom, base64url-encoded, through the one crates/core/src/random.rs. The nonce was a UUID v4 until 0.2.0 — 122 bits, and a form this requirement names explicitly
11.5.2RNG works securely under heavy demand3metSystemRandom draws from the OS CSPRNG; there is no userspace pool to exhaust
11.6.1Approved algorithms for key generation and signatures2metrcgen generates ECDSA P-256 by default; the accepted account-key algorithms are the two RFC 8555 defines. Key generation can be delegated to a PKCS#11 token, where the key never leaves the device
11.6.2Approved key exchange with secure parameters3metrustls with with_safe_default_protocol_versions(): TLS 1.2 and 1.3 only, and only its own vetted groups
11.7.1Full memory encryption for data in use3n/aA property of the host, not of this process
11.7.2Data minimization during processing3partialThe CA key can live in a PKCS#11 token and never enter this process at all. EAB and TOTP secrets are necessarily readable, because both are verified by recomputing an HMAC — file mode is the boundary, and that is stated in Security Model

V12 Secure Communication

#RequirementLStatusEvidence
12.1.1Only current TLS versions, newest preferred1metwith_safe_default_protocol_versions() on the rustls ring provider — TLS 1.3 and 1.2 only (crates/net/src/tls.rs)
12.1.2Recommended cipher suites, forward secrecy for L32metrustls ships no suite without forward secrecy and none that is not current; there is no knob to weaken it
12.1.3mTLS client certificates validated before use2n/aNo mTLS. The one place a client certificate is inspected is tls-alpn-01 validation, where the certificate is the challenge response and is checked for the RFC 8737 acmeIdentifier extension rather than for trust
12.1.4Certificate revocation such as OCSP stapling3partialAs a CA, the server publishes a CRL signed over the revocations recorded in its database (Revocation & CRL). As a TLS server it does not staple
12.1.5Encrypted Client Hello3gapNot offered by rustls in a form this could adopt today
12.2.1TLS for all client connectivity, no fallback1metWith server.tls.enabled the socket speaks TLS instead of cleartext; there is no downgrade path. HTTPS is on the hardening checklist for deployments that terminate elsewhere
12.2.2Publicly trusted certificates on external services1n/aThis is an internal service by design; its clients trust the CA the operator installed
12.3.1Encrypted protocols for all inbound and outbound connections2partialThe relay upstream, webhooks and IPAM are HTTPS. http-01 validation is HTTP because RFC 8555 §8.3 defines it that way, SQLite is a local file, not a connection, and a PostgreSQL connection is TLS only when database.url asks for it with sslmode — the server does not enforce it
12.3.2TLS clients validate certificates2metThe relay client validates against webpki-roots — there, the certificate is the only thing identifying the CA being handed your CSRs. The IPAM clients validate too; insecure_skip_verify exists, defaults off, and warns on every startup while on
12.3.3TLS between internal HTTP services2metSame set. The http-01 exception above is the protocol’s
12.3.4Internal TLS uses trusted certificates2metThe IPAM clients take a ca_bundle so a NetBox behind an internal PKI is trusted specifically rather than by disabling verification (crates/core/src/config/types/ipam.rs)
12.3.5Strong mutual authentication between internal services3n/aSingle process; there are no intra-service hops

V13 Configuration

#RequirementLStatusEvidence
13.1.1All communication needs documented, including user-supplied destinations2metSecurity Model names all three outbound surfaces and says which of them a client can steer
13.1.2Documented connection limits and behaviour at the limit3metThe database pool size, the admission limiter’s slots, its queue budget and its deadline are all in Configuration Reference, and shedding at the limit is a 503 problem document
13.1.3Documented resource-management strategy per external system3partialTimeouts are documented per subsystem and every outbound call has one. Retry policy is documented for the job runner but not stated as a policy for the IPAM and webhook clients
13.1.4Documented critical secrets and a rotation schedule3metThe secrets are named and classified in Security Model; Secret Rotation gives a recommended interval and the early-rotation triggers for each, with the CA key called out as structural rather than scheduled
13.2.1Authenticated backend communication with non-shared credentials2partialThe relay upstream authenticates by account key and the IPAM clients by API token, both per-deployment, and PostgreSQL by the role and password in database.url. SQLite is a local file governed by file mode, not by a credential
13.2.2Least privilege for backend accounts2metcustom hooks run with env_clear(), a minimal PATH, a timeout and kill_on_drop (crates/core/src/script_hook.rs); the systemd unit in Deployment runs as a dedicated acme-proxy user and the repository Containerfile runs as a non-root acme-proxy user (uid 1000) owning only /data; the IPAM token needs read access only
13.2.3No default service credentials2metNothing ships with a credential. Every secret is either operator-supplied or generated on first start
13.2.4Allowlist of external systems the application may contact2partialThe relay upstream, the IPAM host and the webhook URL are each a single configured destination — an allowlist of one. The http-01 validator is the exception, and deliberately so
13.2.5Server-level allowlist of destinations2partialSame. The containment for http-01 is scheme, port and hop count rather than destination
13.2.6Documented per-connection configuration followed3metEach client is built from its own configuration block at startup, so a broken setting stops the server rather than failing every later call
13.3.1A secrets management solution; no secrets in source or artifacts2partialNo secret is in the source tree or the image. Every secret can come from the environment rather than the file, and the CA key can live in a PKCS#11 token — which is the L3 hardware-backed form. There is no vault integration, and the database necessarily holds EAB and TOTP secrets in retrievable form
13.3.2Least privilege for secret access2metKeys are created 0600 with create_new rather than chmod’ed afterwards (crates/core/src/pemfile.rs); the database file mode is the documented boundary
13.3.3Cryptographic operations inside an isolated security module3partialAvailable but not required: --features hsm puts the issuing key in a PKCS#11 token, where it can be used and not copied (Hardware Keys)
13.3.4Secrets expire and rotate as documented3partialRotation is now documented per secret in Secret Rotation. Sessions expire on their own and EAB credentials are revocable live; the CA key, the TSIG key and the API tokens rotate on an operator-run cadence, not a timer
13.4.1No source-control metadata deployed1met.dockerignore is an allowlist — * then !Cargo.toml, !Cargo.lock, !src/, !crates/store/migrations/ — so .git never enters the build context, and the final stage copies only the compiled binary
13.4.2Debug modes disabled in production2metLog level is configuration and defaults to info; there is no debug endpoint and no development mode. challenge.bypass, the one setting that genuinely weakens the server, is off by default and warns on every startup while on
13.4.3No directory listings2metNothing is served from a directory. tower-http’s fs feature is off and static assets are a two-arm match
13.4.4HTTP TRACE unsupported2metNever routed; axum answers 405
13.4.5Documentation and monitoring endpoints not exposed unless intended2met/metrics is a separate listener, off by default; /health is deliberately outside the filter chain and the hardening checklist tells operators not to forward it (Monitoring)
13.4.6No detailed version information exposed3metNo Server header is set and no version appears in any response body
13.4.7Web tier serves only specific extensions3metThe static allowlist is two filenames; everything else is a 404

V14 Data Protection

#RequirementLStatusEvidence
14.1.1Sensitive data identified and classified2metSecurity Model classifies every secret by what its compromise buys; Database Schema classifies each by the form it is stored in — one-way, retrievable, or never stored
14.1.2Documented protection requirements per level2metThe same two pages, plus Audit Trail for retention
14.2.1No sensitive data in URLs or query strings1metThe session token is in a cookie, the CSRF token in a header, the EAB secret in a response body shown once. No credential is ever a path or query parameter
14.2.2Sensitive data not cached in server components2metCache-Control: no-store on every admin response — account contacts and a freshly minted EAB secret must not sit in a disk cache after the tab closes (crates/admin/src/webadmin/mod.rs)
14.2.3Sensitive data not sent to untrusted parties2metThe only outbound payloads are the relay’s own ACME traffic, a webhook to an operator-configured URL and IPAM lookups. No analytics, no third-party asset, no CDN
14.2.4Documented controls implemented2metNonces and session tokens reach logs only as fingerprints (crates/store/src/nonce.rs, crates/store/src/admin_session.rs); proxy URLs are redacted before they are logged or Debug-formatted, pinned by neither_debug_nor_redacted_leaks_the_password (crates/net/src/proxy.rs); the database URL’s password is redacted wherever it is printed (redact_url, crates/core/src/logfields.rs), and an EAB row’s Debug omits its HMAC secret (crates/store/src/eab.rs)
14.2.5Caching only for expected content types (web cache deception)3metno-store on the whole admin listener, and an unknown path returns a 404, never a different valid file
14.2.6Return the minimum sensitive data3metAn EAB secret is shown exactly once at creation; a session is displayed by the fingerprint of its token hash, never by the hash; render_admin_session_json is the one serializer
14.2.7Retention classification and scheduled deletion3partialaudit.retention_days sweeps the trail and the job runner reaps nonces, expired sessions and stale orders. The default is 0 — keep everything — which is the right default for a trail whose value is that it is complete, and is a decision the operator is asked to make
14.2.8Strip metadata from user-submitted files3n/aNo file uploads
14.3.1Authenticated data cleared from client storage on termination1metThe panel keeps nothing in localStorage or sessionStorage; sign-out clears the cookie with a Max-Age=0 Set-Cookie carrying the same attributes
14.3.2Anti-caching response header fields2metCache-Control: no-store
14.3.3No sensitive data in browser storage beyond session tokens2metOnly the session cookie exists

V15 Secure Coding and Architecture

#RequirementLStatusEvidence
15.1.1Documented remediation time frames for vulnerable components1partialSecurity Policy states that fixes land on main and in the next release, and cargo deny runs advisories on every CI run and on a schedule. No numeric time frame is committed to
15.1.2An SBOM or equivalent inventory is maintained2metsbom.cdx.json is a committed CycloneDX 1.5 inventory of the shipped dependency closure (--all-features --target all, dev-dependencies excluded), regenerated and diffed by the sbom CI job and carried in the published crate; cargo deny check gates that same closure
15.1.3Documented resource-demanding functionality2metThe expensive paths are named and bounded: http-01 and dns-01 validation have timeouts, the PBKDF2 cost is documented as a denial-of-service lever with the limiter placed before it, and the admission limiter’s shed-versus-queue reasoning is written out in crates/protocol/src/middlewares/admission.rs
15.1.4Risky third-party libraries highlighted3metdeny.toml is the allow list, run with all-features = true, and the rationale for refusing dependencies is recorded where the refusal was made — crates/admin/src/admin/password.rs on Argon2id, issue #5 on webauthn-rs
15.1.5Dangerous functionality highlighted3metSecurity Model names the three request-forgery surfaces, and Security Policy lists the behaviour that looks alarming and is deliberate
15.2.1No components past the documented remediation window1metThe Advisories, licenses & sources CI job fails the build on a RUSTSEC advisory
15.2.2Implemented defenses against availability loss2metAdmission limiter with a queue budget and a request deadline, body limits on both listeners, a login limiter ahead of the KDF, per-call timeouts on every outbound subsystem, and kill_on_drop on script hooks
15.2.3Production contains no test or development functionality2metTest helpers are #[cfg(test)] or behind testutil; there is no sample data, no seeded account and no development route
15.2.4Dependencies from expected repositories3metcargo deny check sources restricts registries, and Cargo.lock pins every transitive dependency by hash
15.2.5Extra protection around dangerous functionality3metScript hooks are the dangerous surface and run in a cleared environment with a minimal PATH, a deadline and kill_on_drop; the CA key can be moved into a PKCS#11 token; the Containerfile is the network-isolation story
15.3.1Return only the required subset of fields1metExplicit serializers per resource; no row is serialized wholesale
15.3.2Do not follow redirects unless intended2metIntended, bounded and switchable: follow_redirects and max_redirects on the http-01 validator, with scheme and port checked on every hop (crates/net/src/challenge/http_01.rs)
15.3.3Countermeasures against mass assignment2metRequest bodies deserialize into per-route structs holding only the fields that route accepts; nothing constructs a row from client JSON
15.3.4Original client IP transferred correctly and used for decisions2metfilter.trusted_proxies is the allowlist of hops whose forwarded header is believed; empty means the header is ignored. The admin listener has its own list, admin.filter.trusted_proxies, and with it empty — the default — no caller can choose its own rate-limiter key (crates/core/src/client.rs, crates/admin/src/webadmin/filter.rs, crates/admin/src/webadmin/session.rs)
15.3.5Explicit types and strict comparisons2metRust’s type system; there is no coercion to juggle
15.3.6JavaScript written to prevent prototype pollution2n/aThe panel ships no application JavaScript
15.3.7Defenses against HTTP parameter pollution2metaxum extractors read from one named source per parameter — a path segment, a typed query struct, or a JSON body — never from a merged bag
15.4.1Thread-safe access to shared objects3metSend/Sync are checked at compile time; shared mutable state is behind Mutex or Semaphore (LoginLimiter, Admission)
15.4.2State checks and dependent actions are atomic3metThe single-use idiom is one statement: UPDATE … WHERE <still unused> decided by rows_affected, never a read followed by a write. Key files are created with create_new, which is the atomic form of “exists?” then “create”
15.4.3Consistent locking, contained in the owning code3metLocks are held inside the type that owns the resource and never across an await
15.4.4Resource allocation prevents starvation3metThe admission limiter refuses past its queue budget rather than queueing without bound — the reasoning for not using GlobalConcurrencyLimitLayer is written out in crates/protocol/src/middlewares/admission.rs

V16 Security Logging and Error Handling

#RequirementLStatusEvidence
16.1.1A logging inventory exists2metMonitoring enumerates every event = "…" name; Audit Trail states what the trail records, where it lives, who can read it and how retention works
16.2.1Log entries carry when, where, who, what2metThe access line carries method, URI, status, latency, client address, profile and a request id (crates/protocol/src/middlewares/access.rs); an audit row carries actor, address, reverse name, identifiers, User-Agent and the same request id
16.2.2Synchronized time sources; UTC or explicit offset2metTimestamps come from the host clock as Unix seconds in the database and RFC 3339 in the log; host time sync is the operator’s
16.2.3Logs only go to documented destinations2metOne tracing subscriber built in one place — prepare_logging in crates/server/src/logging.rs — with logging.target naming the sink, so a reload cannot drift from startup
16.2.4Logs readable by the log processor2metlogging.json_format produces one JSON object per line, with flatten_event for pipelines that want fields at the top level
16.2.5Sensitive data logged according to its protection level2metNonces and session tokens appear only as fingerprints; proxy credentials and the database URL’s password are redacted; a password never enters a log or argv — admin user passwd reads from stdin or --password-file
16.3.1All authentication operations logged2metadmin_login_*, admin_mfa_verified, admin_mfa_attempts_exhausted, admin_logout and admin_password_hash_unreadable, each with the outcome and the method used
16.3.2Failed authorization attempts logged2metFilter denials, jws_url_mismatch, jws_jwk_and_kid_both_present, nonce_replayed and the *_failed audit rows. certificate_revoke_failed is written specifically so a run of them is visible as somebody enumerating serials
16.3.3Security events and control-bypass attempts logged2metchallenge_validation_bypassed and two other weakened-configuration warnings repeat on every startup so they cannot become background noise (Hardening)
16.3.4Unexpected errors and control failures logged2metBackend, signer, DNS and IPAM failures each log with outcome = "failure" and their own event name
16.4.1Logging components encode data to prevent log injection2metJSON mode escapes structurally. In text mode the only client-controlled fields are the request URI, which http::Uri renders percent-encoded, and header values, which HeaderValue::to_str accepts only as visible ASCII — so a User-Agent carrying a control byte is dropped before it can be stored, let alone printed
16.4.2Logs protected from unauthorized access and modification2metThe audit trail has no foreign keys, so deleting an account does not take its history; nothing in the panel can erase it — the audit surface is read-only and pruning is a host command (Audit Trail). The log stream itself is the operator’s to protect
16.4.3Logs transmitted to a logically separate system2partialThe server writes to stdout or a file in a format built for shipping, and Monitoring shows the pipeline — but shipping them is the deployment’s job, not this process’s
16.5.1Generic message to the consumer on unexpected errors2metEvery ACME refusal is an RFC 8555 problem document with a fixed type; internal detail goes to the log and not the body. The http-01 validator’s fetched body is never echoed into a client-visible error, precisely because it is attacker-chosen
16.5.2Secure operation when external resources fail2metA check that cannot reach its authority answers undecided rather than “allow”, so an IPAM outage degrades to a retryable 500 instead of failing open — the property Filters is built around
16.5.3Fail gracefully and securely; no fail-open2metStartup refuses rather than degrading: a non-loopback admin.bind_address without TLS, an unknown challenge type, a deadline below signer.custom.timeout_ms. tests/security.rs is the regression set for the request-path equivalents
16.5.4A last-resort handler for unhandled exceptions3metA CatchPanicLayer on each listener catches a handler panic, logs request_handler_panicked, and answers with that listener’s own error shape — an ACME problem document, or the admin JSON (/api) / HTML (/ui) error — instead of the aborted connection it used to be. panic = "abort" stays unset so the layer can unwind; the panic message goes to the log only

Documented deviations

Places where this project has knowingly chosen differently from what ASVS asks. Each was argued before this assessment existed; the assessment’s job is to surface them against the standard, not to reverse them.

http-01 validation does not block private addresses — V1.3.6, V13.2.4, V13.2.5. RFC 8555 requires following redirects, and Boulder’s mitigation — refusing RFC 1918 targets — cannot apply to a server whose entire purpose is serving private networks. What contains it instead: only http and https, only the two configured ports, at most max_redirects hops, a shared timeout, an off switch, and the fetched body never being echoed into a client-visible error. → HTTP-01

RS256 and RSA from 2048 bits — V11.2.3, V11.3.1. RFC 8555 §6.2 names RS256 as an algorithm an ACME server must accept, and RS256 is RSASSA-PKCS1-v1_5. Refusing it would refuse conforming clients. Two things soften it: this is a signature scheme, not the encryption padding V11.3.1 targets, and the key in question is a client’s own account key, whose compromise costs that client its account rather than costing the CA anything. Raising the accepted floor to 3072 bits is a protocol-compatibility decision, not a code change.

PBKDF2-HMAC-SHA256 rather than Argon2id — V11.4.2. Argon2id is the stronger primitive. Adopting it would add four crates to a certificate authority’s dependency graph — all audited on every cargo deny check, which runs with all-features = true — for a subsystem that is disabled by default and whose password is the bootstrap credential in a design that ends in a second factor. 600 000 iterations is OWASP’s current recommendation for the non-Argon2 case, and the stored form is self-describing so the trade can be revisited without a migration. The argument is in the module doc of crates/admin/src/admin/password.rs.

admin.require_mfa defaults to false — V6.3.3. Defaulting it on would brick a panel whose first operator has not enrolled yet, with no way in to fix it. The hardening checklist tells operators to turn it on, and the panel supports a bootstrap flow where enrolment is the only thing a session can do. An L2 claim for the web admin depends on the operator setting it. → Hardening

No hardware-based authentication factor — V6.3.3 at L3. WebAuthn was investigated and deferred, and both blocking checks were actually run: webauthn-rs 0.5.5 is MPL-2.0, which deny.toml’s allow list does not carry, and webauthn-rs-core hard-depends on openssl, which this tree has avoided at every turn. Nothing in the design precludes it — another factor kind is another MfaStep variant, not a change to the state machine. It stays open as issue #5.

The relay backend multiplexes one upstream account — V8.3.3. One upstream ACME account, and one centrally held RFC 2136 TSIG key, standing in for every local client. That is the whole point of the backend: not distributing a scarce credential is what it exists to do. Every authorization decision is made locally, before the upstream is ever asked. → Relay

Two listeners, usually two ports on one host — V3.5.4. They are separate sockets with separate TLS, separate authentication and separate defaults, and the admin one binds loopback unless TLS is on. They are not separate hostnames, and cookies are not port-scoped — which is exactly why the panel does not rely on SameSite for CSRF and carries a per-session token plus an Origin check instead. → Web Admin

The audit trail is a record, not a control — V8.2.4. Nothing in the server compares a live request against the trail. Pinning an identity to an address breaks CGNAT and mobile clients, and that is a deliberate non-feature. It is stated as such in the Security Model.

Gaps

Open shortfalls, worst first. What remains is all L3, recorded only here.

Lower-priority L3 items, recorded here only and with no issue open: no CSP violation-report endpoint (V3.4.7), no Cross-Origin-Opener-Policy (V3.4.8), no documented behaviour for browsers lacking security features (V3.1.1, V3.7.5), no OCSP stapling as a TLS server (V12.1.4), no Encrypted Client Hello (V12.1.5), no post-quantum migration plan (V11.1.4), no multi-user approval for issuance (V2.3.5), and admin user passwd letting the resetter learn the password (V6.4.6).

Re-running this

The requirement text is vendored at rfc/asvs-5.0/, so this page can be re-derived against a later ASVS release by diffing the chapter files and revisiting only the rows whose requirement text moved. The per-chapter tables enumerate every in-scope requirement rather than only the failures for exactly that reason: a list of gaps alone cannot be compared against anything.