A music player that connects to your cloud/distributed storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at v4 320 lines 8.6 kB view raw
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}