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.
- ssh_server_manager/__init__.py +4 -0
- ssh_server_manager/askpass.py +63 -0
- ssh_server_manager/assets/ui/app.js +452 -0
- ssh_server_manager/assets/ui/index.html +188 -0
- ssh_server_manager/assets/ui/styles.css +104 -0
- ssh_server_manager/auth.py +189 -0
- ssh_server_manager/cli.py +406 -0
- ssh_server_manager/db.py +477 -0
- ssh_server_manager/importer.py +149 -0
- ssh_server_manager/paths.py +80 -0
- ssh_server_manager/service.py +102 -0
- ssh_server_manager/ssh_config.py +162 -0
- ssh_server_manager/ssh_runner.py +262 -0
- ssh_server_manager/validation.py +89 -0
- ssh_server_manager/vault.py +113 -0
- ssh_server_manager/webapp.py +377 -0
- ssh_server_manager-0.2.0.dist-info/METADATA +173 -0
- ssh_server_manager-0.2.0.dist-info/RECORD +21 -0
- ssh_server_manager-0.2.0.dist-info/WHEEL +5 -0
- ssh_server_manager-0.2.0.dist-info/entry_points.txt +3 -0
- ssh_server_manager-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
|