diff --git a/hive-ag3nt/assets/screen.html b/hive-ag3nt/assets/screen.html index 31c1db13..268e04be 100644 --- a/hive-ag3nt/assets/screen.html +++ b/hive-ag3nt/assets/screen.html @@ -189,6 +189,87 @@ canvas { display: block; cursor: default; } function u32be(b, o) { return ((b[o]<<24)|(b[o+1]<<16)|(b[o+2]<<8)|b[o+3])>>>0; } function u16be(b, o) { return ((b[o]<<8)|b[o+1])>>>0; } + // ── Apple-DH (security type 30) helpers ───────────────────────────────── + // Protocol (from neatvnc apple-dh.c): + // Server → client: generator(2) + key_size(2) + prime[key_size] + server_pub[key_size] + // Client → server: client_pub[key_size] + aes128ecb(MD5(shared_secret), creds[128]) + // After SecurityResult=0: normal plaintext VNC (no session encryption) + // + // BigInt mod-pow — handles 2048-bit DH arithmetic. + function modpow(base, exp, mod) { + let r = 1n; + base = base % mod; + while (exp > 0n) { + if (exp & 1n) r = r * base % mod; + exp >>= 1n; + base = base * base % mod; + } + return r; + } + function bytesToBigInt(b) { + let n = 0n; + for (const byte of b) n = (n << 8n) | BigInt(byte); + return n; + } + function bigIntToBytes(n, len) { + const out = new Uint8Array(len); + for (let i = len - 1; i >= 0; i--) { out[i] = Number(n & 0xffn); n >>= 8n; } + return out; + } + + // Compact MD5 — needed because Web Crypto doesn't expose MD5. + // Based on the RFC 1321 reference implementation, minified. + function md5(data) { + const b = data instanceof Uint8Array ? data : new Uint8Array(data); + const len = b.length; + // pad message + const padLen = ((len + 8) >>> 6 << 4) + 16; + const m = new Uint32Array(padLen); + for (let i = 0; i < len; i++) m[i>>2] |= b[i] << ((i&3)*8); + m[len>>2] |= 0x80 << ((len&3)*8); + m[padLen-2] = len*8; + const T = new Int32Array(64); + for (let i = 0; i < 64; i++) T[i] = (Math.abs(Math.sin(i+1)) * 0x100000000)|0; + let [a, b2, c, d] = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476]; + const S = [7,12,17,22, 5,9,14,20, 4,11,16,23, 6,10,15,21]; + function add(x,y){return (x+y)|0;} + function r(v,s){return (v<>>(32-s));} + for (let i = 0; i < padLen; i += 16) { + let [aa,bb,cc,dd] = [a,b2,c,d]; + for (let j = 0; j < 64; j++) { + let [f, g] = j<16 ? [(bb&cc)|((~bb)&dd), j] + : j<32 ? [(dd&bb)|((~dd)&cc), (5*j+1)%16] + : j<48 ? [bb^cc^dd, (3*j+5)%16] + : [cc^(bb|(~dd)), (7*j)%16]; + f = add(add(aa, f), add(m[i+g], T[j])); + [aa,dd,cc,bb] = [dd, cc, bb, add(bb, r(f, S[(j%4)+((j>>2&3)*4)]))]; + } + [a,b2,c,d] = [add(a,aa), add(b2,bb), add(c,cc), add(d,dd)]; + } + const out = new Uint8Array(16); + [a,b2,c,d].forEach((x,i) => { + out[i*4]=(x)&0xff; out[i*4+1]=(x>>8)&0xff; + out[i*4+2]=(x>>16)&0xff; out[i*4+3]=(x>>24)&0xff; + }); + return out; + } + + // AES-128-ECB encrypt 128 bytes using Web Crypto AES-CBC (null IV per block = ECB). + async function aes128ecb(key16, data128) { + const keyObj = await crypto.subtle.importKey('raw', key16, {name:'AES-CBC'}, false, ['encrypt']); + const out = new Uint8Array(128); + const iv = new Uint8Array(16); // zeroed IV → ECB mode for single blocks + for (let i = 0; i < 8; i++) { + const enc = await crypto.subtle.encrypt({name:'AES-CBC',iv}, keyObj, data128.slice(i*16,(i+1)*16)); + out.set(new Uint8Array(enc,0,16), i*16); + } + return out; + } + + // Apple-DH state — stored between async continuations. + let appleDhState = null; + // ──────────────────────────────────────────────────────────────────────── + function processRfb() { while (true) { if (!tryStep()) break; @@ -221,18 +302,22 @@ canvas { display: block; cursor: default; } if (types.indexOf(1) !== -1) prefer = 1; // plain None else if (types.indexOf(19) !== -1) prefer = 19; // VeNCrypt else prefer = types[0]; - // Only handle known-safe types; reject everything else. - if (prefer !== 1 && prefer !== 19) { - dbg('no supported security type in [' + Array.from(types).join(', ') + '] — need 1 (None) or 19 (VeNCrypt)', 'err'); + // Prefer: 1 (None) → 19 (VeNCrypt) → 30 (Apple-DH) + if (types.indexOf(1) !== -1) prefer = 1; + else if (types.indexOf(19) !== -1) prefer = 19; + else if (types.indexOf(30) !== -1) prefer = 30; + else { + dbg('no supported type in [' + Array.from(types).join(', ') + '] — need 1, 19, or 30', 'err'); setStatus('unsupported security types: [' + Array.from(types).join(', ') + ']', 'error'); ws.close(); return false; } dbg('→ choosing security type ' + prefer + - (prefer === 1 ? ' (None)' : ' (VeNCrypt)')); + (prefer === 1 ? ' (None)' : prefer === 19 ? ' (VeNCrypt)' : ' (Apple-DH)')); send(new Uint8Array([prefer])); if (prefer === 1) state = 'security-result'; - else state = 'vencrypt-version'; + else if (prefer === 19) state = 'vencrypt-version'; + else state = 'apple-dh-params'; return true; } case 'security-vnc-challenge': { @@ -290,6 +375,72 @@ canvas { display: block; cursor: default; } state = 'security-result'; return true; } + // ── Apple-DH (type 30) ──────────────────────────────────────────── + // Server sends: generator(2 BE) + key_size(2 BE) + prime[key_size] + + // server_pub[key_size] + // Client sends: client_pub[key_size] + aes128ecb(MD5(shared), creds[128]) + // No session encryption after auth — plain RFB follows. + case 'apple-dh-params': { + const hdr = drainTo(4); + if (!hdr) return false; + const generator = u16be(hdr, 0); + const keySize = u16be(hdr, 2); + dbg('← Apple-DH: generator=' + generator + ' key_size=' + keySize); + const rest = drainTo(keySize * 2); + if (!rest) { chunks.unshift(hdr); totalBytes += 4; return false; } + const prime = rest.slice(0, keySize); + const serverPub = rest.slice(keySize); + dbg('← Apple-DH prime[0:4]=' + hex(prime.slice(0,4)) + + ' server_pub[0:4]=' + hex(serverPub.slice(0,4))); + + // Async DH computation — pause state machine, resume when done. + appleDhState = { generator, keySize, prime, serverPub }; + state = 'apple-dh-wait'; + (async () => { + try { + const p = bytesToBigInt(appleDhState.prime); + const g = BigInt(appleDhState.generator); + const ks = appleDhState.keySize; + + // Generate client private key: random ks bytes, then mod p + const privBytes = crypto.getRandomValues(new Uint8Array(ks)); + const priv = bytesToBigInt(privBytes) % p; + + // Client public key = g ^ priv mod p + const clientPub = modpow(g, priv, p); + const clientPubBytes = bigIntToBytes(clientPub, ks); + + // Shared secret = server_pub ^ priv mod p + const serverPubInt = bytesToBigInt(appleDhState.serverPub); + const shared = modpow(serverPubInt, priv, p); + const sharedBytes = bigIntToBytes(shared, ks); + + // AES key = MD5(shared_secret) + const aesKey = md5(sharedBytes); + dbg('Apple-DH: shared MD5 key=' + hex(aesKey)); + + // Credentials: 64 bytes username (zeroed) + 64 bytes password (zeroed) + const creds = new Uint8Array(128); // empty username + empty password + const encCreds = await aes128ecb(aesKey, creds); + + // Send: client_pub + encrypted_creds + const response = new Uint8Array(ks + 128); + response.set(clientPubBytes, 0); + response.set(encCreds, ks); + send(response); + dbg('→ Apple-DH response sent (' + response.length + ' bytes)', 'ok'); + + state = 'security-result'; + processRfb(); // resume state machine + } catch(e) { + setStatus('Apple-DH error: ' + e.message, 'error'); + } + })(); + return false; // pause — async resumes + } + case 'apple-dh-wait': + // Async handshake in progress — don't consume bytes + return false; // ────────────────────────────────────────────────────────────────── case 'security-result': { const b = drainTo(4); diff --git a/nix/templates/weston-vnc.nix b/nix/templates/weston-vnc.nix index d89a4913..e17b1515 100644 --- a/nix/templates/weston-vnc.nix +++ b/nix/templates/weston-vnc.nix @@ -57,33 +57,6 @@ }; config = lib.mkIf config.hyperhive.gui.enable { - # Build weston linked against a neatvnc without TLS/auth support. - # neatvnc ≥ 0.9 compiled with gnutls always advertises RSA-AES-256 - # (type 129), RSA-AES (type 5), and Apple-DH (type 30) security types - # regardless of the weston.ini auth-method setting — because - # nvnc_has_auth() returns true at the C level, causing weston to call - # nvnc_enable_auth() unconditionally. The in-browser RFB client has no - # RSA key and cannot complete these handshakes. - # - # The fix: pass `-Dtls=disabled` to neatvnc's meson build (the option - # name from neatvnc's meson_options.txt that guards gnutls + the entire - # auth module). With TLS disabled, nvnc_has_auth() returns false, weston - # skips nvnc_enable_auth(), and neatvnc advertises only type 1 (None). - # gnutls stays in buildInputs so pkg-config resolution doesn't error; - # the meson flag overrides the feature to "disabled" at configure time. - nixpkgs.overlays = [ - (_final: prev: { - weston = prev.weston.override { - neatvnc = prev.neatvnc.overrideAttrs (old: { - mesonFlags = (old.mesonFlags or []) ++ [ - "-Dtls=disabled" - "-Dnettle=disabled" - ]; - }); - }; - }) - ]; - systemd.services.weston = { description = "Weston Wayland compositor (VNC backend)"; after = [ "network.target" ]; @@ -131,14 +104,17 @@ ${pkgs.coreutils}/bin/printf '{"vnc_port":%d,"auth":"none"}\n' \ "$VNC_PORT" > /etc/hyperhive/gui.json || true - # neatvnc is built without gnutls (see nixpkgs.overlays above), - # so nvnc_has_auth() returns false and weston skips auth setup - # entirely — neatvnc advertises only security type 1 (None). - # No weston.ini or --disable-transport-layer-security needed. + # neatvnc ≥ 0.9 advertises RSA-AES and Apple-DH security types + # when auth is compiled in. The browser client handles Apple-DH + # (type 30) with empty credentials (weston auth-method=none + # accepts any credentials). --disable-transport-layer-security + # prevents the VeNCrypt TLS wrapper over the VNC-over-WebSocket + # relay; plain auth types are used directly. exec ${pkgs.weston}/bin/weston \ --backend=vnc-backend.so \ --renderer=pixman \ - --port="$VNC_PORT" + --port="$VNC_PORT" \ + --disable-transport-layer-security ''; Restart = "on-failure"; RestartSec = "5s";