Peer admission required the remote ticket's subject to equal the local account on both sides, so only devices of one account could pair. That made the product multi-device sync rather than collaboration. The subject equality was the authorization, so it is replaced rather than deleted. `PeerIdentityPolicy` states which accounts a peer may belong to, and the two sides get different answers because they do not have the same ability to tell who the peer is: - The owner accepting a guest admits any issued account. Nothing at this layer decides whether the guest joins — a human does, from the approval prompt, which is shown the verified identity and which the admission state machine makes unskippable (`Active` is reachable only through `OwnerAuthorized`). - A guest joining by invite or relay admits any issued account, because the invite's signed locator already pinned the owner's Noise static key and that pin is checked before admission runs. The device is authenticated whatever account is behind it, which is what makes joining a stranger's session safe. - A guest joining over an unpinned LAN discovery still requires the same account. A guest has no approval prompt — whatever it accepts, it accepts silently — and nothing else names the peer there: mDNS is spoofable and no key is known in advance. Relaxing it would let anyone on the segment holding any valid ticket pose as the owner, undetected. Opening this needs a way for the guest to confirm who it is joining, which is a user-facing decision, not a protocol change. Relaxing the account relaxes nothing else: issuer, expiry, and the binding to the observed Noise static key are unchanged, and renewal still refuses any mid-session change of issuer, subject, device id, or key. Tests cover that a foreign subject is admitted while each of those still rejects, and the unpinned-LAN case has its own regression guard. Also fixes two things this uncovered. The live MCP tests in op-host-desktop drove the endpoint with raw HTTP and no token, so they failed with 401 after bc75c765b authenticated it — they now send the instance token, which is also a live check that the authentication works. And two constant relations in the transport config were runtime asserts that clippy rejects as constant-valued; they are compile-time asserts now, which is what a relation between constants should have been.
39 KiB
P2P collaboration threat model and trust boundary
Status: public M1 and M2.1 source checkpoint, 2026-07-30; the private identity source integration and public relay/bootstrap client boundaries are implemented, while production provisioning, op-hub/relay/locator deployment, 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.
M2.1 adds an owner-anchored public WSS relay path. An overseas peer may join a
signed home_region=cn invite through a Global L4 ingress that fixed-backhauls
to the same CN relay and locator services; being overseas does not change the
signed home region or authorize a Global-home fallback. Relay infrastructure
forwards bounded prelude, Noise handshake, and inner Noise ciphertext as an
opaque stream. It can observe admission and routing metadata at the documented
layers, but it must not decrypt or persist document frames.
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. Identity and bootstrap services
do not store document content; relay services forward but do not decrypt or
persist it.
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 |
| Collaboration bootstrap root private key | Offline signing/HSM boundary only; never held by the desktop or online op-hub service |
| Signed relay bootstrap and verified LKG | Authenticity, canonical encoding, bounded validity, rollback resistance, and atomic CN/Global endpoint/key selection |
| 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 to admitted session participants and, on the relay path only, to the relay operator through the bearer ticket (see "Relay operator visibility") |
| 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.
- For a public relay session, the desktop fetches one signed collaboration bootstrap from a startup-configured exact HTTPS endpoint. It verifies the canonical payload under the embedded Ed25519 root and selects one complete CN or Global entry. The bootstrap mirror, DNS, TLS endpoint, invite, and peer cannot replace that trust root.
- An owner publishes a route to the selected entry's locator service. A guest
selects the entry named by the invite's signed
home_region. Both use the same verified snapshot for the relay URL, locator URL and verification keys, and relay challenge X25519 pins. - 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.
Signed collaboration bootstrap and cross-region relay routing
The desktop's former direct injection of five relay/locator endpoint and public
key values is retired. Production now configures
OPENPENCIL_COLLAB_BOOTSTRAP_URL, while an owner additionally retains
OPENPENCIL_COLLAB_RELAY_HOME_REGION=cn|global as a local home selector. A
guest obtains its home region only from the signed invite. The embedded
openpencil-collab-root-v1 Ed25519 public key currently has the same bytes as
the collaboration union-policy root, but the two source constants are not yet
single-sourced; deployment and tests must not assume source-level coupling.
The bootstrap URL must be HTTPS with the exact
/api/v1/collaboration/bootstrap path and no credentials, query, or fragment.
The envelope and decoded payload must each match their canonical JSON
re-encoding. Payload, signature, and public-key fields use unpadded canonical
base64url. The signature is Ed25519 over:
"openpencil/op-hub/collaboration-bootstrap/v1\0" ||
canonical_payload_json_bytes
The domain separator prevents a valid signature for another collaboration
artifact from being interpreted as a bootstrap signature. A payload has one
non-zero generation and one validity window of at most seven days, and contains
exactly one CN and one Global entry in the same signed snapshot. Each entry
atomically contains its exact relay WSS URL, locator HTTPS URL, locator
Ed25519 public keys, and relay-challenge X25519 public keys. The desktop allows
at most 300 seconds of future not_before clock skew, while not_after remains
exclusive.
An overseas guest joining a signed home_region=cn invite therefore selects
the complete CN entry from that snapshot. GeoDNS or an audited edge may land
the CN entry's logical hostnames at a Global L4 ingress and fixed-backhaul the
untouched inner TLS stream to CN. The guest must not select the Global entry
because of its physical location, combine a Global endpoint with CN keys, or
fall back to a Global home when the CN path fails. The CN locator signer and CN
relay authenticator remain the authoritative route and admission boundaries.
The desktop cache is bound to the exact bootstrap endpoint and stores the signed body with its strong SHA-256 ETag. A still-current, freshly reverified last-known-good snapshot may be used after a fetch/request-send failure before a response is available, a status other than 200/304, or a valid 304 with the exact cached ETag. A body-read failure after a 200 response, or any invalid 200 response, does not fall back to LKG. A lower generation, or different signed payload bytes at the same generation, fails closed. An expired snapshot is not used for routing, but remains a rollback baseline. This high-water mark exists only in a valid persisted endpoint-bound cache: changing the bootstrap URL, or deleting, corrupting, making unreadable, or failing to persist the cache, does not preserve a global generation floor.
Domestic and overseas op-hub sites may serve the same byte-identical signed
envelope, but the current service does not replicate or hot-reload snapshots;
operations must deploy the same artifact to both sites. Each op-hub process
loads OP_HUB_BOOTSTRAP_FILE, verifies it with
OP_HUB_BOOTSTRAP_ROOT_KEYS, and enforces
OP_HUB_BOOTSTRAP_MIN_GENERATION before serving an immutable in-memory
snapshot. The online service never receives the offline bootstrap private key.
Debug plaintext is a narrow exception, not a production alternate trust path.
Only a test/debug build with
OPENPENCIL_COLLAB_BOOTSTRAP_DEV_HTTP=1 may fetch bootstrap HTTP from a
numeric-loopback address. Additional
OPENPENCIL_COLLAB_BOOTSTRAP_DEV_ROOT_KEYS are considered only in that mode;
plaintext locator and relay endpoints in the snapshot must also be numeric
loopback. Unsigned relay operation separately requires
OPENPENCIL_COLLAB_RELAY_DEV_UNSIGNED=1 in a debug build and numeric-loopback
WebSocket endpoint. localhost, non-loopback plaintext, and release builds are
not covered by these exceptions.
Client bootstrap does not replace service-side secret and policy
provisioning. The relay server retains
OPENPENCIL_COLLAB_RELAY_TICKET_POLICY_FILE,
OPENPENCIL_COLLAB_RELAY_LOCATOR_KEYS_FILE, and
OPENPENCIL_COLLAB_RELAY_X25519_KEYS_FILE; the locator server retains
OPENPENCIL_COLLAB_LOCATOR_TICKET_POLICY_FILE. Those files remain bounded
operator/HSM-side production inputs and are not desktop discovery settings.
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 |
| Relay protocol/client/server, signed locator verifier, bootstrap verifier/cache, canonical wire and rollback tests | Public openpencil crates and desktop host |
Endpoint, key-selection, and data-plane trust decisions must remain reviewable |
| Signed bootstrap HTTP serving and deployment | Private op-hub service; public signed response |
The online service holds no root private key; private deployment topology is not a cryptographic control |
| 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.
Relay operator visibility
The relay never sees document content. The tunnel payload is a byte-for-byte
bridge of the same prelude, Noise XX_25519_ChaChaPoly_BLAKE2s handshake, and
encrypted records the LAN path uses, and post-pair frames are forwarded as
opaque binary without being parsed. Admission is re-established inside the
Noise channel, so peer admission never depends on the relay's view.
What the relay does see, in plaintext after its own TLS terminator, is more than "routing metadata" in the narrow sense, and is stated here explicitly:
-
The full admission ticket, presented as the WSS
Authorization: Bearercredential and verified by the relay. Its claims carry the global account subject, the device id, and — when present — the display name and avatar URL. Cross-account collaboration is supported, so a relay operator reconstructs which accounts collaborate with each other, from which devices, and when — a social graph, not merely one account's device fleet. This is the strongest argument for minimizing the credential, and it is why the minimization is tracked as open work below rather than as a nicety.Note also that the relay reads exactly one field out of this ticket, the expiry it clamps the session deadline to. The subject, device id,
jti, display name, and avatar URL are format-checked and then discarded — they are never compared, stored, or returned by the authorization path. The disclosure is therefore gratuitous rather than load-bearing. -
The client hello: route id, role, caller device X25519 public key, possession proof, and the embedded signed locator (owner Noise static key, region, validity window, discovery id).
-
Traffic shape: pairing times, message counts, and volume.
-
The cleartext server prelude, which necessarily precedes the handshake because it is the Noise prologue. It carries
session_idandepoch, which are stable correlation handles for the lifetime of a session — unlike the discovery id, which rotates every five minutes.
Residual risk: this is a disclosure to the relay operator, not to the network,
and it is inherent to operating a relay that authenticates before forwarding.
It is accepted for the first-party relay. Two mitigations were investigated. A claim-minimized relay credential that
carries no account identity is open work, and is designed: the relay's
authorization output is (route, role, expiry), none of which comes from the
ticket's identity claims, so the credential can be reduced to an
audience-scoped token carrying only issuer, audience, version, scope, the
channel binding to the caller's X25519 key, and the time bounds. Two honest
limits on what that buys. First, it de-identifies but does not make the relay's
view unlinkable: the device's X25519 static key is persistent and travels in
cleartext in every hello, so the operator still builds a permanent device
graph — it simply can no longer name the nodes or join them to the account
namespace used by support, billing, and every other first-party service.
Second, an operator who runs both the issuer and the relay can re-identify a
connection by joining on the channel-binding key and the issuance timestamp, so
against the first party the change is close to symbolic; its real value is
against relay compromise, log leakage, a third-party or regional relay
operator, and relay-scoped lawful-access requests. Implementation is blocked on
the private provider ABI, which mints the credential.
Replacing the cleartext session_id in the prologue with a binding commitment
was prototyped and rejected: the prologue is not merely a binding, it is the
only channel by which a guest learns the session id at all. The LAN join path
reads it straight out of the prelude, the mDNS record deliberately carries no
session identifier, and the relay invite carries only the locator and route
capability. A commitment would therefore require publishing the session id
somewhere a guest can reach before connecting — on LAN that means the mDNS
record, which trades a handle visible to one relay operator for a handle
broadcast to the whole local network. The marginal gain was also narrower than
it first appeared: the relay already holds the discovery_id inside the signed
locator for the whole session, so only cross-epoch correlation would have been
closed. Closing this properly needs the session id added to the invite and a
different LAN join handshake — a product change, not a protocol tweak. Until then, the deployment requirement is
that relay and locator operators must not persist bearer headers, hello bytes,
or client addresses beyond what an incident needs, and must not log them at
all: no relay or locator code path interpolates ticket, key, or identity
material into a log statement, and both binaries clamp their log filter to
their own crate so dependency traces cannot be turned on to dump wire data.
Invite disclosure and re-sharing
A relay invite is a bearer capability. Anyone holding it can reach the pairing step for that route, so it is treated as a secret: it is never placed in a URL path or query, its pairing window is capped at one hour, and it is bound to the signed locator's region and validity window. It does not by itself grant admission — the owner must still approve each guest, and the guest must still present a valid ticket bound to its own device key inside the Noise channel.
Residual risk: a leaked or forwarded invite lets an unintended party consume pairing capacity and reach the owner's approval prompt, which is a social- engineering surface (an approval prompt for a plausible-looking name) rather than a cryptographic one. Deliberate re-sharing by an authorized participant is out of scope, consistent with the rest of this model.
Local process boundary during a session
The desktop exposes an HTTP JSON-RPC MCP endpoint on 127.0.0.1 so external
agent CLIs can drive the editor. During a collaboration session this endpoint
reads and writes the same document that the admission system guards, but it is
a local boundary: its per-instance token authenticates identity probes and
shutdown, while document tool calls are available to any process running as the
same user, and the endpoint does not check Origin/Host.
Residual risk: any local process — or, through DNS rebinding, a web page open
in a browser on the same machine — can read or mutate the shared document
without passing session admission. A CollabGatePolicy can reject MCP
mutations mid-session, but reads and the boundary itself remain outside the
collaboration trust model. This is accepted for a single-user desktop where a
hostile local process already has the document on disk; it is called out here
because the collaboration feature widens the blast radius from "this user's
file" to "every participant's live document". Origin/Host validation and
authenticating document tool calls are open work.
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.
Bootstrap mirror compromise, rollback, and region confusion
A compromised op-hub mirror, DNS path, CDN, or TLS terminator can deny service, replay bytes, or return malformed data, but cannot authorize a new endpoint or public key without a valid domain-separated root signature. Canonical JSON and base64url checks remove alternate encodings; bounded response, payload, region, and key counts constrain parser and allocation work. An invalid signed time window, weak key, unknown root, malformed ETag, lower generation, or same-generation rewrite fails closed.
LKG is an availability control, not a trust bypass. It is usable only while its signature and validity window still verify, and it does not conceal a malformed 200 response. Operators must nevertheless treat cache deletion, corruption, unreadability, persistence failure, bootstrap URL changes, and unsynchronized regional mirrors as rollback-risk events because the client has no durable cross-endpoint global generation ledger without a valid cache. Production rollouts must publish one byte-identical envelope to domestic and overseas mirrors, advance the op-hub minimum-generation floor, and preserve overlapping region keys inside the signed snapshot. Rotating the embedded root requires a coordinated client-and-service release; the current production desktop does not load additional roots from runtime configuration.
The invite's signed home_region is authoritative. Physical geolocation,
bootstrap mirror location, DNS answer, and edge ingress do not authorize a
different region. If an overseas client cannot reach the CN entry for a CN-home
invite, it closes or reports relay unavailable; it does not mix Global
endpoint/key material or silently create a Global-home session.
Which accounts may pair
Collaboration is cross-account: a peer holding a valid ticket from the trusted issuer may pair with a peer of any other account. The account is therefore no longer the authorization, and what replaces it is deliberately asymmetric, because the two sides do not have the same ability to answer "who is this?".
- The owner accepting a guest admits any issued account. Nothing at this
layer decides whether the guest joins — a human does, from the approval
prompt, which is shown the verified identity, and which the admission state
machine makes unskippable (
Activeis reachable only throughOwnerAuthorized). - A guest joining by invite or relay admits any issued account, because the invite's signed locator has already pinned the owner's Noise static key and that pin is checked before admission runs. The device is authenticated regardless of the account behind it, which is precisely what makes joining a stranger's session safe.
- A guest joining over an unpinned LAN discovery still requires the same account. A guest has no approval prompt — whatever it accepts, it accepts silently — and on an unpinned LAN join nothing else names the peer: mDNS is spoofable and no key is known in advance. There the subject is the authentication, so relaxing it would let anyone on the segment holding any valid ticket pose as the owner, undetected.
Relaxing the account relaxes nothing else: the issuer must still be the pinned one, the ticket must be unexpired, and it must be bound to the Noise static key actually observed on the connection. Renewal continuity is also unchanged — a session's issuer, subject, device id, and static key may not change mid-session.
Residual risk: the unpinned LAN path remains same-account only. Opening it needs a way for the guest to confirm who it is joining, which is a user-facing decision rather than a protocol change.
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.