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 351 lines 9.7 kB view raw
1import { computed, signal } from "~/common/signal.js"; 2import { OutputTransformer } from "../../base.js"; 3import { defineElement } from "~/common/element.js"; 4 5import { 6 adoptPasskeyPrfResult, 7 createPasskey, 8 decryptUri, 9 deriveCipherKey, 10 encryptUri, 11 isEncryptedUri, 12 loadStoredCipherKey, 13 removeStoredPasskey, 14 storeCipherKey, 15} from "./passkey.js"; 16 17/** 18 * @import { Setting, Track } from "~/definitions/types.d.ts" 19 * @import { OutputManager } from "~/components/output/types.d.ts" 20 */ 21 22/** 23 * Output transformer that encrypts track URIs and setting values using a 24 * passkey-derived key. 25 * 26 * On read, decrypts `encrypted://` URIs in tracks and `encrypted://`-encoded 27 * JSON in setting values transparently. On write, re-encrypts before passing 28 * downstream. 29 * 30 * Tracks/settings that cannot be decrypted (no key in memory) are held in 31 * `lockedTracks`/`lockedSettings` and excluded from the visible collection. 32 * 33 * @extends {OutputTransformer} 34 */ 35class PasskeyEncryptionTransformer extends OutputTransformer { 36 static NAME = "diffuse/transformer/output/refiner/passkey-encryption"; 37 38 #tracks; 39 40 constructor() { 41 super(); 42 43 const base = this.base(); 44 45 const encryptionKey = this.#encryptionKey; 46 const lockedSettings = this.#lockedSettings; 47 const lockedTracks = this.#lockedTracks; 48 49 this.facets = base.facets; 50 this.playlistItems = base.playlistItems; 51 this.ready = this.#keyReady.get; 52 53 // Settings 54 /** @type {OutputManager["settings"]} */ 55 this.settings = { 56 ...base.settings, 57 58 collection: computed(() => { 59 const col = base.settings.collection(); 60 if (col?.state !== "loaded") return { state: "loading" }; 61 62 const key = encryptionKey.get(); 63 64 /** @type {Setting[]} */ 65 const unlocked = []; 66 67 /** @type {Setting[]} */ 68 const locked = []; 69 70 for (const setting of col.data) { 71 const value = setting.value; 72 if (typeof value === "string" && isEncryptedUri(value)) { 73 if (key) { 74 try { 75 unlocked.push({ 76 ...setting, 77 value: decryptUri(key, value), 78 }); 79 } catch { 80 locked.push(setting); 81 } 82 } else { 83 locked.push(setting); 84 } 85 } else { 86 unlocked.push(setting); 87 } 88 } 89 90 lockedSettings.set(locked); 91 return { state: "loaded", data: unlocked }; 92 }), 93 94 save: async (/** @type {Setting[]} */ newSettings) => { 95 const key = encryptionKey.get(); 96 97 if (key) { 98 newSettings = newSettings.map((setting) => ({ 99 ...setting, 100 value: encryptUri(key, setting.value), 101 })); 102 103 // Re-append still-locked settings so they are not lost 104 newSettings = newSettings.concat(lockedSettings.value); 105 } 106 107 await base.settings.save(newSettings); 108 }, 109 }; 110 111 // Tracks 112 this.#tracks = () => { 113 const col = base.tracks.collection(); 114 115 if (col?.state !== "loaded") { 116 return { state: "loading", locked: [], unlocked: [] }; 117 } 118 119 const key = encryptionKey.get(); 120 121 /** @type {Track[]} */ 122 const unlocked = []; 123 124 /** @type {Track[]} */ 125 const locked = []; 126 127 for (const track of col.data) { 128 if (!isEncryptedUri(track.uri)) { 129 unlocked.push(track); 130 } else if (key) { 131 try { 132 unlocked.push({ ...track, uri: decryptUri(key, track.uri) }); 133 } catch { 134 locked.push(track); 135 } 136 } else { 137 locked.push(track); 138 } 139 } 140 141 return { state: "loaded", locked, unlocked }; 142 }; 143 144 /** @type {OutputManager["tracks"]} */ 145 this.tracks = { 146 ...base.tracks, 147 148 collection: computed(() => { 149 const result = this.#tracks(); 150 if (result.state === "loading") return { state: "loading" }; 151 lockedTracks.set(result.locked); 152 return { state: "loaded", data: result.unlocked }; 153 }), 154 155 save: async (/** @type {Track[]} */ newTracks) => { 156 const key = encryptionKey.get(); 157 158 if (key) { 159 newTracks = newTracks.map((track) => ({ 160 ...track, 161 uri: encryptUri(key, track.uri), 162 })); 163 164 // Re-append still-locked tracks so they are not lost 165 newTracks = newTracks.concat(lockedTracks.value); 166 } 167 168 await base.tracks.save(newTracks); 169 }, 170 }; 171 } 172 173 // SIGNALS 174 175 #encryptionKey = signal(/** @type {Uint8Array | null} */ (null)); 176 #keyReady = signal(false); 177 #lockedSettings = signal(/** @type {Setting[]} */ ([])); 178 #lockedTracks = signal(/** @type {Track[]} */ ([])); 179 180 passkeyActive = computed(() => this.#encryptionKey.get() !== null); 181 lockedSettings = this.#lockedSettings.get; 182 lockedTracks = this.#lockedTracks.get; 183 184 // LIFECYCLE 185 186 /** @override */ 187 connectedCallback() { 188 if (this.hasAttribute("group")) { 189 const channelName = this.namespace?.length 190 ? `${PasskeyEncryptionTransformer.NAME}/${this.namespace}/${this.group}` 191 : `${PasskeyEncryptionTransformer.NAME}/${this.group}`; 192 193 const actions = this.broadcast(channelName, { 194 getLockedSettings: { 195 strategy: "leaderOnly", 196 fn: this.#lockedSettings.get, 197 }, 198 setLockedSettings: { 199 strategy: "replicate", 200 fn: this.#lockedSettings.set, 201 }, 202 getLockedTracks: { 203 strategy: "leaderOnly", 204 fn: this.#lockedTracks.get, 205 }, 206 setLockedTracks: { 207 strategy: "replicate", 208 fn: this.#lockedTracks.set, 209 }, 210 }); 211 212 if (actions) { 213 this.#lockedSettings.set = actions.setLockedSettings; 214 this.#lockedTracks.set = actions.setLockedTracks; 215 216 actions.getLockedSettings().then((locked) => { 217 this.#lockedSettings.value = locked; 218 }); 219 220 actions.getLockedTracks().then((locked) => { 221 this.#lockedTracks.value = locked; 222 }); 223 } 224 } 225 226 super.connectedCallback(); 227 228 loadStoredCipherKey(this.namespace ?? "").then((key) => { 229 if (key) { 230 this.#encryptionKey.value = key; 231 } 232 233 this.#keyReady.value = true; 234 }); 235 } 236 237 // PASSKEY 238 239 /** 240 * Register a new passkey for track URI encryption. 241 * Throws if the authenticator does not support the PRF extension. 242 */ 243 async setupPasskey() { 244 const namespace = this.namespace ?? ""; 245 const result = await createPasskey(namespace); 246 247 if (!result.supported) { 248 throw new Error(result.reason); 249 } 250 251 const key = await deriveCipherKey(result.prfSecond); 252 await storeCipherKey(namespace, key); 253 this.#encryptionKey.value = key; 254 255 let savedSettings = false; 256 let savedTracks = false; 257 258 const stopSettings = this.effect(() => { 259 if (savedSettings) { stopSettings(); return; } 260 const col = this.settings.collection(); 261 if (col.state === "loading") return; 262 savedSettings = true; 263 this.settings.save(col.data); 264 }); 265 266 const stopTracks = this.effect(() => { 267 if (savedTracks) { stopTracks(); return; } 268 const col = this.tracks.collection(); 269 if (col.state === "loading") return; 270 savedTracks = true; 271 this.tracks.save(col.data); 272 }); 273 } 274 275 /** 276 * Adopt an existing passkey from another device via discoverable-credential 277 * lookup. Stores the credential ID locally and derives the cipher key. 278 */ 279 async adoptPasskey() { 280 const namespace = this.namespace ?? ""; 281 const result = await adoptPasskeyPrfResult(namespace); 282 283 if (!result.supported) { 284 throw new Error(result.reason); 285 } 286 287 const key = await deriveCipherKey(result.prfSecond); 288 await storeCipherKey(namespace, key); 289 this.#encryptionKey.value = key; 290 291 let savedSettings = false; 292 let savedTracks = false; 293 294 const stopSettings = this.effect(() => { 295 if (savedSettings) { stopSettings(); return; } 296 const col = this.settings.collection(); 297 if (col.state !== "loaded") return; 298 savedSettings = true; 299 this.settings.save(col.data); 300 }); 301 302 const stopTracks = this.effect(() => { 303 if (savedTracks) { stopTracks(); return; } 304 const col = this.tracks.collection(); 305 if (col.state !== "loaded") return; 306 savedTracks = true; 307 this.tracks.save(col.data); 308 }); 309 } 310 311 /** 312 * Remove the stored passkey credential and clear in-memory key material. 313 */ 314 async removePasskey() { 315 const namespace = this.namespace ?? ""; 316 await removeStoredPasskey(namespace); 317 318 // Both collections must be captured in the same reactive snapshot before 319 // clearing the key. If the key were cleared between the two reads, the 320 // second collection would evaluate with key=null and show encrypted items 321 // as locked (invisible), causing them to be silently dropped on save. 322 let removed = false; 323 324 const stop = this.effect(() => { 325 if (removed) { stop(); return; } 326 327 const settingsCol = this.settings.collection(); 328 const tracksCol = this.tracks.collection(); 329 330 if (settingsCol.state !== "loaded" || tracksCol.state !== "loaded") return; 331 332 removed = true; 333 334 this.#encryptionKey.value = null; 335 336 this.settings.save(settingsCol.data); 337 this.tracks.save(tracksCol.data); 338 }); 339 } 340} 341 342export default PasskeyEncryptionTransformer; 343 344//////////////////////////////////////////// 345// REGISTER 346//////////////////////////////////////////// 347 348export const CLASS = PasskeyEncryptionTransformer; 349export const NAME = "dtor-passkey-encryption"; 350 351defineElement(NAME, CLASS);