Move TLS to aws-lc-rs with post-quantum key exchange, drop ring via ureq 3, and fix the dependency defects behind it #192
+19
-19
@@ -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.
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+1503
-616
File diff suppressed because it is too large
Load Diff
+5
-5
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -33,6 +33,7 @@ mod ui_trust;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn main() -> gtk::glib::ExitCode {
|
||||
punktfunk_core::tls::install_default_provider();
|
||||
app::run()
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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:?}");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
Generated
-18
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
Reference in New Issue
Block a user