@mahmoud_helmy/telecall 0.3.0-beta.2 → 0.3.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/THIRD-PARTY-NOTICES.md +2 -3
- package/app/Telecall.apk +0 -0
- package/app/app.json +6 -7
- package/app/cert.mjs +54 -0
- package/app/page.js +1 -4
- package/app/pairing.mjs +17 -98
- package/app/secure-host.mjs +169 -0
- package/app/ws.mjs +100 -0
- package/bin/telecall.mjs +2 -5
- package/package.json +1 -2
- package/licenses/ASPNETCORE-LICENSE.txt +0 -23
- package/licenses/ASPNETCORE-THIRD-PARTY-NOTICES.txt +0 -1110
- package/licenses/CSWINRT-LICENSE.txt +0 -21
- package/licenses/DOTNET-LICENSE.txt +0 -23
- package/licenses/DOTNET-THIRD-PARTY-NOTICES.txt +0 -1418
- package/licenses/WINDOWS-SDK-LICENSE.html +0 -1138
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ Run once on the PC:
|
|
|
14
14
|
npx @mahmoud_helmy/telecall@beta
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
The setup page opens in your browser.
|
|
17
|
+
The setup page opens in your browser. This single package (about 4 MB) contains the phone APK, setup pages and the secure pairing service, all running on your Node.js. There are no npm dependencies, native helpers or post-install downloads. Android Studio, .NET and APK_Bridge are not required.
|
|
18
18
|
|
|
19
19
|
1. Scan **Get the app** with your phone camera. Download **Telecall.apk**, open it, and allow your browser to install apps if Android asks.
|
|
20
20
|
2. On the PC page, open **Pair phone**, select the phone's network, and click **Create pairing QR**.
|
|
@@ -47,9 +47,9 @@ Before updating, stop sharing, then run the global install command again. Downlo
|
|
|
47
47
|
|
|
48
48
|
Pairing uses a certificate-pinned encrypted link, a 256-bit single-use credential and a five-minute QR expiry. Confirm pairing on the phone. The credential is shown only on the PC's local page. Camera images are neither sent nor saved. This beta pairs for one session; create a new QR after disconnecting.
|
|
49
49
|
|
|
50
|
-
No Windows network-profile, firewall, administrator or router change is performed. Guest Wi-Fi isolation, VPNs or a firewall can still block device-to-device traffic. Telecall reports failures; it cannot override an organization's network policy. The PC page uses 8770 by default; the encrypted channel uses 8768 on your selected adapter
|
|
50
|
+
No Windows network-profile, firewall, administrator or router change is performed. Guest Wi-Fi isolation, VPNs or a firewall can still block device-to-device traffic. Telecall reports failures; it cannot override an organization's network policy. The PC page uses 8770 by default; the encrypted channel uses 8768 on your selected adapter only. Do not forward these ports to the internet.
|
|
51
51
|
|
|
52
|
-
The APK is publicly downloadable on the selected local network while sharing runs. Pairing controls and the pairing QR are computer-only. Stop sharing when finished. Optional logs contain
|
|
52
|
+
The APK is publicly downloadable on the selected local network while sharing runs. Pairing controls and the pairing QR are computer-only. Stop sharing when finished. Optional logs contain startup errors and local addresses, not pairing secrets or phone numbers; set `TELECALL_HOME` to choose their folder.
|
|
53
53
|
|
|
54
54
|
## Report beta findings
|
|
55
55
|
|
package/THIRD-PARTY-NOTICES.md
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
# Third-party notices
|
|
2
2
|
|
|
3
|
-
The npm beta includes the independently signed Android beta and
|
|
4
|
-
|
|
5
|
-
The Android Gradle baseline, wrapper, and connection/lifecycle architecture were adapted using the local APK Bridge project. Telecall is a separate application and does not share its tokens or app identity. APK Bridge's license is reproduced below. The Gradle wrapper is distributed by Gradle under Apache License 2.0; dependency artifacts retain their respective licenses. Windows self-contained runtime license files are retained in build output.
|
|
3
|
+
The npm beta includes the independently signed Android beta and the Node.js setup and pairing code; it has no npm dependencies and no native Windows components. Android dependencies include AndroidX Core/Activity/Compose/CameraX, Kotlin, OkHttp 4.12.0 and ZXing 3.5.3, under Apache License 2.0; the full license is included there as `APACHE-2.0.txt`. These components retain their upstream copyrights and licenses. The npm beta uses the tester's existing Node runtime rather than bundling Node.
|
|
6
4
|
|
|
5
|
+
The Android Gradle baseline, wrapper, and connection/lifecycle architecture were adapted using the local APK Bridge project. Telecall is a separate application and does not share its tokens or app identity. APK Bridge's license is reproduced below. The Gradle wrapper is distributed by Gradle under Apache License 2.0; dependency artifacts retain their respective licenses.
|
|
7
6
|
## APK Bridge — MIT License
|
|
8
7
|
|
|
9
8
|
The local installation page follows APK Bridge's QR-to-download pattern. Its offline QR generator vendors the qrcode 1.5.4 core and SVG renderer (Copyright 2012 Ryan Day) and dijkstrajs (Copyright 2008 Wyatt Baldwin), both MIT licensed. Full license texts are in `install/vendor/qrcode/LICENSE` and `install/vendor/qrcode/DIJKSTRA-LICENSE`. The only QR library modification changes the dijkstrajs import to its vendored relative path. The portable install bundle includes the existing local Node.js runtime; its complete license notices are preserved in `runtime/LICENSE`.
|
package/app/Telecall.apk
CHANGED
|
Binary file
|
package/app/app.json
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
"
|
|
6
|
-
|
|
7
|
-
"
|
|
8
|
-
"channel": "signed-beta"
|
|
2
|
+
"version": "0.3.0-beta.4-api36",
|
|
3
|
+
"packageName": "com.telecall.diagnostics.beta",
|
|
4
|
+
"size": 3548980,
|
|
5
|
+
"sha256": "4508b98680fba77f8e06052d4209c481b64fa899a488869a3b150224d9b92d4b",
|
|
6
|
+
"minimumAndroid": 10,
|
|
7
|
+
"channel": "signed-beta"
|
|
9
8
|
}
|
package/app/cert.mjs
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import {X509Certificate, createHash, generateKeyPairSync, randomBytes, sign} from 'node:crypto';
|
|
2
|
+
import {isIPv4} from 'node:net';
|
|
3
|
+
|
|
4
|
+
// Node can verify certificates but not issue them. This builds the one shape Telecall
|
|
5
|
+
// needs: an ephemeral self-signed P-256 leaf whose SHA-256 the phone pins from the QR.
|
|
6
|
+
const length = n => n < 0x80 ? Buffer.from([n]) : n < 0x100 ? Buffer.from([0x81, n]) : Buffer.from([0x82, n >> 8, n & 0xff]);
|
|
7
|
+
const tlv = (tag, ...parts) => { const body = Buffer.concat(parts); return Buffer.concat([Buffer.from([tag]), length(body.length), body]); };
|
|
8
|
+
const seq = (...parts) => tlv(0x30, ...parts);
|
|
9
|
+
const oid = dotted => {
|
|
10
|
+
const [a, b, ...rest] = dotted.split('.').map(Number);
|
|
11
|
+
const bytes = [40 * a + b];
|
|
12
|
+
for (const value of rest) {
|
|
13
|
+
const group = [value & 0x7f];
|
|
14
|
+
for (let v = value >>> 7; v; v >>>= 7) group.unshift((v & 0x7f) | 0x80);
|
|
15
|
+
bytes.push(...group);
|
|
16
|
+
}
|
|
17
|
+
return tlv(0x06, Buffer.from(bytes));
|
|
18
|
+
};
|
|
19
|
+
const utcTime = date => tlv(0x17, Buffer.from(date.toISOString().replace(/[-:T]/g, '').slice(2, 14) + 'Z'));
|
|
20
|
+
const extension = (id, critical, value) => seq(oid(id), ...(critical ? [tlv(0x01, Buffer.from([0xff]))] : []), tlv(0x04, value));
|
|
21
|
+
const pem = (label, der) => `-----BEGIN ${label}-----\n${der.toString('base64').match(/.{1,64}/g).join('\n')}\n-----END ${label}-----\n`;
|
|
22
|
+
|
|
23
|
+
export function createCertificate(ip, now = new Date()) {
|
|
24
|
+
if (!isIPv4(ip)) throw new Error('Certificate address must be IPv4');
|
|
25
|
+
const {privateKey, publicKey} = generateKeyPairSync('ec', {namedCurve: 'P-256'});
|
|
26
|
+
const serial = randomBytes(16);
|
|
27
|
+
serial[0] = (serial[0] & 0x7f) | 0x40; // positive and minimally encoded
|
|
28
|
+
const algorithm = seq(oid('1.2.840.10045.4.3.2')); // ecdsa-with-SHA256, no parameters
|
|
29
|
+
const name = seq(tlv(0x31, seq(oid('2.5.4.3'), tlv(0x0c, Buffer.from('Telecall')))));
|
|
30
|
+
const tbs = seq(
|
|
31
|
+
tlv(0xa0, tlv(0x02, Buffer.from([2]))), // v3
|
|
32
|
+
tlv(0x02, serial),
|
|
33
|
+
algorithm,
|
|
34
|
+
name,
|
|
35
|
+
seq(utcTime(new Date(now.getTime() - 60_000)), utcTime(new Date(now.getTime() + 8 * 3600_000))),
|
|
36
|
+
name,
|
|
37
|
+
publicKey.export({type: 'spki', format: 'der'}),
|
|
38
|
+
tlv(0xa3, seq(
|
|
39
|
+
extension('2.5.29.19', true, seq()), // basicConstraints: not a CA
|
|
40
|
+
extension('2.5.29.37', true, seq(oid('1.3.6.1.5.5.7.3.1'))), // serverAuth only
|
|
41
|
+
extension('2.5.29.17', false, seq(tlv(0x87, Buffer.from(ip.split('.').map(Number)))))
|
|
42
|
+
))
|
|
43
|
+
);
|
|
44
|
+
const signature = sign('sha256', tbs, {key: privateKey, dsaEncoding: 'der'});
|
|
45
|
+
const der = seq(tbs, algorithm, tlv(0x03, Buffer.from([0]), signature));
|
|
46
|
+
const parsed = new X509Certificate(der);
|
|
47
|
+
if (!parsed.verify(publicKey) || !parsed.checkPrivateKey(privateKey) || parsed.checkIP(ip) !== ip) throw new Error('Generated certificate failed its self-check');
|
|
48
|
+
return {
|
|
49
|
+
key: privateKey.export({type: 'pkcs8', format: 'pem'}),
|
|
50
|
+
cert: pem('CERTIFICATE', der),
|
|
51
|
+
der,
|
|
52
|
+
fingerprint: createHash('sha256').update(der).digest('hex').toUpperCase()
|
|
53
|
+
};
|
|
54
|
+
}
|
package/app/page.js
CHANGED
|
@@ -53,10 +53,7 @@ if (document.body.classList.contains('pairing')) {
|
|
|
53
53
|
document.querySelector('#pair-countdown').textContent = `Expires in ${Math.floor(remaining/60)}:${String(remaining%60).padStart(2,'0')} · Keep this QR private`;
|
|
54
54
|
} else {image.removeAttribute('src'); expires = null;}
|
|
55
55
|
error.textContent = state.state !== 'blocked' ? '' : ({
|
|
56
|
-
|
|
57
|
-
CannotVerifyPrivateProfile:'Windows could not confirm a Private profile for this adapter. Choose your trusted Private Wi-Fi network above.',
|
|
58
|
-
MissingPairingHost:'The secure companion is missing. Extract the complete updated Telecall installer folder.',
|
|
59
|
-
CouldNotStartSecureListener:'The secure listener could not start. Check that another Telecall session is not using port 8768.'
|
|
56
|
+
CouldNotStartSecureListener:'The secure listener could not start on this network. Choose a connected Wi-Fi or hotspot address above, then try again.'
|
|
60
57
|
,LanPortBusy:'Port 8768 is already in use. Stop the other Telecall session, then create a fresh QR.'
|
|
61
58
|
}[state.code] || 'Check the companion and your Windows network settings, then create a fresh QR.');
|
|
62
59
|
}
|
package/app/pairing.mjs
CHANGED
|
@@ -1,116 +1,35 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {existsSync} from 'node:fs';
|
|
3
|
-
import {join, resolve} from 'node:path';
|
|
4
|
-
import net from 'node:net';
|
|
5
|
-
import {createRequire} from 'node:module';
|
|
6
|
-
|
|
7
|
-
export function resolvePairingHost(root) {
|
|
8
|
-
const portable = join(root,'pairing','Telecall.PairingHost.exe');
|
|
9
|
-
if (existsSync(portable)) return portable;
|
|
10
|
-
try {return createRequire(import.meta.url).resolve('@mahmoud_helmy/telecall-windows-x64/host');}
|
|
11
|
-
catch {return null;}
|
|
12
|
-
}
|
|
1
|
+
import {SecureSession} from './secure-host.mjs';
|
|
13
2
|
|
|
3
|
+
// One computer-only pairing at a time. The secure session runs in this Node process and
|
|
4
|
+
// listens only on the selected adapter; no helper process, relay or network-profile change.
|
|
14
5
|
export class PairingController {
|
|
15
|
-
#
|
|
6
|
+
#session = null;
|
|
7
|
+
#closing = Promise.resolve();
|
|
16
8
|
#state = {state:'idle'};
|
|
17
|
-
#payload = null;
|
|
18
|
-
#timer = null;
|
|
19
9
|
#generation = 0;
|
|
20
|
-
|
|
21
|
-
#sockets = new Set();
|
|
22
|
-
constructor(root) { this.root = root; }
|
|
10
|
+
constructor(root, {port = 8768} = {}) { this.root = root; this.port = port; }
|
|
23
11
|
status() { return {...this.#state}; }
|
|
24
|
-
payload() { return this.#state.state === 'waiting' && Date.now() < this.#state.expiresAt ? this.#payload : null; }
|
|
12
|
+
payload() { return this.#state.state === 'waiting' && Date.now() < this.#state.expiresAt ? this.#session?.payload ?? null : null; }
|
|
25
13
|
async start(address) {
|
|
26
14
|
if (['connected','starting'].includes(this.#state.state)) throw new Error('Finish or disconnect the current session before creating another QR.');
|
|
27
|
-
const previous = this.#child;
|
|
28
15
|
this.stop();
|
|
29
16
|
const generation = this.#generation;
|
|
30
17
|
this.#state = {state:'starting'};
|
|
31
|
-
|
|
32
|
-
const timer = setTimeout(ok,2000); timer.unref();
|
|
33
|
-
previous.once('exit', () => {clearTimeout(timer); ok();});
|
|
34
|
-
});
|
|
18
|
+
await this.#closing;
|
|
35
19
|
if (generation !== this.#generation) return;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (
|
|
40
|
-
|
|
41
|
-
this.#sockets.add(client); this.#sockets.add(upstream);
|
|
42
|
-
for (const socket of [client,upstream]) {
|
|
43
|
-
socket.setTimeout(45000, () => socket.destroy());
|
|
44
|
-
socket.on('error', () => {client.destroy(); upstream.destroy();});
|
|
45
|
-
socket.on('close', () => {this.#sockets.delete(socket); client.destroy(); upstream.destroy();});
|
|
46
|
-
}
|
|
47
|
-
client.pipe(upstream).pipe(client);
|
|
48
|
-
});
|
|
49
|
-
this.#proxy = proxy;
|
|
50
|
-
const listening = await new Promise(ok => {
|
|
51
|
-
proxy.once('error', () => ok(false));
|
|
52
|
-
proxy.listen(8768,address, () => ok(true));
|
|
20
|
+
const session = new SecureSession({address, port:this.port});
|
|
21
|
+
this.#session = session;
|
|
22
|
+
session.on('state', ({state, code}) => {
|
|
23
|
+
if (this.#session !== session) return;
|
|
24
|
+
this.#state = {state, code, expiresAt: state === 'blocked' ? undefined : session.expiresAt};
|
|
53
25
|
});
|
|
54
|
-
|
|
55
|
-
if (generation !== this.#generation)
|
|
56
|
-
const portable = resolvePairingHost(this.root);
|
|
57
|
-
const project = resolve(this.root,'..','..','..');
|
|
58
|
-
const dotnet = join(project,'.tooling','dotnet','dotnet.exe');
|
|
59
|
-
const dll = join(project,'windows','Telecall.PairingHost','bin','Debug','net10.0','win-x64','Telecall.PairingHost.dll');
|
|
60
|
-
let executable, args;
|
|
61
|
-
if (portable) { executable = portable; args = ['--qr-pairing',address]; }
|
|
62
|
-
else if (existsSync(dotnet) && existsSync(dll)) { executable = dotnet; args = [dll,'--qr-pairing',address]; }
|
|
63
|
-
else { this.stop(); this.#state = {state:'blocked',code:'MissingPairingHost'}; return; }
|
|
64
|
-
let child;
|
|
65
|
-
try {child = spawn(executable,args,{cwd:this.root,windowsHide:true,stdio:['ignore','pipe','pipe']});}
|
|
66
|
-
catch {this.stop(); this.#state = {state:'blocked',code:'CouldNotStartSecureListener'}; return;}
|
|
67
|
-
this.#child = child;
|
|
68
|
-
let buffer = '';
|
|
69
|
-
child.stdout.setEncoding('utf8');
|
|
70
|
-
child.stdout.on('data', chunk => {
|
|
71
|
-
if (this.#child !== child) return;
|
|
72
|
-
buffer += chunk;
|
|
73
|
-
if (buffer.length > 16384) { this.stop(); this.#state = {state:'blocked',code:'InvalidHostResponse'}; return; }
|
|
74
|
-
let end;
|
|
75
|
-
while ((end = buffer.indexOf('\n')) >= 0) {
|
|
76
|
-
const line = buffer.slice(0,end).trim(); buffer = buffer.slice(end+1);
|
|
77
|
-
if (!line.startsWith('TELECALL_PAIRING:')) continue;
|
|
78
|
-
try {
|
|
79
|
-
const event = JSON.parse(line.slice('TELECALL_PAIRING:'.length));
|
|
80
|
-
if (!['waiting','connected','disconnected','expired','blocked'].includes(event.state)) continue;
|
|
81
|
-
clearTimeout(this.#timer);
|
|
82
|
-
this.#state = {state:event.state, code:event.code, expiresAt:event.expiresAt ?? this.#state.expiresAt};
|
|
83
|
-
this.#payload = event.state === 'waiting' ? event.payload : null;
|
|
84
|
-
if (['blocked','expired','disconnected'].includes(event.state)) this.#closeProxy();
|
|
85
|
-
} catch { this.stop(); this.#state = {state:'blocked',code:'InvalidHostResponse'}; }
|
|
86
|
-
}
|
|
87
|
-
});
|
|
88
|
-
// Consume errors without ever writing a child's output or a pairing credential to logs.
|
|
89
|
-
child.stderr.resume();
|
|
90
|
-
child.on('error', () => {
|
|
91
|
-
if (this.#child === child) {clearTimeout(this.#timer); this.#closeProxy(); this.#payload = null; this.#state = {state:'blocked',code:'CouldNotStartSecureListener'};}
|
|
92
|
-
});
|
|
93
|
-
child.on('exit', () => {
|
|
94
|
-
if (this.#child !== child) return;
|
|
95
|
-
this.#child = null; clearTimeout(this.#timer); this.#payload = null;
|
|
96
|
-
this.#closeProxy();
|
|
97
|
-
if (!['blocked','expired','disconnected'].includes(this.#state.state)) this.#state = {state:'disconnected'};
|
|
98
|
-
});
|
|
99
|
-
this.#timer = setTimeout(() => {if (this.#child === child && this.#state.state === 'starting') {this.stop(); this.#state = {state:'blocked',code:'PairingHostTimeout'};}},15000);
|
|
100
|
-
this.#timer.unref();
|
|
26
|
+
await session.listen();
|
|
27
|
+
if (generation !== this.#generation) await session.close();
|
|
101
28
|
}
|
|
102
29
|
stop() {
|
|
103
30
|
this.#generation++;
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
child?.kill();
|
|
107
|
-
this.#closeProxy();
|
|
108
|
-
this.#payload = null;
|
|
31
|
+
const session = this.#session; this.#session = null;
|
|
32
|
+
if (session) this.#closing = session.close();
|
|
109
33
|
this.#state = {state:'idle'};
|
|
110
34
|
}
|
|
111
|
-
#closeProxy() {
|
|
112
|
-
this.#proxy?.close(); this.#proxy = null;
|
|
113
|
-
for (const socket of this.#sockets) socket.destroy();
|
|
114
|
-
this.#sockets.clear();
|
|
115
|
-
}
|
|
116
35
|
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import https from 'node:https';
|
|
2
|
+
import {EventEmitter} from 'node:events';
|
|
3
|
+
import {randomBytes, randomUUID, timingSafeEqual} from 'node:crypto';
|
|
4
|
+
import {hostname, networkInterfaces} from 'node:os';
|
|
5
|
+
import {createCertificate} from './cert.mjs';
|
|
6
|
+
import {acceptWebSocket, isWebSocketRequest} from './ws.mjs';
|
|
7
|
+
|
|
8
|
+
// Ephemeral pairing session, not production trust: the full certificate fingerprint and
|
|
9
|
+
// a 256-bit one-use secret travel only in the computer-only QR. Nothing is persisted.
|
|
10
|
+
export const PAIRING_LIFETIME = 5 * 60_000;
|
|
11
|
+
const CALL_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
12
|
+
|
|
13
|
+
export function isPrivateIPv4(ip = '') {
|
|
14
|
+
const p = ip.split('.').map(Number);
|
|
15
|
+
if (p.length !== 4 || p.some(n => !Number.isInteger(n) || n < 0 || n > 255)) return false;
|
|
16
|
+
return p[0] === 10 || p[0] === 192 && p[1] === 168 || p[0] === 172 && p[1] >= 16 && p[1] <= 31;
|
|
17
|
+
}
|
|
18
|
+
const adapterIsUp = (ip, interfaces = networkInterfaces()) =>
|
|
19
|
+
Object.values(interfaces).flat().some(i => i && i.family === 'IPv4' && !i.internal && i.address === ip);
|
|
20
|
+
const compact = hex => Buffer.from(hex, 'hex').toString('base64url');
|
|
21
|
+
|
|
22
|
+
// Same rules as the earlier .NET PairingChallenge: single claim, expiry, five-failure lockout.
|
|
23
|
+
export class PairingChallenge {
|
|
24
|
+
#expected; #expiresAt; #claimed = false; #failures = 0;
|
|
25
|
+
constructor(secret, expiresAt) { this.#expected = Buffer.from('Bearer ' + secret, 'ascii'); this.#expiresAt = expiresAt; }
|
|
26
|
+
tryClaim(authorization, now, isWebSocket) {
|
|
27
|
+
if (this.#claimed || now >= this.#expiresAt || this.#failures >= 5) return false;
|
|
28
|
+
const supplied = Buffer.from(authorization ?? '', 'utf8');
|
|
29
|
+
if (!isWebSocket || supplied.length !== this.#expected.length || !timingSafeEqual(supplied, this.#expected)) { this.#failures++; return false; }
|
|
30
|
+
this.#claimed = true;
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class SecureSession extends EventEmitter {
|
|
36
|
+
state = 'idle';
|
|
37
|
+
code = undefined;
|
|
38
|
+
revision = -1;
|
|
39
|
+
calls = [];
|
|
40
|
+
sessionId = randomUUID().replaceAll('-', '');
|
|
41
|
+
#secret = randomBytes(32).toString('hex').toUpperCase();
|
|
42
|
+
#server = null; #socket = null; #sockets = new Set(); #timer = null; #closed = null;
|
|
43
|
+
constructor({address, port = 8768, debugTunnel = false, lifetime = PAIRING_LIFETIME, now = Date.now()}) {
|
|
44
|
+
super();
|
|
45
|
+
this.address = debugTunnel ? '127.0.0.1' : address;
|
|
46
|
+
this.port = port;
|
|
47
|
+
this.debugTunnel = debugTunnel;
|
|
48
|
+
this.expiresAt = now + lifetime;
|
|
49
|
+
this.challenge = new PairingChallenge(this.#secret, this.expiresAt);
|
|
50
|
+
}
|
|
51
|
+
get payload() {
|
|
52
|
+
if (this.state !== 'waiting') return null;
|
|
53
|
+
const name = hostname().split('.')[0] || 'Computer';
|
|
54
|
+
return `telecall://pair?v=1&h=${this.address}&p=${compact(this.fingerprint)}&s=${compact(this.#secret)}` +
|
|
55
|
+
`&e=${Math.floor(this.expiresAt / 1000)}&n=${encodeURIComponent(name)}`;
|
|
56
|
+
}
|
|
57
|
+
get pairingJson() {
|
|
58
|
+
return JSON.stringify({url: `wss://${this.address}:${this.port}/probe`, fingerprint: this.fingerprint, secret: this.#secret, debugTunnel: this.debugTunnel});
|
|
59
|
+
}
|
|
60
|
+
#setState(state, code) {
|
|
61
|
+
if (this.state === state) return;
|
|
62
|
+
this.state = state; this.code = code;
|
|
63
|
+
this.emit('state', {state, code});
|
|
64
|
+
}
|
|
65
|
+
// Resolves once the session is waiting or blocked; never rejects.
|
|
66
|
+
async listen() {
|
|
67
|
+
try {
|
|
68
|
+
if (!this.debugTunnel && (!isPrivateIPv4(this.address) || !adapterIsUp(this.address))) throw new Error('Not a connected local adapter');
|
|
69
|
+
const certificate = createCertificate(this.address);
|
|
70
|
+
this.fingerprint = certificate.fingerprint;
|
|
71
|
+
this.certificate = certificate.cert;
|
|
72
|
+
const server = https.createServer({key: certificate.key, cert: certificate.cert, minVersion: 'TLSv1.2'}, (req, res) => {
|
|
73
|
+
// Plain HTTPS to the endpoint is a failed pairing attempt, as before.
|
|
74
|
+
if (new URL(req.url, 'https://x').pathname === '/probe') this.challenge.tryClaim(req.headers.authorization, Date.now(), false);
|
|
75
|
+
res.writeHead(new URL(req.url, 'https://x').pathname === '/probe' ? 403 : 404, {connection: 'close', 'content-length': 0}).end();
|
|
76
|
+
});
|
|
77
|
+
this.#server = server;
|
|
78
|
+
server.maxConnections = 8;
|
|
79
|
+
server.headersTimeout = 15000;
|
|
80
|
+
server.requestTimeout = 15000;
|
|
81
|
+
server.on('connection', socket => {
|
|
82
|
+
this.#sockets.add(socket);
|
|
83
|
+
socket.setTimeout(45000, () => socket.destroy());
|
|
84
|
+
socket.on('error', () => socket.destroy());
|
|
85
|
+
socket.on('close', () => this.#sockets.delete(socket));
|
|
86
|
+
});
|
|
87
|
+
server.on('tlsClientError', (_, socket) => socket.destroy());
|
|
88
|
+
server.on('upgrade', (req, socket, head) => this.#upgrade(req, socket, head));
|
|
89
|
+
this.#closed = new Promise(ok => server.once('close', ok));
|
|
90
|
+
await new Promise((ok, fail) => { server.once('error', fail); server.listen(this.port, this.address, ok); });
|
|
91
|
+
server.on('error', () => {});
|
|
92
|
+
this.port = server.address().port;
|
|
93
|
+
this.#timer = setTimeout(() => { if (this.state === 'waiting') { this.#setState('expired'); this.close(); } }, Math.max(0, this.expiresAt - Date.now()));
|
|
94
|
+
this.#timer.unref();
|
|
95
|
+
this.#setState('waiting');
|
|
96
|
+
} catch (error) {
|
|
97
|
+
this.close();
|
|
98
|
+
this.#setState('blocked', error?.code === 'EADDRINUSE' ? 'LanPortBusy' : 'CouldNotStartSecureListener');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
#upgrade(req, socket, head) {
|
|
102
|
+
socket.on('error', () => socket.destroy());
|
|
103
|
+
if (new URL(req.url, 'https://x').pathname !== '/probe') return reject(socket, 404);
|
|
104
|
+
if (!this.challenge.tryClaim(req.headers.authorization, Date.now(), isWebSocketRequest(req))) return reject(socket, 403);
|
|
105
|
+
const ws = acceptWebSocket(req, socket, head);
|
|
106
|
+
this.#socket = ws;
|
|
107
|
+
clearTimeout(this.#timer); // expiry limits claiming, not an established session
|
|
108
|
+
this.#setState('connected');
|
|
109
|
+
ws.on('message', text => {
|
|
110
|
+
try { this.#handle(JSON.parse(text)); }
|
|
111
|
+
catch { ws.close(1008); }
|
|
112
|
+
});
|
|
113
|
+
ws.on('close', () => {
|
|
114
|
+
this.#socket = null; this.calls = []; this.revision = -1;
|
|
115
|
+
if (this.state === 'connected') this.#setState('disconnected');
|
|
116
|
+
this.close();
|
|
117
|
+
});
|
|
118
|
+
this.#send({type: 'hello', version: 1, sessionId: this.sessionId});
|
|
119
|
+
}
|
|
120
|
+
#handle(message) {
|
|
121
|
+
if (message?.version !== 1 || message.sessionId !== this.sessionId) throw new Error('Session mismatch');
|
|
122
|
+
if (message.type === 'snapshot') {
|
|
123
|
+
if (!Number.isSafeInteger(message.revision) || !Array.isArray(message.calls)) throw new Error('Invalid snapshot');
|
|
124
|
+
if (message.revision < this.revision) return;
|
|
125
|
+
const calls = message.calls.map(call => {
|
|
126
|
+
if (typeof call?.callId !== 'string' || !CALL_ID.test(call.callId) || typeof call.cellular !== 'boolean' || !Number.isInteger(call.state)) throw new Error('Invalid call');
|
|
127
|
+
return {callId: call.callId, state: call.state, cellular: call.cellular};
|
|
128
|
+
});
|
|
129
|
+
this.calls = calls; this.revision = message.revision;
|
|
130
|
+
this.emit('snapshot', {revision: this.revision, calls});
|
|
131
|
+
} else if (message.type === 'result') {
|
|
132
|
+
if (typeof message.commandId !== 'string' || typeof message.code !== 'string') throw new Error('Invalid result');
|
|
133
|
+
this.emit('result', {commandId: message.commandId, code: message.code});
|
|
134
|
+
} else throw new Error('Unexpected message');
|
|
135
|
+
}
|
|
136
|
+
#send(message) {
|
|
137
|
+
if (!this.#socket?.send(JSON.stringify(message))) throw new Error('No paired phone');
|
|
138
|
+
}
|
|
139
|
+
refresh() { this.#send({type: 'refresh', version: 1, sessionId: this.sessionId}); }
|
|
140
|
+
// Loopback test harness only: resolves with the phone's next result for this commandId.
|
|
141
|
+
sendTestCommand({commandId, callId, revision, action}, timeout = 15000) {
|
|
142
|
+
if (!this.debugTunnel) return Promise.reject(new Error('Test commands require the loopback tunnel'));
|
|
143
|
+
return new Promise((ok, fail) => {
|
|
144
|
+
const done = (fn, value) => { clearTimeout(timer); this.off('result', listener); fn(value); };
|
|
145
|
+
const listener = result => { if (result.commandId === commandId) done(ok, result.code); };
|
|
146
|
+
const timer = setTimeout(() => done(fail, new Error('No result')), timeout);
|
|
147
|
+
this.on('result', listener);
|
|
148
|
+
try { this.#send({type: 'command', version: 1, sessionId: this.sessionId, commandId, callId, revision, action}); }
|
|
149
|
+
catch (error) { done(fail, error); }
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
// Ends the session and frees the port; resolves when the listener has closed.
|
|
153
|
+
close() {
|
|
154
|
+
clearTimeout(this.#timer);
|
|
155
|
+
this.#socket?.close(1000);
|
|
156
|
+
for (const socket of this.#sockets) socket.destroy();
|
|
157
|
+
this.#sockets.clear();
|
|
158
|
+
const listening = this.#server?.listening;
|
|
159
|
+
if (listening) this.#server.close();
|
|
160
|
+
const closed = listening ? this.#closed : Promise.resolve();
|
|
161
|
+
this.#server = null;
|
|
162
|
+
return Promise.race([closed, new Promise(ok => setTimeout(ok, 2000).unref())]);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function reject(socket, status) {
|
|
167
|
+
socket.end(`HTTP/1.1 ${status} ${status === 403 ? 'Forbidden' : 'Not Found'}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
|
|
168
|
+
setTimeout(() => socket.destroy(), 1000).unref();
|
|
169
|
+
}
|
package/app/ws.mjs
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import {EventEmitter} from 'node:events';
|
|
2
|
+
import {createHash} from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
// Minimal RFC 6455 server side for the phone's single text channel. Strict by design:
|
|
5
|
+
// masked client frames, text only, no extensions, bounded per-message size.
|
|
6
|
+
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
|
7
|
+
|
|
8
|
+
export function isWebSocketRequest(req) {
|
|
9
|
+
return req.method === 'GET' && /^websocket$/i.test(req.headers.upgrade ?? '') &&
|
|
10
|
+
/(^|,)\s*upgrade\s*(,|$)/i.test(req.headers.connection ?? '') &&
|
|
11
|
+
req.headers['sec-websocket-version'] === '13' &&
|
|
12
|
+
/^[A-Za-z0-9+/]{21}[AQgw]==$/.test(req.headers['sec-websocket-key'] ?? '');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function acceptWebSocket(req, socket, head, options) {
|
|
16
|
+
const accept = createHash('sha1').update(req.headers['sec-websocket-key'] + GUID).digest('base64');
|
|
17
|
+
socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`);
|
|
18
|
+
return new WebSocketConnection(socket, head, options);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class WebSocketConnection extends EventEmitter {
|
|
22
|
+
#socket; #max; #buffer = Buffer.alloc(0); #parts = []; #partSize = 0; #inMessage = false;
|
|
23
|
+
#closing = false; #closed = false;
|
|
24
|
+
#decoder = new TextDecoder('utf-8', {fatal: true});
|
|
25
|
+
constructor(socket, head = Buffer.alloc(0), {maxMessage = 16384} = {}) {
|
|
26
|
+
super();
|
|
27
|
+
this.#socket = socket; this.#max = maxMessage;
|
|
28
|
+
socket.on('data', chunk => this.#receive(chunk));
|
|
29
|
+
socket.on('error', () => socket.destroy());
|
|
30
|
+
socket.on('close', () => { if (!this.#closed) { this.#closed = true; this.#closing = true; this.emit('close'); } });
|
|
31
|
+
if (head.length) queueMicrotask(() => this.#receive(head));
|
|
32
|
+
}
|
|
33
|
+
get open() { return !this.#closing; }
|
|
34
|
+
send(text) {
|
|
35
|
+
const payload = Buffer.from(text, 'utf8');
|
|
36
|
+
if (this.#closing || payload.length > this.#max) return false;
|
|
37
|
+
this.#write(0x1, payload);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
close(code = 1000) {
|
|
41
|
+
if (this.#closing) return;
|
|
42
|
+
this.#closing = true;
|
|
43
|
+
const body = Buffer.alloc(2); body.writeUInt16BE(code);
|
|
44
|
+
this.#write(0x8, body);
|
|
45
|
+
this.#socket.end();
|
|
46
|
+
setTimeout(() => this.#socket.destroy(), 1000).unref();
|
|
47
|
+
}
|
|
48
|
+
#write(opcode, payload) {
|
|
49
|
+
const n = payload.length;
|
|
50
|
+
const header = n < 126 ? Buffer.from([0x80 | opcode, n]) : Buffer.from([0x80 | opcode, 126, n >> 8, n & 0xff]);
|
|
51
|
+
if (!this.#socket.destroyed) this.#socket.write(Buffer.concat([header, payload]));
|
|
52
|
+
}
|
|
53
|
+
#receive(chunk) {
|
|
54
|
+
if (this.#closing) return;
|
|
55
|
+
// At most one maximal frame plus its header is ever buffered.
|
|
56
|
+
if (this.#buffer.length + chunk.length > this.#max + 14) return this.close(1009);
|
|
57
|
+
this.#buffer = Buffer.concat([this.#buffer, chunk]);
|
|
58
|
+
while (!this.#closing && this.#frame());
|
|
59
|
+
}
|
|
60
|
+
#frame() {
|
|
61
|
+
const b = this.#buffer;
|
|
62
|
+
if (b.length < 2) return false;
|
|
63
|
+
const fin = (b[0] & 0x80) !== 0, opcode = b[0] & 0x0f;
|
|
64
|
+
if (b[0] & 0x70 || !(b[1] & 0x80)) { this.close(1002); return false; }
|
|
65
|
+
let size = b[1] & 0x7f, offset = 2;
|
|
66
|
+
if (size === 126) { if (b.length < 4) return false; size = b.readUInt16BE(2); offset = 4; }
|
|
67
|
+
else if (size === 127) {
|
|
68
|
+
if (b.length < 10) return false;
|
|
69
|
+
const big = b.readBigUInt64BE(2);
|
|
70
|
+
if (big > BigInt(this.#max)) { this.close(1009); return false; }
|
|
71
|
+
size = Number(big); offset = 10;
|
|
72
|
+
}
|
|
73
|
+
if (opcode >= 0x8) {
|
|
74
|
+
if (!fin || size > 125 || opcode > 0xa) { this.close(1002); return false; }
|
|
75
|
+
} else if (opcode === 0x2) { this.close(1003); return false; }
|
|
76
|
+
else if (opcode > 0x2 || (opcode === 0x0) !== this.#inMessage) { this.close(1002); return false; }
|
|
77
|
+
else if (this.#partSize + size > this.#max) { this.close(1009); return false; }
|
|
78
|
+
if (b.length < offset + 4 + size) return false;
|
|
79
|
+
const mask = b.subarray(offset, offset + 4);
|
|
80
|
+
const payload = Buffer.from(b.subarray(offset + 4, offset + 4 + size));
|
|
81
|
+
for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i & 3];
|
|
82
|
+
this.#buffer = b.subarray(offset + 4 + size);
|
|
83
|
+
if (opcode === 0x9) this.#write(0xa, payload);
|
|
84
|
+
else if (opcode === 0x8) {
|
|
85
|
+
const code = payload.length >= 2 ? payload.readUInt16BE(0) : 1000;
|
|
86
|
+
const valid = payload.length !== 1 && (code >= 1000 && code <= 1014 && ![1004, 1005, 1006].includes(code) || code >= 3000 && code <= 4999);
|
|
87
|
+
this.close(valid ? code : 1002);
|
|
88
|
+
} else if (opcode <= 0x1) {
|
|
89
|
+
this.#parts.push(payload); this.#partSize += size; this.#inMessage = !fin;
|
|
90
|
+
if (fin) {
|
|
91
|
+
const data = Buffer.concat(this.#parts);
|
|
92
|
+
this.#parts = []; this.#partSize = 0;
|
|
93
|
+
let text;
|
|
94
|
+
try { text = this.#decoder.decode(data); } catch { this.close(1007); return false; }
|
|
95
|
+
this.emit('message', text);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
}
|
package/bin/telecall.mjs
CHANGED
|
@@ -6,7 +6,6 @@ import {dirname,join} from 'node:path';
|
|
|
6
6
|
import {fileURLToPath} from 'node:url';
|
|
7
7
|
import {homedir} from 'node:os';
|
|
8
8
|
import {createHash} from 'node:crypto';
|
|
9
|
-
import {resolvePairingHost} from '../app/pairing.mjs';
|
|
10
9
|
|
|
11
10
|
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
12
11
|
const app = join(root,'app');
|
|
@@ -45,9 +44,7 @@ async function verify() {
|
|
|
45
44
|
const metadata = JSON.parse(await readFile(join(app,'app.json'),'utf8'));
|
|
46
45
|
const apk = await readFile(join(app,'Telecall.apk'));
|
|
47
46
|
if (createHash('sha256').update(apk).digest('hex') !== metadata.sha256 || apk.length !== metadata.size) throw Error('The bundled Android APK failed its integrity check. Reinstall this beta.');
|
|
48
|
-
const host
|
|
49
|
-
if (!host) throw Error('The Windows companion dependency is missing. Reinstall this beta.');
|
|
50
|
-
await stat(host);
|
|
47
|
+
for (const file of ['server.mjs','pairing.mjs','secure-host.mjs','cert.mjs','ws.mjs']) await stat(join(app,file));
|
|
51
48
|
return metadata;
|
|
52
49
|
}
|
|
53
50
|
async function run() {
|
|
@@ -55,7 +52,7 @@ try {
|
|
|
55
52
|
if (process.platform !== 'win32' || process.arch !== 'x64') throw Error('This beta supports Windows 11 x64. The phone needs Android 10 or later.');
|
|
56
53
|
if (command === 'doctor') {
|
|
57
54
|
const metadata = await verify();
|
|
58
|
-
console.log(`Telecall ${pkg.version}\nAPK verified: ${metadata.version}\
|
|
55
|
+
console.log(`Telecall ${pkg.version}\nAPK verified: ${metadata.version}\nPairing runs in Node; nothing else to install.\nNo network profile or firewall settings are changed.\nBoth devices need a reachable local network. Desktop call audio is unverified.`);
|
|
59
56
|
return;
|
|
60
57
|
}
|
|
61
58
|
const existing = await health();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mahmoud_helmy/telecall",
|
|
3
|
-
"version": "0.3.0-beta.
|
|
3
|
+
"version": "0.3.0-beta.4",
|
|
4
4
|
"description": "Telecall beta: install the Android app and pair it with Windows using local QR codes.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"author": "Mahmoud Helmy",
|
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
"os": ["win32"],
|
|
11
11
|
"cpu": ["x64"],
|
|
12
12
|
"engines": {"node": ">=22"},
|
|
13
|
-
"dependencies": {"@mahmoud_helmy/telecall-windows-x64": "0.3.0-beta.2"},
|
|
14
13
|
"keywords": ["android", "windows", "local-network", "qr", "beta"],
|
|
15
14
|
"publishConfig": {"access": "public", "tag": "beta"}
|
|
16
15
|
}
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
The MIT License (MIT)
|
|
2
|
-
|
|
3
|
-
Copyright (c) .NET Foundation and Contributors
|
|
4
|
-
|
|
5
|
-
All rights reserved.
|
|
6
|
-
|
|
7
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
8
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
9
|
-
in the Software without restriction, including without limitation the rights
|
|
10
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
11
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
12
|
-
furnished to do so, subject to the following conditions:
|
|
13
|
-
|
|
14
|
-
The above copyright notice and this permission notice shall be included in all
|
|
15
|
-
copies or substantial portions of the Software.
|
|
16
|
-
|
|
17
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
18
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
19
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
20
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
21
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
22
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
23
|
-
SOFTWARE.
|