···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
···1import type { SignalReader } from "@common/signal.d.ts";
02import type { OutputElement } from "../../types.d.ts";
34export type ATProtoOutputElement =
···6 & {
7 did: SignalReader<string | null>;
8 rev: SignalReader<string | null>;
00000009 getLatestCommit(): Promise<string | null>;
10 login(handle: string): Promise<void>;
11 logout(): Promise<void>;
00000000012 };
···1import type { SignalReader } from "@common/signal.d.ts";
2+import type { Track } from "@definitions/types.d.ts";
3import type { OutputElement } from "../../types.d.ts";
45export 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 };