@hyperfrontend/network-protocol/protocol

Protocol

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

ProtocolFactorycreateProtocol signatureInput keying materialEntry points
v3createV3ProtocolFactory(crypto)createProtocol(logger)ECDH shared secret/browser/v3, /node/v3
v4createV4ProtocolFactory(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.

FunctionRole
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.tsencodeHeader, 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

  1. Construction: the provider is called with send, receive, and the session; the instance mints its material at once.
  2. Hello: hello() returns this side's frame. The owner transmits it and retries until the peer confirms; the bytes never change.
  3. 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.
  4. Traffic: seal and open wait 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 with invalid-session.

Wire Format

FrameLayoutLength
Hello[version][type=1][nonce 32][public key 65]99 bytes
Data[version][type=0][counter u64 big-endian] + ciphertext + tagat least 27 bytes
  • Version bytes are 3 and 4; isHello accepts 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 } with data serialised (serializeData).
  • The tag is 16 bytes; a data frame shorter than 27 bytes (header, tag, one ciphertext byte) is malformed.
  • decodeHello also 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.

StepValue
saltinitiatorNonce || responderNonce
dhECDH(own private key, peer public key), 32 bytes
ikmdh (v3), or dh || PBKDF2-SHA256(sharedKey, salt, 600000 iterations) (v4)
i2rHKDF-SHA256(ikm, salt, hyperfrontend/network-protocol/<protocol>/<initiatorId>/<responderId>/i2r) as AES-GCM-256
r2ithe 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 1 and increase by one per sealed frame in each direction.
  • open rejects 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 counterLimit frames rejects the next seal with counter-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.

CodeRaised byWhen
malformedopenFewer than 27 bytes, or the plaintext is not a valid packet
unsupported-versionopenThe version byte is not this protocol's
replayedopenThe counter is not above the last accepted one
authentication-failedopenThe tag does not verify under the session's receiving key
counter-exhaustedsealThe session has sealed every frame it can number
invalid-sessionseal, openThe 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:

FunctionResult
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


See Also

Related Modules

ModuleRelationship
channel/Binds a protocol instance to a session
security/Session, hello outcome, and error codes
packet/The packet shapes seal and open convert
API reference for protocol is not available yet; rebuild docs to regenerate.