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";