Add verified WSS transport for legacy boards
Configure the default WSS endpoint and ISRG Root X2 trust anchor, preserve device authentication, enforce TLS and WebSocket handshake validation, and handle partial I/O and connection cleanup. Add host tests, server probes, package verification and integration documentation. Backend 443 /ws still returns HTTP 404; on-device validation remains pending.
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
param([string]$OutputDir = "$PSScriptRoot/../artifacts/wss", [ValidateSet(443, 8080)][int]$ServerPort = 443)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$serverHost = 'banban.api.talkingq.com'
|
||||
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
|
||||
$OutputDir = (Resolve-Path -LiteralPath $OutputDir).Path
|
||||
$tcp = New-Object System.Net.Sockets.TcpClient
|
||||
$tls = $null
|
||||
try {
|
||||
$pending = $tcp.BeginConnect($serverHost, $serverPort, $null, $null)
|
||||
if (-not $pending.AsyncWaitHandle.WaitOne(15000)) { throw 'TCP connect timeout' }
|
||||
$tcp.EndConnect($pending)
|
||||
$tcp.ReceiveTimeout = 15000
|
||||
$tcp.SendTimeout = 15000
|
||||
# Default SslStream validation checks the trusted chain and server hostname.
|
||||
$tls = New-Object System.Net.Security.SslStream($tcp.GetStream(), $false)
|
||||
$tls.ReadTimeout = 15000
|
||||
$tls.WriteTimeout = 15000
|
||||
$tls.AuthenticateAsClient($serverHost, $null, [System.Security.Authentication.SslProtocols]::Tls12, $false)
|
||||
$leaf = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($tls.RemoteCertificate)
|
||||
$chain = New-Object System.Security.Cryptography.X509Certificates.X509Chain
|
||||
$chain.ChainPolicy.RevocationMode = [System.Security.Cryptography.X509Certificates.X509RevocationMode]::NoCheck
|
||||
if (-not $chain.Build($leaf)) { throw 'Trusted certificate chain could not be built' }
|
||||
$certificates = @()
|
||||
for ($i = 0; $i -lt $chain.ChainElements.Count; $i++) {
|
||||
$cert = $chain.ChainElements[$i].Certificate
|
||||
$pem = "-----BEGIN CERTIFICATE-----`n" + [Convert]::ToBase64String($cert.RawData, [Base64FormattingOptions]::InsertLineBreaks).Replace("`r`n", "`n") + "`n-----END CERTIFICATE-----`n"
|
||||
[IO.File]::WriteAllText((Join-Path $OutputDir "chain-$i.pem"), $pem, [Text.Encoding]::ASCII)
|
||||
$certificates += [ordered]@{subject=$cert.Subject; issuer=$cert.Issuer; not_before=$cert.NotBefore.ToUniversalTime().ToString('o'); not_after=$cert.NotAfter.ToUniversalTime().ToString('o'); thumbprint=$cert.Thumbprint}
|
||||
if ($i -eq $chain.ChainElements.Count - 1) {
|
||||
if ($cert.Subject -ne $cert.Issuer) { throw 'Chain did not terminate at a root CA' }
|
||||
[IO.File]::WriteAllText((Join-Path $OutputDir 'root-ca.pem'), $pem, [Text.Encoding]::ASCII)
|
||||
}
|
||||
}
|
||||
$nonce = New-Object byte[] 16
|
||||
$rng = [Security.Cryptography.RandomNumberGenerator]::Create()
|
||||
$rng.GetBytes($nonce)
|
||||
$rng.Dispose()
|
||||
$key = [Convert]::ToBase64String($nonce)
|
||||
$request = "GET /ws HTTP/1.1`r`nHost: ${serverHost}:${serverPort}`r`nUpgrade: websocket`r`nConnection: Upgrade`r`nSec-WebSocket-Key: $key`r`nSec-WebSocket-Version: 13`r`n`r`n"
|
||||
$bytes = [Text.Encoding]::ASCII.GetBytes($request)
|
||||
$tls.Write($bytes, 0, $bytes.Length)
|
||||
$response = ''
|
||||
while (-not $response.EndsWith("`r`n`r`n") -and $response.Length -lt 16384) {
|
||||
$next = $tls.ReadByte()
|
||||
if ($next -lt 0) { break }
|
||||
$response += [char]$next
|
||||
}
|
||||
$sha1 = [Security.Cryptography.SHA1]::Create()
|
||||
$expected = [Convert]::ToBase64String($sha1.ComputeHash([Text.Encoding]::ASCII.GetBytes($key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')))
|
||||
$sha1.Dispose()
|
||||
$accepted = $response -match '^HTTP/1\.1 101 ' -and $response -match ('(?im)^Sec-WebSocket-Accept:\s*' + [regex]::Escape($expected) + '\s*$')
|
||||
$report = [ordered]@{url="wss://${serverHost}:${serverPort}/ws"; tested_utc=[DateTime]::UtcNow.ToString('o'); tls_protocol=$tls.SslProtocol.ToString(); cipher=$tls.CipherAlgorithm.ToString(); cipher_bits=$tls.CipherStrength; certificate_validation='System trust and hostname validation passed'; certificates=$certificates; websocket_upgrade=$accepted; response_headers=$response; device_auth_sent=$false}
|
||||
$report | ConvertTo-Json -Depth 6 | Set-Content -Encoding UTF8 -LiteralPath (Join-Path $OutputDir 'server-probe.json')
|
||||
$report | ConvertTo-Json -Depth 6
|
||||
if (-not $accepted) { throw 'WebSocket upgrade did not return a valid HTTP 101 response' }
|
||||
} finally {
|
||||
if ($tls) { $tls.Dispose() }
|
||||
$tcp.Close()
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import socket
|
||||
import ssl
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
source = (root / 'ql-application/threadx/interface/custom_app/talkingq_root_ca.c').read_text()
|
||||
pem = ''.join(json.loads('"' + value + '"') for value in re.findall(r'"(.*?)"', source) if 'CERTIFICATE' in value or value.endswith(r'\n'))
|
||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
context.load_verify_locations(cadata=pem)
|
||||
context.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
context.maximum_version = ssl.TLSVersion.TLSv1_2
|
||||
server = 'banban.api.talkingq.com'
|
||||
report = {'root_sha256': hashlib.sha256(ssl.PEM_cert_to_DER_cert(pem)).hexdigest(), 'trust': 'only the compiled root CA; no OS trust or intermediate download'}
|
||||
with socket.create_connection((server, 443), timeout=15) as tcp:
|
||||
with context.wrap_socket(tcp, server_hostname=server) as tls:
|
||||
report['tls'] = tls.version()
|
||||
report['cipher'] = tls.cipher()
|
||||
report['root_only_validation'] = 'PASS'
|
||||
try:
|
||||
with socket.create_connection((server, 443), timeout=15) as tcp:
|
||||
with context.wrap_socket(tcp, server_hostname='wrong.invalid'):
|
||||
raise AssertionError('wrong hostname accepted')
|
||||
except ssl.SSLCertVerificationError:
|
||||
report['wrong_hostname_rejected'] = True
|
||||
empty = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
try:
|
||||
with socket.create_connection((server, 443), timeout=15) as tcp:
|
||||
with empty.wrap_socket(tcp, server_hostname=server):
|
||||
raise AssertionError('untrusted CA accepted')
|
||||
except ssl.SSLCertVerificationError:
|
||||
report['untrusted_ca_rejected'] = True
|
||||
(root / 'artifacts/wss/root-only-probe.json').write_text(json.dumps(report, indent=2))
|
||||
print(json.dumps(report, indent=2))
|
||||
@@ -0,0 +1,58 @@
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "talkingq_wss.h"
|
||||
#include "rws_handshake.h"
|
||||
|
||||
static const char accept[] = "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=";
|
||||
static const char good[] = "HTTP/1.1 101 Switching Protocols\r\n"
|
||||
"uPgRaDe: WebSocket\r\nConnection: keep-alive, Upgrade\r\n"
|
||||
"Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n";
|
||||
|
||||
int main(void)
|
||||
{
|
||||
char host[64], path[128], data[RWS_HANDSHAKE_MAX + 20];
|
||||
int port, status;
|
||||
size_t i, header;
|
||||
const char *bad[] = {"ws://example.com/ws", "https://example.com/ws", "wss:///ws",
|
||||
"wss://user@example.com/ws", "wss://example.com:0/ws", "wss://example.com:65536/ws",
|
||||
"wss://example.com:-1/ws", "wss://example.com:/ws", "wss://example.com:abc/ws",
|
||||
"wss://example.com/ws#frag", "wss://example.com/ws\r\nInjected:yes",
|
||||
"wss://example.com:99999999999999999999999/ws", "wss://example.com/bad path"};
|
||||
assert(!talkingq_parse_wss_url(TALKINGQ_WSS_URL, host, sizeof(host), &port, path, sizeof(path)));
|
||||
assert(!strcmp(host, "banban.api.talkingq.com") && port == 443 && !strcmp(path, "/ws"));
|
||||
assert(!talkingq_parse_wss_url("wss://example.com", host, sizeof(host), &port, path, sizeof(path)));
|
||||
assert(port == 443 && !strcmp(path, "/ws"));
|
||||
assert(!talkingq_parse_wss_url("wss://example.com:8080/ws?x=y", host, sizeof(host), &port, path, sizeof(path)));
|
||||
assert(port == 8080 && !strcmp(path, "/ws?x=y"));
|
||||
for (i = 0; i < sizeof(bad)/sizeof(bad[0]); ++i)
|
||||
assert(talkingq_parse_wss_url(bad[i], host, sizeof(host), &port, path, sizeof(path)) == -1);
|
||||
assert(talkingq_parse_wss_url(TALKINGQ_WSS_URL, host, 3, &port, path, sizeof(path)) == -1);
|
||||
assert(talkingq_parse_wss_url(TALKINGQ_WSS_URL, host, sizeof(host), &port, path, 3) == -1);
|
||||
assert(talkingq_is_legacy_ws_url("ws://101.35.224.118:8080"));
|
||||
assert(talkingq_is_legacy_ws_url("ws://101.35.224.118:8080/ws"));
|
||||
assert(!talkingq_is_legacy_ws_url(TALKINGQ_WSS_URL));
|
||||
assert(!talkingq_is_legacy_ws_url("ws://custom.example:8080/ws"));
|
||||
/* Every possible TLS fragment boundary, with no NUL terminator available. */
|
||||
for (i = 0; i < sizeof(good)-1; ++i) {
|
||||
memset(data, 'X', sizeof(data));
|
||||
memcpy(data, good, i);
|
||||
assert(rws_check_handshake(data, i, accept, &header, &status) == 0);
|
||||
}
|
||||
memcpy(data, good, sizeof(good)-1);
|
||||
((unsigned char *)data)[sizeof(good)-1] = 0x81; data[sizeof(good)] = 0;
|
||||
assert(rws_check_handshake(data, sizeof(good)+1, accept, &header, &status) == 1);
|
||||
assert(status == 101 && header == sizeof(good)-1);
|
||||
assert(rws_check_handshake(data, sizeof(good)-1, "wrong", &header, &status) == -1);
|
||||
memcpy(data + 9, "404", 3);
|
||||
assert(rws_check_handshake(data, sizeof(good)-1, accept, &header, &status) == -1 && status == 404);
|
||||
strcpy(data, "HTTP/1.1 101 OK\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n");
|
||||
assert(rws_check_handshake(data, strlen(data), accept, &header, &status) == -1);
|
||||
memcpy(data, good, sizeof(good));
|
||||
strcpy(data + sizeof(good)-3, "Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n");
|
||||
assert(rws_check_handshake(data, strlen(data), accept, &header, &status) == -1);
|
||||
memset(data, 'X', sizeof(data));
|
||||
assert(rws_check_handshake(data, sizeof(data), accept, &header, &status) == -1);
|
||||
puts("PASS: WSS URL policy, legacy migration, fragmented upgrade, status, accept, headers and limits");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
@echo off
|
||||
setlocal
|
||||
cd /d "%~dp0.."
|
||||
if not exist artifacts\wss mkdir artifacts\wss
|
||||
where cl >nul 2>nul
|
||||
if errorlevel 1 call "C:\Program Files (x86)\Microsoft Visual Studio\18\BuildTools\VC\Auxiliary\Build\vcvars64.bat" >nul
|
||||
if errorlevel 1 exit /b 1
|
||||
cl /nologo /W4 /WX /D_CRT_SECURE_NO_WARNINGS /TC /Iql-application\threadx\interface\custom_app\inc /Iql-application\threadx\interface\custom_app\lib\librws\inc tools\test_wss.c ql-application\threadx\interface\custom_app\talkingq_wss.c ql-application\threadx\interface\custom_app\lib\librws\src\rws_handshake.c /Foartifacts\wss\ /Feartifacts\wss\test_wss.exe
|
||||
if errorlevel 1 exit /b 1
|
||||
artifacts\wss\test_wss.exe
|
||||
if errorlevel 1 exit /b 1
|
||||
ql-cross-tool\win32\owtoolchain\Python\python.exe tools\test_wss_io.py
|
||||
exit /b %errorlevel%
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Compile actual SDK-independent function bodies with scripted TLS/RTOS stubs."""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
lib = root / 'ql-application/threadx/interface/custom_app/lib/librws/src'
|
||||
private = (lib / 'rws_socketpriv.c').read_bytes().decode('latin1')
|
||||
public = (lib / 'rws_socketpub.c').read_bytes().decode('latin1')
|
||||
send = private[private.index('static int rws_tls_send_all('):].split('#endif', 1)[0]
|
||||
cancel = public[public.index('void rws_socket_disconnect_and_release('):].split('rws_bool rws_socket_send_text(', 1)[0]
|
||||
harness = r'''
|
||||
#include <assert.h>
|
||||
#include <stddef.h>
|
||||
#include <stdio.h>
|
||||
#define MBEDTLS_ERR_SSL_WANT_READ -2
|
||||
#define MBEDTLS_ERR_SSL_WANT_WRITE -3
|
||||
#define COMMAND_NONE 0
|
||||
#define COMMAND_DISCONNECT 6
|
||||
#define COMMAND_END 9999
|
||||
typedef struct {int ssl_ctx;} tls;
|
||||
typedef struct {tls *ssl; int work_mutex, is_connected, worker_started, work_thread, command;} _rws_socket;
|
||||
typedef _rws_socket *rws_socket;
|
||||
static int replies[16], calls, reply_count, expected_offset, freed, locks;
|
||||
static unsigned int ticks;
|
||||
static const unsigned char bytes[] = "abcdef";
|
||||
static unsigned int ql_rtos_get_systicks(void) { return ticks; }
|
||||
static void rws_thread_sleep(int ms) { ticks += (unsigned int)ms; }
|
||||
static int mbedtls_ssl_write(int *ctx, const unsigned char *data, size_t size) {
|
||||
int ret;
|
||||
(void)ctx;
|
||||
assert(data == bytes + expected_offset);
|
||||
assert(size == 6 - (size_t)expected_offset);
|
||||
ret = calls < reply_count ? replies[calls] : MBEDTLS_ERR_SSL_WANT_WRITE;
|
||||
++calls;
|
||||
if (ret > 0) expected_offset += ret;
|
||||
return ret;
|
||||
}
|
||||
static void rws_mutex_lock(int mutex) {(void)mutex; ++locks;}
|
||||
static void rws_mutex_unlock(int mutex) {(void)mutex; --locks;}
|
||||
static void rws_socket_delete(_rws_socket *s) {(void)s; assert(locks == 0); ++freed;}
|
||||
'''
|
||||
harness += send + cancel
|
||||
harness += r'''
|
||||
int main(void) {
|
||||
tls ctx = {0};
|
||||
_rws_socket s = {0};
|
||||
s.ssl = &ctx;
|
||||
replies[0] = -2; replies[1] = 2; replies[2] = -3; replies[3] = 4; reply_count = 4;
|
||||
assert(rws_tls_send_all(&s, bytes, 6) == 6 && calls == 4 && expected_offset == 6);
|
||||
calls = 0; expected_offset = 0; replies[0] = 2; replies[1] = -99; reply_count = 2;
|
||||
assert(rws_tls_send_all(&s, bytes, 6) == -1 && calls == 2);
|
||||
calls = 0; expected_offset = 0; replies[0] = 0; reply_count = 1;
|
||||
assert(rws_tls_send_all(&s, bytes, 6) == -1 && calls == 1);
|
||||
calls = 0; expected_offset = 0; reply_count = 0; ticks = 0;
|
||||
assert(rws_tls_send_all(&s, bytes, 6) == -1 && ticks >= 1000 && calls < 1000);
|
||||
calls = 0; ticks = (unsigned int)-500;
|
||||
assert(rws_tls_send_all(&s, bytes, 6) == -1 && calls < 1000);
|
||||
s.ssl = NULL; calls = 0;
|
||||
assert(rws_tls_send_all(&s, bytes, 6) == -1 && calls == 0);
|
||||
s.is_connected = 1; rws_socket_disconnect_and_release(&s);
|
||||
assert(s.command == COMMAND_DISCONNECT && locks == 0 && !freed);
|
||||
s.is_connected = 0; s.worker_started = 1; s.command = 1;
|
||||
rws_socket_disconnect_and_release(&s);
|
||||
assert(s.command == COMMAND_END && locks == 0 && !freed);
|
||||
s.worker_started = 0; s.command = COMMAND_NONE;
|
||||
rws_socket_disconnect_and_release(&s);
|
||||
assert(freed == 1 && locks == 0);
|
||||
rws_socket_disconnect_and_release(NULL);
|
||||
puts("PASS: TLS partial writes, WANT retry, fatal/EOF, timeout, tick wrap, null context, worker cancellation");
|
||||
return 0;
|
||||
}
|
||||
'''
|
||||
output = root / 'artifacts/wss'
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
cfile = output / 'test_wss_io.c'
|
||||
cfile.write_text(harness, encoding='utf-8')
|
||||
exe = output / 'test_wss_io.exe'
|
||||
subprocess.run(['cl', '/nologo', '/W4', '/WX', '/TC', str(cfile),
|
||||
'/Fo' + str(output / 'test_wss_io.obj'), '/Fe' + str(exe)], check=True)
|
||||
subprocess.run([str(exe)], check=True)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Verify the WSS flash package against the current legacy-board build."""
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
output = root / 'artifacts/wss'
|
||||
package = output / 'EG800AKCN_11LG-legacy-wss.zip'
|
||||
objdump = root / 'ql-cross-tool/win32/owtoolchain/gcc-arm-none-eabi/bin/arm-none-eabi-objdump.exe'
|
||||
app = root / 'ql-application/threadx/build/app.elf'
|
||||
symbols = subprocess.check_output([str(objdump.with_name('arm-none-eabi-nm.exe')), str(app)], text=True)
|
||||
required = ['talkingq_parse_wss_url', 'talkingq_root_ca', 'rws_check_handshake', 'rws_ssl_conn',
|
||||
'mbedtls_ssl_set_hostname', 'mbedtls_ssl_get_verify_result', 'mbedtls_ssl_conf_authmode',
|
||||
'mbedtls_ctr_drbg_random', 'mbedtls_sha1', 'mbedtls_base64_encode']
|
||||
for symbol in required:
|
||||
assert re.search(r'\b' + symbol + r'$', symbols, re.M), 'Missing linked symbol: ' + symbol
|
||||
report = {'package': package.name, 'sha256': hashlib.sha256(package.read_bytes()).hexdigest(),
|
||||
'linked_symbols': required, 'hardware_test': 'Not performed',
|
||||
'backend': '443 /ws returns HTTP 404; 8080 is plaintext WS'}
|
||||
with zipfile.ZipFile(package) as archive:
|
||||
assert archive.testzip() is None, 'ZIP CRC failure'
|
||||
for name, source in {'cp.bin': 'ql-kernel/threadx/build/release_kernel.bin',
|
||||
'customer_app.bin': 'ql-application/threadx/build/app.bin'}.items():
|
||||
data = archive.read(name)
|
||||
assert data == (root / source).read_bytes(), 'Stale image: ' + name
|
||||
report[name] = {'bytes': len(data), 'sha256': hashlib.sha256(data).hexdigest()}
|
||||
assert b'wss://banban.api.talkingq.com:443/ws' in archive.read('customer_app.bin')
|
||||
report['download_manifest'] = json.loads(archive.read('download.json'))
|
||||
with zipfile.ZipFile(root / 'artifacts/legacy-no-gnss/EG800AKCN_11LG-legacy-no-gnss.zip') as old:
|
||||
with zipfile.ZipFile(package) as new:
|
||||
assert old.read('cp.bin') == new.read('cp.bin'), 'Legacy kernel changed unexpectedly'
|
||||
printf_address = int(re.search(r'^([0-9a-fA-F]+) T printf$', symbols, re.M).group(1), 16)
|
||||
for symbol, binary, expected in [
|
||||
('quec_get_gnssid_task', root / 'ql-kernel/threadx/build/kernel.axf', '<RTI_LOG>'),
|
||||
('app_gnss_init', app, '0x%08x' % (printf_address | 1)),
|
||||
('set_gnss_mode', app, 'bx\tlr')]:
|
||||
body = subprocess.check_output([str(objdump), '-d', '--disassemble=' + symbol, str(binary)], text=True)
|
||||
assert '<' + symbol + '>:' in body and expected in body and 'task_create' not in body
|
||||
(output / (symbol + '.disasm.txt')).write_text(body)
|
||||
report['verification'] = 'PASS: ZIP CRC, current APP/kernel identity, unchanged legacy kernel, GNSS bypass, WSS URL and TLS symbols'
|
||||
(output / 'verification.json').write_text(json.dumps(report, indent=2))
|
||||
print(report['verification'])
|
||||
print('SHA256:', report['sha256'])
|
||||
Reference in New Issue
Block a user