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.

wip: passkey transformer

+481 -357
+1 -1
.env
··· 1 - ATPROTO_CLIENT_ID=https://1908-185-142-225-217.ngrok-free.app/oauth-client-metadata.tunnel.json 1 + ATPROTO_CLIENT_ID=https://measures-cancel-archive-www.trycloudflare.com/oauth-client-metadata.tunnel.json 2 2 DISABLE_AUTOMATIC_TRACKS_PROCESSING=t
+1
deno.jsonc
··· 137 137 "./components/transformer/output/bytes/automerge/utils.js": "./src/components/transformer/output/bytes/automerge/utils.js", 138 138 "./components/transformer/output/bytes/json/element.js": "./src/components/transformer/output/bytes/json/element.js", 139 139 "./components/transformer/output/refiner/default/element.js": "./src/components/transformer/output/refiner/default/element.js", 140 + "./components/transformer/output/refiner/track-uri-passkey/element.js": "./src/components/transformer/output/refiner/track-uri-passkey/element.js", 140 141 "./components/transformer/output/string/json/element.js": "./src/components/transformer/output/string/json/element.js", 141 142 142 143 // .d.ts
+21 -7
src/components/orchestrator/output/element.js
··· 50 50 import( 51 51 "@components/transformer/output/raw/atproto-sync/element.js" 52 52 ); 53 + import( 54 + "@components/transformer/output/refiner/track-uri-passkey/element.js" 55 + ); 53 56 break; 54 57 } 55 58 case "do-output__dc-output__s3": { ··· 151 154 namespace="json" 152 155 ></dop-indexed-db> 153 156 157 + <!-- ⚙️ S3 --> 154 158 <dob-s3 id="do-output__dob-s3"></dob-s3> 159 + 160 + <!-- ⚙️ ATPROTO --> 161 + <dtor-atproto-sync 162 + id="do-output__dtor-atproto-sync" 163 + namespace="atproto" 164 + output-selector="#do-output__dor-atproto" 165 + ></dtor-atproto-sync> 166 + 155 167 <dor-atproto id="do-output__dor-atproto"></dor-atproto> 156 168 157 169 <!-- OUTPUT CONFIGURATOR --> ··· 160 172 default="do-output__dc-output__local" 161 173 group="${ifDefined(group)}" 162 174 > 163 - <!-- Local --> 175 + <!-- local --> 164 176 <dtos-json 165 177 id="do-output__dc-output__local" 166 178 output-selector="#do-output__dop-indexed-db__json" 167 179 label="Local" 168 180 ></dtos-json> 169 181 170 - <!-- ATProto --> 171 - <dtor-atproto-sync 182 + <!-- atproto --> 183 + <dtor-track-uri-passkey 172 184 id="do-output__dc-output__atproto" 173 185 namespace="atproto" 174 - output-selector="#do-output__dor-atproto" 186 + output-selector="#do-output__dtor-atproto-sync" 187 + group="${ifDefined(group)}" 175 188 label="AT Protocol" 176 - ></dtor-atproto-sync> 189 + > 190 + </dtor-track-uri-passkey> 177 191 178 - <!-- S3 --> 192 + <!-- s3 --> 179 193 <dtob-dasl-sync 180 194 id="do-output__dc-output__s3" 181 195 namespace="s3" ··· 184 198 ></dtob-dasl-sync> 185 199 </dc-output> 186 200 187 - <!-- REFINER --> 201 + <!-- REFINERS --> 188 202 <dtor-default 189 203 id="do-output__dtor-default" 190 204 output-selector="#do-output__dc-output"
+15 -1
src/components/output/common.js
··· 14 14 export function promiseLoadedState(loader, state) { 15 15 return () => 16 16 new Promise((resolve) => { 17 + let resolved = false; 18 + 17 19 const stop = effect(() => { 20 + if (resolved) { 21 + try { 22 + stop(); 23 + } catch {} 24 + return; 25 + } 26 + 18 27 if (state() === "loaded") { 19 - stop(); 28 + try { 29 + stop(); 30 + } catch { 31 + resolved = true; 32 + } 33 + 20 34 resolve(void 0); 21 35 } 22 36 });
+7 -177
src/components/output/raw/atproto/element.js
··· 13 13 TokenRefreshError, 14 14 } from "./oauth.js"; 15 15 16 - import { 17 - adoptPasskeyPrfResult, 18 - createPasskey, 19 - decryptUri, 20 - deriveCipherKey, 21 - encryptUri, 22 - isEncryptedUri, 23 - loadStoredCipherKey, 24 - removeStoredPasskey, 25 - storeCipherKey, 26 - } from "./passkey.js"; 27 - 28 16 /** 29 - * @import {Track} from "@definitions/types.d.ts" 30 17 * @import {OutputManager} from "../../types.d.ts" 31 18 * @import {ATProtoOutputElement} from "./types.d.ts" 32 19 */ ··· 74 61 }, 75 62 tracks: { 76 63 empty: () => [], 77 - get: async () => { 78 - const { locked, unlocked } = await this.#getTracks(); 79 - this.#lockedTracks.value = locked; 80 - return unlocked; 81 - }, 82 - put: (data) => this.#putTracks(data), 64 + get: () => this.listRecords("sh.diffuse.output.track"), 65 + put: (data) => this.#putRecords("sh.diffuse.output.track", data), 83 66 }, 84 67 }); 85 68 ··· 93 76 94 77 #did = signal(/** @type {string | null} */ (null)); 95 78 #isOnline = signal(navigator.onLine); 96 - #lockedTracks = signal(/** @type {Track[]} */ ([])); 97 - #passkeyActive = signal(false); 98 79 #rev = signal(/** @type {string | null} */ (null)); 99 80 100 - // STATE 101 - 102 - /** @type {Uint8Array | null} */ 103 - #encryptionKey = null; 104 - 105 81 did = this.#did.get; 106 82 rev = this.#rev.get; 107 - lockedTracks = this.#lockedTracks.get; 108 - passkeyActive = this.#passkeyActive.get; 109 83 110 84 ready = computed(() => { 111 85 return this.#did.value !== null && !!this.#rpc && this.#isOnline.value; ··· 117 91 connectedCallback() { 118 92 super.connectedCallback(); 119 93 120 - loadStoredCipherKey().then((key) => { 121 - if (key) { 122 - this.#encryptionKey = key; 123 - this.#passkeyActive.value = true; 124 - this.#decryptLockedTracks(); 125 - } 126 - }); 127 - 128 94 this.#tryRestore(); 129 95 130 96 globalThis.addEventListener("online", this.#online); ··· 161 127 this.#agent = null; 162 128 this.#authenticated = Promise.withResolvers(); 163 129 this.#did.value = null; 164 - this.#encryptionKey = null; 165 - this.#passkeyActive.value = false; 166 130 this.#rpc = null; 167 131 } 168 132 } ··· 175 139 this.#agent = null; 176 140 this.#authenticated = Promise.withResolvers(); 177 141 this.#did.value = null; 178 - this.#encryptionKey = null; 179 - this.#passkeyActive.value = false; 180 142 this.#rpc = null; 181 143 182 144 clearStoredSession(); ··· 243 205 this.#authenticated.resolve(); 244 206 } 245 207 246 - // PASSKEY 247 - 248 - /** 249 - * Register a new passkey for track URI encryption. 250 - * Throws if the authenticator does not support the PRF extension. 251 - */ 252 - async setupPasskey() { 253 - const result = await createPasskey(); 254 - 255 - if (!result.supported) { 256 - throw new Error(result.reason); 257 - } 258 - } 259 - 260 - /** 261 - * Adopt an existing passkey via discoverable-credential 262 - * lookup. Stores the credential ID locally and derives the cipher key. 263 - */ 264 - async adoptPasskey() { 265 - const result = await adoptPasskeyPrfResult(); 266 - 267 - if (!result.supported) { 268 - throw new Error(result.reason); 269 - } 270 - 271 - this.#encryptionKey = await deriveCipherKey(result.prfSecond); 272 - this.#passkeyActive.value = true; 273 - 274 - await storeCipherKey(this.#encryptionKey); 275 - await this.#decryptLockedTracks(); 276 - } 277 - 278 - /** 279 - * Remove the stored passkey credential and clear in-memory key material. 280 - */ 281 - async removePasskey() { 282 - await removeStoredPasskey(); 283 - this.#encryptionKey = null; 284 - this.#passkeyActive.value = false; 285 - this.#lockedTracks.value = []; 286 - } 287 - 288 - /** 289 - * Attempt to decrypt tracks that were held back due to a missing key. 290 - * Called automatically after `unlockWithPasskey()`. 291 - */ 292 - async #decryptLockedTracks() { 293 - const key = this.#encryptionKey; 294 - if (!key) return; 295 - 296 - const locked = this.#lockedTracks.value; 297 - if (locked.length === 0) return; 298 - 299 - const results = locked.map((track) => { 300 - try { 301 - const uri = decryptUri(key, track.uri); 302 - return { ...track, uri }; 303 - } catch { 304 - return null; 305 - } 306 - }); 307 - 308 - const decrypted = results.filter((r) => r !== null); 309 - const stillLocked = locked.filter((_, i) => results[i] === null); 310 - 311 - this.#lockedTracks.value = stillLocked; 312 - 313 - const current = this.#manager.signals.tracks.value; 314 - this.#manager.signals.tracks.value = [...current, ...decrypted]; 315 - } 316 - 317 208 // RECORDS 318 209 319 210 /** ··· 386 277 } 387 278 388 279 /** 389 - * Fetch tracks and separate encrypted-but-locked records from usable ones. 390 - * Encrypted records with no key in memory are stored in `#lockedTracks` 391 - * and excluded from the returned array. 392 - * 393 - * @returns {Promise<{ locked: Track[]; unlocked: Track[] }>} 394 - */ 395 - async #getTracks() { 396 - /** @type {Track[]} */ 397 - const raw = await this.listRecords("sh.diffuse.output.track"); 398 - 399 - /** @type {Track[]} */ 400 - const unlocked = []; 401 - 402 - /** @type {Track[]} */ 403 - const locked = []; 404 - 405 - console.log("Get tracks", raw); 406 - 407 - for (const track of raw) { 408 - if (!isEncryptedUri(track.uri)) { 409 - unlocked.push(track); 410 - } else if (this.#encryptionKey) { 411 - try { 412 - const uri = decryptUri(this.#encryptionKey, track.uri); 413 - unlocked.push({ ...track, uri }); 414 - } catch { 415 - locked.push(track); 416 - } 417 - } else { 418 - locked.push(track); 419 - } 420 - } 421 - 422 - console.log("Locked", locked); 423 - console.log("Unlocked", unlocked); 424 - 425 - return { 426 - locked, 427 - unlocked, 428 - }; 429 - } 430 - 431 - /** 432 280 * @param {string} collection 433 281 * @param {Array<{ id: string }>} data 434 282 */ ··· 495 343 } 496 344 } 497 345 498 - // 4. Apply 499 - if (writes.length > 0) { 346 + // 4. Apply in batches of 100 347 + for (let i = 0; i < writes.length; i += 100) { 348 + const batch = writes.slice(i, i + 100); 349 + 500 350 /** @type {any} */ 501 351 const result = await ok(this.#rpc.post("com.atproto.repo.applyWrites", { 502 - input: { repo: this.#did.value, writes }, 352 + input: { repo: this.#did.value, writes: batch }, 503 353 })); 504 354 505 355 if (result?.commit?.rev) { ··· 514 364 515 365 throw err; 516 366 } 517 - } 518 - 519 - /** 520 - * @param {Track[]} tracks 521 - */ 522 - async #putTracks(tracks) { 523 - const key = this.#encryptionKey; 524 - 525 - if (key) { 526 - tracks = tracks.map((track) => { 527 - return { 528 - ...track, 529 - uri: encryptUri(key, track.uri), 530 - }; 531 - }); 532 - 533 - tracks = tracks.concat(this.#lockedTracks.value); 534 - } 535 - 536 - this.#putRecords("sh.diffuse.output.track", tracks); 537 367 } 538 368 } 539 369
-2
src/components/output/raw/atproto/oauth.js
··· 54 54 : /** @type {any} */ (import.meta).env?.ATPROTO_CLIENT_ID ?? 55 55 "https://elements.diffuse.sh/oauth-client-metadata.json"; 56 56 57 - console.log(client_id); 58 - 59 57 configureOAuth({ 60 58 metadata: { 61 59 client_id,
+47 -14
src/components/output/raw/atproto/passkey.js src/components/transformer/output/refiner/track-uri-passkey/passkey.js
··· 9 9 // CONSTANTS 10 10 //////////////////////////////////////////// 11 11 12 - const IDB_KEY = "diffuse/output/raw/atproto/passkey"; 13 - const IDB_KEY_CIPHER = "diffuse/output/raw/atproto/passkey/cipher-key"; 12 + const IDB_PREFIX = "diffuse/transformer/output/refiner/track-uri-passkey"; 13 + 14 + /** 15 + * @param {string} namespace 16 + * @returns {{ credential: string, cipher: string }} 17 + */ 18 + function idbKeys(namespace) { 19 + const prefix = namespace && namespace.length 20 + ? `${IDB_PREFIX}/${namespace}` 21 + : IDB_PREFIX; 22 + return { 23 + credential: `${prefix}/passkey`, 24 + cipher: `${prefix}/passkey/cipher-key`, 25 + }; 26 + } 14 27 15 28 //////////////////////////////////////////// 16 29 // RELYING PARTY ··· 31 44 /** 32 45 * Register a new passkey with the PRF extension. 33 46 * 34 - * @returns {Promise<{ supported: true, credentialId: Uint8Array } | { supported: false, reason: string }>} 47 + * @param {string} namespace 48 + * @returns {Promise<{ supported: true, credentialId: Uint8Array, prfSecond: ArrayBuffer } | { supported: false, reason: string }>} 35 49 */ 36 - export async function createPasskey() { 50 + export async function createPasskey(namespace) { 37 51 const rp = relyingParty(); 38 52 const challenge = crypto.getRandomValues(new Uint8Array(32)); 39 53 const userId = crypto.getRandomValues(new Uint8Array(16)); ··· 97 111 }; 98 112 } 99 113 114 + // @ts-ignore — PRF is not yet in the TS DOM types 115 + const prfSecond = extensions.prf?.results?.second; 116 + 117 + if (!prfSecond) { 118 + return { 119 + supported: false, 120 + reason: "Authenticator did not return PRF results at registration time", 121 + }; 122 + } 123 + 100 124 const credentialId = new Uint8Array(credential.rawId); 101 - await IDB.set(IDB_KEY, { credentialId: Array.from(credentialId) }); 125 + await IDB.set(idbKeys(namespace).credential, { 126 + credentialId: Array.from(credentialId), 127 + }); 102 128 103 - return { supported: true, credentialId }; 129 + return { supported: true, credentialId, prfSecond: /** @type {ArrayBuffer} */ (prfSecond) }; 104 130 } 105 131 106 132 /** ··· 108 134 * (no `allowCredentials`), so it works on a new device that has no stored 109 135 * credential ID yet. Saves the credential ID to IDB and returns PRF material. 110 136 * 137 + * @param {string} namespace 111 138 * @returns {Promise<{ supported: true, credentialId: Uint8Array, prfSecond: ArrayBuffer } | { supported: false, reason: string }>} 112 139 */ 113 - export async function adoptPasskeyPrfResult() { 140 + export async function adoptPasskeyPrfResult(namespace) { 114 141 const rp = relyingParty(); 115 142 const challenge = crypto.getRandomValues(new Uint8Array(32)); 116 143 ··· 163 190 } 164 191 165 192 const credentialId = new Uint8Array(assertion.rawId); 166 - await IDB.set(IDB_KEY, { credentialId: Array.from(credentialId) }); 193 + await IDB.set(idbKeys(namespace).credential, { 194 + credentialId: Array.from(credentialId), 195 + }); 167 196 168 197 return { 169 198 supported: true, ··· 175 204 /** 176 205 * Remove the stored passkey credential ID and cached cipher key from IDB. 177 206 * 207 + * @param {string} namespace 178 208 * @returns {Promise<void>} 179 209 */ 180 - export async function removeStoredPasskey() { 181 - await Promise.all([IDB.del(IDB_KEY), IDB.del(IDB_KEY_CIPHER)]); 210 + export async function removeStoredPasskey(namespace) { 211 + const keys = idbKeys(namespace); 212 + await Promise.all([IDB.del(keys.credential), IDB.del(keys.cipher)]); 182 213 } 183 214 184 215 /** 185 216 * Persist the derived cipher key to IDB so it survives page reloads. 186 217 * 218 + * @param {string} namespace 187 219 * @param {Uint8Array} key 188 220 * @returns {Promise<void>} 189 221 */ 190 - export async function storeCipherKey(key) { 191 - await IDB.set(IDB_KEY_CIPHER, key); 222 + export async function storeCipherKey(namespace, key) { 223 + await IDB.set(idbKeys(namespace).cipher, key); 192 224 } 193 225 194 226 /** 195 227 * Retrieve the previously persisted cipher key from IDB. 196 228 * 229 + * @param {string} namespace 197 230 * @returns {Promise<Uint8Array | undefined>} 198 231 */ 199 - export async function loadStoredCipherKey() { 200 - return IDB.get(IDB_KEY_CIPHER); 232 + export async function loadStoredCipherKey(namespace) { 233 + return IDB.get(idbKeys(namespace).cipher); 201 234 } 202 235 203 236 ////////////////////////////////////////////
-16
src/components/output/raw/atproto/types.d.ts
··· 1 1 import type { SignalReader } from "@common/signal.d.ts"; 2 - import type { Track } from "@definitions/types.d.ts"; 3 2 import type { OutputElement } from "../../types.d.ts"; 4 3 5 4 export type ATProtoOutputElement = ··· 8 7 did: SignalReader<string | null>; 9 8 rev: SignalReader<string | null>; 10 9 11 - /** Track records with encrypted URIs that cannot be decrypted without the passkey. */ 12 - lockedTracks: SignalReader<Track[]>; 13 - 14 - /** True if passkey encryption is active for this session. */ 15 - passkeyActive: SignalReader<boolean>; 16 - 17 10 getLatestCommit(): Promise<string | null>; 18 11 login(handle: string): Promise<void>; 19 12 logout(): Promise<void>; 20 - 21 - /** Adopt an existing passkey from another device via discoverable-credential lookup. */ 22 - adoptPasskey(): Promise<void>; 23 - 24 - /** Remove the stored passkey credential and clear the in-memory key. */ 25 - removePasskey(): Promise<void>; 26 - 27 - /** Register a new passkey for track URI encryption. Throws if PRF is not supported. */ 28 - setupPasskey(): Promise<void>; 29 13 };
+223
src/components/transformer/output/refiner/track-uri-passkey/element.js
··· 1 + import { computed, signal } from "@common/signal.js"; 2 + import { OutputTransformer } from "../../base.js"; 3 + 4 + import { 5 + adoptPasskeyPrfResult, 6 + createPasskey, 7 + decryptUri, 8 + deriveCipherKey, 9 + encryptUri, 10 + isEncryptedUri, 11 + loadStoredCipherKey, 12 + removeStoredPasskey, 13 + storeCipherKey, 14 + } from "./passkey.js"; 15 + 16 + /** 17 + * @import { Track } from "@definitions/types.d.ts" 18 + */ 19 + 20 + /** 21 + * Output transformer that encrypts track URIs using a passkey-derived key. 22 + * 23 + * Sits in front of any output element. On read (`tracks.collection`), 24 + * decrypts `encrypted://` URIs transparently. On write (`tracks.save`), 25 + * re-encrypts all URIs before passing them downstream. 26 + * 27 + * Tracks whose URIs cannot be decrypted (no key in memory) are held 28 + * in `lockedTracks` and excluded from the visible collection. 29 + * 30 + * @extends {OutputTransformer} 31 + */ 32 + class TrackUriPasskeyTransformer extends OutputTransformer { 33 + static NAME = "diffuse/transformer/output/refiner/track-uri-passkey"; 34 + 35 + #tracks; 36 + 37 + constructor() { 38 + super(); 39 + 40 + const base = this.base(); 41 + 42 + const encryptionKey = this.#encryptionKey; 43 + const lockedTracks = this.#lockedTracks; 44 + 45 + this.facets = base.facets; 46 + this.playlistItems = base.playlistItems; 47 + this.themes = base.themes; 48 + this.ready = this.#keyReady.get; 49 + 50 + // Tracks 51 + this.#tracks = () => { 52 + const raw = base.tracks.collection(); 53 + if (!raw) return { locked: [], unlocked: [] }; 54 + 55 + const key = encryptionKey.get(); 56 + 57 + /** @type {Track[]} */ 58 + const unlocked = []; 59 + 60 + /** @type {Track[]} */ 61 + const locked = []; 62 + 63 + for (const track of raw) { 64 + if (!isEncryptedUri(track.uri)) { 65 + unlocked.push(track); 66 + } else if (key) { 67 + try { 68 + unlocked.push({ ...track, uri: decryptUri(key, track.uri) }); 69 + } catch { 70 + locked.push(track); 71 + } 72 + } else { 73 + locked.push(track); 74 + } 75 + } 76 + 77 + return { locked, unlocked }; 78 + }; 79 + 80 + this.tracks = { 81 + ...base.tracks, 82 + 83 + collection: computed(() => { 84 + const { locked, unlocked } = this.#tracks(); 85 + lockedTracks.set(locked); 86 + return unlocked; 87 + }), 88 + 89 + save: async (/** @type {Track[]} */ newTracks) => { 90 + const key = encryptionKey.get(); 91 + 92 + if (key) { 93 + newTracks = newTracks.map((track) => ({ 94 + ...track, 95 + uri: encryptUri(key, track.uri), 96 + })); 97 + 98 + // Re-append still-locked tracks so they are not lost 99 + newTracks = newTracks.concat(lockedTracks.value); 100 + } 101 + 102 + await base.tracks.save(newTracks); 103 + }, 104 + }; 105 + } 106 + 107 + // SIGNALS 108 + 109 + #encryptionKey = signal(/** @type {Uint8Array | null} */ (null)); 110 + #keyReady = signal(false); 111 + #lockedTracks = signal(/** @type {Track[]} */ ([])); 112 + 113 + passkeyActive = computed(() => this.#encryptionKey.get() !== null); 114 + lockedTracks = this.#lockedTracks.get; 115 + 116 + // NAMESPACE 117 + 118 + get namespace() { 119 + return this.getAttribute("namespace") ?? ""; 120 + } 121 + 122 + // LIFECYCLE 123 + 124 + /** @override */ 125 + connectedCallback() { 126 + if (this.hasAttribute("group")) { 127 + const channelName = this.namespace?.length 128 + ? `${TrackUriPasskeyTransformer.NAME}/${this.namespace}/${this.group}` 129 + : `${TrackUriPasskeyTransformer.NAME}/${this.group}`; 130 + 131 + const actions = this.broadcast(channelName, { 132 + getLockedTracks: { 133 + strategy: "leaderOnly", 134 + fn: this.#lockedTracks.get, 135 + }, 136 + setLockedTracks: { 137 + strategy: "replicate", 138 + fn: this.#lockedTracks.set, 139 + }, 140 + }); 141 + 142 + if (actions) { 143 + this.#lockedTracks.set = actions.setLockedTracks; 144 + 145 + actions.getLockedTracks().then((locked) => { 146 + this.#lockedTracks.value = locked; 147 + }); 148 + } 149 + } 150 + 151 + super.connectedCallback(); 152 + 153 + loadStoredCipherKey(this.namespace).then((key) => { 154 + if (key) { 155 + this.#encryptionKey.value = key; 156 + } 157 + 158 + this.#keyReady.value = true; 159 + }); 160 + } 161 + 162 + // PASSKEY 163 + 164 + /** 165 + * Register a new passkey for track URI encryption. 166 + * Throws if the authenticator does not support the PRF extension. 167 + */ 168 + async setupPasskey() { 169 + const result = await createPasskey(this.namespace); 170 + 171 + if (!result.supported) { 172 + throw new Error(result.reason); 173 + } 174 + 175 + const key = await deriveCipherKey(result.prfSecond); 176 + await storeCipherKey(this.namespace, key); 177 + this.#encryptionKey.value = key; 178 + } 179 + 180 + /** 181 + * Adopt an existing passkey from another device via discoverable-credential 182 + * lookup. Stores the credential ID locally and derives the cipher key. 183 + */ 184 + async adoptPasskey() { 185 + const result = await adoptPasskeyPrfResult(this.namespace); 186 + 187 + if (!result.supported) { 188 + throw new Error(result.reason); 189 + } 190 + 191 + const key = await deriveCipherKey(result.prfSecond); 192 + await storeCipherKey(this.namespace, key); 193 + this.#encryptionKey.value = key; 194 + } 195 + 196 + /** 197 + * Remove the stored passkey credential and clear in-memory key material. 198 + */ 199 + async removePasskey() { 200 + await removeStoredPasskey(this.namespace); 201 + 202 + // Capture decrypted tracks while encryption key is still in memory 203 + const unlocked = this.tracks.collection(); 204 + 205 + this.#encryptionKey.value = null; 206 + 207 + console.log(unlocked, this.tracks.state(), this.lockedTracks()); 208 + 209 + // Re-save as plaintext (key is now null, so save skips encryption) 210 + await this.tracks.save(unlocked); 211 + } 212 + } 213 + 214 + export default TrackUriPasskeyTransformer; 215 + 216 + //////////////////////////////////////////// 217 + // REGISTER 218 + //////////////////////////////////////////// 219 + 220 + export const CLASS = TrackUriPasskeyTransformer; 221 + export const NAME = "dtor-track-uri-passkey"; 222 + 223 + customElements.define(NAME, CLASS);
+3 -3
src/oauth-client-metadata.tunnel.json
··· 1 1 { 2 - "client_id": "https://1908-185-142-225-217.ngrok-free.app/oauth-client-metadata.tunnel.json", 2 + "client_id": "https://measures-cancel-archive-www.trycloudflare.com/oauth-client-metadata.tunnel.json", 3 3 "client_name": "Diffuse", 4 - "client_uri": "https://1908-185-142-225-217.ngrok-free.app", 5 - "redirect_uris": ["https://1908-185-142-225-217.ngrok-free.app/oauth/callback"], 4 + "client_uri": "https://measures-cancel-archive-www.trycloudflare.com", 5 + "redirect_uris": ["https://measures-cancel-archive-www.trycloudflare.com/oauth/callback"], 6 6 "scope": "atproto repo?collection=sh.diffuse.output.facet&collection=sh.diffuse.output.playlistItem&collection=sh.diffuse.output.theme&collection=sh.diffuse.output.track", 7 7 "grant_types": ["authorization_code", "refresh_token"], 8 8 "response_types": ["code"],
+163 -136
src/themes/webamp/configurators/output/element.js
··· 4 4 import { NAME as ATPROTO_NAME } from "@components/output/raw/atproto/element.js"; 5 5 import { NAME as S3_NAME } from "@components/output/bytes/s3/element.js"; 6 6 7 + import { NAME as PASSKEY_NAME } from "@components/transformer/output/refiner/track-uri-passkey/element.js"; 8 + 7 9 /** 8 10 * @import {ATProtoOutputElement} from "@components/output/raw/atproto/types.d.ts" 9 11 * ··· 12 14 * 13 15 * @import {OutputElement} from "@components/output/types.d.ts" 14 16 * @import {OutputConfiguratorElement} from "@components/configurator/output/types.d.ts" 17 + * @import TrackUriPasskeyTransformer from "@components/transformer/output/refiner/track-uri-passkey/element.js"; 18 + * 15 19 * @import {RenderArg} from "@common/element.d.ts" 16 20 */ 17 21 ··· 35 39 /** @type {ATProtoOutputElement | null} */ (null), 36 40 ); 37 41 42 + $atprotoPasskey = signal( 43 + /** @type {TrackUriPasskeyTransformer | null} */ (null), 44 + ); 45 + 38 46 $s3 = signal( 39 47 /** @type {S3OutputElement | null} */ (null), 40 48 ); ··· 62 70 63 71 if (atproto) { 64 72 this.$atproto.value = /** @type {ATProtoOutputElement} */ (atproto); 73 + } 74 + 75 + const atprotoPasskey = output.root().querySelector( 76 + `${PASSKEY_NAME}[namespace="atproto"]`, 77 + ); 78 + 79 + if (atprotoPasskey) { 80 + await customElements.whenDefined(PASSKEY_NAME); 81 + this.$atprotoPasskey.value = 82 + /** @type {TrackUriPasskeyTransformer} */ (atprotoPasskey); 65 83 } 66 84 67 85 const s3 = output.root().querySelector(S3_NAME); ··· 115 133 }; 116 134 117 135 #handlePasskeySetup = async () => { 118 - const atproto = this.$atproto.value; 119 - if (!atproto) return; 136 + const passkey = this.$atprotoPasskey.value; 137 + if (!passkey) return; 120 138 121 139 this.$passkeyError.value = null; 122 140 this.$passkeyWorking.value = true; 123 141 124 142 try { 125 - await atproto.setupPasskey(); 143 + await passkey.setupPasskey(); 126 144 } catch (err) { 127 145 this.$passkeyError.value = err instanceof Error 128 146 ? err.message ··· 133 151 }; 134 152 135 153 #handlePasskeyAdopt = async () => { 136 - const atproto = this.$atproto.value; 137 - if (!atproto) return; 154 + const passkey = this.$atprotoPasskey.value; 155 + if (!passkey) return; 138 156 139 157 this.$passkeyError.value = null; 140 158 this.$passkeyWorking.value = true; 141 159 142 160 try { 143 - await atproto.adoptPasskey(); 161 + await passkey.adoptPasskey(); 144 162 } catch (err) { 145 163 this.$passkeyError.value = err instanceof Error 146 164 ? err.message ··· 151 169 }; 152 170 153 171 #handlePasskeyRemove = async () => { 154 - const atproto = this.$atproto.value; 155 - if (!atproto) return; 172 + const passkey = this.$atprotoPasskey.value; 173 + if (!passkey) return; 156 174 157 175 this.$passkeyError.value = null; 158 - await atproto.removePasskey(); 176 + await passkey.removePasskey(); 159 177 }; 160 178 161 179 /** @param {Event} event */ ··· 430 448 : undefined; 431 449 432 450 const authenticated = () => { 433 - const atproto = this.$atproto.value; 434 - const passkeyActive = atproto?.passkeyActive() ?? false; 435 - const lockedTracksCount = atproto?.lockedTracks().length ?? 0; 436 - 437 - console.log(lockedTracksCount); 438 - 439 451 return html` 440 452 <fieldset> 441 453 <span class="with-icon with-icon--large"> ··· 444 456 </span> 445 457 </fieldset> 446 458 447 - <fieldset> 448 - <legend>Passkey encryption (optional)</legend> 449 - 450 - <div class="with-icon with-icon--large"> 451 - <img src="images/icons/windows_98/keys-5.png" width="24" /> 452 - 453 - <div> 454 - ${passkeyActive 455 - ? html` 456 - <p class="with-icon with-icon--large"> 457 - <img 458 - src="images/icons/windows_98/directory_channels-2.png" 459 - width="24" 460 - /> 461 - Passkey active — track URIs are encrypted. 462 - </p> 463 - 464 - ${this.$passkeyError.value 465 - ? html` 466 - <fieldset> 467 - <span class="with-icon with-icon--large"> 468 - <img src="images/icons/windows_98/msg_error-0.png" width="24" /> 469 - <span>${this.$passkeyError.value}</span> 470 - </span> 471 - </fieldset> 472 - ` 473 - : nothing} 474 - 475 - <p> 476 - <button @click="${this 477 - .#handlePasskeyRemove}">Remove passkey</button> 478 - </p> 479 - ` 480 - : html` 481 - <p> 482 - Data stored on the AT Protocol is public by default.<br /> 483 - This feature optionally hides any passwords and other<br /> 484 - sensitive authentication details from the inputs you've added. 485 - </p> 486 - <p> 487 - Note that, with this enabled, other people can NOT play audio listed on your 488 - account. 489 - </p> 490 - 491 - ${this.$passkeyError.value 492 - ? html` 493 - <fieldset> 494 - <span class="with-icon with-icon--large"> 495 - <img src="images/icons/windows_98/msg_error-0.png" width="24" /> 496 - <span>${this.$passkeyError.value}</span> 497 - </span> 498 - </fieldset> 499 - ` 500 - : nothing} 501 - 502 - <p class="button-row"> 503 - <button 504 - ?disabled="${this.$passkeyWorking.value}" 505 - @click="${this.#handlePasskeySetup}" 506 - > 507 - ${this.$passkeyWorking.value 508 - ? "Setting up ..." 509 - : "Set up passkey encryption"} 510 - </button> 511 - <button 512 - ?disabled="${this.$passkeyWorking.value}" 513 - @click="${this.#handlePasskeyAdopt}" 514 - > 515 - ${this.$passkeyWorking.value 516 - ? "Authenticating ..." 517 - : "Use existing passkey"} 518 - </button> 519 - </p> 520 - `} 521 - </div> 522 - </div> 523 - </fieldset> 524 - 525 - ${lockedTracksCount > 0 526 - ? html` 527 - <fieldset> 528 - <legend></legend> 529 - <p class="with-icon with-icon--large"> 530 - <img 531 - src="images/icons/windows_98/msg_warning-0.png" 532 - width="24" 533 - /> 534 - ${lockedTracksCount} encrypted track(s) cannot be played until you unlock them with 535 - your passkey. 536 - </p> 537 - </fieldset> 538 - ` 539 - : nothing} 459 + ${this.#renderPasskeySection(html)} 540 460 541 461 <p class="button-row"> 542 462 <button @click="${this.#handleAtprotoLogout}">Sign out</button> ··· 557 477 </span> 558 478 </fieldset> 559 479 560 - <form @submit="${this.#handleAtprotoLogin}"> 561 - <fieldset> 562 - <div class="field-row"> 563 - <label for="atproto-handle">Your internet handle:</label> 564 - <input 565 - id="atproto-handle" 566 - type="text" 567 - required 568 - placeholder="you.bsky.social" 569 - /> 570 - </div> 571 - </fieldset> 480 + <fieldset> 481 + <form @submit="${this.#handleAtprotoLogin}" class="field-row"> 482 + <label for="atproto-handle">Your internet handle:</label> 483 + <input 484 + id="atproto-handle" 485 + type="text" 486 + required 487 + placeholder="you.bsky.social" 488 + /> 489 + </form> 490 + </fieldset> 572 491 573 - ${this.$atprotoError.value 574 - ? html` 575 - <fieldset> 576 - <span class="with-icon with-icon--large"> 577 - <img src="images/icons/windows_98/msg_error-0.png" width="24" /> 578 - <span> 579 - Sign in failed, please check the provided handle and try again. 580 - </span> 492 + ${this.$atprotoError.value 493 + ? html` 494 + <fieldset> 495 + <span class="with-icon with-icon--large"> 496 + <img src="images/icons/windows_98/msg_error-0.png" width="24" /> 497 + <span> 498 + Sign in failed, please check the provided handle and try again. 581 499 </span> 582 - </fieldset> 583 - ` 584 - : nothing} 500 + </span> 501 + </fieldset> 502 + ` 503 + : nothing} ${this.#renderPasskeySection(html)} 585 504 586 - <p> 587 - <button type="submit" id="atproto-submit">Sign in</button> 588 - ${this.#renderAtprotoActivation(html, selectedOutput)} 589 - </p> 590 - </form> 505 + <p> 506 + <button @click="${this 507 + .#handleAtprotoLogin}" id="atproto-submit">Sign in</button> 508 + ${this.#renderAtprotoActivation(html, selectedOutput)} 509 + </p> 591 510 `; 592 511 }; 593 512 ··· 720 639 <div class="window-body"> 721 640 ${ready ? configured() : unconfigured()} 722 641 </div> 642 + `; 643 + } 644 + 645 + /** 646 + * @param {RenderArg["html"]} html 647 + */ 648 + #renderPasskeySection(html) { 649 + const passkey = this.$atprotoPasskey.value; 650 + if (!passkey) return nothing; 651 + 652 + const passkeyActive = passkey.passkeyActive() ?? false; 653 + const lockedTracksCount = passkey.lockedTracks().length ?? 0; 654 + 655 + return html` 656 + <fieldset> 657 + <legend>Passkey encryption (optional)</legend> 658 + 659 + <div class="with-icon with-icon--large"> 660 + <img src="images/icons/windows_98/keys-5.png" width="24" /> 661 + 662 + <div> 663 + ${passkeyActive 664 + ? html` 665 + <p class="with-icon with-icon--large"> 666 + <input type="checkbox" checked /> 667 + <label>Passkey active — Track URIs are encrypted</label> 668 + </p> 669 + 670 + ${this.$passkeyError.value 671 + ? html` 672 + <fieldset> 673 + <span class="with-icon with-icon--large"> 674 + <img src="images/icons/windows_98/msg_error-0.png" width="24" /> 675 + <span>${this.$passkeyError.value}</span> 676 + </span> 677 + </fieldset> 678 + ` 679 + : nothing} 680 + 681 + <p> 682 + <button @click="${this 683 + .#handlePasskeyRemove}">Remove passkey</button> 684 + </p> 685 + 686 + <p> 687 + Removing the passkey will expose all the sensitive<br /> 688 + information that was previously encrypted. 689 + </p> 690 + ` 691 + : html` 692 + <p> 693 + Track URIs can optionally be encrypted so that passwords and<br /> 694 + other sensitive authentication details are kept private. 695 + </p> 696 + <p> 697 + Note that, with this enabled, other people can NOT play audio listed on your 698 + account. 699 + </p> 700 + 701 + ${this.$passkeyError.value 702 + ? html` 703 + <fieldset> 704 + <span class="with-icon with-icon--large"> 705 + <img src="images/icons/windows_98/msg_error-0.png" width="24" /> 706 + <span>${this.$passkeyError.value}</span> 707 + </span> 708 + </fieldset> 709 + ` 710 + : nothing} 711 + 712 + <p class="button-row"> 713 + <button 714 + ?disabled="${this.$passkeyWorking.value}" 715 + @click="${this.#handlePasskeySetup}" 716 + > 717 + ${this.$passkeyWorking.value 718 + ? "Setting up ..." 719 + : "Set up passkey encryption"} 720 + </button> 721 + <button 722 + ?disabled="${this.$passkeyWorking.value}" 723 + @click="${this.#handlePasskeyAdopt}" 724 + > 725 + ${this.$passkeyWorking.value 726 + ? "Authenticating ..." 727 + : "Use existing passkey"} 728 + </button> 729 + </p> 730 + `} 731 + </div> 732 + </div> 733 + </fieldset> 734 + 735 + ${lockedTracksCount > 0 736 + ? html` 737 + <fieldset> 738 + <p class="with-icon with-icon--large"> 739 + <img 740 + src="images/icons/windows_98/msg_warning-0.png" 741 + width="24" 742 + /> 743 + ${lockedTracksCount} encrypted track(s) cannot be played until you unlock them with 744 + your passkey. If you're already using a passkey, remember that you have to 745 + use same passkey as the one you originally locked the tracks with. 746 + </p> 747 + </fieldset> 748 + ` 749 + : nothing} 723 750 `; 724 751 } 725 752