forked from
tokono.ma/diffuse
A music player that connects to your cloud/distributed storage.
1import { xchacha20poly1305 } from "@noble/ciphers/chacha.js";
2import { managedNonce } from "@noble/ciphers/utils.js";
3
4import * as IDB from "idb-keyval";
5import { base64url } from "iso-base/rfc4648";
6import { utf8 } from "iso-base/utf8";
7
8////////////////////////////////////////////
9// CONSTANTS
10////////////////////////////////////////////
11
12const IDB_PREFIX = "diffuse/transformer/output/refiner/passkey-encryption";
13
14/**
15 * @param {string} namespace
16 * @returns {{ credential: string, cipher: string }}
17 */
18function idbKeys(namespace) {
19 const prefix = namespace?.length ? `${IDB_PREFIX}/${namespace}` : IDB_PREFIX;
20 return {
21 credential: `${prefix}/passkey`,
22 cipher: `${prefix}/passkey/cipher-key`,
23 };
24}
25
26////////////////////////////////////////////
27// RELYING PARTY
28////////////////////////////////////////////
29
30/**
31 * @returns {{ name: string, id: string }}
32 */
33export function relyingParty() {
34 const id = document.location.hostname;
35 return { name: id, id };
36}
37
38////////////////////////////////////////////
39// PASSKEY MANAGEMENT
40////////////////////////////////////////////
41
42/**
43 * Register a new passkey with the PRF extension.
44 *
45 * @param {string} namespace
46 * @returns {Promise<{ supported: true, credentialId: Uint8Array, prfSecond: ArrayBuffer } | { supported: false, reason: string }>}
47 */
48export async function createPasskey(namespace) {
49 const rp = relyingParty();
50 const challenge = crypto.getRandomValues(new Uint8Array(32));
51 const userId = crypto.getRandomValues(new Uint8Array(16));
52
53 /** @type {PublicKeyCredential | null} */
54 let credential;
55
56 try {
57 credential = /** @type {PublicKeyCredential} */ (
58 await navigator.credentials.create({
59 publicKey: {
60 challenge,
61 rp,
62 user: {
63 id: userId,
64 name: rp.id,
65 displayName: "Diffuse – " + rp.id,
66 },
67 pubKeyCredParams: [
68 { type: "public-key", alg: -7 },
69 { type: "public-key", alg: -257 },
70 ],
71 attestation: "none",
72 authenticatorSelection: {
73 userVerification: "required",
74 requireResidentKey: true,
75 residentKey: "required",
76 },
77 extensions: {
78 // @ts-ignore — PRF is not yet in the TS DOM types
79 prf: {
80 eval: {
81 // @ts-ignore
82 first: utf8.decode(rp.id + "signing"),
83 // @ts-ignore
84 second: utf8.decode(rp.id + "encryption"),
85 },
86 },
87 },
88 },
89 })
90 );
91 } catch (err) {
92 return {
93 supported: false,
94 reason: err instanceof Error ? err.message : String(err),
95 };
96 }
97
98 if (!credential) {
99 return { supported: false, reason: "Credential creation returned null" };
100 }
101
102 const extensions = credential.getClientExtensionResults();
103
104 // @ts-ignore — PRF is not yet in the TS DOM types
105 if (extensions.prf?.enabled !== true) {
106 return {
107 supported: false,
108 reason: "This authenticator does not support the WebAuthn PRF extension",
109 };
110 }
111
112 // @ts-ignore — PRF is not yet in the TS DOM types
113 const prfSecond = extensions.prf?.results?.second;
114
115 if (!prfSecond) {
116 return {
117 supported: false,
118 reason: "Authenticator did not return PRF results at registration time",
119 };
120 }
121
122 const credentialId = new Uint8Array(credential.rawId);
123 await IDB.set(idbKeys(namespace).credential, {
124 credentialId: Array.from(credentialId),
125 });
126
127 return {
128 supported: true,
129 credentialId,
130 prfSecond: /** @type {ArrayBuffer} */ (prfSecond),
131 };
132}
133
134/**
135 * Authenticate with an existing passkey via discoverable-credential lookup
136 * (no `allowCredentials`), so it works on a new device that has no stored
137 * credential ID yet. Saves the credential ID to IDB and returns PRF material.
138 *
139 * @param {string} namespace
140 * @returns {Promise<{ supported: true, credentialId: Uint8Array, prfSecond: ArrayBuffer } | { supported: false, reason: string }>}
141 */
142export async function adoptPasskeyPrfResult(namespace) {
143 const rp = relyingParty();
144 const challenge = crypto.getRandomValues(new Uint8Array(32));
145
146 /** @type {PublicKeyCredential | null} */
147 let assertion;
148
149 try {
150 assertion = /** @type {PublicKeyCredential} */ (
151 await navigator.credentials.get({
152 publicKey: {
153 challenge,
154 rpId: rp.id,
155 userVerification: "required",
156 extensions: {
157 // @ts-ignore — PRF is not yet in the TS DOM types
158 prf: {
159 eval: {
160 // @ts-ignore
161 first: utf8.decode(rp.id + "signing"),
162 // @ts-ignore
163 second: utf8.decode(rp.id + "encryption"),
164 },
165 },
166 },
167 },
168 })
169 );
170 } catch (err) {
171 return {
172 supported: false,
173 reason: err instanceof Error ? err.message : String(err),
174 };
175 }
176
177 if (!assertion) {
178 return { supported: false, reason: "Credential get returned null" };
179 }
180
181 const extensions = assertion.getClientExtensionResults();
182
183 // @ts-ignore — PRF is not yet in the TS DOM types
184 const prfSecond = extensions.prf?.results?.second;
185
186 if (!prfSecond) {
187 return {
188 supported: false,
189 reason:
190 "This authenticator did not return PRF results — PRF extension may not be supported",
191 };
192 }
193
194 const credentialId = new Uint8Array(assertion.rawId);
195 await IDB.set(idbKeys(namespace).credential, {
196 credentialId: Array.from(credentialId),
197 });
198
199 return {
200 supported: true,
201 credentialId,
202 prfSecond: /** @type {ArrayBuffer} */ (prfSecond),
203 };
204}
205
206/**
207 * Remove the stored passkey credential ID and cached cipher key from IDB.
208 *
209 * @param {string} namespace
210 * @returns {Promise<void>}
211 */
212export async function removeStoredPasskey(namespace) {
213 const keys = idbKeys(namespace);
214 await Promise.all([IDB.del(keys.credential), IDB.del(keys.cipher)]);
215}
216
217/**
218 * Persist the derived cipher key to IDB so it survives page reloads.
219 *
220 * @param {string} namespace
221 * @param {Uint8Array} key
222 * @returns {Promise<void>}
223 */
224export async function storeCipherKey(namespace, key) {
225 await IDB.set(idbKeys(namespace).cipher, key);
226}
227
228/**
229 * Retrieve the previously persisted cipher key from IDB.
230 *
231 * @param {string} namespace
232 * @returns {Promise<Uint8Array | undefined>}
233 */
234export async function loadStoredCipherKey(namespace) {
235 return IDB.get(idbKeys(namespace).cipher);
236}
237
238////////////////////////////////////////////
239// KEY DERIVATION
240////////////////////////////////////////////
241
242/**
243 * Derive a 256-bit key from the PRF "second" output via HKDF.
244 * Returns raw bytes suitable for use with XChaCha20-Poly1305.
245 *
246 * @param {ArrayBuffer} prfSecond
247 * @returns {Promise<Uint8Array>}
248 */
249export async function deriveCipherKey(prfSecond) {
250 const keyMaterial = await crypto.subtle.importKey(
251 "raw",
252 prfSecond,
253 { name: "HKDF" },
254 false,
255 ["deriveBits"],
256 );
257
258 const bits = await crypto.subtle.deriveBits(
259 {
260 name: "HKDF",
261 hash: "SHA-256",
262
263 salt:
264 /** @type {ArrayBuffer} */ (/** @type {unknown} */ (utf8.decode(
265 "diffuse-atproto-passkey-salt",
266 ))),
267
268 info:
269 /** @type {ArrayBuffer} */ (/** @type {unknown} */ (utf8.decode(
270 "diffuse-atproto-track-uri",
271 ))),
272 },
273 keyMaterial,
274 256,
275 );
276
277 return new Uint8Array(bits);
278}
279
280////////////////////////////////////////////
281// ENCRYPT / DECRYPT
282////////////////////////////////////////////
283
284/**
285 * Detect whether a URI is encrypted by this module.
286 *
287 * @param {string} uri
288 * @returns {boolean}
289 */
290export function isEncryptedUri(uri) {
291 return uri.startsWith("encrypted://");
292}
293
294const xchacha = managedNonce(xchacha20poly1305);
295
296/**
297 * Encrypt a plaintext URI with XChaCha20-Poly1305.
298 * Returns a string of the form: `encrypted://<base64url(nonce || ciphertext)>`
299 * The nonce is prepended automatically by `managedNonce`.
300 *
301 * @param {Uint8Array} key
302 * @param {string} plaintext
303 * @returns {string}
304 */
305export function encryptUri(key, plaintext) {
306 const ciphertext = xchacha(key).encrypt(utf8.decode(plaintext));
307 return "encrypted://" + base64url.encode(ciphertext);
308}
309
310/**
311 * Decrypt an encrypted URI produced by `encryptUri`.
312 *
313 * @param {Uint8Array} key
314 * @param {string} encryptedUri
315 * @returns {string}
316 */
317export function decryptUri(key, encryptedUri) {
318 const ciphertext = base64url.decode(encryptedUri.slice(12));
319 return utf8.encode(xchacha(key).decrypt(ciphertext));
320}