RETIRED — NOT IMPLEMENTED
Status as of 2026-08-08: this profile has never worked end to end, in any implementation, and is retired rather than corrected.
It is kept here because the wire contract it defines (
sealed,sealMode,pair.reveal,SEAL_REQUIRED, theocp-seal-1suite and its golden vectors) is still registered in v1.0 and removing it is a major-version change. Nothing in this repository negotiates it:seal="require"is refused at config time andseal="offer"runs unsealed.Why it never worked. §6.1 binds the claiming device's
cnf.jktinto the transcript salt and calls the binding not optional — but no registered frame carries a thumbprint to the Surface.pairApprovalRequestSchemahassealPubKeyandviaand nothing else. So the Surface always read a field that never arrives and always took the unsealable branch:offerran in the clear andrequirerefused every pairing, for every Companion, always. The reference Companion had a second, independent break on the §2.2 code path. Three fail-closed implementations of a property that could not be true.What replaced it. §1 of this document already named the thing that beats it, and that is what shipped: a Companion that does not mirror leaves the Hub turn boundaries, with no cryptography and no Hub-served code in the confidentiality path. See
MirrorMode/transcript="phone-only".What is NOT replaced, and is an open problem. §9's confirm-under-sealing is the one construction here with no counterpart anywhere: an untrusted relay that must GATE an authorization decision on content it cannot read. Phone-only does not solve it — it does not cover the tool path at all — and neither do MLS, SFrame, Noise or FIDO. It is left stated because it is real and unsolved, not because it is scheduled.
OCP Sealing profile — normative specification (v1.0-draft)
Extends Core, the Companion profile, and the Companion-Provider profile. The key words MUST, MUST NOT, SHOULD, SHOULD NOT, MAY are per RFC 2119.
The Hub brokers every frame (Core §1). In answerMode: 'companion' the model
moved to the visitor's phone, but the question still reaches the Hub — it
mirrors the transcript to the Surface — and every turn.delta passes through it
because the Surface has to render them. companion-provider.md §5 says so and
forbids implementations from claiming otherwise. This profile is the part that
makes the relay blind to CONTENT while leaving it able to do its job.
Sealing is NEGOTIATED and OPTIONAL. A session that does not settle on it behaves exactly as it did before this document existed.
1. What sealing protects, and what it does not
Implementations MUST NOT describe sealing in terms broader than this section. The limits are not caveats appended to a claim; they are the claim.
Sealed sessions guarantee:
- No conversation content crosses the relay leg, whatever terminates TLS in front of it, or the Hub's logs, heap and storage in plaintext. A CDN or a tunnel provider is one such terminator; a deployment that terminates TLS on its own box has one fewer party in that list and not zero.
- The Hub holds no key material at any point. A later breach, subpoena, acquisition or change of operator cannot recover past conversations from anything the Hub retained.
- The device key (
companion.md§2) is used only for signing andcnf.jkt, never for ECDH. A device-key compromise therefore reveals NOTHING about past session content. - The pairing offer cannot be stripped or downgraded by the relay, because it travels out of band (§4).
Sealing does NOT protect against a Hub that serves modified widget code.
The Hub serves the Surface's widget document and chooses its appScriptUrl and
that script's own integrity hash (embed.md, Topology). The Surface's entire
trusted computing base is therefore delivered by the party sealing defends
against, per request and uncacheable. A Hub that ships one visitor a modified
bundle can read that visitor's conversation, and the six-digit check cannot
detect it, because the same bundle renders the digits. No arrangement of
protocol messages fixes this, and implementations MUST NOT imply otherwise.
What sealing changes is nonetheless real, and is the reason to ship it: reading a conversation stops being a matter of reading a log and becomes an active, targeted, per-visitor act of serving different code. That is a different thing legally, operationally, and in detectability — the same argument as reproducible builds.
Two consequences implementations MUST carry into their documentation:
- A deployment where the Hub operator and the site operator are the SAME party
gets no protection from that party.
fantas.aiembeddinghub.fantas.aiis sealed against the network path and against the Hub's own logs and retention; it is not sealed against fantas.ai. Deployments MUST state which of these they are. - The Surface is a decryption endpoint. It decrypts tool arguments and hands
them to the host page to execute (
embed.ts), so whoever controls the page controls the plaintext.
Sealing does not protect against the relay's remaining powers. It is a
confidentiality control, not an integrity control over the conversation's
CONTROL flow. The Hub can still drop, stall, delay, reorder-into-failure or
truncate; it can close a session; and ui.request is Hub-initiated, so it can
raise a confirm at any time with wording of its own choosing. Sealing makes
content unreadable and unforgeable; it does not make the relay honest.
The metadata the Hub keeps, stated in full (§8) is not incidental. On a
medical, legal or financial site, the sequence search → get_price →
book_appointment at known times IS a disclosure, arguments or no arguments.
The honest summary for a key-custody test: key custody is on the visitor's
phone and in the visitor's browser, and the relay holds nothing — followed
immediately by: the browser half is delivered by the relay. Implementations MUST
NOT present answerMode: 'companion' plus sealing under the sentence "the Hub
cannot see your conversation". companion-provider.md §5 already forbids that
sentence, and sealing does not earn it.
Stronger alternatives exist and SHOULD be offered where they fit. A
Companion that simply does not mirror turn.input/turn.delta to the Surface
gives the Hub turn boundaries and nothing else, with no cryptography and no
Hub-served code in the confidentiality path. Where the visitor does not need the
transcript on the page, that is strictly stronger than sealing.
2. Roles and the shape of the construction
- The Surface generates an ephemeral P-256 key pair per session and holds the session's sealing keys. It seals what it sends and opens what it receives.
- The Companion does the same with its own ephemeral pair.
- The Hub relays sealed units and MUST NOT hold, derive, escrow or log any key material, plaintext, or SAS.
The Companion is not Hub-delivered — it is an app-store binary with an OS-keychain root key — and that asymmetry is what every guarantee in §4 rests on.
3. Key agreement
- The Surface generates an ephemeral P-256 key pair. The private key SHOULD be
non-extractable where the platform supports it (WebCrypto
generateKey(..., false, ['deriveBits'])). This does not stop a hostile Hub (§1); it does stop XSS in the widget document, error-reporting paths, and a Hub that turns hostile without redeploying. - The Surface obtains the pairing JWS from
pair/startas today, then appends its own public key to the deeplink CLIENT-SIDE:ocp://pair?d=<jws>&s=<b64url(compressed P-256 point)>. The Hub mintsdand MUST NEVER sees.sis 33 bytes SEC1-compressed → 44 characters. - The Companion scans, verifies the JWS per
companion.md§2, generates its own ephemeral P-256 pair, and sends the public key assealPubKeyonpair/claim. - The Hub relays that key unchanged on
pair.approval_request.sealPubKey. - Both sides compute
Z= P-256 ECDH, X coordinate only, 32 bytes big-endian, and derive as in §5.
s MUST be per SESSION, not per QR rotation. The reference Hub re-mints the QR
every ~10 s; a Surface that rotated s with it would leave a phone that scanned
a slightly stale code deriving a dead key, and would force the widget to retain
several private keys for no benefit.
The out-of-band channel is what provides authentication. The SAS is defence in depth. This ordering is normative and implementations MUST NOT invert it.
The two directions are not symmetric. s reaches the phone by screen→camera, a
path the Hub is not on, so the phone ALWAYS holds the authentic Surface key. A
Hub that substitutes the Companion's key toward the Surface makes the Surface
derive a secret the Hub cannot compute — it never learned s — so it can break
the session but cannot read it. sealPubKey travels through the Hub and IS
substitutable; that is expected, and it is the entire reason §4 requires a SAS.
This is written down because the obvious later "simplification" — carrying s
in session.open, since the SAS covers it anyway — is a total break. A Surface
MUST NOT transmit s in any OCP frame, with exactly ONE exception:
pair.reveal on the sealed code path (companion.md §2.2), where a
commitment fixed before either ephemeral is disclosed is what makes that
single, narrowly-scoped transmission safe to rely on. The exception does not
soften the rule here: on the camera path the ban is absolute, and a Surface
MUST NOT send pair.reveal for a claim that did not arrive by code.
s is treated as PUBLIC. A construction whose security rests on a public key
having stayed secret is brittle, so the SAS and not the secrecy of s is what
this profile relies on. Even so, s and every derived secret MUST NOT appear in
any frame, URL sent to the Hub, log line, analytics event, or error report, and
MUST NOT cross the host-page postMessage boundary (embed.ts).
3.1 Short authentication string
Both sides derive six digits from the shared secret (§5) and display them: the
Companion on its own screen, the Surface on the approval screen it already shows
(companion.md §2 step 4). A human compares them before approving. No new
ceremony is introduced; the mandatory approval was already there.
- The SAS MUST be computed independently on both sides and MUST NOT be transmitted in any frame, in either direction, ever. A SAS the relay carries is a SAS the relay can make match, and that is the usual way this check is quietly neutered.
- The SAS MUST be rendered identically on both devices — six digits, grouped three and three, zero-padded.
- The Surface's approval step MUST NOT present an affirmative default that can be taken without performing the comparison. A screen offering a primary "Allow" button beside some digits collects approvals, not comparisons. A forced choice — the phone shows one code, the Surface shows the real one among decoys plus an explicit "none of these match" — costs the same single tap and makes the shortest path through the screen the comparison itself.
- A mismatch and a deliberate denial MUST produce the identical
pair.approval_response {approved: false}. A Hub that can tell "I was caught" from "the human said no" is a Hub an adaptive attacker can probe. Reason codes MUST NOT be added to this frame. - Because mismatches in production are overwhelmingly benign (a second tab, a stale claim, a version-skewed bundle, two people pairing in one room), mismatch copy SHOULD name those causes first. Copy that alleges interception on a benign event trains dismissal.
- The SAS covers the INITIAL key agreement and nothing after it. It is compared once.
Repeated forced re-pairings are an attack on the human, not on the cryptography: a relay that drops the session enough times gets a visitor who stops comparing. Surfaces SHOULD rate-limit re-pair prompts, MUST NOT silently auto-restart the QR after an unexplained drop, and SHOULD escalate rather than routinise the UI on repeated re-pairs within a window.
3.2 Non-visual pairing
The normative requirement is an out-of-band channel from Surface to Companion that the Hub cannot modify. A camera-scanned QR is the only encoding this version defines, and it is unusable by a visitor who cannot aim a camera.
A deployment that requires sealing therefore MUST NOT thereby make the assistant unreachable: it MUST either offer an unsealed path or an alternative out-of-band encoding, and MUST disclose which. Non-downgradable sealing over a camera-only channel is an exclusion, not a degradation, and shipping it as the only path is non-conformant.
4. Negotiation, and why downgrade protection does not come from it
Three gates. The third is the one that holds.
-
Offer. The Surface sets
capabilities.seal: trueinsession.open. A Hub that never heard of the field strips it. -
Settlement. The Hub answers
session.linked.sealMode:'none'or'e2e'. The field is OPTIONAL and absent MUST be read as'none'.Settlement MUST be this separate top-level field. It MUST NOT be read off
session.linked.capabilities.seal: both reference Hubs echo the offered capabilities back verbatim rather than intersecting them, so a Hub that merely upgraded its protocol package would assert a security property with nothing behind it, and a Surface would then commit to sealing against a Hub that strips ciphertext. Core §5's "the Hub answers with the intersection" is not implemented by the reference Hubs and MUST NOT be built on. -
The out-of-band gate.
&s=in the deeplink is what makes sealing possible at all. A Companion that finds noscannot seal; a Surface that never appends one never offered. The Hub is not a party to this gate.
Negotiation buys compatibility. It does not buy non-downgradability, and the two MUST NOT be conflated. A hostile Hub simply never settles. What cannot be downgraded is the Surface's own offer, because the offer travelled by camera:
- A Hub cannot strip
&s=— it never saw it. - A Hub telling the Surface "your peer does not support sealing" is not
evidence; the Surface put
son the screen itself. - A Hub telling the Companion "the Surface does not support sealing" is
contradicted by the
sthe Companion scanned.
Rules that follow, all normative:
- Once a Surface has offered sealing for a session, accepting an unsealed
sealed-eligible frame for that session is a FAILURE, not a fallback. The peer
MUST send
error{SEAL_REQUIRED}and MUST close the session (session.closed{reason: 'error'}). It MUST NOT drop the frame silently: silent dropping lets frame injection hide behind a lossy network. - The same applies to a sealed unit that does not authenticate, that carries an
unimplemented
suite, or whosekidis not the session's. - Attrition MUST fail closed. Repeated failures end in an error state visible to the visitor, never in a plaintext session.
- Sites configure policy locally —
seal: 'require' | 'offer' | 'off'in the embed config or equivalent.'require'means the Surface refuses to send content in the clear whateversession.linkedsays. This switch MUST live on the Surface. A Hub-side sealing switch is a downgrade oracle held by the party sealing protects against. - A Companion SHOULD keep a sticky per-Hub-origin record that a Hub has offered sealing before, and SHOULD warn loudly on a later unsealed pairing with that origin. The Companion is not Hub-controlled, which is the only reason this works. It MUST be user-clearable, with a warning — a legitimate rollback would otherwise brick the pairing — and MUST NOT clear itself silently.
4.1 Relays MUST carry unknown payload fields
This profile depends on a rule Core §2 did not state, because §2 was written for
terminal receivers. "Ignore unknown fields" is safe for a receiver and unsafe
for a broker: a relay that validates a frame with an older schema and then
re-serialises it does not ignore an unknown sealed field, it DELETES it and
forwards a hollow frame.
A relay that re-serialises a frame MUST carry unknown payload fields through
unchanged. Stripping is not ignoring. Sealing is therefore NOT transparently
additive at the Hub: a Hub must ship a version that carries sealed through,
and settlement (§4 gate 2) is what tells the Surface whether it did.
5. The sealed and the clear
sealed is {suite, kid, epoch, counter, ct} (sealing.ts). A frame carries
the sealed unit INSTEAD of the cleartext field, never alongside it: a frame with
both is the shape a downgrade takes, and receivers MUST reject it rather than
prefer one.
Implementers validating against the published JSON Schemas MUST enforce that
exclusivity themselves. The mirrors at /ocp/schemas/1.0/ are generated from
the Zod contracts and cannot express the rule — both fields appear as merely
optional — so a JSON-Schema-only validator accepts exactly the frame this rule
exists to reject. The Zod contract and the reject vectors are authoritative
here; the mirror is not.
| frame | sealed | stays clear |
|---|---|---|
turn.input | {text, lang} | turnId, audioRef |
turn.delta | {text} | turnId |
turn.citation | {resource, quote} | turnId, index |
turn.done | — | all of it |
turn.error | — | all of it |
context.set | {url, resource, selection, view} | rev |
action.request | {input} | actionId, tool, turnId |
action.result | {output, error} | actionId, ok |
ui.request | the gated action's unit, relayed verbatim (§9) | everything else |
ui.response | — | all of it |
tools.advertise | — | all of it |
every session.*, pair.* | — | all of it |
Decisions in that table that are not obvious, with their reasons:
- The tool catalogue is never sealed.
name,locationandwriteare the inputs to per-call re-authorisation (Core §9) and the write gate (Core §10); a Hub that cannot readwritecannot know which calls to gate, and §10 collapses entirely.descriptionandinputSchemacould be sealed and are not: the Hub serves the widget and resolves the site key, so it already holds the site's catalogue from its own configuration. ui.responseis never sealed. The Hub IS the gate; it resolves the confirm. Sealing the verdict breaks §10 outright. The Hub therefore learns every approve/deny and which peer made it.turn.erroris never sealed. The Hub needscodefor barge-in (Core §8), and sealing onlymessagewould add a second sealed shape for a field nobody renders. Implementations MUST keep answer content out ofturn.error.messagein a sealed session.turn.done.answeredBystays CLEAR in this version. That discloses to the relay which model vendor and which model answered for every visitor —on-device, or a named provider. Core §5.2 requires that disclosure to the VISITOR, not to the relay. It is left clear here because the reference funnel reads it and sealing it is a separable change; a deployment for which the visitor's choice of model vendor is sensitive does not get that hidden by this version, and MUST say so rather than assume it.context.set.revstays clear so the Hub can keep enforcing monotonicity (Core §6). A Hub that reads context CONTENT cannot be a sealing Hub — it will hold{rev, sealed}and nothing more. That is Core §5.1 restated, not a defect: a Hub that answers cannot be blind to the page any more than to the question. A Hub MUST treat stored context as inert in a sealed session.audioRefis not sealed. The audio was uploaded to the Hub; sealing a pointer to it would protect nothing. A sealed session that uses the audio extension discloses its audio to the Hub, and implementations MUST say so rather than let sealing imply otherwise.
6. The crypto contract — suite ocp-seal-1
Normative and exact. Encodings are in seal-contract.ts; known-answer vectors
are in vectors/sealing.json. Two implementations that agree on this section
interoperate; one that differs is caught by the vectors rather than by a session
that will not talk.
Implementations MUST NOT implement these primitives themselves. P-256, HKDF,
SHA-256 and AES-GCM come from WebCrypto or @noble/*. ECDH implementations MUST
validate the peer point and reject the identity; @noble/curves
getSharedSecret and WebCrypto importKey both do.
6.1 Derivation
All integers are BIG-ENDIAN. LP(x) is a 4-byte big-endian length followed by
x. HKDF is the full RFC 5869 HKDF-SHA256 — Extract and Expand in one call,
which is what WebCrypto's deriveBits and @noble/hashes' hkdf() both expose;
specifying bare Expand would force every implementation to hand-roll HMAC around
a primitive it already has, which is where implementations stop agreeing.
saltPreimage = LP("ocp-seal-1") ‖ LP(sid) ‖ LP(s_pub) ‖ LP(e_C_pub)
‖ LP(jkt) ‖ LP(pairNonce)
salt = SHA-256(saltPreimage)
Z = ECDH_P256(...) // X coordinate only, 32 B
K_0 = HKDF(Z, salt, "ocp/seal/v1", 32)
sasBytes = HKDF(Z, salt, "ocp/sas/v1", 4)
kidBytes = HKDF(Z, salt, "ocp/seal/keyid/v1", 8)
K_{n+1} = HKDF(K_n, empty, "ocp/seal/rekey/v1" ‖ uint32be(n+1), 32)
K_s2c = HKDF(K_epoch, empty, "ocp/seal/s2c/v1", 32)
K_c2s = HKDF(K_epoch, empty, "ocp/seal/c2s/v1", 32)
SAS = uint32be(sasBytes) mod 1_000_000, zero-padded to 6 digits
kid = base64url(kidBytes) // 11 characters
ZMUST NOT key AES directly. The transcript salt is what binds the derived keys to the pairing they came from.- Binding
jktis not optional. Without it the Hub can relay the claim from device A — whose ephemeral key the human's digits covered — and then mint the session token bound to device B's key, so the human verified a device that is not the one incnf.jkt. - Binding the whole transcript, not just the two public keys, is what keeps the SAS ungrindable. Six digits is 20 bits of ONLINE security only while the attacker has no free choice on the authenticated side. If a future change ever let an attacker choose both ephemerals, ~10⁶ offline ECDH+HKDF pairs would force a collision in seconds; the salt makes that search useless.
- Four bytes for the SAS, not two: reducing 2³² mod 10⁶ biases low values by ~2⁻¹² relative, which is unobservable, where a 2-byte reduction would be visibly skewed.
- Direction separation is at the KEY, not only in the AAD. A reflected unit then fails to decrypt by construction, rather than because a receiver implemented a string comparison correctly. A Hub MUST NOT relay a sealed unit back to its originator.
6.2 Nonces — the rule that must be airtight
GCM nonce reuse under one key leaks the plaintext XOR and the GHASH key, i.e. universal forgery. This is the part of the profile with no margin.
nonce (12 B) = uint32be(epoch) ‖ uint64be(counter)
Direction is NOT in the nonce because it is in the key (§6.1), so uniqueness needs only that one sender never reuses a counter within an epoch.
- The counter is per
(key, direction). It MUST NOT reset on turn boundaries,turn.cancel,TURN_ABORTED,context.set, or barge-in. "A new turn is a fresh start" is intuitive and is a nonce-reuse bug. - The counter travels in the clear inside
sealedand is bound in the AAD, so editing it produces a unit that does not authenticate. - It is a uint64 in the nonce but a JSON number on the wire, so the ceiling is 2⁵³−1. A sender that reaches it MUST close the session. Wrapping is nonce reuse.
- Random nonces MUST NOT be used. 96 bits is numerically survivable at these rates, but it discards the free replay/reorder/truncation detection below and costs 12 bytes of entropy per token-sized frame on a phone.
Resume is the reuse landmine, and the ratchet is the answer. Core §5 gives
the Companion a 60 s grace and a resume. The session key must survive it or
every reconnect needs a re-scan — and on a phone, backgrounding and process
death are routine, so persisting a counter durably ahead of every turn.delta
is not viable. Resetting counters to 0 on reconnect, the obvious
implementation, reuses the entire nonce sequence.
- On each COMPLETED resume both sides advance the epoch:
K_{n+1}per §6.1, eraseK_n, and reset counters to 0. A new key makes the reset safe. - Each side counts its OWN completed resumes. The epoch MUST NOT be taken from
any Hub-supplied field. A unit at an unexpected epoch MUST be rejected, never
resynced: a Hub that replays
session.resumedto one side only causes both sides to fail closed, which is the correct outcome. K_nMUST NOT be persisted across a process restart. A Companion that died MUST re-run key agreement. A persisted key beside a non-atomically-persisted counter is exactly the reuse case.
Receiver rules. The receiver keeps the highest accepted counter per (key, epoch). WebSocket delivery is ordered, so strict monotonicity is correct and
simpler than a window — and this, not the AAD, is what actually stops replay and
reorder.
- A unit whose counter is below the highest accepted MUST be rejected, UNLESS it is byte-identical to a unit already accepted at that counter, in which case it is a duplicate and MUST be delivered exactly once. Core §3 delivery is at-least-once, and §9 relays one unit on two frames deliberately.
- A gap is fatal, not a skip. A counter above the expected next value means a unit was withheld. The receiver MUST fail the turn. Otherwise the Hub silently truncates a streamed answer and the visitor reads a half-answer as a whole one — "you should not take the medication" cut at "you should" is the entire attack.
6.3 AAD
AES-256-GCM, 16-byte tags. The AAD binds the ciphertext to its routing metadata:
AAD = LP("ocp/seal/aad/v1") ‖ LP(suite) ‖ LP(kid) ‖ LP(sid)
‖ uint32be(epoch) ‖ uint64be(counter)
‖ LP(type) ‖ LP(turnId) ‖ LP(actionId) ‖ LP(tool)
‖ LP(rev) ‖ LP(index)
Absent fields are ZERO-LENGTH. rev and index are decimal ASCII when present,
so "absent" can never be confused with the value 0 — rev: 0 is a legal first
revision.
- Every field is length-prefixed.
sidis Hub-minted and arbitrary; a[sid, turnId, type].join(':')is a collision the Hub gets to choose. toolMUST be bound. It stays visible so the Hub can re-authorise by name (Core §9) — visible is not bound. Unbound, the Hub takes the sealed input meant forread_doc, relabels the framedelete_doc, getsdelete_docauthorised by §9 and confirmed by §10, and the Surface executesdelete_docwith arguments chosen by the relabelling.actionIdMUST be bound, or the sealed input of action A is swapped onto action B.revMUST be bound. The Hub enforces monotonicrevon the cleartext field; unbound, it pairs an old sealed context with a highrev.indexMUST be bound, or footnote [1]'s quote lands on [2].kidMUST be bound, so a unit from a previous pairing of the samesidfails legibly rather than as an opaque AEAD error.- Envelope
idandtsMUST NOT be bound. The reference relay re-mints the envelope when it forwardsaction.request. Binding them would break the tool path in every deployment and would present as "the AEAD is flaky".
The bound values are not repeated inside the plaintext. The AAD already makes them unforgeable, and a second copy is state that can itself desynchronise. Implementations SHOULD instead report the AAD field values locally on a decrypt failure, so an operator sees an attributable event rather than "decryption failed".
6.4 Plaintext framing and padding
framed = uint32be(len(content)) ‖ content ‖ zero padding
zero-padded up to a multiple of the block for the unit's type: 64 bytes for
turn.delta, 256 for every other type. The explicit length is what makes the
padding unambiguous — trailing zero bytes are legal content, so "strip the
zeros" is not a decoder. Receivers MUST reject a frame with non-zero padding,
with a declared length past the frame end, or with more padding than the block
rule could have produced; a decoder that repairs a malformed frame is one that
accepts a frame the relay shaped.
Padding is REQUIRED because GCM is length-preserving and turn.delta is one
frame per token, so an unpadded stream hands the relay the exact byte length of
every token. Padding does not close the timing channel. Implementations
SHOULD additionally batch deltas onto a fixed cadence (e.g. flush every 80 ms
regardless of token count); an implementation that pads without batching MUST
NOT claim the stream is free of content leakage.
7. Forward secrecy, and its limit
Session keys come from ephemeral ECDH keys used for nothing else, so a later compromise of the Companion's long-lived device key reveals nothing about past content, and a Hub compromised later holds no key material at all. That is the precise answer to the retention threat this profile exists for.
The limit, stated rather than implied: the Surface's ephemeral private key lives in a browser heap for up to the session's absolute TTL (Core §5: 4 hours), protected by one static ECDH with a ratchet that advances only on resume. A browser compromise, a malicious extension with host permissions on the Hub origin, or a heap dump at time T therefore exposes everything since the last epoch advance and everything after it. Sealing is forward-secret at session and resume granularity, NOT at turn granularity.
A per-turn ratchet would fix this for one HKDF per turn, and is deliberately NOT specified in this version: turn boundaries are not unambiguously agreed between the peers under barge-in and abort, and a ratchet whose trigger the relay can desynchronise converts a relay nuisance into a session kill. A future suite MAY add it once turn-boundary agreement is tightened; until then implementations MUST NOT claim per-turn forward secrecy.
8. What the Hub still sees
The complete ledger. Deployments MUST make this available to site operators, who will otherwise assume the opposite.
sid,turnId,actionId, messagetype, direction, envelopeid/ts- which tools were invoked, in what order, how often, and when — arguments and results excepted
- the site's full tool catalogue (
name,description,inputSchema,location,write) - every confirm raised and every approve/deny, and which peer answered
- turn start and end, model think-time, tool-call latency, terminal frame type
contextRevbumps — i.e. how often the visitor navigates or selects- ciphertext byte counts, padded per §6.4
turn.done.answeredBy— the model vendor and model id (§5)- site key, rotating visitor id, IP, user agent, and the device name and
platform from
pair/claim
Genuinely hidden that was not before: with context.set sealed and the
reference Hub sending referrer-policy: no-referrer on a same-origin frame, the
Hub no longer learns which page of the site the visitor is on. The visitor's
path through the site is sealed.
Operational rules that follow:
- A Hub MUST NOT archive sealed frames. A relay that stores ciphertext has built a corpus that is one key compromise away from plaintext, and it defeats the purpose of the profile.
- A Hub MUST NOT log the SAS,
s,sealPubKey, or any derived key material. - Hubs SHOULD emit a content-free
sealedflag on their session and turn events and SHOULD count pairing denials without a reason code (§3.1). A sealing rollout that cannot be observed cannot be operated, and an operator who cannot support their users will turn sealing off. - Both endpoints SHOULD log the suite id on pairing failure. Version skew, not attack, is the realistic cause of a systematic SAS mismatch, and a suite id turns a week of debugging into five minutes.
9. Confirms under sealing
Built naively, sealing hollows out Core §10 completely. The Hub composes the
confirm from the only thing it knows — Allow <tool>? — and under sealing that
is all it will ever be able to say. A visitor approving "Allow transfer_funds?"
with the amount, destination and recipient sealed is approving a category, not
an act. §10's words would still be satisfied while the control had stopped
meaning anything.
The gate therefore splits, and this is only defensible because the Surface holds the key:
- The Hub keeps the gate. It decides THAT a confirm is required (from
write, which stays clear), ownsexpiresAtand expiry-to-deny, ownssurfaceOnlywhen the Companion is the asker, and owns the audit record. None of that needs plaintext. - The Surface composes what the human reads. The Hub relays the action's
sealed
inputon theui.requestassealed; the Surface opens it — it must open it anyway to execute the tool — and renders "Allowbook_appointment— 14 March, Dr Meier?".
Normative consequences:
- The Hub MUST relay the unit BYTE-IDENTICALLY. It MUST NOT re-seal, and it has
no key to do so with. The unit's AAD therefore still reads
type: 'action.request': it is bound to the ACTION it authorises, not to the envelope it arrived in. Itscounteris unchanged, so a Surface that sees the unit on both theui.requestand theaction.requestMUST treat the second as a duplicate (§6.2), not as a replay. - The AAD binding of
actionIdANDtool(§6.3) is what makes this safe. Without it the Hub shows a confirm for the harmless tool while the Surface decrypts and executes the dangerous one's arguments. - In a sealed session the Surface MUST NOT render the Hub's
titleas the sole basis for a decision. A unit that fails to open or authenticate is not a degraded confirm, it is a forged one: the Surface MUST show that the request could not be verified and MUST let the deadline deny it. Falling back to the Hub's plaintext title hands the relay the exact authority this profile removes. - The Hub MUST carry the unit on the
ui.requestrather than dispatching the action early. Dispatching first would leave the Surface holding an unapproved write, which is the pending-diff hazard Core §10 already warns about.
Net: the visitor reads text derived from an authenticated ciphertext instead of text the relay composed, which is stronger than §10 is today. Built the obvious way it is a downgrade dressed as a privacy win.
10. Versioning
Everything in this profile is additive within wire version 1.0. No golden
vector changes meaning; sealMode absent is today's behaviour exactly.
The wire v is deliberately NOT bumped. It is the only signal available before
parsing, but no peer gates on it, and the real gate is negotiation (§4) — a
version bump would be mistaken for a compatibility control. What is new is that,
for the first time, whether a KNOWN type's payload is valid depends on a
negotiated session property; that is a real change in how a receiver reads
bytes, and it is why this profile is a separate document rather than a paragraph
in Core.
A change to any byte in §6 is a NEW SUITE ID, never an edit. Peers already deployed would otherwise stop interoperating with no signal saying why.
Appendix A — registry additions
Capability: seal (offer only — see §4).
session.linked field: sealMode — none | e2e, absent ⇒ none.
pair/claim field: sealPubKey. pair.approval_request field:
sealPubKey.
Payload field: sealed on turn.input, turn.delta, turn.citation,
context.set, action.request, action.result, ui.request.
Error code: SEAL_REQUIRED — sealing was negotiated and then not honoured.
Distinct from UNSUPPORTED, which means never negotiated; this is a security
event and MUST be separable in an audit log. Always fatal to the session.
Crypto suite: ocp-seal-1.