OCP Companion profile — normative specification (v1.0-draft)
Extends Core. A Companion is an authenticated remote I/O device (typically a phone doing voice) bound to ONE Surface session.
1. Discovery
GET {iss}/.well-known/ocp.json (schema: wellknown.ts) over TLS. JWKS keys
carry kid and rotate; Companions SHOULD cache per HTTP caching headers.
1.1 The deployment declaration (OPTIONAL)
The document MAY carry a deployment object (schema:
wellknown.ts#deploymentSchema) — the operator's standing disclosure, machine-
readable where until now it lived only in prose (Core §5.2, §11):
deployment.answerMode—hub: the operator's model reads the question (Hub-as-answerer).companion: this deployment relays, and answers are generated on the visitor's device. The registry is Core §5.1's, the tense is not: this field states what the deployment IS,session.linked.answerModestates what one session settled on, andturn.done.answeredBywhat actually answered (Core §5.2). A reader MUST NOT substitute one for another.deployment.operator(OPTIONAL) — who operates this Hub, human-readable, for display.deployment.dataResidency(OPTIONAL) — ISO 3166-1 alpha-2 country code: where session data is stored and processed.deployment.retention(OPTIONAL) —{transcripts?, audit?}, each an ISO-8601 duration in whole units (P90D,PT24H) or the literalnone.noneclaims zero retention; an ABSENT field claims nothing, and a reader MUST NOT collapse the two — most deployments that say nothing retain something.
A deployment that declares MUST declare truthfully: a false declaration is
nonconformant. A declaration is a CLAIM by the operator, not a property the
wire can verify — a Companion MAY display it, and displaying it as verified
fact would be the lie the retired sealing profile already taught this spec to
refuse (retired/sealing.md; Core §5.2 holds answeredBy to the same
standard).
2. Pairing (delegated — the default)
- Surface → Hub
pair/start(HTTPS, authenticated by whatever the Surface has). Hub mints{sid, nonce}(each ≥128-bit random) and a ≤90 s ES256 JWS (claims:pairing.ts#pairTokenClaimsSchema), rendered asocp://pair?d=<b64url(JWS)>ONLY inside that Surface session. - Companion scans → fetches
.well-known→ verifies the JWS OFFLINE (±30 s skew) → MUST requirenew URL(cap).origin === new URL(iss).origin. - Companion → Hub
pair/claim {jws, devicePubKey, attestation?, device}(HTTPS). The nonce is burned ATOMICALLY. Attestation MAY be required for T1+, MUST be for write scopes. All failures return uniformPAIR_DENIED. - Hub → Surface
pair.approval_request. Delegation MUST NOT activate before a human approves ON THE SURFACE (anti-QRLjacking). - On approval the Hub mints a Companion session token (15 min, silently
refreshed over the live WS) bound to
sid+ device key + tier + scopes; both peers receivepair.linked. - The Surface MUST show a persistent connected indicator with one-click revoke; every pair/claim/approve/revoke is audit-logged.
Proof-of-possession: every subsequent connect and sensitive call is signed with the device key. A stolen token without the key is useless.
Token refresh: a Companion with a live, authenticated WS leg SHOULD send
session.refresh before its token expires (recommended: at 2/3 of the TTL).
The Hub replies session.refreshed {token, expiresIn} with a new token bound
to the SAME cnf.jkt, tier, and scopes — no re-pairing, no new PoP over the
established leg (possession was proven at connect). Reconnect/resume MUST
present the newest token with a fresh PoP. A Hub MUST NOT extend a session's
absolute TTL through refresh.
Account-bound variant (OPTIONAL): when the claim carries a Companion JWT whose subject equals the Surface session's subject, the Hub MAY auto-approve. Cross-subject delegation ALWAYS requires explicit on-Surface consent.
2.1 Spoken code (OPTIONAL, and NOT for sealed pairings)
A QR is a visual gate. A visitor who cannot see the Surface has no way through it, and on a kiosk or a shared terminal that visitor has no alternative device to fall back to either. A Hub MAY therefore mint, alongside the deeplink, a short code the Surface can display as text and a person can read aloud:
- The code MUST resolve to the SAME pairing token the QR encodes. It is a second way to reach one token, never a second kind of token, so the single-use nonce, the device-key PoP and the mandatory on-Surface approval all apply unchanged.
- It MUST expire with that token.
- It MUST NOT be single-use. The nonce is what may be spent once; burning the code at resolution would let anyone who can reach the endpoint invalidate a code they cannot use, by guessing it once.
- A Hub SHOULD draw it from an alphabet with no ambiguous glyphs and SHOULD normalise case, spaces and hyphens on the way in — that is what a person reading characters off a screen produces.
- Resolution MUST share the claim rate limiter, and a resolution failure MUST be indistinguishable from a claim failure. Two different refusals would turn the code into an oracle.
A bare code CANNOT seal on its own. Sealing works because the Surface's
ephemeral public key rides on the deeplink and is appended CLIENT-SIDE, so the
Hub never sees it and cannot substitute its own (SPEC/retired/sealing.md §4,
RETIRED). A code
is resolved BY the Hub, so a sealed pairing established over nothing but the
code would be one whose key exchange the Hub mediates unchecked. §2.2 defines
the one construction under which that exchange is safe to rely on; a Surface
that seals MUST NOT display a code except as part of it.
2.2 Sealed code pairing (commit-then-reveal)
A visitor who cannot see the Surface needs sealing no less than a sighted one,
and a visitor browsing on the SAME device that runs the Companion has no camera
angle even with perfect eyesight. For both, s must travel through the Hub —
there is no other channel — and this section exists to make that single
transmission survivable.
The inversion, stated before the mechanism: on this path the SAS ceremony IS
the authentication. §3 of sealing.md makes the out-of-band channel primary
and the SAS defence in depth, and that ordering still holds — for camera
pairings, which this section does not touch. A code pairing has no out-of-band
channel, so its security rests on the human comparison: ~20 bits, online-only,
kept honest by the forced choice. That is the standing of every Bluetooth
numeric-comparison pairing, and it is stated here rather than smoothed over.
What makes 20 online bits enough is that the offline attack is removed. Without
more, a Hub in the middle picks both ephemerals and grinds one of them —
sealing.md §5 puts ~10⁶ ECDH+HKDF pairs at seconds — until the two sides'
digits match. Commit-then-reveal removes its freedom to pick:
- COMMIT. A Surface that seals and offers a code MUST send
sealCommit = base64url( SHA-256( s_pub as its 33 compressed bytes ) )with itspair/startrequest, and the Hub MUST store it with the code, for the token's own TTL. - RESOLVE. A Companion pairing by code MUST resolve the code FIRST
(
POST /ocp/pair/resolve), receiving the commitment together with the token'ssidandnonce— the transcript inputs a scanned deeplink carries and eight characters cannot (sealing.md§6.1 binds both into the salt). Handing them to a code-holder grants nothing the code does not already grant, and a Hub that lies about either produces a SAS mismatch, which fails closed. The Companion MUST NOT have disclosed its ownsealPubKeyto this Hub beforehand. The claim itself still travels by code (claim-by-code, now withsealPubKey), so the Hub knows first-hand which claims are code-borne. The split is the security boundary, not a transport nicety: the response that carries the commitment is produced before the Hub has seen the Companion's key, so a substituteds'is fixed blind. Resolution shares §2.1's rate limiter, and its failures are byte-identical to claim failures. - REVEAL, Companion half. The Companion claims with its
sealPubKeyexactly as a scanned claim would (sealing.md§3). - MARK. The Hub marks the claim
via: 'code'onpair.approval_request. A claim of the same token by deeplink carriesvia: 'qr'; absent MUST be read as'qr', because the reveal must never default on. - REVEAL, Surface half. A sealing Surface that receives an approval
request marked
via: 'code'— and that itself offered a code for this pairing — MUST sendpair.reveal { claimId, sPub }to the Hub, which delivers it to the Companion on the result poll (sealReveal). A Surface MUST NOT sendpair.revealfor a claim not markedvia: 'code', MUST NOT send it twice for one pairing, and a Surface that displayed no code MUST NOT send it at all. - VERIFY. Before deriving anything, the Companion MUST check that
SHA-256 of the revealed key equals the commitment from step 2. A mismatch,
a reveal with no commitment behind it, or a second, differing reveal MUST
fail closed exactly as a SAS mismatch does (
sealing.md§4): the session ends, and there is no unsealed fallback. - SAS. Both sides derive per
sealing.md§5, unchanged — same salt preimage, same six digits — and the mandatory forced-choice approval on the Surface proceeds per §3.1. The ceremony's rendering MUST be exposed to assistive technology on both devices; §3.1's ban on an affirmative default applies to the accessible rendering equally, or this section closes one door for a blind visitor while §3.1 quietly holds another shut.
Why the ordering defeats grinding: a Hub substituting toward the phone must fix
s' when it answers resolution — before it has seen the phone's key — and a
Hub substituting toward the Surface must fix its companion key when it sends
the approval request — before the Surface has revealed s. Each side's digits
are therefore beyond the Hub's influence before it can compute either. What
remains is one blind 10⁻⁶ guess per pairing, spent against a human performing
a forced choice.
Residual exposure, normative to acknowledge:
- The Hub learns
s_pubhere. The camera path's stronger property — a Hub that substitutes keys cannot even COMPUTE the Surface-side secret — does not apply, which is why step 5 scopes the reveal so narrowly. - A hostile Hub may falsely mark a scanned claim
via: 'code'to coax a reveal out of a Surface that displayed a code. The reveal follows both transmissions it could have wanted to influence, so this enables no grinding; it degrades that one pairing to this section's SAS-primary standing, and a Surface that showed no code gives it nothing.
3. Connecting: the device-key proof of possession
Core §14 makes this mandatory — "every companion connect and resume presents a fresh device-key signature over the current token hash with a bounded timestamp" — and until now it was specified NOWHERE. The only definition in existence was one Hub's verifier, which meant an independent Companion could not construct a conformant PoP from this document and no golden vector covered the most security-critical handshake in the protocol. This section is that definition.
A Companion connects to cap (the WSS endpoint from the pairing token) with:
| Parameter | Value |
|---|---|
role | companion |
token | the companion session token, verbatim |
pop | the proof, below |
jwk | the device PUBLIC key, JSON, as a JWK |
resumeKey | present only when resuming |
-
popis a COMPACT JWS,alg: ES256, signed by the device PRIVATE key. Its payload is exactly:{ "th": "<base64url(SHA-256(token))>", "ts": 1786192928123 }this the hash of the token BYTES as sent, base64url without padding.tsis milliseconds since the epoch at the Companion. -
A Hub MUST verify, in this order, and MUST answer every failure identically:
- the token's own signature and issuer;
jwk's RFC 7638 thumbprint equals the token'scnf.jkt— this is what binds the token to the device rather than to whoever holds it;- the
popJWS verifies againstjwk; pop.thequals the Hub's own hash of the token;|now - pop.ts| <= 60000.
-
jwkis REQUIRED. A Hub verifying a signature has to have the key, and the token carries only a thumbprint; a Hub that stashed the key at claim time MAY use that instead, but it MUST NOT accept a connect with neither. -
The window is ±60 s and a Hub MUST reject outside it. It is a replay bound and not a clock sync: a Companion whose clock is wrong by more than a minute cannot connect, which is the correct outcome, because the alternative is a signature that stays valid for as long as anyone holds it.
-
A missing or non-numeric
tsMUST be rejected.Math.abs(now - undefined)isNaNand every comparison against it is false, so an implementation that compares without checking the type accepts a proof that never expires.
Golden vector: vectors/pairing.json, pop-connect.
4. Sessions & scope
One active Companion per session; a second claim requires an explicit takeover
confirm on the Surface. The Companion tier is capped by the Surface session's
tier. Scopes are explicit (context.read, qa.ask, tools.invoke:<name>).
5. Voice
Default: mic/VAD/STT on-device → turn.input{text}; Hub streams text back for
on-device TTS. stt/tts location is negotiated at session.open. Confirms
render natively on the Companion and are equivalent to Surface confirms.
6. Answering
A Companion MAY carry the model as well as the microphone. Where answers are
generated is negotiated like speech is: capabilities.answer at
session.open, settled as answerMode in session.linked (Core §5.1), with
provenance disclosed in answeredBy (Core §5.2). Absent answerMode MUST be
read as hub, so a Companion that never answers is unaffected by any of it.
The obligations of a Companion that does answer — the frames it may originate,
why answering confers no authority to act, and what the Hub still sees — are in
companion-provider.md.
7. Sealing
A pairing MAY additionally agree a key with the Surface so that content crosses
the Hub sealed. The Surface appends its ephemeral public key to the deeplink
CLIENT-SIDE (ocp://pair?d=<jws>&s=<key>), so the key reaches the phone by
camera and never through the Hub; the Companion returns its own on pair/claim,
and both sides show six digits the human compares on the approval screen §2 step
4 already requires. The Hub carries the keys and never the digits.
That out-of-band path — not the digit comparison — is what authenticates the
exchange, and the ordering matters: carrying s in a frame instead would be a
total break. sealing.md is normative, including §1, which states what sealing
does not protect.