feat(l2ps): SR-4 WI-A — channel-over-L2PS transport adapter#95
feat(l2ps): SR-4 WI-A — channel-over-L2PS transport adapter#95Shitikyan wants to merge 3 commits into
Conversation
`bun run build` is red on main: BroadcastFailedError and TransportError declare `public override readonly cause`, but the configured lib (tsconfig `lib: ["es2021"]`, `target: es2020`) predates ES2022's `Error.cause`, so the base `Error` has no `cause` to override — TS4113. The pre-commit build hook blocks every commit until this clears. Drop the `override` modifier; the class still declares `cause` as its own property (identical runtime behaviour). Pre-existing — landed with #94/#91, not introduced here. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bridges the transport-agnostic DACS channel layer (ChannelSession, which only produces/verifies signed envelopes) onto the L2PS instant-messaging transport (L2PSMessagingPeer, which moves encrypted bytes between subnet members). Core is a reorder buffer. ChannelSession.receiveIncoming accepts any sequence strictly greater than the highest seen and advances its counter to it — so a gap (seq N+2 before N+1) silently skips the missing messages rather than rejecting. The L2PS transport delivers by timestamp and replays an offline queue, so out-of-order arrival is normal. The adapter hands the session only the next contiguous sequence and buffers anything ahead of the gap until it fills; duplicates / already-applied sequences are dropped; envelopes for other channels sharing the subnet are ignored. Outgoing: session.sendOutgoing -> JSON -> AES-256-GCM under the subnet key -> peer.send to every member except self. Incoming: decrypt -> parse -> buffer -> drain in order -> session.receiveIncoming (still enforces signature / sender-in-members / channelId, throwing on tamper). Dependencies are declared as structural interfaces so the reorder logic is unit-tested without real signing keys or a live socket. 5/5 tests: in-order, gap-buffer-and-fill, duplicate-drop, foreign-channel-ignore, send/reply interleave. Scope: turn-based messaging (DACS negotiation is offer->counter->accept); concurrent same-sequence emission from both parties is out of SR-4 v1 scope. SDK stays transport-agnostic — this adapter is the opt-in bridge. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Qodo reviews are paused for this user.Troubleshooting steps vary by plan Learn more → On a Teams plan? Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center? |
|
Warning Review limit reached
More reviews will be available in 36 minutes and 25 seconds. Learn how PR review limits work. Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file). ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits. 🚦 How do rate limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate. For paid Pro and Pro+ PR reviews, CodeRabbit uses rolling per-developer review limits. Reviews become available again as older review attempts age out of the rolling limit window. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (6)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Complements the unit test (which mocks the session to isolate the reorder buffer) with a full-stack E2E: two real Demos identities, two real ChannelSessions, two L2PSChannelTransports over an in-process relay that stands in for the messaging server. Exercises real CCI-key signing, real cross-party verification (signature + sender∈members + channelId), real AES-256-GCM under a shared subnet key, and the reorder buffer under realistic out-of-order delivery. 3 cases: - full offer → counter → accept round verifies on both ends; both transcripts agree on [1,2,3] - a two-message burst delivered REVERSED (seq2 before seq1) is buffered and applied in order - a tampered frame surfaces via onError and does not advance the session Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
| const key = await crypto.subtle.importKey( | ||
| "raw", | ||
| this.sharedKey.buffer as ArrayBuffer, | ||
| "AES-GCM", | ||
| false, | ||
| ["encrypt"], | ||
| ) |
There was a problem hiding this comment.
Wrong key material when
sharedKey is a typed-array subview
this.sharedKey.buffer gives the entire underlying ArrayBuffer, ignoring byteOffset and byteLength. If sharedKey was created as a view into a larger buffer (e.g. a KDF output slice: new Uint8Array(kdfOutput, 32, 32)), the full backing buffer is handed to importKey instead of the 32-byte window, producing a silently different AES key. Encryption would succeed, decryption on the other party would fail with an authentication error at runtime. The safe form is to pass the Uint8Array directly — Web Crypto's BufferSource accepts typed-array views and correctly respects their offset/length.
| const key = await crypto.subtle.importKey( | |
| "raw", | |
| this.sharedKey.buffer as ArrayBuffer, | |
| "AES-GCM", | |
| false, | |
| ["encrypt"], | |
| ) | |
| const key = await crypto.subtle.importKey( | |
| "raw", | |
| this.sharedKey, | |
| "AES-GCM", | |
| false, | |
| ["encrypt"], | |
| ) |
| const key = await crypto.subtle.importKey( | ||
| "raw", | ||
| this.sharedKey.buffer as ArrayBuffer, | ||
| "AES-GCM", | ||
| false, | ||
| ["decrypt"], | ||
| ) |
There was a problem hiding this comment.
Same
sharedKey.buffer subview problem in decrypt — pass the Uint8Array directly so the view's byteOffset and byteLength are respected.
| const key = await crypto.subtle.importKey( | |
| "raw", | |
| this.sharedKey.buffer as ArrayBuffer, | |
| "AES-GCM", | |
| false, | |
| ["decrypt"], | |
| ) | |
| const key = await crypto.subtle.importKey( | |
| "raw", | |
| this.sharedKey, | |
| "AES-GCM", | |
| false, | |
| ["decrypt"], | |
| ) |
| while (next) { | ||
| this.buffer.delete(next.sequence) | ||
| // Throws on tamper (bad signature, wrong sender, channelId | ||
| // mismatch) — propagate as channel-fatal per §8.12. | ||
| await this.session.receiveIncoming(next) | ||
| this.appliedSeq = next.sequence | ||
| this.onMessage?.(next) | ||
| next = this.buffer.get(this.appliedSeq + 1) | ||
| } |
There was a problem hiding this comment.
Buffered messages silently stranded after a verification failure
drain() calls this.buffer.delete(next.sequence) before awaiting receiveIncoming. If receiveIncoming throws (bad signature, sender-not-in-members, etc.), the message is gone from the buffer but appliedSeq is not advanced. All subsequently buffered messages (sequence N+1, N+2, …) expect the gap at N to fill; since it never will, drain() returns immediately on every future call and those messages are silently held forever — with no further onError fired. The PR documents this as "channel-fatal per §8.12", but callers only learn of the lethality via a single error and then observe silence. Either move the buffer.delete after a successful receiveIncoming, or add a stopped flag that gate-drops all future inbound frames with a clear error.
| async send(opts: { | ||
| type: ChannelMessageType | ||
| body: unknown | ||
| repliesTo?: number | ||
| }): Promise<ChannelMessage> { | ||
| const signed = await this.session.sendOutgoing(opts) | ||
| // Our own send advances the shared per-channel counter; record it | ||
| // so we expect the peer's reply at appliedSeq + 1. | ||
| if (signed.sequence > this.appliedSeq) this.appliedSeq = signed.sequence | ||
|
|
||
| const encrypted = await this.encrypt(JSON.stringify(signed)) | ||
| const messageHash = signed.signature.signature // unique per signed envelope | ||
| for (const to of this.recipients) { | ||
| await this.peer.send(to, encrypted, messageHash) | ||
| } | ||
| return signed | ||
| } |
There was a problem hiding this comment.
send() updates appliedSeq outside the serialised draining chain
send() reads and then writes appliedSeq synchronously (after the sendOutgoing await) while the inbound draining promise chain can be mid-flight concurrently. In the turn-based DACS model (offer → counter → accept) a party never sends while expecting an inbound, so this race is out-of-scope for SR-4 v1. The gap is nonetheless real: any caller that issues a send() while an inbound is mid-drain can see appliedSeq advanced by drain() before send() writes it, or vice-versa. A lightweight fix is to route send()'s appliedSeq update through a helper that also chains onto draining.
|



First slice of the DACS negotiation layer on top of the merged SR-4 substrate (#94): wire the transport-agnostic
ChannelSessiononto the L2PS instant-messaging transport.What
L2PSChannelTransport(src/l2ps/channel/transport.ts) bridges:session.sendOutgoing→ JSON → AES-256-GCM under the subnet key →peer.sendto every member except selfpeer.onMessage→ decrypt → parse → reorder buffer →session.receiveIncomingin strict sequence orderWhy a reorder buffer
ChannelSession.receiveIncomingaccepts any sequence strictly greater than the highest seen and advances its counter to it — a gap (seq N+2 before N+1) silently skips the missing messages rather than rejecting. The L2PS transport delivers by timestamp and replays an offline queue, so out-of-order arrival is normal. The adapter hands the session only the next contiguous sequence, buffers ahead-of-gap, drops duplicates/already-applied, and ignores envelopes for other channels on the same subnet.Tests
Dependencies declared as structural interfaces (
ChannelSessionLike,L2PSMessagingPeerLike) so the reorder logic is unit-tested without real keys or a socket. 5/5: in-order, gap-buffer-and-fill, duplicate-drop, foreign-channel-ignore, send/reply interleave.Scope
Turn-based messaging (DACS negotiation is offer→counter→accept); concurrent same-sequence emission from both parties is out of SR-4 v1 scope. The SDK stays transport-agnostic — this adapter is the opt-in bridge. Next: WI-B negotiate-rfq phase logic (needs DACS-3 §8.4 spec).
Note — includes a pre-existing build fix
First commit (
fix(build): drop invalid override on Error.cause) clears a TS4113 that makesbun run buildred onmain(BroadcastFailedError/TransportError useoverrideoncause, but tsconfig lib es2021 predates ES2022Error.cause). The pre-commit build hook blocks all commits until it clears, so it's bundled here — happy to split it into its own PR if preferred; it should land regardless of WI-A.