22 KiB
P2P collaboration threat model and trust boundary
Status: public M1 implementation checkpoint, 2026-07-28; the private identity source integration is implemented, while production provisioning and the full real-platform acceptance matrix remain pending.
This document is the security contract for OpenPencil peer-to-peer collaboration. It is intentionally public. The protocol, parsers, state machines, cryptographic transport integration, resource limits, and security tests do not rely on implementation secrecy.
Scope and security goals
M1 is designed to connect native desktop peers on a local network by mDNS discovery or a manually entered IP address. Document bytes travel directly between peers. Multicast DNS is LAN-scoped and is not a cross-region discovery mechanism. A manually entered publicly routable address can exercise the current TCP and Noise path across sites when firewall and NAT policy permit it, but M1 has no rendezvous, NAT traversal, or relay and does not claim robust Internet reachability. ZSeven services authenticate accounts and are the production issuer boundary for short-lived admission tickets. The private issuer and credential-bearing provider source implementations now exist, including a strict JWKS profile, persistent rotation ledger, Unix-HSM peer authentication, and an append-only ABI-v2 client contract. Production HSM keys, protected static archives, deployment policy, and hardened runners are separate provisioning gates and are not claimed complete at this checkpoint. The services do not store or relay document content.
The implementation must:
- authenticate both ends with a server-signed collaboration ticket;
- prove that each ticket is bound to the Noise static key used by that connection;
- admit only the expected issuer and account subject;
- encrypt and integrity-protect all admission and document traffic;
- prevent an unauthenticated peer from receiving document, thumbnail, session name, or presence data;
- enforce the owner-authoritative role and edit policy;
- reject malformed, oversized, stale, replayed-state, or unsupported operations without partially mutating the document;
- bound connections, handshakes, frames, transfers, queues, document validation, and cached metadata;
- keep tickets, private keys, device credentials, document content, and personally identifying account data out of logs and discovery metadata.
Availability against a party that can saturate the host's network link is not guaranteed. The limits below are intended to contain CPU and memory use after traffic reaches OpenPencil.
Assets
| Asset | Required protection |
|---|---|
| Document and presence content | Confidentiality and integrity in transit; no disclosure before admission |
| ZSeven device credential | Private implementation and platform-protected storage; never exposed through the open collaboration API |
| Collaboration signing private key | Issuer/HSM boundary only; never shipped to a client or repository |
| Short-lived collaboration ticket | Treated as a bearer credential, redacted and zeroized where owned |
| Noise X25519 static private key | Local-only, zeroized in memory, stored with platform/file protections |
| Account subject and device id | Derived only from verified claims; omitted from mDNS and routine logs |
| Collaboration display name and avatar URL | Accepted only as bounded signed claims; disclosed only to admitted session participants |
| Owner role and commit sequence | Owner-authoritative and guarded against guest self-assertion or stale writes |
| Resource availability | Bounded before allocation, reassembly, parsing, or broadcast |
Trust boundaries and data flow
- The private authentication provider uses an already authenticated device session to request an opaque ticket. Its public request contains only the local X25519 public key.
- The ticket issuer signs the fixed public claims profile. It does not return signing keys to the client.
- mDNS advertises only an ephemeral discovery id, protocol version, and TCP port. Discovery is a locator, not an authentication statement.
- Peers complete
Noise_XX_25519_ChaChaPoly_BLAKE2s. The responder prelude is included in the Noise prologue. - Tickets are exchanged inside the encrypted Noise channel. The open verifier
checks signature, issuer, audience, version, scope, time, identifiers, and
the equality of
dh_pub_x25519with the observed remote Noise static key. Optionaldisplay_nameandavatar_urlclaims are accepted only from this signed payload; names are bounded and control-free, and avatar URLs must be bounded HTTPS URLs without credentials or fragments. - Only after both admission checks succeed may the owner send a welcome, snapshot, commit, or presence message.
- The owner assigns the connection role and is the serialization point for accepted commits. A guest cannot acquire permissions by putting a role, author, subject, or device id in an untrusted message.
Neither an mDNS record, a local SignedIn flag, an email address, nor a
peer-supplied profile is an authentication source. Issuers that have not yet
added the optional signed profile claims remain compatible, but their peers
receive generic epoch-local labels rather than a fallback from local account
UI state.
Ticket-bearing protocol values are deliberately non-Clone. Generic JSON and
frame-transfer encoders reject RenewTicket; the dedicated sensitive encoder
serializes directly into uniquely owned zeroizing storage without first
materializing a serde_json::Value or ordinary output buffer. Admission,
renewal commands, chunking, decryption, reassembly, and per-peer queues retain
that ownership discipline so ticket plaintext is zeroized on every drop path.
Domestic and overseas issuer topology
Regional credential origins are not collaboration trust roots. A domestic
client may use its local SSO for login and ticket requests while an overseas
client uses another SSO origin. Both deployments must issue the exact same
logical collaboration iss and the same immutable global account sub; email
address matching or region-local account ids are not a federation mechanism.
The desktop reads OPENPENCIL_SSO_URL,
OPENPENCIL_COLLAB_ISSUER, and
OPENPENCIL_COLLAB_POLICY_ENDPOINT only from trusted process-startup
configuration. Production fetches /api/v1/collab/policy; the envelope must
verify under the offline Ed25519 root pinned into the open client. Endpoint-only
configuration, conflicting policy/JWKS endpoints, signature or issuer
mismatch, inactive key metadata, generation rollback, and same-generation
rewrites fail closed without a raw-JWKS fallback. The old
OPENPENCIL_COLLAB_JWKS_ENDPOINT remains an explicit self-hosted compatibility
path. No ticket, provider response, mDNS record, invite, or peer message can
supply or replace the pinned trust values.
Each region owns an independent HSM signing key hierarchy and never exports or copies private keys to the other region. Region-local mirrors publish one canonical offline-signed policy containing the complete public union. The client validates no more than 8 regions/24 keys, exact region membership, unique key ids/public keys, one active and one next key per region, and at most one retired overlap key. It excludes unactivated next keys from ticket verification and rechecks policy/key times on every cache use. Key publication, activation, overlap, retirement, and emergency removal must therefore be authorized in a higher generation before either region changes signing state. Mirror availability, consistency, HSM provisioning, and physical multi-region timing tests remain production gates.
Public and private ownership
The default is open source. Code stays private only when publishing it would expose an account credential or a production signing secret.
| Component | Repository boundary | Rationale |
|---|---|---|
| Wire protocol, exact diff/apply, canonical hash, owner/guest state machines | Public openpencil/crates/op-collab |
Reviewable deterministic behavior; wasm-compatible |
| Noise/TCP framing, admission, limits, queues, discovery, key-store interface and safe fallback | Public openpencil/crates/op-collab-transport |
Security comes from open protocol and audited libraries |
| Ticket claims/profile, signed-union-policy and legacy JWKS parser/cache, Ed25519 verifier, provider trait, stub, ABI declarations | Public openpencil/crates/op-auth-bridge |
Trust decisions must remain reviewable |
| Deterministic issuer and rotation fixtures | Public, compiled only for tests or test-issuer |
Contain deliberately public seeds and a .invalid issuer; production verifier rejects that issuer |
| Host integration, UI, recovery, diagnostics, and smoke tests | Public openpencil |
No credential-handling reason to hide them |
| Device token and authenticated ticket request implementation | Private op-platform real provider |
Holds the account credential and platform storage integration |
| Ticket issuance policy, production signing/HSM keys, rotation and revocation | Private zseven-sso |
Contains production signing authority and account policy |
| Runtime tickets, Noise private keys, device tokens, HSM material | Never committed | Secrets are runtime data, not source assets |
Public test keys are not a backup or development production key. A build that
enables test-issuer must still use an explicitly pinned test verifier; the
production constructor defaults to https://sso.zseven.cn. The desktop may
use explicit startup-pinned collaboration issuer/JWKS values for a controlled
regional deployment, independently of its credential origin.
Threats, controls, and residual risk
LAN interception and active man-in-the-middle
Noise XX encrypts traffic and authenticates possession of both static private keys. The signed ticket binds the authenticated account/device claims to the remote static key observed in that handshake. A relayed or substituted static key therefore fails admission.
Traffic endpoints, timing, and approximate volume remain visible to the local network. M1 does not provide anonymity or traffic-shape concealment.
Discovery spoofing and privacy
mDNS is untrusted and spoofable. Advertisements contain no account, email,
device name, document title, session title, or stable hostname. Consumers
strictly parse the id, v, and p fields, cap the discovery cache, honor
removal, and proceed to Noise plus ticket authentication before disclosure.
On macOS, a live DNSServiceGetAddrInfo operation refreshes a 30-second
upper-layer lease every 10 seconds; removal clears it, while a dead worker ages
out within that lease. A full bounded event lane drops only the current
notification and relies on the next heartbeat to republish current state;
receiver disconnection is the condition that stops the worker.
The macOS source plist declares the canonical local-network usage description
and exactly _openpencil-collab._tcp in NSBonjourServices. Both bundle paths
patch and validate the final plist through one helper, and the release workflow
validates the exact app plist before notarization. This is a packaging/TCC
precondition, not peer authentication.
An attacker can advertise many endpoints or make connection attempts. Cache, pending-handshake, per-IP, and active-connection limits reduce impact but cannot prevent network-link exhaustion.
Forged, replayed, wrong-account, or downgraded tickets
The verifier accepts only compact JWS with protected alg=Ed25519,
typ=openpencil-collab+jwt, and a bounded kid. Claims are exact and include
the pinned issuer/audience/version/scope, canonical subject/device ids, channel
binding, iat, nbf, exp, and jti. JWKS data is fetched only from the
pinned HTTPS endpoint and is strictly parsed as public OKP/Ed25519 verification
keys.
A stolen ticket can be used only with the bound Noise private key and until its signed expiry. M1's revocation service-level objective is the ticket lifetime, currently at most 15 minutes; sign-out prevents renewal but does not retroactively invalidate an issued ticket. If immediate revocation becomes a product requirement, it needs an online revocation epoch or introspection mechanism.
Unknown signing keys trigger a throttled refresh. Expired JWKS cache entries fail closed when refresh fails. Key rotation must publish overlap keys for at least the maximum ticket lifetime and cache interval.
In a multi-region deployment, a regional ticket is accepted only after its signing key appears in the same logical union JWKS observed by every client. The pinned endpoint URL may be a region-local mirror, but its canonical keyset must match every other mirror. Routing clients to partial regional keysets would create asymmetric admission and renewal failures and is a deployment error, never a reason to fall back to a ticket-provided key or a second issuer.
Renewal distinguishes evidence that a ticket is invalid from an unavailable trust source. A key id absent from a successfully refreshed keyset is rejected; transport, cache, malformed-keyset, invalid-ETag, rejected-response, and response-limit failures publish no renewed ticket and retry only while the previously verified ticket remains valid. Persistent failure closes the session before the old ticket expires.
The production native fetcher propagates cancellation through cache-lock waiting, the async HTTPS send, and every streamed body chunk. Cancellation drops the unfinished request future, rolls back refresh/unknown-key timing markers, closes the pending result lane, and joins the worker without waiting for the network timeout or publishing a late result. The trait default can only check before and after a blocking third-party fetch; any other production blocking adapter must override the cancellable method.
Unauthorized edits and identity injection
Verified identity metadata is constructed only by the admission boundary. Owner state assigns roles and rewrites authoritative author/sequence fields. Viewer edits, counter gaps, stale bases, failed preconditions, unsupported operations, and session/epoch mismatches are typed rejection paths.
The authenticated Participant roster wire projects only the verified display
name and HTTPS avatar URL alongside epoch-local participant/peer ids and role.
Persistent subject and device ids remain inside the non-serializable connection
principal and are never added to Welcome, presence, commit, or roster messages.
The desktop host copies the URL only into a process-local, generation-scoped
avatar registry. It is absent from the document, EditorState, narrowed
off-thread snapshots, and redacted Debug; fetched image bytes never enter the
collaboration protocol.
The owner can intentionally send document content or grant edit access; that is the collaboration action, not an attacker bypass. M1 does not protect a document from an authorized malicious participant after disclosure.
Parser, memory, CPU, and queue exhaustion
Limits are typed configuration with hard maxima. They cover compact tickets, JWKS bodies and key counts, identifiers, operation count, validation visits, tree depth, document node count, presence, envelopes, transaction/snapshot transfers, Noise records and handshakes, reassembly, connection counts, per-peer/global queues, rate buckets, timeouts, commit history, and discovery cache entries.
Lengths and counts are checked before allocating complete buffers or applying mutations. Invalid refreshes do not replace a valid JWKS cache. Exact apply is transactional: rejection must not leave partial document changes.
Every new externally controlled collection, string, transfer, parser, retry, cache, or queue requires:
- a named default and hard maximum;
- a typed configuration or wrapper validated at construction;
- a typed, log-safe error;
- tests at the limit and one unit over it;
- outbound enforcement as well as inbound enforcement.
Local key theft and filesystem attacks
Private key buffers implement zeroization and redact Debug. The open
OsKeyStore uses macOS Keychain, Windows Credential Manager, or Linux Secret
Service for the device static key. A locked, inaccessible, ambiguous, or
malformed platform-store entry fails closed; it must not silently create a
replacement identity.
The Unix file store is a narrowly scoped fallback only when the selected
platform store has definitively reported that it is unavailable. It uses a
dedicated 0700 directory, 0600 files, no-follow opens, atomic installation,
length/all-zero checks, and reopens through the hardened read path. A locked or
temporarily inaccessible platform store is not “unavailable” and must not
trigger this fallback. The file store is not hardware-backed and cannot protect
against a compromised user account or process; platforms without the required
filesystem guarantees fail closed.
Platform key-store adapters may remain public; only their runtime secret values are private.
Logging, crash reports, and fixtures
Errors crossing public boundaries contain closed codes and sizes, never remote
bodies or credentials. Debug for ticket, key, verified identity, signed
profile, roster profile, and document containers must redact content.
Production logging must not include tickets, Noise keys, full snapshots,
document text, email, subject, device id, display name, or avatar URL.
Repository fixtures must not contain PEM private keys, production-looking
tokens, compact bearer tickets, or copied runtime key files. Deterministic test
signing material is permitted only behind the test-issuer/test compilation
gate and must use a non-production .invalid issuer.
The permanent Go-to-Rust interoperability vector follows that boundary. Its public fixture stores the non-production compact JWS as three separate segments and locks the segments plus public JWKS with one SHA-256 digest. The Rust test joins and verifies them in memory; a private-repository producer test invokes the real Go issuer service and independently locks the exact same segments, JWKS, and digest. It contains no device token, production identity, signing private key, HSM metadata, or authorization policy.
Supply-chain and cryptographic downgrade
Cryptographic algorithms and protocol versions are fixed rather than selected by a peer. Unknown versions and algorithms fail closed. Dependencies remain covered by the repository's pinned lockfile, cargo-deny advisories/bans checks, and targeted collaboration CI.
Changing the Noise pattern, JWS algorithm, canonical encoding, key type, protocol version, dependency source, or maximum size is a security review event, not a routine refactor.
Committed authentication static archives are inspectable client inputs, not a trust boundary. The current ABI-v1 matrix is a legacy compatibility lane: SHA-256 and the narrow C ABI are audited, but the archives still leak source/debug/private-symbol metadata and are not claimed to be stripped or obfuscated. Production ABI-v2 requires a private-source hardened rebuild plus an Ed25519-signed provenance manifest covering the exact artifact hash, target, version, ABI, source revision, build id, and hardening profile; missing or invalid provenance fails closed. Obfuscation may raise reverse-engineering cost, but authorization and ticket trust remain rooted in server-held signing keys and policy.
Encrypting an archive at rest is useful only when the decryption key remains in
the private release system. Committing the key, embedding it in build.rs, or
shipping a client-side decryptor beside the ciphertext does not materially
raise the reverse-engineering boundary and would make a reproducible public
build misleading. A published client binary remains inspectable even when its
build input was encrypted.
Automated boundary gate
Run:
bash tools/check-collab-security-boundaries.sh
bash tools/check-collab-security-boundaries.test.sh
bash tools/check-op-auth-prebuilt.sh
bash tools/check-op-auth-prebuilt.test.sh
bash tools/check-macos-bundle-plist.sh
bash tools/check-macos-bundle-plist.test.sh
The gate verifies:
- the
op-collabwasm dependency closure contains no native transport, authentication, network, or random-key dependencies; - every
op-collab*crate andop-auth-bridgeresolves its license from the MIT workspace license; - required typed protocol, apply, transport, queue, and hard-limit anchors are
present, and collaboration source does not return
Result<_, String>; - test issuer/signing fixtures remain behind explicit test feature gates;
- committed authentication archives keep their exact hashes and documented C ABI, while hardened ABI-v2 artifacts require signed provenance and contain no source paths, debug markers, or private Rust module symbols;
- high-signal private-key, token, compact-ticket, and sensitive-file patterns are absent;
- both macOS bundle paths and the pre-notarization release path preserve the exact local-network and Bonjour plist declarations;
- collaboration Rust files stay within the repository's 800-line limit.
The independent collab-security.yml workflow also compiles op-collab for
wasm32-unknown-unknown, runs the full protocol/state-machine/property suite,
checks transport limits and production-verifier isolation, and exercises the
two-process Noise/ticket collaboration smoke with the isolated public test
issuer.
Static scanning is defense in depth, not a secret scanner or security review replacement. Production credentials must also be blocked by repository host secret scanning and incident-response procedures.
Review triggers
Request a security review when any change:
- adds or changes a claim, trust root, key source, cryptographic algorithm, or protocol version;
- permits document or presence data before admission completes;
- adds discovery metadata or log fields;
- changes expiry, renewal, rotation, revocation, or owner-leave behavior;
- introduces an externally controlled allocation, retry, cache, or queue;
- adds a new collaboration crate or moves code across the public/private boundary;
- adds a native dependency to
op-collabor enables a new default feature; - changes persistent private-key storage.
Security reports should identify the affected boundary and avoid attaching live tickets, credentials, private keys, or document content.