ccssh 1.0.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.
- ccssh/__init__.py +21 -0
- ccssh/__main__.py +5 -0
- ccssh/algs.py +290 -0
- ccssh/async_client.py +419 -0
- ccssh/auth.py +170 -0
- ccssh/certs.py +283 -0
- ccssh/chacha.py +117 -0
- ccssh/channel.py +124 -0
- ccssh/chansock.py +227 -0
- ccssh/cli.py +889 -0
- ccssh/client.py +1274 -0
- ccssh/crypto.py +316 -0
- ccssh/dh_groups.py +92 -0
- ccssh/forward.py +872 -0
- ccssh/gcm.py +87 -0
- ccssh/hostcheck.py +217 -0
- ccssh/hostkeys.py +250 -0
- ccssh/kex.py +222 -0
- ccssh/kex_ext.py +429 -0
- ccssh/mldsa.py +210 -0
- ccssh/mlkem.py +367 -0
- ccssh/mux.py +301 -0
- ccssh/sftp.py +270 -0
- ccssh/shell.py +257 -0
- ccssh/sk.py +224 -0
- ccssh/sntrup.py +325 -0
- ccssh/stub.py +710 -0
- ccssh/transport.py +737 -0
- ccssh/umac.py +262 -0
- ccssh/wire.py +199 -0
- ccssh-1.0.0.dist-info/METADATA +442 -0
- ccssh-1.0.0.dist-info/RECORD +36 -0
- ccssh-1.0.0.dist-info/WHEEL +5 -0
- ccssh-1.0.0.dist-info/entry_points.txt +2 -0
- ccssh-1.0.0.dist-info/licenses/LICENSE +21 -0
- ccssh-1.0.0.dist-info/top_level.txt +1 -0
ccssh/shell.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Interactive shell over a ShellSession: an `ssh host` experience.
|
|
3
|
+
|
|
4
|
+
Single-reader model: a background pump thread owns transport receives and
|
|
5
|
+
writes server output to stdout/stderr; the main thread forwards stdin.
|
|
6
|
+
Terminal is put into raw mode (POSIX) so editors, arrows and Ctrl-C behave;
|
|
7
|
+
SIGWINCH propagates the new size via window-change. The stdin loop polls
|
|
8
|
+
with select(2) so a server-side close (e.g. remote bash exiting after
|
|
9
|
+
Ctrl+D) ends the session promptly instead of hanging on a stdin read.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import select
|
|
15
|
+
import shutil
|
|
16
|
+
import signal
|
|
17
|
+
import sys
|
|
18
|
+
import threading
|
|
19
|
+
|
|
20
|
+
from ccssh import channel as CH
|
|
21
|
+
from ccssh import wire as W
|
|
22
|
+
from ccssh.transport import CCSSHError
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _termsize() -> tuple[int, int]:
|
|
26
|
+
s = shutil.get_terminal_size()
|
|
27
|
+
return max(1, s.columns), max(1, s.lines)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _write_all(fd: int, data: bytes) -> None:
|
|
31
|
+
"""Write the full buffer, retrying partial writes (EAGAIN-safe)."""
|
|
32
|
+
mv = memoryview(data)
|
|
33
|
+
while mv:
|
|
34
|
+
n = os.write(fd, mv)
|
|
35
|
+
if n <= 0:
|
|
36
|
+
raise OSError("short write to output")
|
|
37
|
+
mv = mv[n:]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def run_interactive(client, sess=None, *, pty: bool = True, command: str | None = None, stdin_fd: int | None = None) -> int:
|
|
41
|
+
"""
|
|
42
|
+
Run a shell session interactively. Opens one if not given.
|
|
43
|
+
|
|
44
|
+
With command set, runs it on a pty channel instead of a shell
|
|
45
|
+
(`ssh -t host command`). With pty=False no pty is requested
|
|
46
|
+
(`ssh -T`). stdin_fd overrides standard input (`ssh -n` passes
|
|
47
|
+
/dev/null). Returns the remote exit status (0 when none is sent).
|
|
48
|
+
Restores the terminal on all exits.
|
|
49
|
+
"""
|
|
50
|
+
own = sess is None
|
|
51
|
+
if own:
|
|
52
|
+
cols, rows = _termsize()
|
|
53
|
+
sess = client.open_shell(cols=cols, rows=rows, pty=pty, command=command)
|
|
54
|
+
if sess is None:
|
|
55
|
+
raise CCSSHError("no shell session")
|
|
56
|
+
t = client._t
|
|
57
|
+
if t is None:
|
|
58
|
+
raise CCSSHError("connection closed")
|
|
59
|
+
done = threading.Event()
|
|
60
|
+
err: list[BaseException] = []
|
|
61
|
+
# Through the mux when one is active (forwarding shares the
|
|
62
|
+
# connection); direct transport reads otherwise (test fakes included).
|
|
63
|
+
_rx = getattr(client, "_rx", None)
|
|
64
|
+
|
|
65
|
+
def _recv_one():
|
|
66
|
+
if callable(_rx):
|
|
67
|
+
return _rx(sess.chan)
|
|
68
|
+
return t.recv_packet()
|
|
69
|
+
if stdin_fd is None:
|
|
70
|
+
stdin_fd = sys.stdin.fileno() if hasattr(sys.stdin, "fileno") else -1
|
|
71
|
+
stdin_no = stdin_fd if stdin_fd >= 0 else 0
|
|
72
|
+
tty_mode = stdin_fd >= 0 and sys.stdin.isatty()
|
|
73
|
+
old_attrs = None
|
|
74
|
+
|
|
75
|
+
def pump() -> None:
|
|
76
|
+
try:
|
|
77
|
+
while not done.is_set():
|
|
78
|
+
try:
|
|
79
|
+
p = _recv_one()
|
|
80
|
+
except Exception as e:
|
|
81
|
+
err.append(e)
|
|
82
|
+
done.set()
|
|
83
|
+
break
|
|
84
|
+
if not p:
|
|
85
|
+
continue
|
|
86
|
+
m = p[0]
|
|
87
|
+
try:
|
|
88
|
+
rr = W.Reader(p)
|
|
89
|
+
rr.byte()
|
|
90
|
+
want = getattr(sess, "chan", sess.srv)
|
|
91
|
+
if m == CH.MSG_CHANNEL_DATA:
|
|
92
|
+
if rr.u32() != want:
|
|
93
|
+
continue
|
|
94
|
+
chunk = rr.string()
|
|
95
|
+
_write_all(1, chunk)
|
|
96
|
+
try:
|
|
97
|
+
add = getattr(sess, "add_consumed", None)
|
|
98
|
+
if callable(add):
|
|
99
|
+
add(len(chunk))
|
|
100
|
+
else:
|
|
101
|
+
sess.consumed += len(chunk)
|
|
102
|
+
except AttributeError:
|
|
103
|
+
pass
|
|
104
|
+
sess.window_adjust()
|
|
105
|
+
elif m == CH.MSG_CHANNEL_EXTENDED_DATA:
|
|
106
|
+
if rr.u32() != want:
|
|
107
|
+
continue
|
|
108
|
+
if rr.u32() != 1:
|
|
109
|
+
continue
|
|
110
|
+
chunk = rr.string()
|
|
111
|
+
_write_all(2, chunk)
|
|
112
|
+
# stderr consumes the same server window as stdout:
|
|
113
|
+
# without crediting it, >2MB on stderr deadlocks.
|
|
114
|
+
try:
|
|
115
|
+
add = getattr(sess, "add_consumed", None)
|
|
116
|
+
if callable(add):
|
|
117
|
+
add(len(chunk))
|
|
118
|
+
else:
|
|
119
|
+
sess.consumed += len(chunk)
|
|
120
|
+
except AttributeError:
|
|
121
|
+
pass
|
|
122
|
+
sess.window_adjust()
|
|
123
|
+
elif m == CH.MSG_WINDOW_ADJUST:
|
|
124
|
+
if rr.u32() != want:
|
|
125
|
+
continue
|
|
126
|
+
sess.credit(rr.u32())
|
|
127
|
+
elif m == CH.MSG_CHANNEL_REQUEST:
|
|
128
|
+
if rr.u32() != want:
|
|
129
|
+
continue
|
|
130
|
+
name = rr.string()
|
|
131
|
+
if name in (b"exit-status", b"exit-signal"):
|
|
132
|
+
rr.bool()
|
|
133
|
+
if name == b"exit-status":
|
|
134
|
+
try:
|
|
135
|
+
sess.exit_status = rr.u32()
|
|
136
|
+
except Exception:
|
|
137
|
+
pass
|
|
138
|
+
else:
|
|
139
|
+
# A signalled remote process: ssh reports 128
|
|
140
|
+
# (unknown layouts can't be parsed for a
|
|
141
|
+
# FAILURE reply, so anything else is ignored).
|
|
142
|
+
sess.exit_status = 128
|
|
143
|
+
elif m == CH.MSG_CHANNEL_EOF:
|
|
144
|
+
if rr.u32() != want:
|
|
145
|
+
continue
|
|
146
|
+
sess.eof_received = True
|
|
147
|
+
elif m == CH.MSG_CHANNEL_CLOSE:
|
|
148
|
+
if rr.u32() != want:
|
|
149
|
+
continue
|
|
150
|
+
_peer_closed = getattr(sess, "_peer_closed", None)
|
|
151
|
+
if callable(_peer_closed):
|
|
152
|
+
try:
|
|
153
|
+
_peer_closed()
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
done.set()
|
|
157
|
+
break
|
|
158
|
+
elif m == 20: # no rekey with an open channel (see exec)
|
|
159
|
+
err.append(RuntimeError("peer started rekey with an open channel"))
|
|
160
|
+
done.set()
|
|
161
|
+
break
|
|
162
|
+
# IGNORE/DEBUG/GLOBAL_REQUEST never reach here (transport).
|
|
163
|
+
except Exception as e:
|
|
164
|
+
err.append(e)
|
|
165
|
+
done.set()
|
|
166
|
+
break
|
|
167
|
+
finally:
|
|
168
|
+
done.set()
|
|
169
|
+
|
|
170
|
+
th = threading.Thread(target=pump, daemon=True)
|
|
171
|
+
if tty_mode:
|
|
172
|
+
try:
|
|
173
|
+
import termios
|
|
174
|
+
import tty as _tty
|
|
175
|
+
old_attrs = termios.tcgetattr(stdin_fd)
|
|
176
|
+
_tty.setraw(stdin_fd)
|
|
177
|
+
except Exception:
|
|
178
|
+
old_attrs = None
|
|
179
|
+
tty_mode = False
|
|
180
|
+
|
|
181
|
+
def _on_winch(_signo, _frame) -> None:
|
|
182
|
+
# No pty was allocated (ssh -T): window-change is meaningless.
|
|
183
|
+
if getattr(sess, "no_pty", False):
|
|
184
|
+
return
|
|
185
|
+
try:
|
|
186
|
+
cols, rows = _termsize()
|
|
187
|
+
sess.window_change(cols, rows)
|
|
188
|
+
except Exception:
|
|
189
|
+
pass
|
|
190
|
+
|
|
191
|
+
old_winch = None
|
|
192
|
+
_sigwinch = getattr(signal, "SIGWINCH", None) # None on Windows
|
|
193
|
+
if tty_mode and _sigwinch is not None:
|
|
194
|
+
try:
|
|
195
|
+
old_winch = signal.signal(_sigwinch, _on_winch)
|
|
196
|
+
except Exception:
|
|
197
|
+
old_winch = None
|
|
198
|
+
stdin_open = True
|
|
199
|
+
try:
|
|
200
|
+
th.start()
|
|
201
|
+
while not done.is_set():
|
|
202
|
+
try:
|
|
203
|
+
ready, _, _ = select.select([stdin_no], [], [], 0.2)
|
|
204
|
+
except (OSError, ValueError):
|
|
205
|
+
ready = [stdin_no] # not selectable: blocking read fallback
|
|
206
|
+
if done.is_set(): # server closed first (e.g. after Ctrl+D)
|
|
207
|
+
break
|
|
208
|
+
if not ready:
|
|
209
|
+
continue
|
|
210
|
+
try:
|
|
211
|
+
chunk = os.read(stdin_no, 4096)
|
|
212
|
+
except (OSError, ValueError):
|
|
213
|
+
chunk = b""
|
|
214
|
+
if not chunk:
|
|
215
|
+
if stdin_open: # stdin EOF -> half-close, keep reading output
|
|
216
|
+
stdin_open = False
|
|
217
|
+
sess.send_eof()
|
|
218
|
+
# Wait for the server to close (bounded: no infinite hang
|
|
219
|
+
# if the shell ignores EOF).
|
|
220
|
+
import time as _time
|
|
221
|
+
_deadline = _time.monotonic() + 10.0
|
|
222
|
+
while not done.is_set() and _time.monotonic() < _deadline:
|
|
223
|
+
done.wait(0.05)
|
|
224
|
+
if not done.is_set():
|
|
225
|
+
try:
|
|
226
|
+
sess.close()
|
|
227
|
+
except Exception:
|
|
228
|
+
pass
|
|
229
|
+
break
|
|
230
|
+
try:
|
|
231
|
+
sess.send(chunk)
|
|
232
|
+
except Exception as e:
|
|
233
|
+
err.append(e)
|
|
234
|
+
break
|
|
235
|
+
done.wait()
|
|
236
|
+
th.join(timeout=5)
|
|
237
|
+
except KeyboardInterrupt:
|
|
238
|
+
pass
|
|
239
|
+
finally:
|
|
240
|
+
done.set()
|
|
241
|
+
sess.close()
|
|
242
|
+
if old_winch is not None and _sigwinch is not None:
|
|
243
|
+
try:
|
|
244
|
+
signal.signal(_sigwinch, old_winch)
|
|
245
|
+
except Exception:
|
|
246
|
+
pass
|
|
247
|
+
if old_attrs is not None:
|
|
248
|
+
try:
|
|
249
|
+
import termios
|
|
250
|
+
termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_attrs)
|
|
251
|
+
sys.stdout.write("\n")
|
|
252
|
+
sys.stdout.flush()
|
|
253
|
+
except Exception:
|
|
254
|
+
pass
|
|
255
|
+
if err and sess.exit_status is None:
|
|
256
|
+
raise err[0]
|
|
257
|
+
return sess.exit_status if sess.exit_status is not None else 0
|
ccssh/sk.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FIDO2 security-key user authentication (sk-ssh-ed25519, sk-ecdsa-sha2-nistp256).
|
|
3
|
+
|
|
4
|
+
Formats follow OpenSSH PROTOCOL.u2f exactly (fetched 2026-10-11, v1.26):
|
|
5
|
+
|
|
6
|
+
pub blob: string(alg) + key fields + string(application, usually "ssh:")
|
|
7
|
+
priv half: string(alg) + key fields + string(application)
|
|
8
|
+
+ byte(flags) + string(key_handle) + string(reserved)
|
|
9
|
+
preimage: SHA256(application) + byte(flags) + u32(counter)
|
|
10
|
+
+ ext(empty) + SHA256(message)
|
|
11
|
+
wire sig: string(alg) + string(sig) + byte(flags) + u32(counter),
|
|
12
|
+
where ecdsa sig = mpint(r) + mpint(s) (RFC 5656) and ed25519
|
|
13
|
+
sig is raw 64 B.
|
|
14
|
+
|
|
15
|
+
The touch itself cannot happen in software: signing needs a FIDO2 token.
|
|
16
|
+
SkKey therefore carries an injectable `signer(application, key_handle,
|
|
17
|
+
message) -> (sig_raw, flags, counter)`; without one, sign_nested raises
|
|
18
|
+
a clear error instead of failing obscurely server-side. Parsing,
|
|
19
|
+
verification and userauth assembly are fully testable without hardware.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import base64
|
|
24
|
+
import hashlib
|
|
25
|
+
import os
|
|
26
|
+
|
|
27
|
+
from cryptography.hazmat.primitives import hashes
|
|
28
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
29
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
30
|
+
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
|
|
31
|
+
|
|
32
|
+
from ccssh import wire as W
|
|
33
|
+
|
|
34
|
+
ALG_ED = "sk-ssh-ed25519@openssh.com"
|
|
35
|
+
ALG_EC = "sk-ecdsa-sha2-nistp256@openssh.com"
|
|
36
|
+
ALGS = (ALG_ED, ALG_EC)
|
|
37
|
+
DEFAULT_APPLICATION = b"ssh:"
|
|
38
|
+
EC_CURVE_SSH = b"nistp256"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def preimage(application: bytes, flags: int, counter: int, message: bytes) -> bytes:
|
|
42
|
+
"""
|
|
43
|
+
The exact bytes a security key signs (PROTOCOL.u2f, no extensions).
|
|
44
|
+
"""
|
|
45
|
+
if not 0 <= flags <= 255:
|
|
46
|
+
raise ValueError("flags must be one byte")
|
|
47
|
+
if not 0 <= counter < 2**32:
|
|
48
|
+
raise ValueError("counter must fit uint32")
|
|
49
|
+
return (hashlib.sha256(application).digest()
|
|
50
|
+
+ bytes([flags])
|
|
51
|
+
+ counter.to_bytes(4, "big")
|
|
52
|
+
+ hashlib.sha256(message).digest())
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SkKey:
|
|
56
|
+
"""
|
|
57
|
+
A security key: public parts + key handle + optional signer callback.
|
|
58
|
+
|
|
59
|
+
`signer(application, key_handle, message)` returns (sig_raw, flags,
|
|
60
|
+
counter); hardware builds the preimage itself. Pass None when no
|
|
61
|
+
token is attached - signing then raises instead of misbehaving.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, alg: str, application: bytes = DEFAULT_APPLICATION, flags: int = 0, key_handle: bytes = b"", pub: bytes = b"", curve: bytes = b"", q: bytes = b"", signer=None):
|
|
65
|
+
if alg not in ALGS:
|
|
66
|
+
raise ValueError(f"not a sk key type: {alg!r}")
|
|
67
|
+
if not 0 <= flags <= 255:
|
|
68
|
+
raise ValueError("flags must be one byte")
|
|
69
|
+
self.alg = alg
|
|
70
|
+
self.application = application
|
|
71
|
+
self.flags = flags
|
|
72
|
+
self.key_handle = key_handle
|
|
73
|
+
self.signer = signer
|
|
74
|
+
if alg == ALG_ED:
|
|
75
|
+
if len(pub) != 32:
|
|
76
|
+
raise ValueError("sk ed25519 pub must be 32 bytes")
|
|
77
|
+
self.pub = pub
|
|
78
|
+
self.curve, self.q = b"", b""
|
|
79
|
+
else:
|
|
80
|
+
if curve != EC_CURVE_SSH or not q:
|
|
81
|
+
raise ValueError("sk ecdsa needs nistp256 + point")
|
|
82
|
+
self.pub, self.curve, self.q = b"", curve, q
|
|
83
|
+
|
|
84
|
+
def public_blob(self) -> bytes:
|
|
85
|
+
if self.alg == ALG_ED:
|
|
86
|
+
return W.enc_string(self.alg.encode()) + W.enc_string(self.pub) + W.enc_string(self.application)
|
|
87
|
+
return W.enc_string(self.alg.encode()) + W.enc_string(self.curve) + W.enc_string(self.q) + W.enc_string(self.application)
|
|
88
|
+
|
|
89
|
+
def public_line(self, comment: str = "") -> str:
|
|
90
|
+
# OpenSSH .pub format: "alg base64(full_blob_with_alg) [comment]".
|
|
91
|
+
inner = self.public_blob()
|
|
92
|
+
suffix = f" {comment}" if comment else ""
|
|
93
|
+
return f"{self.alg} {base64.b64encode(inner).decode()}{suffix}"
|
|
94
|
+
|
|
95
|
+
def sign_nested(self, message: bytes) -> bytes:
|
|
96
|
+
"""
|
|
97
|
+
Wire signature buffer: string(alg) + string(sig) + byte(flags) +
|
|
98
|
+
u32(counter). Verifies its own output against the public key so
|
|
99
|
+
a broken middleware fails here, not as a server rejection.
|
|
100
|
+
"""
|
|
101
|
+
if self.signer is None:
|
|
102
|
+
raise ValueError("no security-key signer configured (FIDO2 touch required)")
|
|
103
|
+
sig_raw, flags, counter = self.signer(self.application, self.key_handle, message)
|
|
104
|
+
if self.alg == ALG_ED:
|
|
105
|
+
if len(sig_raw) != 64:
|
|
106
|
+
raise ValueError("sk ed25519 signature must be 64 bytes")
|
|
107
|
+
else:
|
|
108
|
+
r = W.Reader(sig_raw)
|
|
109
|
+
r.mpint()
|
|
110
|
+
r.mpint()
|
|
111
|
+
if r.rest():
|
|
112
|
+
raise ValueError("sk ecdsa signature must be mpint(r)+mpint(s)")
|
|
113
|
+
if not 0 <= flags <= 255 or not 0 <= counter < 2**32:
|
|
114
|
+
raise ValueError("signer returned bad flags/counter")
|
|
115
|
+
out = (W.enc_string(self.alg.encode()) + W.enc_string(sig_raw)
|
|
116
|
+
+ bytes([flags]) + counter.to_bytes(4, "big"))
|
|
117
|
+
if not verify_nested(self.public_blob(), message, out):
|
|
118
|
+
raise ValueError("signer output does not verify")
|
|
119
|
+
return out
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def parse_pub_blob(blob: bytes) -> SkKey:
|
|
123
|
+
r = W.Reader(blob)
|
|
124
|
+
alg = r.string().decode()
|
|
125
|
+
if alg == ALG_ED:
|
|
126
|
+
pub, app = r.string(), r.string()
|
|
127
|
+
if r.rest():
|
|
128
|
+
raise ValueError("trailing bytes in sk pub blob")
|
|
129
|
+
return SkKey(alg, application=app, pub=pub)
|
|
130
|
+
if alg == ALG_EC:
|
|
131
|
+
curve, q, app = r.string(), r.string(), r.string()
|
|
132
|
+
if r.rest():
|
|
133
|
+
raise ValueError("trailing bytes in sk pub blob")
|
|
134
|
+
return SkKey(alg, application=app, curve=curve, q=q)
|
|
135
|
+
raise ValueError(f"not a sk public key: {alg!r}")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def parse_private_blob(blob: bytes, *, strict: bool = True) -> SkKey:
|
|
139
|
+
"""
|
|
140
|
+
Private half: pub fields + application + flags + key_handle + reserved.
|
|
141
|
+
Cert-form halves are refused: pass the plain key file with --cert.
|
|
142
|
+
strict=False tolerates the openssh-key-v1 block padding that follows
|
|
143
|
+
the half inside key files (used by the file loader only).
|
|
144
|
+
"""
|
|
145
|
+
r = W.Reader(blob)
|
|
146
|
+
alg = r.string().decode()
|
|
147
|
+
if alg in ALGS:
|
|
148
|
+
pub_part = r.string() if alg == ALG_ED else None
|
|
149
|
+
if alg == ALG_EC:
|
|
150
|
+
curve, q = r.string(), r.string()
|
|
151
|
+
else:
|
|
152
|
+
curve, q = b"", b""
|
|
153
|
+
app = r.string()
|
|
154
|
+
flags = r.byte()
|
|
155
|
+
handle = r.string()
|
|
156
|
+
r.string() # reserved
|
|
157
|
+
if strict and r.rest():
|
|
158
|
+
raise ValueError("trailing bytes in sk private half")
|
|
159
|
+
kw = {"pub": pub_part} if alg == ALG_ED else {"curve": curve, "q": q}
|
|
160
|
+
return SkKey(alg, application=app, flags=flags, key_handle=handle, **kw)
|
|
161
|
+
raise ValueError(f"not a sk private key: {alg!r}")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def verify_nested(pub_blob: bytes, message: bytes, sig_nested: bytes) -> bool:
|
|
165
|
+
"""
|
|
166
|
+
Verify an sk wire signature. Returns True/False, never raises.
|
|
167
|
+
"""
|
|
168
|
+
try:
|
|
169
|
+
r = W.Reader(sig_nested)
|
|
170
|
+
sig_alg = r.string().decode()
|
|
171
|
+
sig_raw = r.string()
|
|
172
|
+
flags = r.byte()
|
|
173
|
+
counter = r.u32()
|
|
174
|
+
if r.rest():
|
|
175
|
+
return False
|
|
176
|
+
key = parse_pub_blob(pub_blob)
|
|
177
|
+
if key.alg != sig_alg:
|
|
178
|
+
return False
|
|
179
|
+
pre = preimage(key.application, flags, counter, message)
|
|
180
|
+
if key.alg == ALG_ED:
|
|
181
|
+
if len(sig_raw) != 64:
|
|
182
|
+
return False
|
|
183
|
+
Ed25519PublicKey.from_public_bytes(key.pub).verify(sig_raw, pre)
|
|
184
|
+
return True
|
|
185
|
+
rr = W.Reader(sig_raw)
|
|
186
|
+
der = encode_dss_signature(rr.mpint(), rr.mpint())
|
|
187
|
+
if rr.rest():
|
|
188
|
+
return False
|
|
189
|
+
ec.EllipticCurvePublicKey.from_encoded_point(
|
|
190
|
+
ec.SECP256R1(), key.q).verify(der, pre, ec.ECDSA(hashes.SHA256()))
|
|
191
|
+
return True
|
|
192
|
+
except Exception:
|
|
193
|
+
return False
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def load_openssh_private(path: str, passphrase: bytes | None = None) -> SkKey:
|
|
197
|
+
"""
|
|
198
|
+
Load an sk openssh-key-v1 file (unencrypted only, like the mldsa
|
|
199
|
+
loader: encrypted keys raise a clear error).
|
|
200
|
+
"""
|
|
201
|
+
with open(os.path.expanduser(path), "rb") as f:
|
|
202
|
+
pem = f.read().decode()
|
|
203
|
+
b64 = "".join(l for l in pem.splitlines() if l and not l.startswith("-----"))
|
|
204
|
+
try:
|
|
205
|
+
raw = base64.b64decode(b64, validate=True)
|
|
206
|
+
except Exception as e:
|
|
207
|
+
raise ValueError(f"bad sk key file {path}: {e}") from e
|
|
208
|
+
if not raw.startswith(b"openssh-key-v1\x00"):
|
|
209
|
+
raise ValueError("not an openssh-key-v1 file")
|
|
210
|
+
r = W.Reader(raw[15:])
|
|
211
|
+
if r.string() != b"none":
|
|
212
|
+
# Encrypted sk keys are unsupported (passphrase or not): callers
|
|
213
|
+
# passing one unconditionally get the same clear error.
|
|
214
|
+
raise ValueError("encrypted sk keys are unsupported: decrypt with ssh-keygen first")
|
|
215
|
+
r.string()
|
|
216
|
+
r.string()
|
|
217
|
+
if r.u32() != 1:
|
|
218
|
+
raise ValueError("expected exactly 1 key")
|
|
219
|
+
r.string() # public blob
|
|
220
|
+
priv = r.string()
|
|
221
|
+
pr = W.Reader(priv)
|
|
222
|
+
if pr.u32() != pr.u32():
|
|
223
|
+
raise ValueError("checkint mismatch (wrong passphrase?)")
|
|
224
|
+
return parse_private_blob(priv[pr.tell():], strict=False)
|