diff --git a/package-lock.json b/package-lock.json index f6ce6bb..332eeb7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,11 @@ "": { "name": "kik-website", "dependencies": { + "@noble/ciphers": "^2.4.0", + "@noble/curves": "^2.4.0", + "@noble/hashes": "^2.4.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "astro": "^7.4.2" } }, @@ -1057,6 +1062,45 @@ "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, + "node_modules/@noble/ciphers": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz", + "integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz", + "integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.4.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@oslojs/encoding": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", @@ -1482,6 +1526,21 @@ "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", "license": "ISC" }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/am-i-vibing": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/am-i-vibing/-/am-i-vibing-0.5.0.tgz", diff --git a/package.json b/package.json index d087ca7..83e6eb8 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,11 @@ "preview": "astro preview" }, "dependencies": { + "@noble/ciphers": "^2.4.0", + "@noble/curves": "^2.4.0", + "@noble/hashes": "^2.4.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "astro": "^7.4.2" } } diff --git a/src/components/Prompt.astro b/src/components/Prompt.astro index c7c74bc..6081309 100644 --- a/src/components/Prompt.astro +++ b/src/components/Prompt.astro @@ -1 +1 @@ -

root@khaos-im-kiez:~#

+

root@khaos-im-kiez:~#

diff --git a/src/components/Terminal.astro b/src/components/Terminal.astro index cb712ac..eed8acb 100644 --- a/src/components/Terminal.astro +++ b/src/components/Terminal.astro @@ -1,110 +1,36 @@ --- import Prompt from './Prompt.astro'; --- - + diff --git a/src/scripts/ssh.ts b/src/scripts/ssh.ts new file mode 100644 index 0000000..e20e5c1 --- /dev/null +++ b/src/scripts/ssh.ts @@ -0,0 +1,276 @@ +// Minimal SSH client for one interactive shell over a websockify bridge (raw SSH bytes in binary frames). +// Supports exactly: curve25519-sha256 with strict kex, ssh-ed25519 host and user keys, +// chacha20-poly1305@openssh.com, one session channel with a pty, and server-initiated re-keying. +import { ed25519, x25519 } from '@noble/curves/ed25519.js'; +import { sha256 } from '@noble/hashes/sha2.js'; +import { chacha20orig } from '@noble/ciphers/chacha.js'; +import { poly1305 } from '@noble/ciphers/_poly1305.js'; +import { concatBytes as concat, equalBytes, randomBytes } from '@noble/ciphers/utils.js'; + +export interface Options { + socket: WebSocket; + user: string; + privateKey: string; // unencrypted ed25519 key in openssh-key-v1 format + hostKey: string; // expected host key fingerprint, "SHA256:..." + term: string; + cols: number; + rows: number; + onData: (data: Uint8Array) => void; + onClose: (error?: Error) => void; +} + +export interface Session { + write(data: string | Uint8Array): void; + resize(cols: number, rows: number): void; +} + +const CIPHER = 'chacha20-poly1305@openssh.com'; +const WINDOW = 1 << 16; +const MAX_PACKET = 1 << 15; + +const enc = new TextEncoder(); +const dec = new TextDecoder(); + +const u32 = (n: number) => Uint8Array.of(n >>> 24, n >>> 16, n >>> 8, n); + +/** SSH wire encoding: number → uint32, boolean → byte, string or bytes → length-prefixed string. */ +function pack(...values: (number | boolean | string | Uint8Array)[]): Uint8Array { + return concat(...values.map((v) => { + if (typeof v === 'number') return u32(v); + if (typeof v === 'boolean') return Uint8Array.of(+v); + const bytes = typeof v === 'string' ? enc.encode(v) : v; + return concat(u32(bytes.length), bytes); + })); +} + +const msg = (type: number, ...values: Parameters) => concat(Uint8Array.of(type), pack(...values)); + +function reader(bytes: Uint8Array, offset = 0) { + const r = { + u8: () => bytes[offset++], + u32: () => new DataView(bytes.buffer, bytes.byteOffset).getUint32((offset += 4) - 4), + bytes: () => { const n = r.u32(); return bytes.subarray(offset, (offset += n)); }, + str: () => dec.decode(r.bytes()), + }; + return r; +} + +/** Unsigned big-endian integer → SSH mpint. */ +function mpint(bytes: Uint8Array) { + const n = bytes.subarray(bytes.findIndex((b) => b !== 0)); + return pack(n[0] & 0x80 ? concat(Uint8Array.of(0), n) : n); +} + +function parsePrivateKey(text: string) { + const r = reader(Uint8Array.from(atob(text.replace(/-----[^-]+-----|\s/g, '')), (c) => c.charCodeAt(0)), 15); // skip "openssh-key-v1\0" + if (r.str() !== 'none') throw new Error('encrypted private keys are not supported'); + r.str(); r.bytes(); r.u32(); // kdf name, kdf options, number of keys + const publicKey = r.bytes(); + const secret = reader(r.bytes(), 8); // skip check ints + secret.str(); secret.bytes(); // key type, public key + return { publicKey, seed: secret.bytes().subarray(0, 32) }; +} + +/** chacha20-poly1305@openssh.com. Sequence numbers restart at 0 after every NEWKEYS because of strict kex. */ +function chachaPoly(key: Uint8Array) { + const main = key.subarray(0, 32); + const header = key.subarray(32); + let seq = 0; + const nonce = () => concat(u32(0), u32(seq)); + const mac = (n: Uint8Array, data: Uint8Array) => poly1305(data, chacha20orig(main, n, new Uint8Array(32))); + return { + length: (head: Uint8Array) => reader(chacha20orig(header, nonce(), head)).u32(), + seal(packet: Uint8Array) { + const n = nonce(); + seq++; + const sealed = concat(chacha20orig(header, n, packet.subarray(0, 4)), chacha20orig(main, n, packet.subarray(4), undefined, 1)); + return concat(sealed, mac(n, sealed)); + }, + open(packet: Uint8Array) { + const n = nonce(); + seq++; + const sealed = packet.subarray(0, -16); + if (!equalBytes(mac(n, sealed), packet.subarray(-16))) throw new Error('bad packet MAC'); + return chacha20orig(main, n, sealed.subarray(4), undefined, 1); + }, + }; +} + +export function connect(o: Options): Session { + const { socket } = o; + let buffer = new Uint8Array(0); + let wake = () => {}; + let closed = false; + socket.binaryType = 'arraybuffer'; + socket.onmessage = (e) => { buffer = concat(buffer, new Uint8Array(e.data)); wake(); }; + socket.onclose = () => { closed = true; wake(); }; + + async function read(n: number) { + while (buffer.length < n) { + if (closed) throw new Error('connection closed'); + await new Promise((resolve) => (wake = resolve)); + } + const bytes = buffer.subarray(0, n); + buffer = buffer.subarray(n); + return bytes; + } + + let tx: ReturnType | undefined; + let rx: ReturnType | undefined; + let held: Uint8Array[] | undefined; // connection-layer messages held back during a key exchange + + function send(payload: Uint8Array) { + if (socket.readyState !== WebSocket.OPEN) return; // the session has ended + if (held && payload[0] >= 50) return void held.push(payload); + let padding = 8 - ((payload.length + (tx ? 1 : 5)) % 8); // chacha20-poly1305 excludes the length field + if (padding < 4) padding += 8; + const packet = concat(u32(payload.length + padding + 1), Uint8Array.of(padding), payload, new Uint8Array(padding)); + socket.send(tx ? tx.seal(packet) : packet); + } + + async function readPacket() { + const head = await read(4); + const packet = concat(head, await read(rx ? rx.length(head) + 16 : reader(head).u32())); + const plain = rx ? rx.open(packet) : packet.subarray(4); + return plain.subarray(1, plain.length - plain[0]); + } + + /** Next packet, after handling the messages that may arrive at any time once the initial key exchange is done. */ + async function receive(): Promise { + for (;;) { + const p = await readPacket(); + if (!rx) return p; // strict kex: nothing else is allowed during the initial key exchange + const r = reader(p, 1); + switch (p[0]) { + case 1: r.u32(); throw new Error(`disconnected: ${r.str()}`); + case 80: r.str(); if (r.u8()) send(Uint8Array.of(82)); break; // global request: reply failure + case 2: case 3: case 4: case 53: break; // ignore, unimplemented, debug, auth banner + default: return p; + } + } + } + + async function expect(type: number) { + const p = await receive(); + if (p[0] !== type) throw new Error(`expected message ${type}, got ${p[0]}`); + return p; + } + + let channel: number | undefined; + let remoteWindow = 0; + let remoteMaxPacket = 0; + let pending = new Uint8Array(0); + + function flush() { + while (pending.length && remoteWindow) { + const n = Math.min(pending.length, remoteWindow, remoteMaxPacket); + send(msg(94, channel!, pending.subarray(0, n))); + pending = pending.subarray(n); + remoteWindow -= n; + } + } + + async function run() { + let line; + do { + line = ''; + while (!line.endsWith('\n')) line += String.fromCharCode((await read(1))[0]); + } while (!line.startsWith('SSH-')); + const serverVersion = line.trimEnd(); + const clientVersion = 'SSH-2.0-web'; + socket.send(enc.encode(`${clientVersion}\r\n`)); + + let sessionId: Uint8Array | undefined; + /** The initial key exchange, or a re-key started by the server's KEXINIT. */ + async function kex(serverKexinit?: Uint8Array) { + held = []; + const clientKexinit = concat(Uint8Array.of(20), randomBytes(16), pack( + 'curve25519-sha256,kex-strict-c-v00@openssh.com', 'ssh-ed25519', + CIPHER, CIPHER, '', '', 'none', 'none', '', '', false, 0, + )); + send(clientKexinit); + serverKexinit ??= await expect(20); + if (!sessionId && !reader(serverKexinit, 17).str().split(',').includes('kex-strict-s-v00@openssh.com')) throw new Error('server lacks strict kex'); + + const ephemeral = x25519.keygen(); + send(msg(30, ephemeral.publicKey)); + const reply = reader(await expect(31), 1); + const hostKey = reply.bytes(); + const serverPublic = reply.bytes(); + const signature = reply.bytes(); + const fingerprint = `SHA256:${btoa(String.fromCharCode(...sha256(hostKey))).replace(/=+$/, '')}`; + if (fingerprint !== o.hostKey) throw new Error(`unexpected host key ${fingerprint}`); + const K = mpint(x25519.getSharedSecret(ephemeral.secretKey, serverPublic)); + const H = sha256(concat(pack(clientVersion, serverVersion, clientKexinit, serverKexinit, hostKey, ephemeral.publicKey, serverPublic), K)); + if (!ed25519.verify(signature.subarray(-64), H, hostKey.subarray(-32))) throw new Error('bad host key signature'); + + const id = (sessionId ??= H); + const key = (letter: string) => { + const k1 = sha256(concat(K, H, enc.encode(letter), id)); + return concat(k1, sha256(concat(K, H, k1))); + }; + send(Uint8Array.of(21)); + tx = chachaPoly(key('C')); + await expect(21); + rx = chachaPoly(key('D')); + const messages = held; + held = undefined; + messages.forEach(send); + } + await kex(); + + send(msg(5, 'ssh-userauth')); + await expect(6); + const { publicKey, seed } = parsePrivateKey(o.privateKey); + const auth = msg(50, o.user, 'ssh-connection', 'publickey', true, 'ssh-ed25519', publicKey); + send(concat(auth, pack(pack('ssh-ed25519', ed25519.sign(concat(pack(sessionId!), auth), seed))))); + if ((await receive())[0] !== 52) throw new Error('authentication failed'); + + send(msg(90, 'session', 0, WINDOW, MAX_PACKET)); + const confirmation = reader(await expect(91), 5); + channel = confirmation.u32(); + remoteWindow = confirmation.u32(); + remoteMaxPacket = confirmation.u32(); + send(msg(98, channel, 'pty-req', true, o.term, o.cols, o.rows, 0, 0, '\0')); // no terminal modes, just TTY_OP_END + send(msg(98, channel, 'shell', true)); + flush(); + + let window = WINDOW; + for (;;) { + const p = await receive(); + const r = reader(p, 5); // everything from here on is for our only channel + switch (p[0]) { + case 20: await kex(p); break; + case 93: remoteWindow += r.u32(); flush(); break; + case 95: r.u32(); // extended data (stderr): skip the type code + // falls through + case 94: { + const data = r.bytes(); + o.onData(data); + if ((window -= data.length) <= WINDOW / 2) { + send(msg(93, channel, WINDOW - window)); + window = WINDOW; + } + break; + } + case 98: r.str(); if (r.u8()) send(msg(100, channel)); break; // exit-status, keepalive, ... + case 100: throw new Error('pty or shell request failed'); + case 97: send(msg(97, channel)); return; + } + } + } + + run().catch((e) => e).then((error) => { socket.close(); o.onClose(error); }); + + return { + write(data) { + pending = concat(pending, typeof data === 'string' ? enc.encode(data) : data); + flush(); + }, + resize(cols, rows) { + o.cols = cols; + o.rows = rows; + if (channel !== undefined) send(msg(98, channel, 'window-change', false, cols, rows, 0, 0)); + }, + }; +} diff --git a/src/scripts/terminal.ts b/src/scripts/terminal.ts new file mode 100644 index 0000000..980f95b --- /dev/null +++ b/src/scripts/terminal.ts @@ -0,0 +1,129 @@ +// The real terminal: xterm.js connected to the shared shell server over SSH. Loaded only once the first command is sent. +import { Terminal, type IMarker } from '@xterm/xterm'; +import { FitAddon } from '@xterm/addon-fit'; +import css from '@xterm/xterm/css/xterm.css?inline'; +import { connect } from './ssh'; + +const SERVER = 'wss://web.nowhere.wirs.ing'; +const HOST_KEY = 'SHA256:aLNZKHCfhvbGYnjghKsOLPrOWPVWwCsIPCuTb5f9J6A'; +const SCROLLBACK = 10000; + +/** Continues the page's `prompt` with a terminal in which `command` was just entered, and returns that terminal. */ +export async function start(prompt: HTMLElement, command: string) { + const privateKey = await (await fetch('root_key')).text(); + document.head.append(Object.assign(document.createElement('style'), { textContent: css })); + // The page is the scrollback: lines that scroll off the terminal's screen continue as page text above it, + // and the screen only grows to the rows in use, up to the window height. + const history = document.createElement('div'); + history.className = 'history'; + const screen = document.createElement('div'); + screen.className = 'screen'; + const element = screen.appendChild(document.createElement('div')); + prompt.after(history, screen); + + const { color, fontFamily, fontSize } = getComputedStyle(screen); + const term = new Terminal({ + fontFamily, + fontSize: parseFloat(fontSize), + scrollback: SCROLLBACK, + cursorBlink: !matchMedia('(prefers-reduced-motion: reduce)').matches, + cursorInactiveStyle: 'block', // typing anywhere on the page reaches the terminal, focused or not + theme: { background: '#00000000', foreground: color, cursor: color, green: color }, // xterm.js rejects 'transparent' + }); + const fit = new FitAddon(); + term.loadAddon(fit); + term.open(element); + fit.fit(); + // The wheel scrolls the page, except in full-screen apps, where xterm.js turns it into arrow keys. + screen.addEventListener('wheel', (e) => term.buffer.active.type === 'normal' && e.stopPropagation(), { capture: true }); + + const grid = element.querySelector('.xterm-screen')!; + let used = 1; // screen rows in use, like a terminal window filling up + let moved: IMarker | undefined; // the last scrollback line already moved into the page + function layout() { + const buffer = term.buffer.active; + const rowHeight = grid.clientHeight / term.rows; + if (buffer.type === 'normal') { + used = buffer.baseY ? term.rows : Math.max(used, buffer.cursorY + 1); + const last = moved && !moved.isDisposed ? moved.line : -1; + for (let y = last; y >= buffer.baseY; y--) history.lastChild!.remove(); // pulled back onto a taller screen + for (let y = last + 1; y < buffer.baseY; y++) history.append(`${buffer.getLine(y)!.translateToString(true)}\n`); + while (history.childNodes.length > SCROLLBACK) history.firstChild!.remove(); + if (last !== buffer.baseY - 1) { + moved?.dispose(); + moved = buffer.baseY ? term.registerMarker(-1 - buffer.cursorY) : undefined; + } + } + history.style.lineHeight = `${rowHeight}px`; + screen.style.height = `${(buffer.type === 'normal' ? used : term.rows) * rowHeight}px`; + } + // "Erase scrollback" (sent by `clear`) erases it from the page too. + term.parser.registerCsiHandler({ final: 'J' }, (params) => (params[0] === 3 && history.replaceChildren(), false)); + + // Like a terminal window, the page scrolls along as output reaches the bottom, unless the visitor scrolled away. + const cursorRow = () => { + const { top, height } = grid.getBoundingClientRect(); + const rowHeight = height / term.rows; + return { top: top + term.buffer.active.cursorY * rowHeight, bottom: top + (term.buffer.active.cursorY + 1) * rowHeight }; + }; + const reveal = () => + term.buffer.active.type === 'alternate' + ? screen.scrollIntoView({ block: 'nearest' }) // full-screen apps are shown whole + : scrollBy(0, Math.max(0, Math.ceil(cursorRow().bottom - innerHeight))); // whole pixels, or it stays a fraction below + let follow = true; + addEventListener('scroll', () => (follow = cursorRow().top < innerHeight), { passive: true }); + term.onWriteParsed(() => { + layout(); + if (follow) reveal(); + }); + term.onResize(layout); + new ResizeObserver(() => { + fit.fit(); + if (follow) reveal(); + }).observe(element); + layout(); + reveal(); + term.focus(); + + // The page's prompt stands in for the shell's first prompt, so that prompt and the shell's echo of the command are dropped. + let phase: 'starting' | 'echo' | 'live' = 'starting'; + let typeahead = ''; + let idle: ReturnType; + const session = connect({ + socket: new WebSocket(SERVER), + user: 'root', + privateKey, + hostKey: HOST_KEY, + term: 'xterm-256color', + cols: term.cols, + rows: term.rows, + onData(data) { + if (phase === 'live') return term.write(data); + if (phase === 'echo') { + const newline = data.indexOf(10); + if (newline >= 0) { + phase = 'live'; + term.write(data.subarray(newline + 1)); + } + return; + } + // The shell is ready once its first output has settled; a command sent earlier would be echoed twice. + clearTimeout(idle); + idle = setTimeout(() => { + phase = 'echo'; + session.write(`${command}\r${typeahead}`); + }, 300); + }, + onClose(error) { + term.write(`${term.buffer.active.cursorX ? '\r\n' : ''}[${error?.message ?? 'connection closed'}]\r\n`); + }, + }); + term.onData((data) => { + reveal(); + if (phase === 'starting') typeahead += data; + else session.write(data); + }); + term.onBinary((data) => session.write(Uint8Array.from(data, (c) => c.charCodeAt(0)))); // mouse reports in the default encoding + term.onResize(({ cols, rows }) => session.resize(cols, rows)); + return term; +} diff --git a/src/styles/style.css b/src/styles/style.css index 24f2f33..4e1ecc3 100644 --- a/src/styles/style.css +++ b/src/styles/style.css @@ -54,7 +54,7 @@ a:hover, a:focus-visible { } /* Prompt */ -.prompt { color: var(--dim); } +.prompt { color: var(--dim); white-space: pre-wrap; } .prompt b { color: var(--fg); font-weight: normal; } .cursor { display: inline-block; @@ -64,6 +64,20 @@ a:hover, a:focus-visible { animation: blink 1.1s steps(1) infinite; } @keyframes blink { 50% { opacity: 0; } } +#term-input { all: unset; width: 0; padding-right: 1px; caret-color: var(--fg); } /* as wide as its text */ +.prompt:focus-within .cursor { display: none; } /* the input's own caret takes over while typing */ + +/* Terminal: continues right below the first prompt. Lines scrolled off its screen become page text, + the screen shows the rows in use and xterm.js fits rows and columns to the window. */ +.prompt:has(+ .history) { margin-bottom: 0; } +.history { white-space: pre-wrap; } +.screen { overflow: clip; } +.screen > div { height: 100dvh; } +.screen .xterm-viewport, .screen .scrollbar { display: none; } /* the page scrolls instead */ +@media (prefers-reduced-motion: no-preference) { + /* typing still reaches the terminal when it is not focused, so its cursor keeps blinking like the page's */ + .screen .xterm-rows:not(.xterm-focus) .xterm-cursor-block { animation: blink 1.1s steps(1) infinite; } +} /* Header */ header {