mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
SCENES > VCAM pairs a phone through a QR code and turns it into a handheld camera, after Unreal's Live Link VCAM and Blender's VirtuCamera: - Android Chrome tracks 6DoF through WebXR immersive-ar; iPhone Safari, which has no WebXR AR, tracks rotation through DeviceOrientation, and thumb sticks dolly, truck, crane and pan. - A motion scale (1:1 walk to 1:10000 orbit), smoothing, height and horizon locks, hold/clutch and recenter, none of which make the camera jump. Focal length and scale-aware depth of field with tap-to-focus. - The phone shows the app's render as a monitor (newest-frame JPEG pull). - REC records a take as a version-7 track shot in the selected scene. - The rig claims the camera through the navigation seam (Cockpit refuses it) and holds on any other navigation, scene run or globe input. Pairing never exposes the app: a loopback-only session request starts a separate HTTPS listener (VCAM_PORT, default 4174) that serves only the static phone page and a token-paired relay, and stops after the last session. Its certificate comes from a local CA built with node:crypto, which an iPhone can trust once; without it the phone falls back from WebSocket to HTTPS requests, which iOS allows past a certificate warning. scripts/qa-vcam.mjs drives it end to end: the real phone page over the HTTPS fallback, a scripted 6DoF phone, a recorded and replayed take, depth of field and camera handoff. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_015H1iLusTzWpVKitGic2AW8
238 lines
8.3 KiB
JavaScript
238 lines
8.3 KiB
JavaScript
/**
|
|
* Local certificate authority for the virtual-camera phone listener.
|
|
*
|
|
* Phones only expose camera tracking (WebXR, DeviceOrientation) to secure
|
|
* contexts, so the LAN listener must speak HTTPS. A tapped-through
|
|
* self-signed certificate is not enough on iOS: Safari loads the page but
|
|
* refuses its WebSocket. Instead this module keeps one long-lived local CA,
|
|
* which a phone can install and trust once, and issues the listener a short
|
|
* server certificate for this machine's current LAN addresses.
|
|
*
|
|
* Certificates are encoded here with node:crypto (EC P-256, ECDSA-SHA256), so
|
|
* no OpenSSL binary or dependency is needed. Keys never leave the cache
|
|
* directory, which is git-ignored and outside the dev server's watcher.
|
|
*/
|
|
import crypto from 'node:crypto';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { isIP } from 'node:net';
|
|
|
|
const DAY_MS = 86400000;
|
|
// Apple rejects user-trusted TLS server certificates valid for over 825 days.
|
|
const SERVER_VALIDITY_DAYS = 397;
|
|
const CA_VALIDITY_DAYS = 3650;
|
|
// Reissue the server certificate this long before it expires.
|
|
const RENEW_MARGIN_DAYS = 30;
|
|
|
|
const length = (n) =>
|
|
n < 0x80
|
|
? Buffer.from([n])
|
|
: n < 0x100
|
|
? Buffer.from([0x81, n])
|
|
: Buffer.from([0x82, n >> 8, n & 0xff]);
|
|
const tlv = (tag, ...parts) => {
|
|
const body = Buffer.concat(parts);
|
|
return Buffer.concat([Buffer.from([tag]), length(body.length), body]);
|
|
};
|
|
const sequence = (...parts) => tlv(0x30, ...parts);
|
|
const set = (...parts) => tlv(0x31, ...parts);
|
|
const explicit = (n, ...parts) => tlv(0xa0 + n, ...parts);
|
|
const octets = (buffer) => tlv(0x04, buffer);
|
|
const bits = (buffer, unused = 0) => tlv(0x03, Buffer.from([unused]), buffer);
|
|
const boolean = (value) => tlv(0x01, Buffer.from([value ? 0xff : 0]));
|
|
const utf8 = (text) => tlv(0x0c, Buffer.from(text, 'utf8'));
|
|
function integer(buffer) {
|
|
let bytes = Buffer.from(buffer);
|
|
while (bytes.length > 1 && bytes[0] === 0 && !(bytes[1] & 0x80))
|
|
bytes = bytes.subarray(1);
|
|
if (bytes[0] & 0x80) bytes = Buffer.concat([Buffer.from([0]), bytes]);
|
|
return tlv(0x02, bytes);
|
|
}
|
|
function oid(dotted) {
|
|
const [first, second, ...rest] = dotted.split('.').map(Number);
|
|
const bytes = [40 * first + second];
|
|
for (const part of rest) {
|
|
const group = [part & 0x7f];
|
|
for (let v = part >>> 7; v; v >>>= 7) group.unshift((v & 0x7f) | 0x80);
|
|
bytes.push(...group);
|
|
}
|
|
return tlv(0x06, Buffer.from(bytes));
|
|
}
|
|
function time(date) {
|
|
const iso = date.toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
|
// RFC 5280: UTCTime through 2049, GeneralizedTime from 2050.
|
|
return date.getUTCFullYear() < 2050
|
|
? tlv(0x17, Buffer.from(`${iso.slice(2)}Z`))
|
|
: tlv(0x18, Buffer.from(`${iso}Z`));
|
|
}
|
|
const name = (commonName) =>
|
|
sequence(
|
|
set(sequence(oid('2.5.4.10'), utf8("God's Eye View"))),
|
|
set(sequence(oid('2.5.4.3'), utf8(commonName))),
|
|
);
|
|
const extension = (id, critical, value) =>
|
|
sequence(oid(id), ...(critical ? [boolean(true)] : []), octets(value));
|
|
const ECDSA_SHA256 = sequence(oid('1.2.840.10045.4.3.2'));
|
|
const spkiOf = (publicKey) => publicKey.export({ type: 'spki', format: 'der' });
|
|
// RFC 5280 method 1: SHA-1 of the subjectPublicKey bit string's contents.
|
|
const keyIdentifier = (spki) =>
|
|
crypto.createHash('sha1').update(spki.subarray(-65)).digest();
|
|
|
|
function subjectAltNames(names) {
|
|
return sequence(
|
|
...names.map((entry) =>
|
|
isIP(entry) === 4
|
|
? tlv(0x87, Buffer.from(entry.split('.').map(Number)))
|
|
: tlv(0x82, Buffer.from(entry, 'ascii')),
|
|
),
|
|
);
|
|
}
|
|
|
|
function sign({ subject, issuer, publicKey, signingKey, days, extensions }) {
|
|
const now = Date.now();
|
|
const tbs = sequence(
|
|
explicit(0, integer(Buffer.from([2]))),
|
|
integer(crypto.randomBytes(16)),
|
|
ECDSA_SHA256,
|
|
issuer,
|
|
sequence(time(new Date(now - DAY_MS)), time(new Date(now + days * DAY_MS))),
|
|
subject,
|
|
spkiOf(publicKey),
|
|
explicit(3, sequence(...extensions)),
|
|
);
|
|
const signature = crypto.sign('sha256', tbs, signingKey);
|
|
return sequence(tbs, ECDSA_SHA256, bits(signature));
|
|
}
|
|
|
|
const pem = (label, der) =>
|
|
`-----BEGIN ${label}-----\n${der
|
|
.toString('base64')
|
|
.match(/.{1,64}/g)
|
|
.join('\n')}\n-----END ${label}-----\n`;
|
|
const keyPem = (key) => key.export({ type: 'pkcs8', format: 'pem' });
|
|
|
|
const CA_COMMON_NAME = "God's Eye View Local VCam CA";
|
|
|
|
/** Create a CA that may only sign server certificates (path length 0). */
|
|
export function createCertificateAuthority(commonName = CA_COMMON_NAME) {
|
|
const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', {
|
|
namedCurve: 'P-256',
|
|
});
|
|
const subject = name(commonName);
|
|
const der = sign({
|
|
subject,
|
|
issuer: subject,
|
|
publicKey,
|
|
signingKey: privateKey,
|
|
days: CA_VALIDITY_DAYS,
|
|
extensions: [
|
|
extension(
|
|
'2.5.29.19',
|
|
true,
|
|
sequence(boolean(true), integer(Buffer.from([0]))),
|
|
),
|
|
// keyCertSign and cRLSign.
|
|
extension('2.5.29.15', true, bits(Buffer.from([0x06]), 1)),
|
|
extension('2.5.29.14', false, octets(keyIdentifier(spkiOf(publicKey)))),
|
|
],
|
|
});
|
|
return { cert: pem('CERTIFICATE', der), key: keyPem(privateKey) };
|
|
}
|
|
|
|
/** Issue a TLS server certificate for the given IP addresses and DNS names. */
|
|
export function issueServerCertificate(authority, names) {
|
|
const caKey = crypto.createPrivateKey(authority.key);
|
|
const caCert = new crypto.X509Certificate(authority.cert);
|
|
const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', {
|
|
namedCurve: 'P-256',
|
|
});
|
|
const caSpki = spkiOf(caCert.publicKey);
|
|
const der = sign({
|
|
subject: name(names[0]),
|
|
issuer: name(caCert.subject.match(/CN=([^\n]+)/)?.[1] ?? CA_COMMON_NAME),
|
|
publicKey,
|
|
signingKey: caKey,
|
|
days: SERVER_VALIDITY_DAYS,
|
|
extensions: [
|
|
extension('2.5.29.19', true, sequence()),
|
|
// digitalSignature.
|
|
extension('2.5.29.15', true, bits(Buffer.from([0x80]), 7)),
|
|
extension('2.5.29.37', false, sequence(oid('1.3.6.1.5.5.7.3.1'))),
|
|
extension('2.5.29.17', false, subjectAltNames(names)),
|
|
extension('2.5.29.35', false, sequence(tlv(0x80, keyIdentifier(caSpki)))),
|
|
],
|
|
});
|
|
return { cert: pem('CERTIFICATE', der), key: keyPem(privateKey) };
|
|
}
|
|
|
|
function readPair(directory, base) {
|
|
try {
|
|
return {
|
|
cert: fs.readFileSync(path.join(directory, `${base}.crt`), 'utf8'),
|
|
key: fs.readFileSync(path.join(directory, `${base}.key`), 'utf8'),
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function writePair(directory, base, pair) {
|
|
fs.writeFileSync(path.join(directory, `${base}.key`), pair.key, {
|
|
mode: 0o600,
|
|
});
|
|
fs.writeFileSync(path.join(directory, `${base}.crt`), pair.cert);
|
|
}
|
|
|
|
function serverCertificateCovers(pair, authority, names) {
|
|
try {
|
|
const cert = new crypto.X509Certificate(pair.cert);
|
|
if (!cert.verify(new crypto.X509Certificate(authority.cert).publicKey))
|
|
return false;
|
|
if (Date.parse(cert.validTo) - Date.now() < RENEW_MARGIN_DAYS * DAY_MS)
|
|
return false;
|
|
const listed = new Set(
|
|
String(cert.subjectAltName || '')
|
|
.split(',')
|
|
.map((entry) => entry.trim().replace(/^(DNS|IP Address):/, '')),
|
|
);
|
|
return names.every((entry) => listed.has(entry));
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load or create the cached CA and a server certificate covering `names`.
|
|
* The CA survives restarts so a phone's one-time trust keeps working; the
|
|
* server certificate is reissued when an address changes or it nears expiry.
|
|
*
|
|
* @param {string} directory - Cache directory, created with owner-only access.
|
|
* @param {string[]} names - IPv4 addresses and DNS names; the first is the subject.
|
|
* @returns {{ ca: string, cert: string, key: string, caFingerprint: string }}
|
|
*/
|
|
export function loadLocalCertificates(directory, names) {
|
|
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
let authority = readPair(directory, 'ca');
|
|
try {
|
|
if (authority && !new crypto.X509Certificate(authority.cert).ca)
|
|
authority = null;
|
|
} catch {
|
|
authority = null;
|
|
}
|
|
if (!authority) {
|
|
authority = createCertificateAuthority();
|
|
writePair(directory, 'ca', authority);
|
|
}
|
|
let server = readPair(directory, 'server');
|
|
if (!server || !serverCertificateCovers(server, authority, names)) {
|
|
server = issueServerCertificate(authority, names);
|
|
writePair(directory, 'server', server);
|
|
}
|
|
return {
|
|
ca: authority.cert,
|
|
cert: server.cert,
|
|
key: server.key,
|
|
caFingerprint: new crypto.X509Certificate(authority.cert).fingerprint256,
|
|
};
|
|
}
|