openpencil/crates/op-collab-transport
Fini 30b11f3584 fix(collab): clamp kernel keepalive to whole seconds
socket2 truncates sub-second durations to zero and Linux rejects
TCP_KEEPIDLE=0 with EINVAL, so any sub-second heartbeat config broke
every connection at the socket; macOS silently kept the 7200s default
instead. os_keepalive_period() rounds the period up to the kernel's
whole-second granularity with a 1s floor, and a getsockopt readback
test pins what the kernel actually holds on both platforms.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
2026-08-09 02:22:02 +08:00
..
src fix(collab): clamp kernel keepalive to whole seconds 2026-08-09 02:22:02 +08:00
tests feat(collab): allow cross-account collaboration 2026-08-01 13:01:24 +08:00
Cargo.toml feat(collab): add authenticated p2p collaboration 2026-07-29 00:06:58 +08:00
LICENSE feat(collab): add authenticated p2p collaboration 2026-07-29 00:06:58 +08:00
README.md fix(collab): harden p2p collaboration against resource exhaustion 2026-08-01 09:48:23 +08:00

op-collab-transport

Native, open-source transport for OpenPencil peer-to-peer collaboration.

The crate owns the M1 TCP record framing, Noise XX handshake, authenticated transfer chunking, admission boundary, connection resource limits, device static-key persistence, and optional mDNS discovery. Collaboration sequencing and document mutation remain in the wasm-clean op-collab crate.

Production signing keys, device tokens, and account credentials are not part of this crate. Ticket verification is injected through a narrow public trait.

Production driver

Use ConnectionDriver as the sole owner of a connected TcpStream and its Noise state. It is nonblocking in both directions, preserves partial reads and writes, bounds queued and reassembled data, and never holds a Noise lock across I/O. GUI and document threads should send typed commands to the event-loop thread instead of touching the connection directly.

let (_, secure) = connect_secure_tcp(
    address,
    &device_static_key,
    selected_discovery_id.as_deref(),
    config,
)?;
let budget = SharedQueueBudget::new(config.connections.global_queued_bytes)?;
let inbound_policy = if is_initiator {
    InboundTransferPolicy::OwnerToGuest
} else {
    InboundTransferPolicy::PeerToOwner
};
let mut driver = ConnectionDriver::new(secure, budget, inbound_policy)?;

if is_initiator {
    driver.queue_admission(&local_hello, Instant::now())?;
}

loop {
    let now = Instant::now();
    let polled = driver.poll(now)?;

    match polled.event {
        Some(DriverEvent::Admission(hello)) => {
            let identity = driver.verify_remote_admission(
                &hello,
                &ticket_verifier,
                expected_issuer,
                expected_subject,
                now_unix_ms(),
                now,
            )?;
            if !is_initiator {
                // The responder reveals its ticket only after authenticating
                // the initiator.
                driver.queue_admission(&local_hello, now)?;
            }
            authorize(identity)?;
            driver.authorize_remote(remote_role)?;
            driver.activate(now)?;
        }
        Some(DriverEvent::Frame { frame, .. }) => handle_frame(frame)?,
        None => {}
    }

    if driver.ticket_renewal_due(now) {
        request_or_expect_renewal();
    }

    let wake_at = [polled.rate_ready_at, driver.next_deadline()]
        .into_iter()
        .flatten()
        .min();
    schedule_socket_or_timer(wake_at, polled.has_pending_output);
}

For accepted sockets, acquire ConnectionLimiter::try_begin_handshake before spawning a worker, call accept_secure_tcp_guarded, and convert the pending guard to an active guard only after ticket admission succeeds. Every live pending worker continuously owns a global and per-address seat; the guarded accept applies handshake_first_message as the socket deadline until the first valid Noise message, so a silent worker exits before its seat is freed.

The inbound direction policy is part of allocation admission. In particular, an owner rejects guest-originated Snapshot transfers from their authenticated header before reserving the snapshot reassembly budget; a guest permits owner-originated snapshots for initial sync and log-gap recovery. The same trusted direction selects the pre-parse JSON envelope ceiling, so a guest's attacker-declared message kind cannot select the larger owner Snapshot budget. An aggregate reassembly reservation remains charged after the final chunk and is released only after the completed bytes have been decoded or dropped.

SecureConnection also exposes a blocking helper API for focused tools and tests. Its send_transfer atomically preflights the entire transfer against the configured burst so a rate-limit rejection cannot leave a partial transfer. The production driver instead rate-limits and resumes one authenticated chunk at a time.

Pre-encoded frames can only be created by the validated frame encoders. Non-ticket transfers may share their immutable encoded allocation across peer queues; ticket transfers stay uniquely owned and zeroize on every drop path. Ticket bytes cannot enter the ordinary shared or coalescing queue constructors. Queue item types are crate-private, and received transfer fields are exposed through borrowed accessors rather than public, cloneable storage. Payload-bearing transport types implement redacted Debug output that reports only transfer metadata such as class, id, and encoded length.

The generic raw JSON and frame-transfer encoders reject RenewTicket. Its dedicated sensitive encoder writes directly into uniquely owned zeroizing storage without first constructing a serde_json::Value or ordinary encoded buffer. Admission, chunking, decryption, reassembly, and renewal commands move that same non-shareable ownership forward rather than cloning ticket plaintext.

Liveness and ticket lifecycle

After admission becomes active, idle connections exchange a fixed transport-level heartbeat encrypted and authenticated by Noise. Only a successfully decrypted inbound record refreshes the inbound idle deadline, so an unresponsive peer cannot be kept alive by this process's own writes.

Ticket expiry is converted to monotonic deadlines when a ticket is installed. The driver exposes a one-shot proactive renewal point at 80% of the remaining TTL and retains the hard expiry deadline. Successful renewal verifies the same issuer, subject, device, and Noise static key, requires a later expiry, and atomically rearms both deadlines.

Open-source and private boundaries

The wire protocol, validation, rate and connection limits, ticket verifier trait, Noise/TCP implementation, discovery parser, and key stores are public. OsKeyStore uses macOS Keychain, Windows Credential Manager, or Linux Secret Service (zbus + RustCrypto). Its stored value is versioned and text-safe for Secret Service implementations such as KDE Wallet. Locked, inaccessible, ambiguous, or malformed entries fail closed and never trigger silent identity replacement.

FileKeyStore is the explicit fallback. On Unix it enforces owner-only permissions, no-follow opens, and atomic creation; on platforms without those guarantees it fails closed. Hosts should select this fallback only when the platform store is known to be unavailable, not when it is merely locked, so a temporary access failure cannot create a second device identity.

For local diagnosis, a debug-assertions openpencil-desktop build can set OPENPENCIL_COLLAB_DEV_FILE_KEYSTORE=1 to bypass the OS credential store and use that same hardened FileKeyStore directly. The opt-in is exact, ignored by release builds, and must not be enabled in packaged applications. Without it, the desktop still tries OsKeyStore first and falls back only for PlatformStoreUnavailable; access-denied, malformed, and other failures continue to fail closed.

Production ticket signing keys, device/account credentials, token exchange, and account authorization policy stay outside this crate. Logs and Debug output must never contain opaque tickets, Noise private keys, account identifiers, document text, or snapshots.

Platform mDNS smoke

The deterministic discovery parser/cache tests run in normal CI. A separate ignored smoke exercises the actual multicast stack and must run on each native platform runner (or release machine) with LAN multicast enabled:

cargo test -p op-collab-transport --features mdns \
  --test mdns_smoke -- --ignored --exact publisher_is_discovered_and_unregisters_cleanly

It publishes only an opaque random discovery id, verifies the advertised endpoint, unregisters it, and waits for the browser cache to remove it. It is not enabled on generic hosted runners because those networks commonly suppress mDNS and would make the result environmental rather than deterministic. Runners where interface auto-selection is ambiguous can set OP_COLLAB_MDNS_SMOKE_ADDRESS to that runner's active LAN address.

On macOS the discovery I/O backend uses the system Bonjour daemon through dns_sd.h; Linux and Windows continue to use mdns-sd. The macOS publisher keeps the random service and host labels, registers only addresses belonging to eligible local interfaces, and never substitutes the user-visible computer name. A second opt-in macOS check verifies browser interoperability with the system dns-sd publisher:

cargo test -p op-collab-transport --features mdns \
  --test mdns_smoke -- --ignored --exact browser_interoperates_with_system_dns_sd_publisher