daemon: rpc/ws_server — loopback WebSocket transport + pairing (build step 1, second half)

The extension's fallback transport (docs/05 §4). veloxd now also listens
on 127.0.0.1, first free port in 52000-52016, and writes it to
<runtime>/ws.port (0600).

- rpc/ws_frame — RFC 6455 frame codec. Incremental; reassembles
  continuation frames; enforces "client frames MUST be masked" (§5.1);
  caps a reassembled message at 8 MiB. This is the attacker-adjacent
  parser, so it has its own test table.
- rpc/ws_handshake — HTTP upgrade parse, Sec-WebSocket-Accept
  (SHA-1 + base64 via libcrypto), and the two non-negotiable checks:
  an Origin header must be present and must be moz-extension:// (a page
  cannot pair). Version must be 13.
- rpc/ws_server — per-connection Handshake -> Open state machine on the
  shared EventLoop. Token gate: session.pair mints a token behind the
  approver + rate limiter; session.hello must present a valid one;
  every other method is -32002 until authed. Privileged methods are
  refused -32003 by the generated dispatch(). Ping -> Pong; Close
  echoed. session.hello major-version mismatch -> -32001.
- rpc/pairing — PairingApprover interface + EnvAutoApprover dev stub
  (approves iff VELOX_PAIR_AUTO=1); PairingRateLimiter (5 failures / 60 s
  per origin, then 60 s lockout -> -32014, survives reconnect); a
  four-digit code generator.
- store/pairings — the pairings table: create() returns the plaintext
  token once and stores only its SHA-256; find_active_by_token,
  touch, revoke, list_active.
- util/crypto — sha1 / sha256_hex / base64 / random_token over libcrypto.
- store/sqlite — pin the DB file (and -wal/-shm) to 0600.
- runtime_dir — resolve_data_dir() for $XDG_DATA_HOME/velox (velox.db).
- main.cpp — opens + migrates velox.db, starts both transports; a WS
  bind failure is logged, not fatal (capture must fail open, the Unix
  socket still serves the GUI/CLI).

Real gap, flagged not hidden: the pairing prompt is EnvAutoApprover for
now — a GUI dialog / desktop notification is build step 7. Pairing
needs VELOX_PAIR_AUTO=1 until then.

Tests (ASan+UBSan and TSan clean): veloxd.ws_frame (codec + handshake
vectors incl. the RFC 6455 §1.3 accept sample), veloxd.pairings (token
create/find/revoke, hash-not-token, rate-limit window + lockout +
per-origin isolation + success reset), veloxd.ws_server (full flow: 101
handshake, -32002 gate, deny-then-approve pairing, hello-with-token,
-32003 privileged refusal, real download.list). 27 daemon/cli tests
green; full tree green.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Upd9WhG9oppieig5nRDLig
This commit is contained in:
2026-09-10 15:44:28 +04:00
co-authored by Claude Sonnet 5
parent e585113daf
commit dab071c41a
21 changed files with 1823 additions and 32 deletions
+15 -16
View File
@@ -1,19 +1,18 @@
# daemon unit + integration tests. Registered with ctest; run via `ctest --preset dev`.
# No external test framework — each file is a small self-checking binary (matches the
# lightweight style core/ uses, without depending on core's private test support).
# No external test framework — each file is a small self-checking binary.
add_executable(veloxd_ndjson_test ndjson_test.cpp)
target_link_libraries(veloxd_ndjson_test PRIVATE veloxd_rpc)
target_compile_options(veloxd_ndjson_test PRIVATE -Wall -Wextra -Wpedantic -Werror)
add_test(NAME veloxd.ndjson COMMAND veloxd_ndjson_test)
function(veloxd_test name)
cmake_parse_arguments(T "" "" "LIBS" ${ARGN})
add_executable(veloxd_${name}_test ${name}_test.cpp)
target_link_libraries(veloxd_${name}_test PRIVATE ${T_LIBS})
target_compile_options(veloxd_${name}_test PRIVATE -Wall -Wextra -Wpedantic -Werror)
add_test(NAME veloxd.${name} COMMAND veloxd_${name}_test)
set_tests_properties(veloxd.${name} PROPERTIES TIMEOUT 30)
endfunction()
add_executable(veloxd_uds_roundtrip_test uds_roundtrip_test.cpp)
target_link_libraries(veloxd_uds_roundtrip_test PRIVATE veloxd_rpc)
target_compile_options(veloxd_uds_roundtrip_test PRIVATE -Wall -Wextra -Wpedantic -Werror)
add_test(NAME veloxd.uds_roundtrip COMMAND veloxd_uds_roundtrip_test)
set_tests_properties(veloxd.uds_roundtrip PROPERTIES TIMEOUT 30)
add_executable(veloxd_store_migrations_test store_migrations_test.cpp)
target_link_libraries(veloxd_store_migrations_test PRIVATE veloxd_store)
target_compile_options(veloxd_store_migrations_test PRIVATE -Wall -Wextra -Wpedantic -Werror)
add_test(NAME veloxd.store_migrations COMMAND veloxd_store_migrations_test)
veloxd_test(ndjson LIBS veloxd_rpc)
veloxd_test(uds_roundtrip LIBS veloxd_rpc)
veloxd_test(store_migrations LIBS veloxd_store)
veloxd_test(pairings LIBS veloxd_store veloxd_rpc)
veloxd_test(ws_frame LIBS veloxd_rpc)
veloxd_test(ws_server LIBS veloxd_rpc)
+87
View File
@@ -0,0 +1,87 @@
// The pairings table + the pairing rate limiter.
#include <string>
#include "check.hpp"
#include "rpc/pairing.hpp"
#include "store/migrations.hpp"
#include "store/pairings.hpp"
#include "store/sqlite.hpp"
using namespace velox::daemon;
void run() {
// --- store: create -> find-by-token -> revoke ---------------------------------
{
auto db = store::Db::open(":memory:");
CHECK(db.has_value());
if (!db) return;
CHECK(store::migrate_to_head(*db).has_value());
store::Pairings p(*db);
auto created = p.create("moz-extension://abc", "Velox for Firefox", "2026-09-10T00:00:00Z");
CHECK(created.has_value());
if (!created) return;
CHECK(created->token.size() >= 40); // 32 bytes base64url, unpadded
CHECK(!created->pairing_id.empty());
// The plaintext token is not in the DB — only its hash.
auto st = db->prepare("SELECT count(*) FROM pairings WHERE token_sha256 = ?1");
CHECK(st.has_value());
CHECK(st->bind(1, std::string_view(created->token)).has_value());
auto row = st->step();
CHECK(row.has_value() && *row);
CHECK_EQ(st->column_int(0), 0); // token itself never stored
auto found = p.find_active_by_token(created->token);
CHECK(found.has_value());
CHECK(found->has_value());
if (found && *found) CHECK_EQ((*found)->origin, std::string("moz-extension://abc"));
auto missing = p.find_active_by_token("not-the-token");
CHECK(missing.has_value() && !missing->has_value());
auto revoked = p.revoke(created->pairing_id, "2026-09-10T01:00:00Z");
CHECK(revoked.has_value() && *revoked == true);
auto after = p.find_active_by_token(created->token);
CHECK(after.has_value() && !after->has_value()); // revoked -> not active
auto revoke_again = p.revoke(created->pairing_id, "2026-09-10T02:00:00Z");
CHECK(revoke_again.has_value() && *revoke_again == false);
}
// --- rate limiter: 5 failures, then a lockout with retryAfter -----------------
{
rpc::PairingRateLimiter rl;
using Clock = rpc::PairingRateLimiter::Clock;
const auto t0 = Clock::now();
for (int i = 0; i < 5; ++i) {
CHECK(rl.check("origin-a", t0).allowed);
rl.record_failure("origin-a", t0);
}
const auto d = rl.check("origin-a", t0);
CHECK(!d.allowed);
CHECK(d.retry_after_sec > 0 && d.retry_after_sec <= 61);
// A different origin is unaffected — the lockout is per-origin.
CHECK(rl.check("origin-b", t0).allowed);
// Still locked 30 s later; clear after the lockout elapses.
CHECK(!rl.check("origin-a", t0 + std::chrono::seconds(30)).allowed);
CHECK(rl.check("origin-a", t0 + std::chrono::seconds(121)).allowed);
// A success wipes the origin's history.
rl.record_failure("origin-c", t0);
rl.record_failure("origin-c", t0);
rl.record_success("origin-c");
for (int i = 0; i < 4; ++i) {
CHECK(rl.check("origin-c", t0).allowed);
rl.record_failure("origin-c", t0);
}
CHECK(rl.check("origin-c", t0).allowed); // only 4 since the reset
}
}
TEST_MAIN()
+150
View File
@@ -0,0 +1,150 @@
#include "rpc/ws_frame.hpp"
#include "rpc/ws_handshake.hpp"
#include <string>
#include <vector>
#include "check.hpp"
using namespace velox::daemon::rpc;
namespace {
// Build a *client* frame: FIN/opcode, mask bit set, a fixed 4-byte mask, masked payload.
std::string client_frame(WsOpcode op, std::string_view payload, bool fin = true) {
std::string f;
f.push_back(static_cast<char>((fin ? 0x80 : 0x00) | static_cast<std::uint8_t>(op)));
const std::size_t n = payload.size();
if (n < 126) {
f.push_back(static_cast<char>(0x80 | n));
} else if (n <= 0xFFFF) {
f.push_back(static_cast<char>(0x80 | 126));
f.push_back(static_cast<char>((n >> 8) & 0xFF));
f.push_back(static_cast<char>(n & 0xFF));
} else {
f.push_back(static_cast<char>(0x80 | 127));
for (int i = 7; i >= 0; --i)
f.push_back(static_cast<char>((static_cast<std::uint64_t>(n) >> (i * 8)) & 0xFF));
}
const char key[4] = {0x12, 0x34, 0x56, 0x78};
f.append(key, 4);
for (std::size_t i = 0; i < n; ++i) f.push_back(static_cast<char>(payload[i] ^ key[i & 3]));
return f;
}
} // namespace
void run() {
// --- one text frame ------------------------------------------------------------
{
WsFrameReader r;
std::vector<WsMessage> m;
CHECK(r.feed(client_frame(WsOpcode::Text, "{\"a\":1}"), m) == WsFrameReader::Status::Ok);
CHECK_EQ(m.size(), 1u);
CHECK(m[0].opcode == WsOpcode::Text);
CHECK_EQ(m[0].payload, std::string("{\"a\":1}"));
}
// --- fragmented: text (fin=0) + continuation (fin=1) --------------------------
{
WsFrameReader r;
std::vector<WsMessage> m;
r.feed(client_frame(WsOpcode::Text, "hel", /*fin=*/false), m);
CHECK_EQ(m.size(), 0u);
r.feed(client_frame(WsOpcode::Continuation, "lo", /*fin=*/true), m);
CHECK_EQ(m.size(), 1u);
CHECK_EQ(m[0].payload, std::string("hello"));
}
// --- byte-at-a-time delivery still reassembles -------------------------------
{
WsFrameReader r;
std::vector<WsMessage> m;
const std::string frame = client_frame(WsOpcode::Text, "streamed");
for (char ch : frame) r.feed(std::string_view(&ch, 1), m);
CHECK_EQ(m.size(), 1u);
CHECK_EQ(m[0].payload, std::string("streamed"));
}
// --- a 200-byte payload exercises the 16-bit length path --------------------
{
WsFrameReader r;
std::vector<WsMessage> m;
const std::string big(200, 'x');
r.feed(client_frame(WsOpcode::Text, big), m);
CHECK_EQ(m.size(), 1u);
CHECK_EQ(m[0].payload.size(), 200u);
}
// --- ping is surfaced so the server can pong -------------------------------
{
WsFrameReader r;
std::vector<WsMessage> m;
r.feed(client_frame(WsOpcode::Ping, "hi"), m);
CHECK_EQ(m.size(), 1u);
CHECK(m[0].opcode == WsOpcode::Ping);
}
// --- an unmasked client frame is a protocol error (RFC 6455 §5.1) ----------
{
WsFrameReader r;
std::vector<WsMessage> m;
std::string bad;
bad.push_back(static_cast<char>(0x81)); // FIN + text
bad.push_back(static_cast<char>(0x03)); // len 3, mask bit clear
bad.append("abc");
CHECK(r.feed(bad, m) == WsFrameReader::Status::ProtocolError);
}
// --- a declared length past the cap is rejected before allocating ----------
{
WsFrameReader r;
std::vector<WsMessage> m;
std::string hdr;
hdr.push_back(static_cast<char>(0x82)); // FIN + binary
hdr.push_back(static_cast<char>(0x80 | 127));
for (int i = 7; i >= 0; --i)
hdr.push_back(static_cast<char>((0x0000000001000000ull >> (i * 8)) & 0xFF)); // 16 MiB
CHECK(r.feed(hdr, m) == WsFrameReader::Status::MessageTooBig);
}
// --- ws_encode: server frames are unmasked, correct length byte ------------
{
const std::string f = ws_encode(WsOpcode::Text, "abc");
CHECK_EQ(static_cast<std::uint8_t>(f[0]), 0x81u);
CHECK_EQ(static_cast<std::uint8_t>(f[1]), 0x03u); // len 3, no mask bit
CHECK_EQ(f.substr(2), std::string("abc"));
}
// --- RFC 6455 §1.3 sample accept value ------------------------------------
CHECK_EQ(ws_accept_key("dGhlIHNhbXBsZSBub25jZQ=="),
std::string("s3pPLMBiTxaQ9kYGzzhZRbK+xOo="));
// --- handshake: a page origin is refused, an extension origin upgrades -----
{
const std::string req_page =
"GET / HTTP/1.1\r\nHost: 127.0.0.1:52000\r\nUpgrade: websocket\r\n"
"Connection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
"Sec-WebSocket-Version: 13\r\nOrigin: https://evil.example\r\n\r\n";
const auto r = ws_try_handshake(req_page);
CHECK(r.complete);
CHECK(!r.ok);
CHECK(r.response.find("403") != std::string::npos);
}
{
const std::string req_ext =
"GET / HTTP/1.1\r\nHost: 127.0.0.1:52000\r\nUpgrade: websocket\r\n"
"Connection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
"Sec-WebSocket-Version: 13\r\n"
"Origin: moz-extension://11111111-2222-3333-4444-555555555555\r\n\r\n";
const auto r = ws_try_handshake(req_ext);
CHECK(r.complete);
CHECK(r.ok);
CHECK(r.response.find("101") != std::string::npos);
CHECK(r.response.find("Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=") !=
std::string::npos);
CHECK_EQ(r.origin, std::string("moz-extension://11111111-2222-3333-4444-555555555555"));
}
}
TEST_MAIN()
+216
View File
@@ -0,0 +1,216 @@
// Integration: a real WsServer on a loopback port, a hand-rolled WebSocket client.
// Covers the handshake, the pairing flow, the token gate (-32002), and the
// privileged-over-WS refusal (-32003).
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cstdlib>
#include <string>
#include <thread>
#include <vector>
#include <nlohmann/json.hpp>
#include "check.hpp"
#include "rpc/dispatcher.hpp"
#include "rpc/event_loop.hpp"
#include "rpc/pairing.hpp"
#include "rpc/runtime_dir.hpp"
#include "rpc/ws_frame.hpp"
#include "rpc/ws_server.hpp"
#include "store/migrations.hpp"
#include "store/sqlite.hpp"
using nlohmann::json;
namespace rpc = velox::daemon::rpc;
namespace store = velox::daemon::store;
namespace {
int dial(int port) {
const int fd = ::socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0);
sockaddr_in a{};
a.sin_family = AF_INET;
a.sin_addr.s_addr = ::htonl(INADDR_LOOPBACK);
a.sin_port = ::htons(static_cast<std::uint16_t>(port));
if (::connect(fd, reinterpret_cast<sockaddr*>(&a), sizeof(a)) != 0) {
::close(fd);
return -1;
}
return fd;
}
void write_all(int fd, std::string_view s) {
std::size_t off = 0;
while (off < s.size()) {
const ssize_t n = ::write(fd, s.data() + off, s.size() - off);
if (n <= 0) return;
off += static_cast<std::size_t>(n);
}
}
std::string read_some(int fd) {
char buf[8192];
const ssize_t n = ::read(fd, buf, sizeof(buf));
return n > 0 ? std::string(buf, static_cast<std::size_t>(n)) : std::string{};
}
// A masked client text frame.
std::string client_text(std::string_view payload) {
std::string f;
f.push_back(static_cast<char>(0x81)); // FIN + text
const std::size_t n = payload.size();
if (n < 126) {
f.push_back(static_cast<char>(0x80 | n));
} else {
f.push_back(static_cast<char>(0x80 | 126));
f.push_back(static_cast<char>((n >> 8) & 0xFF));
f.push_back(static_cast<char>(n & 0xFF));
}
const char k[4] = {0x0A, 0x0B, 0x0C, 0x0D};
f.append(k, 4);
for (std::size_t i = 0; i < n; ++i) f.push_back(static_cast<char>(payload[i] ^ k[i & 3]));
return f;
}
// Decode one unmasked server frame from `buf`, consuming it. Returns payload; sets `op`.
std::string server_frame(std::string& buf, rpc::WsOpcode& op) {
if (buf.size() < 2) return {};
op = static_cast<rpc::WsOpcode>(buf[0] & 0x0F);
std::size_t len = static_cast<std::uint8_t>(buf[1]) & 0x7F;
std::size_t header = 2;
if (len == 126) {
len = (static_cast<std::size_t>(static_cast<std::uint8_t>(buf[2])) << 8) |
static_cast<std::uint8_t>(buf[3]);
header = 4;
}
if (buf.size() < header + len) return {};
std::string payload = buf.substr(header, len);
buf.erase(0, header + len);
return payload;
}
// Send a request frame, wait for one text reply, return its parsed JSON.
json rpc_call(int fd, const json& req) {
write_all(fd, client_text(req.dump()));
std::string buf;
for (;;) {
buf += read_some(fd);
rpc::WsOpcode op{};
std::string save = buf;
const std::string payload = server_frame(buf, op);
if (payload.empty() && buf == save) continue; // need more bytes
if (op == rpc::WsOpcode::Text) return json::parse(payload, nullptr, false);
}
}
} // namespace
void run() {
::unsetenv("VELOX_PAIR_AUTO");
auto db = store::Db::open(":memory:");
CHECK(db.has_value());
if (!db) return;
CHECK(store::migrate_to_head(*db).has_value());
char tmpl[] = "/tmp/velox-ws-test-XXXXXX";
const char* dir = ::mkdtemp(tmpl);
CHECK(dir != nullptr);
rpc::RuntimeDir rt{dir ? dir : "/tmp"};
rpc::EventLoop loop;
rpc::VeloxDispatcher dispatcher;
rpc::EnvAutoApprover approver;
rpc::WsServer server(loop, dispatcher, *db, approver, rt);
const auto ec = server.start();
CHECK(!ec);
if (ec) return;
CHECK(server.port() >= rpc::WsServer::kPortLo);
CHECK(server.port() <= rpc::WsServer::kPortHi);
std::thread th([&loop] { loop.run(); });
const std::string origin = "moz-extension://11111111-2222-3333-4444-555555555555";
// --- handshake ---------------------------------------------------------------
const int fd = dial(server.port());
CHECK(fd >= 0);
write_all(fd,
"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n"
"Origin: " + origin + "\r\n\r\n");
std::string hs;
while (hs.find("\r\n\r\n") == std::string::npos) hs += read_some(fd);
CHECK(hs.find("101 Switching Protocols") != std::string::npos);
// --- session.hello with no token -> -32002 --------------------------------
{
const json r = rpc_call(fd, {{"jsonrpc", "2.0"}, {"id", 1}, {"method", "session.hello"},
{"params", {{"clientType", "extension"},
{"clientName", "Velox for Firefox"},
{"protocolVersion",
std::string(velox::proto::kProtocolVersion)}}}});
CHECK(r.contains("error"));
CHECK_EQ(r["error"]["code"].get<int>(), -32002);
}
// --- session.pair with approval off -> not approved ----------------------
{
const json r = rpc_call(fd, {{"jsonrpc", "2.0"}, {"id", 2}, {"method", "session.pair"},
{"params", {{"clientName", "Velox for Firefox"},
{"extensionId", "11111111-2222-3333-4444-555555555555"}}}});
CHECK(r.contains("error"));
}
// --- approval on -> a token, then hello with it succeeds ----------------
::setenv("VELOX_PAIR_AUTO", "1", 1);
std::string token;
{
const json r = rpc_call(fd, {{"jsonrpc", "2.0"}, {"id", 3}, {"method", "session.pair"},
{"params", {{"clientName", "Velox for Firefox"},
{"extensionId", "11111111-2222-3333-4444-555555555555"}}}});
CHECK(r.contains("result"));
if (r.contains("result")) {
token = r["result"]["token"].get<std::string>();
CHECK(token.size() >= 40);
}
}
{
const json r = rpc_call(fd, {{"jsonrpc", "2.0"}, {"id", 4}, {"method", "session.hello"},
{"params", {{"clientType", "extension"},
{"clientName", "Velox for Firefox"},
{"protocolVersion",
std::string(velox::proto::kProtocolVersion)},
{"token", token}}}});
CHECK(r.contains("result"));
if (r.contains("result"))
CHECK_EQ(r["result"]["transport"].get<std::string>(), std::string("ws"));
}
// --- privileged method over WS -> -32003 -------------------------------
{
const json r = rpc_call(fd, {{"jsonrpc", "2.0"}, {"id", 5}, {"method", "settings.get"},
{"params", {{"keys", nullptr}}}});
CHECK(r.contains("error"));
CHECK_EQ(r["error"]["code"].get<int>(), -32003);
}
// --- a non-privileged method while authed -> a real result -----------
{
const json r = rpc_call(fd, {{"jsonrpc", "2.0"}, {"id", 6}, {"method", "download.list"},
{"params", json::object()}});
CHECK(r.contains("result"));
if (r.contains("result")) CHECK_EQ(r["result"]["total"].get<int>(), 0);
}
::close(fd);
loop.stop();
th.join();
::unlink(rt.ws_port_path().c_str());
}
TEST_MAIN()