doc/developers/README.passkey.md
This document describes the design, implementation, and operational details of the WebAuthn/Passkey second-factor authentication support in ntopng.
ntopng supports WebAuthn (Web Authentication API, W3C standard) as a second authentication factor, alongside the existing TOTP/MFA support. End users register one or more hardware security keys or platform authenticators (Touch ID, Face ID, Windows Hello, YubiKey, etc.) — collectively called passkeys — and are prompted to use one after password login.
No external WebAuthn library is required. The entire implementation is
self-contained in src/Ntop.cpp using only libraries already required by
ntopng:
| Library | Used for |
|---|---|
| OpenSSL (libssl + libcrypto) | RAND_bytes for challenge generation; SHA256, EC_KEY, ECDSA_verify for assertion verification |
| Redis / hiredis | Credential storage, pending-token and challenge state |
The implementation supports ES256 (ECDSA over P-256 with SHA-256), which is the algorithm mandated by the WebAuthn Level 2 specification and universally supported by browsers and authenticators.
WebAuthn takes priority over TOTP when both are configured for the same user: if a user has at least one registered passkey, the WebAuthn prompt is shown instead of the TOTP prompt.
Browser (logged-in user) ntopng (Lua + C++) Redis
│ │ │
│ POST /lua/admin/ │ │
│ change_user_webauthn.lua │ │
│ action=get_registration_ │ │
│ options&username=…&csrf=… │ │
│─────────────────────────────────>│ │
│ │ generateWebAuthnChallenge │
│ │ (RAND_bytes 32 → b64url) │
│ │──────────────────────────>│
│ │ SET webauthn.reg.<chal> │
│ │ = username (TTL 5min) │
│ { challenge, rp, user, … } │ │
│<─────────────────────────────────│ │
│ │ │
│ navigator.credentials.create() │ │
│ (browser prompts user for │ │
│ authenticator gesture) │ │
│ │ │
│ POST action=complete_ │ │
│ registration │ │
│ cred_id, client_data, │ │
│ att_obj, challenge, … │ │
│─────────────────────────────────>│ │
│ │ verifyAndStoreWebAuthn │
│ │ Registration(): │
│ │ • verify clientDataJSON │
│ │ • CBOR-decode attObj │
│ │ • parse authData │
│ │ • verify rpIdHash │
│ │ • check UP flag │
│ │ • store credential │
│ │──────────────────────────>│
│ │ SET webauthn_cred_<n> │
│ { result: 0 } │ │
│<─────────────────────────────────│ │
Browser ntopng (HTTPserver.cpp) Redis
│ │ │
│ POST /lua/login.lua │ │
│ (username + password) │ │
│─────────────────────────────────>│ │
│ │ password OK │
│ │ isWebAuthnEnabled(user)? │
│ │──────────────────────────>│
│ │ YES: cred_count > 0 │
│ │ createWebAuthnPendingToken │
│ │ → token, challenge │
│ │──────────────────────────>│
│ │ SET webauthn.pending.<tok>│
│ │ = user|referer|challenge │
│ 302 → /lua/webauthn_verify.lua │ (TTL 5 min) │
│ ?token=<tok> │ │
│<─────────────────────────────────│ │
│ │ │
│ GET /lua/webauthn_verify.lua │ │
│─────────────────────────────────>│ │
│ (page auto-triggers │ │
│ navigator.credentials.get()) │ │
│ │ │
│ POST /webauthn_authorize.html │ │
│ token, cred_id, client_data, │ │
│ auth_data, signature │ │
│─────────────────────────────────>│ │
│ │ getWebAuthnPendingToken │
│ │──────────────────────────>│
│ │ verifyWebAuthnAssertion() │
│ │ • decode b64url inputs │
│ │ • verify clientDataJSON │
│ │ • verify rpIdHash │
│ │ • check UP flag │
│ │ • find credential by ID │
│ │ • check signCount │
│ │ • verify ECDSA signature │
│ │ • update signCount │
│ │──────────────────────────>│
│ │ deleteWebAuthnPendingToken│
│ 302 → original referer │ set_session_cookie() │
│<─────────────────────────────────│ │
| File | Role |
|---|---|
src/Ntop.cpp | All WebAuthn crypto and Redis CRUD: challenge generation, registration verification, assertion verification, credential storage |
include/Ntop.h | Public declarations of all WebAuthn methods on Ntop |
include/ntop_defines.h | Redis key prefixes and constants (WEBAUTHN_*) |
src/HTTPserver.cpp | webauthn_authorize() handler; second-factor routing after password login |
src/LuaEngineNtop.cpp | Lua bindings (ntop.generateWebAuthnRegistrationOptions, ntop.completeWebAuthnRegistration, etc.) |
scripts/lua/webauthn_verify.lua | Second-factor challenge page; auto-invokes navigator.credentials.get() |
scripts/lua/admin/change_user_webauthn.lua | REST endpoint for credential list/register/delete |
scripts/lua/inc/password_dialog.lua | Passkeys tab UI in the user management modal |
scripts/locales/en.lua | webauthn.* i18n strings |
All WebAuthn state is stored in Redis with no additional persistence layer.
ntopng.user.<username>.webauthn_cred_count → "<n>"
ntopng.user.<username>.webauthn_cred_0 → "<cred_id_b64url>|<pk_x_hex>|<pk_y_hex>|<sign_count>|<name>"
ntopng.user.<username>.webauthn_cred_1 → …
…
ntopng.user.<username>.webauthn_cred_9 → … (max 10 credentials, WEBAUTHN_MAX_CREDS)
The credential record fields are pipe-separated:
| Field | Description |
|---|---|
cred_id_b64url | Credential ID as returned by the authenticator (base64url) |
pk_x_hex | P-256 public key X coordinate (32 bytes, hex) |
pk_y_hex | P-256 public key Y coordinate (32 bytes, hex) |
sign_count | Last observed authenticator signature counter |
name | User-assigned label (e.g. "My iPhone") |
webauthn.reg.<challenge_b64url> → "<username>"
Created by generateWebAuthnRegistrationOptions, consumed and deleted by
completeWebAuthnRegistration.
webauthn.pending.<token> → "<username>|<referer>|<challenge_b64url>"
Created by createWebAuthnPendingToken after password login succeeds,
deleted by deleteWebAuthnPendingToken after assertion verification.
After a successful password check, the login handler checks whether WebAuthn is enabled for the user before checking TOTP:
if (ntop->isWebAuthnEnabled(user)) {
char token[64], challenge[128];
if (ntop->createWebAuthnPendingToken(user, referer, token, sizeof(token),
challenge, sizeof(challenge)))
redirect_to_webauthn(conn, token); // → /lua/webauthn_verify.lua?token=…
return;
}
// TOTP check follows here
POST /webauthn_authorize.htmlHandled by webauthn_authorize() in HTTPserver.cpp. This endpoint is
whitelisted (accessible without a session).
Steps:
token, cred_id, client_data, auth_data, signature.getWebAuthnPendingToken).origin (scheme://Host header) and rp_id (hostname, port stripped)
from the incoming HTTP request.verifyWebAuthnAssertion().set_session_cookie(), redirect to
the stored referer./lua/webauthn_verify.lua?token=…&reason=invalid-key.scripts/lua/webauthn_verify.luaThe second-factor challenge page. On page load it:
token from _GET.ntop.getWebAuthnPendingToken(token) to retrieve username and
challenge.navigator.credentials.get() with the
challenge, then POSTs the assertion to /webauthn_authorize.html.scripts/lua/admin/change_user_webauthn.luaREST endpoint for credential management. Requires CSRF token on all POST requests.
action | Method | Description |
|---|---|---|
get_registration_options | POST | Generate and return a registration challenge |
complete_registration | POST | Verify attestation and store credential |
list | GET | Return JSON array of credentials for a user |
delete | POST | Remove a credential by ID |
Authorization: admin users can manage any user's credentials; non-admin users can manage only their own credentials (enforced in both Lua and C++).
The Passkeys tab is rendered inside the user management modal
(scripts/lua/inc/password_dialog.lua). The JavaScript:
navigator.credentials.create() for registration.rp: { name: "ntopng" } without an explicit id, letting the browser
use the effective domain of the current page (required for IP access to work
with localhost; note that IP addresses other than localhost are not valid
RP IDs per the WebAuthn spec).updateWebAuthnStatus(username) after each
add or remove operation.All three POST requests (get options, complete registration, delete) include a
csrf= token rendered server-side by ntop.getRandomCSRFValue(), matching the
pattern used by the existing MFA tab.
Registered in src/LuaEngineNtop.cpp:
| Lua function | C++ handler |
|---|---|
ntop.generateWebAuthnRegistrationOptions(username) | ntop_generate_webauthn_registration_options |
ntop.completeWebAuthnRegistration(username, name, cred_id, cdj, attobj, challenge, origin, rp_id) | ntop_complete_webauthn_registration |
ntop.getWebAuthnCredentials(username) | ntop_get_webauthn_credentials |
ntop.deleteWebAuthnCredential(username, cred_id) | ntop_delete_webauthn_credential |
ntop.isWebAuthnEnabled(username) | ntop_is_webauthn_enabled |
ntop.getWebAuthnPendingToken(token) | ntop_get_webauthn_pending_token |
Authorization in the C++ bindings uses a dedicated helper
allowWebAuthnManagement(vm, target_username) that permits the call if the
caller is an administrator or if the caller is the same user as
target_username. This differs from allowLocalUserManagement() (admin-only)
and mirrors the self-service pattern used by ntop_reset_user_password.
RAND_bytes(32 bytes) → base64url-encode → 43-character challenge string
Stored in Redis with a 5-minute TTL. Challenges are single-use: consumed and deleted on first use to prevent replay.
verifyAndStoreWebAuthnRegistration)clientDataJSON (base64url) and attestationObject (base64url).clientDataJSON:
type must be "webauthn.create".challenge must match the stored registration challenge (byte-for-byte
after decoding both from base64url).origin must match expected_origin.attestationObject: minimal CBOR decoder extracts the authData
byte array from the "none" attestation format (the only format requested).authData binary structure:
rpIdHash — SHA-256 of the RP ID.x (key -2) and y (key -3) as 32-byte
P-256 coordinates.rpIdHash: SHA256(rp_id) must equal bytes 0–31 of authData.verifyWebAuthnAssertion)clientDataJSON, authenticatorData, and signature (all
base64url).clientDataJSON:
type must be "webauthn.get".challenge must match the pending token's stored challenge.origin must match the scheme://host of the incoming request.rpIdHash: SHA256(rp_id) must equal bytes 0–31 of
authenticatorData.signCount from bytes 33–36 (big-endian uint32).cred_id against stored credentials.signCount: if the stored counter is non-zero, the new counter
must be strictly greater (replay protection). Authenticators that always
return 0 are accepted (stored counter stays 0).authenticatorData || SHA256(clientDataJSON).pk_x, pk_y via EC_KEY.ECDSA_verify(0, msg, mlen, sig, slen, ec_key).signCount in Redis.| Concern | Mitigation |
|---|---|
| Challenge replay | Challenges stored in Redis with 5-minute TTL; deleted on first use |
| CSRF on credential management | All POST requests to change_user_webauthn.lua require a valid csrf= token (rendered server-side) |
| Unauthorized credential access | C++ allowWebAuthnManagement() enforces admin-or-self; Lua endpoint has an additional authorization check |
| Assertion replay | signCount strictly increases; stale assertions rejected |
| Origin binding | origin in clientDataJSON verified against scheme://Host header of the actual HTTP request |
| RP ID binding | rpIdHash in authenticatorData verified against SHA256(hostname) |
| User presence | UP flag (bit 0) checked in both registration and assertion |
| Max credentials | Capped at 10 per user (WEBAUTHN_MAX_CREDS) to bound Redis key proliferation |
| Pending token scope | Token links a specific username to a specific challenge; cannot be used for a different user |
HTTPS required. Browsers expose window.PublicKeyCredential only in
secure contexts (HTTPS
or http://localhost). Accessing ntopng via plain HTTP on a non-localhost
address will silently make the API unavailable.
IP addresses not supported as RP IDs. The WebAuthn spec forbids IP
addresses (e.g. 192.168.1.1) as RP IDs. ntopng omits rp.id in the
navigator.credentials.create() call so the browser defaults to the
effective domain, which handles named hostnames and localhost correctly.
Deployment behind a reverse proxy with a proper DNS hostname is recommended.
ES256 only. Only ECDSA P-256 (alg: -7) is requested and verified.
RSA-based authenticators (RS256) are not supported.
"none" attestation only. ntopng requests attestation: "none" and
does not verify authenticator provenance (no attestation certificate
validation). This is appropriate for a second-factor scenario where the
goal is binding to a physical device rather than auditing device models.
No resident keys / discoverable credentials. Registration requests
residentKey: "preferred" but login always requires a username + password
first; the WebAuthn assertion is a second factor, not a passwordless
replacement.