···11+import { xchacha20poly1305 } from "@noble/ciphers/chacha.js";
22+import { managedNonce } from "@noble/ciphers/utils.js";
33+44+import * as IDB from "idb-keyval";
55+import { base64url } from "iso-base/rfc4648";
66+import { utf8 } from "iso-base/utf8";
77+88+////////////////////////////////////////////
99+// CONSTANTS
1010+////////////////////////////////////////////
1111+1212+const IDB_KEY = "diffuse/output/raw/atproto/passkey";
1313+const IDB_KEY_CIPHER = "diffuse/output/raw/atproto/passkey/cipher-key";
1414+1515+////////////////////////////////////////////
1616+// RELYING PARTY
1717+////////////////////////////////////////////
1818+1919+/**
2020+ * @returns {{ name: string, id: string }}
2121+ */
2222+export function relyingParty() {
2323+ const id = document.location.hostname;
2424+ return { name: id, id };
2525+}
2626+2727+////////////////////////////////////////////
2828+// PASSKEY MANAGEMENT
2929+////////////////////////////////////////////
3030+3131+/**
3232+ * Register a new passkey with the PRF extension.
3333+ *
3434+ * @returns {Promise<{ supported: true, credentialId: Uint8Array } | { supported: false, reason: string }>}
3535+ */
3636+export async function createPasskey() {
3737+ const rp = relyingParty();
3838+ const challenge = crypto.getRandomValues(new Uint8Array(32));
3939+ const userId = crypto.getRandomValues(new Uint8Array(16));
4040+4141+ /** @type {PublicKeyCredential | null} */
4242+ let credential;
4343+4444+ try {
4545+ credential = /** @type {PublicKeyCredential} */ (
4646+ await navigator.credentials.create({
4747+ publicKey: {
4848+ challenge,
4949+ rp,
5050+ user: {
5151+ id: userId,
5252+ name: rp.id,
5353+ displayName: "Diffuse – " + rp.id,
5454+ },
5555+ pubKeyCredParams: [
5656+ { type: "public-key", alg: -7 },
5757+ { type: "public-key", alg: -257 },
5858+ ],
5959+ attestation: "none",
6060+ authenticatorSelection: {
6161+ userVerification: "required",
6262+ requireResidentKey: true,
6363+ residentKey: "required",
6464+ },
6565+ extensions: {
6666+ // @ts-ignore — PRF is not yet in the TS DOM types
6767+ prf: {
6868+ eval: {
6969+ // @ts-ignore
7070+ first: utf8.decode(rp.id + "signing"),
7171+ // @ts-ignore
7272+ second: utf8.decode(rp.id + "encryption"),
7373+ },
7474+ },
7575+ },
7676+ },
7777+ })
7878+ );
7979+ } catch (err) {
8080+ return {
8181+ supported: false,
8282+ reason: err instanceof Error ? err.message : String(err),
8383+ };
8484+ }
8585+8686+ if (!credential) {
8787+ return { supported: false, reason: "Credential creation returned null" };
8888+ }
8989+9090+ const extensions = credential.getClientExtensionResults();
9191+9292+ // @ts-ignore — PRF is not yet in the TS DOM types
9393+ if (extensions.prf?.enabled !== true) {
9494+ return {
9595+ supported: false,
9696+ reason: "This authenticator does not support the WebAuthn PRF extension",
9797+ };
9898+ }
9999+100100+ const credentialId = new Uint8Array(credential.rawId);
101101+ await IDB.set(IDB_KEY, { credentialId: Array.from(credentialId) });
102102+103103+ return { supported: true, credentialId };
104104+}
105105+106106+/**
107107+ * Authenticate with an existing passkey via discoverable-credential lookup
108108+ * (no `allowCredentials`), so it works on a new device that has no stored
109109+ * credential ID yet. Saves the credential ID to IDB and returns PRF material.
110110+ *
111111+ * @returns {Promise<{ supported: true, credentialId: Uint8Array, prfSecond: ArrayBuffer } | { supported: false, reason: string }>}
112112+ */
113113+export async function adoptPasskeyPrfResult() {
114114+ const rp = relyingParty();
115115+ const challenge = crypto.getRandomValues(new Uint8Array(32));
116116+117117+ /** @type {PublicKeyCredential | null} */
118118+ let assertion;
119119+120120+ try {
121121+ assertion = /** @type {PublicKeyCredential} */ (
122122+ await navigator.credentials.get({
123123+ publicKey: {
124124+ challenge,
125125+ rpId: rp.id,
126126+ userVerification: "required",
127127+ extensions: {
128128+ // @ts-ignore — PRF is not yet in the TS DOM types
129129+ prf: {
130130+ eval: {
131131+ // @ts-ignore
132132+ first: utf8.decode(rp.id + "signing"),
133133+ // @ts-ignore
134134+ second: utf8.decode(rp.id + "encryption"),
135135+ },
136136+ },
137137+ },
138138+ },
139139+ })
140140+ );
141141+ } catch (err) {
142142+ return {
143143+ supported: false,
144144+ reason: err instanceof Error ? err.message : String(err),
145145+ };
146146+ }
147147+148148+ if (!assertion) {
149149+ return { supported: false, reason: "Credential get returned null" };
150150+ }
151151+152152+ const extensions = assertion.getClientExtensionResults();
153153+154154+ // @ts-ignore — PRF is not yet in the TS DOM types
155155+ const prfSecond = extensions.prf?.results?.second;
156156+157157+ if (!prfSecond) {
158158+ return {
159159+ supported: false,
160160+ reason:
161161+ "This authenticator did not return PRF results — PRF extension may not be supported",
162162+ };
163163+ }
164164+165165+ const credentialId = new Uint8Array(assertion.rawId);
166166+ await IDB.set(IDB_KEY, { credentialId: Array.from(credentialId) });
167167+168168+ return {
169169+ supported: true,
170170+ credentialId,
171171+ prfSecond: /** @type {ArrayBuffer} */ (prfSecond),
172172+ };
173173+}
174174+175175+/**
176176+ * Remove the stored passkey credential ID and cached cipher key from IDB.
177177+ *
178178+ * @returns {Promise<void>}
179179+ */
180180+export async function removeStoredPasskey() {
181181+ await Promise.all([IDB.del(IDB_KEY), IDB.del(IDB_KEY_CIPHER)]);
182182+}
183183+184184+/**
185185+ * Persist the derived cipher key to IDB so it survives page reloads.
186186+ *
187187+ * @param {Uint8Array} key
188188+ * @returns {Promise<void>}
189189+ */
190190+export async function storeCipherKey(key) {
191191+ await IDB.set(IDB_KEY_CIPHER, key);
192192+}
193193+194194+/**
195195+ * Retrieve the previously persisted cipher key from IDB.
196196+ *
197197+ * @returns {Promise<Uint8Array | undefined>}
198198+ */
199199+export async function loadStoredCipherKey() {
200200+ return IDB.get(IDB_KEY_CIPHER);
201201+}
202202+203203+////////////////////////////////////////////
204204+// KEY DERIVATION
205205+////////////////////////////////////////////
206206+207207+/**
208208+ * Derive a 256-bit key from the PRF "second" output via HKDF.
209209+ * Returns raw bytes suitable for use with XChaCha20-Poly1305.
210210+ *
211211+ * @param {ArrayBuffer} prfSecond
212212+ * @returns {Promise<Uint8Array>}
213213+ */
214214+export async function deriveCipherKey(prfSecond) {
215215+ const keyMaterial = await crypto.subtle.importKey(
216216+ "raw",
217217+ prfSecond,
218218+ { name: "HKDF" },
219219+ false,
220220+ ["deriveBits"],
221221+ );
222222+223223+ const bits = await crypto.subtle.deriveBits(
224224+ {
225225+ name: "HKDF",
226226+ hash: "SHA-256",
227227+228228+ // @ts-ignore
229229+ salt: utf8.decode("diffuse-atproto-passkey-salt"),
230230+231231+ // @ts-ignore
232232+ info: utf8.decode("diffuse-atproto-track-uri"),
233233+ },
234234+ keyMaterial,
235235+ 256,
236236+ );
237237+238238+ return new Uint8Array(bits);
239239+}
240240+241241+////////////////////////////////////////////
242242+// ENCRYPT / DECRYPT
243243+////////////////////////////////////////////
244244+245245+/**
246246+ * Detect whether a URI is encrypted by this module.
247247+ *
248248+ * @param {string} uri
249249+ * @returns {boolean}
250250+ */
251251+export function isEncryptedUri(uri) {
252252+ return uri.startsWith("encrypted://");
253253+}
254254+255255+const xchacha = managedNonce(xchacha20poly1305);
256256+257257+/**
258258+ * Encrypt a plaintext URI with XChaCha20-Poly1305.
259259+ * Returns a string of the form: `encrypted://<base64url(nonce || ciphertext)>`
260260+ * The nonce is prepended automatically by `managedNonce`.
261261+ *
262262+ * @param {Uint8Array} key
263263+ * @param {string} plaintext
264264+ * @returns {string}
265265+ */
266266+export function encryptUri(key, plaintext) {
267267+ const ciphertext = xchacha(key).encrypt(utf8.decode(plaintext));
268268+ return "encrypted://" + base64url.encode(ciphertext);
269269+}
270270+271271+/**
272272+ * Decrypt an encrypted URI produced by `encryptUri`.
273273+ *
274274+ * @param {Uint8Array} key
275275+ * @param {string} encryptedUri
276276+ * @returns {string}
277277+ */
278278+export function decryptUri(key, encryptedUri) {
279279+ const ciphertext = base64url.decode(encryptedUri.slice(12));
280280+ return utf8.encode(xchacha(key).decrypt(ciphertext));
281281+}
+17
src/components/output/raw/atproto/types.d.ts
···11import type { SignalReader } from "@common/signal.d.ts";
22+import type { Track } from "@definitions/types.d.ts";
23import type { OutputElement } from "../../types.d.ts";
3445export type ATProtoOutputElement =
···67 & {
78 did: SignalReader<string | null>;
89 rev: SignalReader<string | null>;
1010+1111+ /** Track records with encrypted URIs that cannot be decrypted without the passkey. */
1212+ lockedTracks: SignalReader<Track[]>;
1313+1414+ /** True if passkey encryption is active for this session. */
1515+ passkeyActive: SignalReader<boolean>;
1616+917 getLatestCommit(): Promise<string | null>;
1018 login(handle: string): Promise<void>;
1119 logout(): Promise<void>;
2020+2121+ /** Adopt an existing passkey from another device via discoverable-credential lookup. */
2222+ adoptPasskey(): Promise<void>;
2323+2424+ /** Remove the stored passkey credential and clear the in-memory key. */
2525+ removePasskey(): Promise<void>;
2626+2727+ /** Register a new passkey for track URI encryption. Throws if PRF is not supported. */
2828+ setupPasskey(): Promise<void>;
1229 };