@dotrino/identity 0.10.0 → 0.11.0

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.
@@ -0,0 +1,92 @@
1
+ /**
2
+ * ECDSA P-256 keypair management using SubtleCrypto, persisted in localStorage as JWK.
3
+ * Public key in JWK form is what the proxy expects in `channel.data.publickey`.
4
+ */
5
+ import { canonicalStringify } from './canonical.js'
6
+
7
+ const STORAGE_KEY = 'dotrino.proxy-client.keypair'
8
+
9
+ let cachedKeypair = null
10
+
11
+ async function loadOrCreate () {
12
+ if (cachedKeypair) return cachedKeypair
13
+
14
+ if (typeof localStorage !== 'undefined') {
15
+ const raw = localStorage.getItem(STORAGE_KEY)
16
+ if (raw) {
17
+ try {
18
+ const { privateJwk, publicJwk } = JSON.parse(raw)
19
+ const privateKey = await crypto.subtle.importKey(
20
+ 'jwk', privateJwk,
21
+ { name: 'ECDSA', namedCurve: 'P-256' },
22
+ true, ['sign']
23
+ )
24
+ const publicKey = await crypto.subtle.importKey(
25
+ 'jwk', publicJwk,
26
+ { name: 'ECDSA', namedCurve: 'P-256' },
27
+ true, ['verify']
28
+ )
29
+ cachedKeypair = { privateKey, publicKey, publicJwk }
30
+ return cachedKeypair
31
+ } catch (e) {
32
+ // corrupt entry, regenerate
33
+ }
34
+ }
35
+ }
36
+
37
+ const pair = await crypto.subtle.generateKey(
38
+ { name: 'ECDSA', namedCurve: 'P-256' },
39
+ true, ['sign', 'verify']
40
+ )
41
+ const privateJwk = await crypto.subtle.exportKey('jwk', pair.privateKey)
42
+ const publicJwk = await crypto.subtle.exportKey('jwk', pair.publicKey)
43
+ if (typeof localStorage !== 'undefined') {
44
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ privateJwk, publicJwk }))
45
+ }
46
+ cachedKeypair = { privateKey: pair.privateKey, publicKey: pair.publicKey, publicJwk }
47
+ return cachedKeypair
48
+ }
49
+
50
+ /**
51
+ * Returns the public key as a JWK string (what the proxy stores in data.publickey).
52
+ */
53
+ export async function getPublicKeyJwk () {
54
+ const { publicJwk } = await loadOrCreate()
55
+ return JSON.stringify(publicJwk)
56
+ }
57
+
58
+ /**
59
+ * Sign the canonical JSON of `data` and return base64 signature.
60
+ */
61
+ export async function signData (data) {
62
+ const { privateKey } = await loadOrCreate()
63
+ const encoder = new TextEncoder()
64
+ const bytes = encoder.encode(canonicalStringify(data))
65
+ const signature = await crypto.subtle.sign(
66
+ { name: 'ECDSA', hash: { name: 'SHA-256' } },
67
+ privateKey,
68
+ bytes
69
+ )
70
+ return bufferToBase64(new Uint8Array(signature))
71
+ }
72
+
73
+ function bufferToBase64 (bytes) {
74
+ let binary = ''
75
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i])
76
+ return btoa(binary)
77
+ }
78
+
79
+ /**
80
+ * Build the {data, signature} envelope for a channel name.
81
+ */
82
+ export async function buildSignedChannel (channelName, extraData = {}) {
83
+ const publickey = await getPublicKeyJwk()
84
+ // `name` (clave del canal) y `publickey` son AUTORITATIVOS: van DESPUÉS del
85
+ // spread para que extraData no pueda pisarlos. extraData es solo metadata
86
+ // (p.ej. nickname, roomName, gameType); si trae `name` no debe cambiar el
87
+ // canal bajo el que se publica/lista (era un bug que rompía el descubrimiento
88
+ // del lobby, que publica con { name: <roomName> } como extra).
89
+ const data = { ...extraData, name: channelName, publickey }
90
+ const signature = await signData(data)
91
+ return { data, signature }
92
+ }
@@ -0,0 +1,246 @@
1
+ /**
2
+ * WebRTC layer for the Dotrino proxy client.
3
+ *
4
+ * - One RTCPeerConnection + RTCDataChannel per remote token (lazy).
5
+ * - Signaling (offer / answer / ICE) goes through the proxy as regular
6
+ * `send()` payloads tagged with `_rtc`.
7
+ * - Once the DataChannel is open, payloads from `client.send(token, ...)`
8
+ * travel P2P; before that (or on failure) they fall back to the proxy.
9
+ * - STUN-only (no TURN). Symmetric NATs will simply stay on the proxy.
10
+ *
11
+ * Glare resolution: the peer with the lexicographically smaller token is
12
+ * the "polite" one (rolls back on collision).
13
+ */
14
+
15
+ const DEFAULT_ICE_SERVERS = [
16
+ { urls: 'stun:stun.l.google.com:19302' },
17
+ { urls: 'stun:stun1.l.google.com:19302' },
18
+ { urls: 'stun:global.stun.twilio.com:3478' }
19
+ ]
20
+
21
+ const RTC_TAG = '__cc_rtc__'
22
+
23
+ export class WebRTCManager {
24
+ /**
25
+ * @param {object} opts
26
+ * @param {() => string|null} opts.getSelfToken
27
+ * @param {(to: string, payload: any) => void} opts.signalSend raw proxy send
28
+ * @param {(from: string, parsed: any, meta: any) => void} opts.deliverMessage forwards
29
+ * an incoming P2P payload as if it had arrived via the proxy
30
+ * @param {(event: string, ...args: any[]) => void} opts.emit
31
+ * @param {{iceServers?: any[]}} [opts.config]
32
+ */
33
+ constructor (opts) {
34
+ this.getSelfToken = opts.getSelfToken
35
+ this.signalSend = opts.signalSend
36
+ this.deliverMessage = opts.deliverMessage
37
+ this.emit = opts.emit
38
+ this.iceServers = (opts.config && opts.config.iceServers) || DEFAULT_ICE_SERVERS
39
+ this.peers = new Map() // remoteToken -> PeerState
40
+ }
41
+
42
+ /**
43
+ * True if this is a control envelope and was consumed.
44
+ * Otherwise the caller should keep delivering it normally.
45
+ */
46
+ handleIncoming (from, parsed) {
47
+ if (!parsed || typeof parsed !== 'object' || parsed.t !== RTC_TAG) return false
48
+ const peer = this._ensurePeer(from)
49
+ this._handleSignal(peer, parsed).catch((e) => {
50
+ this.emit('error', { type: 'webrtc_signal', error: e, peer: from })
51
+ })
52
+ return true
53
+ }
54
+
55
+ /**
56
+ * Try to send the given JSON payload over a DataChannel.
57
+ * Returns true if it was sent P2P; false if the caller should fall back
58
+ * to the proxy. Also opportunistically starts the connection negotiation.
59
+ */
60
+ trySend (to, payloadString) {
61
+ const peer = this._ensurePeer(to)
62
+ if (peer.dc && peer.dc.readyState === 'open') {
63
+ try {
64
+ peer.dc.send(payloadString)
65
+ return true
66
+ } catch (_) {
67
+ return false
68
+ }
69
+ }
70
+ if (!peer.negotiating && !peer.failed) this._startNegotiation(peer).catch(() => {})
71
+ return false
72
+ }
73
+
74
+ /** Optional: explicitly preconnect to a peer. */
75
+ connect (to) {
76
+ const peer = this._ensurePeer(to)
77
+ if (peer.dc && peer.dc.readyState === 'open') return Promise.resolve()
78
+ if (!peer.negotiating) this._startNegotiation(peer).catch(() => {})
79
+ return new Promise((resolve, reject) => {
80
+ peer.openWaiters.push({ resolve, reject })
81
+ })
82
+ }
83
+
84
+ closePeer (to) {
85
+ const peer = this.peers.get(to)
86
+ if (!peer) return
87
+ try { if (peer.dc) peer.dc.close() } catch (_) {}
88
+ try { if (peer.pc) peer.pc.close() } catch (_) {}
89
+ this.peers.delete(to)
90
+ }
91
+
92
+ closeAll () {
93
+ for (const t of Array.from(this.peers.keys())) this.closePeer(t)
94
+ }
95
+
96
+ isOpen (to) {
97
+ const p = this.peers.get(to)
98
+ return !!(p && p.dc && p.dc.readyState === 'open')
99
+ }
100
+
101
+ // ---------- internals ----------
102
+
103
+ _ensurePeer (remoteToken) {
104
+ let peer = this.peers.get(remoteToken)
105
+ if (peer) return peer
106
+ peer = {
107
+ remote: remoteToken,
108
+ pc: null,
109
+ dc: null,
110
+ makingOffer: false,
111
+ ignoreOffer: false,
112
+ negotiating: false,
113
+ failed: false,
114
+ polite: this._isPolite(remoteToken),
115
+ pendingCandidates: [],
116
+ openWaiters: []
117
+ }
118
+ this.peers.set(remoteToken, peer)
119
+ return peer
120
+ }
121
+
122
+ _isPolite (remoteToken) {
123
+ const self = this.getSelfToken()
124
+ if (!self) return false
125
+ return self < remoteToken
126
+ }
127
+
128
+ _createPC (peer) {
129
+ const pc = new RTCPeerConnection({ iceServers: this.iceServers })
130
+ peer.pc = pc
131
+ peer.polite = this._isPolite(peer.remote)
132
+
133
+ pc.onicecandidate = (ev) => {
134
+ if (ev.candidate) {
135
+ this._signal(peer, { kind: 'ice', candidate: ev.candidate })
136
+ }
137
+ }
138
+ pc.onconnectionstatechange = () => {
139
+ if (pc.connectionState === 'failed' || pc.connectionState === 'closed') {
140
+ this._failPeer(peer)
141
+ }
142
+ }
143
+ pc.ondatachannel = (ev) => this._attachDC(peer, ev.channel)
144
+ pc.onnegotiationneeded = async () => {
145
+ try {
146
+ peer.makingOffer = true
147
+ await pc.setLocalDescription()
148
+ this._signal(peer, { kind: 'sdp', sdp: pc.localDescription })
149
+ } catch (e) {
150
+ this.emit('error', { type: 'webrtc_negotiate', error: e, peer: peer.remote })
151
+ } finally {
152
+ peer.makingOffer = false
153
+ }
154
+ }
155
+ return pc
156
+ }
157
+
158
+ async _startNegotiation (peer) {
159
+ if (peer.negotiating) return
160
+ peer.negotiating = true
161
+ try {
162
+ if (!peer.pc) this._createPC(peer)
163
+ // Caller side creates the data channel; the other end gets it via
164
+ // `ondatachannel`. The token comparison decides who initiates.
165
+ const self = this.getSelfToken()
166
+ if (self && self > peer.remote && !peer.dc) {
167
+ const dc = peer.pc.createDataChannel('cc', { ordered: true })
168
+ this._attachDC(peer, dc)
169
+ }
170
+ } catch (e) {
171
+ this.emit('error', { type: 'webrtc_start', error: e, peer: peer.remote })
172
+ this._failPeer(peer)
173
+ }
174
+ }
175
+
176
+ _attachDC (peer, dc) {
177
+ peer.dc = dc
178
+ dc.onopen = () => {
179
+ this.emit('webrtc_open', peer.remote)
180
+ const waiters = peer.openWaiters
181
+ peer.openWaiters = []
182
+ for (const w of waiters) w.resolve()
183
+ }
184
+ dc.onclose = () => {
185
+ this.emit('webrtc_close', peer.remote)
186
+ }
187
+ dc.onerror = (err) => {
188
+ this.emit('error', { type: 'webrtc_dc', error: err, peer: peer.remote })
189
+ }
190
+ dc.onmessage = (ev) => {
191
+ let parsed = null
192
+ const raw = ev.data
193
+ if (typeof raw === 'string') {
194
+ try { parsed = JSON.parse(raw) } catch (_) { parsed = null }
195
+ }
196
+ this.deliverMessage(peer.remote, parsed ?? raw, { raw, timestamp: Date.now(), via: 'webrtc' })
197
+ }
198
+ }
199
+
200
+ _failPeer (peer) {
201
+ peer.failed = true
202
+ peer.negotiating = false
203
+ const waiters = peer.openWaiters
204
+ peer.openWaiters = []
205
+ const err = new Error('WebRTC failed')
206
+ for (const w of waiters) w.reject(err)
207
+ }
208
+
209
+ _signal (peer, body) {
210
+ this.signalSend(peer.remote, { t: RTC_TAG, ...body })
211
+ }
212
+
213
+ async _handleSignal (peer, msg) {
214
+ if (!peer.pc) this._createPC(peer)
215
+ const pc = peer.pc
216
+
217
+ if (msg.kind === 'sdp' && msg.sdp) {
218
+ const offerCollision = msg.sdp.type === 'offer' &&
219
+ (peer.makingOffer || pc.signalingState !== 'stable')
220
+ peer.ignoreOffer = !peer.polite && offerCollision
221
+ if (peer.ignoreOffer) return
222
+ await pc.setRemoteDescription(msg.sdp)
223
+ // flush any queued candidates
224
+ for (const c of peer.pendingCandidates) {
225
+ try { await pc.addIceCandidate(c) } catch (_) {}
226
+ }
227
+ peer.pendingCandidates = []
228
+ if (msg.sdp.type === 'offer') {
229
+ await pc.setLocalDescription()
230
+ this._signal(peer, { kind: 'sdp', sdp: pc.localDescription })
231
+ }
232
+ } else if (msg.kind === 'ice' && msg.candidate) {
233
+ try {
234
+ if (!pc.remoteDescription || !pc.remoteDescription.type) {
235
+ peer.pendingCandidates.push(msg.candidate)
236
+ } else {
237
+ await pc.addIceCandidate(msg.candidate)
238
+ }
239
+ } catch (e) {
240
+ if (!peer.ignoreOffer) throw e
241
+ }
242
+ }
243
+ }
244
+ }
245
+
246
+ export { RTC_TAG }