ssh-server-manager 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,188 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>SSH Server Manager</title>
7
+ <link rel="stylesheet" href="/assets/styles.css">
8
+ </head>
9
+ <body>
10
+ <header class="topbar">
11
+ <div>
12
+ <p class="eyebrow">LOCAL CONTROL PLANE</p>
13
+ <h1>SSH Server Manager</h1>
14
+ </div>
15
+ <div class="header-actions">
16
+ <span id="systemStatus" class="status-pill neutral">Starting…</span>
17
+ <button id="refreshButton" class="secondary">Refresh</button>
18
+ </div>
19
+ </header>
20
+
21
+ <main>
22
+ <section class="summary-grid" aria-label="Summary">
23
+ <article class="summary-card">
24
+ <span>Connection profiles</span>
25
+ <strong id="serverCount">0</strong>
26
+ </article>
27
+ <article class="summary-card">
28
+ <span>Credential profiles</span>
29
+ <strong id="credentialCount">0</strong>
30
+ </article>
31
+ <article class="summary-card wide">
32
+ <span>Managed OpenSSH config</span>
33
+ <code id="configPath">—</code>
34
+ </article>
35
+ </section>
36
+
37
+ <section class="panel">
38
+ <div class="panel-heading">
39
+ <div>
40
+ <p class="eyebrow">INVENTORY</p>
41
+ <h2>Servers</h2>
42
+ </div>
43
+ <div class="row-actions">
44
+ <button id="importButton" class="secondary">Import SSH config</button>
45
+ <button id="addServerButton">Add server</button>
46
+ </div>
47
+ </div>
48
+ <div class="table-wrap">
49
+ <table>
50
+ <thead>
51
+ <tr><th>Alias</th><th>Endpoint</th><th>Credential</th><th>ProxyJump</th><th>Status</th><th></th></tr>
52
+ </thead>
53
+ <tbody id="serverRows"></tbody>
54
+ </table>
55
+ </div>
56
+ <p id="emptyServers" class="empty">No managed servers yet. Import your SSH config or add one.</p>
57
+ </section>
58
+
59
+ <section class="panel">
60
+ <div class="panel-heading">
61
+ <div>
62
+ <p class="eyebrow">SECURE STORAGE</p>
63
+ <h2>Credentials</h2>
64
+ </div>
65
+ <button id="addCredentialButton">Add credential</button>
66
+ </div>
67
+ <div class="notice">
68
+ Passwords and key passphrases stay in your operating-system vault. Private keys are referenced by path and never uploaded.
69
+ </div>
70
+ <div class="table-wrap">
71
+ <table>
72
+ <thead>
73
+ <tr><th>Label</th><th>Type</th><th>Secret state</th><th>Key path</th><th></th></tr>
74
+ </thead>
75
+ <tbody id="credentialRows"></tbody>
76
+ </table>
77
+ </div>
78
+ <p id="emptyCredentials" class="empty">No credential profiles configured.</p>
79
+ </section>
80
+
81
+ <section class="panel auth-panel">
82
+ <div>
83
+ <p class="eyebrow">REVEAL PROTECTION</p>
84
+ <h2>Local reauthentication</h2>
85
+ <p id="authDescription">Checking authentication options…</p>
86
+ </div>
87
+ <div class="row-actions">
88
+ <button id="enrollPasskeyButton" class="secondary">Enroll passkey</button>
89
+ <button id="enrollMasterButton" class="secondary">Set fallback master password</button>
90
+ </div>
91
+ </section>
92
+ </main>
93
+
94
+ <div id="toast" role="status" aria-live="polite"></div>
95
+
96
+ <dialog id="serverDialog">
97
+ <form id="serverForm" method="dialog">
98
+ <div class="dialog-heading">
99
+ <h2 id="serverDialogTitle">Add server</h2>
100
+ <button type="button" class="icon-button close-dialog" aria-label="Close">×</button>
101
+ </div>
102
+ <input id="serverId" type="hidden">
103
+ <label>Alias<input id="serverAlias" required pattern="[A-Za-z0-9][A-Za-z0-9._-]{0,62}"></label>
104
+ <div class="two-columns">
105
+ <label>Hostname or IP<input id="serverHostname" required></label>
106
+ <label>Port<input id="serverPort" type="number" min="1" max="65535" value="22" required></label>
107
+ </div>
108
+ <label>Username<input id="serverUsername" required></label>
109
+ <label>Credential
110
+ <div class="credential-picker">
111
+ <select id="serverCredential"><option value="">OpenSSH default / agent</option></select>
112
+ <button id="newServerCredentialButton" type="button" class="secondary small">+ New</button>
113
+ </div>
114
+ </label>
115
+ <fieldset id="serverCredentialComposer" class="inline-composer" hidden>
116
+ <legend>New credential</legend>
117
+ <p class="hint">Create it here and it will be selected for this server automatically.</p>
118
+ <label>Label<input id="serverCredentialNewLabel" maxlength="100" autocomplete="off"></label>
119
+ <label>Authentication type
120
+ <select id="serverCredentialNewKind">
121
+ <option value="password">Password</option>
122
+ <option value="key">Private key file</option>
123
+ <option value="agent">OpenSSH default / ssh-agent</option>
124
+ </select>
125
+ </label>
126
+ <label id="serverCredentialNewSecretField">Password<input id="serverCredentialNewSecret" type="password" autocomplete="new-password"></label>
127
+ <label id="serverCredentialNewKeyPathField" hidden>Private-key path<input id="serverCredentialNewKeyPath" placeholder="~/.ssh/id_ed25519"></label>
128
+ <label id="serverCredentialNewPassphraseField" hidden>Key passphrase (optional)<input id="serverCredentialNewPassphrase" type="password" autocomplete="new-password"></label>
129
+ <div class="inline-composer-actions">
130
+ <button id="cancelServerCredentialButton" type="button" class="secondary">Cancel</button>
131
+ <button id="saveServerCredentialButton" type="button">Save and use</button>
132
+ </div>
133
+ </fieldset>
134
+ <label>ProxyJump aliases<input id="serverProxy" placeholder="jump-one, jump-two"></label>
135
+ <label>Notes<textarea id="serverNotes" rows="3"></textarea></label>
136
+ <div class="dialog-actions"><button type="button" class="secondary close-dialog">Cancel</button><button type="submit">Save server</button></div>
137
+ </form>
138
+ </dialog>
139
+
140
+ <dialog id="credentialDialog">
141
+ <form id="credentialForm" method="dialog">
142
+ <div class="dialog-heading">
143
+ <h2 id="credentialDialogTitle">Add credential</h2>
144
+ <button type="button" class="icon-button close-dialog" aria-label="Close">×</button>
145
+ </div>
146
+ <input id="credentialId" type="hidden">
147
+ <label>Label<input id="credentialLabel" required maxlength="100"></label>
148
+ <label>Authentication type
149
+ <select id="credentialKind">
150
+ <option value="password">Password</option>
151
+ <option value="key">Private key file</option>
152
+ <option value="agent">OpenSSH default / ssh-agent</option>
153
+ </select>
154
+ </label>
155
+ <label id="secretField">Password<input id="credentialSecret" type="password" autocomplete="new-password"></label>
156
+ <label id="keyPathField" hidden>Private-key path<input id="credentialKeyPath" placeholder="~/.ssh/id_ed25519"></label>
157
+ <label id="passphraseField" hidden>Key passphrase (optional)<input id="credentialPassphrase" type="password" autocomplete="new-password"></label>
158
+ <label id="clearPassphraseField" class="checkbox-label" hidden><input id="credentialClearPassphrase" type="checkbox"> Remove the stored passphrase</label>
159
+ <p id="credentialEditHint" class="hint" hidden>Leave secret fields empty to keep their existing values.</p>
160
+ <div class="dialog-actions"><button type="button" class="secondary close-dialog">Cancel</button><button type="submit">Save credential</button></div>
161
+ </form>
162
+ </dialog>
163
+
164
+ <dialog id="revealDialog">
165
+ <div class="dialog-heading">
166
+ <h2>Revealed secret</h2>
167
+ <button type="button" class="icon-button close-reveal" aria-label="Close">×</button>
168
+ </div>
169
+ <p>This value will be cleared from the page in <strong id="revealCountdown">15</strong> seconds.</p>
170
+ <div class="secret-box"><code id="revealedSecret"></code><button id="copySecretButton" class="secondary">Copy</button></div>
171
+ <div class="dialog-actions"><button type="button" class="secondary close-reveal">Close now</button></div>
172
+ </dialog>
173
+
174
+ <dialog id="masterDialog">
175
+ <form id="masterForm" method="dialog">
176
+ <div class="dialog-heading">
177
+ <h2 id="masterDialogTitle">Local authentication</h2>
178
+ <button type="button" class="icon-button cancel-master" aria-label="Close">×</button>
179
+ </div>
180
+ <label>Master password<input id="masterPassword" type="password" autocomplete="current-password" required minlength="12"></label>
181
+ <label id="masterConfirmField" hidden>Confirm master password<input id="masterPasswordConfirm" type="password" autocomplete="new-password" minlength="12"></label>
182
+ <div class="dialog-actions"><button type="button" class="secondary cancel-master">Cancel</button><button type="submit">Authenticate</button></div>
183
+ </form>
184
+ </dialog>
185
+
186
+ <script src="/assets/app.js" defer></script>
187
+ </body>
188
+ </html>
@@ -0,0 +1,104 @@
1
+ :root {
2
+ color-scheme: light;
3
+ --ink: #14201d;
4
+ --muted: #61706b;
5
+ --line: #dce3df;
6
+ --paper: #f4f7f5;
7
+ --panel: #ffffff;
8
+ --accent: #0f766e;
9
+ --accent-hover: #0a5f59;
10
+ --danger: #b42318;
11
+ --warning: #9a6700;
12
+ --success: #087443;
13
+ --shadow: 0 18px 48px rgba(20, 32, 29, 0.08);
14
+ font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
15
+ }
16
+
17
+ * { box-sizing: border-box; }
18
+ body { margin: 0; background: var(--paper); color: var(--ink); min-height: 100vh; }
19
+ button, input, select, textarea { font: inherit; }
20
+ button { border: 0; border-radius: 9px; background: var(--accent); color: white; padding: 10px 15px; cursor: pointer; font-weight: 650; }
21
+ button:hover { background: var(--accent-hover); }
22
+ button:disabled { opacity: .45; cursor: wait; }
23
+ button.secondary { background: #e8efec; color: var(--ink); }
24
+ button.secondary:hover { background: #dce8e3; }
25
+ button.danger { background: #fff0ee; color: var(--danger); }
26
+ button.small { padding: 6px 9px; font-size: 12px; }
27
+
28
+ .topbar { display: flex; align-items: flex-end; justify-content: space-between; padding: 34px max(28px, calc((100vw - 1280px) / 2)); background: #102b27; color: white; }
29
+ h1, h2, p { margin-top: 0; }
30
+ h1 { margin-bottom: 0; font-size: clamp(28px, 4vw, 42px); letter-spacing: -.035em; }
31
+ h2 { margin-bottom: 0; font-size: 21px; }
32
+ .eyebrow { margin-bottom: 7px; color: #79c7b9; font-size: 11px; font-weight: 800; letter-spacing: .17em; }
33
+ .header-actions, .row-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
34
+ .status-pill { display: inline-flex; align-items: center; min-height: 32px; padding: 7px 11px; border-radius: 999px; font-size: 12px; font-weight: 750; }
35
+ .status-pill.neutral { background: rgba(255,255,255,.14); }
36
+ .status-pill.ok { background: #d6f5e5; color: #075b35; }
37
+ .status-pill.failed { background: #ffe2de; color: #8b2118; }
38
+
39
+ main { width: min(1280px, calc(100% - 40px)); margin: 28px auto 80px; display: grid; gap: 22px; }
40
+ .summary-grid { display: grid; grid-template-columns: 1fr 1fr 2fr; gap: 16px; }
41
+ .summary-card { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 18px 20px; box-shadow: var(--shadow); }
42
+ .summary-card span { display: block; color: var(--muted); font-size: 12px; margin-bottom: 10px; }
43
+ .summary-card strong { font-size: 30px; }
44
+ .summary-card code { overflow-wrap: anywhere; }
45
+ .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 15px; padding: 22px; box-shadow: var(--shadow); }
46
+ .panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin-bottom: 18px; }
47
+ .table-wrap { overflow-x: auto; }
48
+ table { width: 100%; border-collapse: collapse; }
49
+ th { color: var(--muted); text-align: left; font-size: 11px; letter-spacing: .06em; text-transform: uppercase; padding: 10px 9px; border-bottom: 1px solid var(--line); }
50
+ td { padding: 13px 9px; border-bottom: 1px solid #edf1ef; vertical-align: middle; font-size: 14px; }
51
+ td:last-child { text-align: right; min-width: 200px; }
52
+ td code { font-size: 12px; }
53
+ .alias { font-weight: 750; }
54
+ .subtle { color: var(--muted); font-size: 12px; }
55
+ .empty { color: var(--muted); text-align: center; padding: 32px 0 12px; }
56
+ .notice { background: #f1f8f5; border: 1px solid #d3e9e0; border-radius: 10px; padding: 11px 14px; color: #315c52; font-size: 13px; margin-bottom: 14px; }
57
+ .auth-panel { display: flex; justify-content: space-between; align-items: center; gap: 20px; }
58
+ .auth-panel p:not(.eyebrow) { margin: 8px 0 0; color: var(--muted); }
59
+
60
+ dialog { width: min(560px, calc(100% - 28px)); border: 0; border-radius: 16px; padding: 0; color: var(--ink); box-shadow: 0 30px 90px rgba(0,0,0,.24); }
61
+ dialog::backdrop { background: rgba(9, 26, 22, .56); backdrop-filter: blur(3px); }
62
+ dialog form, dialog > div:not(.dialog-heading), dialog > p, dialog > .secret-box { margin-left: 24px; margin-right: 24px; }
63
+ dialog form { margin: 0; padding: 24px; }
64
+ .dialog-heading { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-bottom: 20px; }
65
+ #revealDialog { padding: 24px; }
66
+ #revealDialog .dialog-heading, #revealDialog p, #revealDialog .secret-box, #revealDialog .dialog-actions { margin-left: 0; margin-right: 0; }
67
+ .icon-button { background: transparent; color: var(--muted); padding: 3px 8px; font-size: 25px; }
68
+ .icon-button:hover { background: #eef2f0; }
69
+ label { display: grid; gap: 7px; margin-bottom: 15px; font-size: 13px; font-weight: 700; }
70
+ .checkbox-label { display: flex; align-items: center; gap: 9px; }
71
+ .checkbox-label input { width: auto; }
72
+ input, select, textarea { width: 100%; border: 1px solid #cbd5d0; border-radius: 9px; padding: 10px 11px; color: var(--ink); background: white; }
73
+ input:focus, select:focus, textarea:focus { outline: 3px solid rgba(15,118,110,.14); border-color: var(--accent); }
74
+ .two-columns { display: grid; grid-template-columns: 2fr 1fr; gap: 12px; }
75
+ .credential-picker { display: flex; align-items: center; gap: 8px; }
76
+ .credential-picker select { flex: 1; min-width: 0; }
77
+ .inline-composer { margin: 2px 0 16px; padding: 15px 16px 3px; border: 1px solid #cfe2db; border-radius: 11px; background: #f5faf8; }
78
+ .inline-composer legend { padding: 0 6px; color: var(--accent); font-size: 13px; font-weight: 800; }
79
+ .inline-composer .hint { margin: -2px 0 14px; }
80
+ .inline-composer label { margin-bottom: 12px; }
81
+ .inline-composer-actions { display: flex; justify-content: flex-end; gap: 8px; margin: 4px 0 12px; }
82
+ .dialog-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; }
83
+ .hint { color: var(--muted); font-size: 12px; }
84
+ .secret-box { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px; background: #102b27; color: #d4fff6; border-radius: 10px; overflow: hidden; }
85
+ .secret-box code { overflow-x: auto; white-space: pre; }
86
+
87
+ #toast { position: fixed; z-index: 20; bottom: 22px; right: 22px; max-width: min(460px, calc(100% - 44px)); padding: 13px 16px; border-radius: 11px; color: white; background: #243a35; box-shadow: var(--shadow); opacity: 0; pointer-events: none; transform: translateY(12px); transition: .2s ease; }
88
+ #toast.show { opacity: 1; transform: translateY(0); }
89
+ #toast.error { background: var(--danger); }
90
+
91
+ @media (max-width: 800px) {
92
+ .topbar { align-items: flex-start; padding: 24px 20px; gap: 20px; }
93
+ .summary-grid { grid-template-columns: 1fr 1fr; }
94
+ .summary-card.wide { grid-column: 1 / -1; }
95
+ .panel-heading, .auth-panel { align-items: flex-start; flex-direction: column; }
96
+ }
97
+ @media (max-width: 520px) {
98
+ .topbar { flex-direction: column; }
99
+ main { width: min(100% - 22px, 1280px); }
100
+ .summary-grid { grid-template-columns: 1fr; }
101
+ .summary-card.wide { grid-column: auto; }
102
+ .panel { padding: 16px; }
103
+ .two-columns { grid-template-columns: 1fr; }
104
+ }
@@ -0,0 +1,189 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import os
6
+ import secrets
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ from .db import Database
11
+
12
+
13
+ class AuthenticationError(RuntimeError):
14
+ pass
15
+
16
+
17
+ def b64url_encode(value: bytes) -> str:
18
+ return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
19
+
20
+
21
+ def b64url_decode(value: str) -> bytes:
22
+ return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
23
+
24
+
25
+ @dataclass
26
+ class PendingCeremony:
27
+ kind: str
28
+ challenge: bytes
29
+
30
+
31
+ class RevealAuth:
32
+ def __init__(self, database: Database, *, rp_id: str = "localhost", origin: str) -> None:
33
+ self.database = database
34
+ self.rp_id = rp_id
35
+ self.origin = origin
36
+ self.pending: dict[str, PendingCeremony] = {}
37
+
38
+ def status(self) -> dict[str, Any]:
39
+ return {
40
+ "passkeys": len(self.database.list_webauthn_credentials()),
41
+ "master_password_enrolled": bool(self.database.get_setting("master_password_hash")),
42
+ "webauthn_available": self._webauthn_available(),
43
+ }
44
+
45
+ @staticmethod
46
+ def _webauthn_available() -> bool:
47
+ try:
48
+ import webauthn # noqa: F401
49
+ except ImportError:
50
+ return False
51
+ return True
52
+
53
+ def _user_id(self) -> bytes:
54
+ existing = self.database.get_setting("webauthn_user_id")
55
+ if existing:
56
+ return b64url_decode(existing)
57
+ value = secrets.token_bytes(32)
58
+ self.database.set_setting("webauthn_user_id", b64url_encode(value))
59
+ return value
60
+
61
+ def begin_registration(self, session_id: str) -> dict[str, Any]:
62
+ try:
63
+ from webauthn import generate_registration_options, options_to_json
64
+ from webauthn.helpers.structs import (
65
+ AuthenticatorSelectionCriteria,
66
+ PublicKeyCredentialDescriptor,
67
+ ResidentKeyRequirement,
68
+ UserVerificationRequirement,
69
+ )
70
+ except ImportError as exc:
71
+ raise AuthenticationError("WebAuthn support is not installed") from exc
72
+ existing = self.database.list_webauthn_credentials()
73
+ options = generate_registration_options(
74
+ rp_id=self.rp_id,
75
+ rp_name="SSH Server Manager",
76
+ user_id=self._user_id(),
77
+ user_name=os.environ.get("USER") or os.environ.get("USERNAME") or "local-user",
78
+ user_display_name="Local SSH Server Manager user",
79
+ exclude_credentials=[PublicKeyCredentialDescriptor(id=b64url_decode(item["credential_id"])) for item in existing],
80
+ authenticator_selection=AuthenticatorSelectionCriteria(
81
+ resident_key=ResidentKeyRequirement.PREFERRED,
82
+ user_verification=UserVerificationRequirement.REQUIRED,
83
+ ),
84
+ )
85
+ self.pending[session_id] = PendingCeremony("register", options.challenge)
86
+ return json.loads(options_to_json(options))
87
+
88
+ def finish_registration(self, session_id: str, response: dict[str, Any]) -> dict[str, Any]:
89
+ pending = self.pending.pop(session_id, None)
90
+ if not pending or pending.kind != "register":
91
+ raise AuthenticationError("registration challenge is missing or expired")
92
+ try:
93
+ from webauthn import verify_registration_response
94
+
95
+ verification = verify_registration_response(
96
+ credential=response,
97
+ expected_challenge=pending.challenge,
98
+ expected_rp_id=self.rp_id,
99
+ expected_origin=self.origin,
100
+ require_user_verification=True,
101
+ )
102
+ except Exception as exc:
103
+ raise AuthenticationError(f"passkey registration failed: {exc}") from exc
104
+ transports = response.get("response", {}).get("transports", [])
105
+ record = {
106
+ "credential_id": b64url_encode(verification.credential_id),
107
+ "public_key": b64url_encode(verification.credential_public_key),
108
+ "sign_count": verification.sign_count,
109
+ "transports": transports,
110
+ "device_type": str(getattr(verification, "credential_device_type", "")),
111
+ "backed_up": bool(getattr(verification, "credential_backed_up", False)),
112
+ }
113
+ self.database.save_webauthn_credential(record)
114
+ return {"ok": True, "credential_id": record["credential_id"]}
115
+
116
+ def begin_authentication(self, session_id: str) -> dict[str, Any]:
117
+ try:
118
+ from webauthn import generate_authentication_options, options_to_json
119
+ from webauthn.helpers.structs import PublicKeyCredentialDescriptor, UserVerificationRequirement
120
+ except ImportError as exc:
121
+ raise AuthenticationError("WebAuthn support is not installed") from exc
122
+ credentials = self.database.list_webauthn_credentials()
123
+ if not credentials:
124
+ raise AuthenticationError("no passkey is enrolled")
125
+ options = generate_authentication_options(
126
+ rp_id=self.rp_id,
127
+ allow_credentials=[
128
+ PublicKeyCredentialDescriptor(id=b64url_decode(item["credential_id"])) for item in credentials
129
+ ],
130
+ user_verification=UserVerificationRequirement.REQUIRED,
131
+ )
132
+ self.pending[session_id] = PendingCeremony("authenticate", options.challenge)
133
+ return json.loads(options_to_json(options))
134
+
135
+ def finish_authentication(self, session_id: str, response: dict[str, Any]) -> dict[str, Any]:
136
+ pending = self.pending.pop(session_id, None)
137
+ if not pending or pending.kind != "authenticate":
138
+ raise AuthenticationError("authentication challenge is missing or expired")
139
+ response_id = response.get("id") or response.get("rawId")
140
+ credential = next(
141
+ (item for item in self.database.list_webauthn_credentials() if item["credential_id"] == response_id),
142
+ None,
143
+ )
144
+ if not credential:
145
+ raise AuthenticationError("passkey is not registered with this application")
146
+ try:
147
+ from webauthn import verify_authentication_response
148
+
149
+ verification = verify_authentication_response(
150
+ credential=response,
151
+ expected_challenge=pending.challenge,
152
+ expected_rp_id=self.rp_id,
153
+ expected_origin=self.origin,
154
+ credential_public_key=b64url_decode(credential["public_key"]),
155
+ credential_current_sign_count=credential["sign_count"],
156
+ require_user_verification=True,
157
+ )
158
+ except Exception as exc:
159
+ raise AuthenticationError(f"passkey authentication failed: {exc}") from exc
160
+ self.database.update_webauthn_sign_count(credential["credential_id"], verification.new_sign_count)
161
+ return {"ok": True}
162
+
163
+ def enroll_master_password(self, password: str) -> dict[str, Any]:
164
+ if self.database.get_setting("master_password_hash"):
165
+ raise AuthenticationError("a master password is already enrolled")
166
+ if len(password) < 12:
167
+ raise AuthenticationError("master password must contain at least 12 characters")
168
+ try:
169
+ from argon2 import PasswordHasher
170
+ except ImportError as exc:
171
+ raise AuthenticationError("Argon2 support is not installed") from exc
172
+ self.database.set_setting("master_password_hash", PasswordHasher().hash(password))
173
+ return {"ok": True}
174
+
175
+ def verify_master_password(self, password: str) -> bool:
176
+ encoded = self.database.get_setting("master_password_hash")
177
+ if not encoded:
178
+ raise AuthenticationError("no master password is enrolled")
179
+ try:
180
+ from argon2 import PasswordHasher
181
+ from argon2.exceptions import VerifyMismatchError
182
+
183
+ valid = PasswordHasher().verify(encoded, password)
184
+ except VerifyMismatchError:
185
+ return False
186
+ except ImportError as exc:
187
+ raise AuthenticationError("Argon2 support is not installed") from exc
188
+ return bool(valid)
189
+