@hyperfrontend/network-protocol/protocolProtocol
Purpose
The Protocol module produces the object a channel drives: one Protocol instance per session, holding the session's seal and open operations, its hello exchange, and the transport callbacks. Two protocols are built here, v3 and v4. Both key a session from an ephemeral P-256 agreement carried in plaintext hello frames; v4 also mixes a stretched pre-shared key into the schedule. The module also provides a store for named protocol providers.
Key Interfaces
Protocol<T>
Declared in channel/model.ts; every provider returns one.
interface Protocol<T = any> extends HelloExchange {
seal: PacketSealer<T> // (packet: UnencryptedPacket<T>) => Promise<WirePacket>
open: PacketOpener<T> // (frame: WirePacket) => Promise<UnencryptedPacket<T>>
send: SendPacketFn // Transmits a sealed frame
receive: ReceivePacketFn<T> // Receives an opened packet
getLogger: () => Logger
}
interface HelloExchange {
hello(): Promise<WirePacket> // This side's hello frame; the same bytes on every call
isHello(frame: WirePacket): boolean // True for a hello frame of this protocol's version
acceptHello(frame: WirePacket): HelloOutcome // 'accepted' | 'duplicate' | 'rejected'
}
ProtocolProvider<T>
Binds a protocol instance to one negotiated session.
type ProtocolProvider<T = any> = (send: SendPacketFn, receive: ReceivePacketFn<T>, session: ProtocolSession) => Protocol<T>
ProtocolSession is { protocol, role: 'initiator' | 'responder', localId, peerId }; see security/.
ProtocolProviderStore<T>
Store for named protocol providers.
interface ProtocolProviderStore<T = unknown> {
readonly add: (name: string, protocolProvider: ProtocolProvider<T>) => void
readonly existsByName: (name: string) => boolean
readonly existsById: (id: string) => boolean
readonly removeByName: (...name: string[]) => void
readonly removeById: (...id: string[]) => void
readonly clear: () => void
readonly getByName: (name: string) => ProtocolProvider<T> | null
readonly getById: (id: string) => ProtocolProvider<T> | null
readonly list: readonly ProtocolProviderEntry<T>[] // { id, name, provider }
}
SessionCrypto
The platform primitives a session protocol is composed from. The /browser/v3 and /browser/v4 entries fill it from @hyperfrontend/cryptography/browser and @hyperfrontend/string-utils/browser; the /node/* entries use the Node.js counterparts.
interface SessionCrypto {
getRandomValues: (byteLength: number) => Uint8Array
createKeyAgreement: () => Promise<KeyAgreementLike> // Ephemeral P-256; publicKey is the 65-byte uncompressed point
stretchPassword: (password: string, salt: Uint8Array, options?: StretchOptions) => Promise<Uint8Array> // PBKDF2-SHA256
expandKey: (ikm: Uint8Array, salt: Uint8Array, info: Uint8Array, usages: readonly KeyUsage[]) => Promise<CryptoKey> // HKDF-SHA256
seal: (key: CryptoKey, nonce: Uint8Array, additionalData: Uint8Array, plaintext: Uint8Array) => Promise<Uint8Array> // AES-GCM
open: (key: CryptoKey, nonce: Uint8Array, additionalData: Uint8Array, sealed: Uint8Array) => Promise<Uint8Array>
utf8Encode: (text: string) => Uint8Array
utf8Decode: (bytes: Uint8Array) => string
}
SessionProtocolDefinition
What distinguishes one protocol from another.
interface SessionProtocolDefinition {
readonly id: string // 'v3' or 'v4'
readonly version: number // The version byte every frame starts with: 3 or 4
readonly sharedKey?: string // Mixed into the key schedule when present (v4)
}
V3 is { id: 'v3', version: 3 } and V4 is { id: 'v4', version: 4 }.
Protocol Versions
| Protocol | Factory | createProtocol signature | Input keying material | Entry points |
|---|---|---|---|---|
| v3 | createV3ProtocolFactory(crypto) | createProtocol(logger) | ECDH shared secret | /browser/v3, /node/v3 |
| v4 | createV4ProtocolFactory(crypto) | createProtocol(logger, sharedKey) | ECDH shared secret, then PBKDF2 of sharedKey | /browser/v4, /node/v4 |
The two protocols share every mechanism below; only the input keying material differs.
Factory Functions
createProtocol (v3)
Location: @hyperfrontend/network-protocol/browser/v3, @hyperfrontend/network-protocol/node/v3
function createProtocol(logger: Logger): ProtocolProvider
Throws Cannot create protocol provider without a valid logger for an invalid logger.
import { createProtocol } from '@hyperfrontend/network-protocol/browser/v3'
import { createChannel } from '@hyperfrontend/network-protocol/browser/channel'
import { createLogger } from '@hyperfrontend/logging'
const channel = createChannel('app-to-widget', {
send: (frame) => otherWindow.postMessage(frame, origin, [frame.buffer]),
receive: (packet) => handle(packet.data.message),
protocolProvider: createProtocol(createLogger({ level: 'info' })),
session: { protocol: 'v3', role: 'initiator', localId, peerId },
})
createProtocol (v4)
Location: @hyperfrontend/network-protocol/browser/v4, @hyperfrontend/network-protocol/node/v4
function createProtocol(logger: Logger, sharedKey: string): ProtocolProvider
Throws Cannot create the v4 protocol without a shared key of at least 16 characters when isValidSharedKey(sharedKey) is false. MIN_SHARED_KEY_LENGTH is 16. The guarantee needs a generated key of 128 bits or more: a party that can run a hello exchange against this side can test key guesses offline afterwards, so a human-chosen passphrase is not a substitute.
import { createProtocol } from '@hyperfrontend/network-protocol/browser/v4'
const protocolProvider = createProtocol(createLogger({ level: 'info' }), sharedKey)
createProtocolProviderStore
Location: every v3 and v4 entry
import { createProtocolProviderStore } from '@hyperfrontend/network-protocol/browser/v4'
import { createProtocol as createV3 } from '@hyperfrontend/network-protocol/browser/v3'
import { createProtocol as createV4 } from '@hyperfrontend/network-protocol/browser/v4'
const store = createProtocolProviderStore()
store.add('v3', createV3(logger))
store.add('v4', createV4(logger, sharedKey))
const provider = store.getByName('v4')
store.list.forEach((entry) => register(entry.id, entry.name, entry.provider))
add throws for an empty name (Cannot add a provider with invalid name), a name already in the store, or a provider already registered under another name; removeByName and removeById throw when nothing matches.
Composition
The platform entries compose the factories in session/. Of these, only createV3ProtocolFactory, createV4ProtocolFactory, V3, and V4 are exported from the package entries; the rest are internal.
| Function | Role |
|---|---|
createV3ProtocolFactory(crypto) | (logger) => ProtocolProvider for definition V3 |
createV4ProtocolFactory(crypto) | (logger, sharedKey) => ProtocolProvider for definition { ...V4, sharedKey } |
createSessionProtocolProvider(crypto, definition, logger) | The provider: validates the transport callbacks and that session.protocol === definition.id, then creates the instance |
createSessionProtocol(input) | One session's instance; input adds an optional counterLimit (defaults to the largest safe integer) |
mintLocalMaterial(crypto) | A 32-byte nonce and an ephemeral key agreement |
deriveSessionKeys(crypto, definition, session, own, peer) | The two directional AES-GCM keys |
frame.ts | encodeHeader, decodeHeader, nonceFor, assembleFrame, encodeHello, decodeHello, isHelloFrame, the length constants |
Because the two factories are exported, a custom SessionCrypto can be wired without touching the rest.
Session Lifecycle
- Construction: the provider is called with
send,receive, and the session; the instance mints its material at once. - Hello:
hello()returns this side's frame. The owner transmits it and retries until the peer confirms; the bytes never change. - Accept: the peer's frame goes to
acceptHello. The first hello keys the session ('accepted'); the same bytes again are'duplicate'; any other frame, including a different hello, is'rejected'. A live session is never rekeyed. - Traffic:
sealandopenwait until both materials exist, so frames queued before the peer's hello simply hold. Keys derive once; a derivation that fails rejects every later operation withinvalid-session.
Wire Format
| Frame | Layout | Length |
|---|---|---|
| Hello | [version][type=1][nonce 32][public key 65] | 99 bytes |
| Data | [version][type=0][counter u64 big-endian] + ciphertext + tag | at least 27 bytes |
- Version bytes are
3and4;isHelloaccepts only this protocol's version. - The ten-byte data header is the additional authenticated data. The AES-GCM nonce is four zero bytes followed by the eight counter bytes, so it is unique per direction by construction.
- The plaintext is the UTF-8 JSON of
{ origin, target, data }withdataserialised (serializeData). - The tag is 16 bytes; a data frame shorter than 27 bytes (header, tag, one ciphertext byte) is malformed.
decodeHelloalso requires the public key to start with the uncompressed-point tag; whether the point lies on the curve is decided by the key agreement when keys derive.
Key Schedule
Both sides order the material by role, compute the same two keys, and each picks the sending one for its own role.
| Step | Value |
|---|---|
salt | initiatorNonce || responderNonce |
dh | ECDH(own private key, peer public key), 32 bytes |
ikm | dh (v3), or dh || PBKDF2-SHA256(sharedKey, salt, 600000 iterations) (v4) |
i2r | HKDF-SHA256(ikm, salt, hyperfrontend/network-protocol/<protocol>/<initiatorId>/<responderId>/i2r) as AES-GCM-256 |
r2i | the same with .../r2i |
The initiator seals with i2r and opens with r2i; the responder does the reverse. Each key is non-extractable and restricted to one usage. Binding the protocol id and both identities into the info ties the keys to the negotiated session. dh, ikm, and the stretched key are zeroed once the keys exist. The stretch runs once per session, so its cost lands on the handshake, not on traffic.
Replay and Ordering
- Counters start at
1and increase by one per sealed frame in each direction. openrejects a frame whose counter is not above the last accepted counter before any decryption, so a replayed or forged frame costs nothing.- A session that has sealed
counterLimitframes rejects the nextsealwithcounter-exhausted; open a new session. - The pipelines process one frame at a time (see
queue/), which keeps the counter exact.
Error Handling
At construction
createProtocol(null)
// Error: 'Cannot create protocol provider without a valid logger'
createProtocol(logger, 'short')
// Error: 'Cannot create the v4 protocol without a shared key of at least 16 characters'
protocolProvider(null, receiveFn, session)
// Error: 'Cannot create protocol without a valid send function'
protocolProvider(sendFn, null, session)
// Error: 'Cannot create protocol without a valid receive function'
protocolProvider(sendFn, receiveFn, { ...session, protocol: 'v4' }) // on a v3 provider
// ProtocolError (code 'invalid-session'): "The session was negotiated for 'v4', not 'v3'"
At seal and open
Every rejection is a ProtocolError whose code is one of ProtocolErrorCode (see security/); the pipelines report it through onDrop with the error as cause.
| Code | Raised by | When |
|---|---|---|
malformed | open | Fewer than 27 bytes, or the plaintext is not a valid packet |
unsupported-version | open | The version byte is not this protocol's |
replayed | open | The counter is not above the last accepted one |
authentication-failed | open | The tag does not verify under the session's receiving key |
counter-exhausted | seal | The session has sealed every frame it can number |
invalid-session | seal, open | The keys could not be derived (for example an off-curve public key) |
Security Claims
- v3 defeats scripts that can only listen: a passive observer of the hello exchange and the traffic cannot read or forge frames. Any script that can post to a peer's window with a genuine source can complete a v3 handshake as that peer, so v3 does not authenticate who the counterpart is.
- v4 binds the session to the pre-shared key: without the key a script can neither read frames nor produce frames the counterpart accepts, and a key mismatch is detected because no frame ever authenticates. A key that leaks later does not expose earlier sessions.
- Neither protocol hides the hello; public keys and nonces are public by design.
- Cost: one ECDH agreement plus one HKDF per session (plus one 600k-iteration PBKDF2 for v4), then one AES-GCM operation per frame in each direction.
Validation Helpers
Exported from every v3 and v4 entry for upstream guards:
| Function | Result |
|---|---|
isValidProtocolProvider(value) | true when the value is a function |
isValidProtocol(value) | A ValidProtocolResult: seal, open, hello, isHello, acceptHello, send, receive, and getLogger each mapped to true, false, or undefined (not reached because an earlier property failed) |
isValidSendFn(value) | true when the value is a function |
isValidReceiveFn(value) | true when the value is a function |
isValidName(value) | true for a non-empty string |
isValidSharedKey(value) | true for a string of at least MIN_SHARED_KEY_LENGTH characters; v4 entries only |
Relationship to Other Modules
- Depends on:
security/,packet/,data/,@hyperfrontend/cryptography,@hyperfrontend/logging - Used by:
channel/(binds a provider to a session)
See Also
- Library Index - All modules
- Architecture Guide - Protocol architecture
- Browser v3 - Browser-specific v3 protocol
- Browser v4 - Browser-specific v4 protocol
- Node v3 - Node.js-specific v3 protocol
- Node v4 - Node.js-specific v4 protocol
Related Modules
| Module | Relationship |
|---|---|
| channel/ | Binds a protocol instance to a session |
| security/ | Session, hello outcome, and error codes |
| packet/ | The packet shapes seal and open convert |
protocol is not available yet; rebuild docs to regenerate.