Move TLS to aws-lc-rs with post-quantum key exchange, drop ring via ureq 3, and fix the dependency defects behind it #192

Merged
enricobuehler merged 6 commits from worktree-aws-lc-rs-migration into main 2026-08-13 10:43:38 +00:00
50 changed files with 2783 additions and 1658 deletions
+19 -19
View File
@@ -4,12 +4,21 @@
# or an accepted, documented risk. Keep this list TIGHT and justify every entry — an ignore here
# means the audit job stops flagging it, so the reasoning must hold up.
#
# NOTE: `cargo audit` (no `--deny warnings`) fails only on *vulnerabilities*, not on the
# `unmaintained` warnings (audiopus_sys via opus, paste via utoipa-axum). Both are transitive, at
# their latest published version with no successor, so there's nothing to bump — left visible on
# purpose so we keep getting the maintenance signal; they do not fail CI. (rustls-pemfile was dropped
# 2026-06-29 by removing axum-server's unused tls-rustls feature + moving our own PEM parsing to
# rustls-pki-types; memmap2's unsoundness was fixed by the 0.9.11 bump.)
# NOTE: `cargo audit` (no `--deny warnings`) fails only on *vulnerabilities* — `unmaintained` AND
# `unsound` advisories are warnings that do NOT fail CI. That is deliberate for the two unmaintained
# crates below, but it does mean an unsoundness can sit here unnoticed: RUSTSEC-2026-0221
# (event-listener) did exactly that until the 2026-08-13 sweep. Read the job's warnings, not just
# its exit code.
#
# The two unmaintained ones, both transitive with no successor to bump to, left visible on purpose
# so we keep getting the maintenance signal:
# * audiopus_sys via opus (opus itself IS maintained; only its -sys layer is stuck).
# * paste via BOTH utoipa-axum (host) and rav1d (client decode path) — an earlier version of this
# note named only utoipa-axum, which would have made dropping utoipa-axum look like it cleared
# paste. It would not: every client pulls it through rav1d.
# (rustls-pemfile was dropped 2026-06-29 by removing axum-server's unused tls-rustls feature +
# moving our own PEM parsing to rustls-pki-types; memmap2's unsoundness was fixed by the 0.9.11
# bump.)
[advisories]
ignore = [
@@ -34,17 +43,8 @@ ignore = [
# a constant-time rsa ships (then drop this), the host ever signs an attacker-chosen message with
# this key, or any RSA decryption / key-transport using the private key is added.
"RUSTSEC-2023-0071",
# quick-xml DoS advisories (RUSTSEC-2026-0194 quadratic-time duplicate-attribute check;
# RUSTSEC-2026-0195 unbounded namespace-declaration allocation in NsReader). Both are
# exploited by feeding attacker-controlled XML to a running parser. In this tree quick-xml is
# a BUILD-TIME-ONLY, transitive dependency of `wayland-scanner` (a proc-macro that parses the
# TRUSTED wayland protocol XML files shipped with the wayland-rs crates at compile time). It is
# never linked into any shipped binary and never parses runtime/attacker-controlled input, so
# neither DoS is reachable. There is no fix to bump to: wayland-scanner 0.31.10 (latest) pins
# `quick-xml ^0.39`, and the fixes only exist in quick-xml >=0.41. Revisit (drop these) when
# wayland-scanner releases against quick-xml >=0.41, or if quick-xml is ever pulled onto a
# runtime path that parses untrusted XML.
"RUSTSEC-2026-0194",
"RUSTSEC-2026-0195",
# The quick-xml DoS pair (RUSTSEC-2026-0194/0195) used to be ignored here, with the note
# "revisit when wayland-scanner releases against quick-xml >=0.41". It has: wayland-scanner
# 0.31.11 moved to `quick-xml ^0.41` and the lock is on 0.41.0 as of 2026-08-13, so both
# entries were dropped rather than left as permanent exceptions.
]
+15 -1
View File
@@ -2,6 +2,9 @@
# license-allowlist gate (CRA Annex I Part II: know your components; catch a bad dep the moment
# it lands).
# * cargo-audit → the (network-facing, crypto-heavy) Rust tree, against the RustSec advisory DB.
# ⚠ ALL FIVE Rust lockfiles, each named with its own `--file`: a bare `cargo audit`
# reads only the root one, which is how the drivers lock went unscanned for so
# long despite already being in this job's `paths:` filter.
# * bun audit → each Bun-managed tree that ships or publishes: web (the mgmt console BFF —
# login gate, session sealing, mgmt bearer token), sdk (@punktfunk/host),
# plugin-kit (@punktfunk/plugin-kit).
@@ -11,7 +14,7 @@
# build chain (node-tar, brace-expansion); clearing them needs coordinated bumps
# verified against the LIVE site (the docs don't build standalone) — tracked in
# punktfunk-planning design/cra-readiness.md. Flip to blocking once clean.
# * cargo-about → license-allowlist gate over BOTH Rust workspaces (about.toml `accepted`);
# * cargo-about → license-allowlist gate over the host + driver workspaces (about.toml `accepted`);
# fails if any crate carries a license outside the allowlist — the regression
# guard about.toml always promised. (The Android Gradle tree has no lockfile, so
# nothing scans it — see the CRA roadmap.)
@@ -47,6 +50,9 @@ on:
paths:
- 'Cargo.lock'
- 'packaging/windows/drivers/Cargo.lock'
- 'packaging/windows/pf-vkhdr-layer/Cargo.lock'
- 'tools/win-input-matrix/Cargo.lock'
- 'tools/hid-descriptor-dump/Cargo.lock'
- 'web/bun.lock'
- 'docs-site/bun.lock'
- 'sdk/bun.lock'
@@ -83,7 +89,15 @@ jobs:
run: |
git config --global --add safe.directory "$PWD"
command -v cargo-audit >/dev/null 2>&1 || cargo install --locked cargo-audit
# Bare `cargo audit` scans ONLY the root Cargo.lock. The other three Rust workspaces are
# separate locks and were silently never scanned — the drivers one despite already being
# in this job's `paths:` filter, so edits to it triggered a run that then ignored it.
# Each needs its own `--file`. `pf-vkhdr-layer` had no lockfile at all until 2026-08-13.
cargo audit
cargo audit --file packaging/windows/drivers/Cargo.lock
cargo audit --file packaging/windows/pf-vkhdr-layer/Cargo.lock
cargo audit --file tools/win-input-matrix/Cargo.lock
cargo audit --file tools/hid-descriptor-dump/Cargo.lock
bun-audit:
strategy:
+41
View File
@@ -32,6 +32,47 @@ pairing + the legacy GCM path, security-review #5/#9) are enabled only by an exp
the old flag is still accepted as explicit-off).
- Windows was already opt-in (unchecked installer task) and is unchanged.
### TLS moved to aws-lc-rs, with post-quantum key exchange (⚠ build-visible for packagers/embedders)
The rustls backend across the whole workspace — host, tray, clients and `punktfunk-core` — is now
**aws-lc-rs** instead of `ring`, which enables rustls's `prefer-post-quantum`: every TLS 1.3
handshake (management API, the native `punktfunk/1` control plane, QUIC) now offers the
**X25519MLKEM768** hybrid key exchange first. Ring has no ML-KEM, which is why the backend had to
move. This is negotiation-only and additive — the classical curves stay in the list, so any client
that does not implement ML-KEM connects exactly as before, and no wire format, ABI or pairing
record changes. The session AEAD (AES-128-GCM / ChaCha20-Poly1305) is a separate mechanism and is
untouched.
**Building from source now needs a working C compiler**, because `aws-lc-sys` compiles AWS-LC.
No CMake, Go, or NASM is required for the default (non-FIPS) build — on Windows x86_64 rustls turns
on `aws-lc-rs/prebuilt-nasm`, so no NASM has to be installed. If you add a crate that depends on
`aws-lc-rs` *directly*, name `features = ["prebuilt-nasm"]` on it: a package selection that pulls
`aws-lc-rs` without also enabling rustls's `aws_lc_rs` feature otherwise fails on Windows.
`punktfunk-core` gains an off-by-default **`ureq-tls`** feature (`tls::ureq_agent`) that builds a
blocking HTTP agent around a caller-supplied `rustls::ClientConfig` — the only way to install the
fingerprint-pinning verifier, since ureq's own `TlsConfig` has no hook for one. The desktop client
and the tray enable it; the Apple/Android cdylib embedders do not, and pull no HTTP stack.
**`ring` is gone from the tree entirely** — aws-lc-rs is now the only crypto backend on every
target we ship. Getting there needed the `ureq 2 → 3` upgrade in the same change, because ureq 2
named `rustls/ring` inside its own dependency declaration where no dependent could switch it off.
ureq 3 declares rustls with `default-features = false` and picks no backend, so the choice is
finally ours. ⚠ Spell that dependency `features = ["rustls-no-provider", "rustls-webpki-roots"]`:
ureq 3's convenience `rustls` feature pulls `_ring` and would quietly restore the second backend.
The ureq upgrade is otherwise internal, but two behaviours are worth knowing. Response size caps
are now enforced by the body reader, so an over-cap response is an **error** instead of ureq 2's
silent truncation (which used to surface as a confusing signature failure). And a fingerprint
mismatch is now matched on ureq 3's typed `Error::Rustls(..)` rather than by sniffing a substring
out of a transport error message — the old test could also fire on unrelated certificate errors.
Conditional requests are unchanged: ureq 3 still returns 304 as `Ok`, only 4xx/5xx become `Err`.
**Embedders of `punktfunk-core` that build their own rustls configs** should still call
`punktfunk_core::tls::install_default_provider()` at startup, or use `builder_with_provider`. With
one backend present rustls can infer it, so this is now insurance rather than a requirement — but
it is what stops a future second backend from turning config construction into a panic.
### The ENet control port now exists only while a pairing does (rust-safety WP0)
`rusty_enet` — a c2rust-style transpile of C ENet, and the host's only pre-auth-reachable unsafe
Generated
+580 -726
View File
File diff suppressed because it is too large Load Diff
+1503 -616
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -40,7 +40,6 @@ accepted = [
"CC0-1.0",
"Unlicense",
"WTFPL",
"OpenSSL",
]
# cbindgen is MPL-2.0 but it is a BUILD-ONLY codegen tool that never links into a shipped artifact
@@ -57,7 +56,8 @@ ignore-dev-dependencies = true
# accepted arm on its own (MIT/Apache-2.0 are globally accepted), so it needs no entry. (It is
# also UEFI-target-gated out of every shipped build.)
#
# ring's license is an AND of permissive terms including the OpenSSL license; accept the
# OpenSSL/ISC parts for this crate only, not globally.
[ring]
accepted = ["OpenSSL", "ISC"]
# There is deliberately NO per-crate entry here any more. `ring` used to need one (its licence is an
# AND that includes the OpenSSL licence, which was accepted for that crate alone), but the crypto
# backend moved to aws-lc-rs and the ureq 2 → 3 upgrade removed ring from every target we build.
# aws-lc-sys 0.44's SPDX is an AND of ISC / Apache-2.0 / MIT / BSD-3-Clause / MIT-0 — all globally
# accepted above — and carries no OpenSSL clause, so `OpenSSL` left the global list with ring.
+2 -1
View File
@@ -15,7 +15,8 @@ crate-type = ["cdylib"]
[dependencies]
# The whole protocol/transport/FEC/crypto + the embeddable NativeClient connector. `quic` pulls
# the punktfunk/1 control plane (now ring-only — no aws-lc, see punktfunk-core/Cargo.toml).
# the punktfunk/1 control plane, whose TLS runs on aws-lc-rs (see punktfunk-core/Cargo.toml)
# aws-lc-sys cross-compiles for all three ABIs with the NDK clang cargo-ndk already exports.
punktfunk-core = { path = "../../../crates/punktfunk-core", features = ["quic"] }
jni = "0.21"
log = "0.4"
-1
View File
@@ -21,7 +21,6 @@ path = "src/main.rs"
pf-client-core = { path = "../../crates/pf-client-core", default-features = false }
punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[lints]
+1
View File
@@ -1389,6 +1389,7 @@ from the config directory for a true factory reset."
#[cfg(any(target_os = "linux", windows))]
fn main() -> std::process::ExitCode {
punktfunk_core::tls::install_default_provider();
// Logs to stderr; stdout is the machine interface (TSV/JSON), exactly like the session
// binary's contract.
tracing_subscriber::fmt()
+1
View File
@@ -33,6 +33,7 @@ mod ui_trust;
#[cfg(target_os = "linux")]
fn main() -> gtk::glib::ExitCode {
punktfunk_core::tls::install_default_provider();
app::run()
}
+8 -1
View File
@@ -10,7 +10,14 @@ repository.workspace = true
[dependencies]
punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
quinn = "0.11"
# Backend features mirror punktfunk-core's quinn exactly (see its Cargo.toml).
quinn = { version = "0.11", default-features = false, features = [
"log",
"platform-verifier",
"runtime-tokio",
"rustls-aws-lc-rs",
"bloom",
] }
tokio = { version = "1", features = ["rt-multi-thread", "net", "time", "macros"] }
anyhow = "1"
tracing = "0.1"
-1
View File
@@ -37,7 +37,6 @@ pf-client-core = { path = "../../crates/pf-client-core", default-features = fals
punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
# The fake-library dev hook (`PUNKTFUNK_FAKE_LIBRARY`, browse mode) parses GameEntry JSON.
serde_json = { version = "1", optional = true }
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+9 -3
View File
@@ -47,8 +47,15 @@ pf-client-core = { path = "../../crates/pf-client-core", default-features = fals
# Unpublished (version 0.0.0) and fast-moving, so pinned to a verified commit. Pin bumped
# 2026-07-29 (from the 2026-07-01 rev) for: reconciler keyed-child-order fix (#4728), widget
# validation (#4727), DPI collision fix (#4751), icon elements (#4736), multi-window (#4730),
# scroll virtualization (#4710). All three windows-rs deps here MUST share this rev, and it
# must match pf-client-core's `windows` pin, so the workspace builds ONE windows-rs.
# scroll virtualization (#4710). All three windows-rs deps here MUST share this rev, and it must
# match pf-client-core's `windows` pin — that is what makes the `IDXGISwapChain1` handed to reactor
# satisfy reactor's own `windows_core::Interface`.
# ⚠ This is NOT "the workspace builds ONE windows-rs", which an earlier version of this note
# claimed. `wasapi` (via pf-client-core) pulls the crates.io `windows 0.62.2` alongside this git
# copy, so both are in the lock and both compile. That costs build time and binary size, not
# correctness. ⛔ Do NOT try to collapse it with a blanket `[patch.crates-io] windows`: this rev
# uses header-named features (`dxgi`, `combaseapi`) while a dozen other manifests still use the
# old `Win32_*` namespace features, and the patch would break every one of them.
windows-reactor = { git = "https://github.com/microsoft/windows-rs", rev = "acb5a1a7441033d9312b16842af02eb0c2b403dc" }
# Win32 / DXGI for the GPU picker and the shell's window plumbing. Pulled from the SAME
# windows-rs commit as windows-reactor so their `windows-core` unifies — the `IDXGISwapChain1`
@@ -84,7 +91,6 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "acb5a1a74410
# pf-client-core with the same `build-from-source,hidapi` features, so it is not a direct dep here.
mdns-sd = "0.20"
async-channel = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+1
View File
@@ -58,6 +58,7 @@ fn main() {
let _ = AttachConsole(ATTACH_PARENT_PROCESS);
}
set_app_user_model_id();
punktfunk_core::tls::install_default_provider();
// Everything logs to stderr AND `%LOCALAPPDATA%\punktfunk\logs\client.log` (see [`logfile`]):
// a GUI/MSIX launch has no console, so without the file the client side of any field report
+3 -1
View File
@@ -29,7 +29,9 @@ ashpd = { version = "0.13", features = ["screencast", "remote_desktop"] }
pipewire = "0.9"
libc = "0.2"
# ashpd 0.13 uses the tokio runtime for the one-time portal handshake (control plane).
tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "time"] }
# `sync` is for the `tokio::sync::oneshot` quit channels in the portal/linux capture paths. It used
# to be absent and compile anyway, borrowed from ashpd→zbus via feature unification.
tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "time", "sync"] }
# XFixes cursor source for gamescope (remote-desktop-sweep Phase C): gamescope paints no
# `SPA_META_Cursor`, so the pointer never reaches the PipeWire node. We read the shape/hotspot/
# visibility from gamescope's nested Xwayland via XFixes instead and feed the existing cursor slot.
+16 -3
View File
@@ -14,7 +14,7 @@ repository.workspace = true
# the old main.rs. Audio is the one per-OS swap: PipeWire on Linux, WASAPI on Windows
# (same public surface — see lib.rs).
[target.'cfg(any(target_os = "linux", windows))'.dependencies]
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
punktfunk-core = { path = "../punktfunk-core", features = ["quic", "ureq-tls"] }
# Native Vulkan Video decode (WP-C of the native-decode program, HEVC added by M3
# WP-2, AV1 by M7): auto's TOP rung on both desktop OSes since M9 — for every codec it
# speaks, AV1 included — also pinnable via `PUNKTFUNK_DECODER=native-vulkan` —
@@ -101,11 +101,19 @@ ash = { version = "0.38", optional = true }
# Game-library fetch from the host's management API over mTLS + fingerprint pinning.
# `ureq` is small + sync (the host uses it too) and its rustls unifies with the
# workspace's (quinn's) 0.23; the pinning verifier mirrors core's private `PinVerify`.
ureq = "2"
# ⚠ `rustls-no-provider`, NEVER the default `rustls` feature: that one pulls `_ring`, which would
# put the ring backend back into a tree that has moved to aws-lc-rs. Same spelling everywhere.
ureq = { version = "3", default-features = false, features = [
"rustls-no-provider",
"rustls-webpki-roots",
"gzip",
] }
# Signed update-manifest fetch/verify + the install-kind ladder, shared with the host so one
# trust rule serves both (crates/pf-update-check).
pf-update-check = { path = "../pf-update-check" }
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
# aws-lc-rs backend + PQ hybrid key exchange, matching punktfunk-core (see its Cargo.toml for
# why every crate that names a rustls backend has to name the same one).
rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "prefer-post-quantum", "logging", "std", "tls12"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
@@ -166,6 +174,11 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "acb5a1a74410
"handleapi",
# RECT/HMONITOR for DXGI_OUTPUT_DESC1 (the display-HDR volume query).
"windef",
# HGLOBAL (clipboard.rs) + HINSTANCE (video_d3d11.rs), and the NT `HANDLE` the shared-surface
# hand-off uses. Both headers were used without being declared — they resolved only because
# clients/windows enables them on the same pinned rev, so this crate did not build standalone.
"minwindef",
"winnt",
# IDXGIResource1::CreateSharedHandle takes an optional SECURITY_ATTRIBUTES.
"minwinbase",
# The GlobalAlloc block the clipboard takes ownership of (clipboard.rs).
+39 -31
View File
@@ -7,7 +7,6 @@
use serde::Deserialize;
use std::collections::VecDeque;
use std::io::Read;
use std::sync::{Arc, Mutex};
use std::time::Duration;
@@ -171,9 +170,9 @@ pub fn agent(
use rustls::pki_types::pem::PemObject;
let bad =
|what: &str, e: &dyn std::fmt::Display| LibraryError::Unreachable(format!("{what}: {e}"));
// The ring provider, explicitly — the same one core's QUIC endpoints install, so the
// The aws-lc-rs provider, explicitly — the same one core's QUIC endpoints install, so the
// process never mixes rustls crypto providers.
let provider = Arc::new(rustls::crypto::ring::default_provider());
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
let builder = rustls::ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.map_err(|e| bad("tls config", &e))?
@@ -186,11 +185,15 @@ pub fn agent(
let cfg = builder
.with_client_auth_cert(vec![cert], key)
.map_err(|e| bad("client auth", &e))?;
Ok(ureq::AgentBuilder::new()
.tls_config(Arc::new(cfg))
.timeout_connect(Duration::from_secs(5))
.timeout(Duration::from_secs(10))
.build())
// ureq's own `TlsConfig` has no hook for a custom verifier, so the agent is built around this
// `ClientConfig` verbatim (punktfunk-core owns that glue — see `tls::ureq_agent`).
Ok(punktfunk_core::tls::ureq_agent::agent(
Arc::new(cfg),
ureq::Agent::config_builder()
.timeout_connect(Some(Duration::from_secs(5)))
.timeout_global(Some(Duration::from_secs(10)))
.build(),
))
}
/// Fetch the host's unified library. Errors are pre-classified for the UI (401/403 →
@@ -204,8 +207,9 @@ pub fn fetch_games(
let agent = agent(identity, pin)?;
let url = format!("{}/api/v1/library", base_url(addr, mgmt_port));
let body = match agent.get(&url).call() {
Ok(resp) => resp
.into_string()
Ok(mut resp) => resp
.body_mut()
.read_to_string()
.map_err(|e| LibraryError::Unreachable(format!("read body: {e}")))?,
Err(e) => return Err(classify(e)),
};
@@ -221,18 +225,26 @@ const ART_MAX_BYTES: u64 = 16 * 1024 * 1024;
/// a public CDN URL on a custom entry — uses ureq's default agent with normal webpki
/// trust and no client cert (Apple's `LibraryTLSDelegate` does the same split).
pub fn fetch_art(pinned: &ureq::Agent, base: &str, url: &str) -> Result<Vec<u8>, LibraryError> {
let resp = if url.starts_with(base) {
let mut resp = if url.starts_with(base) {
pinned.get(url).call()
} else {
ureq::get(url).timeout(Duration::from_secs(10)).call()
// ureq's default agent builds its own rustls config from the process-default provider.
// Installed here rather than trusting the binary, since several link this crate.
punktfunk_core::tls::install_default_provider();
ureq::get(url)
.config()
.timeout_global(Some(Duration::from_secs(10)))
.build()
.call()
}
.map_err(classify)?;
let mut bytes = Vec::new();
resp.into_reader()
.take(ART_MAX_BYTES)
.read_to_end(&mut bytes)
.map_err(|e| LibraryError::Unreachable(format!("read image: {e}")))?;
Ok(bytes)
// `limit` replaces the old `take()` — ureq 3 caps body reads itself, and its default cap is
// lower than the largest legitimate hero asset.
resp.body_mut()
.with_config()
.limit(ART_MAX_BYTES)
.read_to_vec()
.map_err(|e| LibraryError::Unreachable(format!("read image: {e}")))
}
/// Concurrent poster fetches — a handful is plenty for a LAN art proxy without turning a
@@ -288,19 +300,15 @@ pub fn spawn_art_fetch(
fn classify(e: ureq::Error) -> LibraryError {
match e {
ureq::Error::Status(401 | 403, _) => LibraryError::NotPaired,
ureq::Error::Status(code, _) => LibraryError::Http(code),
ureq::Error::Transport(t) => {
// A pin rejection surfaces as a TLS alert wrapped in a transport error; the
// verifier's error kind survives in the message.
let msg = t.to_string();
if msg.contains("ApplicationVerificationFailure") || msg.contains("InvalidCertificate")
{
LibraryError::PinMismatch
} else {
LibraryError::Unreachable(msg)
}
}
ureq::Error::StatusCode(401 | 403) => LibraryError::NotPaired,
ureq::Error::StatusCode(code) => LibraryError::Http(code),
// Exactly the rejection `PinVerify` raises on a fingerprint mismatch. ureq 3 carries the
// typed `rustls::Error`, so this is a real match instead of the substring sniff the 2.x
// `Transport(t)` string forced — which would also have fired on unrelated cert errors.
ureq::Error::Rustls(rustls::Error::InvalidCertificate(
rustls::CertificateError::ApplicationVerificationFailure,
)) => LibraryError::PinMismatch,
other => LibraryError::Unreachable(other.to_string()),
}
}
+9 -1
View File
@@ -18,7 +18,15 @@ publish = false
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
anyhow = "1"
tracing = "0.1"
quinn = "0.11"
# Backend features mirror punktfunk-core's quinn exactly — quinn's default `rustls-ring` would
# drag a second crypto stack into every build that links this crate.
quinn = { version = "0.11", default-features = false, features = [
"log",
"platform-verifier",
"runtime-tokio",
"rustls-aws-lc-rs",
"bloom",
] }
tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
# CF_DIB <-> PNG conversion (winfmt) - most Windows apps paste bitmaps, not the "PNG" format.
# Unconditional (not windows-gated) so winfmt's pure-conversion unit tests run on every host.
+5 -1
View File
@@ -12,7 +12,11 @@ repository.workspace = true
[target.'cfg(any(target_os = "linux", windows))'.dependencies]
pf-presenter = { path = "../pf-presenter" }
# MenuEvent/MenuPulse (the gamepad service's menu mode drives the library).
pf-client-core = { path = "../pf-client-core" }
# `default-features = false` like every other consumer (pf-presenter, cli, session, clients/windows):
# pf-client-core's default is `pyrowave`, which compiles the vendored PyroWave C++ — fatal on
# Windows ARM64. Whether that backend is on is the session binary's call (it forwards a `pyrowave`
# feature); this crate needs none of it, and taking defaults here quietly turned it on.
pf-client-core = { path = "../pf-client-core", default-features = false }
# Skia on the presenter's VkDevice (`vulkan`); `textlayout` = skparagraph/harfbuzz for
# the typography the console library needs (~15 MB stripped, prebuilt binaries exist for
+3 -3
View File
@@ -84,9 +84,9 @@ windows = { version = "0.62", features = [
"Win32_Storage_FileSystem",
"Win32_System_LibraryLoader",
"Win32_System_Threading",
# D3DKMTSetProcessSchedulingPriorityClass — raise the host's WDDM GPU scheduling priority
# above a running game so PyroWave's compute-shader encode isn't starved (enc/windows/pyrowave.rs).
"Wdk_Graphics_Direct3D",
# ("Wdk_Graphics_Direct3D" used to be here for D3DKMTSetProcessSchedulingPriorityClass. That
# call lives in pf-frame's dxgi.rs and is resolved via GetProcAddress on gdi32 because
# windows-rs has no stable binding for it — so nothing in this crate ever used the feature.)
] }
[features]
+4 -1
View File
@@ -41,7 +41,10 @@ wayland-backend = "0.3"
# libei (EI sender) for the portable input path on KWin/GNOME (RemoteDesktop portal) + gamescope-EI.
reis = { version = "0.6.1", features = ["tokio"] }
futures-util = "0.3"
tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "time"] }
# `macros` is for the `tokio::select!` in the libei and steam_usbip worker loops. It used to be
# absent and compile anyway, borrowed from punktfunk-core's `quic` feature via unification — i.e. an
# unrelated crate dropping it would have broken this one.
tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "time", "macros"] }
# Builds/validates the xkb keymap uploaded to the virtual keyboard + tracks modifier state.
xkbcommon = "0.8"
# Vendored + trimmed usbip server core — presents a virtual Steam Deck over USB/IP for Steam Input.
+18 -4
View File
@@ -22,13 +22,27 @@ publish = false
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Ed25519 over the exact manifest bytes. The workspace is ring-only (no aws-lc-sys — it fails
# on the Windows CI runner), and this is the same primitive the plugin-store index uses.
ring = "0.17"
# Ed25519 over the exact manifest bytes — the same primitive the plugin-store index uses, on the
# workspace's one crypto backend. aws-lc-rs's API is ring-compatible, so the call sites are
# unchanged apart from the crate name.
#
# `prebuilt-nasm` is what lets aws-lc-sys build on Windows x86_64 without NASM installed. rustls
# enables it for its own dependents, but a build that selects THIS crate without one that turns on
# rustls's `aws_lc_rs` feature — `cargo test -p pf-update-check` is exactly that, since its only
# rustls comes from ureq's ring-flavoured dependency — would get no enabler and fail on the CI
# runner. Naming it here makes the crate build standalone instead of relying on who else is in
# the selection.
aws-lc-rs = { version = "1", features = ["prebuilt-nasm"] }
base64 = "0.22"
# Small, sync, bundles webpki roots — no system cert store dependency, which matters on the
# Deck (Decky's embedded Python has no usable roots either; see clients/decky/main.py).
ureq = "2"
# ⚠ `rustls-no-provider`, NEVER the default `rustls` feature — that one pulls `_ring`, which would
# put the ring backend back into a tree that has deliberately moved to aws-lc-rs.
ureq = { version = "3", default-features = false, features = [
"rustls-no-provider",
"rustls-webpki-roots",
"gzip",
] }
[lints]
workspace = true
+19 -16
View File
@@ -79,17 +79,18 @@ pub fn fetch_manifest_blocking(
"no update key is pinned in this build".into(),
));
}
let agent = ureq::AgentBuilder::new()
.timeout(FETCH_TIMEOUT)
.redirects(3)
.user_agent(user_agent)
.build();
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(FETCH_TIMEOUT))
.max_redirects(3)
.user_agent(user_agent.to_string())
.build()
.into();
let url = format!("{base}/{channel}/manifest.json");
let sig_url = format!("{url}.sig");
// Only the MANIFEST leg can report an empty channel; see [`FeedError::NotPublished`].
let body = read_capped(agent.get(&url).call().map_err(manifest_err)?)?;
let sig = read_capped(agent.get(&sig_url).call().map_err(fetch_err)?)?;
let body = read_capped(&mut agent.get(&url).call().map_err(manifest_err)?)?;
let sig = read_capped(&mut agent.get(&sig_url).call().map_err(fetch_err)?)?;
let sig_text = String::from_utf8(sig)
.map_err(|_| FeedError::Failed("signature file is not text".into()))?;
@@ -100,24 +101,26 @@ pub fn fetch_manifest_blocking(
/// The manifest leg: a 404 here means the channel is empty, not broken.
fn manifest_err(e: ureq::Error) -> FeedError {
match e {
ureq::Error::Status(404, _) => FeedError::NotPublished,
ureq::Error::StatusCode(404) => FeedError::NotPublished,
other => fetch_err(other),
}
}
fn fetch_err(e: ureq::Error) -> FeedError {
FeedError::Failed(match e {
ureq::Error::Status(code, _) => format!("feed returned HTTP {code}"),
ureq::Error::StatusCode(code) => format!("feed returned HTTP {code}"),
other => format!("feed fetch failed: {other}"),
})
}
fn read_capped(resp: ureq::Response) -> Result<Vec<u8>, FeedError> {
use std::io::Read as _;
let mut buf = Vec::new();
let mut reader = resp.into_reader().take(MAX_MANIFEST_BYTES as u64 + 1);
reader
.read_to_end(&mut buf)
fn read_capped(resp: &mut ureq::http::Response<ureq::Body>) -> Result<Vec<u8>, FeedError> {
// cap+1 so an over-cap body is rejected by the length check rather than silently truncated
// into something that would then fail signature verification for the wrong reason.
let buf = resp
.body_mut()
.with_config()
.limit(MAX_MANIFEST_BYTES as u64 + 1)
.read_to_vec()
.map_err(|e| FeedError::Failed(format!("read failed: {e}")))?;
if buf.len() > MAX_MANIFEST_BYTES {
return Err(FeedError::Failed(
@@ -143,7 +146,7 @@ mod tests {
}
fn status(code: u16) -> ureq::Error {
ureq::Error::Status(code, ureq::Response::new(code, "status", "").unwrap())
ureq::Error::StatusCode(code)
}
/// The whole point of the split: an empty channel is not a broken feed.
+8 -7
View File
@@ -40,7 +40,8 @@ pub fn verify_signature(bytes: &[u8], sig_text: &str, keys: &[PublicKey]) -> Res
.decode(sig_text.trim())
.context("signature file is not valid base64")?;
for key in keys {
let pk = ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, &key.0);
let pk =
aws_lc_rs::signature::UnparsedPublicKey::new(&aws_lc_rs::signature::ED25519, &key.0);
if pk.verify(bytes, &sig).is_ok() {
return Ok(());
}
@@ -52,14 +53,14 @@ pub fn verify_signature(bytes: &[u8], sig_text: &str, keys: &[PublicKey]) -> Res
pub(crate) mod tests {
use super::*;
/// A fresh ring keypair as `(pinned key string, signer)` — the format contract with the
/// A fresh keypair as `(pinned key string, signer)` — the format contract with the
/// CI signers (raw 32-byte key, `ed25519:<base64>`; raw 64-byte signature, base64).
pub(crate) fn keypair() -> (String, ring::signature::Ed25519KeyPair) {
pub(crate) fn keypair() -> (String, aws_lc_rs::signature::Ed25519KeyPair) {
use aws_lc_rs::signature::KeyPair as _;
use base64::Engine as _;
use ring::signature::KeyPair as _;
let rng = ring::rand::SystemRandom::new();
let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
let kp = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
let rng = aws_lc_rs::rand::SystemRandom::new();
let pkcs8 = aws_lc_rs::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
let kp = aws_lc_rs::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
let key_str = format!(
"ed25519:{}",
base64::engine::general_purpose::STANDARD.encode(kp.public_key().as_ref())
+4 -2
View File
@@ -16,9 +16,11 @@ publish = false
[target.'cfg(target_os = "windows")'.dependencies]
# `Mode` (the negotiated display mode) is the core wire type; `pf-paths` for the pnp-disabled-monitors
# state file.
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
# Just `punktfunk_core::Mode` (win_display.rs), which lives in the ungated `config` module — the
# `quic` feature this used to request dragged quinn/tokio/rcgen/hmac/spake2/opus/rustls into a leaf
# crate's declared closure for one type.
punktfunk-core = { path = "../punktfunk-core", default-features = false }
pf-paths = { path = "../pf-paths" }
anyhow = "1"
tracing = "0.1"
# The pnp-disabled-monitors state file (a `Vec<String>` of instance ids) is serialized as JSON.
serde_json = "1"
+30 -8
View File
@@ -24,6 +24,12 @@ tls = ["dep:rustls", "dep:sha2", "dep:rustls-pki-types"]
# Control-plane QUIC (pairing, config, reverse audio). tokio is permitted ONLY here,
# never on the per-frame hot path. Off by default so the core stays runtime-free.
quic = ["tls", "dep:quinn", "dep:tokio", "dep:rcgen", "dep:hmac", "dep:spake2", "dep:opus"]
# Blocking-HTTP clients that must speak the SAME pinned TLS as the QUIC plane: `tls::ureq_agent`
# hands ureq a caller-built `rustls::ClientConfig` (which is how `PinVerify` gets installed —
# ureq's own `TlsConfig` has no hook for a custom verifier). Separate from `tls` because the
# cdylib/staticlib embedders (Apple, Android) pin the host themselves over QUIC and have no use
# for an HTTP stack; only the desktop clients and the tray turn this on.
ureq-tls = ["tls", "dep:ureq"]
[dependencies]
reed-solomon-simd = "3.1" # GF(2^16) Leopard-RS, SIMD, O(n log n) — the wall-breaker (P2)
@@ -39,7 +45,6 @@ aes-gcm = "0.10" # AES-128-GCM session crypto, matches GameStream
# cross-compiles like aes-gcm (no cmake). See design/chacha20-session-cipher.md.
chacha20poly1305 = "0.10"
zerocopy = { version = "0.8", features = ["derive"] }
bytes = "1"
socket2 = { version = "0.6", features = [
"all",
] } # SO_SNDBUF/SO_RCVBUF growth (default UDP buffers too small for 4K/5K bursts) + DSCP/SO_PRIORITY media QoS
@@ -52,14 +57,31 @@ zeroize = "1"
# not just the default route. Tiny, cross-platform (getifaddrs / GetAdaptersAddresses), no cmake.
if-addrs = "0.13"
quinn = { version = "0.11", optional = true }
rustls = { version = "0.23", optional = true, default-features = false, features = ["ring", "std"] }
# Crypto backend pinned to `ring` (matching rustls/quinn above) so the whole quic tree is
# ring-only: no aws-lc-rs/aws-lc-sys (heavy C dep, needs cmake) is pulled in. Keeps the
# Android/iOS cdylib lean and the cross-compile cmake-free. `generate_simple_self_signed`
# is backend-agnostic, so the swap is transparent.
rcgen = { version = "0.13", optional = true, default-features = false, features = ["ring", "pem"] }
# Crypto backend is aws-lc-rs, and rustls/quinn/rcgen must all name it: they each select a
# backend independently, so one dissenter pulls a SECOND crypto stack in via feature unification.
# `prefer-post-quantum` puts the X25519MLKEM768 hybrid key exchange first in the TLS 1.3
# handshake, which is the reason the old `ring` pin is gone — ring has no ML-KEM.
# Windows needs no NASM: rustls's `aws_lc_rs` feature enables `aws-lc-rs/prebuilt-nasm`.
# quinn's feature list is its own default set with `rustls-ring` swapped out, nothing more.
quinn = { version = "0.11", optional = true, default-features = false, features = [
"log",
"platform-verifier",
"runtime-tokio",
"rustls-aws-lc-rs",
"bloom",
] }
rustls = { version = "0.23", optional = true, default-features = false, features = ["aws_lc_rs", "prefer-post-quantum", "std"] }
# `generate_simple_self_signed` is backend-agnostic, so the swap is transparent here.
rcgen = { version = "0.13", optional = true, default-features = false, features = ["aws_lc_rs", "pem"] }
rustls-pki-types = { version = "1", optional = true }
# `rustls-no-provider`, NOT the default `rustls` feature — ureq's `rustls` feature body pulls
# `_ring`, which would drag the whole ring backend back into a tree that has deliberately moved to
# aws-lc-rs. `rustls-webpki-roots` supplies the CA set for the non-pinned origins (cover-art CDNs).
ureq = { version = "3", optional = true, default-features = false, features = [
"rustls-no-provider",
"rustls-webpki-roots",
"gzip",
] }
sha2 = { version = "0.10", optional = true }
hmac = { version = "0.12", optional = true }
spake2 = { version = "0.4", optional = true }
+5 -5
View File
@@ -176,7 +176,7 @@ fn server_from_der(
addr: std::net::SocketAddr,
idle: std::time::Duration,
) -> anyhow_result::Result<quinn::Endpoint> {
let _ = rustls::crypto::ring::default_provider().install_default();
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
// Client auth is OFFERED but optional: a client that presents its self-signed
// identity is fingerprinted post-handshake (pairing / --require-pairing checks);
// one that presents none still connects (and is rejected at the app layer when
@@ -254,7 +254,7 @@ pub fn client_pinned_with_identity(
) -> PinnedClient {
let observed = Arc::new(Mutex::new(None));
let ep = (|| {
let _ = rustls::crypto::ring::default_provider().install_default();
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let builder = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(crate::tls::PinVerify::with_observed(
@@ -354,7 +354,7 @@ impl rustls::server::danger::ClientCertVerifier for AcceptAnyClientCert {
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
&rustls::crypto::aws_lc_rs::default_provider().signature_verification_algorithms,
)
}
@@ -368,12 +368,12 @@ impl rustls::server::danger::ClientCertVerifier for AcceptAnyClientCert {
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
&rustls::crypto::aws_lc_rs::default_provider().signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
rustls::crypto::ring::default_provider()
rustls::crypto::aws_lc_rs::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
+22 -3
View File
@@ -8,6 +8,25 @@
use std::sync::{Arc, Mutex};
/// A blocking HTTP agent over a caller-built `rustls::ClientConfig` — the only way to give an
/// HTTP client the [`PinVerify`] verifier below.
#[cfg(feature = "ureq-tls")]
pub mod ureq_agent;
/// Install aws-lc-rs as this process's rustls provider. Call once, early, from `main`.
///
/// aws-lc-rs is currently the ONLY backend in the tree, so rustls can infer it and this call is
/// belt-and-braces rather than load-bearing. It is kept because the inference is what breaks
/// first: the moment any dependency drags a second backend in — which is exactly what `ureq 2`
/// used to do, naming `rustls/ring` in its own dependency line where no dependent could switch it
/// off — rustls stops guessing, and every config built through `ClientConfig::builder()` rather
/// than `builder_with_provider` **panics** instead of picking one. Calling this makes the choice
/// explicit and survives that. Idempotent: losing the race to another installer is the expected
/// outcome, not an error, since every caller in this workspace installs the same provider.
pub fn install_default_provider() {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
}
/// SHA-256 of a certificate's DER encoding — the fingerprint clients pin. Re-exported as
/// `crate::quic::endpoint::cert_fingerprint` for callers that already reach it there.
pub fn cert_fingerprint(cert_der: &[u8]) -> [u8; 32] {
@@ -91,7 +110,7 @@ impl rustls::client::danger::ServerCertVerifier for PinVerify {
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
&rustls::crypto::aws_lc_rs::default_provider().signature_verification_algorithms,
)
}
@@ -105,12 +124,12 @@ impl rustls::client::danger::ServerCertVerifier for PinVerify {
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
&rustls::crypto::aws_lc_rs::default_provider().signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
rustls::crypto::ring::default_provider()
rustls::crypto::aws_lc_rs::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
+116
View File
@@ -0,0 +1,116 @@
//! A blocking [`ureq::Agent`] that speaks TLS through a caller-supplied
//! [`rustls::ClientConfig`] — which is the only way to get [`PinVerify`](super::PinVerify) into an
//! HTTP client, because ureq's own `TlsConfig` exposes roots, a client cert and an
//! off-switch, but no hook for a custom [`ServerCertVerifier`](rustls::client::danger::ServerCertVerifier).
//!
//! Every caller here pins the host's self-signed leaf by fingerprint (the same trust rule as the
//! QUIC plane), so "just use the default agent" is not an option: the default agent validates
//! against webpki roots, which a self-signed host cert can never satisfy.
//!
//! The connector below is modelled on ureq 3.x's own (crate-private) `RustlsConnector` minus its
//! `TlsConfig`-driven config-building step. It is transport glue, not crypto: the handshake, the
//! verifier and the cipher suites all live in the `ClientConfig` the caller hands in.
use std::io::{Read as _, Write as _};
use std::sync::Arc;
use ureq::unversioned::resolver::DefaultResolver;
use ureq::unversioned::transport::{
Buffers, ConnectionDetails, Connector, Either, LazyBuffers, NextTimeout, TcpConnector,
Transport, TransportAdapter,
};
/// Build an agent whose HTTPS connections use `tls` verbatim, with `config` for everything else
/// (timeouts, redirect policy, buffer sizes) — built by the caller via
/// [`ureq::Agent::config_builder`], since those knobs differ per call site.
pub fn agent(tls: Arc<rustls::ClientConfig>, config: ureq::config::Config) -> ureq::Agent {
let connector = TcpConnector::default().chain(PinnedTlsConnector { config: tls });
ureq::Agent::with_parts(config, connector, DefaultResolver::default())
}
struct PinnedTlsConnector {
config: Arc<rustls::ClientConfig>,
}
impl std::fmt::Debug for PinnedTlsConnector {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PinnedTlsConnector").finish()
}
}
impl<In: Transport> Connector<In> for PinnedTlsConnector {
type Out = Either<In, PinnedTlsTransport>;
fn connect(
&self,
details: &ConnectionDetails,
chained: Option<In>,
) -> Result<Option<Self::Out>, ureq::Error> {
let Some(transport) = chained else {
// Unreachable via `agent()` above, which always chains onto a TcpConnector.
return Err(ureq::Error::Tls("no chained transport to wrap in TLS"));
};
// A plain-HTTP URL, or something that already negotiated TLS, passes straight through.
if !details.needs_tls() || transport.is_tls() {
return Ok(Some(Either::A(transport)));
}
let name: rustls::pki_types::ServerName<'_> = details
.uri
.authority()
.ok_or(ureq::Error::Tls("uri has no authority"))?
.host()
.try_into()
.map_err(|_| ureq::Error::Tls("invalid DNS name"))?;
let conn = rustls::ClientConnection::new(self.config.clone(), name.to_owned())?;
let stream = rustls::StreamOwned {
conn,
sock: TransportAdapter::new(transport.boxed()),
};
let buffers = LazyBuffers::new(
details.config.input_buffer_size(),
details.config.output_buffer_size(),
);
Ok(Some(Either::B(PinnedTlsTransport { buffers, stream })))
}
}
struct PinnedTlsTransport {
buffers: LazyBuffers,
stream: rustls::StreamOwned<rustls::ClientConnection, TransportAdapter>,
}
impl std::fmt::Debug for PinnedTlsTransport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PinnedTlsTransport").finish()
}
}
impl Transport for PinnedTlsTransport {
fn buffers(&mut self) -> &mut dyn Buffers {
&mut self.buffers
}
fn transmit_output(&mut self, amount: usize, timeout: NextTimeout) -> Result<(), ureq::Error> {
self.stream.get_mut().set_timeout(timeout);
let output = &self.buffers.output()[..amount];
self.stream.write_all(output)?;
Ok(())
}
fn await_input(&mut self, timeout: NextTimeout) -> Result<bool, ureq::Error> {
self.stream.get_mut().set_timeout(timeout);
let input = self.buffers.input_append_buf();
let amount = self.stream.read(input)?;
self.buffers.input_appended(amount);
Ok(amount > 0)
}
fn is_open(&mut self) -> bool {
self.stream.get_mut().get_mut().is_open()
}
fn is_tls(&self) -> bool {
true
}
}
+44 -57
View File
@@ -48,7 +48,15 @@ pf-vdisplay = { path = "../pf-vdisplay" }
# compiles everywhere; the backends are cfg-gated inside it.
pf-clipboard = { path = "../pf-clipboard" }
# M3 native control plane (the `punktfunk/1` QUIC handshake; data plane stays native-thread UDP).
quinn = "0.11"
# Feature list = quinn's own defaults with `rustls-ring` swapped for `rustls-aws-lc-rs`; every
# crate selecting a rustls backend must agree or feature unification builds both (see core).
quinn = { version = "0.11", default-features = false, features = [
"log",
"platform-verifier",
"runtime-tokio",
"rustls-aws-lc-rs",
"bloom",
] }
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
@@ -66,7 +74,6 @@ mdns-sd = "0.20"
mac_address = "1"
if-addrs = "0.13"
tokio = { version = "1", features = ["full"] }
parking_lot = "0.12"
# GameStream-only (behind the `gamestream` feature): the Moonlight RSA-2048 identity generator +
# pairing signer (cert.rs, pairing.rs) and the legacy-client-cert leniency verifier (tls.rs).
# The native planes use the P-256 identity (src/identity.rs) and never touch this crate — so a
@@ -85,21 +92,28 @@ base64 = "0.22"
# run on a background thread off the hot path. `ureq` is small + sync (no tokio here) and bundles
# webpki roots (no system cert dependency). Cross-platform so the fetch/parse code is compiled +
# checked everywhere even though only the Windows GOG/Xbox providers need it today.
ureq = "2"
rcgen = { version = "0.13", default-features = false, features = ["ring", "pem"] }
# ⚠ `rustls-no-provider`, NEVER the default `rustls` feature — that one pulls `_ring`, which would
# put the ring backend back into a tree that has deliberately moved to aws-lc-rs.
ureq = { version = "3", default-features = false, features = [
"rustls-no-provider",
"rustls-webpki-roots",
"gzip",
] }
rcgen = { version = "0.13", default-features = false, features = ["aws_lc_rs", "pem"] }
x509-parser = "0.16"
# Only used for the plain-HTTP nvhttp listener (`bind().serve()`); HTTPS/mTLS is hand-rolled over
# tokio-rustls (axum-server can't surface the peer cert), so we do NOT enable `tls-rustls` — that
# feature is what pulled the unmaintained `rustls-pemfile` (security-review dep hygiene).
axum-server = "0.8"
# Ring backend, NOT the (default) aws-lc-rs one — matches punktfunk-core + the client so the whole
# tree stays ring-only (no aws-lc-sys: a heavy C dep that fails to build on the Windows CI runner).
# aws-lc-rs backend, matching punktfunk-core + the clients (a dissenting crate would pull a
# second crypto stack in). `prefer-post-quantum` offers X25519MLKEM768 first on the mgmt/native
# TLS 1.3 listeners; classical curves stay in the list, so a client without ML-KEM still connects.
# Keep `tls12` for GameStream/Moonlight clients that negotiate TLS 1.2.
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "prefer-post-quantum", "std", "tls12", "logging"] }
# Manual HTTPS+mTLS serve loop for the mgmt API (axum-server can't surface the peer cert): a
# tokio-rustls handshake exposes the client cert, then hyper serves the axum Router with the
# verified fingerprint injected as a request extension. Versions match the workspace lock.
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12", "logging"] }
tokio-rustls = { version = "0.26", default-features = false, features = ["aws_lc_rs", "tls12", "logging"] }
hyper = { version = "1", features = ["server", "http1", "http2"] }
hyper-util = { version = "0.1", features = ["server", "server-auto", "tokio", "service"] }
tower = { version = "0.5", features = ["util"] }
@@ -122,10 +136,12 @@ serde_json = "1"
# utoipa into axum 0.8 extractors; utoipa-axum collects `#[utoipa::path]` routes into the
# spec; utoipa-scalar serves the interactive docs. Codegen-friendly: the spec is emitted
# verbatim by the `openapi` subcommand. Control plane only — never the per-frame path.
# Plugin-store index signatures: ed25519 verification of a catalog document before any field of
# it is read (store/index.rs). Already in the tree via rustls — the workspace is ring-only, so this
# is the one signature primitive available without pulling aws-lc-sys (which fails on Windows CI).
ring = "0.17"
# SHA-256 over the downloaded installer and its signing-leaf DER (update/windows.rs) — Windows-only
# in practice, the plugin-store's ed25519 path re-exports pf-update-check's verifier. Already in the
# tree via rustls; its API is ring-compatible by design, which is what made the swap mechanical.
# `prebuilt-nasm`: see pf-update-check's copy — it keeps aws-lc-sys building on Windows x86_64
# with no NASM on the box, whatever else the build happens to select.
aws-lc-rs = { version = "1", features = ["prebuilt-nasm"] }
# Semver comparisons for the plugin store: `minHost` gating and the revocation list's version
# ranges (`<0.3.2`). Already in the lockfile transitively.
semver = "1"
@@ -134,8 +150,9 @@ utoipa-axum = "0.2"
utoipa-scalar = { version = "0.3", features = ["axum"] }
[dev-dependencies]
# Drive the management API router in-process (no socket) in the handler tests.
tower = { version = "0.5", features = ["util"] }
# (`tower` drives the management API router in-process in the handler tests, but it is already a
# normal dependency above and those are visible to tests — the dev-dependency re-declaration was
# redundant.)
http-body-util = "0.1"
# Disposable directory fixtures for the Steam local-librarycache scan tests (library.rs).
tempfile = "3"
@@ -157,51 +174,21 @@ libc = "0.2"
# Must match the pipewire crate ashpd 0.13 links (libspa/pipewire-sys `links` key is
# unique per build), i.e. 0.9 — NOT the 0.10 the setup doc mentions.
pipewire = "0.9"
# ashpd 0.13 uses the tokio runtime; a current-thread runtime drives the one-time
# portal handshake (control plane — never the per-frame path).
tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "time"] }
# Input injection into headless Sway via the wlroots virtual-input Wayland protocols
# (uinput won't reach a compositor running with WLR_LIBINPUT_NO_DEVICES=1).
wayland-client = "0.31"
wayland-protocols-wlr = { version = "0.3", features = ["client"] }
wayland-protocols-misc = { version = "0.3", features = ["client"] }
# `xdg-output` (zxdg_output_v1): the per-output *logical* geometry (post-scale size + global
# position), used by the KWin fake_input backend to map absolute coordinates under display scaling.
wayland-protocols = { version = "0.32", features = ["client"] }
# Codegen for KDE's `zkde_screencast_unstable_v1` (vendored in `protocols/`): create a KWin
# virtual output sized to the client's resolution and get its PipeWire node (KRdp's path).
# `wayland-backend` is referenced by the generated interface tables.
wayland-scanner = "0.31"
wayland-backend = "0.3"
# Parse `pw-dump` JSON to find gamescope's PipeWire node (gamescope backend).
serde_json = "1"
# (ashpd 0.13 drives the one-time portal handshake on tokio; the unconditional `tokio = "full"`
# in [dependencies] already covers that — this block used to re-declare a strict subset of it.)
# NOTE: the Wayland stack (wayland-client / -protocols{,-wlr,-misc} / -scanner / -backend),
# `xkbcommon`, `reis`, `khronos-egl`, `ash` and `usbip-sim` used to be declared here. The code that
# used them moved to `pf-inject` (virtual input, libei, USB/IP) and `pf-zerocopy` (EGL/Vulkan dmabuf)
# in the subsystem extraction, and each of those crates declares them itself — only the manifest
# entries were left behind, along with comments describing code this crate no longer contains.
# Verified unused before removal: zero `use`/path references across src/ + build.rs.
# Read the Lutris library DB (`pga.db`) for the Lutris store provider. `bundled` vendors + compiles
# SQLite (cc, already needed for ffmpeg/opus) so there's no system libsqlite3 runtime dependency —
# clean for the deb/rpm/flatpak packaging. Opened read-only/immutable (Lutris may hold it open).
rusqlite = { version = "0.40", features = ["bundled"] }
# Builds/validates the xkb keymap uploaded to the virtual keyboard + tracks modifier state.
xkbcommon = "0.8"
# libei (EI sender) for the portable input path on KWin/GNOME (RemoteDesktop portal).
# The `tokio` feature wires reis's event stream into tokio's reactor.
reis = { version = "0.6.1", features = ["tokio"] }
# `StreamExt::next` on reis's tokio event stream in the libei worker loop.
futures-util = "0.3"
# Zero-copy capture (plan §9): EGL imports the PipeWire dmabuf, CUDA maps it, NVENC encodes
# it with no CPU roundtrip. `khronos-egl` (dynamic = load the NVIDIA libEGL at runtime) gives
# eglCreateImage + the dma_buf import; the CUDA driver API (EGL interop) and libgbm are linked
# via hand-rolled FFI in `src/zerocopy/` (no Rust crate exposes the EGL-interop driver calls).
khronos-egl = { version = "6", features = ["dynamic"] }
# Vulkan bridge for LINEAR dmabufs (gamescope): import via VK_EXT_external_memory_dma_buf,
# GPU-copy into an exportable allocation, export OPAQUE_FD → cuImportExternalMemory (the
# officially-supported CUDA pairing; raw dmabuf fds are rejected by the desktop driver).
ash = "0.38"
# `libcuda.so.1` is dlopen'd at runtime (NOT link-time) so one Linux binary runs on NVIDIA
# (zero-copy via CUDA) AND on AMD/Intel (VAAPI, no NVIDIA driver present) — see `zerocopy::cuda`.
libloading = "0.8"
# Vendored + trimmed `usbip` server core (no libusb) — presents a virtual Steam Deck over USB/IP
# so the local `vhci_hcd` attaches it: the shippable, Secure-Boot-clean, Steam-Input-promotable
# virtual-Deck transport on non-SteamOS hosts (`inject/linux/steam_usbip.rs`). See the crate's NOTICE.
usbip-sim = { path = "vendor/usbip-sim" }
[target.'cfg(target_os = "windows")'.dependencies]
# Windows host backends. `windows` covers the Win32/CCD APIs the SudoVDA virtual-display backend
@@ -300,12 +287,12 @@ winreg = "0.56"
roxmltree = "0.21"
# WASAPI loopback audio capture (default render endpoint -> 48 kHz stereo f32 for the Opus path).
wasapi = "0.23"
# Shared host<->driver wire contract for the pf-vdisplay IddCx virtual-display backend
# (vdisplay/pf_vdisplay.rs): the control-plane IOCTL codes + `#[repr(C)] Pod` request/reply structs,
# defined ONCE so host<->driver ABI drift is a compile error. `bytemuck` serializes those structs
# to/from the DeviceIoControl byte buffers.
# Shared host<->driver wire contract for the pf-vdisplay IddCx virtual-display backend: the
# control-plane IOCTL codes + `#[repr(C)] Pod` request/reply structs, defined ONCE so host<->driver
# ABI drift is a compile error (used from `capture.rs`). The `bytemuck` that serializes those
# structs into the DeviceIoControl buffers is pf-vdisplay's dependency, not this crate's — it was
# declared here too, unused.
pf-driver-proto = { path = "../pf-driver-proto" }
bytemuck = { version = "1.19", features = ["derive"] }
# The encode feature flags now FORWARD to the pf-encode subsystem crate (the heavy encoder deps —
# ffmpeg-next, the NVENC SDK, openh264, pyrowave-sys — moved there, plan §W6). Selecting a feature
+1 -1
View File
@@ -459,7 +459,7 @@ pub fn serve(
let rt = tokio::runtime::Runtime::new().context("build tokio runtime")?;
rt.block_on(async move {
// rustls needs a process-wide crypto provider before any TLS config is built.
let _ = rustls::crypto::ring::default_provider().install_default();
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let native_opts = crate::native::native_serve_opts(&native);
// The hook runner consumes the live event tail for the host's lifetime — spawned BEFORE
// `host.started` is emitted so operator hooks observe the full lifecycle (RFC §6).
+1 -1
View File
@@ -292,7 +292,7 @@ fn build_server_config(
key_pem: &str,
mandatory: bool,
) -> Result<Arc<ServerConfig>> {
let provider = Arc::new(rustls::crypto::ring::default_provider());
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
// PEM parsing via rustls-pki-types (the same `PemObject` path punktfunk-core/quic.rs uses),
// so we don't pull the unmaintained `rustls-pemfile`.
let certs = CertificateDer::pem_slice_iter(cert_pem.as_bytes())
+10 -9
View File
@@ -811,11 +811,12 @@ fn webhook_host_is_internal(url: &str) -> bool {
fn post_webhook(url: &str, json: &str, secret_file: Option<&std::path::Path>) {
// TLS is verified (ureq's default rustls roots); redirects are never followed, so a
// compromised receiver can't bounce the POST cross-origin (RFC §9.5).
let agent = ureq::builder()
.redirects(0)
.timeout(WEBHOOK_TIMEOUT)
.build();
let mut req = agent.post(url).set("Content-Type", "application/json");
let agent: ureq::Agent = ureq::Agent::config_builder()
.max_redirects(0)
.timeout_global(Some(WEBHOOK_TIMEOUT))
.build()
.into();
let mut req = agent.post(url).header("Content-Type", "application/json");
if let Some(path) = secret_file {
match std::fs::read(path) {
Ok(secret) => {
@@ -829,7 +830,7 @@ fn post_webhook(url: &str, json: &str, secret_file: Option<&std::path::Path>) {
};
mac.update(json.as_bytes());
let sig = hex::encode(mac.finalize().into_bytes());
req = req.set("X-Punktfunk-Signature", &format!("sha256={sig}"));
req = req.header("X-Punktfunk-Signature", &format!("sha256={sig}"));
}
Err(e) => {
// A configured-but-unreadable secret means the operator WANTS signing —
@@ -840,9 +841,9 @@ fn post_webhook(url: &str, json: &str, secret_file: Option<&std::path::Path>) {
}
}
}
match req.send_string(json) {
Ok(resp) => tracing::debug!(url, status = resp.status(), "webhook delivered"),
Err(ureq::Error::Status(code, _)) => {
match req.send(json) {
Ok(resp) => tracing::debug!(url, status = resp.status().as_u16(), "webhook delivered"),
Err(ureq::Error::StatusCode(code)) => {
tracing::warn!(url, status = code, "webhook rejected by receiver")
}
Err(e) => tracing::warn!(url, error = %e, "webhook delivery failed"),
+27 -16
View File
@@ -86,14 +86,21 @@ fn warm_art_once() {
/// HTTP GET + parse JSON with a bounded timeout. `None` on any network/parse failure (best-effort —
/// art is non-essential, so a failure just leaves the title-only card).
fn fetch_json(url: &str) -> Option<serde_json::Value> {
let agent = ureq::AgentBuilder::new()
.timeout(std::time::Duration::from_secs(10))
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(std::time::Duration::from_secs(10)))
// Don't follow redirects — a redirect target (`3xx` → `http://169.254.169.254/…` or an
// internal host) would be an SSRF pivot from the privileged host. Matches the webhook path
// (security-review 2026-07-17). A rare legitimately-redirecting CDN just yields no art.
.redirects(0)
.build();
let body = agent.get(url).call().ok()?.into_string().ok()?;
.max_redirects(0)
.build()
.into();
let body = agent
.get(url)
.call()
.ok()?
.body_mut()
.read_to_string()
.ok()?;
serde_json::from_str(&body).ok()
}
@@ -103,7 +110,6 @@ fn fetch_json(url: &str) -> Option<serde_json::Value> {
/// network/decoder error, or empty body. Blocking (ureq) — call off the async runtime.
pub(crate) fn fetch_image(url: &str) -> Option<(Vec<u8>, String)> {
use base64::Engine as _;
use std::io::Read as _;
if let Some(rest) = url.strip_prefix("data:") {
// data:[<mediatype>][;base64],<payload>
let (meta, data) = rest.split_once(',')?;
@@ -125,22 +131,27 @@ pub(crate) fn fetch_image(url: &str) -> Option<(Vec<u8>, String)> {
if !(url.starts_with("http://") || url.starts_with("https://")) {
return None;
}
let agent = ureq::AgentBuilder::new()
.timeout(std::time::Duration::from_secs(10))
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(std::time::Duration::from_secs(10)))
// Don't follow redirects (SSRF pivot): this is called on launcher-cache- and custom-entry-
// supplied URLs, so a `3xx` to an internal/metadata endpoint must not be chased by the
// privileged host. Matches the webhook path (security-review 2026-07-17).
.redirects(0)
.build();
let resp = agent.get(url).call().ok()?;
.max_redirects(0)
.build()
.into();
let mut resp = agent.get(url).call().ok()?;
let ctype = resp
.header("Content-Type")
.headers()
.get("Content-Type")
.and_then(|v| v.to_str().ok())
.unwrap_or("image/jpeg")
.to_string();
let mut bytes = Vec::new();
resp.into_reader()
.take(8 * 1024 * 1024)
.read_to_end(&mut bytes)
// The 8 MiB cap is now the body reader's own limit rather than a `take()` on the stream.
let bytes = resp
.body_mut()
.with_config()
.limit(8 * 1024 * 1024)
.read_to_vec()
.ok()?;
(!bytes.is_empty()).then_some((bytes, ctype))
}
@@ -32,7 +32,6 @@
//! itself would land it outside the captured session and outside that lifetime.
use super::*;
use std::io::Read;
use std::time::Duration;
/// The whole ask, end to end. A plugin resolving one of its own entries is a local lookup against
@@ -96,23 +95,26 @@ pub fn ask_plugin_launch(plugin: &str, key: &str) -> Option<PluginLaunch> {
);
return None;
};
let agent = ureq::AgentBuilder::new().timeout(ASK_TIMEOUT).build();
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(ASK_TIMEOUT))
.build()
.into();
// Loopback + the plugin's own per-boot secret, exactly what the console proxy presents. The
// registration stores a PORT, never an address (mgmt::plugins D5), so this can only ever dial
// this machine.
// `send_string` + an explicit content type rather than `send_json`: that one needs ureq's `json`
// `send` with an explicit content type rather than `send_json`: that one needs ureq's `json`
// feature, and the body is one field.
let body = serde_json::json!({ "entry": key }).to_string();
let resp = match agent
.post(&format!("http://127.0.0.1:{}/__launch", cred.port))
.set("Authorization", &format!("Bearer {}", cred.secret))
.set("Content-Type", "application/json")
.send_string(&body)
.header("Authorization", &format!("Bearer {}", cred.secret))
.header("Content-Type", "application/json")
.send(&body)
{
Ok(r) => r,
// A plugin that does not know the entry says so with a 404 — the answer a FORGED entry gets,
// and the reason planting one is not enough to make the host run anything.
Err(ureq::Error::Status(404, _)) => {
Err(ureq::Error::StatusCode(404)) => {
tracing::warn!(
plugin,
entry = key,
@@ -120,7 +122,7 @@ pub fn ask_plugin_launch(plugin: &str, key: &str) -> Option<PluginLaunch> {
);
return None;
}
Err(ureq::Error::Status(code, _)) => {
Err(ureq::Error::StatusCode(code)) => {
tracing::warn!(
plugin,
entry = key,
@@ -139,15 +141,20 @@ pub fn ask_plugin_launch(plugin: &str, key: &str) -> Option<PluginLaunch> {
return None;
}
};
let mut buf = Vec::new();
if let Err(e) = resp
.into_reader()
.take((MAX_BODY + 1) as u64)
.read_to_end(&mut buf)
let mut resp = resp;
// cap+1 so an over-cap answer is caught by the length check below rather than truncated.
let buf = match resp
.body_mut()
.with_config()
.limit((MAX_BODY + 1) as u64)
.read_to_vec()
{
tracing::warn!(plugin, entry = key, error = %e, "plugin launch: reading the answer failed");
return None;
}
Ok(b) => b,
Err(e) => {
tracing::warn!(plugin, entry = key, error = %e, "plugin launch: reading the answer failed");
return None;
}
};
if buf.len() > MAX_BODY {
tracing::warn!(
plugin,
@@ -226,7 +233,7 @@ fn validate_reply(plugin: &str, key: &str, reply: LaunchReply) -> Option<PluginL
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use std::io::{Read, Write};
/// A one-shot HTTP/1.1 stub on an ephemeral loopback port. Returns the port and a handle that
/// yields the raw request text — so the assertions about what the HOST sent (method, path,
@@ -274,13 +281,27 @@ mod tests {
#[test]
fn asks_the_registered_plugin_and_takes_its_answer() {
let (port, server) =
stub_plugin(200, r#"{"command":"retroarch 'smw.sfc'","cwd":"/opt/emu"}"#);
// The cwd has to be absolute FOR THE HOST PLATFORM: `/opt/emu` has no drive letter, so
// `Path::is_absolute` is false on Windows and `validate_reply` refuses the recipe — this
// test could never pass there. Same split as `a_working_directory_must_be_absolute`.
// (The `\\` is JSON escaping; the decoded value is `C:\emu`.)
let (answer, cwd) = if cfg!(windows) {
(
r#"{"command":"retroarch 'smw.sfc'","cwd":"C:\\emu"}"#,
r"C:\emu",
)
} else {
(
r#"{"command":"retroarch 'smw.sfc'","cwd":"/opt/emu"}"#,
"/opt/emu",
)
};
let (port, server) = stub_plugin(200, answer);
crate::mgmt::register_ui_for_test("stub-launcher", port, "s3cr3t");
let got = ask_plugin_launch("stub-launcher", "snes/smw.sfc").expect("a recipe");
assert_eq!(got.command, "retroarch 'smw.sfc'");
assert_eq!(got.cwd.as_deref(), Some(std::path::Path::new("/opt/emu")));
assert_eq!(got.cwd.as_deref(), Some(std::path::Path::new(cwd)));
let req = server.join().expect("stub thread");
assert!(req.starts_with("POST /__launch "), "request was {req:?}");
+3
View File
@@ -148,6 +148,9 @@ use spike::{Options, Source};
use std::path::PathBuf;
fn main() {
// Before anything can reach an HTTPS call (the cover-art warmer, webhooks, the plugin-store
// catalog, the update downloader all build default `ureq` agents).
punktfunk_core::tls::install_default_provider();
let filter =
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into());
// `service run` is launched by the SCM with no console — log to a file instead of stderr.
+44 -19
View File
@@ -14,7 +14,6 @@
use super::index::{Index, MAX_INDEX_BYTES};
use super::sources::Source;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::time::Duration;
@@ -41,19 +40,20 @@ pub(crate) fn fetch(source: &Source, etag: Option<&str>) -> Fetched {
if !source.url.starts_with("https://") {
return Fetched::Failed("source url must be https".into());
}
let agent = ureq::AgentBuilder::new()
.timeout(FETCH_TIMEOUT)
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(FETCH_TIMEOUT))
// A signed document doesn't need many hops to reach us; a redirect chain is a good way to
// waste a host's time.
.redirects(3)
.user_agent(&format!("punktfunk-host/{}", super::index::host_version()))
.build();
.max_redirects(3)
.user_agent(format!("punktfunk-host/{}", super::index::host_version()))
.build()
.into();
let mut req = agent.get(&source.url);
if let Some(tag) = etag {
req = req.set("If-None-Match", tag);
req = req.header("If-None-Match", tag);
}
let resp = match req.call() {
let mut resp = match req.call() {
// `ureq` only turns status >= 400 into `Err(Status)`, so a conditional request's 304
// arrives here as **Ok with an empty body** — not as an error. Reading it as an error arm
// (the intuitive reading) means every refresh after the first one verifies a signature
@@ -61,13 +61,17 @@ pub(crate) fn fetch(source: &Source, etag: Option<&str>) -> Fetched {
// never picking up a new entry. Found on-glass; pinned by `ureq_returns_304_as_ok`.
Ok(r) if r.status() == 304 => return Fetched::NotModified,
Ok(r) => r,
Err(ureq::Error::Status(code, _)) => {
Err(ureq::Error::StatusCode(code)) => {
return Fetched::Failed(format!("index fetch returned HTTP {code}"))
}
Err(e) => return Fetched::Failed(format!("index fetch failed: {e}")),
};
let new_etag = resp.header("etag").map(str::to_string);
let body = match read_capped(resp) {
let new_etag = resp
.headers()
.get("etag")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let body = match read_capped(&mut resp) {
Ok(b) => b,
Err(e) => return Fetched::Failed(e),
};
@@ -76,7 +80,7 @@ pub(crate) fn fetch(source: &Source, etag: Option<&str>) -> Fetched {
let keys = source.keys();
if !keys.is_empty() {
let sig = match agent.get(&source.sig_url()).call() {
Ok(r) => match read_capped(r) {
Ok(mut r) => match read_capped(&mut r) {
Ok(b) => b,
Err(e) => return Fetched::Failed(format!("signature: {e}")),
},
@@ -105,11 +109,16 @@ pub(crate) fn fetch(source: &Source, etag: Option<&str>) -> Fetched {
}
/// Read a response body, refusing anything past the cap without buffering it.
fn read_capped(resp: ureq::Response) -> Result<Vec<u8>, String> {
let mut buf = Vec::new();
resp.into_reader()
.take((MAX_INDEX_BYTES + 1) as u64)
.read_to_end(&mut buf)
fn read_capped(resp: &mut ureq::http::Response<ureq::Body>) -> Result<Vec<u8>, String> {
// Limit is cap+1 so a body of exactly cap+1 comes back intact and is rejected by the length
// check below with our own message; anything larger trips ureq's own limit error. Either way it
// is an Err — unlike ureq 2's `take()`, which truncated silently and then failed the signature
// check with a message that pointed at the wrong thing.
let buf = resp
.body_mut()
.with_config()
.limit((MAX_INDEX_BYTES + 1) as u64)
.read_to_vec()
.map_err(|e| format!("reading the response body failed: {e}"))?;
if buf.len() > MAX_INDEX_BYTES {
return Err(format!("response exceeds the {MAX_INDEX_BYTES}-byte cap"));
@@ -257,18 +266,34 @@ mod tests {
/// on someone's host.
#[test]
fn ureq_returns_304_as_ok() {
use std::io::Write as _;
use std::io::{Read as _, Write as _};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let server = std::thread::spawn(move || {
if let Ok((mut sock, _)) = listener.accept() {
// Drain the request BEFORE answering. Closing a socket that still has unread
// received data makes Windows send an RST rather than a FIN, which destroys the
// response we just wrote — the client then sees a transport error (os error 10053)
// instead of the 304 this test exists to pin. A GET has no body, so the header
// terminator is the whole request.
let mut buf = Vec::new();
let mut chunk = [0u8; 1024];
while let Ok(n) = sock.read(&mut chunk) {
if n == 0 {
break;
}
buf.extend_from_slice(&chunk[..n]);
if buf.windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
}
let _ = sock.write_all(b"HTTP/1.1 304 Not Modified\r\nETag: \"x\"\r\n\r\n");
let _ = sock.flush();
}
});
let resp = ureq::get(&format!("http://{addr}/index.json"))
.set("If-None-Match", "\"x\"")
.header("If-None-Match", "\"x\"")
.call();
let _ = server.join();
+15 -13
View File
@@ -26,7 +26,7 @@ use super::index::{scope_of, Entry};
use super::manifest::{self, Record, Tier};
use anyhow::{bail, Context, Result};
use std::collections::VecDeque;
use std::io::{BufRead, BufReader, Read};
use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio};
use std::sync::Mutex;
use std::time::{Duration, Instant};
@@ -564,25 +564,27 @@ fn registry_integrity(registry: &str, pkg: &str, version: &str) -> Result<String
let base = registry.trim_end_matches('/');
// npm registry convention: the scope separator is percent-encoded in the packument path.
let url = format!("{base}/{}", pkg.replace('/', "%2f"));
let agent = ureq::AgentBuilder::new()
.timeout(Duration::from_secs(20))
.redirects(3)
.user_agent(&format!("punktfunk-host/{}", super::index::host_version()))
.build();
let resp = agent
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_secs(20)))
.max_redirects(3)
.user_agent(format!("punktfunk-host/{}", super::index::host_version()))
.build()
.into();
let mut resp = agent
.get(&url)
.set("Accept", "application/json")
.header("Accept", "application/json")
.call()
.map_err(|e| match e {
ureq::Error::Status(404, _) => {
ureq::Error::StatusCode(404) => {
anyhow::anyhow!("the registry does not know this package")
}
other => anyhow::anyhow!("registry request failed: {other}"),
})?;
let mut body = Vec::new();
resp.into_reader()
.take(16 * 1024 * 1024)
.read_to_end(&mut body)
let body = resp
.body_mut()
.with_config()
.limit(16 * 1024 * 1024)
.read_to_vec()
.context("read the registry response")?;
let doc: serde_json::Value =
serde_json::from_slice(&body).context("registry returned invalid JSON")?;
+20 -11
View File
@@ -168,27 +168,34 @@ fn download(url: &str, part: &Path, progress: &dyn Fn(u64, Option<u64>)) -> Resu
if !url.starts_with("https://") {
return Err("installer url must be https".into());
}
let agent = ureq::AgentBuilder::new()
.timeout_connect(std::time::Duration::from_secs(15))
.redirects(3)
.user_agent(&format!(
// Connect timeout only, deliberately no global one: this streams an installer that is tens of
// MB, and a whole-request deadline would abort a slow-but-healthy download.
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_connect(Some(std::time::Duration::from_secs(15)))
.max_redirects(3)
.user_agent(format!(
"punktfunk-host/{} (update-apply)",
env!("PUNKTFUNK_VERSION")
))
.build();
.build()
.into();
let existing = std::fs::metadata(part).map(|m| m.len()).unwrap_or(0);
let mut req = agent.get(url);
if existing > 0 {
req = req.set("Range", &format!("bytes={existing}-"));
req = req.header("Range", &format!("bytes={existing}-"));
}
let resp = req.call().map_err(|e| match e {
ureq::Error::Status(code, _) => format!("download returned HTTP {code}"),
ureq::Error::StatusCode(code) => format!("download returned HTTP {code}"),
other => format!("download failed: {other}"),
})?;
let resumed = resp.status() == 206;
let content_len: Option<u64> = resp.header("content-length").and_then(|v| v.parse().ok());
let content_len: Option<u64> = resp
.headers()
.get("content-length")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse().ok());
let total = content_len.map(|l| if resumed { existing + l } else { l });
if let Some(t) = total {
preflight_disk(part, t.saturating_mul(DISK_MARGIN))?;
@@ -211,7 +218,9 @@ fn download(url: &str, part: &Path, progress: &dyn Fn(u64, Option<u64>)) -> Resu
};
progress(received, total);
let mut reader = resp.into_reader();
// Unlimited reader, not `read_to_vec` — the installer is streamed to disk in 64 KiB chunks so
// it never lands in memory, and ureq 3's body-read caps do not apply to this path.
let mut reader = resp.into_body().into_reader();
let mut buf = [0u8; 64 * 1024];
loop {
let n = reader.read(&mut buf).map_err(|e| format!("read: {e}"))?;
@@ -234,7 +243,7 @@ fn download(url: &str, part: &Path, progress: &dyn Fn(u64, Option<u64>)) -> Resu
fn verify_sha256(path: &Path, expected_hex: &str) -> Result<(), String> {
let mut file = std::fs::File::open(path).map_err(|e| format!("open for hashing: {e}"))?;
let mut ctx = ring::digest::Context::new(&ring::digest::SHA256);
let mut ctx = aws_lc_rs::digest::Context::new(&aws_lc_rs::digest::SHA256);
let mut buf = [0u8; 128 * 1024];
loop {
let n = file
@@ -366,7 +375,7 @@ pub(crate) fn verify_authenticode(path: &Path, pins: &[String]) -> Result<(), St
// cert context above; the slice is consumed (hashed) before the state is closed.
let der =
unsafe { std::slice::from_raw_parts(leaf.pbCertEncoded, leaf.cbCertEncoded as usize) };
let fp = hex(ring::digest::digest(&ring::digest::SHA256, der).as_ref());
let fp = hex(aws_lc_rs::digest::digest(&aws_lc_rs::digest::SHA256, der).as_ref());
if !pins.iter().any(|p| p.eq_ignore_ascii_case(&fp)) {
return Err(format!(
"installer signing-leaf fingerprint {fp} matches none of the manifest's \
+12 -9
View File
@@ -22,17 +22,20 @@ anyhow = "1"
[target.'cfg(any(windows, target_os = "linux"))'.dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Loopback HTTPS poll of GET /api/v1/local/summary. Same sync ureq + rustls(ring) stack and
# custom-verifier pattern as the Linux client's library fetch (crates/pf-client-core/src/library.rs)
# but ring-only (no default aws-lc-rs provider: it needs a C toolchain per target and the agent
# pins the ring provider explicitly anyway).
ureq = { version = "2", default-features = false, features = ["tls"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
# Loopback HTTPS poll of GET /api/v1/local/summary. Same sync ureq + rustls stack and
# custom-verifier pattern as the Linux client's library fetch (crates/pf-client-core/src/library.rs),
# on the same aws-lc-rs backend as the rest of the tree (the agent pins the provider explicitly).
# ⚠ `rustls-no-provider`, NEVER the default `rustls` feature — that one pulls `_ring`.
ureq = { version = "3", default-features = false, features = [
"rustls-no-provider",
"rustls-webpki-roots",
] }
rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "prefer-post-quantum", "logging", "std", "tls12"] }
# The one shared cert-fingerprint pin verifier (`punktfunk_core::tls::PinVerify`) + fingerprint
# hash, instead of the tray hand-rolling its own copy on a trust boundary. The light `tls` feature
# is rustls + sha2 only (no QUIC runtime / tokio), so this stays a lean helper; core is a pure-Rust
# leaf (no C toolchain), unlike the host dependency ruled out above.
punktfunk-core = { path = "../punktfunk-core", default-features = false, features = ["tls"] }
# is rustls + sha2 only (no QUIC runtime / tokio), so this stays a lean helper — much smaller than
# the host dependency ruled out above, though rustls's aws-lc-rs backend does mean a C compiler.
punktfunk-core = { path = "../punktfunk-core", default-features = false, features = ["tls", "ureq-tls"] }
[target.'cfg(windows)'.dependencies]
# SCM QUERY_STATUS works unprivileged — the service-state probe. Same crate the host service uses.
+4
View File
@@ -79,6 +79,10 @@ fn parse_args() -> anyhow::Result<Args> {
}
fn main() -> anyhow::Result<()> {
// punktfunk-core is a Windows/Linux-only dependency here (the macOS build is a stub), so the
// provider install follows the same cfg as `run`.
#[cfg(any(windows, target_os = "linux"))]
punktfunk_core::tls::install_default_provider();
let args = parse_args()?;
run(args)
}
+23 -13
View File
@@ -254,7 +254,7 @@ fn poll_loop(
fn probe_console(agent: &ureq::Agent, url: &str) -> bool {
match agent.get(url).call() {
Ok(_) => true,
Err(ureq::Error::Status(..)) => true,
Err(ureq::Error::StatusCode(..)) => true,
Err(_) => false,
}
}
@@ -262,7 +262,13 @@ fn probe_console(agent: &ureq::Agent, url: &str) -> bool {
// ── Summary fetch (loopback HTTPS) ──────────────────────────────────────────────────────────────
fn fetch_summary(agent: &ureq::Agent, url: &str) -> Option<Summary> {
let body = agent.get(url).call().ok()?.into_string().ok()?;
let body = agent
.get(url)
.call()
.ok()?
.body_mut()
.read_to_string()
.ok()?;
serde_json::from_str(&body).ok()
}
@@ -310,25 +316,29 @@ pub fn punktfunk_config_dir() -> Option<std::path::PathBuf> {
None
}
/// A sync HTTPS agent over the same rustls(ring) stack the rest of the workspace uses, with a
/// A sync HTTPS agent over the same rustls(aws-lc-rs) stack the rest of the workspace uses, with a
/// pin-or-accept-any verifier (the Linux client's `PinVerify` pattern, `library.rs`).
fn agent(pin: Option<[u8; 32]>) -> ureq::Agent {
let provider = Arc::new(rustls::crypto::ring::default_provider());
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
let cfg = rustls::ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.expect("rustls default protocol versions")
.dangerous()
.with_custom_certificate_verifier(Arc::new(punktfunk_core::tls::PinVerify::new(pin)))
.with_no_client_auth();
ureq::AgentBuilder::new()
.tls_config(Arc::new(cfg))
.timeout_connect(Duration::from_secs(2))
.timeout(Duration::from_secs(2))
// No redirect-following. Neither user of this agent wants it: the summary is a terminal
// JSON route, and the console probe treats any HTTP answer (302 included) as "up", so
// chasing the hop only spends the 2 s budget re-rendering a page nobody reads.
.redirects(0)
.build()
// ureq's `TlsConfig` cannot install a custom verifier, so the agent wraps this `ClientConfig`
// directly (the glue lives in punktfunk-core, shared with the desktop client's library fetch).
punktfunk_core::tls::ureq_agent::agent(
Arc::new(cfg),
ureq::Agent::config_builder()
.timeout_connect(Some(Duration::from_secs(2)))
.timeout_global(Some(Duration::from_secs(2)))
// No redirect-following. Neither user of this agent wants it: the summary is a
// terminal JSON route, and the console probe treats any HTTP answer (302 included) as
// "up", so chasing the hop only spends the 2 s budget re-rendering a page nobody reads.
.max_redirects(0)
.build(),
)
}
// ── Service-manager probe ───────────────────────────────────────────────────────────────────────
-18
View File
@@ -407,7 +407,6 @@ version = "0.0.1"
dependencies = [
"pf-driver-proto",
"pf-umdf-util",
"wdk",
"wdk-build",
"wdk-sys",
]
@@ -418,7 +417,6 @@ version = "0.0.1"
dependencies = [
"pf-driver-proto",
"pf-umdf-util",
"wdk",
"wdk-build",
"wdk-sys",
]
@@ -437,7 +435,6 @@ version = "0.0.1"
dependencies = [
"pf-driver-proto",
"thiserror",
"wdk",
"wdk-build",
"wdk-iddcx",
"wdk-sys",
@@ -450,7 +447,6 @@ version = "0.0.1"
dependencies = [
"pf-driver-proto",
"pf-umdf-util",
"wdk",
"wdk-build",
"wdk-sys",
]
@@ -754,19 +750,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "wdk"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd496a19ec75c3d98f8be805f62ebde4651fc01babf681b832d8bae9c584d25"
dependencies = [
"cfg-if",
"tracing",
"tracing-subscriber",
"wdk-build",
"wdk-sys",
]
[[package]]
name = "wdk-build"
version = "0.5.1"
@@ -818,7 +801,6 @@ name = "wdk-probe"
version = "0.0.1"
dependencies = [
"pf-driver-proto",
"wdk",
"wdk-build",
"wdk-sys",
]
+8 -3
View File
@@ -27,7 +27,12 @@ unsafe_op_in_unsafe_fn = "deny"
undocumented_unsafe_blocks = "deny"
[workspace.dependencies]
wdk = "0.4.1"
# NOTE: the high-level `wdk` crate (0.4.1) used to be declared here and taken by five driver
# crates. None of them ever referenced `wdk::` — every driver goes through `wdk_sys`, including
# the WDF call macro, which wdk-sys re-exports (see pf-umdf-util, a full WDF crate that never
# declared `wdk` either). Removing it drops a normal (linked) dependency from all five.
# `tracing`/`tracing-subscriber` are still in the lock afterwards, but only as wdk-sys BUILD
# dependencies — build-script machinery, not code in the shipped DLLs.
wdk-sys = "0.5.1"
wdk-build = "0.5.1"
wdk-iddcx = { path = "wdk-iddcx" }
@@ -36,8 +41,8 @@ pf-driver-proto = { path = "../../../crates/pf-driver-proto" }
# Vendored windows-drivers-rs 0.5.1 (the published, self-contained crates) + an added `iddcx`
# ApiSubset (M1 — bindgens iddcx/1.10/IddCx.h reusing wdk_default for WDF type-identity). Redirect ALL
# wdk-sys/wdk-build refs (incl. wdk 0.4.1's transitive deps) to the patched copies so there is exactly
# one (iddcx-capable) wdk-sys in the graph. Pinned; do not chase upstream.
# wdk-sys/wdk-build refs to the patched copies so there is exactly one (iddcx-capable) wdk-sys in the
# graph. Pinned; do not chase upstream.
[patch.crates-io]
wdk-build = { path = "vendor/wdk-build" }
wdk-sys = { path = "vendor/wdk-sys" }
@@ -23,7 +23,6 @@ crate-type = ["cdylib"]
wdk-build.workspace = true
[dependencies]
wdk.workspace = true
wdk-sys.workspace = true
pf-driver-proto.workspace = true
pf-umdf-util.workspace = true
@@ -31,7 +30,7 @@ pf-umdf-util.workspace = true
[features]
default = ["hid"]
hid = ["wdk-sys/hid"]
nightly = ["wdk-sys/nightly", "wdk/nightly"]
nightly = ["wdk-sys/nightly"]
[lints]
workspace = true
@@ -21,7 +21,6 @@ crate-type = ["cdylib"]
wdk-build.workspace = true
[dependencies]
wdk.workspace = true
wdk-sys.workspace = true
pf-driver-proto.workspace = true
pf-umdf-util.workspace = true
@@ -29,7 +28,7 @@ pf-umdf-util.workspace = true
[features]
default = ["hid"]
hid = ["wdk-sys/hid"]
nightly = ["wdk-sys/nightly", "wdk/nightly"]
nightly = ["wdk-sys/nightly"]
[lints]
workspace = true
@@ -20,7 +20,6 @@ crate-type = ["cdylib"]
wdk-build.workspace = true
[dependencies]
wdk.workspace = true
wdk-sys = { workspace = true, features = ["iddcx"] }
wdk-iddcx.workspace = true
pf-driver-proto.workspace = true
+1 -2
View File
@@ -21,14 +21,13 @@ crate-type = ["cdylib"]
wdk-build.workspace = true
[dependencies]
wdk.workspace = true
wdk-sys.workspace = true
pf-driver-proto.workspace = true
pf-umdf-util.workspace = true
[features]
default = []
nightly = ["wdk-sys/nightly", "wdk/nightly"]
nightly = ["wdk-sys/nightly"]
[lints]
workspace = true
@@ -21,7 +21,6 @@ crate-type = ["cdylib"]
wdk-build.workspace = true
[dependencies]
wdk.workspace = true
# `iddcx` feature → wdk-sys runs the IddCx bindgen pass (generate_iddcx) + compiles `wdk_sys::iddcx`.
# This is the M1 make-or-break: does IddCx.h bindgen in wdk-sys's config without a header conflict, and
# do its WDF/DXGI types resolve to wdk-sys's (so the generated module compiles)?
+41
View File
@@ -0,0 +1,41 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "ash"
version = "0.38.0+1.3.281"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f"
dependencies = [
"libloading",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "libloading"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
dependencies = [
"cfg-if",
"windows-link",
]
[[package]]
name = "pf-vkhdr-layer"
version = "0.1.0"
dependencies = [
"ash",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"