feat(l2ps): SR-4 DACS negotiation stack (WI-A/B/C combined)#99
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>
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>
RfqSession drives the request-for-quote state machine: offer/counter proposals terminating in accept/reject/abort (CH-5 termination). Per the SR-4 brief the RFQ message bodies are implementation-defined (only sealed-envelope bodies are spec-locked, §8.4.3), so this fixes a minimal body schema (proposal carries opaque `terms`; accept references the accepted proposal's sequence) and the legal-transition rules while staying agnostic to the actual terms. Pure protocol state machine — transport-agnostic like ChannelSession: the caller wires `send` to a transport (e.g. L2PSChannelTransport.send, WI-A) and feeds verified inbound envelopes to `onIncoming`. By the time a message reaches onIncoming the channel layer has verified signature / sender∈members / channelId / sequence, so this layer only enforces protocol rules (legal transitions, accept referencing an existing proposal). Resolves accepted terms by the exact proposal sequence the accept points at, so a multi-round counter chain settles on the right terms on both sides. 7 tests: offer→counter→accept settles agreedTerms both sides; reject terminates both; accept resolves the referenced proposal's terms across a counter chain; illegal transitions rejected (accept/counter with nothing standing, double offer); no action after terminal; inbound accept referencing an unknown proposal throws; impl-defined terms carried opaquely. negotiate-sealed-envelope remains gated on DACS-3 §8.4.3 (not in repo). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Ties WI-B's terminal state to WI-3's transcript anchor: on an agreed (accepted) negotiate-rfq, export the signed channel transcript and — per the disclosure policy (DACS-3 §8.7) — anchor an encrypted copy, returning the channelTranscriptRef the rfq output carries. finalizeRfq orchestrates exportTranscript (sign the ordered messages) → anchorEncryptedTranscript (encrypt-to-members + SR-2 anchor). Policy: - none: transcript stays local, nothing anchored - encrypted-anchored-recommended: anchor only on explicit consent - encrypted-anchored-required: MUST anchor; a null result throws (the phase fails, §8.7) Refuses to finalize a non-accepted negotiation; requires an L2PS instance when the policy actually anchors. The anchor call is injectable so the orchestration is unit-tested without a live node (the real anchor deploys an SR-2 storage program). 6 tests cover all four policy paths + the two guards, using a real signed offer→counter→accept so the exported transcript is genuinely CCI-signed. WI-D (AgreementDocument co-sign) needs DACS-3 §8.5.1; WI-E (devnet E2E) needs a node running the messaging server. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Combines the three stacked WIP slices into one branch off main: - WI-A: channel-over-L2PS transport adapter (transport.ts) - WI-B: negotiate-rfq phase logic (negotiate.ts) - WI-C: finalizeRfq transcript anchor on terminal (finalize.ts) Conflict resolution: BroadcastFailedError/TransportError — main already landed the equivalent TS4113 fix (drop invalid override on Error.cause); kept the explanatory comment from the chain. Functionally identical.
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 42 minutes and 42 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 review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling 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)
WalkthroughThree new channel-layer modules are added under ChangesSR-4 negotiate-rfq channel stack
Sequence Diagram(s)sequenceDiagram
participant Alice as Alice RfqSession
participant AliceCh as Alice ChannelSession / Transport
participant Relay as L2PSMessagingPeer / Relay
participant BobCh as Bob ChannelSession / Transport
participant Bob as Bob RfqSession
Alice->>AliceCh: offer(terms)
AliceCh->>Relay: AES-256-GCM encrypt + send
Relay->>BobCh: deliver frame
BobCh->>BobCh: ingest → decrypt → drain → receiveIncoming
BobCh->>Bob: onIncoming(offer)
Bob->>Bob: record standingProposal
Bob->>BobCh: counter(terms)
BobCh->>Relay: AES-256-GCM encrypt + send
Relay->>AliceCh: deliver frame
AliceCh->>Alice: onIncoming(counter)
Alice->>AliceCh: accept(seq)
AliceCh->>Relay: AES-256-GCM encrypt + send
Relay->>BobCh: deliver frame
BobCh->>Bob: onIncoming(accept) → state=accepted
Note over Alice,Bob: Both sides reach accepted
Alice->>Alice: finalizeRfq(policy, session)
Alice->>Alice: exportSignedTranscript
Alice->>Alice: anchorEncryptedTranscript → AttestationRef
Alice-->>Alice: RfqFinalizeResult { transcript, channelTranscriptRef }
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related issues
Possibly related PRs
Suggested labels
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ 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 |
Greptile SummaryThis PR merges three stacked SR-4 DACS negotiation slices (WI-A/B/C) into a single branch off
Confidence Score: 5/5All three modules are well-guarded and the previously raised concerns have been fully addressed; this is safe to merge. The transport correctly handles subarray keys, bounds the reorder buffer, delivers before advancing the local sequence counter, and serialises the inbound drain chain. The negotiation state machine enforces both outbound and inbound transition rules, blocks self-accept, and rejects stale-proposal accepts. The finalise function validates signer membership and session-transcript consistency before exporting or anchoring. The only open point is a module-level style nit (base64 helpers duplicated from anchor.ts). No files require special attention; the pre-existing ValidityData import error affecting finalize.test.ts and transport.e2e.test.ts is documented as out of scope. Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant A as Alice (RfqSession + Transport)
participant L as L2PS Messaging Layer
participant B as Bob (RfqSession + Transport)
Note over A,B: WI-A: Transport layer (encrypt, reorder buffer)
A->>A: "sendOutgoing(offer, seq=1)"
A->>A: AES-256-GCM encrypt
A->>L: "peer.send(to=bob, ciphertext, hash)"
L->>B: deliver payload (onMessage)
B->>B: decrypt → ChannelMessage
B->>B: "buffer.set(seq=1) → drain()"
B->>B: "session.receiveIncoming(seq=1)"
B->>B: "appliedSeq = 1"
Note over A,B: WI-B: RFQ state machine
B->>B: "onIncoming(offer, seq=1) → _standing = {seq:1, sender:A}"
B->>B: "sendOutgoing(counter, seq=2)"
B->>L: "peer.send(to=alice, ciphertext)"
L->>A: deliver
A->>A: "decrypt → receiveIncoming(seq=2)"
A->>A: "onIncoming(counter, seq=2) → _standing = {seq:2, sender:B}"
A->>A: "accept() checks sender≠me → sends accept{acceptedSequence:2}"
A->>L: "peer.send(to=bob, ciphertext)"
L->>B: deliver
B->>B: "onIncoming(accept) → seq matches _standing.seq=2 → settle(accepted)"
Note over A,B: WI-C: Finalize + Anchor
A->>A: finalizeRfq(rfq, session, policy)
A->>A: "check rfq.state=accepted"
A->>A: check signer∈members
A->>A: "check transcript has seq=2 proposal + accept"
A->>A: exportTranscript → signed ChannelTranscript
alt "policy = encrypted-anchored-required"
A->>A: anchorEncryptedTranscript(transcript, l2ps)
A-->>A: AttestationRef
else "policy = none or recommended without consent"
A-->>A: "channelTranscriptRef = null"
end
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant A as Alice (RfqSession + Transport)
participant L as L2PS Messaging Layer
participant B as Bob (RfqSession + Transport)
Note over A,B: WI-A: Transport layer (encrypt, reorder buffer)
A->>A: "sendOutgoing(offer, seq=1)"
A->>A: AES-256-GCM encrypt
A->>L: "peer.send(to=bob, ciphertext, hash)"
L->>B: deliver payload (onMessage)
B->>B: decrypt → ChannelMessage
B->>B: "buffer.set(seq=1) → drain()"
B->>B: "session.receiveIncoming(seq=1)"
B->>B: "appliedSeq = 1"
Note over A,B: WI-B: RFQ state machine
B->>B: "onIncoming(offer, seq=1) → _standing = {seq:1, sender:A}"
B->>B: "sendOutgoing(counter, seq=2)"
B->>L: "peer.send(to=alice, ciphertext)"
L->>A: deliver
A->>A: "decrypt → receiveIncoming(seq=2)"
A->>A: "onIncoming(counter, seq=2) → _standing = {seq:2, sender:B}"
A->>A: "accept() checks sender≠me → sends accept{acceptedSequence:2}"
A->>L: "peer.send(to=bob, ciphertext)"
L->>B: deliver
B->>B: "onIncoming(accept) → seq matches _standing.seq=2 → settle(accepted)"
Note over A,B: WI-C: Finalize + Anchor
A->>A: finalizeRfq(rfq, session, policy)
A->>A: "check rfq.state=accepted"
A->>A: check signer∈members
A->>A: "check transcript has seq=2 proposal + accept"
A->>A: exportTranscript → signed ChannelTranscript
alt "policy = encrypted-anchored-required"
A->>A: anchorEncryptedTranscript(transcript, l2ps)
A-->>A: AttestationRef
else "policy = none or recommended without consent"
A-->>A: "channelTranscriptRef = null"
end
Reviews (2): Last reviewed commit: "fix(l2ps): address Greptile review on SR..." | Re-trigger Greptile |
There was a problem hiding this comment.
Actionable comments posted: 6
🧹 Nitpick comments (4)
src/l2ps/channel/index.ts (1)
54-80: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winUse
@/aliases for the new barrel re-exports.These new exports use relative paths; repo TS guidelines require alias-based imports/exports (
@/...) for consistency.As per coding guidelines: "
**/*.{ts,tsx}: Use@/path aliases instead of relative imports".🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/l2ps/channel/index.ts` around lines 54 - 80, The new barrel re-exports in the channel index file are using relative module paths instead of the required `@/` alias style. Update the export statements in the index barrel that re-export from transport, negotiate, and finalize to use alias-based paths consistent with the repo’s TypeScript import/export guidelines, keeping the existing exported symbols unchanged.Source: Coding guidelines
src/tests/l2ps/finalize.test.ts (1)
123-135: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winThis test currently masks the non-consent branch contract.
l2ps: {} as neverhides whetherrecommended + consent:falseworks without L2PS. Add an assertion for the no-l2pscase and replaceas neverwith a typed stub where needed to keep TS coverage strict.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/tests/l2ps/finalize.test.ts` around lines 123 - 135, The `finalizeRfq` test is masking the non-consent path by using `l2ps: {} as never`, so it does not verify that `recommended` with `consent: false` works when L2PS is absent. Update the `recommended without consent` case in `finalize.test.ts` to add a separate assertion for the no-`l2ps` scenario using `finalizeRfq`, and replace the `as never` cast with a properly typed stub or explicit optional omission so TypeScript still checks the contract strictly.Source: Coding guidelines
src/l2ps/channel/finalize.ts (1)
20-32: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winUse
@/aliases in this TypeScript module.This file currently uses relative imports (
../../,../,./), which conflicts with the repository TS import guideline.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/l2ps/channel/finalize.ts` around lines 20 - 32, Replace the relative imports in finalize.ts with the repository’s `@/` alias paths so the module follows the TypeScript import guideline. Update each import at the top of the file, including the ones for Demos, ClaimReference, L2PS, anchorEncryptedTranscript, exportTranscript, ChannelMessage, ChannelTranscript, RfqOutcome, and RfqState, to use the appropriate `@/` alias equivalent while keeping the same symbols and grouping.Sources: Coding guidelines, Learnings
src/l2ps/channel/transport.ts (1)
34-34: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winUse the repo alias for this import.
Line 34 introduces a relative import in a new TypeScript file.
@/l2ps/channel/typeswould keep this file aligned with the repo’s alias convention.As per coding guidelines,
**/*.{ts,tsx}should "Use@/path aliases instead of relative imports".🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/l2ps/channel/transport.ts` at line 34, The new import in transport.ts uses a relative path instead of the repo alias convention. Update the ChannelMessage and ChannelMessageType import in the transport module to use the `@/l2ps/channel/types` alias so it matches the existing TypeScript import style across the repo.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/l2ps/channel/finalize.ts`:
- Around line 98-102: The guard in finalizeRfq is too broad and blocks the
non-anchoring encrypted-anchored-recommended path when consent is not true.
Update the policy check around opts.l2ps so it only throws when the current flow
actually needs transcript encryption, and allow the consent !== true branch to
proceed and return channelTranscriptRef: null without requiring an L2PS
instance. Use finalizeRfq and the encrypted-anchored-recommended policy handling
to locate the conditional.
- Around line 87-92: Validate the signer’s membership before calling
exportTranscript in finalize, since FinalizeRfqOpts requires the signer to be a
member but finalize currently never checks it. In finalize.ts, use the existing
opts.session.members data (and the signer claim from opts.signer) to verify the
signer is included, and reject finalization with an error if they are not. Keep
the check colocated with the transcript export path so non-members cannot reach
exportTranscript under policy: "none".
In `@src/l2ps/channel/negotiate.ts`:
- Around line 181-194: Inbound “counter” messages are being accepted in
onIncoming even when there is no existing standing proposal, which bypasses the
RFQ transition guard used by counter(). Update the switch handling in
negotiate.ts so the "counter" branch first verifies a current standing proposal
exists (using the same state tracked by this._standing / proposals) and rejects
or ignores the message when the precondition is not met. Keep the existing
"offer" handling unchanged, and ensure the validation is applied in the
onIncoming path before constructing and storing the StandingProposal.
In `@src/l2ps/channel/transport.ts`:
- Around line 142-150: The `send()` flow in `ChannelTransport` currently
consumes a new sequence via `this.session.sendOutgoing()` before fan-out, so a
partial `peer.send()` failure makes the signed envelope unretryable and can
desync recipients. Update `send()` and the surrounding
`ChannelTransport`/session contract so the same signed envelope can be retried
idempotently after transport errors instead of minting a fresh sequence on
retry; keep the sequence/signature stable across retries and only advance the
shared channel counter once delivery is successfully committed or explicitly
acknowledged.
- Around line 111-117: The shared key handling in the L2PSChannelTransport
constructor is using the full backing ArrayBuffer instead of the intended
32-byte Uint8Array view. Update the constructor in L2PSChannelTransport to
validate that opts.sharedKey.byteLength is exactly 32, then ensure both
crypto.subtle.importKey calls use this.sharedKey itself (or a copied Uint8Array
view) rather than this.sharedKey.buffer so only the actual key bytes are
imported.
In `@src/tests/l2ps/negotiate.test.ts`:
- Around line 104-116: Add a regression test in negotiate.test.ts to cover
inbound counter before any offer: the current illegal transition coverage in
rejects illegal transitions only checks outbound aSes.counter, but not
onIncoming handling. Extend the existing harness-based test flow around
harness(), aSes, and onIncoming so that an inbound "counter" is rejected when
nothing is standing, and assert the expected rejection message. Also mirror this
coverage in the similar test block referenced by the same behavior so the
protocol rule stays enforced consistently.
---
Nitpick comments:
In `@src/l2ps/channel/finalize.ts`:
- Around line 20-32: Replace the relative imports in finalize.ts with the
repository’s `@/` alias paths so the module follows the TypeScript import
guideline. Update each import at the top of the file, including the ones for
Demos, ClaimReference, L2PS, anchorEncryptedTranscript, exportTranscript,
ChannelMessage, ChannelTranscript, RfqOutcome, and RfqState, to use the
appropriate `@/` alias equivalent while keeping the same symbols and grouping.
In `@src/l2ps/channel/index.ts`:
- Around line 54-80: The new barrel re-exports in the channel index file are
using relative module paths instead of the required `@/` alias style. Update the
export statements in the index barrel that re-export from transport, negotiate,
and finalize to use alias-based paths consistent with the repo’s TypeScript
import/export guidelines, keeping the existing exported symbols unchanged.
In `@src/l2ps/channel/transport.ts`:
- Line 34: The new import in transport.ts uses a relative path instead of the
repo alias convention. Update the ChannelMessage and ChannelMessageType import
in the transport module to use the `@/l2ps/channel/types` alias so it matches the
existing TypeScript import style across the repo.
In `@src/tests/l2ps/finalize.test.ts`:
- Around line 123-135: The `finalizeRfq` test is masking the non-consent path by
using `l2ps: {} as never`, so it does not verify that `recommended` with
`consent: false` works when L2PS is absent. Update the `recommended without
consent` case in `finalize.test.ts` to add a separate assertion for the
no-`l2ps` scenario using `finalizeRfq`, and replace the `as never` cast with a
properly typed stub or explicit optional omission so TypeScript still checks the
contract strictly.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 5939e492-f0a9-445e-861e-88258f1af32d
📒 Files selected for processing (10)
src/l2ps/channel/finalize.tssrc/l2ps/channel/index.tssrc/l2ps/channel/negotiate.tssrc/l2ps/channel/transport.tssrc/tests/l2ps/finalize.test.tssrc/tests/l2ps/negotiate.test.tssrc/tests/l2ps/transport.e2e.test.tssrc/tests/l2ps/transport.test.tssrc/websdk/BroadcastFailedError.tssrc/websdk/TransportError.ts
| constructor(opts: L2PSChannelTransportOpts) { | ||
| this.session = opts.session | ||
| this.peer = opts.peer | ||
| this.sharedKey = opts.sharedKey | ||
| this.recipients = opts.recipients | ||
| this.onMessage = opts.onMessage | ||
| this.onError = opts.onError |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major
🧩 Analysis chain
🌐 Web query:
For Web Crypto SubtleCrypto.importKey("raw", keyData, "AES-GCM"), does passing a Uint8Array's .bufferignore the view'sbyteOffset/byteLength, and what raw key sizes are accepted for AES-GCM?
💡 Result:
When you pass a TypedArray (like Uint8Array) to SubtleCrypto.importKey, the Web Crypto API correctly respects the view's byteOffset and byteLength. It does not ignore these properties; rather, it uses the specific range of the underlying ArrayBuffer defined by that view [1][2]. If you pass the view's.buffer property directly instead of the view itself, you are passing the entire underlying ArrayBuffer. This will ignore the byteOffset and byteLength of your view, potentially importing unintended data if your view was only a slice of the buffer. Therefore, you should always pass the TypedArray or DataView object itself, rather than its.buffer, to ensure the API only uses the intended range of bytes. For AES-GCM, the raw key data must be exactly 128, 192, or 256 bits (16, 24, or 32 bytes) in length [3]. If the provided key data does not match one of these specific lengths, the importKey operation will throw an error [3].
Citations:
- 1: https://w3c.github.io/webcrypto/
- 2: https://nodejs.org/api/webcrypto.html
- 3: https://nearform.com/insights/implementing-the-web-cryptography-api-for-node-js-core/
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== transport.ts outline ==\n'
ast-grep outline src/l2ps/channel/transport.ts --view expanded || true
printf '\n== transport.ts relevant lines ==\n'
nl -ba src/l2ps/channel/transport.ts | sed -n '1,320p'
printf '\n== sharedKey search ==\n'
rg -n "sharedKey|importKey\\(|AES-GCM" src/l2ps -S || trueRepository: kynesyslabs/sdks
Length of output: 2226
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== transport.ts outline ==\n'
ast-grep outline src/l2ps/channel/transport.ts --view expanded || true
printf '\n== transport.ts relevant lines ==\n'
nl -ba src/l2ps/channel/transport.ts | sed -n '1,320p'
printf '\n== sharedKey search ==\n'
rg -n "sharedKey|importKey\\(|AES-GCM" src/l2ps -S || trueRepository: kynesyslabs/sdks
Length of output: 2226
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== transport.ts constructor and crypto helpers ==\n'
sed -n '111,235p' src/l2ps/channel/transport.ts | cat -n
printf '\n== sharedKey length checks ==\n'
rg -n "byteLength\s*!==\s*32|byteLength\s*===\s*32|sharedKey.*byteLength|sharedKey.*slice\\(" src/l2ps -S || true
printf '\n== importKey call sites ==\n'
rg -n 'importKey\("raw"|sharedKey\.buffer|sharedKey\b' src/l2ps/channel/transport.ts -n -S || trueRepository: kynesyslabs/sdks
Length of output: 6176
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== transport.ts constructor and crypto helpers ==\n'
sed -n '111,235p' src/l2ps/channel/transport.ts | cat -n
printf '\n== sharedKey length checks ==\n'
rg -n "byteLength\s*!==\s*32|byteLength\s*===\s*32|sharedKey.*byteLength|sharedKey.*slice\\(" src/l2ps -S || true
printf '\n== importKey call sites ==\n'
rg -n 'importKey\("raw"|sharedKey\.buffer|sharedKey\b' src/l2ps/channel/transport.ts -n -S || trueRepository: kynesyslabs/sdks
Length of output: 6176
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== transport.ts type comments and exports ==\n'
sed -n '70,130p' src/l2ps/channel/transport.ts | cat -n
printf '\n== L2PSChannelTransport usage sites ==\n'
rg -n "new L2PSChannelTransport\\(" -S .
printf '\n== sharedKey producers/usages ==\n'
rg -n "sharedKey" -S src . | sed -n '1,200p'Repository: kynesyslabs/sdks
Length of output: 4854
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== transport.ts type comments and exports ==\n'
sed -n '70,130p' src/l2ps/channel/transport.ts | cat -n
printf '\n== L2PSChannelTransport usage sites ==\n'
rg -n "new L2PSChannelTransport\\(" -S .
printf '\n== sharedKey producers/usages ==\n'
rg -n "sharedKey" -S src . | sed -n '1,200p'Repository: kynesyslabs/sdks
Length of output: 4854
Use the 32-byte key view directly
this.sharedKey.buffer imports the whole backing buffer, not the Uint8Array slice. Enforce opts.sharedKey.byteLength === 32 in the constructor and pass this.sharedKey itself (or a copied Uint8Array) to both crypto.subtle.importKey calls.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/l2ps/channel/transport.ts` around lines 111 - 117, The shared key
handling in the L2PSChannelTransport constructor is using the full backing
ArrayBuffer instead of the intended 32-byte Uint8Array view. Update the
constructor in L2PSChannelTransport to validate that opts.sharedKey.byteLength
is exactly 32, then ensure both crypto.subtle.importKey calls use this.sharedKey
itself (or a copied Uint8Array view) rather than this.sharedKey.buffer so only
the actual key bytes are imported.
Transport (transport.ts): - key-view bug (P1): importKey copied the whole backing ArrayBuffer; a 32-byte subarray key over a larger buffer broke AES-256 import. Slice the exact [byteOffset, byteOffset+byteLength) window (keyBytes()). - send-commits-early (P1): deliver to all recipients BEFORE advancing appliedSeq, so a rejected peer.send leaves the sequence uncommitted and a retry re-emits it instead of skipping ahead. - unbounded reorder buffer (P2): drop inbound frames more than MAX_REORDER_AHEAD (256) ahead of the contiguous point. Negotiate (negotiate.ts): - inbound transitions bypass rules (P1): inbound offer rejected when a proposal stands; inbound counter rejected when none stands. - stale accept (P1): inbound accept must reference the standing proposal sequence, not any past one. - self-accept (P1): accept() rejects accepting your own standing proposal. Finalize (finalize.ts): - no-consent path throws (P2): 'recommended' without consent is a no-anchor outcome and no longer requires an L2PS instance. - non-member signer (P2): reject a signer not in members up front. - transcript mismatch (P2): require the session messages to contain the accepted proposal + matching accept before exporting/anchoring. Tests: +10 regression tests (transport 4, negotiate 4, finalize 3 — one existing terminal-state test updated to use a counterparty accept since self-accept is now forbidden). transport+negotiate suites: 19/19 green.
|



Combines the three stacked SR-4 DACS negotiation slices (#95 WI-A, #96 WI-B, #97 WI-C) into a single PR off
main, rebased pastv4.0.11.What your code does (plain English)
L2PS lets two parties hold a private, encrypted conversation over the Demos messaging layer. This PR builds the negotiation layer on top of that: two parties can make an offer, send counter-offers back and forth, and finally accept or reject — then optionally record a tamper-proof receipt of the whole exchange on-chain. Three pieces:
none/recommended/required).What's in the diff
src/l2ps/channel/transport.tssrc/l2ps/channel/negotiate.tssrc/l2ps/channel/finalize.tssrc/l2ps/channel/index.tssrc/tests/l2ps/{transport,negotiate,finalize,transport.e2e}.test.tsConflict resolution
#95conflicted withmainbecausev4.0.11independently landed the same TS4113 fix (BroadcastFailedError/TransportError: drop invalidoverrideonError.cause). Resolved by taking the chain's version — functionally identical to main (public readonly cause: unknown) but keeps the explanatory comment. The standalone build-fix commit from the chain is now redundant and folded away.Verification
bun run build— green.bun test transport.test.ts negotiate.test.ts— 12/12 pure unit tests pass.finalize.test.ts/transport.e2e.test.tsfail to load with a pre-existingexport 'ValidityData' not found in './blockchain/ValidityData'module error — reproduced identically on the originalfeat/sr4-wi-c-finalize-anchorbranch, so not introduced here. Unblocking those is out of scope for this merge.Scope / follow-ups (unchanged from the slices)
negotiate-sealed-envelope(commit-then-reveal) gated on DACS-3 §8.4.3 (not in repo).Closes #95, closes #96, closes #97.
Summary by CodeRabbit
New Features
Tests
Documentation