From 82bc13de678c3e7b59800eea1d03bef0bf6fcad3 Mon Sep 17 00:00:00 2001 From: Rudis Muiznieks Date: Sun, 30 Apr 2023 15:31:20 -0500 Subject: [PATCH] c# rewrite --- .gitignore | 3 + Dockerfile | 23 +-- main.py | 296 ---------------------------------- requirements.txt | 2 - run.sh | 4 - src/.editorconfig | 323 ++++++++++++++++++++++++++++++++++++++ src/Program.cs | 311 ++++++++++++++++++++++++++++++++++++ src/login.html | 102 ++++++++++++ src/omnisharp.json | 8 + src/schema.sql | 10 ++ src/webauthn-proxy.csproj | 24 +++ 11 files changed, 793 insertions(+), 313 deletions(-) create mode 100644 .gitignore delete mode 100644 main.py delete mode 100644 requirements.txt delete mode 100755 run.sh create mode 100644 src/.editorconfig create mode 100644 src/Program.cs create mode 100644 src/login.html create mode 100644 src/omnisharp.json create mode 100644 src/schema.sql create mode 100644 src/webauthn-proxy.csproj diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6dd3285 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +bin/ +obj/ +credentials.db diff --git a/Dockerfile b/Dockerfile index f80f597..bbfac09 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,12 @@ -from alpine:latest -run apk update && apk upgrade -run apk add python3 py3-virtualenv -run adduser -D -u 1000 webauthn -copy . /opt/app -run chown -R webauthn:webauthn /opt/app -user webauthn -workdir /opt/app -run virtualenv venv -run . venv/bin/activate && pip install -r requirements.txt -cmd ["./run.sh"] +from mcr.microsoft.com/dotnet/sdk:7.0 as build-env + +workdir /App +copy ./src/ ./ +run dotnet restore +run dotnet publish -c Release -o out + +from mcr.microsoft.com/dotnet/aspnet:7.0 + +workdir /App +copy --from=build-env /App/out . +entrypoint ["dotnet", "webauthn-proxy.dll"] diff --git a/main.py b/main.py deleted file mode 100644 index 691f6cd..0000000 --- a/main.py +++ /dev/null @@ -1,296 +0,0 @@ -from json.decoder import JSONDecodeError -import datetime -import random -import socketserver -import http.server -import http.cookies -import json -import base64 -import sys -import os -import time - -from fido2.client import ClientData -from fido2.webauthn import PublicKeyCredentialRpEntity -from fido2.server import U2FFido2Server -from fido2.ctap2 import AttestationObject, AttestedCredentialData, AuthenticatorData -from fido2 import cbor - -TOKEN_LIFETIME = 60 * 60 * 24 * 7 -PORT = 8000 -FORM = """ - - -
- -""" - -class TokenManager(object): - """Who needs a database when you can just store everything in memory?""" - - def __init__(self): - self.tokens = {} - self.random = random.SystemRandom() - - def generate(self): - t = '%064x' % self.random.getrandbits(8*32) - self.tokens[t] = time.time() - return t - - def is_valid(self, t): - try: - return time.time() - self.tokens.get(t, 0) < TOKEN_LIFETIME - except Exception: - return False - - def invalidate(self, t): - if t in self.tokens: - del self.tokens[t] - -CHALLENGE = {} -TOKEN_MANAGER = TokenManager() - -class AuthHandler(http.server.BaseHTTPRequestHandler): - def do_GET(self): - if self.path == '/auth/check': - cookie = http.cookies.SimpleCookie(self.headers.get('Cookie')) - if '__Secure-Token' in cookie and TOKEN_MANAGER.is_valid(cookie['__Secure-Token'].value): - self.send_response(200) - self.end_headers() - return - - self.send_response(401) - self.end_headers() - return - - if self.path[:11] == "/auth/login": - self.send_response(200) - self.send_header('Content-type', 'text/html') - self.end_headers() - self.wfile.write(bytes(FORM, 'UTF-8')) - return - - if self.path == '/auth/logout': - cookie = http.cookies.SimpleCookie(self.headers.get('Cookie')) - if '__Secure-Token' in cookie: - TOKEN_MANAGER.invalidate(cookie['__Secure-Token'].value) - - # This just replaces the token with garbage - self.send_response(302) - cookie = http.cookies.SimpleCookie() - cookie["__Secure-Token"] = '' - cookie["__Secure-Token"]["path"] = '/' - cookie["__Secure-Token"]["secure"] = True - cookie["__Secure-Token"]["max-age"] = 0 # remove the cookie ASAP - self.send_header('Set-Cookie', cookie.output(header='')) - self.send_header('Location', '/') - self.end_headers() - - self.send_response(404) - self.end_headers() - - def do_POST(self): - origin = self.headers.get('Origin') - host = origin[len('https://'):] - - rp = PublicKeyCredentialRpEntity(host, 'NGINX Auth Server') - server = U2FFido2Server(origin, rp) - - if self.path == "/auth/get_challenge_for_new_key": - # check whether credentials file is locked (can't be written) - if os.access('.credentials', os.F_OK) and not os.access('.credentials', os.W_OK): - self.send_response(403) - self.end_headers() - return - - registration_data, state = server.register_begin({ 'id': b'default', 'name': "Default user", 'displayName': "Default user" }) - registration_data["publicKey"]["challenge"] = str(base64.b64encode(registration_data["publicKey"]["challenge"]), 'utf-8') - registration_data["publicKey"]["user"]["id"] = str(base64.b64encode(registration_data["publicKey"]["user"]["id"]), 'utf-8') - - self.send_response(200) - self.send_header('Content-type', 'application/json') - self.end_headers() - # Save this challenge to a file so you can kill the host to add the client via CLI - with open('data/lastchallenge', 'w') as f: - f.write(json.dumps(state)) - self.wfile.write(bytes(json.dumps(registration_data), 'UTF-8')) - return - - creds = [] - try: - with open('data/credentials', 'r', encoding='utf8') as f: - for cred_b64 in json.load(f): - cred, _ = AttestedCredentialData.unpack_from(base64.b64decode(cred_b64)) - creds.append(cred) - except: - self.send_response(401) - self.send_header('Content-type', 'application/json') - self.end_headers() - self.wfile.write(bytes(json.dumps({'error': 'not_configured'}), 'UTF-8')) - return - - if self.path == "/auth/get_challenge_for_existing_key": - auth_data, state = server.authenticate_begin(creds) - auth_data["publicKey"]["challenge"] = str(base64.b64encode(auth_data["publicKey"]["challenge"]), 'utf-8') - for i in range(len(auth_data["publicKey"]["allowCredentials"])): - auth_data["publicKey"]["allowCredentials"][i]["id"] = base64.b64encode(auth_data["publicKey"]["allowCredentials"][i]["id"]).decode('utf8') - - CHALLENGE.update(state) - - self.send_response(200) - self.send_header('Content-type', 'application/json') - self.end_headers() - self.wfile.write(bytes(json.dumps(auth_data), 'UTF-8')) - - if self.path == "/auth/complete_challenge_for_existing_key": - data = json.loads(self.rfile.read(int(self.headers.get('Content-Length')))) - - credential_id = base64.b64decode(data['id']) - client_data = ClientData(base64.b64decode(data['clientDataJSON'])) - auth_data = AuthenticatorData(base64.b64decode(data['authenticatorData'])) - signature = base64.b64decode(data['signature']) - - server.authenticate_complete( - CHALLENGE, - creds, - credential_id, - client_data, - auth_data, - signature - ) - - cookie = http.cookies.SimpleCookie() - cookie["__Secure-Token"] = TOKEN_MANAGER.generate() - cookie["__Secure-Token"]["path"] = "/" - cookie["__Secure-Token"]["secure"] = True - cookie["__Secure-Token"]["httponly"] = True - cookie["__Secure-Token"]["samesite"] = 'None' - cookie["__Secure-Token"]["domain"] = 'rdsm.ca' - cookie["__Secure-Token"]["max-age"] = TOKEN_LIFETIME - - self.send_response(200) - self.send_header('Set-Cookie', cookie.output(header='')) - self.end_headers() - self.wfile.write(bytes(json.dumps({'status': 'ok'}), 'UTF-8')) - -if __name__ == "__main__": - - def run_server(): - socketserver.TCPServer.allow_reuse_address = True - httpd = socketserver.TCPServer(("", PORT), AuthHandler) - try: - httpd.serve_forever() - finally: - httpd.server_close() - - if len(sys.argv) > 1 and sys.argv[1] == "save-client": - host = sys.argv[2] - client_data = ClientData(base64.b64decode(sys.argv[3])) - attestation_object = AttestationObject(base64.b64decode(sys.argv[4])) - - rp = PublicKeyCredentialRpEntity(host, 'NGINX Auth Server') - server = U2FFido2Server('https://' + host, rp) - - with open('data/lastchallenge') as f: - auth_data = server.register_complete(json.loads(f.read()), client_data, attestation_object) - cred = base64.b64encode(auth_data.credential_data).decode('utf8') - with open('data/credentials', 'a+', encoding='utf8') as f: - f.seek(0) - try: - creds = json.load(f) - except JSONDecodeError: - creds = [] - print("Created new credentials file") - if cred not in creds: - creds.append(cred) - f.truncate(0) - json.dump(creds, f) - print("Credentials saved successfully") - else: - print("Credentials already in database") - - elif len(sys.argv) == 2 and sys.argv[1] == '-d': - # starts the server non-daemonized - run_server() - - else: - from daemon import DaemonContext - from sys import stdout, stderr, exit - from lockfile import FileLock - from signal import SIGTERM, SIGTSTP - - def shutdown(signum, frame): - exit(0) - - with DaemonContext( - chroot_directory=None, - working_directory='/opt/app', - stdout=stdout, - stderr=stderr, - pidfile=FileLock('/var/run/webauthn/webauthn.pid'), - signal_map={ - SIGTERM: shutdown, - SIGTSTP: shutdown - }): - run_server() diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index eabcb93..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -fido2==0.8.1 -python-daemon diff --git a/run.sh b/run.sh deleted file mode 100755 index 4d360ca..0000000 --- a/run.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh - -. venv/bin/activate -exec python main.py -d diff --git a/src/.editorconfig b/src/.editorconfig new file mode 100644 index 0000000..b69fd05 --- /dev/null +++ b/src/.editorconfig @@ -0,0 +1,323 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Don't use tabs for indentation. +[*] +end_of_line = lf +indent_style = space +# (Please don't specify an indent_size here; that has too many unintended consequences.) + +# Code files +[*.{cs,csx,vb,vbx}] +indent_size = 2 +insert_final_newline = true +charset = utf-8-bom + +# XML project files +[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}] +indent_size = 2 + +# XML config files +[*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}] +indent_size = 2 + +# JSON files +[*.json] +indent_size = 2 + +# Powershell files +[*.ps1] +indent_size = 2 + +# Shell script files +[*.sh] +indent_size = 2 + +# Dotnet code style settings: +[*.{cs,vb}] + +# IDE0055: Fix formatting +dotnet_diagnostic.IDE0055.severity = warning + +# CA1848: Use LoggerDelegates +dotnet_diagnostic.CA1848.severity = none + +# Sort using and Import directives with System.* appearing first +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false +# Avoid "this." and "Me." if not necessary +dotnet_style_qualification_for_field = false:refactoring +dotnet_style_qualification_for_property = false:refactoring +dotnet_style_qualification_for_method = false:refactoring +dotnet_style_qualification_for_event = false:refactoring + +# Use language keywords instead of framework type names for type references +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion + +# Suggest more modern language features when available +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion + +# Whitespace options +dotnet_style_allow_multiple_blank_lines_experimental = false + +# Non-private static fields are PascalCase +dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.symbols = non_private_static_fields +dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.style = non_private_static_field_style + +dotnet_naming_symbols.non_private_static_fields.applicable_kinds = field +dotnet_naming_symbols.non_private_static_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected +dotnet_naming_symbols.non_private_static_fields.required_modifiers = static + +dotnet_naming_style.non_private_static_field_style.capitalization = pascal_case + +# Non-private readonly fields are PascalCase +dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.symbols = non_private_readonly_fields +dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.style = non_private_readonly_field_style + +dotnet_naming_symbols.non_private_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.non_private_readonly_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected +dotnet_naming_symbols.non_private_readonly_fields.required_modifiers = readonly + +dotnet_naming_style.non_private_readonly_field_style.capitalization = pascal_case + +# Constants are PascalCase +dotnet_naming_rule.constants_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.constants_should_be_pascal_case.symbols = constants +dotnet_naming_rule.constants_should_be_pascal_case.style = constant_style + +dotnet_naming_symbols.constants.applicable_kinds = field, local +dotnet_naming_symbols.constants.required_modifiers = const + +dotnet_naming_style.constant_style.capitalization = pascal_case + +# Static fields are camelCase and start with s_ +dotnet_naming_rule.static_fields_should_be_camel_case.severity = suggestion +dotnet_naming_rule.static_fields_should_be_camel_case.symbols = static_fields +dotnet_naming_rule.static_fields_should_be_camel_case.style = static_field_style + +dotnet_naming_symbols.static_fields.applicable_kinds = field +dotnet_naming_symbols.static_fields.required_modifiers = static + +dotnet_naming_style.static_field_style.capitalization = camel_case +dotnet_naming_style.static_field_style.required_prefix = s_ + +# Instance fields are camelCase and start with _ +dotnet_naming_rule.instance_fields_should_be_camel_case.severity = suggestion +dotnet_naming_rule.instance_fields_should_be_camel_case.symbols = instance_fields +dotnet_naming_rule.instance_fields_should_be_camel_case.style = instance_field_style + +dotnet_naming_symbols.instance_fields.applicable_kinds = field + +dotnet_naming_style.instance_field_style.capitalization = camel_case +dotnet_naming_style.instance_field_style.required_prefix = _ + +# Locals and parameters are camelCase +dotnet_naming_rule.locals_should_be_camel_case.severity = suggestion +dotnet_naming_rule.locals_should_be_camel_case.symbols = locals_and_parameters +dotnet_naming_rule.locals_should_be_camel_case.style = camel_case_style + +dotnet_naming_symbols.locals_and_parameters.applicable_kinds = parameter, local + +dotnet_naming_style.camel_case_style.capitalization = camel_case + +# Local functions are PascalCase +dotnet_naming_rule.local_functions_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.local_functions_should_be_pascal_case.symbols = local_functions +dotnet_naming_rule.local_functions_should_be_pascal_case.style = local_function_style + +dotnet_naming_symbols.local_functions.applicable_kinds = local_function + +dotnet_naming_style.local_function_style.capitalization = pascal_case + +# By default, name items with PascalCase +dotnet_naming_rule.members_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.members_should_be_pascal_case.symbols = all_members +dotnet_naming_rule.members_should_be_pascal_case.style = pascal_case_style + +dotnet_naming_symbols.all_members.applicable_kinds = * + +dotnet_naming_style.pascal_case_style.capitalization = pascal_case + +# error RS2008: Enable analyzer release tracking for the analyzer project containing rule '{0}' +dotnet_diagnostic.RS2008.severity = none + +# IDE0073: File header +dotnet_diagnostic.IDE0073.severity = none +file_header_template = + +# IDE0035: Remove unreachable code +dotnet_diagnostic.IDE0035.severity = warning + +# IDE0036: Order modifiers +dotnet_diagnostic.IDE0036.severity = warning + +# IDE0043: Format string contains invalid placeholder +dotnet_diagnostic.IDE0043.severity = warning + +# IDE0044: Make field readonly +dotnet_diagnostic.IDE0044.severity = warning + +# RS0016: Only enable if API files are present +dotnet_public_api_analyzer.require_api_files = true + +# CSharp code style settings: +[*.cs] + +# enforce file scoped namespaces +csharp_style_namespace_declarations = file_scoped:warning + +# Newline settings +csharp_new_line_before_open_brace = none +csharp_new_line_before_else = false +csharp_new_line_before_catch = false +csharp_new_line_before_finally = false +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = false +csharp_indent_switch_labels = true +csharp_indent_labels = flush_left + +# Whitespace options +csharp_style_allow_embedded_statements_on_same_line_experimental = true +csharp_style_allow_blank_lines_between_consecutive_braces_experimental = false +csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = false + +# Prefer "var" everywhere +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = true:suggestion + +# Prefer method-like constructs to have a block body +csharp_style_expression_bodied_methods = false:none +csharp_style_expression_bodied_constructors = false:none +csharp_style_expression_bodied_operators = false:none + +# Prefer property-like constructs to have an expression-body +csharp_style_expression_bodied_properties = true:none +csharp_style_expression_bodied_indexers = true:none +csharp_style_expression_bodied_accessors = true:none + +# Suggest more modern language features when available +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = do_not_ignore +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Blocks are allowed +csharp_prefer_braces = when_multiline:warning +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true + +# Currently only enabled for C# due to crash in VB analyzer. VB can be enabled once +# https://github.com/dotnet/roslyn/pull/54259 has been published. +dotnet_style_allow_statement_immediately_after_block_experimental = false + +# ignore unused value expression message +csharp_style_unused_value_expression_statement_preference = discard_variable:none + +# allow underscores in our enums +dotnet_diagnostic.CA1707.severity = none + +[OOT.API/Models/Contract/**.cs] +# suppress analyzers in generated code +dotnet_analyzer_diagnostic.severity = none + +[src/CodeStyle/**.{cs,vb}] +# warning RS0005: Do not use generic CodeAction.Create to create CodeAction +dotnet_diagnostic.RS0005.severity = none + +[src/{Analyzers,CodeStyle,Features,Workspaces,EditorFeatures,VisualStudio}/**/*.{cs,vb}] + +# IDE0011: Add braces +csharp_prefer_braces = when_multiline:warning +# NOTE: We need the below severity entry for Add Braces due to https://github.com/dotnet/roslyn/issues/44201 +dotnet_diagnostic.IDE0011.severity = warning + +# IDE0040: Add accessibility modifiers +dotnet_diagnostic.IDE0040.severity = warning + +# CONSIDER: Are IDE0051 and IDE0052 too noisy to be warnings for IDE editing scenarios? Should they be made build-only warnings? +# IDE0051: Remove unused private member +dotnet_diagnostic.IDE0051.severity = warning + +# IDE0052: Remove unread private member +dotnet_diagnostic.IDE0052.severity = warning + +# IDE0059: Unnecessary assignment to a value +dotnet_diagnostic.IDE0059.severity = warning + +# IDE0060: Remove unused parameter +dotnet_diagnostic.IDE0060.severity = warning + +# CA1012: Abstract types should not have public constructors +dotnet_diagnostic.CA1012.severity = warning + +# CA1822: Make member static +dotnet_diagnostic.CA1822.severity = warning + +# Prefer "var" everywhere +dotnet_diagnostic.IDE0007.severity = warning +csharp_style_var_for_built_in_types = true:warning +csharp_style_var_when_type_is_apparent = true:warning +csharp_style_var_elsewhere = true:warning + +# dotnet_style_allow_multiple_blank_lines_experimental +dotnet_diagnostic.IDE2000.severity = warning + +# csharp_style_allow_embedded_statements_on_same_line_experimental +dotnet_diagnostic.IDE2001.severity = warning + +# csharp_style_allow_blank_lines_between_consecutive_braces_experimental +dotnet_diagnostic.IDE2002.severity = warning + +# dotnet_style_allow_statement_immediately_after_block_experimental +dotnet_diagnostic.IDE2003.severity = warning + +# csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental +dotnet_diagnostic.IDE2004.severity = warning + +[src/{VisualStudio}/**/*.{cs,vb}] +# CA1822: Make member static +# Not enforced as a build 'warning' for 'VisualStudio' layer due to large number of false positives from https://github.com/dotnet/roslyn-analyzers/issues/3857 and https://github.com/dotnet/roslyn-analyzers/issues/3858 +# Additionally, there is a risk of accidentally breaking an internal API that partners rely on though IVT. +dotnet_diagnostic.CA1822.severity = suggestion diff --git a/src/Program.cs b/src/Program.cs new file mode 100644 index 0000000..42701ab --- /dev/null +++ b/src/Program.cs @@ -0,0 +1,311 @@ +using System.Globalization; +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Fido2NetLib; +using Fido2NetLib.Objects; +using Microsoft.Data.Sqlite; +using SQLitePCL; + +namespace WebauthnProxy; + +public class AddKeyRequest { + public required string Password { get; set; } + public required AuthenticatorAttestationRawResponse Response { get; set; } +} + +public static class Program { + private const string COOKIE_NAME = "__Secure-Token"; + private const string SESS_ATTESTATION_KEY = "fido2.attestationOptions"; + private const string SESS_ASSERTION_KEY = "fido2.assertionOptions"; + + private static readonly string s_loginHtmlCache = File.ReadAllText(Path.Combine( + AppDomain.CurrentDomain.BaseDirectory, "login.html")); + private static readonly string s_domain = Environment + .GetEnvironmentVariable("WEBAUTHN_DOMAIN") ?? "localhost"; + private static readonly string s_db = Environment + .GetEnvironmentVariable("WEBAUTHN_DB") ?? "credentials.db"; + private static readonly long s_lifetime = long.Parse(Environment + .GetEnvironmentVariable("WEBAUTHN_LIFETIME") ?? "7", + CultureInfo.InvariantCulture) * 60 * 60 * 24; + private static readonly string? s_password = Environment + .GetEnvironmentVariable("WEBAUTHN_PASSWORD"); + private static readonly int s_port = int.Parse(Environment + .GetEnvironmentVariable("WEBAUTHN_PORT") ?? "5000", + CultureInfo.InvariantCulture); + private static readonly Fido2 s_fido2 = new(new Fido2Configuration { + ServerDomain = s_domain, + ServerName = "WebauthnProxy", + Origins = new(new[] { $"http{( + s_domain == "localhost" + ? string.Empty + : "s")}://{s_domain}{( + s_domain == "localhost" + ? $":{s_port}" + : string.Empty)}" }), + }); + private static readonly List s_keys = new(); + + private static string ConnectionString { get => $"Data Source={s_db}"; } + + public static void Main(string[] args) { + var app = Initialize(args); + app.UseSession(); + + app.MapGet("/favicon.ico", () => Results.File(Convert.FromBase64String( + $"AAABAAEAEBAAAAAAAABoBQAAFgAAACgAAAAQAAAAIAAAAAEACAAAAAAAAAEAAAAAAAAAAAAAAAEAAAAAAAD///8{new string('A', 1788)}="), + contentType: "image/x-icon")); + + app.MapGet("/auth/check", async (context) => { + var token = context.Request.Cookies[COOKIE_NAME]; + Console.WriteLine(token); + if (!TokenIsValid(token)) { + context.Response.ContentType = "text/plain"; + context.Response.StatusCode = (int)HttpStatusCode.Unauthorized; + await context.Response.WriteAsync("unauthorized"); + return; + } + + context.Response.ContentType = "text/plain"; + context.Response.StatusCode = (int)HttpStatusCode.OK; + await context.Response.WriteAsync("authorized"); + }); + + app.MapGet("/auth/login", async (context) => { + context.Response.ContentType = "text/html"; + context.Response.StatusCode = (int)HttpStatusCode.OK; + await context.Response.WriteAsync(s_loginHtmlCache); + }); + + app.MapGet("/auth/logout", (context) => { + if (context.Request.Cookies[COOKIE_NAME] == null) + return Task.CompletedTask; + + context.Response.Cookies.Append(COOKIE_NAME, string.Empty, new CookieOptions { + Path = "/", + Secure = true, + HttpOnly = true, + MaxAge = TimeSpan.Zero, + Domain = s_domain, + }); + + context.Response.Redirect("/"); + return Task.CompletedTask; + }); + + app.MapPost("/auth/key", async (context) => { + var options = s_fido2.GetAssertionOptions( + s_keys, UserVerificationRequirement.Discouraged); + context.Session.SetString(SESS_ASSERTION_KEY, + JsonSerializer.Serialize(options)); + await context.Response.WriteAsJsonAsync(options); + }); + + app.MapPost("/auth/complete", async (context) => { + var assertionResponse = await context.Request + .ReadFromJsonAsync(); + if (assertionResponse == null) { + context.Response.StatusCode = 401; + await context.Response.WriteAsJsonAsync(new { error = "bad response" }); + return; + } + + var optsJson = context.Session.GetString(SESS_ASSERTION_KEY); + context.Session.Remove(SESS_ASSERTION_KEY); + var opts = AssertionOptions.FromJson(optsJson); + using var connection = new SqliteConnection(ConnectionString); + connection.Open(); + byte[]? pubKey; + using (var cmd = new SqliteCommand( + "select key from credentials where id=@Id", + connection)) { + var param = cmd.CreateParameter(); + param.ParameterName = "@Id"; + param.Value = Convert.ToBase64String(assertionResponse.Id); + cmd.Parameters.Add(param); + pubKey = cmd.ExecuteScalar() is string keyStr + ? Convert.FromBase64String(keyStr) + : null; + if (pubKey == null) { + context.Response.StatusCode = 401; + await context.Response.WriteAsJsonAsync(new { error = "bad key" }); + return; + } + } + + var res = await s_fido2.MakeAssertionAsync( + assertionResponse, opts, pubKey, 0, (_, _) => Task.FromResult(true)); + if (res.Status != "ok") { + context.Response.StatusCode = 401; + await context.Response.WriteAsJsonAsync(new { error = "auth failed" }); + return; + } + + context.Response.Cookies.Append( + COOKIE_NAME, + GenerateToken(connection), + new CookieOptions { + Path = "/", + Secure = true, + HttpOnly = true, + SameSite = SameSiteMode.None, + Domain = s_domain, + MaxAge = TimeSpan.FromSeconds(s_lifetime), + }); + await context.Response.WriteAsJsonAsync(new { status = "ok" }); + }); + + app.MapPost("/auth/new-key", async (context) => { + var user = new Fido2User { + Id = Encoding.UTF8.GetBytes("default"), + Name = "Default User", + DisplayName = "Default User", + }; + var options = s_fido2.RequestNewCredential( + user, + new List(), + AuthenticatorSelection.Default, + AttestationConveyancePreference.None); + context.Session.SetString(SESS_ATTESTATION_KEY, + JsonSerializer.Serialize(options)); + + await context.Response.WriteAsJsonAsync(options); + }); + + app.MapPost("/auth/add-key", async (context) => { + var req = await context.Request.ReadFromJsonAsync(); + if (req == null) { + context.Response.StatusCode = 400; + return; + } + + if (string.IsNullOrEmpty(s_password) + || s_password != req.Password) { + context.Response.StatusCode = 401; + await context.Response.WriteAsJsonAsync(new { error = "bad password" }); + return; + } + + var optsJson = context.Session.GetString(SESS_ATTESTATION_KEY); + context.Session.Remove(SESS_ATTESTATION_KEY); + var opts = CredentialCreateOptions.FromJson(optsJson); + var cred = await s_fido2.MakeNewCredentialAsync( + req.Response, opts, (_, _) => Task.FromResult(true)); + var descriptor = new PublicKeyCredentialDescriptor( + cred.Result!.CredentialId); + + // store in list and save to database + s_keys.Add(descriptor); + using var connection = new SqliteConnection(ConnectionString); + connection.Open(); + using var cmd = new SqliteCommand( + @"insert into credentials (id, key) values (@Id, @Key)", + connection); + var idParam = cmd.CreateParameter(); + idParam.ParameterName = "@Id"; + idParam.Value = Convert.ToBase64String(cred.Result.CredentialId); + var keyParam = cmd.CreateParameter(); + keyParam.ParameterName = "@Key"; + keyParam.Value = Convert.ToBase64String(cred.Result.PublicKey); + cmd.Parameters.Add(idParam); + cmd.Parameters.Add(keyParam); + await cmd.ExecuteNonQueryAsync(); + }); + + app.Run($"http://0.0.0.0:{s_port}"); + } + + private static string GenerateToken(SqliteConnection connection) { + var rng = RandomNumberGenerator.Create(); + var bytes = new byte[32]; + rng.GetBytes(bytes); + var token = Convert.ToBase64String(bytes); + using var cmd = new SqliteCommand(@"insert into tokens (token) values (@Token)", + connection); + var param = cmd.CreateParameter(); + param.ParameterName = "@Token"; + param.Value = token; + cmd.Parameters.Add(param); + cmd.ExecuteNonQuery(); + return token; + } + + private static bool TokenIsValid(string? token) { + if (string.IsNullOrWhiteSpace(token)) return false; + + using var connection = new SqliteConnection(ConnectionString); + connection.Open(); + using var cmd = new SqliteCommand( + @"select 1 from tokens where + token=@Token + and julianday(created_date, @Timeout) > julianday(CURRENT_TIMESTAMP)", + connection); + var tokenParam = cmd.CreateParameter(); + tokenParam.ParameterName = "@Token"; + tokenParam.Value = token; + var timeoutParam = cmd.CreateParameter(); + timeoutParam.ParameterName = "@Timeout"; + timeoutParam.Value = $"+{s_lifetime} seconds"; + cmd.Parameters.Add(tokenParam); + cmd.Parameters.Add(timeoutParam); + return cmd.ExecuteScalar() != null; + } + + private static void RemoveExpiredTokens() { + using var connection = new SqliteConnection(ConnectionString); + connection.Open(); + using var cmd = new SqliteCommand( + @"delete from tokens where + julianday(created_date, @Timeout) < julianday(CURRENT_TIMESTAMP)", + connection); + var timeoutParam = cmd.CreateParameter(); + timeoutParam.ParameterName = "@Timeout"; + timeoutParam.Value = $"+{s_lifetime} seconds"; + cmd.Parameters.Add(timeoutParam); + cmd.ExecuteNonQuery(); + } + + private static WebApplication Initialize(string[] args) { + Batteries.Init(); + + using var connection = new SqliteConnection(ConnectionString); + connection.Open(); + using (var cmd = new SqliteCommand( + @"select 1 from sqlite_master + where type='table' and name='credentials'", + connection)) { + var exists = cmd.ExecuteScalar(); + if (exists == null) { + var schemaPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "schema.sql"); + if (!File.Exists(schemaPath)) { + throw new FileNotFoundException(schemaPath); + } + + var schema = File.ReadAllText(schemaPath); + using var schemaCmd = new SqliteCommand(schema, connection); + schemaCmd.ExecuteNonQuery(); + } + } + + RemoveExpiredTokens(); + + // read credentials + using (var cmd = new SqliteCommand( + @"select id from credentials", + connection)) { + var reader = cmd.ExecuteReader(); + while (reader.Read()) { + var id = reader.GetString(0); + var desc = new PublicKeyCredentialDescriptor( + Convert.FromBase64String(id)); + if (desc != null) s_keys.Add(desc); + } + } + + var builder = WebApplication.CreateBuilder(args); + builder.Services.AddDistributedMemoryCache(); + builder.Services.AddSession(); + return builder.Build(); + } +} diff --git a/src/login.html b/src/login.html new file mode 100644 index 0000000..7c1f472 --- /dev/null +++ b/src/login.html @@ -0,0 +1,102 @@ + + + + +
+ + diff --git a/src/omnisharp.json b/src/omnisharp.json new file mode 100644 index 0000000..b2e2969 --- /dev/null +++ b/src/omnisharp.json @@ -0,0 +1,8 @@ +{ + "RoslynExtensionsOptions": { + "enableAnalyzersSupport": true + }, + "FormattingOptions": { + "enableEditorConfigSupport": true + } +} diff --git a/src/schema.sql b/src/schema.sql new file mode 100644 index 0000000..51be780 --- /dev/null +++ b/src/schema.sql @@ -0,0 +1,10 @@ +create table credentials ( + id text primary key, + key text not null, + created_date datetime not null default CURRENT_TIMESTAMP +); + +create table tokens ( + token text primary key, + created_date datetime not null default CURRENT_TIMESTAMP +); diff --git a/src/webauthn-proxy.csproj b/src/webauthn-proxy.csproj new file mode 100644 index 0000000..1812153 --- /dev/null +++ b/src/webauthn-proxy.csproj @@ -0,0 +1,24 @@ + + + + net7.0 + enable + enable + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + +