@homie-rocks/studio 0.10.0 → 0.12.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,259 @@
1
+ /**
2
+ * PASSKEYS (WebAuthn), verified in the studio's own Worker with WebCrypto and nothing else.
3
+ *
4
+ * What arrives from a browser is a PUBLIC key and signatures: worthless to anyone who copies them. The private key
5
+ * never leaves the player's device (or its own sync: iCloud Keychain, Google Password Manager, a password manager).
6
+ *
7
+ * Checked on every ceremony (each check is a refusal somebody could otherwise walk through):
8
+ * - clientData.type is the ceremony that was asked for (a registration cannot be replayed as a sign-in);
9
+ * - clientData.challenge is one this site issued and has not spent (the caller passes it);
10
+ * - clientData.origin is this site (an https origin, or a loopback one for `homie-studio dev`);
11
+ * - the authenticator data's rpIdHash is SHA-256 of this site's host name;
12
+ * - the user-present flag is set (somebody touched or looked at the device);
13
+ * - on sign-in, the signature over authenticatorData || SHA-256(clientDataJSON) verifies against the stored key,
14
+ * and the signature counter did not go backwards (a counter that moves back is a cloned authenticator; one
15
+ * that is always 0 is normal, for synced passkeys).
16
+ * Not checked, on purpose: attestation. Registration asks for `none`: a game studio has no reason to care which
17
+ * brand of authenticator a player holds, and refusing some would lock real people out.
18
+ *
19
+ * Keys: ES256 (COSE -7, P-256) and RS256 (COSE -257), stored as a JWK. A tiny CBOR reader takes what an
20
+ * attestation object and a COSE key contain (integers, byte and text strings, arrays, maps) and refuses the rest.
21
+ */
22
+
23
+ const ENC = new TextEncoder();
24
+ const DEC = new TextDecoder();
25
+
26
+ export function b64urlToBytes(text) {
27
+ const s = String(text ?? '');
28
+ if (!/^[A-Za-z0-9_-]*$/.test(s)) throw new Error('base64url');
29
+ const b64 = s.replace(/-/g, '+').replace(/_/g, '/');
30
+ const bin = atob(b64 + '='.repeat((4 - (b64.length % 4)) % 4));
31
+ const out = new Uint8Array(bin.length);
32
+ for (let i = 0; i < bin.length; i += 1) out[i] = bin.charCodeAt(i);
33
+ return out;
34
+ }
35
+
36
+ export function bytesToB64url(bytes) {
37
+ let bin = '';
38
+ for (const b of bytes) bin += String.fromCharCode(b);
39
+ return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
40
+ }
41
+
42
+ export function randomToken(bytes = 32) {
43
+ const raw = new Uint8Array(bytes);
44
+ crypto.getRandomValues(raw);
45
+ return bytesToB64url(raw);
46
+ }
47
+
48
+ export async function sha256(input) {
49
+ const bytes = typeof input === 'string' ? ENC.encode(input) : input;
50
+ return new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
51
+ }
52
+
53
+ export async function sha256Hex(text) {
54
+ return [...await sha256(text)].map((b) => b.toString(16).padStart(2, '0')).join('');
55
+ }
56
+
57
+ /** Equal bytes, in time that does not depend on where they differ. */
58
+ export function sameBytes(a, b) {
59
+ if (a.length !== b.length) return false;
60
+ let diff = 0;
61
+ for (let i = 0; i < a.length; i += 1) diff |= a[i] ^ b[i];
62
+ return diff === 0;
63
+ }
64
+
65
+ /* ------------------------------------------------------------------ CBOR (just enough) */
66
+
67
+ export function cborDecode(bytes, at = 0, depth = 0) {
68
+ if (depth > 8) throw new Error('cbor-deep');
69
+ if (at >= bytes.length) throw new Error('cbor-short');
70
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
71
+ const head = bytes[at];
72
+ const major = head >> 5;
73
+ const minor = head & 31;
74
+ let n = minor;
75
+ let p = at + 1;
76
+ if (minor === 24) { n = bytes[p]; p += 1; }
77
+ else if (minor === 25) { n = view.getUint16(p); p += 2; }
78
+ else if (minor === 26) { n = view.getUint32(p); p += 4; }
79
+ else if (minor >= 27) throw new Error('cbor-unsupported-length');
80
+ if (p > bytes.length) throw new Error('cbor-short');
81
+ switch (major) {
82
+ case 0: return [n, p];
83
+ case 1: return [-1 - n, p];
84
+ case 2: if (p + n > bytes.length) throw new Error('cbor-short'); return [bytes.subarray(p, p + n), p + n];
85
+ case 3: if (p + n > bytes.length) throw new Error('cbor-short'); return [DEC.decode(bytes.subarray(p, p + n)), p + n];
86
+ case 4: {
87
+ if (n > 64) throw new Error('cbor-big');
88
+ const list = [];
89
+ for (let i = 0; i < n; i += 1) { const [v, q] = cborDecode(bytes, p, depth + 1); list.push(v); p = q; }
90
+ return [list, p];
91
+ }
92
+ case 5: {
93
+ if (n > 64) throw new Error('cbor-big');
94
+ const map = new Map();
95
+ for (let i = 0; i < n; i += 1) {
96
+ const [k, q] = cborDecode(bytes, p, depth + 1);
97
+ const [v, r] = cborDecode(bytes, q, depth + 1);
98
+ map.set(k, v);
99
+ p = r;
100
+ }
101
+ return [map, p];
102
+ }
103
+ default: throw new Error('cbor-unsupported');
104
+ }
105
+ }
106
+
107
+ /* ------------------------------------------------------------------ authenticator data */
108
+
109
+ /** rpIdHash (32) | flags (1) | signCount (4) | [aaguid (16) | idLength (2) | credentialId | COSE key]. */
110
+ export function parseAuthData(bytes) {
111
+ if (!(bytes instanceof Uint8Array) || bytes.length < 37) throw new Error('authdata-short');
112
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
113
+ const flags = bytes[32];
114
+ const out = {
115
+ rpIdHash: bytes.subarray(0, 32),
116
+ up: Boolean(flags & 0x01),
117
+ uv: Boolean(flags & 0x04),
118
+ backupEligible: Boolean(flags & 0x08),
119
+ backedUp: Boolean(flags & 0x10),
120
+ at: Boolean(flags & 0x40),
121
+ signCount: view.getUint32(33),
122
+ };
123
+ if (!out.at) return out;
124
+ if (bytes.length < 55) throw new Error('authdata-short');
125
+ const idLen = view.getUint16(53);
126
+ if (55 + idLen > bytes.length) throw new Error('authdata-short');
127
+ out.credentialId = bytes.subarray(55, 55 + idLen);
128
+ const [cose] = cborDecode(bytes, 55 + idLen);
129
+ out.cose = cose;
130
+ return out;
131
+ }
132
+
133
+ /** A COSE public key as { alg, jwk }: ES256 (P-256) or RS256 only. */
134
+ export function coseToJwk(cose) {
135
+ if (!(cose instanceof Map)) throw new Error('cose');
136
+ const kty = cose.get(1);
137
+ const alg = cose.get(3);
138
+ if (kty === 2 && alg === -7) {
139
+ if (cose.get(-1) !== 1) throw new Error('cose-curve');
140
+ const x = cose.get(-2); const y = cose.get(-3);
141
+ if (!(x instanceof Uint8Array) || !(y instanceof Uint8Array) || x.length !== 32 || y.length !== 32) throw new Error('cose-ec');
142
+ return { alg, jwk: { kty: 'EC', crv: 'P-256', x: bytesToB64url(x), y: bytesToB64url(y) } };
143
+ }
144
+ if (kty === 3 && alg === -257) {
145
+ const nn = cose.get(-1); const e = cose.get(-2);
146
+ if (!(nn instanceof Uint8Array) || !(e instanceof Uint8Array) || nn.length < 256) throw new Error('cose-rsa');
147
+ return { alg, jwk: { kty: 'RSA', n: bytesToB64url(nn), e: bytesToB64url(e) } };
148
+ }
149
+ throw new Error('cose-alg');
150
+ }
151
+
152
+ function algorithms(alg) {
153
+ if (alg === -7) return { importAs: { name: 'ECDSA', namedCurve: 'P-256' }, verifyAs: { name: 'ECDSA', hash: 'SHA-256' } };
154
+ if (alg === -257) return { importAs: { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, verifyAs: { name: 'RSASSA-PKCS1-v1_5' } };
155
+ throw new Error('alg');
156
+ }
157
+
158
+ /** An ECDSA signature arrives DER-encoded; WebCrypto wants r || s, 32 bytes each. */
159
+ export function derToRaw(der) {
160
+ if (der[0] !== 0x30) throw new Error('der');
161
+ let p = 2;
162
+ if (der[1] & 0x80) p = 2 + (der[1] & 0x7f);
163
+ const int = () => {
164
+ if (der[p] !== 0x02) throw new Error('der-int');
165
+ const len = der[p + 1];
166
+ let v = der.subarray(p + 2, p + 2 + len);
167
+ p += 2 + len;
168
+ while (v.length > 32 && v[0] === 0) v = v.subarray(1);
169
+ if (v.length > 32) throw new Error('der-int');
170
+ const out = new Uint8Array(32);
171
+ out.set(v, 32 - v.length);
172
+ return out;
173
+ };
174
+ const raw = new Uint8Array(64);
175
+ raw.set(int(), 0);
176
+ raw.set(int(), 32);
177
+ return raw;
178
+ }
179
+
180
+ /* ------------------------------------------------------------------ ceremonies */
181
+
182
+ /** Is `origin` this site: the same origin as the request, https (or http on a loopback address for dev). */
183
+ export function originOk(origin, site) {
184
+ try {
185
+ const o = new URL(origin);
186
+ const s = new URL(site);
187
+ if (o.origin !== s.origin) return false;
188
+ return o.protocol === 'https:' || (o.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(o.hostname));
189
+ } catch { return false; }
190
+ }
191
+
192
+ /** clientDataJSON: its type, challenge and origin. Returns { ok, client, bytes } or { ok: false, error }. */
193
+ function readClient(clientDataJSON, { type, challenge, site }) {
194
+ let bytes; let client;
195
+ try { bytes = b64urlToBytes(clientDataJSON); client = JSON.parse(DEC.decode(bytes)); } catch { return { ok: false, error: 'client-data' }; }
196
+ if (!client || client.type !== type) return { ok: false, error: 'ceremony' };
197
+ if (typeof client.challenge !== 'string' || !sameBytes(ENC.encode(client.challenge), ENC.encode(String(challenge ?? '')))) return { ok: false, error: 'challenge' };
198
+ if (!originOk(client.origin, site)) return { ok: false, error: 'origin' };
199
+ if (client.crossOrigin === true) return { ok: false, error: 'cross-origin' };
200
+ return { ok: true, client, bytes };
201
+ }
202
+
203
+ /**
204
+ * A registration (navigator.credentials.create): verifies it and returns the credential to keep:
205
+ * { ok, id, alg, jwk, signCount, backedUp } or { ok: false, error }.
206
+ */
207
+ export async function verifyRegistration(response, { challenge, site, rpId }) {
208
+ if (!response || typeof response !== 'object') return { ok: false, error: 'response' };
209
+ const c = readClient(response.clientDataJSON, { type: 'webauthn.create', challenge, site });
210
+ if (!c.ok) return c;
211
+ let auth;
212
+ try {
213
+ const [att] = cborDecode(b64urlToBytes(response.attestationObject));
214
+ if (!(att instanceof Map) || !(att.get('authData') instanceof Uint8Array)) return { ok: false, error: 'attestation' };
215
+ auth = parseAuthData(att.get('authData'));
216
+ } catch (e) { return { ok: false, error: `attestation:${String(e.message).slice(0, 40)}` }; }
217
+ if (!sameBytes(auth.rpIdHash, await sha256(rpId))) return { ok: false, error: 'rp' };
218
+ if (!auth.up) return { ok: false, error: 'presence' };
219
+ if (!auth.at || !auth.credentialId?.length || auth.credentialId.length > 1023) return { ok: false, error: 'credential' };
220
+ let key;
221
+ try { key = coseToJwk(auth.cose); } catch (e) { return { ok: false, error: String(e.message) }; }
222
+ const id = bytesToB64url(auth.credentialId);
223
+ if (response.id && response.id !== id) return { ok: false, error: 'credential-id' };
224
+ // The key must import: a stored key that cannot verify would lock its player out later.
225
+ try { await crypto.subtle.importKey('jwk', key.jwk, algorithms(key.alg).importAs, false, ['verify']); } catch { return { ok: false, error: 'key' }; }
226
+ return { ok: true, id, alg: key.alg, jwk: key.jwk, signCount: auth.signCount, backedUp: auth.backedUp };
227
+ }
228
+
229
+ /**
230
+ * A sign-in (navigator.credentials.get) against a stored credential { alg, jwk, signCount }:
231
+ * { ok, signCount, uv } or { ok: false, error }.
232
+ */
233
+ export async function verifyAssertion(response, stored, { challenge, site, rpId }) {
234
+ if (!response || typeof response !== 'object' || !stored) return { ok: false, error: 'response' };
235
+ const c = readClient(response.clientDataJSON, { type: 'webauthn.get', challenge, site });
236
+ if (!c.ok) return c;
237
+ let authBytes; let auth; let sig;
238
+ try {
239
+ authBytes = b64urlToBytes(response.authenticatorData);
240
+ auth = parseAuthData(authBytes);
241
+ sig = b64urlToBytes(response.signature);
242
+ } catch { return { ok: false, error: 'assertion' }; }
243
+ if (!sameBytes(auth.rpIdHash, await sha256(rpId))) return { ok: false, error: 'rp' };
244
+ if (!auth.up) return { ok: false, error: 'presence' };
245
+ let ok = false;
246
+ try {
247
+ const { importAs, verifyAs } = algorithms(Number(stored.alg));
248
+ const key = await crypto.subtle.importKey('jwk', typeof stored.jwk === 'string' ? JSON.parse(stored.jwk) : stored.jwk, importAs, false, ['verify']);
249
+ const clientHash = await sha256(c.bytes);
250
+ const signed = new Uint8Array(authBytes.length + clientHash.length);
251
+ signed.set(authBytes, 0);
252
+ signed.set(clientHash, authBytes.length);
253
+ ok = await crypto.subtle.verify(verifyAs, key, Number(stored.alg) === -7 ? derToRaw(sig) : sig, signed);
254
+ } catch { ok = false; }
255
+ if (!ok) return { ok: false, error: 'signature' };
256
+ const before = Number(stored.signCount) || 0;
257
+ if ((auth.signCount !== 0 || before !== 0) && auth.signCount <= before) return { ok: false, error: 'counter' };
258
+ return { ok: true, signCount: auth.signCount, uv: auth.uv };
259
+ }