Add native security key registration, reference import, and signing on macOS and iOS. Store references in Vault metadata and route authentication through connection, Assistant, MCP, and agent forwarding flows. Preserve signing cancellation and actionable authentication errors for security keys and Secure Enclave. Pin the released libssh2 forks as nested submodules and refresh package resolution.
92 lines
3.4 KiB
Python
92 lines
3.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Disposable OpenSSH server with a software-simulated WebAuthn authenticator.
|
|
|
|
No hardware, user SSH configuration, or private keys are read. Pass --metadata
|
|
PATH as TEST_RUNNER_TERMIND_SECURITY_KEY_FIXTURE to the focused Xcode wire test.
|
|
"""
|
|
import argparse
|
|
import base64
|
|
import getpass
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import signal
|
|
import socket
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--metadata', required=True)
|
|
args = parser.parse_args()
|
|
stopped = False
|
|
|
|
def stop(*_):
|
|
nonlocal stopped
|
|
stopped = True
|
|
|
|
signal.signal(signal.SIGTERM, stop)
|
|
signal.signal(signal.SIGINT, stop)
|
|
with tempfile.TemporaryDirectory(prefix='termind-security-key-') as directory:
|
|
root = Path(directory)
|
|
generator = root / 'generate.swift'
|
|
generator.write_text('''import CryptoKit
|
|
import Foundation
|
|
let key = P256.Signing.PrivateKey()
|
|
let value = ["privateKey": key.rawRepresentation.base64EncodedString(), "publicKey": key.publicKey.x963Representation.base64EncodedString()]
|
|
print(String(data: try JSONEncoder().encode(value), encoding: .utf8)!)
|
|
''')
|
|
key = json.loads(subprocess.check_output(['xcrun', 'swift', str(generator)], text=True))
|
|
|
|
def field(value):
|
|
return len(value).to_bytes(4, 'big') + value
|
|
|
|
algorithm = b'sk-ecdsa-sha2-nistp256@openssh.com'
|
|
public = field(algorithm) + field(b'nistp256') + field(base64.b64decode(key['publicKey'])) + field(b'termind.akinokaede.com')
|
|
(root / 'authorized_keys').write_text(algorithm.decode() + ' ' + base64.b64encode(public).decode() + '\n')
|
|
subprocess.run(['ssh-keygen', '-q', '-t', 'ed25519', '-N', '', '-f', str(root / 'server')], check=True)
|
|
with socket.socket() as sock:
|
|
sock.bind(('127.0.0.1', 0))
|
|
port = sock.getsockname()[1]
|
|
config = root / 'sshd.conf'
|
|
config.write_text(f'''Port {port}
|
|
ListenAddress 127.0.0.1
|
|
HostKey {root / 'server'}
|
|
PidFile {root / 'sshd.pid'}
|
|
AuthorizedKeysFile {root / 'authorized_keys'}
|
|
StrictModes no
|
|
PasswordAuthentication no
|
|
KbdInteractiveAuthentication no
|
|
UsePAM no
|
|
AllowTcpForwarding yes
|
|
Subsystem sftp internal-sftp
|
|
LogLevel VERBOSE
|
|
''')
|
|
with (root / 'sshd.log').open('w+') as log:
|
|
process = subprocess.Popen(['/usr/sbin/sshd', '-D', '-e', '-f', str(config)], stderr=log)
|
|
try:
|
|
time.sleep(1)
|
|
if process.poll() is not None:
|
|
log.seek(0)
|
|
raise RuntimeError(log.read())
|
|
blob = base64.b64decode((root / 'server.pub').read_text().split()[1])
|
|
metadata = Path(args.metadata)
|
|
metadata.write_text(json.dumps({
|
|
'port': port, 'username': getpass.getuser(), 'privateKey': key['privateKey'],
|
|
'fingerprint': 'SHA256:' + base64.b64encode(hashlib.sha256(blob).digest()).decode().rstrip('='),
|
|
}))
|
|
metadata.chmod(0o600)
|
|
print('Security key fixture ready', flush=True)
|
|
while not stopped:
|
|
time.sleep(0.25)
|
|
finally:
|
|
process.terminate()
|
|
process.wait(timeout=5)
|
|
Path(args.metadata).unlink(missing_ok=True)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|