@robota-sdk/agent-transport-webrtc 3.0.0-beta.81
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/dist/node/index.cjs +1 -0
- package/dist/node/index.d.cts +361 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +361 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +2 -0
- package/dist/node/index.js.map +1 -0
- package/package.json +88 -0
- package/src/__tests__/admission-secure-by-default.test.ts +61 -0
- package/src/__tests__/admission-steps.test.ts +46 -0
- package/src/__tests__/cve-2024-29415-reachability.test.ts +80 -0
- package/src/__tests__/dtls-fingerprint-binding.test.ts +60 -0
- package/src/__tests__/handoff-grant-gate.test.ts +401 -0
- package/src/__tests__/local-peer-proof-gate.test.ts +213 -0
- package/src/__tests__/mesh-admission-contract.test.ts +38 -0
- package/src/__tests__/negotiated-certificate.test.ts +80 -0
- package/src/__tests__/operator-approval-gate.test.ts +306 -0
- package/src/__tests__/outbound-delivery-carrier.test.ts +152 -0
- package/src/__tests__/pairing-e2e.test.ts +238 -0
- package/src/__tests__/pairing-gate-e3.test.ts +192 -0
- package/src/__tests__/pairing-gate-e4.test.ts +108 -0
- package/src/__tests__/pairing-gate.test.ts +276 -0
- package/src/__tests__/pairing-relay-in-the-middle.test.ts +229 -0
- package/src/__tests__/session-attachment-budget.test.ts +136 -0
- package/src/__tests__/webrtc-transport.test.ts +349 -0
- package/src/__tests__/werift-loader.test.ts +18 -0
- package/src/__tests__/ws-signaling-client.test.ts +108 -0
- package/src/admission-steps.ts +55 -0
- package/src/channel-delivery.ts +44 -0
- package/src/handoff-grant-gate.ts +186 -0
- package/src/index.ts +21 -0
- package/src/local-peer-proof.ts +135 -0
- package/src/negotiated-certificate.ts +80 -0
- package/src/pairing-channel-lifecycle.ts +31 -0
- package/src/pairing-controllers.ts +79 -0
- package/src/pairing-frames.ts +34 -0
- package/src/pairing-gate-options.ts +129 -0
- package/src/pairing-gate.ts +376 -0
- package/src/session-attachment.ts +88 -0
- package/src/signaling.ts +62 -0
- package/src/transport-lifecycle-error.ts +11 -0
- package/src/webrtc-delivery-lifecycle.ts +51 -0
- package/src/webrtc-transport-options.ts +84 -0
- package/src/webrtc-transport.ts +328 -0
- package/src/werift-loader.ts +40 -0
- package/src/ws-signaling-client.ts +139 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEC-011 (issue #1865): the channel gate consumes the cross-device hand-off grant.
|
|
3
|
+
*
|
|
4
|
+
* The same shape SEC-010's local-proof step established, for the same reason. #1810 is explicit that
|
|
5
|
+
* this package must not implement cryptographic policy, so nothing here verifies a signature,
|
|
6
|
+
* decides an expiry, or knows what revocation is. The gate holds the state machine; the verdict is
|
|
7
|
+
* injected and reported.
|
|
8
|
+
*
|
|
9
|
+
* ## What the grant proves, and what the channel proves
|
|
10
|
+
*
|
|
11
|
+
* The pairing handshake binds the CHANNEL: after it, this data channel provably terminates at the
|
|
12
|
+
* peer that knew the secret, and the DTLS fingerprints are covered so it cannot be spliced. It says
|
|
13
|
+
* nothing about who that peer is.
|
|
14
|
+
*
|
|
15
|
+
* The grant binds the TRANSFER: one user, one source device, one destination, one `handoffId`, one
|
|
16
|
+
* `sessionId`, one nonce, and the fingerprint of the channel it may be presented over. It says
|
|
17
|
+
* nothing about whether the channel it arrived on is the one it names.
|
|
18
|
+
*
|
|
19
|
+
* Presenting the grant OVER the already-bound channel is what joins them, and the fingerprint claim
|
|
20
|
+
* is what makes the join checkable: a grant lifted from one channel and replayed on another names a
|
|
21
|
+
* fingerprint the verifier does not observe. That is `channel-substituted`, and it is a distinct
|
|
22
|
+
* rejection precisely so it cannot be softened into a pass.
|
|
23
|
+
*
|
|
24
|
+
* ## Why it runs after the handshake and before the session
|
|
25
|
+
*
|
|
26
|
+
* Both edges are load-bearing, and both for SEC-010's reasons.
|
|
27
|
+
*
|
|
28
|
+
* BEFORE the handshake completes, the channel is not authenticated — a grant presented there would
|
|
29
|
+
* be handed to an unproven counterpart, and this step would weaken what it exists to strengthen.
|
|
30
|
+
*
|
|
31
|
+
* BEFORE the session is exposed, because "fail closed before content" is the first failure rule. A
|
|
32
|
+
* peer that never presents a grant must not have been talking to the session already; refusing it
|
|
33
|
+
* afterwards is not refusing it.
|
|
34
|
+
*
|
|
35
|
+
* ## The trust it produces is its own
|
|
36
|
+
*
|
|
37
|
+
* `same-user-different-host` is not `same-user-same-host`. #1812 pins that they are different values
|
|
38
|
+
* and this gate is the reason: a cross-device authorization must never satisfy a check that wanted
|
|
39
|
+
* same-machine. It travels as a value on the admission rather than being re-derived from a boolean
|
|
40
|
+
* by whoever reads it next.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import type { IPeerAdmission } from '@robota-sdk/agent-interface-session-mobility';
|
|
44
|
+
|
|
45
|
+
/** The frame a source device presents to show it holds a grant for this transfer. */
|
|
46
|
+
export interface IHandoffGrantFrame {
|
|
47
|
+
readonly t: 'handoff-grant';
|
|
48
|
+
/**
|
|
49
|
+
* The signed grant, carried opaquely.
|
|
50
|
+
*
|
|
51
|
+
* `unknown` on purpose: this package cannot import the grant type without importing the package
|
|
52
|
+
* that owns the crypto, and typing it structurally here would create a second declaration of a
|
|
53
|
+
* signed object's shape — which is how a field ends up checked in one copy and not the other.
|
|
54
|
+
* The verifier owns the type; this gate owns the envelope.
|
|
55
|
+
*/
|
|
56
|
+
readonly grant: unknown;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The cross-device authorization this gate requires before exposing the session.
|
|
61
|
+
*
|
|
62
|
+
* Absent → the gate behaves exactly as before. Requiring a grant is opt-in because an ordinary
|
|
63
|
+
* remote peer has no hand-off to authorize, and a gate that demanded one unconditionally would
|
|
64
|
+
* refuse every legitimate remote session.
|
|
65
|
+
*/
|
|
66
|
+
export interface IHandoffGrantProof {
|
|
67
|
+
/**
|
|
68
|
+
* Judge a presented grant. Owned by the verifier — this package asks and does not decide.
|
|
69
|
+
*
|
|
70
|
+
* Async because verifying a signature is: `verifyHandoffGrant` returns a promise, and a
|
|
71
|
+
* synchronous seam here would force the owner to block or to pre-verify, and pre-verifying means
|
|
72
|
+
* deciding before the channel that the grant is bound to even exists.
|
|
73
|
+
*
|
|
74
|
+
* Returns the admission the consumer receives, so the trust level travels as a value.
|
|
75
|
+
*/
|
|
76
|
+
readonly verify: (grant: unknown) => Promise<IPeerAdmission>;
|
|
77
|
+
/**
|
|
78
|
+
* Ask the person at THIS machine whether to accept the transfer. Absent → no consent is required.
|
|
79
|
+
*
|
|
80
|
+
* Called ONLY on a verdict the verifier admitted, and given that verdict, for two reasons that
|
|
81
|
+
* both matter:
|
|
82
|
+
*
|
|
83
|
+
* The prompt can then name a PROVEN origin. Asking before verification would let anyone who can
|
|
84
|
+
* open a channel raise a dialog on someone's machine claiming to be any device they like, which
|
|
85
|
+
* turns the consent step into an attack surface instead of a control.
|
|
86
|
+
*
|
|
87
|
+
* And consent is not cryptographic policy, so it does not belong inside `verify`. A verifier that
|
|
88
|
+
* also asked a human would make "is this grant valid" and "does this person want it" one answer,
|
|
89
|
+
* and the first is the one that must be decidable without a person present.
|
|
90
|
+
*
|
|
91
|
+
* Returning false — or throwing, or having no renderer to ask — refuses. Denial fails CLOSED.
|
|
92
|
+
*/
|
|
93
|
+
readonly consent?: (admission: IPeerAdmission) => Promise<boolean>;
|
|
94
|
+
/**
|
|
95
|
+
* Fired on the outcome, admitted or not.
|
|
96
|
+
*
|
|
97
|
+
* On BOTH, deliberately: a consumer told only about successes cannot distinguish "no hand-off was
|
|
98
|
+
* offered" from "a hand-off was refused", and those call for different operator responses — the
|
|
99
|
+
* second is the one that belongs in front of a person.
|
|
100
|
+
*/
|
|
101
|
+
readonly onAdmission?: (admission: IPeerAdmission) => void;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* True when a parsed pre-accept value is a grant frame.
|
|
106
|
+
*
|
|
107
|
+
* Module-private: `judgeHandoffGrant` is the only thing that should ask. Exporting it would offer a
|
|
108
|
+
* caller a way to check the shape and then act on it without going through the judge, which is how
|
|
109
|
+
* an admission decision ends up made in two places.
|
|
110
|
+
*/
|
|
111
|
+
function isHandoffGrantFrame(value: unknown): value is IHandoffGrantFrame {
|
|
112
|
+
return (
|
|
113
|
+
typeof value === 'object' &&
|
|
114
|
+
value !== null &&
|
|
115
|
+
(value as { t?: unknown }).t === 'handoff-grant' &&
|
|
116
|
+
(value as { grant?: unknown }).grant !== undefined
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** A refusal shaped like every other admission, so no caller has to special-case the failure path. */
|
|
121
|
+
function refuse(reason: string): IPeerAdmission {
|
|
122
|
+
return { admitted: false, trust: 'unproven', reason };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Judge one pre-accept frame against the configured grant proof.
|
|
127
|
+
*
|
|
128
|
+
* Every path that is not an admitted verification returns a refusal — including a missing config,
|
|
129
|
+
* which cannot happen through the gate but would be a fail-OPEN if it ever did.
|
|
130
|
+
*/
|
|
131
|
+
export async function judgeHandoffGrant(
|
|
132
|
+
parsed: unknown,
|
|
133
|
+
proof: IHandoffGrantProof | undefined,
|
|
134
|
+
): Promise<IPeerAdmission> {
|
|
135
|
+
if (proof === undefined)
|
|
136
|
+
return refuse('no hand-off grant verifier is configured for this channel');
|
|
137
|
+
if (!isHandoffGrantFrame(parsed)) {
|
|
138
|
+
return refuse('expected a handoff-grant frame carrying the signed grant');
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
const admission = await proof.verify(parsed.grant);
|
|
142
|
+
// The verifier owns the verdict, but not the ability to widen it. A cross-device grant cannot
|
|
143
|
+
// establish that the peer is on THIS machine, so an admission claiming it did is refused rather
|
|
144
|
+
// than passed on — an implementation that returned the stronger level would otherwise satisfy
|
|
145
|
+
// every check that wanted same-machine, which is the one substitution #1812 forbids by name.
|
|
146
|
+
if (admission.admitted && admission.trust === 'same-user-same-host') {
|
|
147
|
+
return refuse(
|
|
148
|
+
'the grant verifier returned same-user-same-host. A cross-device grant proves the user, ' +
|
|
149
|
+
'not the machine — see SEC-010 for what same-host requires.',
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
if (!admission.admitted || proof.consent === undefined) return admission;
|
|
153
|
+
// The person, after the proof. A refusal here is not a weaker outcome than a cryptographic one:
|
|
154
|
+
// the session is equally not exposed, and the reason says which step declined so the operator is
|
|
155
|
+
// not left reading "unauthorized" when what happened is that they said no.
|
|
156
|
+
if (!(await proof.consent(admission))) {
|
|
157
|
+
return refuse('the person at this machine declined the transfer');
|
|
158
|
+
}
|
|
159
|
+
return admission;
|
|
160
|
+
} catch (error) {
|
|
161
|
+
// allow-fallback: fail-CLOSED, not a fallback to a degraded path. A verifier that throws has not
|
|
162
|
+
// reached a decision, and "not reached" is not "allowed" — the refusal below is strictly more
|
|
163
|
+
// restrictive than the success path, never less. Letting it propagate would be worse: the throw
|
|
164
|
+
// unwinds through the channel's message subscription and leaves the gate parked in its grant
|
|
165
|
+
// state with the channel open, which is a hang — fail-open wearing a crash's clothes.
|
|
166
|
+
return refuse(
|
|
167
|
+
`the grant verifier could not decide: ${
|
|
168
|
+
error instanceof Error ? error.message : 'unknown error'
|
|
169
|
+
}`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Build the frame a source device presents on the channel.
|
|
176
|
+
*
|
|
177
|
+
* Lives beside the judge that reads it, so the two cannot drift: a sender that hand-built the object
|
|
178
|
+
* would keep compiling after the frame gains a field, and the failure would surface as a refusal on
|
|
179
|
+
* the far side with no hint that the SENDER is the stale half.
|
|
180
|
+
*
|
|
181
|
+
* Deliberately not a "send" — it returns the frame and leaves transmission to whoever owns the
|
|
182
|
+
* channel, for the same reason `localProofFrame` does.
|
|
183
|
+
*/
|
|
184
|
+
export function handoffGrantFrame(grant: unknown): IHandoffGrantFrame {
|
|
185
|
+
return { t: 'handoff-grant', grant };
|
|
186
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export { WebRtcTransport } from './webrtc-transport.js';
|
|
2
|
+
export type { IWebRtcTransportOptions, IIceServer } from './webrtc-transport-options.js';
|
|
3
|
+
export type {
|
|
4
|
+
IConnectionApproval,
|
|
5
|
+
IConnectionApprovalContext,
|
|
6
|
+
IHostReconnectConfig,
|
|
7
|
+
} from './pairing-gate.js';
|
|
8
|
+
// The judge and the frame predicate stay internal: they are this package's policy plumbing, and a
|
|
9
|
+
// composition root only needs to SUPPLY the port and, on the peer side, know the frame's shape.
|
|
10
|
+
export { localProofFrame } from './local-peer-proof.js';
|
|
11
|
+
export type { ILocalPeerProof, ILocalProofFrame } from './local-peer-proof.js';
|
|
12
|
+
// SEC-011 (issue #1865): the cross-device hand-off grant gate. The verdict is injected — this
|
|
13
|
+
// package implements no cryptographic policy.
|
|
14
|
+
export { handoffGrantFrame } from './handoff-grant-gate.js';
|
|
15
|
+
export type { IHandoffGrantFrame, IHandoffGrantProof } from './handoff-grant-gate.js';
|
|
16
|
+
export { createInMemorySignalingPair } from './signaling.js';
|
|
17
|
+
export type { ISignalingClient, ISignalMessage, TSignalKind } from './signaling.js';
|
|
18
|
+
export { WsSignalingClient } from './ws-signaling-client.js';
|
|
19
|
+
export type { IWsSignalingClientOptions, IWebSocketLike } from './ws-signaling-client.js';
|
|
20
|
+
export { loadWerift } from './werift-loader.js';
|
|
21
|
+
export type { IWeriftModule, TModuleResolver } from './werift-loader.js';
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEC-010 TC-08: joining the rendezvous proof to the channel the session will run on.
|
|
3
|
+
*
|
|
4
|
+
* ## What each half proves, and why neither is enough alone
|
|
5
|
+
*
|
|
6
|
+
* The pairing handshake binds the CHANNEL: after it, this data channel provably terminates at the
|
|
7
|
+
* peer that knew the secret, and the DTLS fingerprints are covered so it cannot be spliced. It says
|
|
8
|
+
* nothing about where that peer runs.
|
|
9
|
+
*
|
|
10
|
+
* The guarded rendezvous binds the ENVIRONMENT: a peer that reached a 0700 directory owned by this
|
|
11
|
+
* user could only have come from this machine, as this user. It says nothing about which channel
|
|
12
|
+
* that peer later opens.
|
|
13
|
+
*
|
|
14
|
+
* Presenting the rendezvous nonce OVER the already-bound channel is what joins them. The channel is
|
|
15
|
+
* authenticated by the time this runs, so nobody else can inject the frame; the nonce is single-use,
|
|
16
|
+
* so nobody can re-present one they observed. Together they say: the party on this channel is the
|
|
17
|
+
* party the kernel vouched for, on this attempt.
|
|
18
|
+
*
|
|
19
|
+
* ## Why this runs after the handshake and before the session
|
|
20
|
+
*
|
|
21
|
+
* Both edges are load-bearing.
|
|
22
|
+
*
|
|
23
|
+
* BEFORE the handshake completes, the channel is not yet authenticated — a nonce presented there
|
|
24
|
+
* would be a secret handed to an unproven counterpart, and this step would weaken the very thing it
|
|
25
|
+
* exists to strengthen.
|
|
26
|
+
*
|
|
27
|
+
* BEFORE the session is exposed, because "fail closed before content" is SEC-010's first failure
|
|
28
|
+
* rule. If the proof arrived after exposure, a peer that never presents one would have already been
|
|
29
|
+
* talking to the session, and refusing it afterwards is not refusing it.
|
|
30
|
+
*
|
|
31
|
+
* ## This package decides nothing
|
|
32
|
+
*
|
|
33
|
+
* #1810 is explicit that WebRTC must not implement cryptographic policy. Single-use, expiry and
|
|
34
|
+
* revocation belong to the grant ledger in `@robota-sdk/agent-remote-pairing/local`, which is
|
|
35
|
+
* node-only; this module takes an injected `redeem` and reports what it was told. That also keeps
|
|
36
|
+
* `node:fs` out of this package, and keeps the ledger testable without a data channel.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import type { IPeerAdmission } from '@robota-sdk/agent-interface-session-mobility';
|
|
40
|
+
|
|
41
|
+
/** The frame a local peer presents to show it reached the guarded rendezvous. */
|
|
42
|
+
export interface ILocalProofFrame {
|
|
43
|
+
readonly t: 'local-proof';
|
|
44
|
+
readonly nonce: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The local-environment proof this gate requires before exposing the session.
|
|
49
|
+
*
|
|
50
|
+
* Absent → the gate behaves exactly as before. Requiring the proof is opt-in because a remote peer
|
|
51
|
+
* over WebRTC has no rendezvous to have reached, and a gate that demanded one unconditionally would
|
|
52
|
+
* refuse every legitimate remote session.
|
|
53
|
+
*/
|
|
54
|
+
export interface ILocalPeerProof {
|
|
55
|
+
/**
|
|
56
|
+
* Redeem a presented nonce. Owned by the ledger — this package asks and does not decide.
|
|
57
|
+
*
|
|
58
|
+
* Returns the admission the consumer receives, so the trust level travels as a value rather than
|
|
59
|
+
* being re-derived from a boolean by whoever reads it next.
|
|
60
|
+
*/
|
|
61
|
+
readonly redeem: (nonce: string) => IPeerAdmission;
|
|
62
|
+
/**
|
|
63
|
+
* Fired on the outcome, admitted or not.
|
|
64
|
+
*
|
|
65
|
+
* On BOTH, deliberately: a consumer told only about successes cannot distinguish "no local peer
|
|
66
|
+
* connected" from "a local peer was refused", and those call for different operator responses.
|
|
67
|
+
*/
|
|
68
|
+
readonly onAdmission?: (admission: IPeerAdmission) => void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* True when a parsed pre-accept value is a local-proof frame carrying a nonce.
|
|
73
|
+
*
|
|
74
|
+
* Module-private: `judgeLocalProof` is the only thing that should ask. Exporting it would offer a
|
|
75
|
+
* caller a way to check the shape and then act on it without going through the judge, which is how
|
|
76
|
+
* an admission decision ends up made in two places.
|
|
77
|
+
*/
|
|
78
|
+
function isLocalProofFrame(value: unknown): value is ILocalProofFrame {
|
|
79
|
+
return (
|
|
80
|
+
typeof value === 'object' &&
|
|
81
|
+
value !== null &&
|
|
82
|
+
(value as { t?: unknown }).t === 'local-proof' &&
|
|
83
|
+
typeof (value as { nonce?: unknown }).nonce === 'string'
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A refusal shaped like every other admission, so no caller has to special-case the failure path. */
|
|
88
|
+
function refuse(reason: string): IPeerAdmission {
|
|
89
|
+
return { admitted: false, trust: 'unproven', reason };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Judge one pre-accept frame against the configured proof.
|
|
94
|
+
*
|
|
95
|
+
* Every path that is not an admitted redemption returns a refusal — including a missing config,
|
|
96
|
+
* which cannot happen through the gate but would be a fail-OPEN if it ever did.
|
|
97
|
+
*/
|
|
98
|
+
export function judgeLocalProof(
|
|
99
|
+
parsed: unknown,
|
|
100
|
+
proof: ILocalPeerProof | undefined,
|
|
101
|
+
): IPeerAdmission {
|
|
102
|
+
if (proof === undefined) return refuse('no local-peer proof is configured for this channel');
|
|
103
|
+
if (!isLocalProofFrame(parsed)) {
|
|
104
|
+
return refuse('expected a local-proof frame carrying the rendezvous nonce');
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
return proof.redeem(parsed.nonce);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
// allow-fallback: fail-CLOSED, not a fallback to a degraded path. A ledger that throws has not
|
|
110
|
+
// reached a decision, and "not reached" is not "allowed" — the refusal below is strictly more
|
|
111
|
+
// restrictive than the success path, never less. Letting it propagate would be worse: the throw
|
|
112
|
+
// unwinds through the channel's message subscription and leaves the gate parked in its proof
|
|
113
|
+
// state with the channel open, which is a hang — fail-open wearing a crash's clothes.
|
|
114
|
+
return refuse(
|
|
115
|
+
`the rendezvous ledger could not decide: ${
|
|
116
|
+
error instanceof Error ? error.message : 'unknown error'
|
|
117
|
+
}`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Build the frame a local peer presents on the channel.
|
|
124
|
+
*
|
|
125
|
+
* Lives beside the judge that reads it, so the two cannot drift: a sender that hand-built the object
|
|
126
|
+
* would keep compiling after the frame gains a field, and the failure would surface as a refusal on
|
|
127
|
+
* the far side with no hint that the SENDER is the stale half.
|
|
128
|
+
*
|
|
129
|
+
* Deliberately not a "send" — it returns the frame and leaves transmission to whoever owns the
|
|
130
|
+
* channel. A helper that both built and sent would need a channel to be testable, and would put the
|
|
131
|
+
* frame's shape and the carrier's lifecycle in one place.
|
|
132
|
+
*/
|
|
133
|
+
export function localProofFrame(nonce: string): ILocalProofFrame {
|
|
134
|
+
return { t: 'local-proof', nonce };
|
|
135
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The remote fingerprint the pairing confirmation binds to, read from the certificate the DTLS layer verified.
|
|
3
|
+
*
|
|
4
|
+
* The SDP is delivered by an untrusted signaling path, and a DTLS stack accepts the remote certificate when it
|
|
5
|
+
* matches ANY fingerprint the SDP advertises. Binding a value read from SDP text would therefore let the bound
|
|
6
|
+
* fingerprint and the verified certificate differ. Reading the certificate itself, after the DTLS layer has
|
|
7
|
+
* verified it, makes the binding and the connection share one source of truth.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
|
|
12
|
+
import type { RTCPeerConnection } from 'werift';
|
|
13
|
+
|
|
14
|
+
const HASHES: Readonly<Record<string, string>> = {
|
|
15
|
+
'sha-256': 'sha256',
|
|
16
|
+
'sha-384': 'sha384',
|
|
17
|
+
'sha-512': 'sha512',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** SDP-form fingerprint (`AB:CD:…`, upper case) of a DER certificate. Throws on an unsupported algorithm. */
|
|
21
|
+
export function certificateFingerprint(der: Uint8Array, algorithm: string): string {
|
|
22
|
+
const hash = HASHES[algorithm.toLowerCase()];
|
|
23
|
+
if (!hash) throw new Error(`unsupported DTLS fingerprint algorithm: ${algorithm}`);
|
|
24
|
+
const hex = createHash(hash).update(der).digest('hex').toUpperCase();
|
|
25
|
+
return hex.match(/../g)?.join(':') ?? '';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Call `onVerified` with the remote certificate's fingerprint once the DTLS handshake — including its own
|
|
30
|
+
* fingerprint check — has completed, or `onFailed` if the handshake fails or closes first, or the certificate
|
|
31
|
+
* cannot be read. Returns an unsubscribe function.
|
|
32
|
+
*
|
|
33
|
+
* werift moves the DTLS transport to `connected` only after `verifyRemoteCertificateFingerprint` succeeds, and
|
|
34
|
+
* the data channel runs over that DTLS session, so no channel frame can precede this callback.
|
|
35
|
+
*/
|
|
36
|
+
export function whenRemoteCertificateVerified(
|
|
37
|
+
peer: RTCPeerConnection,
|
|
38
|
+
algorithm: string,
|
|
39
|
+
onVerified: (fingerprint: string) => void,
|
|
40
|
+
onFailed: (reason: string) => void,
|
|
41
|
+
): () => void {
|
|
42
|
+
const dtlsTransport = peer.sctpTransport?.dtlsTransport;
|
|
43
|
+
if (!dtlsTransport) {
|
|
44
|
+
onFailed('no DTLS transport for the data channel');
|
|
45
|
+
return () => undefined;
|
|
46
|
+
}
|
|
47
|
+
let done = false;
|
|
48
|
+
const settle = (): void => {
|
|
49
|
+
if (done) return;
|
|
50
|
+
const der = dtlsTransport.dtls?.remoteCertificate;
|
|
51
|
+
done = true;
|
|
52
|
+
if (!der) {
|
|
53
|
+
onFailed('the DTLS layer exposed no remote certificate');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
onVerified(certificateFingerprint(der, algorithm));
|
|
58
|
+
} catch (error) {
|
|
59
|
+
onFailed(error instanceof Error ? error.message : String(error));
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
if (dtlsTransport.state === 'connected') {
|
|
63
|
+
settle();
|
|
64
|
+
return () => undefined;
|
|
65
|
+
}
|
|
66
|
+
if (dtlsTransport.state === 'failed' || dtlsTransport.state === 'closed') {
|
|
67
|
+
onFailed(`the DTLS handshake ended ${dtlsTransport.state}`);
|
|
68
|
+
return () => undefined;
|
|
69
|
+
}
|
|
70
|
+
const subscription = dtlsTransport.onStateChange.subscribe((state) => {
|
|
71
|
+
if (state === 'connected') settle();
|
|
72
|
+
else if (state === 'failed' || state === 'closed') {
|
|
73
|
+
if (done) return;
|
|
74
|
+
done = true;
|
|
75
|
+
onFailed(`the DTLS handshake ended ${state}`);
|
|
76
|
+
} else return;
|
|
77
|
+
subscription.unSubscribe();
|
|
78
|
+
});
|
|
79
|
+
return () => subscription.unSubscribe();
|
|
80
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { IPairingChannel } from './pairing-gate-options.js';
|
|
2
|
+
|
|
3
|
+
function send(channel: IPairingChannel, data: string): void {
|
|
4
|
+
try {
|
|
5
|
+
channel.send(data);
|
|
6
|
+
} catch {
|
|
7
|
+
// The peer is gone; pairing frames are best-effort once the carrier is closing.
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function close(channel: IPairingChannel): void {
|
|
12
|
+
try {
|
|
13
|
+
channel.close();
|
|
14
|
+
} catch {
|
|
15
|
+
// The carrier is already closing or closed.
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function reportDeliveryError(
|
|
20
|
+
callback: ((error: Error, event: string) => void) | undefined,
|
|
21
|
+
error: Error,
|
|
22
|
+
event: string,
|
|
23
|
+
): void {
|
|
24
|
+
try {
|
|
25
|
+
callback?.(error, event);
|
|
26
|
+
} catch {
|
|
27
|
+
// A diagnostic callback cannot prevent carrier teardown or escape into the committed operation.
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const pairingChannel = Object.freeze({ send, close, reportDeliveryError });
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Constructing the admission controllers the gate drives.
|
|
3
|
+
*
|
|
4
|
+
* "Which controller runs, with what inputs" is a different subject from "what state is the gate in
|
|
5
|
+
* and what may reach the session" — the same boundary that already moved the frame vocabulary into
|
|
6
|
+
* `pairing-frames.ts` and the best-effort channel calls into `pairing-channel-lifecycle.ts`.
|
|
7
|
+
*
|
|
8
|
+
* Split out because `pairing-gate.ts` had reached the size limit, where the rule is to split rather
|
|
9
|
+
* than extend. These two functions were the largest thing in it that was not about gating, and they
|
|
10
|
+
* touch nothing of the gate's own state: they take inputs, wire a controller's `send` to the
|
|
11
|
+
* channel, and hand back the controller plus a settled/rejected outcome for the caller to act on.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { startHostReconnect, startPairingHandshake } from '@robota-sdk/agent-remote-pairing';
|
|
15
|
+
|
|
16
|
+
import { pairingChannel } from './pairing-channel-lifecycle.js';
|
|
17
|
+
|
|
18
|
+
import type { IHostReconnectConfig, IPairingChannel } from './pairing-gate-options.js';
|
|
19
|
+
import type {
|
|
20
|
+
IPairingResult,
|
|
21
|
+
IReconnectResult,
|
|
22
|
+
TPairingRole,
|
|
23
|
+
} from '@robota-sdk/agent-remote-pairing';
|
|
24
|
+
|
|
25
|
+
/** What both controllers need to talk on the channel and bind to it. */
|
|
26
|
+
export interface IControllerContext {
|
|
27
|
+
readonly channel: IPairingChannel;
|
|
28
|
+
readonly localFingerprint: string;
|
|
29
|
+
readonly remoteFingerprint: string;
|
|
30
|
+
/** Handshake timeout (ms); fail closed on expiry. */
|
|
31
|
+
readonly timeoutMs?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Start the B3 first-pair handshake.
|
|
36
|
+
*
|
|
37
|
+
* `onAccepted`/`onRejected` rather than handing the promise back, so a caller cannot forget to
|
|
38
|
+
* attach a rejection path — an unhandled rejection here would leave a gate waiting forever on a
|
|
39
|
+
* handshake that already failed, which is a fail-OPEN shaped as a hang.
|
|
40
|
+
*/
|
|
41
|
+
export function startFirstPairController(
|
|
42
|
+
context: IControllerContext,
|
|
43
|
+
secret: string,
|
|
44
|
+
role: TPairingRole,
|
|
45
|
+
onAccepted: (result: IPairingResult) => void,
|
|
46
|
+
onRejected: () => void,
|
|
47
|
+
start: typeof startPairingHandshake = startPairingHandshake,
|
|
48
|
+
): ReturnType<typeof startPairingHandshake> {
|
|
49
|
+
const controller = start({
|
|
50
|
+
secret,
|
|
51
|
+
role,
|
|
52
|
+
localFingerprint: context.localFingerprint,
|
|
53
|
+
remoteFingerprint: context.remoteFingerprint,
|
|
54
|
+
send: (frame) => pairingChannel.send(context.channel, JSON.stringify(frame)),
|
|
55
|
+
...(context.timeoutMs !== undefined ? { timeoutMs: context.timeoutMs } : {}),
|
|
56
|
+
});
|
|
57
|
+
controller.result.then(onAccepted, onRejected);
|
|
58
|
+
return controller;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Start the E3 host-side reconnect exchange against a pinned device key. */
|
|
62
|
+
export function startReconnectController(
|
|
63
|
+
context: IControllerContext,
|
|
64
|
+
config: IHostReconnectConfig,
|
|
65
|
+
onAccepted: (result: IReconnectResult) => void,
|
|
66
|
+
onRejected: () => void,
|
|
67
|
+
): ReturnType<typeof startHostReconnect> {
|
|
68
|
+
const controller = startHostReconnect({
|
|
69
|
+
hostIdentityId: config.hostIdentityId,
|
|
70
|
+
localFingerprint: context.localFingerprint,
|
|
71
|
+
remoteFingerprint: context.remoteFingerprint,
|
|
72
|
+
hostPrivateKey: config.hostPrivateKey,
|
|
73
|
+
resolveDevicePublicKey: config.resolveDevicePublicKey,
|
|
74
|
+
send: (frame) => pairingChannel.send(context.channel, JSON.stringify(frame)),
|
|
75
|
+
...(context.timeoutMs !== undefined ? { timeoutMs: context.timeoutMs } : {}),
|
|
76
|
+
});
|
|
77
|
+
controller.result.then(onAccepted, onRejected);
|
|
78
|
+
return controller;
|
|
79
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Predicates over the pairing/reconnect frame vocabulary the gate admits pre-accept.
|
|
3
|
+
*
|
|
4
|
+
* Issue #2046: these are thin adapters over the OWNER codec in `@robota-sdk/agent-remote-pairing`,
|
|
5
|
+
* which decodes every required field (presence, base64url, length ceiling) rather than only the `t`
|
|
6
|
+
* discriminator this file used to check. A true answer means the narrowed value is well-formed; the
|
|
7
|
+
* browser `ResponderGate` imports the same codec, so one corpus governs both carriers.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
decodeEnrollFrame,
|
|
12
|
+
decodePairingFrame,
|
|
13
|
+
decodeReconnectFrame,
|
|
14
|
+
type IEnrollFrame,
|
|
15
|
+
type TPairingFrame,
|
|
16
|
+
type TReconnectFrame,
|
|
17
|
+
} from '@robota-sdk/agent-remote-pairing';
|
|
18
|
+
|
|
19
|
+
export type { IEnrollFrame };
|
|
20
|
+
|
|
21
|
+
/** True when a parsed value is a well-formed B3 pairing frame (`pair-nonce` | `pair-confirm`). */
|
|
22
|
+
export function isPairingFrame(value: unknown): value is TPairingFrame {
|
|
23
|
+
return decodePairingFrame(value).ok;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** True when a parsed value is a well-formed reconnect frame (`rc-hello` | `rc-host` | `rc-device`). */
|
|
27
|
+
export function isReconnectFrame(value: unknown): value is TReconnectFrame {
|
|
28
|
+
return decodeReconnectFrame(value).ok;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** True when a parsed value is a well-formed first-pair enrollment frame carrying a device SPKI. */
|
|
32
|
+
export function isEnrollFrame(value: unknown): value is IEnrollFrame {
|
|
33
|
+
return decodeEnrollFrame(value).ok;
|
|
34
|
+
}
|