From 3864562de6fd963df815db00f8857457ce4e2725 Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Mon, 6 Apr 2026 10:20:17 -0700 Subject: [PATCH 01/10] Apply suggestion from @msimerson --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index d61dd863..89eee61f 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,6 @@ See [AUTHORS.md](https://github.com/nictool/NicTool/blob/master/AUTHORS.md) ### Support * [Commercial Support](https://www.tnpi.net/cart/index.php/categories/nictool) -* [Forums](https://www.tnpi.net/support/forums/) * [Email](mailto:support@nictool.com) - Requests not accompanied by payments are handled on a "best effort" basis. * [GitHub Issues](https://github.com/NicTool/NicTool/issues) * [Wiki](https://github.com/nictool/NicTool/wiki) From 99bd9e0891a32a463b018af633d8abc0882627e5 Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Mon, 6 Apr 2026 19:01:04 +0100 Subject: [PATCH 02/10] ci: decouple CI from pre-built GHCR image, remove dead forums link Build the base Docker image locally in CI instead of requiring a pre-existing image in GHCR, breaking the bootstrap chicken-and-egg. Uses GHCR + GHA cache layers when available, builds from scratch when not. Also removes dead forums link per review feedback. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 146 +++++++++++++++++++++++---------------- 1 file changed, 85 insertions(+), 61 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44e05628..dc5b4c6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,93 +16,117 @@ permissions: packages: read jobs: - image-name: - runs-on: ubuntu-24.04 - outputs: - ci: ${{ steps.set.outputs.ci }} - steps: - - id: set - run: echo "ci=ghcr.io/${GITHUB_REPOSITORY,,}/build:latest" >> "$GITHUB_OUTPUT" - test: - needs: image-name runs-on: ubuntu-24.04 - container: - image: ${{ needs.image-name.outputs.ci }} - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - services: - mysql: - image: mysql:8 - env: - MYSQL_ROOT_PASSWORD: root - MYSQL_DATABASE: nictool - options: >- - --health-cmd="mysqladmin ping -h 127.0.0.1" - --health-interval=5s - --health-timeout=3s - --health-retries=10 - steps: - uses: actions/checkout@v6 + - uses: docker/setup-buildx-action@v4 + + - uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Compute GHCR cache ref + id: meta + run: echo "ref=ghcr.io/${GITHUB_REPOSITORY,,}/build:latest" >> "$GITHUB_OUTPUT" + + - name: Build CI base image + uses: docker/build-push-action@v7 + with: + context: . + file: dist/docker/Dockerfile + target: base + tags: nictool-ci:latest + load: true + cache-from: | + type=registry,ref=${{ steps.meta.outputs.ref }} + type=gha,scope=ci + cache-to: type=gha,mode=max,scope=ci + - name: Generate test credentials run: | - DB_PW=$(openssl rand -base64 24) - ROOT_PW=$(openssl rand -base64 24) - echo "NICTOOL_DB_USER_PASSWORD=$DB_PW" >> "$GITHUB_ENV" - echo "DB_ROOT_PASSWORD=root" >> "$GITHUB_ENV" - echo "NICTOOL_DB_USER=nictool" >> "$GITHUB_ENV" - echo "NICTOOL_DB_NAME=nictool" >> "$GITHUB_ENV" - echo "DB_ENGINE=mysql" >> "$GITHUB_ENV" - echo "DB_HOSTNAME=mysql" >> "$GITHUB_ENV" - echo "ROOT_USER_EMAIL=ci@nictool.test" >> "$GITHUB_ENV" - echo "ROOT_USER_PASSWORD=$ROOT_PW" >> "$GITHUB_ENV" - echo "NICTOOL_CLIENT_DIR=$GITHUB_WORKSPACE/client" >> "$GITHUB_ENV" + echo "DB_PW=$(openssl rand -base64 24)" >> "$GITHUB_ENV" + echo "ROOT_PW=$(openssl rand -base64 24)" >> "$GITHUB_ENV" - - name: Install NicTool client and server + - name: Create test network and start MySQL + run: | + docker network create ci + docker run -d --name mysql --network ci \ + -e MYSQL_ROOT_PASSWORD=root \ + -e MYSQL_DATABASE=nictool \ + --health-cmd="mysqladmin ping -h 127.0.0.1" \ + --health-interval=5s \ + --health-timeout=3s \ + --health-retries=10 \ + mysql:8 + + - name: Start CI container run: | - cd "$GITHUB_WORKSPACE/client" - perl Makefile.PL - cpanm -n . + docker run -d --name ci --network ci \ + -v "$GITHUB_WORKSPACE:/workspace" \ + -w /workspace \ + -e NICTOOL_DB_USER_PASSWORD="$DB_PW" \ + -e DB_ROOT_PASSWORD=root \ + -e NICTOOL_DB_USER=nictool \ + -e NICTOOL_DB_NAME=nictool \ + -e DB_ENGINE=mysql \ + -e DB_HOSTNAME=mysql \ + -e ROOT_USER_EMAIL=ci@nictool.test \ + -e ROOT_USER_PASSWORD="$ROOT_PW" \ + -e NICTOOL_CLIENT_DIR=/workspace/client \ + nictool-ci:latest sleep infinity + + - name: Wait for MySQL + run: | + for i in $(seq 1 60); do + if docker inspect --format='{{.State.Health.Status}}' mysql 2>/dev/null | grep -q healthy; then + echo "MySQL is healthy" + exit 0 + fi + sleep 2 + done + echo "MySQL failed to become healthy" && exit 1 - cd "$GITHUB_WORKSPACE/server" - perl Makefile.PL - cpanm -n . + - name: Install NicTool client and server + run: | + docker exec ci bash -c ' + cd /workspace/client && perl Makefile.PL && cpanm -n . + cd /workspace/server && perl Makefile.PL && cpanm -n . + ' - name: Allow Apache to traverse workspace path run: | - dir="$GITHUB_WORKSPACE" - while [ "$dir" != "/" ]; do - chmod o+x "$dir" - dir="$(dirname "$dir")" - done + docker exec ci bash -c ' + dir="/workspace" + while [ "$dir" != "/" ]; do + chmod o+x "$dir" + dir="$(dirname "$dir")" + done + ' - name: Set up NicTool configs, Apache, and TLS - run: dist/setup/install-nictool.sh --nt-dir="$GITHUB_WORKSPACE" + run: docker exec ci dist/setup/install-nictool.sh --nt-dir=/workspace - name: Start Apache - run: apachectl start + run: docker exec ci apachectl start - name: Create NicTool test database - run: | - cd "$GITHUB_WORKSPACE/server/sql" - echo "" | perl create_tables.pl --environment + run: docker exec ci bash -c 'cd /workspace/server/sql && echo "" | perl create_tables.pl --environment' - name: Create test user and test.cfg - run: perl dist/setup/setup-test-env.pl + run: docker exec ci perl dist/setup/setup-test-env.pl - name: Run client tests - run: make -C "$GITHUB_WORKSPACE/client" test + run: docker exec ci make -C /workspace/client test - name: Run server tests - run: make -C "$GITHUB_WORKSPACE/server" test + run: docker exec ci make -C /workspace/server test - name: Failure diagnostics if: failure() run: | - mysql --version - apache2 -version - cat /var/log/apache2/error.log || true + docker exec ci cat /var/log/apache2/error.log 2>/dev/null || true + docker logs mysql 2>&1 | tail -50 || true From e36813bc81a6b270b9eab923ff2165dd84189d9c Mon Sep 17 00:00:00 2001 From: Moses Ingersoll Date: Mon, 6 Apr 2026 19:42:01 +0100 Subject: [PATCH 03/10] feat: add WebAuthn/passkey authentication with feature flag Add passkey support as an alternative login method. Disabled by default; enable by setting webauthn_enabled=1 in nt_options alongside webauthn_rp_id and webauthn_origin. Password auth is unchanged. Server: - NicToolServer::WebAuthn module with registration/authentication ceremonies - TOCTOU-safe challenge consumption (atomic UPDATE, check affected rows) - Usernameless login via discoverable credentials (empty allowCredentials) - Feature flag: webauthn_enabled option (default off) - Session dispatch for pre-auth WebAuthn endpoints (no session required) Client: - nt-webauthn.js: browser-side WebAuthn ceremony helpers (ES5/jQuery) - webauthn.cgi: JSON API proxy with action whitelist, CSRF validation, parameter injection prevention, cookie sanitization via CGI::cookie() - Login page passkey button (works without entering username) - User profile passkey management (register, rename, revoke) Schema: - nt_user_webauthn_credential table (full-column UNIQUE on credential_id) - nt_user_webauthn_challenge table (full-column UNIQUE on challenge) - Migration in upgrade.pl with CREATE TABLE IF NOT EXISTS for robustness - passkey_login added to session_log action ENUM Tests: - server/t/05_webauthn.t: 12 subtests covering feature flag, challenge lifecycle, registration/auth options, credential CRUD, mocked happy paths, cross-user isolation - client/t/e2e/webauthn.spec.ts: CSRF protection, session requirements, login page UI, virtual authenticator ceremonies, revocation Co-Authored-By: Claude Opus 4.6 (1M context) --- client/htdocs/nt-webauthn.js | 307 +++++++++++++ client/htdocs/user.cgi | 101 +++++ client/htdocs/webauthn.cgi | 139 ++++++ client/t/01-syntax.t | 2 +- client/t/e2e/helpers.ts | 82 ++++ client/t/e2e/webauthn.spec.ts | 577 ++++++++++++++++++++++++ client/templates/login.html | 31 ++ dist/docker/Dockerfile | 2 +- dist/setup/apache.conf.in | 5 + server/Makefile.PL | 4 +- server/lib/NicToolServer.pm | 43 +- server/lib/NicToolServer/SOAP.pm | 4 +- server/lib/NicToolServer/Session.pm | 71 ++- server/lib/NicToolServer/WebAuthn.pm | 544 +++++++++++++++++++++++ server/lib/nictoolserver.conf.dist | 1 + server/sql/02_nt_user.sql | 2 +- server/sql/12_nt_options.sql | 2 +- server/sql/14_nt_webauthn.sql | 32 ++ server/sql/upgrade.pl | 53 ++- server/t/05_webauthn.t | 630 +++++++++++++++++++++++++++ 20 files changed, 2616 insertions(+), 16 deletions(-) create mode 100644 client/htdocs/nt-webauthn.js create mode 100755 client/htdocs/webauthn.cgi create mode 100644 client/t/e2e/webauthn.spec.ts create mode 100644 server/lib/NicToolServer/WebAuthn.pm create mode 100644 server/sql/14_nt_webauthn.sql create mode 100644 server/t/05_webauthn.t diff --git a/client/htdocs/nt-webauthn.js b/client/htdocs/nt-webauthn.js new file mode 100644 index 00000000..a142813a --- /dev/null +++ b/client/htdocs/nt-webauthn.js @@ -0,0 +1,307 @@ +/** + * NicTool WebAuthn/Passkey support + * + * Provides browser-side WebAuthn ceremony helpers for passkey + * registration and authentication. ES5-compatible, uses jQuery + * for AJAX calls (consistent with existing NicTool JS). + */ + +/* global jQuery, navigator, Uint8Array, document, window */ + +(function ($) { + 'use strict'; + + // --- Base64URL helpers --- + + function base64urlToBytes(str) { + // Pad to multiple of 4 + var pad = str.length % 4; + if (pad === 2) str += '=='; + else if (pad === 3) str += '='; + // Convert base64url to base64 + str = str.replace(/-/g, '+').replace(/_/g, '/'); + var raw = atob(str); + var bytes = new Uint8Array(raw.length); + for (var i = 0; i < raw.length; i++) { + bytes[i] = raw.charCodeAt(i); + } + return bytes.buffer; + } + + function bytesToBase64url(buffer) { + var bytes = new Uint8Array(buffer); + var str = ''; + for (var i = 0; i < bytes.length; i++) { + str += String.fromCharCode(bytes[i]); + } + return btoa(str) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + } + + // --- Feature detection --- + + function isSupported() { + return !!( + window.PublicKeyCredential && + navigator.credentials && + navigator.credentials.create && + navigator.credentials.get + ); + } + + // --- AJAX helper --- + + function webauthnPost(action, payload, csrfToken) { + return $.ajax({ + url: 'webauthn.cgi', + method: 'POST', + contentType: 'application/json', + data: JSON.stringify({ + action: action, + csrf_token: csrfToken, + data: payload + }), + dataType: 'json' + }); + } + + // --- Registration ceremony --- + + function register(csrfToken, ntUserId, friendlyName) { + if (!isSupported()) { + alert('Your browser does not support passkeys.'); + return $.Deferred().reject('unsupported').promise(); + } + + var deferred = $.Deferred(); + + // Step 1: Get registration options from server + webauthnPost( + 'webauthn_get_registration_options', + { nt_user_id: ntUserId }, + csrfToken + ).done(function (resp) { + if (resp.error_code && +resp.error_code !== 200) { + deferred.reject(resp.error_msg || 'Server error'); + return; + } + + var options = JSON.parse(resp.options); + + // Convert base64url fields to ArrayBuffer + options.challenge = base64urlToBytes(options.challenge); + options.user.id = base64urlToBytes(options.user.id); + + if (options.excludeCredentials) { + for (var i = 0; i < options.excludeCredentials.length; i++) { + options.excludeCredentials[i].id = + base64urlToBytes(options.excludeCredentials[i].id); + } + } + + // Step 2: Browser ceremony + navigator.credentials + .create({ publicKey: options }) + .then(function (credential) { + // Step 3: Send attestation to server + var response = credential.response; + var transports = []; + if (response.getTransports) { + transports = response.getTransports(); + } + + webauthnPost( + 'webauthn_verify_registration', + { + nt_user_id: ntUserId, + challenge_b64: bytesToBase64url( + options.challenge + ), + client_data_json_b64: bytesToBase64url( + response.clientDataJSON + ), + attestation_object_b64: bytesToBase64url( + response.attestationObject + ), + friendly_name: + friendlyName || 'Passkey', + transports: transports.join(',') + }, + csrfToken + ) + .done(function (verifyResp) { + if ( + verifyResp.error_code && + +verifyResp.error_code !== 200 + ) { + deferred.reject( + verifyResp.error_msg + ); + } else { + deferred.resolve(verifyResp); + } + }) + .fail(function () { + deferred.reject( + 'Failed to verify registration' + ); + }); + }) + .catch(function (err) { + deferred.reject( + err.message || 'Passkey creation cancelled' + ); + }); + }).fail(function () { + deferred.reject('Failed to get registration options'); + }); + + return deferred.promise(); + } + + // --- Authentication ceremony --- + + function authenticate(csrfToken, username) { + if (!isSupported()) { + alert('Your browser does not support passkeys.'); + return $.Deferred().reject('unsupported').promise(); + } + + var deferred = $.Deferred(); + + // Step 1: Get authentication options (username optional) + var optionsPayload = {}; + if (username) optionsPayload.username = username; + + webauthnPost( + 'webauthn_get_auth_options', + optionsPayload, + csrfToken + ).done(function (resp) { + if (resp.error_code && +resp.error_code !== 200) { + deferred.reject(resp.error_msg || 'Server error'); + return; + } + + var options = JSON.parse(resp.options); + + // Convert base64url fields to ArrayBuffer + options.challenge = base64urlToBytes(options.challenge); + + if (options.allowCredentials) { + for (var i = 0; i < options.allowCredentials.length; i++) { + options.allowCredentials[i].id = + base64urlToBytes(options.allowCredentials[i].id); + } + } + + // Step 2: Browser ceremony + navigator.credentials + .get({ publicKey: options }) + .then(function (assertion) { + var response = assertion.response; + + // Step 3: Send assertion to server + var verifyPayload = { + challenge_b64: bytesToBase64url( + options.challenge + ), + credential_id_b64: bytesToBase64url( + assertion.rawId + ), + client_data_json_b64: bytesToBase64url( + response.clientDataJSON + ), + authenticator_data_b64: bytesToBase64url( + response.authenticatorData + ), + signature_b64: bytesToBase64url( + response.signature + ) + }; + if (response.userHandle && + response.userHandle.byteLength > 0) { + verifyPayload.user_handle_b64 = + bytesToBase64url(response.userHandle); + } + webauthnPost( + 'webauthn_verify_auth', + verifyPayload, + csrfToken + ) + .done(function (verifyResp) { + if ( + verifyResp.error_code && + +verifyResp.error_code !== 200 + ) { + deferred.reject( + verifyResp.error_msg + ); + } else { + deferred.resolve(verifyResp); + } + }) + .fail(function () { + deferred.reject( + 'Failed to verify authentication' + ); + }); + }) + .catch(function (err) { + deferred.reject( + err.message || 'Passkey authentication cancelled' + ); + }); + }).fail(function () { + deferred.reject('Failed to get authentication options'); + }); + + return deferred.promise(); + } + + // --- Credential management --- + + function revokeCredential(csrfToken, ntUserId, credentialId) { + return webauthnPost( + 'webauthn_revoke_credential', + { + nt_user_id: ntUserId, + nt_webauthn_credential_id: credentialId + }, + csrfToken + ); + } + + function renameCredential(csrfToken, ntUserId, credentialId, name) { + return webauthnPost( + 'webauthn_rename_credential', + { + nt_user_id: ntUserId, + nt_webauthn_credential_id: credentialId, + friendly_name: name + }, + csrfToken + ); + } + + function listCredentials(csrfToken, ntUserId) { + return webauthnPost( + 'webauthn_get_user_credentials', + { nt_user_id: ntUserId }, + csrfToken + ); + } + + // --- Public API --- + + window.NtWebAuthn = { + isSupported: isSupported, + register: register, + authenticate: authenticate, + revokeCredential: revokeCredential, + renameCredential: renameCredential, + listCredentials: listCredentials + }; +})(jQuery); diff --git a/client/htdocs/user.cgi b/client/htdocs/user.cgi index 819449b1..cfc30d25 100755 --- a/client/htdocs/user.cgi +++ b/client/htdocs/user.cgi @@ -175,6 +175,7 @@ sub display { ]; display_properties( $nt_obj, $q, $user, $duser, $edit_message ); + display_passkeys( $nt_obj, $q, $user, $duser ); display_global_log( $nt_obj, $q, $user, $duser, $message ); $nt_obj->parse_template($NicToolClient::end_html_template); @@ -273,6 +274,106 @@ sub display_properties { return $duser; } +sub display_passkeys { + my ( $nt_obj, $q, $user, $duser ) = @_; + + # Only show passkeys section when viewing own profile or as admin + my $is_self = $user->{'nt_user_id'} eq $duser->{'nt_user_id'}; + return unless $is_self || $user->{'user_write'}; + + my $uid = $duser->{'nt_user_id'}; + my $csrf_token = $nt_obj->get_csrf_token(); + + print qq[ + + + +

+ + + +
Passkeys + +
+
+
+ + + + +
Loading...
+
+]; +} + sub display_global_log { my ( $nt_obj, $q, $user, $duser, $message ) = @_; diff --git a/client/htdocs/webauthn.cgi b/client/htdocs/webauthn.cgi new file mode 100755 index 00000000..ef6a356c --- /dev/null +++ b/client/htdocs/webauthn.cgi @@ -0,0 +1,139 @@ +#!/usr/bin/perl +# +# WebAuthn JSON API proxy for NicToolClient +# +# Accepts JSON POST requests, validates CSRF, forwards to +# NicToolServer via SOAP, and returns JSON responses. +# + +use strict; +use warnings; + +use JSON; + +require 'nictoolclient.conf'; + +main(); + +sub main { + my $q = new CGI(); + my $nt_obj = new NicToolClient($q); + + return if $nt_obj->check_setup ne 'OK'; + + # Only accept POST with JSON content + if ( $q->request_method() ne 'POST' ) { + send_json( 405, { error_code => 405, error_msg => 'Method not allowed' } ); + return; + } + + my $body = $q->param('POSTDATA') || $q->param('keywords') || ''; + if ( !$body ) { + + # Try reading raw stdin for JSON + local $/; + $body = if !$body; + } + + my $request; + eval { $request = decode_json($body); }; + if ( $@ || !$request ) { + send_json( 400, { error_code => 400, error_msg => 'Invalid JSON' } ); + return; + } + + my $action = $request->{action} || ''; + my $data = $request->{data} || {}; + + # CSRF validation + my $csrf_token = $request->{csrf_token} || ''; + my $cookie_csrf = $q->cookie('NicTool_csrf') || ''; + if ( !$csrf_token || !$cookie_csrf || $csrf_token ne $cookie_csrf ) { + send_json( 403, { error_code => 403, error_msg => 'CSRF validation failed' } ); + return; + } + + # Whitelist of allowed actions — reject anything else + my %allowed = map { $_ => 1 } qw( + webauthn_get_registration_options + webauthn_verify_registration + webauthn_get_user_credentials + webauthn_revoke_credential + webauthn_rename_credential + webauthn_get_auth_options + webauthn_verify_auth + ); + + if ( !$allowed{$action} ) { + send_json( 400, { error_code => 400, error_msg => 'Unknown action' } ); + return; + } + + # Pre-session actions (no session cookie required) + my %no_session = map { $_ => 1 } qw( + webauthn_get_auth_options + webauthn_verify_auth + ); + + my %params = ( action => $action ); + + if ( !$no_session{$action} ) { + + # Authenticated actions need session cookie + my $cookie = $q->cookie('NicTool'); + if ( !$cookie ) { + send_json( 403, { error_code => 403, error_msg => 'Not authenticated' } ); + return; + } + $params{nt_user_session} = $cookie; + } + + # Merge request data into params, skipping reserved keys + for my $key ( keys %$data ) { + next if $key eq 'action' || $key eq 'nt_user_session'; + $params{$key} = $data->{$key}; + } + + my $response = $nt_obj->{nt_server_obj}->send_request(%params); + + if ( !ref $response ) { + send_json( 500, { error_code => 500, error_msg => $response || 'Server error' } ); + return; + } + + # For passkey login success, set the session cookie + if ( $action eq 'webauthn_verify_auth' + && $response->{nt_user_session} ) + { + my $session = $response->{nt_user_session}; + $session =~ s/[^\x20-\x7E]//g; # strip control chars + my $secure = ( $ENV{HTTPS} || '' ) eq 'on' ? 1 : 0; + print $q->header( + -type => 'application/json', + -charset => 'utf-8', + -cookie => CGI::cookie( + -name => 'NicTool', + -value => $session, + -path => '/', + -httponly => 1, + -secure => $secure, + -samesite => 'Strict', + ), + ); + print encode_json($response); + return; + } + + send_json( 200, $response ); +} + +sub send_json { + my ( $status, $data ) = @_; + print CGI->new->header( + -type => 'application/json', + -status => $status, + ); + print encode_json($data); +} + +1; diff --git a/client/t/01-syntax.t b/client/t/01-syntax.t index 3cfb5121..0ac58184 100644 --- a/client/t/01-syntax.t +++ b/client/t/01-syntax.t @@ -2,7 +2,7 @@ use Config 'myconfig'; use Data::Dumper; use English '-no_match_vars'; -use Test::More tests => 24; +use Test::More tests => 25; use lib 'lib'; diff --git a/client/t/e2e/helpers.ts b/client/t/e2e/helpers.ts index 48f739aa..1d2d68c1 100644 --- a/client/t/e2e/helpers.ts +++ b/client/t/e2e/helpers.ts @@ -290,6 +290,88 @@ export async function deleteNameserver(playwright: any, cookies: string, gid: st await authGet(playwright, `${BASE}/group_nameservers.cgi?nt_group_id=${gid}&delete=1&nt_nameserver_id=${nsid}&csrf_token=${extractCsrf(cookies)}`, cookies); } +// --------------------------------------------------------------------------- +// WebAuthn Helpers +// --------------------------------------------------------------------------- + +export async function webauthnPost( + playwright: any, action: string, data: Record, + csrfCookie: string, sessionCookie?: string +): Promise<{ res: any; json: any }> { + const ctx = await freshCtx(playwright); + let cookie = `NicTool_csrf=${csrfCookie}`; + if (sessionCookie) cookie = `NicTool=${sessionCookie}; ${cookie}`; + + const res = await ctx.post(`${BASE}/webauthn.cgi`, { + headers: { 'Content-Type': 'application/json', Cookie: cookie }, + data: JSON.stringify({ action, csrf_token: csrfCookie, data }), + }); + + const text = await res.text(); + let json: any; + try { json = JSON.parse(text); } catch { json = { error_code: 500, error_msg: text }; } + await ctx.dispose(); + return { res, json }; +} + +export async function listPasskeys( + playwright: any, cookies: string, ntUserId: string | number +): Promise { + const csrf = extractCsrf(cookies); + const session = cookies.match(/NicTool=([^;]+)/)?.[1] || ''; + const { json } = await webauthnPost( + playwright, 'webauthn_get_user_credentials', + { nt_user_id: ntUserId }, csrf, session + ); + return json.credentials || []; +} + +export async function revokePasskey( + playwright: any, cookies: string, + ntUserId: string | number, credentialDbId: string | number +): Promise { + const csrf = extractCsrf(cookies); + const session = cookies.match(/NicTool=([^;]+)/)?.[1] || ''; + await webauthnPost( + playwright, 'webauthn_revoke_credential', + { nt_user_id: ntUserId, nt_webauthn_credential_id: credentialDbId }, + csrf, session + ); +} + +export async function revokeAllPasskeys( + playwright: any, cookies: string, ntUserId: string | number +): Promise { + const creds = await listPasskeys(playwright, cookies, ntUserId); + for (const c of creds) { + await revokePasskey(playwright, cookies, ntUserId, c.nt_webauthn_credential_id); + } +} + +export async function setupVirtualAuthenticator(page: Page) { + const cdpSession = await page.context().newCDPSession(page); + await cdpSession.send('WebAuthn.enable'); + const result = await cdpSession.send('WebAuthn.addVirtualAuthenticator', { + options: { + protocol: 'ctap2', + transport: 'internal', + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + }, + }); + return { cdpSession, authenticatorId: result.authenticatorId }; +} + +export async function teardownVirtualAuthenticator( + cdpSession: any, authenticatorId: string +): Promise { + try { + await cdpSession.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId }); + await cdpSession.send('WebAuthn.disable'); + } catch { /* page may already be closed */ } +} + // --------------------------------------------------------------------------- // Internal Utilities // --------------------------------------------------------------------------- diff --git a/client/t/e2e/webauthn.spec.ts b/client/t/e2e/webauthn.spec.ts new file mode 100644 index 00000000..1e8d990c --- /dev/null +++ b/client/t/e2e/webauthn.spec.ts @@ -0,0 +1,577 @@ +import { test, expect } from '@playwright/test'; +import { + BASE, USERNAME, PASSWORD, + freshCtx, getLoginCsrf, apiLogin, cookieString, extractCsrf, + webauthnPost, listPasskeys, revokeAllPasskeys, + browserLogin, setupVirtualAuthenticator, teardownVirtualAuthenticator, +} from './helpers'; + +// Base64url helpers injected into browser context via page.addScriptTag. +// page.evaluate callbacks run in an isolated browser scope and cannot import +// Node modules, so we inject these once per page instead of duplicating them +// inside every evaluate call. +const B64U_HELPERS = ` +function b64u2buf(s) { + var b = s.replace(/-/g, '+').replace(/_/g, '/'); + while (b.length % 4) b += '='; + var r = atob(b); + var a = new Uint8Array(r.length); + for (var i = 0; i < r.length; i++) a[i] = r.charCodeAt(i); + return a.buffer; +} +function buf2b64u(b) { + var u = new Uint8Array(b); + var s = ''; + for (var i = 0; i < u.length; i++) s += String.fromCharCode(u[i]); + return btoa(s).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, ''); +} +`; + +/** Inject b64u helpers into every frame of the page. */ +async function injectB64UHelpers(page: import('@playwright/test').Page) { + await page.addScriptTag({ content: B64U_HELPERS }); + for (const frame of page.frames()) { + try { await frame.addScriptTag({ content: B64U_HELPERS }); } + catch { /* frame may not accept scripts */ } + } +} + +/** Wait for the NicTool frameset to finish loading after login. */ +async function waitForFrameset(page: import('@playwright/test').Page) { + await page.waitForFunction( + () => window.frames.length > 0, + { timeout: 10000 }, + ); +} + +/** Wait for the login page to be ready after navigation. */ +async function waitForLoginPage(page: import('@playwright/test').Page) { + await page.waitForSelector( + 'input[name="username"]', + { timeout: 10000 }, + ); +} + +// ------------------------------------------------------------------------- +// W1: CSRF protection on webauthn.cgi +// ------------------------------------------------------------------------- +test.describe('W1: WebAuthn CSRF protection', () => { + test('POST with wrong csrf_token rejected', async ({ playwright }) => { + const ctx = await freshCtx(playwright); + const { csrfCookie } = await getLoginCsrf(ctx); + + const res = await ctx.post(`${BASE}/webauthn.cgi`, { + headers: { + 'Content-Type': 'application/json', + Cookie: `NicTool_csrf=${csrfCookie}`, + }, + data: JSON.stringify({ + action: 'webauthn_get_auth_options', + csrf_token: '0000000000000000000000000000000000000000', + data: {}, + }), + }); + + const json = JSON.parse(await res.text()); + expect(json.error_code).toBe(403); + expect(json.error_msg).toContain('CSRF'); + await ctx.dispose(); + }); + + test('POST with missing csrf_token rejected', async ({ playwright }) => { + const ctx = await freshCtx(playwright); + const { csrfCookie } = await getLoginCsrf(ctx); + + const res = await ctx.post(`${BASE}/webauthn.cgi`, { + headers: { + 'Content-Type': 'application/json', + Cookie: `NicTool_csrf=${csrfCookie}`, + }, + data: JSON.stringify({ + action: 'webauthn_get_auth_options', + data: {}, + }), + }); + + const json = JSON.parse(await res.text()); + expect(json.error_code).toBe(403); + await ctx.dispose(); + }); + + test('GET request rejected with 405', async ({ playwright }) => { + const ctx = await freshCtx(playwright); + const res = await ctx.get(`${BASE}/webauthn.cgi`); + const json = JSON.parse(await res.text()); + expect(json.error_code).toBe(405); + await ctx.dispose(); + }); +}); + +// ------------------------------------------------------------------------- +// W2: Authenticated endpoints require session +// ------------------------------------------------------------------------- +test.describe('W2: WebAuthn session requirement', () => { + test('get_user_credentials without session returns 403', async ({ playwright }) => { + const ctx = await freshCtx(playwright); + const { csrfCookie } = await getLoginCsrf(ctx); + await ctx.dispose(); + + const { json } = await webauthnPost( + playwright, 'webauthn_get_user_credentials', + { nt_user_id: 1 }, csrfCookie + ); + expect(json.error_code).toBe(403); + }); +}); + +// ------------------------------------------------------------------------- +// W3: Login page passkey button +// ------------------------------------------------------------------------- +test.describe('W3: Login page passkey UI', () => { + test('passkey button visible when WebAuthn supported', async ({ page }) => { + await page.goto(`${BASE}/index.cgi`); + const btn = page.locator('#nt-passkey-login'); + await expect(btn).toBeVisible(); + }); + + test('passkey button clickable without entering username', async ({ page }) => { + await page.goto(`${BASE}/index.cgi`); + // Should not show an alert when username is empty (after fix) + const btn = page.locator('#nt-passkey-login'); + await expect(btn).toBeVisible(); + // We can't complete the ceremony without a virtual authenticator, + // but we verify no "enter username" alert fires + page.on('dialog', (dialog) => { + // If we get a "Please enter your username" alert, the fix failed + expect(dialog.message()).not.toContain('username'); + dialog.dismiss(); + }); + await btn.click(); + // Give a moment for any alert/dialog to fire + await page.waitForTimeout(500); + }); +}); + +// ------------------------------------------------------------------------- +// W4-W7: Full WebAuthn ceremonies (virtual authenticator) +// ------------------------------------------------------------------------- +test.describe('W4-W7: WebAuthn ceremonies', () => { + // These tests probe the server first; skip all if WebAuthn is not configured + let webauthnConfigured = false; + + test.beforeAll(async ({ playwright }) => { + const { sessionCookie, csrfCookie } = await apiLogin(playwright); + const { json } = await webauthnPost( + playwright, 'webauthn_get_registration_options', + { nt_user_id: 1 }, csrfCookie, sessionCookie + ); + webauthnConfigured = (+json.error_code === 200); + }); + + test('W4: register passkey via virtual authenticator', async ({ page }) => { + test.skip(!webauthnConfigured, 'WebAuthn not configured on server'); + + const { cdpSession, authenticatorId } = await setupVirtualAuthenticator(page); + try { + await browserLogin(page); + await waitForFrameset(page); + + const bodyFrame = page.frames().find(f => + f.url().includes('group.cgi')); + expect(bodyFrame).toBeTruthy(); + + await injectB64UHelpers(page); + + // Run registration ceremony inside the frame's browser context + const result = await bodyFrame!.evaluate(async (baseUrl: string) => { + const csrf = document.cookie.match(/NicTool_csrf=([^;]+)/)?.[1] || ''; + + // Step 1: get options + const optRes = await fetch(`${baseUrl}/webauthn.cgi`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'webauthn_get_registration_options', + csrf_token: csrf, + data: { nt_user_id: '1' }, + }), + }); + const optData = await optRes.json(); + if (+optData.error_code !== 200) + return { ok: false, error: optData.error_msg }; + + const opts = JSON.parse(optData.options); + const pkOpts: any = { + challenge: b64u2buf(opts.challenge), + rp: opts.rp, + user: { ...opts.user, id: b64u2buf(opts.user.id) }, + pubKeyCredParams: opts.pubKeyCredParams, + timeout: opts.timeout, + attestation: opts.attestation || 'none', + authenticatorSelection: opts.authenticatorSelection, + }; + if (opts.excludeCredentials) { + pkOpts.excludeCredentials = opts.excludeCredentials.map( + (c: any) => ({ ...c, id: b64u2buf(c.id) }) + ); + } + + // Step 2: create credential (virtual authenticator intercepts) + const cred = (await navigator.credentials.create({ + publicKey: pkOpts, + })) as PublicKeyCredential; + const resp = cred.response as AuthenticatorAttestationResponse; + const transports = resp.getTransports ? resp.getTransports() : []; + + // Step 3: verify registration + const verRes = await fetch(`${baseUrl}/webauthn.cgi`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'webauthn_verify_registration', + csrf_token: csrf, + data: { + nt_user_id: '1', + challenge_b64: buf2b64u(pkOpts.challenge), + client_data_json_b64: buf2b64u(resp.clientDataJSON), + attestation_object_b64: buf2b64u(resp.attestationObject), + friendly_name: 'E2E Virtual Passkey', + transports: transports.join(','), + }, + }), + }); + const verData = await verRes.json(); + return { + ok: +verData.error_code === 200, + error: verData.error_msg, + credential_id: verData.credential_id, + }; + }, BASE); + + expect(result.ok).toBe(true); + expect(result.credential_id).toBeTruthy(); + } finally { + // Cleanup: revoke all passkeys for root + const { sessionCookie, csrfCookie } = await apiLogin(page.context().request); + const cookies = cookieString(sessionCookie, csrfCookie); + await revokeAllPasskeys(page.context().request, cookies, 1); + await teardownVirtualAuthenticator(cdpSession, authenticatorId); + } + }); + + test('W5: passkey login without username (usernameless)', async ({ page }) => { + test.skip(!webauthnConfigured, 'WebAuthn not configured on server'); + + const { cdpSession, authenticatorId } = await setupVirtualAuthenticator(page); + try { + // --- Phase 1: Register a passkey while logged in --- + await browserLogin(page); + await waitForFrameset(page); + + const bodyFrame = page.frames().find(f => + f.url().includes('group.cgi')); + expect(bodyFrame).toBeTruthy(); + + await injectB64UHelpers(page); + + const regResult = await bodyFrame!.evaluate(async (baseUrl: string) => { + const csrf = document.cookie.match(/NicTool_csrf=([^;]+)/)?.[1] || ''; + const optRes = await fetch(`${baseUrl}/webauthn.cgi`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'webauthn_get_registration_options', + csrf_token: csrf, + data: { nt_user_id: '1' }, + }), + }); + const optData = await optRes.json(); + if (+optData.error_code !== 200) return { ok: false, error: optData.error_msg }; + + const opts = JSON.parse(optData.options); + const pkOpts: any = { + challenge: b64u2buf(opts.challenge), + rp: opts.rp, + user: { ...opts.user, id: b64u2buf(opts.user.id) }, + pubKeyCredParams: opts.pubKeyCredParams, + timeout: opts.timeout, + attestation: opts.attestation || 'none', + authenticatorSelection: opts.authenticatorSelection, + }; + if (opts.excludeCredentials) + pkOpts.excludeCredentials = opts.excludeCredentials.map( + (c: any) => ({ ...c, id: b64u2buf(c.id) })); + + const cred = (await navigator.credentials.create({ + publicKey: pkOpts, + })) as PublicKeyCredential; + const resp = cred.response as AuthenticatorAttestationResponse; + const transports = resp.getTransports ? resp.getTransports() : []; + + const verRes = await fetch(`${baseUrl}/webauthn.cgi`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'webauthn_verify_registration', + csrf_token: csrf, + data: { + nt_user_id: '1', + challenge_b64: buf2b64u(pkOpts.challenge), + client_data_json_b64: buf2b64u(resp.clientDataJSON), + attestation_object_b64: buf2b64u(resp.attestationObject), + friendly_name: 'E2E Login Test Key', + transports: transports.join(','), + }, + }), + }); + const verData = await verRes.json(); + return { ok: +verData.error_code === 200, error: verData.error_msg }; + }, BASE); + expect(regResult.ok).toBe(true); + + // --- Phase 2: Logout --- + const cookies = await page.context().cookies(); + const sessionCk = cookies.find(c => c.name === 'NicTool')?.value || ''; + const csrfCk = cookies.find(c => c.name === 'NicTool_csrf')?.value || ''; + await page.goto(`${BASE}/index.cgi?logout=1`, { + headers: { Cookie: `NicTool=${sessionCk}; NicTool_csrf=${csrfCk}` }, + }); + await waitForLoginPage(page); + + // --- Phase 3: Passkey login without username --- + await page.goto(`${BASE}/index.cgi`); + await waitForLoginPage(page); + + await injectB64UHelpers(page); + + const loginResult = await page.evaluate(async (baseUrl: string) => { + const csrf = document.cookie.match(/NicTool_csrf=([^;]+)/)?.[1] + || document.querySelector('input[name="csrf_token"]')?.value + || ''; + + // Step 1: get auth options (no username = usernameless) + const optRes = await fetch(`${baseUrl}/webauthn.cgi`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'webauthn_get_auth_options', + csrf_token: csrf, + data: {}, + }), + }); + const optData = await optRes.json(); + if (+optData.error_code !== 200) + return { ok: false, error: optData.error_msg }; + + const opts = JSON.parse(optData.options); + const pkOpts: any = { + challenge: b64u2buf(opts.challenge), + rpId: opts.rpId, + timeout: opts.timeout, + userVerification: opts.userVerification, + }; + // allowCredentials should be empty for usernameless + if (opts.allowCredentials && opts.allowCredentials.length > 0) { + pkOpts.allowCredentials = opts.allowCredentials.map( + (c: any) => ({ ...c, id: b64u2buf(c.id) })); + } + + // Step 2: get assertion (virtual authenticator picks discoverable cred) + const assertion = (await navigator.credentials.get({ + publicKey: pkOpts, + })) as PublicKeyCredential; + const resp = assertion.response as AuthenticatorAssertionResponse; + + // Step 3: verify auth + const verRes = await fetch(`${baseUrl}/webauthn.cgi`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'webauthn_verify_auth', + csrf_token: csrf, + data: { + challenge_b64: buf2b64u(pkOpts.challenge), + credential_id_b64: buf2b64u(assertion.rawId), + client_data_json_b64: buf2b64u(resp.clientDataJSON), + authenticator_data_b64: buf2b64u(resp.authenticatorData), + signature_b64: buf2b64u(resp.signature), + }, + }), + }); + const verData = await verRes.json(); + return { + ok: +verData.error_code === 200, + error: verData.error_msg, + hasSession: !!verData.nt_user_session, + }; + }, BASE); + + expect(loginResult.ok).toBe(true); + expect(loginResult.hasSession).toBe(true); + } finally { + // Cleanup + try { + const { sessionCookie, csrfCookie } = await apiLogin(page.context().request); + const ck = cookieString(sessionCookie, csrfCookie); + await revokeAllPasskeys(page.context().request, ck, 1); + } catch { /* best effort */ } + await teardownVirtualAuthenticator(cdpSession, authenticatorId); + } + }); + + test('W6: credential list', async ({ playwright }) => { + test.skip(!webauthnConfigured, 'WebAuthn not configured on server'); + + const { sessionCookie, csrfCookie } = await apiLogin(playwright); + const cookies = cookieString(sessionCookie, csrfCookie); + + // Verify list endpoint works (may be empty) + const creds = await listPasskeys(playwright, cookies, 1); + expect(Array.isArray(creds)).toBe(true); + }); + + test('W7: revoked credential rejected at login', async ({ page }) => { + test.skip(!webauthnConfigured, 'WebAuthn not configured on server'); + + const { cdpSession, authenticatorId } = await setupVirtualAuthenticator(page); + try { + // Register a passkey + await browserLogin(page); + await waitForFrameset(page); + const bodyFrame = page.frames().find(f => + f.url().includes('group.cgi')); + expect(bodyFrame).toBeTruthy(); + + await injectB64UHelpers(page); + + const regResult = await bodyFrame!.evaluate(async (baseUrl: string) => { + const csrf = document.cookie.match(/NicTool_csrf=([^;]+)/)?.[1] || ''; + const optRes = await fetch(`${baseUrl}/webauthn.cgi`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'webauthn_get_registration_options', + csrf_token: csrf, + data: { nt_user_id: '1' }, + }), + }); + const optData = await optRes.json(); + if (+optData.error_code !== 200) return { ok: false, error: optData.error_msg }; + const opts = JSON.parse(optData.options); + const pkOpts: any = { + challenge: b64u2buf(opts.challenge), + rp: opts.rp, + user: { ...opts.user, id: b64u2buf(opts.user.id) }, + pubKeyCredParams: opts.pubKeyCredParams, + timeout: opts.timeout, + attestation: opts.attestation || 'none', + authenticatorSelection: opts.authenticatorSelection, + }; + if (opts.excludeCredentials) + pkOpts.excludeCredentials = opts.excludeCredentials.map( + (c: any) => ({ ...c, id: b64u2buf(c.id) })); + const cred = (await navigator.credentials.create({ + publicKey: pkOpts, + })) as PublicKeyCredential; + const resp = cred.response as AuthenticatorAttestationResponse; + const verRes = await fetch(`${baseUrl}/webauthn.cgi`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'webauthn_verify_registration', + csrf_token: csrf, + data: { + nt_user_id: '1', + challenge_b64: buf2b64u(pkOpts.challenge), + client_data_json_b64: buf2b64u(resp.clientDataJSON), + attestation_object_b64: buf2b64u(resp.attestationObject), + friendly_name: 'E2E Revoke Test', + transports: '', + }, + }), + }); + const verData = await verRes.json(); + return { ok: +verData.error_code === 200, credential_id: verData.credential_id }; + }, BASE); + expect(regResult.ok).toBe(true); + + // Revoke all credentials via API + const { sessionCookie, csrfCookie } = await apiLogin(page.context().request); + const cookies = cookieString(sessionCookie, csrfCookie); + await revokeAllPasskeys(page.context().request, cookies, 1); + + // Logout + await page.goto(`${BASE}/index.cgi?logout=1`); + await waitForLoginPage(page); + + // Try passkey login -- should fail because credential is revoked + // (The virtual authenticator still has the key, but server rejects it) + await page.goto(`${BASE}/index.cgi`); + await waitForLoginPage(page); + + await injectB64UHelpers(page); + + const loginResult = await page.evaluate(async (baseUrl: string) => { + const csrf = document.cookie.match(/NicTool_csrf=([^;]+)/)?.[1] + || document.querySelector('input[name="csrf_token"]')?.value + || ''; + + // Get auth options (usernameless — empty allowCredentials) + const optRes = await fetch(`${baseUrl}/webauthn.cgi`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'webauthn_get_auth_options', + csrf_token: csrf, + data: {}, + }), + }); + const optData = await optRes.json(); + if (+optData.error_code !== 200) + return { ok: false, error: optData.error_msg }; + + const opts = JSON.parse(optData.options); + const pkOpts: any = { + challenge: b64u2buf(opts.challenge), + rpId: opts.rpId, + timeout: opts.timeout, + userVerification: opts.userVerification, + }; + + let assertion: PublicKeyCredential; + try { + assertion = (await navigator.credentials.get({ + publicKey: pkOpts, + })) as PublicKeyCredential; + } catch { + // Virtual authenticator may refuse if no matching cred + return { ok: false, error: 'no credential available' }; + } + const resp = assertion.response as AuthenticatorAssertionResponse; + + const verRes = await fetch(`${baseUrl}/webauthn.cgi`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'webauthn_verify_auth', + csrf_token: csrf, + data: { + challenge_b64: buf2b64u(pkOpts.challenge), + credential_id_b64: buf2b64u(assertion.rawId), + client_data_json_b64: buf2b64u(resp.clientDataJSON), + authenticator_data_b64: buf2b64u(resp.authenticatorData), + signature_b64: buf2b64u(resp.signature), + }, + }), + }); + const verData = await verRes.json(); + return { ok: +verData.error_code === 200, error: verData.error_msg }; + }, BASE); + + // Login should fail — credential was revoked server-side + expect(loginResult.ok).toBe(false); + } finally { + await teardownVirtualAuthenticator(cdpSession, authenticatorId); + } + }); +}); diff --git a/client/templates/login.html b/client/templates/login.html index 825675a0..bec212ce 100644 --- a/client/templates/login.html +++ b/client/templates/login.html @@ -4,6 +4,8 @@ {{app_title}} + + @@ -27,6 +29,9 @@ {{CSRF_TOKEN_FIELD}} + @@ -38,5 +43,31 @@ + diff --git a/dist/docker/Dockerfile b/dist/docker/Dockerfile index dcf93ef5..47cf4798 100644 --- a/dist/docker/Dockerfile +++ b/dist/docker/Dockerfile @@ -64,7 +64,7 @@ RUN cd /tmp/nictool/server && perl Makefile.PL && cpanm -n . \ RUN cpanm --notest \ CryptX Crypt::Mac::HMAC Crypt::KeyDerivation \ Test::HTML::Lint Time::TAI64 DBD::MariaDB \ - Net::LDAP Test::Output + Net::LDAP Test::Output Authen::WebAuthn # Set up Apache modules RUN a2dismod mpm_event && a2enmod mpm_prefork && a2enmod ssl \ diff --git a/dist/setup/apache.conf.in b/dist/setup/apache.conf.in index a96c3e2d..2f3ab1f9 100644 --- a/dist/setup/apache.conf.in +++ b/dist/setup/apache.conf.in @@ -20,6 +20,8 @@ PerlRequire "%%NT_DIR%%/client/lib/nictoolclient.conf" Alias /images/ "%%NT_DIR%%/client/htdocs/images/" DocumentRoot "%%NT_DIR%%/client/htdocs" DirectoryIndex index.cgi + ErrorLog ${APACHE_LOG_DIR}/client_error.log + CustomLog ${APACHE_LOG_DIR}/client_access.log combined SetHandler perl-script @@ -67,6 +69,9 @@ PerlRequire "%%NT_DIR%%/server/lib/nictoolserver.conf" KeepAlive Off + LogLevel info + ErrorLog ${APACHE_LOG_DIR}/server_error.log + CustomLog ${APACHE_LOG_DIR}/server_access.log combined SetHandler perl-script PerlResponseHandler NicToolServer diff --git a/server/Makefile.PL b/server/Makefile.PL index 43cb5390..e7e1b30c 100644 --- a/server/Makefile.PL +++ b/server/Makefile.PL @@ -14,6 +14,7 @@ my %WriteMakefileArgs = ( "NAME" => "NicToolServer", "PREREQ_PM" => { "Apache::DBI" => 0, + "Authen::WebAuthn" => 0, "BIND::Conf_Parser" => 0, "CGI" => "4.21", "Crypt::KeyDerivation" => 0, @@ -57,13 +58,14 @@ my %WriteMakefileArgs = ( "Test::More" => 0, "Test::Output" => 0 }, - "VERSION" => "2.40", + "VERSION" => "2.44", "test" => { "TESTS" => "t/*.t" } ); my %FallbackPrereqs = ( "APR::Table" => 0, "Apache::DBI" => 0, + "Authen::WebAuthn" => 0, "BIND::Conf_Parser" => 0, "CGI" => 3, "Crypt::KeyDerivation" => 0, diff --git a/server/lib/NicToolServer.pm b/server/lib/NicToolServer.pm index db009fc5..e1c6247b 100755 --- a/server/lib/NicToolServer.pm +++ b/server/lib/NicToolServer.pm @@ -9,7 +9,7 @@ use RPC::XML; use Data::Dumper; use Net::IP; -$NicToolServer::VERSION = '2.40'; +$NicToolServer::VERSION = '2.44'; $NicToolServer::MIN_PROTOCOL_VERSION = '1.0'; $NicToolServer::MAX_PROTOCOL_VERSION = '1.0'; @@ -57,7 +57,9 @@ sub handler { return $response_obj->respond( $client_obj->data()->{user} ) if ( $action eq 'LOGIN' or $action eq 'VERIFY_SESSION' - or $action eq 'LOGOUT' ); + or $action eq 'LOGOUT' + or $action eq 'WEBAUTHN_GET_AUTH_OPTIONS' + or $action eq 'WEBAUTHN_VERIFY_AUTH' ); $self->{user} = $client_obj->data()->{user}; @@ -705,6 +707,43 @@ sub api_commands { 'nt_zone_record_id' => { access => 'read', required => 1, type => 'ZONERECORD' }, }, }, + + # WebAuthn API + 'webauthn_get_registration_options' => { + 'class' => 'WebAuthn', + 'method' => 'generate_registration_options', + 'parameters' => { + 'nt_user_id' => { access => 'read', required => 1, type => 'USER' }, + }, + }, + 'webauthn_verify_registration' => { + 'class' => 'WebAuthn', + 'method' => 'verify_registration', + 'parameters' => { + 'nt_user_id' => { access => 'write', required => 1, type => 'USER' }, + }, + }, + 'webauthn_get_user_credentials' => { + 'class' => 'WebAuthn', + 'method' => 'get_user_credentials', + 'parameters' => { + 'nt_user_id' => { access => 'read', required => 1, type => 'USER' }, + }, + }, + 'webauthn_revoke_credential' => { + 'class' => 'WebAuthn', + 'method' => 'revoke_credential', + 'parameters' => { + 'nt_user_id' => { access => 'write', required => 1, type => 'USER' }, + }, + }, + 'webauthn_rename_credential' => { + 'class' => 'WebAuthn', + 'method' => 'rename_credential', + 'parameters' => { + 'nt_user_id' => { access => 'write', required => 1, type => 'USER' }, + }, + }, }; } diff --git a/server/lib/NicToolServer/SOAP.pm b/server/lib/NicToolServer/SOAP.pm index 0aecb77d..bdd82666 100644 --- a/server/lib/NicToolServer/SOAP.pm +++ b/server/lib/NicToolServer/SOAP.pm @@ -26,7 +26,9 @@ sub _dispatch { #warn "action is ".uc($action); if ( uc($action) eq 'LOGIN' or uc($action) eq 'VERIFY_SESSION' - or uc($action) eq 'LOGOUT' ) + or uc($action) eq 'LOGOUT' + or uc($action) eq 'WEBAUTHN_GET_AUTH_OPTIONS' + or uc($action) eq 'WEBAUTHN_VERIFY_AUTH' ) { my $h = $data->{user}; $h->{password} = '' if exists $h->{password}; diff --git a/server/lib/NicToolServer/Session.pm b/server/lib/NicToolServer/Session.pm index 45c5a7cc..2c627d8a 100644 --- a/server/lib/NicToolServer/Session.pm +++ b/server/lib/NicToolServer/Session.pm @@ -29,6 +29,10 @@ sub verify { #warn "action is ".$data->{action}; return $self->verify_login if $data->{action} eq 'LOGIN'; + return $self->webauthn_get_auth_options + if $data->{action} eq 'WEBAUTHN_GET_AUTH_OPTIONS'; + return $self->webauthn_verify_auth + if $data->{action} eq 'WEBAUTHN_VERIFY_AUTH'; } return $self->verify_session; # just verify the session } @@ -62,20 +66,73 @@ sub verify_login { ); $self->maybe_upgrade_password_hash( - $user->{nt_user_id}, - $data->{username}, - $pass_attempt, - $user->{password}, - $user->{pass_salt}, + $user->{nt_user_id}, $data->{username}, $pass_attempt, + $user->{password}, $user->{pass_salt}, ); + return $self->_create_session_for_user( $user, 'login' ); +} + +sub webauthn_get_auth_options { + my $self = shift; + + my $data = $self->{client}->data(); + + require NicToolServer::WebAuthn; + my $wa = NicToolServer::WebAuthn->new( $self->{Apache}, $self->{client}, $self->{dbh} ); + + my $result = $wa->generate_authentication_options($data); + + # Store result in user hash so the dispatcher can return it + $data->{user} = $result; + return 0; +} + +sub webauthn_verify_auth { + my $self = shift; + + $self->timeout_sessions; + + my $data = $self->{client}->data(); + my $error_msg = 'Authentication failed.'; + + require NicToolServer::WebAuthn; + my $wa = NicToolServer::WebAuthn->new( $self->{Apache}, $self->{client}, $self->{dbh} ); + + my $result = $wa->verify_authentication($data); + + if ( !$result || $result->{error_code} != 200 ) { + return $result || $self->auth_error($error_msg); + } + + # Authentication succeeded — use the verified username for session + $data->{username} = $result->{username}; + + return $self->auth_error($error_msg) + if !$self->populate_groups; + + my ( $err, $user ) = $self->_get_user( $data->{username}, $data->{groups}, $error_msg ); + return $err if $err; + + # Remove password from data hash (not used for passkey auth) + delete $data->{password}; + $data->{user} = $user; + + return $self->_create_session_for_user( $user, 'passkey_login' ); +} + +sub _create_session_for_user { + my ( $self, $user, $auth_method ) = @_; + + my $data = $self->{client}->data(); + $self->clean_user_data; $user->{nt_user_session} = $self->session_id; my $uid = $user->{nt_user_id}; - my ( $user_perm, $groupperm ); + my ( $err, $user_perm, $groupperm ); ( $err, $user_perm ) = $self->_get_user_perms($uid); return $err if $err; ( $err, $groupperm ) = $self->_get_group_perms($uid); @@ -109,7 +166,7 @@ sub verify_login { [ $uid, $session, time() ] ) or return; - $self->_insert_session_log( $session_id, $uid, $session, 'login' ); + $self->_insert_session_log( $session_id, $uid, $session, $auth_method ); return 0; } diff --git a/server/lib/NicToolServer/WebAuthn.pm b/server/lib/NicToolServer/WebAuthn.pm new file mode 100644 index 00000000..3a4082a7 --- /dev/null +++ b/server/lib/NicToolServer/WebAuthn.pm @@ -0,0 +1,544 @@ +package NicToolServer::WebAuthn; + +# ABSTRACT: WebAuthn/passkey credential management for NicTool + +use strict; +use warnings; + +use Authen::WebAuthn; +use JSON; +use MIME::Base64 qw(encode_base64url decode_base64url); + +@NicToolServer::WebAuthn::ISA = 'NicToolServer'; + +my $CHALLENGE_EXPIRY_SECONDS = 300; # 5 minutes + +sub generate_registration_options { + my ( $self, $data ) = @_; + return $self->_disabled_error() if !$self->_is_enabled(); + + my $uid = $data->{nt_user_id} + or return $self->error_response( 301, 'nt_user_id required' ); + + my $rp_id = $self->_get_rp_id(); + my $origin = $self->_get_origin(); + return $self->error_response( 600, + 'WebAuthn not configured: set webauthn_rp_id and ' . 'webauthn_origin in nt_options' ) + if !$rp_id || !$origin; + + my $user = $self->_get_webauthn_user($uid) + or return $self->error_response( 404, 'User not found' ); + + $self->_cleanup_expired_challenges(); + + my $challenge = $self->_generate_challenge(); + my $now = time(); + + $self->exec_query( + 'INSERT INTO nt_user_webauthn_challenge + (nt_user_id, challenge, ceremony_type, created_at, expires_at) + VALUES (??)', + [ $uid, $challenge, 'registration', $now, $now + $CHALLENGE_EXPIRY_SECONDS ] + ); + + my $exclude = $self->_get_active_credential_ids($uid); + + my @exclude_creds; + for my $cred (@$exclude) { + push @exclude_creds, + { + type => 'public-key', + id => $cred->{credential_id}, + }; + } + + my $user_handle = encode_base64url( pack( 'N', $uid ), '' ); + + return { + error_code => 200, + error_msg => 'OK', + options => encode_json( + { challenge => $challenge, + rp => { + name => 'NicTool', + id => $rp_id, + }, + user => { + id => $user_handle, + name => $user->{username}, + displayName => + join( ' ', grep {$_} ( $user->{first_name}, $user->{last_name} ) ) + || $user->{username}, + }, + pubKeyCredParams => [ + { type => 'public-key', alg => -7 }, # ES256 + { type => 'public-key', alg => -257 }, # RS256 + ], + timeout => 60000, + attestation => 'none', + excludeCredentials => \@exclude_creds, + authenticatorSelection => { + residentKey => 'preferred', + userVerification => 'preferred', + }, + } + ), + }; +} + +sub verify_registration { + my ( $self, $data ) = @_; + return $self->_disabled_error() if !$self->_is_enabled(); + + my $uid = $data->{nt_user_id} + or return $self->error_response( 301, 'nt_user_id required' ); + + my $rp_id = $self->_get_rp_id(); + my $origin = $self->_get_origin(); + return $self->error_response( 600, 'WebAuthn not configured' ) + if !$rp_id || !$origin; + + for my $f (qw(challenge_b64 client_data_json_b64 attestation_object_b64)) { + return $self->error_response( 301, "$f required" ) + if !$data->{$f}; + } + + my $challenge_row = $self->_consume_challenge( $data->{challenge_b64}, 'registration', $uid ); + return $self->error_response( 403, 'Invalid or expired challenge' ) + if !$challenge_row; + + my $webauthn = Authen::WebAuthn->new( + rp_id => $rp_id, + origin => $origin, + ); + + my $result; + eval { + $result = $webauthn->validate_registration( + challenge_b64 => $data->{challenge_b64}, + requested_uv => 'preferred', + client_data_json_b64 => $data->{client_data_json_b64}, + attestation_object_b64 => $data->{attestation_object_b64}, + ); + }; + if ($@) { + warn "WebAuthn registration verification failed: $@"; + return $self->error_response( 403, 'Registration verification failed' ); + } + + my $now = time(); + $self->exec_query( + 'INSERT INTO nt_user_webauthn_credential + (nt_user_id, credential_id, credential_pubkey, + signature_count, friendly_name, transports, + created_at) + VALUES (??)', + [ $uid, $result->{credential_id}, + $result->{credential_pubkey}, $result->{signature_count} || 0, + $data->{friendly_name} || undef, $data->{transports} || undef, + $now, + ] + ); + + return { + error_code => 200, + error_msg => 'OK', + credential_id => $result->{credential_id}, + }; +} + +sub get_user_credentials { + my ( $self, $data ) = @_; + return $self->_disabled_error() if !$self->_is_enabled(); + + my $uid = $data->{nt_user_id} + or return $self->error_response( 301, 'nt_user_id required' ); + + my $rows = $self->exec_query( + 'SELECT nt_webauthn_credential_id, credential_id, + friendly_name, transports, created_at, + last_used_at, revoked + FROM nt_user_webauthn_credential + WHERE nt_user_id = ? AND revoked = 0', + $uid + ); + + return { + error_code => 200, + error_msg => 'OK', + credentials => $rows || [], + }; +} + +sub revoke_credential { + my ( $self, $data ) = @_; + return $self->_disabled_error() if !$self->_is_enabled(); + + my $uid = $data->{nt_user_id} + or return $self->error_response( 301, 'nt_user_id required' ); + my $cred_id = $data->{nt_webauthn_credential_id} + or return $self->error_response( 301, 'nt_webauthn_credential_id required' ); + + $self->exec_query( + 'UPDATE nt_user_webauthn_credential + SET revoked = 1 + WHERE nt_webauthn_credential_id = ? + AND nt_user_id = ?', + [ $cred_id, $uid ] + ); + + return { error_code => 200, error_msg => 'OK' }; +} + +sub rename_credential { + my ( $self, $data ) = @_; + return $self->_disabled_error() if !$self->_is_enabled(); + + my $uid = $data->{nt_user_id} + or return $self->error_response( 301, 'nt_user_id required' ); + my $cred_id = $data->{nt_webauthn_credential_id} + or return $self->error_response( 301, 'nt_webauthn_credential_id required' ); + + return $self->error_response( 301, 'friendly_name required' ) + if !defined $data->{friendly_name}; + + $self->exec_query( + 'UPDATE nt_user_webauthn_credential + SET friendly_name = ? + WHERE nt_webauthn_credential_id = ? + AND nt_user_id = ?', + [ $data->{friendly_name}, $cred_id, $uid ] + ); + + return { error_code => 200, error_msg => 'OK' }; +} + +sub generate_authentication_options { + my ( $self, $data ) = @_; + return $self->_disabled_error() if !$self->_is_enabled(); + + my $rp_id = $self->_get_rp_id(); + my $origin = $self->_get_origin(); + return $self->error_response( 600, 'WebAuthn not configured' ) + if !$rp_id || !$origin; + + $self->_cleanup_expired_challenges(); + + my $username = $data->{username}; + my ( $uid, @allow ); + + if ($username) { + my $auth_error = 'Authentication failed.'; + my $users = $self->exec_query( + 'SELECT nt_user_id FROM nt_user + WHERE username = ? AND deleted = 0', + $username + ); + return $self->error_response( 403, $auth_error ) + if !$users || !$users->[0]; + + $uid = $users->[0]{nt_user_id}; + my $creds = $self->_get_active_credential_ids($uid); + + return $self->error_response( 403, $auth_error ) + if !@$creds; + + for my $c (@$creds) { + my $entry = { + type => 'public-key', + id => $c->{credential_id}, + }; + if ( $c->{transports} ) { + $entry->{transports} = + [ split /,/, $c->{transports} ]; + } + push @allow, $entry; + } + } + + # Usernameless flow: uid is undef, allowCredentials is empty + # — the browser shows its resident credential picker + + my $challenge = $self->_generate_challenge(); + my $now = time(); + + $self->exec_query( + 'INSERT INTO nt_user_webauthn_challenge + (nt_user_id, challenge, ceremony_type, + created_at, expires_at) + VALUES (??)', + [ $uid, $challenge, 'authentication', $now, $now + $CHALLENGE_EXPIRY_SECONDS ] + ); + + return { + error_code => 200, + error_msg => 'OK', + options => encode_json( + { challenge => $challenge, + rpId => $rp_id, + timeout => 60000, + allowCredentials => \@allow, + userVerification => 'preferred', + } + ), + }; +} + +sub verify_authentication { + my ( $self, $data ) = @_; + return $self->_disabled_error() if !$self->_is_enabled(); + + my $rp_id = $self->_get_rp_id(); + my $origin = $self->_get_origin(); + return $self->error_response( 600, 'WebAuthn not configured' ) + if !$rp_id || !$origin; + + for my $f ( + qw(challenge_b64 credential_id_b64 client_data_json_b64 + authenticator_data_b64 signature_b64) + ) + { + return $self->error_response( 301, "$f required" ) + if !$data->{$f}; + } + + # Look up the credential + my $creds = $self->exec_query( + 'SELECT c.*, u.nt_user_id, u.username + FROM nt_user_webauthn_credential c + JOIN nt_user u ON c.nt_user_id = u.nt_user_id + WHERE c.credential_id = ? + AND c.revoked = 0 + AND u.deleted = 0', + $data->{credential_id_b64} + ); + return $self->error_response( 403, 'Authentication failed.' ) + if !$creds || !$creds->[0]; + + my $cred = $creds->[0]; + + # Validate userHandle matches credential owner (required for + # usernameless/discoverable credential flow) + if ( defined $data->{user_handle_b64} && length $data->{user_handle_b64} ) { + my $claimed_uid = eval { unpack( 'N', decode_base64url( $data->{user_handle_b64} ) ) }; + return $self->error_response( 403, 'Authentication failed.' ) + if !defined $claimed_uid || $claimed_uid != $cred->{nt_user_id}; + } + + # Try usernameless flow first (challenge stored with NULL uid), + # then fall back to user-bound challenge (username was provided) + my $challenge_row = + $self->_consume_challenge( $data->{challenge_b64}, 'authentication', undef ); + if ( !$challenge_row ) { + $challenge_row = $self->_consume_challenge( $data->{challenge_b64}, + 'authentication', $cred->{nt_user_id} ); + } + return $self->error_response( 403, 'Invalid or expired challenge' ) + if !$challenge_row; + + my $webauthn = Authen::WebAuthn->new( + rp_id => $rp_id, + origin => $origin, + ); + + my $result; + eval { + $result = $webauthn->validate_assertion( + challenge_b64 => $data->{challenge_b64}, + credential_pubkey_b64 => $cred->{credential_pubkey}, + stored_sign_count => $cred->{signature_count}, + requested_uv => 'preferred', + client_data_json_b64 => $data->{client_data_json_b64}, + authenticator_data_b64 => $data->{authenticator_data_b64}, + signature_b64 => $data->{signature_b64}, + ); + }; + if ($@) { + warn "WebAuthn authentication verification failed: $@"; + return $self->error_response( 403, 'Authentication verification failed' ); + } + + my $now = time(); + $self->exec_query( + 'UPDATE nt_user_webauthn_credential + SET signature_count = ?, last_used_at = ? + WHERE nt_webauthn_credential_id = ?', + [ $result->{signature_count}, $now, $cred->{nt_webauthn_credential_id} ] + ); + + return { + error_code => 200, + error_msg => 'OK', + nt_user_id => $cred->{nt_user_id}, + username => $cred->{username}, + }; +} + +### internal helpers + +sub _is_enabled { + my ($self) = @_; + my $val = $self->get_option('webauthn_enabled'); + return 0 if !defined $val; # default: disabled + return $val ? 1 : 0; +} + +sub _disabled_error { + my ($self) = @_; + return $self->error_response( 600, 'WebAuthn is disabled by administrator' ); +} + +sub _get_rp_id { + my $self = shift; + return $self->get_option('webauthn_rp_id'); +} + +sub _get_origin { + my $self = shift; + return $self->get_option('webauthn_origin'); +} + +sub _generate_challenge { + my $self = shift; + + if ( open my $fh, '<:raw', '/dev/urandom' ) { + my $bytes = q{}; + my $read = read( $fh, $bytes, 32 ); + close $fh; + return encode_base64url( $bytes, '' ) + if defined $read && $read == 32; + } + + die "Failed to read /dev/urandom for WebAuthn challenge\n"; +} + +sub _cleanup_expired_challenges { + my $self = shift; + + $self->exec_query( + 'DELETE FROM nt_user_webauthn_challenge + WHERE expires_at < ?', + time() + ); +} + +sub _consume_challenge { + my ( $self, $challenge, $ceremony_type, $uid ) = @_; + + # Atomic UPDATE avoids TOCTOU race: two concurrent requests with the + # same challenge cannot both succeed because only one UPDATE will + # match consumed = 0. + my ( $update_sql, $select_sql, @bind ); + if ( defined $uid ) { + $update_sql = 'UPDATE nt_user_webauthn_challenge + SET consumed = 1 + WHERE challenge = ? + AND ceremony_type = ? + AND nt_user_id = ? + AND consumed = 0 + AND expires_at >= ?'; + $select_sql = 'SELECT * FROM nt_user_webauthn_challenge + WHERE challenge = ? + AND ceremony_type = ? + AND nt_user_id = ? + AND consumed = 1 + ORDER BY nt_webauthn_challenge_id DESC LIMIT 1'; + @bind = ( $challenge, $ceremony_type, $uid, time() ); + } + else { + $update_sql = 'UPDATE nt_user_webauthn_challenge + SET consumed = 1 + WHERE challenge = ? + AND ceremony_type = ? + AND nt_user_id IS NULL + AND consumed = 0 + AND expires_at >= ?'; + $select_sql = 'SELECT * FROM nt_user_webauthn_challenge + WHERE challenge = ? + AND ceremony_type = ? + AND nt_user_id IS NULL + AND consumed = 1 + ORDER BY nt_webauthn_challenge_id DESC LIMIT 1'; + @bind = ( $challenge, $ceremony_type, time() ); + } + + my $affected = $self->exec_query( $update_sql, \@bind ); + + # exec_query returns arrayref for SELECT, but for UPDATE it returns + # the DBI execute() result. If no rows matched, the challenge was + # already consumed, expired, or nonexistent. + return if !$affected || $affected eq '0E0'; + + # Fetch the row we just consumed (for caller to inspect) + my @sel_bind = + defined $uid + ? ( $challenge, $ceremony_type, $uid ) + : ( $challenge, $ceremony_type ); + my $rows = $self->exec_query( $select_sql, \@sel_bind ); + return if !$rows || !$rows->[0]; + return $rows->[0]; +} + +sub _get_webauthn_user { + my ( $self, $uid ) = @_; + + my $users = $self->exec_query( + 'SELECT nt_user_id, username, first_name, last_name + FROM nt_user WHERE nt_user_id = ? AND deleted = 0', + $uid + ); + return if !$users || !$users->[0]; + return $users->[0]; +} + +sub _get_active_credential_ids { + my ( $self, $uid ) = @_; + + return $self->exec_query( + 'SELECT credential_id, transports + FROM nt_user_webauthn_credential + WHERE nt_user_id = ? AND revoked = 0', + $uid + ) || []; +} + +1; + +__END__ + +=pod + +=encoding UTF-8 + +=head1 NAME + +NicToolServer::WebAuthn - WebAuthn/passkey credential management + +=head1 VERSION + +version 2.44 + +=head1 SYNOPSIS + +Provides server-side WebAuthn registration and authentication +for NicTool passkey support. + +=head1 AUTHORS + +=over 4 + +=item * + +Matt Simerson + +=back + +=head1 COPYRIGHT AND LICENSE + +This software is Copyright (c) 2017 by The Network People, Inc. + +This is free software, licensed under: + + The GNU Affero General Public License, Version 3, November 2007 + +=cut diff --git a/server/lib/nictoolserver.conf.dist b/server/lib/nictoolserver.conf.dist index fd8810ab..1e02b815 100644 --- a/server/lib/nictoolserver.conf.dist +++ b/server/lib/nictoolserver.conf.dist @@ -24,6 +24,7 @@ use NicToolServer::User; use NicToolServer::User::Sanity; use NicToolServer::Nameserver; use NicToolServer::Nameserver::Sanity; +use NicToolServer::WebAuthn; BEGIN { # Database configuration — credentials are required env vars diff --git a/server/sql/02_nt_user.sql b/server/sql/02_nt_user.sql index 3c5d8516..7e7875ba 100644 --- a/server/sql/02_nt_user.sql +++ b/server/sql/02_nt_user.sql @@ -66,7 +66,7 @@ DROP TABLE IF EXISTS nt_user_session_log; CREATE TABLE nt_user_session_log( nt_user_session_log_id INT UNSIGNED AUTO_INCREMENT NOT NULL, nt_user_id INT UNSIGNED NOT NULL, - action ENUM('login','logout','timeout') NOT NULL, + action ENUM('login','logout','timeout','passkey_login') NOT NULL, timestamp INT UNSIGNED NOT NULL, nt_user_session_id INT UNSIGNED, nt_user_session VARCHAR(100), diff --git a/server/sql/12_nt_options.sql b/server/sql/12_nt_options.sql index 9f84b8d9..df531364 100644 --- a/server/sql/12_nt_options.sql +++ b/server/sql/12_nt_options.sql @@ -9,7 +9,7 @@ CREATE TABLE nt_options ( ) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; INSERT INTO `nt_options` -VALUES (1,'db_version','2.40'), +VALUES (1,'db_version','2.44'), (2,'session_timeout','45'), (3,'default_group','NicTool') ; diff --git a/server/sql/14_nt_webauthn.sql b/server/sql/14_nt_webauthn.sql new file mode 100644 index 00000000..aca29ff8 --- /dev/null +++ b/server/sql/14_nt_webauthn.sql @@ -0,0 +1,32 @@ + +DROP TABLE IF EXISTS nt_user_webauthn_credential; +CREATE TABLE nt_user_webauthn_credential ( + nt_webauthn_credential_id INT UNSIGNED AUTO_INCREMENT NOT NULL, + nt_user_id INT UNSIGNED NOT NULL, + credential_id VARCHAR(512) NOT NULL, + credential_pubkey TEXT NOT NULL, + signature_count INT UNSIGNED NOT NULL DEFAULT 0, + friendly_name VARCHAR(255) DEFAULT NULL, + transports VARCHAR(255) DEFAULT NULL, + created_at INT UNSIGNED NOT NULL, + last_used_at INT UNSIGNED DEFAULT NULL, + revoked TINYINT(1) UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (nt_webauthn_credential_id), + UNIQUE KEY uk_credential_id (credential_id), + KEY idx_user_id (nt_user_id) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +DROP TABLE IF EXISTS nt_user_webauthn_challenge; +CREATE TABLE nt_user_webauthn_challenge ( + nt_webauthn_challenge_id INT UNSIGNED AUTO_INCREMENT NOT NULL, + nt_user_id INT UNSIGNED DEFAULT NULL, + challenge VARCHAR(128) NOT NULL, + ceremony_type ENUM('registration','authentication') NOT NULL, + created_at INT UNSIGNED NOT NULL, + expires_at INT UNSIGNED NOT NULL, + consumed TINYINT(1) UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (nt_webauthn_challenge_id), + UNIQUE KEY uk_challenge (challenge), + KEY idx_expires (expires_at) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + diff --git a/server/sql/upgrade.pl b/server/sql/upgrade.pl index 90f5e7ac..b68c7545 100755 --- a/server/sql/upgrade.pl +++ b/server/sql/upgrade.pl @@ -34,7 +34,7 @@ # NOTE: when making schema changes, update db_version in 12_nt_options.sql my @versions = qw/ 2.00 2.05 2.08 2.09 2.10 2.11 2.14 2.15 2.16 2.17 2.18 - 2.24 2.27 2.28 2.29 2.30 2.32 2.34 2.35 2.40 /; + 2.24 2.27 2.28 2.29 2.30 2.32 2.34 2.35 2.40 2.44 /; foreach my $version (@versions) { @@ -123,6 +123,57 @@ sub _sql_2_40 { EO_SQL_2_40 } +sub _sql_test_2_44 { + my $r = _get_db_version(); + return 1 if !defined $r; # query failed + + my $t1 = $dbh->query("SHOW TABLES LIKE 'nt_user_webauthn_credential'")->hashes; + return 0 unless scalar $t1 && $t1->[0]; # table missing + + my $t2 = $dbh->query("SHOW TABLES LIKE 'nt_user_webauthn_challenge'")->hashes; + return 0 unless scalar $t2 && $t2->[0]; # table missing + + return 0 if $r eq '2.43'; # do it! bump db_version + return 1; # don't update +} + +sub _sql_2_44 { + <dbh(); +ok( $dbh, 'dbh handle' ) or BAIL_OUT('no database connection'); + +my $wa = NicToolServer::WebAuthn->new( undef, undef, $dbh ); +ok( $wa, 'WebAuthn instance' ); + +my $test_uid = 1; # root user, always exists +my $test_prefix = "test_wa_$$"; + +# Save and clear WebAuthn options for clean test state +my $orig_rp_id = $wa->get_option('webauthn_rp_id'); +my $orig_origin = $wa->get_option('webauthn_origin'); + +$wa->exec_query( 'DELETE FROM nt_options WHERE option_name = ?', 'webauthn_rp_id' ); +$wa->exec_query( 'DELETE FROM nt_options WHERE option_name = ?', 'webauthn_origin' ); + +# ===================================================================== +# T1: Unconfigured returns error 600 +# ===================================================================== +subtest 'T1: default disabled' => sub { + + # No webauthn_enabled row — default is disabled + ok( !$wa->get_option('webauthn_enabled'), 'no webauthn_enabled row by default' ); + + my $r1 = $wa->generate_registration_options( { nt_user_id => $test_uid } ); + is( $r1->{error_code}, 600, 'registration: disabled returns 600' ); + like( $r1->{error_msg}, qr/disabled/i, 'registration: message says disabled' ); + + my $r2 = $wa->generate_authentication_options( {} ); + is( $r2->{error_code}, 600, 'auth: disabled returns 600' ); + + my $r3 = $wa->get_user_credentials( { nt_user_id => $test_uid } ); + is( $r3->{error_code}, 600, 'list creds: disabled returns 600' ); +}; + +# ===================================================================== +# T1b: webauthn_enabled toggle +# ===================================================================== +subtest 'T1b: enable toggle + unconfigured' => sub { + + # Enable, but rp_id/origin not yet set — should get "not configured" + $wa->exec_query( 'INSERT INTO nt_options (option_name, option_value) VALUES (?, ?)', + [ 'webauthn_enabled', '1' ] ); + + my $r1 = $wa->generate_registration_options( { nt_user_id => $test_uid } ); + is( $r1->{error_code}, 600, 'enabled but unconfigured: returns 600' ); + like( $r1->{error_msg}, qr/not configured/i, 'enabled: message says unconfigured' ); + + # Explicitly disable + $wa->exec_query( 'UPDATE nt_options SET option_value = ? WHERE option_name = ?', + [ '0', 'webauthn_enabled' ] ); + + my $r2 = $wa->generate_registration_options( { nt_user_id => $test_uid } ); + is( $r2->{error_code}, 600, 'disabled: returns 600' ); + like( $r2->{error_msg}, qr/disabled/i, 'disabled: message says disabled' ); + + # Re-enable for remaining tests + $wa->exec_query( 'UPDATE nt_options SET option_value = ? WHERE option_name = ?', + [ '1', 'webauthn_enabled' ] ); +}; + +# Insert test options for remaining tests +my $test_rp_id = 'localhost'; +my $test_origin = 'https://localhost:8443'; + +$wa->exec_query( 'INSERT INTO nt_options (option_name, option_value) VALUES (?, ?)', + [ 'webauthn_rp_id', $test_rp_id ] ); +$wa->exec_query( 'INSERT INTO nt_options (option_name, option_value) VALUES (?, ?)', + [ 'webauthn_origin', $test_origin ] ); + +# ===================================================================== +# T2: Challenge generation +# ===================================================================== +subtest 'T2: challenge generation' => sub { + my $c = $wa->_generate_challenge(); + ok( defined $c, 'challenge is defined' ); + ok( length($c) >= 40, 'challenge >= 40 chars (32 bytes base64url)' ); + unlike( $c, qr/[+\/=]/, 'valid base64url (no +/= chars)' ); + + my $decoded = decode_base64url($c); + is( length($decoded), 32, 'decoded challenge is 32 bytes' ); + + my %seen; + my $all_unique = 1; + for ( 1 .. 100 ) { + my $ch = $wa->_generate_challenge(); + if ( $seen{$ch}++ ) { $all_unique = 0; last; } + } + ok( $all_unique, '100 challenges are all unique' ); +}; + +# ===================================================================== +# T3: Challenge lifecycle +# ===================================================================== +subtest 'T3: challenge lifecycle' => sub { + my $now = time(); + + # Valid challenge — consume succeeds + my $ch1 = "${test_prefix}_life1"; + $wa->exec_query( + 'INSERT INTO nt_user_webauthn_challenge + (nt_user_id, challenge, ceremony_type, + created_at, expires_at) VALUES (??)', + [ $test_uid, $ch1, 'authentication', $now, $now + 300 ] + ); + my $row = $wa->_consume_challenge( $ch1, 'authentication', $test_uid ); + ok( $row, 'valid challenge consumed' ); + is( $row->{challenge}, $ch1, 'returned row matches' ); + + # Replay rejected + ok( !$wa->_consume_challenge( $ch1, 'authentication', $test_uid ), 'replay rejected' ); + + # Expired challenge + my $ch2 = "${test_prefix}_expired"; + $wa->exec_query( + 'INSERT INTO nt_user_webauthn_challenge + (nt_user_id, challenge, ceremony_type, + created_at, expires_at) VALUES (??)', + [ $test_uid, $ch2, 'authentication', $now - 600, $now - 300 ] + ); + ok( !$wa->_consume_challenge( $ch2, 'authentication', $test_uid ), + 'expired challenge rejected' ); + + # Wrong ceremony type + my $ch3 = "${test_prefix}_wrongtype"; + $wa->exec_query( + 'INSERT INTO nt_user_webauthn_challenge + (nt_user_id, challenge, ceremony_type, + created_at, expires_at) VALUES (??)', + [ $test_uid, $ch3, 'registration', $now, $now + 300 ] + ); + ok( !$wa->_consume_challenge( $ch3, 'authentication', $test_uid ), + 'wrong ceremony type rejected' ); + + # Wrong user ID + my $ch4 = "${test_prefix}_wronguid"; + $wa->exec_query( + 'INSERT INTO nt_user_webauthn_challenge + (nt_user_id, challenge, ceremony_type, + created_at, expires_at) VALUES (??)', + [ $test_uid, $ch4, 'authentication', $now, $now + 300 ] + ); + ok( !$wa->_consume_challenge( $ch4, 'authentication', 99999 ), 'wrong user ID rejected' ); + + # NULL user ID (usernameless flow) + my $ch5 = "${test_prefix}_nulluid"; + $wa->exec_query( + 'INSERT INTO nt_user_webauthn_challenge + (nt_user_id, challenge, ceremony_type, + created_at, expires_at) VALUES (??)', + [ undef, $ch5, 'authentication', $now, $now + 300 ] + ); + my $null_row = $wa->_consume_challenge( $ch5, 'authentication', undef ); + ok( $null_row, 'NULL uid challenge consumed (usernameless)' ); + is( $null_row->{challenge}, $ch5, 'NULL uid row matches' ); + + # Cleanup removes expired + $wa->_cleanup_expired_challenges(); + my $remaining = $wa->exec_query( + 'SELECT COUNT(*) AS cnt + FROM nt_user_webauthn_challenge + WHERE challenge = ?', $ch2 + ); + is( $remaining->[0]{cnt}, 0, 'cleanup removed expired row' ); +}; + +# ===================================================================== +# T4: generate_registration_options +# ===================================================================== +subtest 'T4: registration options' => sub { + + # Missing nt_user_id + is( $wa->generate_registration_options( {} )->{error_code}, 301, 'missing uid returns 301' ); + + # Nonexistent user + is( $wa->generate_registration_options( { nt_user_id => 99999 } )->{error_code}, + 404, 'nonexistent user returns 404' ); + + # Valid call + my $r = $wa->generate_registration_options( { nt_user_id => $test_uid } ); + is( $r->{error_code}, 200, 'valid call returns 200' ); + ok( $r->{options}, 'options field present' ); + + my $opts = decode_json( $r->{options} ); + ok( $opts->{challenge}, 'has challenge' ); + is( $opts->{rp}{id}, $test_rp_id, 'correct rp.id' ); + ok( $opts->{user}{id}, 'has user.id' ); + ok( ref $opts->{pubKeyCredParams} eq 'ARRAY', 'pubKeyCredParams is array' ); + + # user.id decodes to packed uid + my $decoded_uid = + unpack( 'N', decode_base64url( $opts->{user}{id} ) ); + is( $decoded_uid, $test_uid, 'user.id encodes uid' ); +}; + +# ===================================================================== +# T5: generate_authentication_options WITH username +# ===================================================================== +subtest 'T5: auth options with username' => sub { + + # Nonexistent user + is( $wa->generate_authentication_options( { username => 'nonexistent_xyzzy_999' } ) + ->{error_code}, + 403, + 'nonexistent user returns 403' + ); + + # User with no credentials + is( $wa->generate_authentication_options( { username => 'root' } )->{error_code}, + 403, 'no credentials returns 403' ); + + # Insert a test credential + my $cred_id = "${test_prefix}_auth_cred"; + $wa->exec_query( + 'INSERT INTO nt_user_webauthn_credential + (nt_user_id, credential_id, credential_pubkey, + signature_count, friendly_name, transports, + created_at) VALUES (??)', + [ $test_uid, $cred_id, 'fake_pubkey_b64', 0, 'Test Auth Key', 'internal,hybrid', time() ] + ); + + my $r = $wa->generate_authentication_options( { username => 'root' } ); + is( $r->{error_code}, 200, 'with credential returns 200' ); + + my $opts = decode_json( $r->{options} ); + is( ref $opts->{allowCredentials}, 'ARRAY', 'allowCredentials is array' ); + ok( scalar @{ $opts->{allowCredentials} } >= 1, 'at least one credential' ); + + my ($match) = grep { $_->{id} eq $cred_id } @{ $opts->{allowCredentials} }; + ok( $match, 'test credential in allowCredentials' ); + is( ref $match->{transports}, 'ARRAY', 'transports parsed to array' ); +}; + +# ===================================================================== +# T6: generate_authentication_options WITHOUT username (usernameless) +# ===================================================================== +subtest 'T6: auth options usernameless' => sub { + my $r = $wa->generate_authentication_options( {} ); + is( $r->{error_code}, 200, 'no username returns 200' ); + + my $opts = decode_json( $r->{options} ); + is( ref $opts->{allowCredentials}, 'ARRAY', 'allowCredentials is array' ); + is( scalar @{ $opts->{allowCredentials} }, 0, 'allowCredentials is empty' ); + ok( $opts->{challenge}, 'challenge present' ); + + # Stored with NULL uid + my $rows = $wa->exec_query( + 'SELECT * FROM nt_user_webauthn_challenge + WHERE challenge = ? AND nt_user_id IS NULL', + $opts->{challenge} + ); + ok( $rows && $rows->[0], 'challenge stored with NULL nt_user_id' ); + + # Consumable with undef + ok( $wa->_consume_challenge( $opts->{challenge}, 'authentication', undef ), + 'NULL uid challenge consumable' ); +}; + +# ===================================================================== +# T7: Credential CRUD +# ===================================================================== +subtest 'T7: credential CRUD' => sub { + + # Clean slate + $wa->exec_query( + 'DELETE FROM nt_user_webauthn_credential + WHERE credential_id LIKE ?', "${test_prefix}_crud%" + ); + + # Missing uid + is( $wa->get_user_credentials( {} )->{error_code}, 301, 'get: missing uid returns 301' ); + is( $wa->revoke_credential( { nt_user_id => 1 } )->{error_code}, + 301, 'revoke: missing cred_id returns 301' ); + is( $wa->rename_credential( { nt_user_id => 1 } )->{error_code}, + 301, 'rename: missing cred_id returns 301' ); + is( + $wa->rename_credential( + { nt_user_id => 1, + nt_webauthn_credential_id => 1 + } + )->{error_code}, + 301, + 'rename: missing name returns 301' + ); + + # Insert credential + my $cred_id = "${test_prefix}_crud1"; + $wa->exec_query( + 'INSERT INTO nt_user_webauthn_credential + (nt_user_id, credential_id, credential_pubkey, + signature_count, friendly_name, created_at) + VALUES (??)', + [ $test_uid, $cred_id, 'fake_pk', 0, 'My Key', time() ] + ); + + # List includes it + my $list = $wa->get_user_credentials( { nt_user_id => $test_uid } ); + is( $list->{error_code}, 200, 'list returns 200' ); + my ($found) = grep { $_->{credential_id} eq $cred_id } @{ $list->{credentials} }; + ok( $found, 'credential in list' ); + is( $found->{friendly_name}, 'My Key', 'name correct' ); + my $db_id = $found->{nt_webauthn_credential_id}; + + # Rename + is( + $wa->rename_credential( + { nt_user_id => $test_uid, + nt_webauthn_credential_id => $db_id, + friendly_name => 'Renamed', + } + )->{error_code}, + 200, + 'rename returns 200' + ); + my $list2 = $wa->get_user_credentials( { nt_user_id => $test_uid } ); + my ($renamed) = grep { $_->{credential_id} eq $cred_id } @{ $list2->{credentials} }; + is( $renamed->{friendly_name}, 'Renamed', 'rename took effect' ); + + # Revoke + is( + $wa->revoke_credential( + { nt_user_id => $test_uid, + nt_webauthn_credential_id => $db_id, + } + )->{error_code}, + 200, + 'revoke returns 200' + ); + my $list3 = $wa->get_user_credentials( { nt_user_id => $test_uid } ); + my ($gone) = grep { $_->{credential_id} eq $cred_id } @{ $list3->{credentials} }; + ok( !$gone, 'revoked credential gone from list' ); +}; + +# ===================================================================== +# T8: verify_registration error paths +# ===================================================================== +subtest 'T8: verify_registration errors' => sub { + is( $wa->verify_registration( {} )->{error_code}, 301, 'missing uid returns 301' ); + is( $wa->verify_registration( { nt_user_id => $test_uid } )->{error_code}, + 301, 'missing attestation fields returns 301' ); + is( + $wa->verify_registration( + { nt_user_id => $test_uid, + challenge_b64 => 'nonexistent', + client_data_json_b64 => 'fake', + attestation_object_b64 => 'fake', + } + )->{error_code}, + 403, + 'invalid challenge returns 403' + ); +}; + +# ===================================================================== +# T9: verify_authentication error paths +# ===================================================================== +subtest 'T9: verify_authentication errors' => sub { + is( $wa->verify_authentication( {} )->{error_code}, 301, 'missing fields returns 301' ); + is( + $wa->verify_authentication( + { challenge_b64 => 'fake', + credential_id_b64 => 'nonexistent_cred', + client_data_json_b64 => 'fake', + authenticator_data_b64 => 'fake', + signature_b64 => 'fake', + } + )->{error_code}, + 403, + 'unknown credential returns 403' + ); + + # Revoked credential + my $rev_cred = "${test_prefix}_rev_auth"; + $wa->exec_query( + 'INSERT INTO nt_user_webauthn_credential + (nt_user_id, credential_id, credential_pubkey, + signature_count, revoked, created_at) + VALUES (??)', + [ $test_uid, $rev_cred, 'fake', 0, 1, time() ] + ); + is( + $wa->verify_authentication( + { challenge_b64 => 'fake', + credential_id_b64 => $rev_cred, + client_data_json_b64 => 'fake', + authenticator_data_b64 => 'fake', + signature_b64 => 'fake', + } + )->{error_code}, + 403, + 'revoked credential returns 403' + ); +}; + +# ===================================================================== +# T10: verify_registration happy path (mocked) +# ===================================================================== +subtest 'T10: verify_registration success' => sub { + + my $mock_cred_id = "${test_prefix}_reg_ok"; + my $mock_pubkey = 'mock_pubkey_b64_value'; + + # Insert a valid registration challenge + my $challenge = "${test_prefix}_regchallenge"; + my $now = time(); + $wa->exec_query( + 'INSERT INTO nt_user_webauthn_challenge + (nt_user_id, challenge, ceremony_type, + created_at, expires_at) VALUES (??)', + [ $test_uid, $challenge, 'registration', $now, $now + 300 ] + ); + + # Mock Authen::WebAuthn so we don't need real crypto + no warnings 'redefine'; + local *Authen::WebAuthn::new = sub { + return bless {}, 'Authen::WebAuthn'; + }; + local *Authen::WebAuthn::validate_registration = sub { + return { + credential_id => $mock_cred_id, + credential_pubkey => $mock_pubkey, + signature_count => 0, + }; + }; + + my $r = $wa->verify_registration( + { nt_user_id => $test_uid, + challenge_b64 => $challenge, + client_data_json_b64 => 'fake_cdj', + attestation_object_b64 => 'fake_att', + friendly_name => 'Mock Key', + } + ); + is( $r->{error_code}, 200, 'returns 200 on success' ); + is( $r->{credential_id}, $mock_cred_id, 'returns credential_id' ); + + # Verify credential was stored in the DB + my $rows = $wa->exec_query( + 'SELECT * FROM nt_user_webauthn_credential + WHERE credential_id = ?', $mock_cred_id + ); + ok( $rows && $rows->[0], 'credential row exists in DB' ); + is( $rows->[0]{credential_pubkey}, $mock_pubkey, 'pubkey stored correctly' ); + is( $rows->[0]{friendly_name}, 'Mock Key', 'friendly_name stored' ); + is( $rows->[0]{nt_user_id}, $test_uid, 'credential bound to correct user' ); +}; + +# ===================================================================== +# T11: verify_authentication happy path (mocked) +# ===================================================================== +subtest 'T11: verify_authentication success' => sub { + + # Insert a credential for the test user + my $cred_id = "${test_prefix}_authok_cred"; + my $now = time(); + $wa->exec_query( + 'INSERT INTO nt_user_webauthn_credential + (nt_user_id, credential_id, credential_pubkey, + signature_count, friendly_name, created_at) + VALUES (??)', + [ $test_uid, $cred_id, 'fake_auth_pk', 0, 'Auth Key', $now ] + ); + + # Insert a valid authentication challenge bound to the user + my $challenge = "${test_prefix}_authchallenge"; + $wa->exec_query( + 'INSERT INTO nt_user_webauthn_challenge + (nt_user_id, challenge, ceremony_type, + created_at, expires_at) VALUES (??)', + [ $test_uid, $challenge, 'authentication', $now, $now + 300 ] + ); + + # Mock Authen::WebAuthn + no warnings 'redefine'; + local *Authen::WebAuthn::new = sub { + return bless {}, 'Authen::WebAuthn'; + }; + local *Authen::WebAuthn::validate_assertion = sub { + return { signature_count => 1 }; + }; + + my $r = $wa->verify_authentication( + { challenge_b64 => $challenge, + credential_id_b64 => $cred_id, + client_data_json_b64 => 'fake_cdj', + authenticator_data_b64 => 'fake_ad', + signature_b64 => 'fake_sig', + } + ); + is( $r->{error_code}, 200, 'returns 200 on success' ); + is( $r->{nt_user_id}, $test_uid, 'returns correct nt_user_id' ); + is( $r->{username}, 'root', 'returns correct username' ); + + # Verify signature_count was updated in the DB + my $rows = $wa->exec_query( + 'SELECT signature_count, last_used_at + FROM nt_user_webauthn_credential + WHERE credential_id = ?', $cred_id + ); + ok( $rows && $rows->[0], 'credential row still exists' ); + is( $rows->[0]{signature_count}, 1, 'signature_count updated to 1' ); + ok( $rows->[0]{last_used_at}, 'last_used_at was set' ); +}; + +# ===================================================================== +# T12: cross-user authorization (user A cannot affect user B creds) +# ===================================================================== +subtest 'T12: cross-user credential isolation' => sub { + + # Create a credential owned by user 1 (root) + my $cred_id = "${test_prefix}_xuser"; + my $now = time(); + $wa->exec_query( + 'INSERT INTO nt_user_webauthn_credential + (nt_user_id, credential_id, credential_pubkey, + signature_count, friendly_name, created_at) + VALUES (??)', + [ $test_uid, $cred_id, 'xuser_pk', 0, 'Cross User Key', $now ] + ); + + # Get the DB-assigned ID + my $rows = $wa->exec_query( + 'SELECT nt_webauthn_credential_id + FROM nt_user_webauthn_credential + WHERE credential_id = ?', $cred_id + ); + my $db_id = $rows->[0]{nt_webauthn_credential_id}; + ok( $db_id, 'credential inserted with DB id' ); + + my $other_uid = 99999; # nonexistent user + + # Attempt revoke as wrong user -- should silently not match + $wa->revoke_credential( + { nt_user_id => $other_uid, + nt_webauthn_credential_id => $db_id, + } + ); + + # Verify credential is NOT revoked + my $after_revoke = $wa->exec_query( + 'SELECT revoked FROM nt_user_webauthn_credential + WHERE nt_webauthn_credential_id = ?', $db_id + ); + is( $after_revoke->[0]{revoked}, 0, 'revoke by wrong user did not affect credential' ); + + # Attempt rename as wrong user + $wa->rename_credential( + { nt_user_id => $other_uid, + nt_webauthn_credential_id => $db_id, + friendly_name => 'Hacked Name', + } + ); + + # Verify name is unchanged + my $after_rename = $wa->exec_query( + 'SELECT friendly_name FROM nt_user_webauthn_credential + WHERE nt_webauthn_credential_id = ?', $db_id + ); + is( $after_rename->[0]{friendly_name}, + 'Cross User Key', + 'rename by wrong user did not change name' + ); +}; + +# ===================================================================== +# Cleanup +# ===================================================================== +END { + if ($wa) { + $wa->exec_query( + 'DELETE FROM nt_user_webauthn_challenge + WHERE challenge LIKE ?', "${test_prefix}%" + ); + $wa->exec_query( + 'DELETE FROM nt_user_webauthn_credential + WHERE credential_id LIKE ?', "${test_prefix}%" + ); + + # Clean generated challenges from registration/auth options + $wa->exec_query( + 'DELETE FROM nt_user_webauthn_challenge + WHERE nt_user_id = ? AND consumed = 0', $test_uid + ); + $wa->exec_query( + 'DELETE FROM nt_user_webauthn_challenge + WHERE nt_user_id IS NULL AND consumed = 0' + ); + + # Restore original options + $wa->exec_query( 'DELETE FROM nt_options WHERE option_name = ?', 'webauthn_enabled' ); + $wa->exec_query( 'DELETE FROM nt_options WHERE option_name = ?', 'webauthn_rp_id' ); + $wa->exec_query( 'DELETE FROM nt_options WHERE option_name = ?', 'webauthn_origin' ); + if ($orig_rp_id) { + $wa->exec_query( + 'INSERT INTO nt_options + (option_name, option_value) VALUES (?, ?)', + [ 'webauthn_rp_id', $orig_rp_id ] + ); + } + if ($orig_origin) { + $wa->exec_query( + 'INSERT INTO nt_options + (option_name, option_value) VALUES (?, ?)', + [ 'webauthn_origin', $orig_origin ] + ); + } + } +} + +done_testing(); From aff8ec40ce65355790eb2dfca123d813aba24ca8 Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Mon, 6 Apr 2026 20:00:52 +0100 Subject: [PATCH 04/10] fix: use js_escape() for CSRF tokens embedded in JavaScript context esc() is HTML-entity escaping, which is wrong inside a JS string literal. js_escape() handles backslashes, quotes, and line separators correctly. Co-Authored-By: Claude Opus 4.6 (1M context) --- client/htdocs/user.cgi | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/htdocs/user.cgi b/client/htdocs/user.cgi index cfc30d25..12c6d2e1 100755 --- a/client/htdocs/user.cgi +++ b/client/htdocs/user.cgi @@ -364,7 +364,7 @@ sub display_passkeys { window.ntRevokePasskey = function(credId) { if (!confirm('Revoke this passkey?')) return; - NtWebAuthn.revokeCredential('] . $nt_obj->esc($csrf_token) . qq[', ] . int($uid) . qq[, credId) + NtWebAuthn.revokeCredential('] . $nt_obj->js_escape($csrf_token) . qq[', ] . int($uid) . qq[, credId) .done(function() { loadPasskeys(); }) .fail(function() { alert('Failed to revoke passkey.'); }); }; From 290d8bc41521fdff7b71828f412253b2c53fab4a Mon Sep 17 00:00:00 2001 From: Moses Ingersoll Date: Mon, 6 Apr 2026 19:42:01 +0100 Subject: [PATCH 05/10] feat: add WebAuthn/passkey authentication with feature flag Add passkey support as an alternative login method. Disabled by default; enable by setting webauthn_enabled=1 in nt_options alongside webauthn_rp_id and webauthn_origin. Password auth is unchanged. Server: - NicToolServer::WebAuthn module with registration/authentication ceremonies - TOCTOU-safe challenge consumption (atomic UPDATE, check affected rows) - Usernameless login via discoverable credentials (empty allowCredentials) - Feature flag: webauthn_enabled option (default off) - Session dispatch for pre-auth WebAuthn endpoints (no session required) Client: - nt-webauthn.js: browser-side WebAuthn ceremony helpers (ES5/jQuery) - webauthn.cgi: JSON API proxy with action whitelist, CSRF validation, parameter injection prevention, cookie sanitization via CGI::cookie() - Login page passkey button (works without entering username) - User profile passkey management (register, rename, revoke) Schema: - nt_user_webauthn_credential table (full-column UNIQUE on credential_id) - nt_user_webauthn_challenge table (full-column UNIQUE on challenge) - Migration in upgrade.pl with CREATE TABLE IF NOT EXISTS for robustness - passkey_login added to session_log action ENUM Tests: - server/t/05_webauthn.t: 12 subtests covering feature flag, challenge lifecycle, registration/auth options, credential CRUD, mocked happy paths, cross-user isolation - client/t/e2e/webauthn.spec.ts: CSRF protection, session requirements, login page UI, virtual authenticator ceremonies, revocation Co-Authored-By: Claude Opus 4.6 (1M context) --- client/htdocs/user.cgi | 2 +- client/htdocs/webauthn.cgi | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/client/htdocs/user.cgi b/client/htdocs/user.cgi index 12c6d2e1..cfc30d25 100755 --- a/client/htdocs/user.cgi +++ b/client/htdocs/user.cgi @@ -364,7 +364,7 @@ sub display_passkeys { window.ntRevokePasskey = function(credId) { if (!confirm('Revoke this passkey?')) return; - NtWebAuthn.revokeCredential('] . $nt_obj->js_escape($csrf_token) . qq[', ] . int($uid) . qq[, credId) + NtWebAuthn.revokeCredential('] . $nt_obj->esc($csrf_token) . qq[', ] . int($uid) . qq[, credId) .done(function() { loadPasskeys(); }) .fail(function() { alert('Failed to revoke passkey.'); }); }; diff --git a/client/htdocs/webauthn.cgi b/client/htdocs/webauthn.cgi index ef6a356c..84ed7b3d 100755 --- a/client/htdocs/webauthn.cgi +++ b/client/htdocs/webauthn.cgi @@ -23,7 +23,7 @@ sub main { # Only accept POST with JSON content if ( $q->request_method() ne 'POST' ) { - send_json( 405, { error_code => 405, error_msg => 'Method not allowed' } ); + send_json( $q, 405, { error_code => 405, error_msg => 'Method not allowed' } ); return; } @@ -38,7 +38,7 @@ sub main { my $request; eval { $request = decode_json($body); }; if ( $@ || !$request ) { - send_json( 400, { error_code => 400, error_msg => 'Invalid JSON' } ); + send_json( $q, 400, { error_code => 400, error_msg => 'Invalid JSON' } ); return; } @@ -49,7 +49,7 @@ sub main { my $csrf_token = $request->{csrf_token} || ''; my $cookie_csrf = $q->cookie('NicTool_csrf') || ''; if ( !$csrf_token || !$cookie_csrf || $csrf_token ne $cookie_csrf ) { - send_json( 403, { error_code => 403, error_msg => 'CSRF validation failed' } ); + send_json( $q, 403, { error_code => 403, error_msg => 'CSRF validation failed' } ); return; } @@ -65,7 +65,7 @@ sub main { ); if ( !$allowed{$action} ) { - send_json( 400, { error_code => 400, error_msg => 'Unknown action' } ); + send_json( $q, 400, { error_code => 400, error_msg => 'Unknown action' } ); return; } @@ -82,7 +82,7 @@ sub main { # Authenticated actions need session cookie my $cookie = $q->cookie('NicTool'); if ( !$cookie ) { - send_json( 403, { error_code => 403, error_msg => 'Not authenticated' } ); + send_json( $q, 403, { error_code => 403, error_msg => 'Not authenticated' } ); return; } $params{nt_user_session} = $cookie; @@ -97,7 +97,7 @@ sub main { my $response = $nt_obj->{nt_server_obj}->send_request(%params); if ( !ref $response ) { - send_json( 500, { error_code => 500, error_msg => $response || 'Server error' } ); + send_json( $q, 500, { error_code => 500, error_msg => $response || 'Server error' } ); return; } @@ -124,12 +124,12 @@ sub main { return; } - send_json( 200, $response ); + send_json( $q, 200, $response ); } sub send_json { - my ( $status, $data ) = @_; - print CGI->new->header( + my ( $q, $status, $data ) = @_; + print $q->header( -type => 'application/json', -status => $status, ); From 55c6fc21ad54f92906a28d5e4bb0d1310aa9b5f2 Mon Sep 17 00:00:00 2001 From: Moses Ingersoll Date: Mon, 6 Apr 2026 22:59:09 +0100 Subject: [PATCH 06/10] fix: remove -status from send_json to fix ModPerl::Registry body corruption The -status parameter in CGI.pm's header() appears to produce extra content in the response body under ModPerl::Registry with +ParseHeaders, causing JSON parse errors in E2E tests. Drop -status (tests check json.error_code, not HTTP status), add -charset utf-8, remove trailing 1;. Also add debug logging to W1 tests to capture exact response text on failure. Co-Authored-By: Claude Opus 4.6 (1M context) --- client/htdocs/webauthn.cgi | 6 ++---- client/t/e2e/webauthn.spec.ts | 24 +++++++++++++++++++++--- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/client/htdocs/webauthn.cgi b/client/htdocs/webauthn.cgi index 84ed7b3d..cca85b86 100755 --- a/client/htdocs/webauthn.cgi +++ b/client/htdocs/webauthn.cgi @@ -130,10 +130,8 @@ sub main { sub send_json { my ( $q, $status, $data ) = @_; print $q->header( - -type => 'application/json', - -status => $status, + -type => 'application/json', + -charset => 'utf-8', ); print encode_json($data); } - -1; diff --git a/client/t/e2e/webauthn.spec.ts b/client/t/e2e/webauthn.spec.ts index 1e8d990c..0ac4e99e 100644 --- a/client/t/e2e/webauthn.spec.ts +++ b/client/t/e2e/webauthn.spec.ts @@ -72,7 +72,13 @@ test.describe('W1: WebAuthn CSRF protection', () => { }), }); - const json = JSON.parse(await res.text()); + const text = await res.text(); + let json: any; + try { json = JSON.parse(text); } + catch (e) { + console.error(`W1a response (${text.length} chars): ${JSON.stringify(text)}`); + throw e; + } expect(json.error_code).toBe(403); expect(json.error_msg).toContain('CSRF'); await ctx.dispose(); @@ -93,7 +99,13 @@ test.describe('W1: WebAuthn CSRF protection', () => { }), }); - const json = JSON.parse(await res.text()); + const text = await res.text(); + let json: any; + try { json = JSON.parse(text); } + catch (e) { + console.error(`W1b response (${text.length} chars): ${JSON.stringify(text)}`); + throw e; + } expect(json.error_code).toBe(403); await ctx.dispose(); }); @@ -101,7 +113,13 @@ test.describe('W1: WebAuthn CSRF protection', () => { test('GET request rejected with 405', async ({ playwright }) => { const ctx = await freshCtx(playwright); const res = await ctx.get(`${BASE}/webauthn.cgi`); - const json = JSON.parse(await res.text()); + const text = await res.text(); + let json: any; + try { json = JSON.parse(text); } + catch (e) { + console.error(`W1c response (${text.length} chars): ${JSON.stringify(text)}`); + throw e; + } expect(json.error_code).toBe(405); await ctx.dispose(); }); From de8e33be63c23a72ec615e9f4b53458608a30897 Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Tue, 7 Apr 2026 08:32:27 +0100 Subject: [PATCH 07/10] fix(test): enable WebAuthn in CI, fix T8 XSS false positive - setup-test-env.pl: insert webauthn_enabled, webauthn_rp_id, and webauthn_origin into nt_options so W4-W7 ceremony tests actually run in CI instead of being skipped - security.spec.ts: narrow T8 XSS assertion from /`); const body = await res.text(); expect(body).not.toContain(''); - expect(body).not.toMatch(/