Channel encryption
This document specifies the construction and implementation of Alcove's application-layer channel encryption and of the authenticated abort marker keyed from the same session key. The other channel-security controls are specified in the files listed under Contents. It is the implementation-level complement to the Channel security overview in SECURITY_DESIGN.md, which covers what each control protects against and why the design is sufficient; this document covers the wire formats, the derived constant values and their justification, the enforcement points, and the dependency assumptions each control rests on. It does not cover the threat model or authentication design (see SECURITY_DESIGN.md), the PSI and key-exchange wire protocol (see PROTOCOL.md), or the network channels and transport contract (see COMMUNICATION.md). Intended readers are security auditors and implementors.
Every net-new control specified here is subject to the explicit pre-release security review required by CONTRIBUTING.
Contents
The channel-security controls are specified in this file and in these files:
- TRANSPORT_BOUNDS.md - the transport memory bounds: the inbound frame size, the directory listing, the rendezvous symlink refusal, and the WebRTC data-channel inbound bound
- TRANSPORT_LIVENESS.md - the transport liveness bounds: per-operation liveness, the slow-operation warning, and the SFTP heartbeat and TCP keepalive
- SFTP_CONNECTION.md - the SFTP connection controls: the server-provisioning endpoint call, fatal-packet crash safety, and host-key verification
- SFTP_SESSION_RECOVERY.md - the SFTP adapter's recovery of a session the server drops mid-exchange
- SIGNALING_SERVER_BOUNDS.md - the bounds the signaling broker holds against an unauthenticated internet client: the inbound frame bound, the upgrade-handshake timeouts, the liveness reaper, and the relay queue bounds
- PARSED_INPUT_BOUNDS.md - the application-layer bounds on parsed input: collection counts, the JSON-decode chokepoint, the CSV-read line ceiling and header sanitation, the name-class character rule, and the web linkage-terms import bounds
- TRANSFORM_REGEX_DIALECT.md - the linear-time dialect partner-supplied transform patterns run under, and the name-constraint character class
- TRANSFORM_PARAMETER_BOUNDS.md - the declared types of transform parameters and the bounds on the values that drive unbounded per-row work
- DISPLAY_ESCAPE.md - the display sanitization escape format and the compatibility diagnostic value delimiting
- DISPLAY_REDACTION.md - the private-key redaction and the message display budgets on operator displays
Application-layer AEAD
Both parties use the HMAC-based Extract-and-Expand Key Derivation Function (HKDF) to derive AES-GCM keys from the 32-byte key-exchange session key - two directional keys, one per direction (HKDF info strings alcove-aead-v2:initiator-to-responder and ...responder-to-initiator) rather than a single common key - so that every key has a single sender, and messages are encrypted using Authenticated Encryption with Associated Data (AEAD). Each message holds a per-direction sequence number as its nonce, preventing replay and reordering. The separate per-direction keys are what keep that nonce construction safe: both directions number their messages from zero, so a single common key would encrypt two different messages under the same key and nonce - a reuse that is catastrophic for AES-GCM - whereas one key per sender keeps every key-nonce pair unique. The server admin sees only opaque ciphertext files, and tampering with a file causes the authentication tag to fail and the exchange to abort. The AEAD context is restricted to this fixed pair of per-direction labels (a closed union, guarded at runtime), not an open string. Both parties must derive identical per-direction keys from identical labels; a variable, non-ASCII, or non-NFC label would make the two sides derive different keys and fail the AEAD with an opaque error rather than a clear authentication failure, so the label set is closed by construction.
The concrete envelope the EncryptedMessageConnection decorator emits is a binary frame version || IV || ciphertext || 16-byte GCM tag, encoded as raw bytes (no base64url, no JSON wrapper). version is a single leading format/version marker (AEAD_ENVELOPE_VERSION, 1); the 12-byte IV is four leading zero bytes (authenticated, but not separately validated) followed by the 8-byte big-endian sequence number; and the encrypted plaintext is a one-byte type tag (0 for a JSON object, 1 for a Uint8Array) ahead of the payload. The type tag stays inside the authenticated ciphertext and is never promoted to a cleartext envelope field, so a transport admin cannot flip JSON/binary routing without failing the GCM tag. The version marker turns a format mismatch -- an envelope from an incompatible build, or an inbound that is not the binary shape at all, such as a base64url-in-JSON { enc } frame -- into a clean security rejection rather than a silent misparse. This wire format is pinned byte-for-byte by a checked-in known-answer vector at packages/core/test/vectors/aead-envelope-vectors.json, following the cross-implementation vector precedent of canonical-vectors.json and exchange-record-vectors.json: each vector fixes a session key, role, sequence number, and plaintext and records the exact envelopeHex. An independent implementation can therefore reproduce it from those inputs, and any change to the version marker, nonce construction, byte order, envelope shape, or tagging is caught. The vectors are regenerated by generate-aead-envelope-vectors.mjs in that directory and asserted against the decorator in packages/core/test/connection/encryptedMessageConnection.test.ts, which separately pins the per-direction HKDF key derivation (deriveAeadKey).
Because the type tag is encrypted, the file-sync transport beneath the AEAD layer cannot read it to route a frame, so it has its own outer, cleartext discriminator. Every data-plane message file is a binary envelope version || type || seq || payload (serialized and parsed in fileSyncFraming.ts, which send()/poll() in fileSyncMessageLoop.ts call), where type selects a JSON control payload (UTF-8 JSON, parsed through the parseBoundedJson chokepoint) or a raw binary frame (delivered as-is). This too holds the frame as raw bytes -- no base64url and no whole-frame string conversion -- so the read path never calls Buffer.prototype.toString() and a frame larger than Node's maximum string length can be read.
Which channels request it
The wrap is applied when the key exchange's transcript-bound decision says so, and that decision is the OR of the two parties' requests: either party asking for it turns it on for both. What each party asks for is fixed by the channel, not configurable.
| Channel | Requests the wrap | Why |
|---|---|---|
sftp, filedrop | yes | The exchange sits in a server's or a share's filesystem. Its administrator can read and alter every frame, so the confidentiality and integrity have to come from a layer above the transport. |
webrtc | yes, from the CLI and from the web application | A coordination server controls the signaling it relays and could otherwise place itself between the two parties, so the wrap, keyed by the authenticated key exchange that server cannot complete, is what keeps exchange content from it. A WebSocket relay that carries the exchange receives every frame either party sends, so the wrap also keeps exchange content from the relay. |
The CLI (apps/cli/src/protocol.ts) and the web application (apps/web/src/psi/authenticateExchange.ts) request the wrap on every webrtc exchange, so every webrtc exchange is wrapped. Each applies it on whichever connection its run opened, the WebRTC data channel or the WebSocket relay's connection (WEBSOCKET_RELAY.md; WEBRTC_TRANSPORT.md). The limit is a stated one: a web application build from before the web application requested the wrap cannot complete a webrtc exchange with a current web application or CLI build, and a recurring exchange run across such a pair is set up again from a new invitation.
IV construction and SP 800-38D conformance
The 96-bit IV is the deterministic construction of SP 800-38D (November 2007) section 8.2.1, laid out as that section suggests for the default 96-bit IV length: the leading 32 bits hold the fixed field and the trailing 64 bits hold the invocation field. Read against section 8.2.1's own stated requirements, the construction satisfies them, with one qualification -- stated at the end of this section -- on what the all-zero fixed field rests on.
- The IV is a fixed field concatenated with an invocation field.
seqToIvwrites the 8-byte big-endian counter atIV_SEQ_OFFSET= 4 into a zeroed 12-byte buffer, giving a 32-bit fixed field followed by a 64-bit invocation field. Section 8.2.1 suggests, but does not require, exactly those lengths and positions for a 96-bit IV. - The fixed field identifies the context. The fixed field is 32 zero bits and identifies nothing by itself, which section 8.2.1 permits where "the entire fixed field may consist of arbitrary bits when there is only one context to identify, such as when a fresh key is limited to a single session of a communications protocol". Each AEAD key here is limited to one direction of one session. It is derived by HKDF from the per-session key-exchange session key, which is transcript-bound and mixes each run's fresh ephemeral scalars, and it is imported non-extractable with a single usage (
encryptfor the send key,decryptfor the receive key) held only by the decorator, so exactly one sender ever invokes the authenticated encryption function under it. One context, so the arbitrary-bit case applies. - No two distinct sets of inputs share an invocation field.
sendSeqstarts at 0 and is consumed by a synchronous post-increment, one value persend(), ahead of the encrypt and transport awaits, so two concurrent sends cannot take the same value. A value consumed by a send that then fails is never re-issued: the failure latches the wrapper terminal, so nothing is sent under it or after it. A counter pastNumber.MAX_SAFE_INTEGERis refused with asecurityfailure rather than wrapped. - The lengths and positions are fixed for the life of the key.
IV_SEQ_OFFSETis a module constant and 96 bits is the only IV length this layer supports, so the split is fixed across every key rather than only within one. The known-answer vectors above assert the two fields separately at three sequence numbers across both roles -- that the fixed field is the four bytes00000000and that the invocation field decodes big-endian to the vector's sequence number -- so a change to either field's length or position fails the suite. - Exactly one construction is used per key (section 8.2).
seqToIvis the sole producer of IVs under an AEAD key. The two keys are imported non-extractable with a single usage each -- the receive key cannot invoke the authenticated encryption function at all, which WebCrypto enforces -- and the send key is held in the decorator's private field rather than returned to a caller, so no second instance encrypts under it and no RBG-based IV is produced for it. - The invocation constraints hold with margin (section 8.3). Section 8.3's 2^32 global invocation cap does not reach this construction -- it excludes an implementation that only uses 96-bit IVs generated by the deterministic construction, which is this one. The two constraints section 8.3 does apply at every IV length are both met: the 64-bit invocation field bounds invocations under one key and fixed field at 2^64, against the counter's own ceiling of
Number.MAX_SAFE_INTEGER(2^53 - 1); and the 32-bit fixed field bounds distinct contexts under one key at 2^32, against the single context each key has.
The qualification. Section 8.2.1 attaches a condition to the arbitrary-bit fixed field: "if different participants in the session share a common fixed field, then the protocol shall ensure that the invocation fields are distinct for distinct data inputs". Both participants do use the same all-zero fixed field and both number their messages from zero, so their invocation fields are not distinct. What keeps the construction off that condition is that section 8.2.1's requirements are scoped for any given key, and the per-direction key split means the two participants never share one -- each key has exactly one sender, so there are no two participants under it whose invocation fields could collide. The satisfied outcome therefore rests on the key split, not on the IV alone: collapsing the two directional keys into one shared key turns it into a violation of section 8.2.1, which is the conformance statement of the key-nonce reuse the AEAD description above calls catastrophic.
Section 8.2.1 constrains how an implementation generates IVs for the authenticated encryption function, not what it accepts, so the receiver's not separately validating that an inbound IV's leading four bytes are zero is outside the section. Those bytes are covered by the GCM tag either way, so an IV that is not the sender's own fails authentication.
Conformance to section 8.2.1 is a statement about the construction and not about a module certificate's approved services. The FIPS 140-3 certificate this project targets makes AES-GCM with an externally supplied IV a non-approved service whatever construction produced the IV, and a crypto.subtle AES-GCM call supplies one by construction; see COMPLIANCE.md for the scoped claim that follows from that.
Associated data is not bound
The envelope binds no Associated Data (AAD), and this is a deliberate, recorded decision rather than an omission. AAD would matter only to stop a frame authenticated under one context (role/direction, session, or transcript) from being spliced or replayed into a different context that shares the same key. Here every such context is already separated below the AAD layer by a distinct key. Direction is bound by the key: each direction uses its own AES-GCM key, derived with a distinct HKDF info string (alcove-aead-v2:initiator-to-responder versus ...responder-to-initiator), so a frame presented to the opposite direction decrypts under the wrong key and the tag fails. Session is bound by the key: both keys derive from the per-session key-exchange session key, which is transcript-bound and mixes in each run's random ephemeral scalars, so it is distinct even across two runs of the same shared secret - a frame from one session decrypts under the wrong key in another. Ordering within a single direction of a single session is bound by the sequence-number nonce and the strict-increasing replay guard. The only set of frames that share one AEAD key is those one sender emits in one direction of one session, and the decorator holds exactly one logical stream there - it does not multiplex sub-channels or protocol phases, and the one-byte payload type tag that distinguishes binary from JSON lives inside the authenticated plaintext. There is therefore no second logical context under a shared key for AAD to separate, so binding the direction (or a session id) as AAD would only restate what the key already enforces. The sequence number is the GCM nonce, not a substitute for AAD: the reason AAD is unnecessary is the absence of a second context under one key, not that the nonce stands in for AAD. Practically, the decorator holds only its inner connection and its two directional keys - no session or transcript id is in scope to bind without new plumbing from the caller that constructs it.
Revisit this decision if a future change multiplexes more than one logical stream or protocol phase over a single direction's key, or collapses the per-direction key split into one shared key. In either case a context or sub-stream id bound as AAD would stop being redundant: it would still block cross-direction splicing, though not the key-nonce reuse that collapsing the key split would itself make catastrophic (AAD does not protect against nonce reuse). Until then, the per-direction key plus the sequence nonce are sufficient and no AAD is bound.
Inbound integrity, replay, and ordering checks
This layer's AEAD/MAC and sequence-number (replay and ordering) checks run at the protocol layer, on the output of a receive() call: the decorator validates and decrypts each inbound envelope inside receive() and rejects with a security ConnectionError on any integrity, replay, or ordering failure. These checks are not latched into the inner connection as a transport control (the fail/finish teardown signals) behind a buffered inbound frame. The message queue drains an already-received frame to the consumer ahead of any terminal error, whether the close was clean or abnormal (see Message delivery and teardown), so a check wired as a transport-level latch could hand a suspect frame to the consumer before the integrity alarm fires. The decorator honors this - it raises the failure from receive() itself, and the inner transports latch no integrity check behind a frame (the security error an inner transport can raise, such as an SFTP host-key refusal, fires while connecting, before any frame) - so the constraint holds and remains one to preserve.
Mid-stream gap detection is strict: the decorator rejects any inbound sequence number that is not exactly the last accepted plus one, so a frame dropped or withheld between two delivered frames is caught here as a security failure on the output of receive() - the same protocol-layer reporting as the replay and tag checks, never a transport-level latch. It raises no false alarm against the send path's synchronous sendSeq++ ahead of the encrypt (which is what keeps two concurrent sends from peeking the same counter and reusing an AES-GCM nonce). A send that consumes a counter value but then fails to transmit (an encrypt fault or an inner transport failure) latches the wrapper terminal, so no later frame is ever sent, and a consumed-but-unsent value can only truncate the tail, never leave a gap between two delivered frames.
End-of-stream and truncated-tail detection
End-of-stream / truncated-tail detection is a deliberate accepted deferral, not a coverage gap to be closed with a marker. A truncated tail - a stream that simply stops, including a dropped final frame - is indistinguishable at the AEAD layer from a clean end, and no AEAD tag can authenticate the absence of a frame: an omitted frame can be detected only by expecting one and not receiving it. The PSI protocol is self-driven lockstep (see PROTOCOL.md): each party drives a deterministic script of matched send/receive calls, so the receiver always knows from its own script whether another frame is due. In the symmetric case a truncated tail therefore is reported - as a stalled receive() that hits the inactivity timeout - and an explicit, integrity-protected end-of-stream marker could only reclassify that timeout from transport to security, not detect the omission any sooner. Reclassification alone does not justify net-new, security-reviewed cryptographic surface, so the marker is not built. The lockstep is unconditional in every implemented cardinality: the matching loop runs identifyIntersection for every agreed key on both parties -- the key set is fixed by the linkage terms, and the PSI library returns an empty intersection without error for an empty input set, so a locally-empty round is a correct no-op that both sides still perform. A per-party-data-gated skip (if (data_j.length === 0) continue;) would break this: data_j is built from a party's own still-unmatched records, so one side can empty a later key while its partner does not, and skipping there drops a send/receive the partner still performs and desyncs the exchange. Any future cardinality loop must therefore not reintroduce such a skip. This is a protocol-loop invariant, not an absent-frame one: no AEAD-layer marker could detect or repair a lockstep desync, so the end-of-stream marker's deferral is independent of it.
Authenticated abort marker
A distinct channel control is the authenticated cross-party abort marker, the one mechanism here that is not a bound on a hostile operation but an authenticated signal between the two honest parties. On the file-sync transports a failed exchange gives the waiting peer no in-band notice that its partner has given up, leaving it to wait out the full peer-inactivity budget (default one hour) and then print a generic peer-silence hedge. The marker closes that gap: when a party fails terminally with its directory still writable, it writes a best-effort <id>-abort-<bytes>.json holding a 32-byte token HKDF(sessionKey, "alcove-abort-token-v2:<role>"); a waiting peer that reads a <peerId>-abort-<bytes>.json whose token verifies against its own locally-derived peer token fails fast with a definitive PeerAbortError (exit 76) instead. The token is the authentication: a hostile filedrop/SFTP admin can write or rename any file in the rendezvous directory, but cannot produce a token that verifies without the per-session session key, so cannot forge an abort against a live peer. The label is domain-separated from the other session-key derivations - exact-distinct and prefix-free from the AEAD alcove-aead-v2:<context> labels and the rotation's alcove-shared-secret-wrap-v2 and alcove-shared-secret-wrap-responder-v2 labels - so an abort token can never collide with or substitute for an AEAD key or a rotation wrapping key. The comparison is constant-time and length-safe, and the read fails closed on every absent, oversized, malformed, wrong-version, or non-matching input. The per-direction (role) binding makes a captured marker renamed to the other party's name verify under the wrong token and fail; the per-session ephemeral key makes a marker captured from one exchange inert in another.
The marker is best-effort and has no cause, both deliberate. Its absence is strictly uninformative: a deleted, withheld, or forged marker simply degrades to the peer-silence timeout and hedge - it is never treated as success - so a hostile admin can at most suppress the fast-fail, never forge an abort or turn a failure into an apparent success. It has no cause because the cause is not actionable to the waiting party - which holds no local error and must contact its partner regardless - and a plaintext cause on the untrusted channel would only widen disclosure; the token itself is a one-way HKDF output that reveals nothing about the session key or any other derived key. Because the marker is re-read every poll cycle, it is capped small - a pre-get() listed-size refusal at 1 KiB rather than the ~512 MiB frame maximum, and a get() capped at the byte count the marker's name states - so an admin-plantable file cannot become an availability vector. A marker that fails to parse or verify is ignored and re-read next cycle, but a read the transport refuses as stalled or over its cap ends the run on that typed error, as the same refusal does on every other read of the cycle, rather than degrading to the peer-silence timeout. The write is short-budgeted on both its put and rename (a few seconds each) so a sick directory cannot hang teardown. The mechanism is file-sync only: the connectionless file transport is the only one with a parked-waiter problem, since a WebRTC peer's death drops the DTLS data channel and is reported promptly; a future cross-transport abort would instead state a protocol-layer error kind rather than an on-disk marker.
See also
- SECURITY_DESIGN.md - the Channel security overview: what each control protects against and why the design is sufficient
- PROTOCOL.md - the PSI and P-256 key-exchange wire-level protocol that produces the session key these controls key from
- COMMUNICATION.md - the network channels and the message-delivery/teardown transport contract these controls run within
- DEPENDENCY_PINS.md - the SFTP-stack upgrade checklist for the ssh2 internal assumptions these controls depend on
- DEPENDENCY_PINS.md - the PeerJS-stack upgrade checklist for the PeerJS and BinaryPack internal assumptions the WebRTC data-channel bound (specified in TRANSPORT_BOUNDS.md) depends on