feat: HDR Step-0 colour-metadata transport + security-audit hardening
ci / rust (push) Failing after 45s
apple / swift (push) Successful in 57s
ci / web (push) Successful in 39s
ci / docs-site (push) Successful in 38s
windows-host / package (push) Successful in 3m26s
android / android (push) Successful in 3m40s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m24s
deb / build-publish (push) Successful in 2m10s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m22s
decky / build-publish (push) Successful in 25s
ci / bench (push) Successful in 4m44s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m4s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m7s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 3m5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m45s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 30s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m37s
flatpak / build-publish (push) Successful in 4m17s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m30s
docker / deploy-docs (push) Successful in 23s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 7m53s
ci / rust (push) Failing after 45s
apple / swift (push) Successful in 57s
ci / web (push) Successful in 39s
ci / docs-site (push) Successful in 38s
windows-host / package (push) Successful in 3m26s
android / android (push) Successful in 3m40s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m24s
deb / build-publish (push) Successful in 2m10s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m22s
decky / build-publish (push) Successful in 25s
ci / bench (push) Successful in 4m44s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m4s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m7s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 3m5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m45s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 30s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m37s
flatpak / build-publish (push) Successful in 4m17s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m30s
docker / deploy-docs (push) Successful in 23s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 7m53s
Two strands, entangled in punktfunk1.rs, committed together (one builds-green tree). HDR pipeline Step 0 — glass-to-glass colour-metadata transport (docs/hdr-pipeline-plan.md): - Protocol/ABI: ColorInfo on the Welcome + a 0xCE HdrMeta datagram carry the source colour space + HDR10 static mastering metadata (quic.rs, abi.rs connect_ex5 fixing caps=0). - New platform-independent, unit-tested HDR static-metadata helpers (hdr.rs): chromaticities (1/50000), mastering luminance (0.0001 cd/m2), MaxCLL/MaxFALL in HDR10/ST.2086 units. - Capture/encode hooks (capture.rs, encode.rs set_hdr_meta) + Linux client / probe plumbing. Security-audit hardening — top 3 from docs/security-review.md, each adversarially verified: - #1 [HIGH] Secret file permissions. The host key.pem/cert.pem and both trust stores are now written owner-only: 0600 + dir 0700 on Unix (mirrors mgmt_token), best-effort SYSTEM/Administrators/OWNER-only icacls DACL on Windows (%ProgramData% is Users-readable). Closes a local key-disclosure -> host-impersonation gap. New gamestream::{create_private_dir, write_secret_file} + a 0600 regression test. - #2 [HIGH] Native SPAKE2 PIN is single-use. The PIN is consumed the moment the host sends its key-confirmation (which lets the client test its one guess), before reading the proof, so any completed attempt -- right OR wrong -- disarms the window. A wrong PIN isn't observable host-side (the client aborts before sending its proof), so consuming on first attempt is what delivers the documented "one online guess" instead of an unbounded brute-force of the static 4-digit PIN. Test verifies single-use. - #3 [MEDIUM] RTSP packetSize is bounded ([64,2048] in stream_config) and VideoPacketizer::new uses saturating .max(1), killing a PRE-AUTH div-by-zero/underflow panic of the video thread. Tests for {0,15,16,17} + out-of-range rejection. fmt + clippy -D warnings clean; full workspace test suite green (93 host tests). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -232,6 +232,91 @@ pub(crate) fn config_dir() -> PathBuf {
|
||||
base.join("punktfunk")
|
||||
}
|
||||
|
||||
/// Create `dir` (and parents) owner-private — **0700** on Unix (so the host's secrets aren't readable
|
||||
/// by other local users via a traversable config path). Best-effort on Windows: the dir inherits the
|
||||
/// (Users-readable) `%ProgramData%` ACL, so secret *files* are individually locked down by
|
||||
/// [`write_secret_file`]. Tightens an already-existing dir too.
|
||||
pub(crate) fn create_private_dir(dir: &std::path::Path) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
|
||||
let r = std::fs::DirBuilder::new()
|
||||
.recursive(true)
|
||||
.mode(0o700)
|
||||
.create(dir);
|
||||
// `recursive` doesn't re-chmod an existing dir — tighten it so an old 0755 dir gets locked.
|
||||
if dir.exists() {
|
||||
let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
|
||||
}
|
||||
r
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
std::fs::create_dir_all(dir)
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `contents` to `path` as an **owner-only secret**: created and re-chmod'd **0600** on Unix
|
||||
/// (never even briefly group/world-readable), and DACL-restricted to SYSTEM/Administrators/owner on
|
||||
/// Windows (the default `%ProgramData%` ACL is Users-readable). Mirrors the mgmt-token hardening; used
|
||||
/// for the host private key and the persisted trust stores so a local unprivileged user can neither
|
||||
/// read the key (impersonation) nor tamper with the paired allow-list (unauthorized pairing).
|
||||
pub(crate) fn write_secret_file(path: &std::path::Path, contents: &[u8]) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
opts.mode(0o600);
|
||||
}
|
||||
let mut f = opts.open(path)?;
|
||||
f.write_all(contents)?;
|
||||
f.flush()?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
#[cfg(windows)]
|
||||
restrict_to_system_admins(path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Best-effort Windows DACL lockdown of a secret file: strip inherited ACEs and grant Full only to
|
||||
/// SYSTEM, Administrators, and OWNER RIGHTS (the creating account — the SYSTEM service or a manually
|
||||
/// running user keeps access). Without this the host key under the default Users-readable
|
||||
/// `%ProgramData%` ACL is readable by ANY local user. Uses `icacls` with hard-coded SIDs
|
||||
/// (locale-independent) via the absolute `%SystemRoot%` path (a privileged service must not trust
|
||||
/// `PATH`). Never fatal — on failure the file is simply left at the inherited ACL (today's behaviour).
|
||||
#[cfg(windows)]
|
||||
fn restrict_to_system_admins(path: &std::path::Path) {
|
||||
let icacls = std::env::var("SystemRoot")
|
||||
.map(|r| format!("{r}\\System32\\icacls.exe"))
|
||||
.unwrap_or_else(|_| "icacls".to_string());
|
||||
let status = std::process::Command::new(icacls)
|
||||
.arg(path.as_os_str())
|
||||
.args([
|
||||
"/inheritance:r",
|
||||
"/grant:r",
|
||||
"*S-1-5-18:(F)", // NT AUTHORITY\SYSTEM
|
||||
"/grant:r",
|
||||
"*S-1-5-32-544:(F)", // BUILTIN\Administrators
|
||||
"/grant:r",
|
||||
"*S-1-3-4:(F)", // OWNER RIGHTS
|
||||
])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
match status {
|
||||
Ok(s) if s.success() => {}
|
||||
_ => tracing::warn!(
|
||||
path = %path.display(),
|
||||
"icacls hardening did not succeed — this secret may be readable by other local users"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn hostname_string() -> String {
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(n) = std::env::var_os("COMPUTERNAME") {
|
||||
@@ -304,7 +389,7 @@ fn load_paired() -> Vec<Vec<u8>> {
|
||||
pub(crate) fn save_paired(paired: &[Vec<u8>]) {
|
||||
let Some(path) = paired_path() else { return };
|
||||
if let Some(dir) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
let _ = create_private_dir(dir);
|
||||
}
|
||||
let bytes = match serde_json::to_vec(paired) {
|
||||
Ok(b) => b,
|
||||
@@ -313,10 +398,10 @@ pub(crate) fn save_paired(paired: &[Vec<u8>]) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Write to a sibling temp file, then rename over the target (atomic replace on Unix and
|
||||
// Windows). Never write `path` in place.
|
||||
// Write to a sibling temp file (owner-only, so a local user can't tamper the allow-list), then
|
||||
// rename over the target (atomic replace on Unix and Windows). Never write `path` in place.
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
if let Err(e) = std::fs::write(&tmp, &bytes) {
|
||||
if let Err(e) = write_secret_file(&tmp, &bytes) {
|
||||
tracing::warn!(error = %e, "persisting pairings failed (temp write)");
|
||||
return;
|
||||
}
|
||||
@@ -325,3 +410,29 @@ pub(crate) fn save_paired(paired: &[Vec<u8>]) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::{create_private_dir, write_secret_file};
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
#[test]
|
||||
fn secrets_are_written_owner_only() {
|
||||
let dir = std::env::temp_dir().join(format!("pf-secret-test-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
create_private_dir(&dir).expect("create private dir");
|
||||
let dmode = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(dmode, 0o700, "config dir must be owner-only (0700)");
|
||||
|
||||
let key = dir.join("key.pem");
|
||||
write_secret_file(&key, b"-----BEGIN PRIVATE KEY-----\n...").expect("write secret");
|
||||
let fmode = std::fs::metadata(&key).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(fmode, 0o600, "private key must be owner-only (0600)");
|
||||
|
||||
// Overwriting an existing secret keeps it 0600 (the truncate+reopen path).
|
||||
write_secret_file(&key, b"new contents").expect("rewrite secret");
|
||||
let fmode = std::fs::metadata(&key).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(fmode, 0o600);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user