Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,26 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends jq
python3 -m pip install --upgrade platformio
python3 -m pip install platformio==6.1.18

- name: Provision verified public trust anchors
working-directory: iobox-firmware
env:
BF_IOBOX_TLS_CA_PEM: ${{ vars.BF_IOBOX_TLS_CA_PEM }}
BF_IOBOX_OTA_PUBLIC_KEY_PEM: ${{ vars.BF_IOBOX_OTA_PUBLIC_KEY_PEM }}
run: python3 scripts/provision_trust.py

- name: Build ioBOX firmware
working-directory: iobox-firmware
run: pio run -e ${{ matrix.env }}

- name: Test firmware trust contracts
if: matrix.env == 'iobox_wifi'
working-directory: iobox-firmware
run: |
python3 -m unittest discover -s tests -v
node tests/test_ota_signature.mjs

- name: Package firmware artifact
working-directory: iobox-firmware
run: |
Expand Down Expand Up @@ -220,7 +234,7 @@ jobs:
BF_BUILD_VERSION: ${{ inputs.version }}
BF_AXIOM_KEY: ${{ secrets.BF_AXIOM_KEY }}
BF_AXIOM_DATASET: ${{ secrets.BF_AXIOM_DATASET }}
run: cargo build --release --target ${{ matrix.rust_target }}
run: cargo build --release --locked --target ${{ matrix.rust_target }}

- name: Strip + rename
working-directory: client
Expand Down Expand Up @@ -335,7 +349,7 @@ jobs:
"LIB=C:\Program Files\gstreamer\1.0\msvc_x86_64\lib;$env:LIB" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
- name: Build and test Windows client
working-directory: client
run: cargo test --release
run: cargo test --release --locked
- name: Build MSI
working-directory: client
env:
Expand Down
133 changes: 133 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
name: validate

on:
pull_request:
workflow_dispatch:

permissions:
contents: read

concurrency:
group: validate-${{ github.ref }}
cancel-in-progress: true

jobs:
server:
runs-on: ubuntu-24.04
services:
postgres:
image: postgres:18-alpine
env:
POSTGRES_USER: betterframe
POSTGRES_PASSWORD: integration-only
POSTGRES_DB: betterframe_test
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U betterframe -d betterframe_test"
--health-interval 5s --health-timeout 5s --health-retries 10
env:
BF_TEST_PG_URL: postgres://betterframe:integration-only@127.0.0.1:5432/betterframe_test
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm test
- run: npm run build
- name: Node-RED routing and deployment syntax
env:
BF_NODERED_MANAGER_SECRET: 0123456789abcdef0123456789abcdef
BF_NODERED_MANAGER_SELF_TEST: "1"
run: |
node deploy/nodered-manager/manager.mjs
find deploy scripts -name '*.sh' -print0 | xargs -0 -n1 bash -n

client:
runs-on: ubuntu-24.04
env:
CARGO_BUILD_JOBS: "2"
CARGO_PROFILE_DEV_DEBUG: "0"
CARGO_PROFILE_TEST_DEBUG: "0"
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- name: Install native client dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgtk-4-dev libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libwebkitgtk-6.0-dev libssl-dev
- run: cargo test --manifest-path client/Cargo.toml --workspace --locked

windows-client:
runs-on: windows-latest
env:
GSTREAMER_VERSION: "1.26.9"
GSTREAMER_RELEASE: deps-1
GSTREAMER_RUNTIME_SHA256: ef56a1e9077c6cadcf8384369d1e435a0f3bc0d5a26594773256cffe79f40ecb
GSTREAMER_DEVEL_SHA256: 79c7ca2a5013181d94a2c823ef1c8a575f3e036e813633dc3e5ac3d393581a15
GSTREAMER_MSM_SHA256: d3f16159d60ac5c7df6be73afdc6715b08e6de77906364ddb8560f706a09452d
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- name: Install GStreamer SDK
shell: powershell
env:
GH_TOKEN: ${{ github.token }}
run: |
$runtime = Join-Path $env:RUNNER_TEMP "gstreamer-1.0-msvc-x86_64-$env:GSTREAMER_VERSION.msi"
$devel = Join-Path $env:RUNNER_TEMP "gstreamer-1.0-devel-msvc-x86_64-$env:GSTREAMER_VERSION.msi"
gh release download $env:GSTREAMER_RELEASE --repo $env:GITHUB_REPOSITORY --dir $env:RUNNER_TEMP `
--pattern (Split-Path $runtime -Leaf) --pattern (Split-Path $devel -Leaf)
@(
@{ Path = $runtime; Hash = $env:GSTREAMER_RUNTIME_SHA256 },
@{ Path = $devel; Hash = $env:GSTREAMER_DEVEL_SHA256 }
) | ForEach-Object {
$actualHash = (Get-FileHash $_.Path -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualHash -ne $_.Hash) { throw "GStreamer checksum mismatch for $($_.Path): $actualHash" }
$arguments = "/i `"$($_.Path)`" ADDLOCAL=ALL /qn /norestart"
$installer = Start-Process msiexec.exe -ArgumentList $arguments -Wait -PassThru
if ($installer.ExitCode -notin 0, 3010) { throw "GStreamer install failed with exit code $($installer.ExitCode)" }
}
$pkgConfigPath = "C:/Program Files/gstreamer/1.0/msvc_x86_64/lib/pkgconfig"
if (-not (Test-Path "$pkgConfigPath/glib-2.0.pc")) { throw "GStreamer development files were not installed" }
C:\msys64\usr\bin\bash.exe -lc "pacman -Sy --noconfirm --needed mingw-w64-x86_64-pkgconf"
$pkgConfig = "C:\msys64\mingw64\bin\pkg-config.exe"
$env:PKG_CONFIG_PATH = $pkgConfigPath
& $pkgConfig --modversion glib-2.0
if ($LASTEXITCODE -ne 0) { throw "pkg-config could not find GStreamer GLib" }
"C:\Program Files\gstreamer\1.0\msvc_x86_64\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
"PKG_CONFIG=$pkgConfig" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
"PKG_CONFIG_PATH=$pkgConfigPath" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
"LIB=C:\Program Files\gstreamer\1.0\msvc_x86_64\lib;$env:LIB" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
- name: Build and test Windows client
working-directory: client
run: cargo test --workspace --locked

iobox:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
- uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Install pinned firmware toolchain manager
run: python3 -m pip install platformio==6.1.18
- name: Generate disposable public test trust anchors
working-directory: iobox-firmware
run: |
openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj '/CN=ioBOX CI test CA' -keyout "$RUNNER_TEMP/iobox-ca.key" -out "$RUNNER_TEMP/iobox-ca.pem"
openssl genpkey -algorithm ED25519 -out "$RUNNER_TEMP/iobox-sign.key"
openssl pkey -in "$RUNNER_TEMP/iobox-sign.key" -pubout -out "$RUNNER_TEMP/iobox-sign.pub.pem"
python3 scripts/provision_trust.py --ca "$RUNNER_TEMP/iobox-ca.pem" --signing-public-key "$RUNNER_TEMP/iobox-sign.pub.pem"
- name: Build Wi-Fi and W5500 variants with verification enabled
working-directory: iobox-firmware
run: pio run -e iobox_wifi -e iobox_eth
- name: Verify provisioning and server-to-firmware signature contract
working-directory: iobox-firmware
run: |
python3 -m unittest discover -s tests -v
node tests/test_ota_signature.mjs
124 changes: 121 additions & 3 deletions client/core/src/protocol.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::time::Duration;
use url::Url;

pub const LOCAL_SERVER_URL: &str = "http://localhost";
Expand All @@ -13,15 +14,103 @@ pub fn server_origin(url: &Url) -> String {
url.origin().ascii_serialization()
}

#[derive(Debug, Deserialize)]
#[derive(Clone, Deserialize, Serialize)]
pub struct PairInitiateResponse {
pub code: String,
pub expires_at: String,
pub expires_in_seconds: Option<u64>,
pub poll_after_ms: Option<u64>,
pub polling_secret: Option<String>,
}

#[derive(Debug, Deserialize)]
impl PairInitiateResponse {
/// Old servers only provide wall-clock expiry. Bound those sessions too,
/// without trusting a kiosk clock that may not yet have synchronized.
pub fn lifetime(&self) -> Duration {
Duration::from_secs(self.expires_in_seconds.unwrap_or(900).clamp(1, 1800))
}

pub fn poll_delay(&self) -> Duration {
poll_delay(self.poll_after_ms)
}
}

pub fn claim_body(code: &str, polling_secret: Option<&str>) -> Value {
let mut body = serde_json::json!({ "code": code });
if let Some(secret) = polling_secret {
body["polling_secret"] = Value::String(secret.to_string());
}
body
}

pub fn poll_delay(milliseconds: Option<u64>) -> Duration {
Duration::from_millis(milliseconds.unwrap_or(2000).clamp(1000, 60000))
}

#[derive(Clone, Deserialize, Serialize)]
pub struct DeviceIdentity {
pub version: u32,
pub server_url: String,
pub kiosk_id: String,
pub kiosk_name: String,
pub kiosk_key: String,
pub cluster_key: Option<String>,
pub encrypt_key: Option<String>,
pub pairing_code: String,
pub polling_secret: Option<String>,
}

impl DeviceIdentity {
pub fn from_claim(
server: &str,
session: &PairInitiateResponse,
claim: PairClaimResponse,
) -> Result<Self, String> {
let identity = Self {
version: 1,
server_url: server.to_string(),
kiosk_id: match claim.kiosk_id {
Some(Value::String(id)) => id,
Some(Value::Number(id)) => id.to_string(),
_ => return Err("claim is missing kiosk ID".into()),
},
kiosk_name: claim.kiosk_name.unwrap_or_else(|| "kiosk".into()),
kiosk_key: claim.kiosk_key.ok_or("claim is missing kiosk key")?,
cluster_key: claim.cluster_key,
encrypt_key: claim.encrypt_key,
pairing_code: session.code.clone(),
polling_secret: session.polling_secret.clone(),
};
identity.validate()?;
Ok(identity)
}

pub fn validate(&self) -> Result<(), String> {
if self.version != 1 || self.kiosk_id.trim().is_empty() || self.kiosk_key.trim().is_empty()
{
return Err("invalid saved device identity".into());
}
let origin = Url::parse(&self.server_url).map_err(|_| "invalid identity server URL")?;
if !matches!(origin.scheme(), "http" | "https") || origin.host_str().is_none() {
return Err("invalid identity server URL".into());
}
if !self
.encrypt_key
.as_deref()
.or(self.cluster_key.as_deref())
.is_some_and(|key| !key.trim().is_empty())
{
return Err("device identity is missing encryption material".into());
}
Ok(())
}
}

#[derive(Deserialize)]
pub struct PairClaimResponse {
pub status: String,
pub expires_in_seconds: Option<u64>,
pub poll_after_ms: Option<u64>,
pub kiosk_id: Option<Value>,
pub kiosk_name: Option<String>,
pub kiosk_key: Option<String>,
Expand Down Expand Up @@ -61,6 +150,35 @@ pub fn websocket_url(server_url: &str, token: &str) -> Result<String, String> {
mod tests {
use super::*;

#[test]
fn pairing_supports_legacy_and_bounds_untrusted_timing() {
let legacy: PairInitiateResponse =
serde_json::from_str(r#"{"code":"ABC123","expires_at":"invalid"}"#).unwrap();
assert_eq!(legacy.lifetime(), Duration::from_secs(900));
assert_eq!(legacy.poll_delay(), Duration::from_secs(2));
let modern: PairInitiateResponse = serde_json::from_str(r#"{"code":"ABC123","expires_at":"invalid","expires_in_seconds":18446744073709551615,"poll_after_ms":0}"#).unwrap();
assert_eq!(modern.lifetime(), Duration::from_secs(1800));
assert_eq!(modern.poll_delay(), Duration::from_secs(1));
}

#[test]
fn incomplete_claim_cannot_be_saved_as_paired() {
let session: PairInitiateResponse =
serde_json::from_str(r#"{"code":"ABC123","expires_at":"invalid"}"#).unwrap();
let parse = |json| serde_json::from_str::<PairClaimResponse>(json).unwrap();
assert!(
DeviceIdentity::from_claim(
"https://example.com",
&session,
parse(r#"{"status":"claimed","kiosk_key":"key","kiosk_id":"42"}"#)
)
.is_err()
);
let identity = DeviceIdentity::from_claim("https://example.com", &session, parse(r#"{"status":"claimed","kiosk_key":"key","kiosk_id":42,"encrypt_key":"encryption-key"}"#)).unwrap();
assert_eq!(identity.kiosk_id, "42");
assert!(identity.validate().is_ok());
}

#[test]
fn websocket_url_preserves_proxy_or_maps_direct_api_port() {
assert_eq!(
Expand Down
2 changes: 2 additions & 0 deletions client/core/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ pub struct ClientState {
#[serde(default)]
pub pairing_expires_at: Option<String>,
#[serde(default)]
pub pairing_secret: Option<String>,
#[serde(default)]
pub active_layouts: HashMap<String, String>,
}

Expand Down
Loading