@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.
- package/README.md +4 -2
- package/package.json +4 -1
- package/src/index.js +56 -3
- package/src/node.js +10 -1
- package/vault/capabilities.js +24 -0
- package/vault/core.js +60 -1
- package/vault/index.html +5 -0
- package/vault/remote.js +128 -0
- package/vault/vault.js +5 -7
- package/vault/vendor/proxy-client/VERSION.txt +4 -0
- package/vault/vendor/proxy-client/canonical.js +15 -0
- package/vault/vendor/proxy-client/client.js +673 -0
- package/vault/vendor/proxy-client/index.js +16 -0
- package/vault/vendor/proxy-client/signature.js +92 -0
- package/vault/vendor/proxy-client/webrtc.js +246 -0
|
@@ -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 }
|