fix(punktfunk/1): adversarial-review fixes — SPAKE2 pairing, renegotiation hardening, +more
ci / rust (push) Has been cancelled
ci / rust (push) Has been cancelled
Triaged the multi-agent review of the renegotiation + pairing + Sway + AV1/surround batch
(1 critical, 11 major/minor confirmed). Fixes:
CRITICAL — PIN pairing was offline-brute-forceable. The HMAC-of-PIN proof let an active
MITM who terminates the TOFU ceremony recover the 4-digit PIN by offline dictionary search
(all other inputs observable) and forge a correctly-bound proof. Replaced with **SPAKE2**
(balanced PAKE, `spake2` crate) + key-confirmation MACs, binding both cert fingerprints as
the SPAKE2 identities: an attacker gets exactly ONE online guess, no offline search, and
mismatched cert views (a real MITM) never reach a shared key. Also reworked the UX to an
"arming PIN" — one PIN per arming window shown at host startup (the SPAKE2 client needs the
PIN to build its first message, so it can't be minted per-connection). Validated live:
wrong PIN rejected in 0.1s, right PIN pairs + persists + the paired identity streams.
Pairing hardening: `--allow-pairing`/`--require-pairing` must arm pairing (default rejects
unsolicited ceremonies); per-host cooldown bounds online guessing; the client flushes its
CONNECTION_CLOSE so a refused ceremony can't wedge the sequential host for the full timeout;
atomic (temp+rename) paired-store writes.
Protocol: control/pairing messages use a distinct CTL_MAGIC (PKFc) — fully disjoint from
the positional Hello namespace (a future abi_version can't be misparsed as a control
message); all typed decodes are length-exact. ABI_VERSION → 2 (punktfunk_connect signature
gained the identity params; header regenerated).
Renegotiation: drain the reconfig channel to the NEWEST mode (one rebuild, not one per
stale step); validate refresh_hz; build the new pipeline BEFORE dropping the old so a
rebuild failure keeps the session on its current mode instead of killing it.
GameStream: packetDuration snaps to {5,10} (an in-between value isn't a legal Opus frame
size and would kill audio). Sway: chooser file moved to $XDG_RUNTIME_DIR (was a fixed
world-writable /tmp path — DoS / capture-misdirection by another local user).
Swift: fixed two compile breakers in the new pairing/identity APIs (Int32 status .rawValue,
UInt cap cast). New SPAKE2 + namespace-disjointness + pairing-roundtrip unit tests; the
in-process pairing test now also exercises the arming PIN + cooldown. 114 tests green,
clippy -D warnings clean (both feature sets), fmt, C-ABI harness.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -117,11 +117,13 @@ signing, bundle id `io.unom.punktfunk`. Notes:
|
||||
contract documented on the constructors; the host accumulates them into a virtual
|
||||
Xbox 360 pad). Poll `nextRumble()` and feed `GCDeviceHaptics` for force feedback.
|
||||
Client-side capture isn't in `InputCapture` yet.
|
||||
7. **Trust — the full ceremony exists now.** `generateIdentity()` once (persist both
|
||||
PEMs in the Keychain), then `pair(host:identity:pin:name:)` with the 4-digit PIN the
|
||||
host displays (its log; UI later) — returns the host's VERIFIED fingerprint; persist
|
||||
it and pass `pinSHA256:` + `identity:` to every connect. A wrong-size pin throws
|
||||
`.invalidPin`, a wrong PIN `.wrongPIN`. The TOFU flow `PunktfunkClient` already
|
||||
7. **Trust — the full ceremony exists now (SPAKE2).** `generateIdentity()` once (persist
|
||||
both PEMs in the Keychain), then `pair(host:identity:pin:name:)` with the 4-digit PIN
|
||||
the host prints when it ARMS pairing (`--allow-pairing`/`--require-pairing`; one PIN
|
||||
per arming window, shown at startup — the user reads it before pairing). Returns the
|
||||
host's VERIFIED fingerprint; persist it and pass `pinSHA256:` + `identity:` to every
|
||||
connect. Pairing is a real PAKE: a wrong PIN gets ONE online guess (no offline
|
||||
dictionary attack), throwing `.wrongPIN`; a wrong-size pin throws `.invalidPin`. The TOFU flow `PunktfunkClient` already
|
||||
implements (fingerprint confirmation sheet, per-host `HostStore`, "Forget Identity")
|
||||
keeps working against hosts not running `--require-pairing`; upgrading the sheet to a
|
||||
PIN-entry field closes the remaining gap — with `--require-pairing` the host now
|
||||
|
||||
@@ -71,9 +71,9 @@ public struct ClientIdentity: Sendable {
|
||||
public func generateIdentity() throws -> ClientIdentity {
|
||||
var cert = [CChar](repeating: 0, count: 4096)
|
||||
var key = [CChar](repeating: 0, count: 4096)
|
||||
let rc = punktfunk_generate_identity(&cert, cert.count, &key, key.count)
|
||||
guard rc.rawValue == PUNKTFUNK_STATUS_OK.rawValue else {
|
||||
throw PunktfunkClientError.status(rc.rawValue)
|
||||
let rc = punktfunk_generate_identity(&cert, UInt(cert.count), &key, UInt(key.count))
|
||||
guard rc == PUNKTFUNK_STATUS_OK.rawValue else {
|
||||
throw PunktfunkClientError.status(rc)
|
||||
}
|
||||
return ClientIdentity(certPEM: String(cString: cert), keyPEM: String(cString: key))
|
||||
}
|
||||
@@ -88,6 +88,9 @@ public func pair(
|
||||
timeoutMs: UInt32 = 90_000
|
||||
) throws -> Data {
|
||||
var observed = [UInt8](repeating: 0, count: 32)
|
||||
// The C header types PunktfunkStatus as a bare int32 (C17, no enum import), so the ABI
|
||||
// functions return Int32 directly — compare against the enum constants' rawValue, the
|
||||
// same bridging the connection methods use (statusOK etc.).
|
||||
let rc = host.withCString { cs in
|
||||
identity.certPEM.withCString { cert in
|
||||
identity.keyPEM.withCString { key in
|
||||
@@ -99,10 +102,10 @@ public func pair(
|
||||
}
|
||||
}
|
||||
}
|
||||
switch rc.rawValue {
|
||||
switch rc {
|
||||
case PUNKTFUNK_STATUS_OK.rawValue: return Data(observed)
|
||||
case PUNKTFUNK_STATUS_CRYPTO.rawValue: throw PunktfunkClientError.wrongPIN
|
||||
default: throw PunktfunkClientError.status(rc.rawValue)
|
||||
default: throw PunktfunkClientError.status(rc)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user