forked from
tokono.ma/diffuse
A music player that connects to your cloud/distributed storage.
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);