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

wip: passkey encryption

+686 -31
+1 -1
.env
··· 1 - ATPROTO_CLIENT_ID=http://127.0.0.1:3000/oauth-client-metadata.json 2 DISABLE_AUTOMATIC_TRACKS_PROCESSING=t
··· 1 + ATPROTO_CLIENT_ID=https://1908-185-142-225-217.ngrok-free.app/oauth-client-metadata.tunnel.json 2 DISABLE_AUTOMATIC_TRACKS_PROCESSING=t
+3
Caddyfile
···
··· 1 + diffuse.localhost { 2 + reverse_proxy 127.0.0.1:3000 3 + }
+1
deno.jsonc
··· 21 "@fry69/deep-diff": "jsr:@fry69/deep-diff@^0.1.10", 22 "@js-temporal/polyfill": "npm:@js-temporal/polyfill@^0.5.1", 23 "@mary/ds-queue": "jsr:@mary/ds-queue@^0.1.3", 24 "@okikio/transferables": "jsr:@okikio/transferables@^1.0.2", 25 "@orama/orama": "npm:@orama/orama@^3.1.18", 26 "@phosphor-icons/web": "npm:@phosphor-icons/web@^2.1.2",
··· 21 "@fry69/deep-diff": "jsr:@fry69/deep-diff@^0.1.10", 22 "@js-temporal/polyfill": "npm:@js-temporal/polyfill@^0.5.1", 23 "@mary/ds-queue": "jsr:@mary/ds-queue@^0.1.3", 24 + "@noble/ciphers": "npm:@noble/ciphers@^2.1.1", 25 "@okikio/transferables": "jsr:@okikio/transferables@^1.0.2", 26 "@orama/orama": "npm:@orama/orama@^3.1.18", 27 "@phosphor-icons/web": "npm:@phosphor-icons/web@^2.1.2",
+20 -20
src/components/output/common.js
··· 8 */ 9 10 /** 11 * @template [Encoding=null] 12 * @param {OutputManagerProperties<Encoding>} _ 13 * @returns {OutputManager<Encoding>} ··· 44 const ts = signal( 45 /** @type {"loading" | "loaded" | "sleeping"} */ ("sleeping"), 46 ); 47 - 48 - /** 49 - * @param {() => void} loader 50 - * @param {SignalReader<"loading" | "loaded" | "sleeping">} state 51 - */ 52 - function promiseLoadedState(loader, state) { 53 - return () => 54 - new Promise((resolve) => { 55 - const stop = effect(() => { 56 - if (state() === "loaded") { 57 - stop(); 58 - resolve(void 0); 59 - } 60 - }); 61 - 62 - if (state() === "sleeping") { 63 - loader(); 64 - } 65 - }); 66 - } 67 68 async function loadFacets() { 69 if (init && (await init()) === false) return;
··· 8 */ 9 10 /** 11 + * @param {() => void} loader 12 + * @param {SignalReader<"loading" | "loaded" | "sleeping">} state 13 + */ 14 + export function promiseLoadedState(loader, state) { 15 + return () => 16 + new Promise((resolve) => { 17 + const stop = effect(() => { 18 + if (state() === "loaded") { 19 + stop(); 20 + resolve(void 0); 21 + } 22 + }); 23 + 24 + if (state() === "sleeping") { 25 + loader(); 26 + } 27 + }); 28 + } 29 + 30 + /** 31 * @template [Encoding=null] 32 * @param {OutputManagerProperties<Encoding>} _ 33 * @returns {OutputManager<Encoding>} ··· 64 const ts = signal( 65 /** @type {"loading" | "loaded" | "sleeping"} */ ("sleeping"), 66 ); 67 68 async function loadFacets() { 69 if (init && (await init()) === false) return;
+175 -3
src/components/output/raw/atproto/element.js
··· 3 import { DiffuseElement } from "@common/element.js"; 4 import { computed, signal } from "@common/signal.js"; 5 import { outputManager } from "../../common.js"; 6 import { 7 clearStoredSession, 8 login, ··· 12 TokenRefreshError, 13 } from "./oauth.js"; 14 15 /** 16 * @import {OutputManager} from "../../types.d.ts" 17 * @import {ATProtoOutputElement} from "./types.d.ts" 18 */ ··· 60 }, 61 tracks: { 62 empty: () => [], 63 - get: () => this.listRecords("sh.diffuse.output.track"), 64 - put: (data) => this.#putRecords("sh.diffuse.output.track", data), 65 }, 66 }); 67 ··· 75 76 #did = signal(/** @type {string | null} */ (null)); 77 #isOnline = signal(navigator.onLine); 78 #rev = signal(/** @type {string | null} */ (null)); 79 80 // STATE 81 82 did = this.#did.get; 83 rev = this.#rev.get; 84 85 ready = computed(() => { 86 - return this.#did.value !== null && this.#isOnline.value; 87 }); 88 89 // LIFECYCLE ··· 92 connectedCallback() { 93 super.connectedCallback(); 94 95 this.#tryRestore(); 96 97 globalThis.addEventListener("online", this.#online); ··· 128 this.#agent = null; 129 this.#authenticated = Promise.withResolvers(); 130 this.#did.value = null; 131 this.#rpc = null; 132 } 133 } ··· 140 this.#agent = null; 141 this.#authenticated = Promise.withResolvers(); 142 this.#did.value = null; 143 this.#rpc = null; 144 clearStoredSession(); 145 } 146 ··· 205 this.#authenticated.resolve(); 206 } 207 208 // RECORDS 209 210 /** ··· 277 } 278 279 /** 280 * @param {string} collection 281 * @param {Array<{ id: string }>} data 282 */ ··· 362 363 throw err; 364 } 365 } 366 } 367
··· 3 import { DiffuseElement } from "@common/element.js"; 4 import { computed, signal } from "@common/signal.js"; 5 import { outputManager } from "../../common.js"; 6 + 7 import { 8 clearStoredSession, 9 login, ··· 13 TokenRefreshError, 14 } from "./oauth.js"; 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 /** 29 + * @import {Track} from "@definitions/types.d.ts" 30 * @import {OutputManager} from "../../types.d.ts" 31 * @import {ATProtoOutputElement} from "./types.d.ts" 32 */ ··· 74 }, 75 tracks: { 76 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), 83 }, 84 }); 85 ··· 93 94 #did = signal(/** @type {string | null} */ (null)); 95 #isOnline = signal(navigator.onLine); 96 + #lockedTracks = signal(/** @type {Track[]} */ ([])); 97 + #passkeyActive = signal(false); 98 #rev = signal(/** @type {string | null} */ (null)); 99 100 // STATE 101 102 + /** @type {Uint8Array | null} */ 103 + #encryptionKey = null; 104 + 105 did = this.#did.get; 106 rev = this.#rev.get; 107 + lockedTracks = this.#lockedTracks.get; 108 + passkeyActive = this.#passkeyActive.get; 109 110 ready = computed(() => { 111 + return this.#did.value !== null && !!this.#rpc && this.#isOnline.value; 112 }); 113 114 // LIFECYCLE ··· 117 connectedCallback() { 118 super.connectedCallback(); 119 120 + loadStoredCipherKey().then((key) => { 121 + if (key) { 122 + this.#encryptionKey = key; 123 + this.#passkeyActive.value = true; 124 + this.#decryptLockedTracks(); 125 + } 126 + }); 127 + 128 this.#tryRestore(); 129 130 globalThis.addEventListener("online", this.#online); ··· 161 this.#agent = null; 162 this.#authenticated = Promise.withResolvers(); 163 this.#did.value = null; 164 + this.#encryptionKey = null; 165 + this.#passkeyActive.value = false; 166 this.#rpc = null; 167 } 168 } ··· 175 this.#agent = null; 176 this.#authenticated = Promise.withResolvers(); 177 this.#did.value = null; 178 + this.#encryptionKey = null; 179 + this.#passkeyActive.value = false; 180 this.#rpc = null; 181 + 182 clearStoredSession(); 183 } 184 ··· 243 this.#authenticated.resolve(); 244 } 245 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 // RECORDS 318 319 /** ··· 386 } 387 388 /** 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 * @param {string} collection 433 * @param {Array<{ id: string }>} data 434 */ ··· 514 515 throw err; 516 } 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 } 538 } 539
+10 -6
src/components/output/raw/atproto/oauth.js
··· 47 redirect_uri = location.origin + "/oauth/callback"; 48 } 49 50 configureOAuth({ 51 metadata: { 52 - client_id: isLocalDev 53 - ? `http://localhost/?redirect_uri=${ 54 - encodeURIComponent(redirect_uri) 55 - }&scope=${encodeURIComponent(SCOPE)}` 56 - : /** @type {any} */ (import.meta).env?.ATPROTO_CLIENT_ID ?? 57 - "https://elements.diffuse.sh/oauth-client-metadata.json", 58 redirect_uri, 59 }, 60 identityResolver: new LocalActorResolver({
··· 47 redirect_uri = location.origin + "/oauth/callback"; 48 } 49 50 + const client_id = isLocalDev 51 + ? `http://localhost/?redirect_uri=${encodeURIComponent(redirect_uri)}&scope=${ 52 + encodeURIComponent(SCOPE) 53 + }` 54 + : /** @type {any} */ (import.meta).env?.ATPROTO_CLIENT_ID ?? 55 + "https://elements.diffuse.sh/oauth-client-metadata.json"; 56 + 57 + console.log(client_id); 58 + 59 configureOAuth({ 60 metadata: { 61 + client_id, 62 redirect_uri, 63 }, 64 identityResolver: new LocalActorResolver({
+281
src/components/output/raw/atproto/passkey.js
···
··· 1 + import { xchacha20poly1305 } from "@noble/ciphers/chacha.js"; 2 + import { managedNonce } from "@noble/ciphers/utils.js"; 3 + 4 + import * as IDB from "idb-keyval"; 5 + import { base64url } from "iso-base/rfc4648"; 6 + import { utf8 } from "iso-base/utf8"; 7 + 8 + //////////////////////////////////////////// 9 + // CONSTANTS 10 + //////////////////////////////////////////// 11 + 12 + const IDB_KEY = "diffuse/output/raw/atproto/passkey"; 13 + const IDB_KEY_CIPHER = "diffuse/output/raw/atproto/passkey/cipher-key"; 14 + 15 + //////////////////////////////////////////// 16 + // RELYING PARTY 17 + //////////////////////////////////////////// 18 + 19 + /** 20 + * @returns {{ name: string, id: string }} 21 + */ 22 + export function relyingParty() { 23 + const id = document.location.hostname; 24 + return { name: id, id }; 25 + } 26 + 27 + //////////////////////////////////////////// 28 + // PASSKEY MANAGEMENT 29 + //////////////////////////////////////////// 30 + 31 + /** 32 + * Register a new passkey with the PRF extension. 33 + * 34 + * @returns {Promise<{ supported: true, credentialId: Uint8Array } | { supported: false, reason: string }>} 35 + */ 36 + export async function createPasskey() { 37 + const rp = relyingParty(); 38 + const challenge = crypto.getRandomValues(new Uint8Array(32)); 39 + const userId = crypto.getRandomValues(new Uint8Array(16)); 40 + 41 + /** @type {PublicKeyCredential | null} */ 42 + let credential; 43 + 44 + try { 45 + credential = /** @type {PublicKeyCredential} */ ( 46 + await navigator.credentials.create({ 47 + publicKey: { 48 + challenge, 49 + rp, 50 + user: { 51 + id: userId, 52 + name: rp.id, 53 + displayName: "Diffuse – " + rp.id, 54 + }, 55 + pubKeyCredParams: [ 56 + { type: "public-key", alg: -7 }, 57 + { type: "public-key", alg: -257 }, 58 + ], 59 + attestation: "none", 60 + authenticatorSelection: { 61 + userVerification: "required", 62 + requireResidentKey: true, 63 + residentKey: "required", 64 + }, 65 + extensions: { 66 + // @ts-ignore — PRF is not yet in the TS DOM types 67 + prf: { 68 + eval: { 69 + // @ts-ignore 70 + first: utf8.decode(rp.id + "signing"), 71 + // @ts-ignore 72 + second: utf8.decode(rp.id + "encryption"), 73 + }, 74 + }, 75 + }, 76 + }, 77 + }) 78 + ); 79 + } catch (err) { 80 + return { 81 + supported: false, 82 + reason: err instanceof Error ? err.message : String(err), 83 + }; 84 + } 85 + 86 + if (!credential) { 87 + return { supported: false, reason: "Credential creation returned null" }; 88 + } 89 + 90 + const extensions = credential.getClientExtensionResults(); 91 + 92 + // @ts-ignore — PRF is not yet in the TS DOM types 93 + if (extensions.prf?.enabled !== true) { 94 + return { 95 + supported: false, 96 + reason: "This authenticator does not support the WebAuthn PRF extension", 97 + }; 98 + } 99 + 100 + const credentialId = new Uint8Array(credential.rawId); 101 + await IDB.set(IDB_KEY, { credentialId: Array.from(credentialId) }); 102 + 103 + return { supported: true, credentialId }; 104 + } 105 + 106 + /** 107 + * Authenticate with an existing passkey via discoverable-credential lookup 108 + * (no `allowCredentials`), so it works on a new device that has no stored 109 + * credential ID yet. Saves the credential ID to IDB and returns PRF material. 110 + * 111 + * @returns {Promise<{ supported: true, credentialId: Uint8Array, prfSecond: ArrayBuffer } | { supported: false, reason: string }>} 112 + */ 113 + export async function adoptPasskeyPrfResult() { 114 + const rp = relyingParty(); 115 + const challenge = crypto.getRandomValues(new Uint8Array(32)); 116 + 117 + /** @type {PublicKeyCredential | null} */ 118 + let assertion; 119 + 120 + try { 121 + assertion = /** @type {PublicKeyCredential} */ ( 122 + await navigator.credentials.get({ 123 + publicKey: { 124 + challenge, 125 + rpId: rp.id, 126 + userVerification: "required", 127 + extensions: { 128 + // @ts-ignore — PRF is not yet in the TS DOM types 129 + prf: { 130 + eval: { 131 + // @ts-ignore 132 + first: utf8.decode(rp.id + "signing"), 133 + // @ts-ignore 134 + second: utf8.decode(rp.id + "encryption"), 135 + }, 136 + }, 137 + }, 138 + }, 139 + }) 140 + ); 141 + } catch (err) { 142 + return { 143 + supported: false, 144 + reason: err instanceof Error ? err.message : String(err), 145 + }; 146 + } 147 + 148 + if (!assertion) { 149 + return { supported: false, reason: "Credential get returned null" }; 150 + } 151 + 152 + const extensions = assertion.getClientExtensionResults(); 153 + 154 + // @ts-ignore — PRF is not yet in the TS DOM types 155 + const prfSecond = extensions.prf?.results?.second; 156 + 157 + if (!prfSecond) { 158 + return { 159 + supported: false, 160 + reason: 161 + "This authenticator did not return PRF results — PRF extension may not be supported", 162 + }; 163 + } 164 + 165 + const credentialId = new Uint8Array(assertion.rawId); 166 + await IDB.set(IDB_KEY, { credentialId: Array.from(credentialId) }); 167 + 168 + return { 169 + supported: true, 170 + credentialId, 171 + prfSecond: /** @type {ArrayBuffer} */ (prfSecond), 172 + }; 173 + } 174 + 175 + /** 176 + * Remove the stored passkey credential ID and cached cipher key from IDB. 177 + * 178 + * @returns {Promise<void>} 179 + */ 180 + export async function removeStoredPasskey() { 181 + await Promise.all([IDB.del(IDB_KEY), IDB.del(IDB_KEY_CIPHER)]); 182 + } 183 + 184 + /** 185 + * Persist the derived cipher key to IDB so it survives page reloads. 186 + * 187 + * @param {Uint8Array} key 188 + * @returns {Promise<void>} 189 + */ 190 + export async function storeCipherKey(key) { 191 + await IDB.set(IDB_KEY_CIPHER, key); 192 + } 193 + 194 + /** 195 + * Retrieve the previously persisted cipher key from IDB. 196 + * 197 + * @returns {Promise<Uint8Array | undefined>} 198 + */ 199 + export async function loadStoredCipherKey() { 200 + return IDB.get(IDB_KEY_CIPHER); 201 + } 202 + 203 + //////////////////////////////////////////// 204 + // KEY DERIVATION 205 + //////////////////////////////////////////// 206 + 207 + /** 208 + * Derive a 256-bit key from the PRF "second" output via HKDF. 209 + * Returns raw bytes suitable for use with XChaCha20-Poly1305. 210 + * 211 + * @param {ArrayBuffer} prfSecond 212 + * @returns {Promise<Uint8Array>} 213 + */ 214 + export async function deriveCipherKey(prfSecond) { 215 + const keyMaterial = await crypto.subtle.importKey( 216 + "raw", 217 + prfSecond, 218 + { name: "HKDF" }, 219 + false, 220 + ["deriveBits"], 221 + ); 222 + 223 + const bits = await crypto.subtle.deriveBits( 224 + { 225 + name: "HKDF", 226 + hash: "SHA-256", 227 + 228 + // @ts-ignore 229 + salt: utf8.decode("diffuse-atproto-passkey-salt"), 230 + 231 + // @ts-ignore 232 + info: utf8.decode("diffuse-atproto-track-uri"), 233 + }, 234 + keyMaterial, 235 + 256, 236 + ); 237 + 238 + return new Uint8Array(bits); 239 + } 240 + 241 + //////////////////////////////////////////// 242 + // ENCRYPT / DECRYPT 243 + //////////////////////////////////////////// 244 + 245 + /** 246 + * Detect whether a URI is encrypted by this module. 247 + * 248 + * @param {string} uri 249 + * @returns {boolean} 250 + */ 251 + export function isEncryptedUri(uri) { 252 + return uri.startsWith("encrypted://"); 253 + } 254 + 255 + const xchacha = managedNonce(xchacha20poly1305); 256 + 257 + /** 258 + * Encrypt a plaintext URI with XChaCha20-Poly1305. 259 + * Returns a string of the form: `encrypted://<base64url(nonce || ciphertext)>` 260 + * The nonce is prepended automatically by `managedNonce`. 261 + * 262 + * @param {Uint8Array} key 263 + * @param {string} plaintext 264 + * @returns {string} 265 + */ 266 + export function encryptUri(key, plaintext) { 267 + const ciphertext = xchacha(key).encrypt(utf8.decode(plaintext)); 268 + return "encrypted://" + base64url.encode(ciphertext); 269 + } 270 + 271 + /** 272 + * Decrypt an encrypted URI produced by `encryptUri`. 273 + * 274 + * @param {Uint8Array} key 275 + * @param {string} encryptedUri 276 + * @returns {string} 277 + */ 278 + export function decryptUri(key, encryptedUri) { 279 + const ciphertext = base64url.decode(encryptedUri.slice(12)); 280 + return utf8.encode(xchacha(key).decrypt(ciphertext)); 281 + }
+17
src/components/output/raw/atproto/types.d.ts
··· 1 import type { SignalReader } from "@common/signal.d.ts"; 2 import type { OutputElement } from "../../types.d.ts"; 3 4 export type ATProtoOutputElement = ··· 6 & { 7 did: SignalReader<string | null>; 8 rev: SignalReader<string | null>; 9 getLatestCommit(): Promise<string | null>; 10 login(handle: string): Promise<void>; 11 logout(): Promise<void>; 12 };
··· 1 import type { SignalReader } from "@common/signal.d.ts"; 2 + import type { Track } from "@definitions/types.d.ts"; 3 import type { OutputElement } from "../../types.d.ts"; 4 5 export type ATProtoOutputElement = ··· 7 & { 8 did: SignalReader<string | null>; 9 rev: SignalReader<string | null>; 10 + 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 getLatestCommit(): Promise<string | null>; 18 login(handle: string): Promise<void>; 19 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 };
+9 -1
src/components/transformer/output/bytes/dasl-sync/element.js
··· 9 import { compareTimestamps } from "@common/utils.js"; 10 import { OutputTransformer } from "../../base.js"; 11 import { IDB_PREFIX } from "./constants.js"; 12 13 /** 14 * @import { Signal, SignalReader } from "@common/signal.d.ts"; ··· 363 * @param {{ save: (bytes: Uint8Array) => Promise<void> | void }} local 364 * @param {{ collection: SignalReader<Uint8Array | undefined>, reload: () => Promise<void>, save: (bytes: Uint8Array) => Promise<void>, state: SignalReader<"loading" | "loaded" | "sleeping"> }} remote 365 * @param {SignalReader<Container<T>>} container 366 - * @returns {{ collection: SignalReader<T[]>, reload: () => Promise<void>, save: (items: T[]) => Promise<void>, state: SignalReader<"loading" | "loaded" | "sleeping"> }} 367 */ 368 managerProp(local, remote, container) { 369 return { 370 collection: computed(() => { 371 return container().data; 372 }), 373 reload: remote.reload, 374 save: async (/** @type {T[]} */ newItems) => { 375 const adjustedContainer = await this.updateContainer({
··· 9 import { compareTimestamps } from "@common/utils.js"; 10 import { OutputTransformer } from "../../base.js"; 11 import { IDB_PREFIX } from "./constants.js"; 12 + import { promiseLoadedState } from "@toko/diffuse/components/output/common.js"; 13 14 /** 15 * @import { Signal, SignalReader } from "@common/signal.d.ts"; ··· 364 * @param {{ save: (bytes: Uint8Array) => Promise<void> | void }} local 365 * @param {{ collection: SignalReader<Uint8Array | undefined>, reload: () => Promise<void>, save: (bytes: Uint8Array) => Promise<void>, state: SignalReader<"loading" | "loaded" | "sleeping"> }} remote 366 * @param {SignalReader<Container<T>>} container 367 + * @returns {{ collection: SignalReader<T[]>, loaded: () => Promise<void>; reload: () => Promise<void>, save: (items: T[]) => Promise<void>, state: SignalReader<"loading" | "loaded" | "sleeping"> }} 368 */ 369 managerProp(local, remote, container) { 370 return { 371 collection: computed(() => { 372 return container().data; 373 }), 374 + loaded: promiseLoadedState( 375 + computed(() => container()), 376 + computed(() => { 377 + if (container().cid) return "loaded"; 378 + return "loading"; 379 + }), 380 + ), 381 reload: remote.reload, 382 save: async (/** @type {T[]} */ newItems) => { 383 const adjustedContainer = await this.updateContainer({
+11
src/components/transformer/output/raw/atproto-sync/element.js
··· 4 5 import { computed, signal } from "@common/signal.js"; 6 import { OutputTransformer } from "../../base.js"; 7 8 /** 9 * @import { RenderArg } from "@common/element.d.ts" ··· 53 const data = l[name].collection(); 54 return Array.isArray(data) ? data : []; 55 }), 56 reload: async () => { 57 await this.#sync(); 58 },
··· 4 5 import { computed, signal } from "@common/signal.js"; 6 import { OutputTransformer } from "../../base.js"; 7 + import { promiseLoadedState } from "@toko/diffuse/components/output/common.js"; 8 9 /** 10 * @import { RenderArg } from "@common/element.d.ts" ··· 54 const data = l[name].collection(); 55 return Array.isArray(data) ? data : []; 56 }), 57 + loaded: promiseLoadedState( 58 + computed(() => { 59 + const l = local(); 60 + if (!l) return []; 61 + l[name].collection(); 62 + }), 63 + computed(() => { 64 + return local()?.[name]?.state() ?? "sleeping"; 65 + }), 66 + ), 67 reload: async () => { 68 await this.#sync(); 69 },
src/images/icons/windows_98/keys-5.png

This is a binary file and will not be displayed.

+12
src/oauth-client-metadata.tunnel.json
···
··· 1 + { 2 + "client_id": "https://1908-185-142-225-217.ngrok-free.app/oauth-client-metadata.tunnel.json", 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"], 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 + "grant_types": ["authorization_code", "refresh_token"], 8 + "response_types": ["code"], 9 + "token_endpoint_auth_method": "none", 10 + "application_type": "web", 11 + "dpop_bound_access_tokens": true 12 + }
+146
src/themes/webamp/configurators/output/element.js
··· 40 ); 41 42 $atprotoError = signal(/** @type {string | null} */ (null)); 43 $tab = signal("overview"); 44 45 // LIFECYCLE ··· 110 if (!atproto) return; 111 112 await atproto.logout(); 113 }; 114 115 /** @param {Event} event */ ··· 384 : undefined; 385 386 const authenticated = () => { 387 return html` 388 <fieldset> 389 <span class="with-icon with-icon--large"> ··· 391 <span>Signed in as <strong>${did}</strong></span> 392 </span> 393 </fieldset> 394 395 <p class="button-row"> 396 <button @click="${this.#handleAtprotoLogout}">Sign out</button>
··· 40 ); 41 42 $atprotoError = signal(/** @type {string | null} */ (null)); 43 + $passkeyError = signal(/** @type {string | null} */ (null)); 44 + $passkeyWorking = signal(false); 45 $tab = signal("overview"); 46 47 // LIFECYCLE ··· 112 if (!atproto) return; 113 114 await atproto.logout(); 115 + }; 116 + 117 + #handlePasskeySetup = async () => { 118 + const atproto = this.$atproto.value; 119 + if (!atproto) return; 120 + 121 + this.$passkeyError.value = null; 122 + this.$passkeyWorking.value = true; 123 + 124 + try { 125 + await atproto.setupPasskey(); 126 + } catch (err) { 127 + this.$passkeyError.value = err instanceof Error 128 + ? err.message 129 + : "Passkey setup failed"; 130 + } finally { 131 + this.$passkeyWorking.value = false; 132 + } 133 + }; 134 + 135 + #handlePasskeyAdopt = async () => { 136 + const atproto = this.$atproto.value; 137 + if (!atproto) return; 138 + 139 + this.$passkeyError.value = null; 140 + this.$passkeyWorking.value = true; 141 + 142 + try { 143 + await atproto.adoptPasskey(); 144 + } catch (err) { 145 + this.$passkeyError.value = err instanceof Error 146 + ? err.message 147 + : "Passkey adoption failed"; 148 + } finally { 149 + this.$passkeyWorking.value = false; 150 + } 151 + }; 152 + 153 + #handlePasskeyRemove = async () => { 154 + const atproto = this.$atproto.value; 155 + if (!atproto) return; 156 + 157 + this.$passkeyError.value = null; 158 + await atproto.removePasskey(); 159 }; 160 161 /** @param {Event} event */ ··· 430 : undefined; 431 432 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 return html` 440 <fieldset> 441 <span class="with-icon with-icon--large"> ··· 443 <span>Signed in as <strong>${did}</strong></span> 444 </span> 445 </fieldset> 446 + 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} 540 541 <p class="button-row"> 542 <button @click="${this.#handleAtprotoLogout}">Sign out</button>