1234567891011121314151617181920212223242526272829303132import { argon2id, blake3 } from "hash-wasm";3334import { compressZstd, decompressZstd } from "./zstd";35import type {36 Canary,37 CardNetwork,38 ManualSummonRecord,39 PeachLicenseExport,40 VaultEntry,41} from "../shared/types";42import type { VaultSettings } from "../background/vault";434445464748export const MAGIC = 0x50434853; 49export const VERSION = 0x01;50export const HEADER_LEN = 8;51export const CHECKSUM_LEN = 4;5253export const SALT_LEN = 16;54export const NONCE_LEN = 12;55export const TAG_LEN = 16;5657const ARGON2_MEMORY_KIB = 65536; 58const ARGON2_ITERATIONS = 3;59const ARGON2_PARALLELISM = 4;6061const SECTION_ENTRIES = 0x01;62const SECTION_SETTINGS = 0x02;63const SECTION_LICENSE = 0x03;64const SECTION_RECALL = 0x04;65const SECTION_DECLINED_CARDS = 0x05;6667const ENTRY_TYPE_LOGIN = 0x01;68const ENTRY_TYPE_NOTE = 0x02;69const ENTRY_TYPE_CARD = 0x03;70const ENTRY_TYPE_PASSKEY = 0x04;717273const F_HAS_TOTP = 1 << 0;74const F_HAS_TAGS = 1 << 1;75const F_HAS_NOTES = 1 << 2;76const F_HAS_CANARY = 1 << 3;77const F_HAS_PWCHANGED = 1 << 4;78const F_HAS_BREACH = 1 << 5;79const F_HAS_ADDITIONAL_URLS = 1 << 6;80const F_HAS_DELETED = 1 << 7;8182const NETWORK_VALUES: CardNetwork[] = ["visa", "mastercard", "amex", "discover", "unknown"];838485868788export interface BuildPayloadInput {89 entries: VaultEntry[];90 settings: VaultSettings;91 license?: PeachLicenseExport;92 93 declinedCardFingerprints?: string[];94 95 manualSummonDomains?: Record<string, ManualSummonRecord>;96 passphrase: string;97}9899export interface ParsePayloadOutput {100 entries: VaultEntry[];101 settings: VaultSettings;102 license?: PeachLicenseExport;103 declinedCardFingerprints?: string[];104 manualSummonDomains?: Record<string, ManualSummonRecord>;105}106107108109110111112113export async function buildPayload(input: BuildPayloadInput): Promise<Uint8Array> {114 const sections: Uint8Array[] = [];115116 sections.push(await buildSection(SECTION_ENTRIES, encodeEntriesSection(input.entries)));117 sections.push(await buildSection(SECTION_SETTINGS, encodeJsonSection(input.settings)));118 if (input.license) {119 sections.push(await buildSection(SECTION_LICENSE, encodeJsonSection(input.license)));120 }121 if (input.manualSummonDomains && Object.keys(input.manualSummonDomains).length > 0) {122 sections.push(123 await buildSection(SECTION_RECALL, encodeJsonSection(input.manualSummonDomains))124 );125 }126 if (input.declinedCardFingerprints && input.declinedCardFingerprints.length > 0) {127 sections.push(128 await buildSection(129 SECTION_DECLINED_CARDS,130 encodeJsonSection(input.declinedCardFingerprints)131 )132 );133 }134135 const header = new Uint8Array(HEADER_LEN);136 const headerView = new DataView(header.buffer);137 headerView.setUint32(0, MAGIC, false); 138 header[4] = VERSION;139 header[5] = sections.length;140 141142 const body = concat([header, ...sections]);143 const checksum = await blake3Truncated(body);144 const inner = concat([body, checksum]);145146 const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));147 const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN));148 const key = await deriveKey(input.passphrase, salt);149 try {150 const { ciphertext, tag } = await aesGcmEncrypt(key, nonce, inner);151 return concat([salt, nonce, ciphertext, tag]);152 } finally {153 key.fill(0);154 }155}156157158159160161162163export async function parsePayload(164 envelope: Uint8Array,165 passphrase: string166): Promise<ParsePayloadOutput> {167 if (envelope.length < SALT_LEN + NONCE_LEN + TAG_LEN + HEADER_LEN + CHECKSUM_LEN) {168 throw new Error("PAYLOAD_TOO_SHORT");169 }170171 const salt = envelope.subarray(0, SALT_LEN);172 const nonce = envelope.subarray(SALT_LEN, SALT_LEN + NONCE_LEN);173 const tag = envelope.subarray(envelope.length - TAG_LEN);174 const ciphertext = envelope.subarray(SALT_LEN + NONCE_LEN, envelope.length - TAG_LEN);175176 const key = await deriveKey(passphrase, salt);177 let inner: Uint8Array;178 try {179 inner = await aesGcmDecrypt(key, nonce, ciphertext, tag);180 } catch {181 throw new Error("INVALID_PASSPHRASE");182 } finally {183 key.fill(0);184 }185186 if (inner.length < HEADER_LEN + CHECKSUM_LEN) throw new Error("PAYLOAD_TOO_SHORT");187188 const body = inner.subarray(0, inner.length - CHECKSUM_LEN);189 const stored = inner.subarray(inner.length - CHECKSUM_LEN);190 const computed = await blake3Truncated(body);191 if (!constantTimeEqual(stored, computed)) throw new Error("CHECKSUM_FAILED");192193 const view = new DataView(body.buffer, body.byteOffset, body.byteLength);194 if (view.getUint32(0, false) !== MAGIC) throw new Error("BAD_MAGIC");195 const version = body[4];196 if (version !== VERSION) throw new Error(`UNSUPPORTED_VERSION:${version}`);197 const sectionCount = body[5];198199 let cursor = HEADER_LEN;200 const out: ParsePayloadOutput = {201 entries: [],202 settings: {},203 };204 for (let i = 0; i < sectionCount; i++) {205 if (cursor + 9 > body.length) throw new Error("PAYLOAD_TOO_SHORT");206 const type = body[cursor];207 const length = view.getUint32(cursor + 1, true);208 const clength = view.getUint32(cursor + 5, true);209 const dataStart = cursor + 9;210 const dataEnd = dataStart + clength;211 if (dataEnd > body.length) throw new Error("PAYLOAD_TOO_SHORT");212 const compressed = body.subarray(dataStart, dataEnd);213 const data = await decompressZstd(compressed);214 if (data.length !== length) throw new Error("SECTION_LENGTH_MISMATCH");215216 switch (type) {217 case SECTION_ENTRIES:218 out.entries = decodeEntriesSection(data);219 break;220 case SECTION_SETTINGS:221 out.settings = decodeJsonSection<VaultSettings>(data);222 break;223 case SECTION_LICENSE:224 out.license = decodeJsonSection<PeachLicenseExport>(data);225 break;226 case SECTION_RECALL:227 out.manualSummonDomains = decodeJsonSection<Record<string, ManualSummonRecord>>(data);228 break;229 case SECTION_DECLINED_CARDS:230 out.declinedCardFingerprints = decodeJsonSection<string[]>(data);231 break;232 default:233 234 235 break;236 }237238 cursor = dataEnd;239 }240241 return out;242}243244245246247248async function buildSection(type: number, raw: Uint8Array): Promise<Uint8Array> {249 const compressed = await compressZstd(raw, 19);250 const out = new Uint8Array(9 + compressed.length);251 const view = new DataView(out.buffer);252 out[0] = type;253 view.setUint32(1, raw.length, true);254 view.setUint32(5, compressed.length, true);255 out.set(compressed, 9);256 return out;257}258259function encodeJsonSection(value: unknown): Uint8Array {260 return new TextEncoder().encode(JSON.stringify(value));261}262263function decodeJsonSection<T>(data: Uint8Array): T {264 return JSON.parse(new TextDecoder().decode(data)) as T;265}266267268269270271class StringTableBuilder {272 private map = new Map<string, number>();273 private list: string[] = [];274275 276 intern(value: string | undefined): number {277 if (!value) return 0;278 const existing = this.map.get(value);279 if (existing !== undefined) return existing;280 const idx = this.list.length + 1; 281 if (idx > 0xffff) throw new Error("STRING_TABLE_OVERFLOW");282 this.map.set(value, idx);283 this.list.push(value);284 return idx;285 }286287 serialize(): Uint8Array {288 const enc = new TextEncoder();289 const encoded = this.list.map((s) => enc.encode(s));290 let total = 2; 291 for (const e of encoded) total += 2 + e.length;292 const out = new Uint8Array(total);293 const view = new DataView(out.buffer);294 let cursor = 0;295 view.setUint16(cursor, encoded.length, true);296 cursor += 2;297 for (const e of encoded) {298 if (e.length > 0xffff) throw new Error("STRING_TOO_LONG");299 view.setUint16(cursor, e.length, true);300 cursor += 2;301 out.set(e, cursor);302 cursor += e.length;303 }304 return out;305 }306}307308function readStringTable(data: Uint8Array, cursor: number): { strings: string[]; cursor: number } {309 const view = new DataView(data.buffer, data.byteOffset, data.byteLength);310 const count = view.getUint16(cursor, true);311 cursor += 2;312 const strings: string[] = [];313 const dec = new TextDecoder();314 for (let i = 0; i < count; i++) {315 const len = view.getUint16(cursor, true);316 cursor += 2;317 strings.push(dec.decode(data.subarray(cursor, cursor + len)));318 cursor += len;319 }320 return { strings, cursor };321}322323function lookupString(strings: string[], idx: number): string | undefined {324 if (idx === 0) return undefined;325 return strings[idx - 1];326}327328function encodeEntriesSection(entries: VaultEntry[]): Uint8Array {329 const table = new StringTableBuilder();330 const bodies: Uint8Array[] = [];331332 333 for (const entry of entries) table.intern(entry.title);334335 for (const entry of entries) {336 bodies.push(encodeEntry(entry, table));337 }338339 const tableBytes = table.serialize();340341 342 let bodiesLen = 0;343 for (const b of bodies) bodiesLen += b.length;344 const out = new Uint8Array(4 + tableBytes.length + bodiesLen);345 const view = new DataView(out.buffer);346 view.setUint32(0, entries.length, true);347 out.set(tableBytes, 4);348 let cursor = 4 + tableBytes.length;349 for (const b of bodies) {350 out.set(b, cursor);351 cursor += b.length;352 }353 return out;354}355356function decodeEntriesSection(data: Uint8Array): VaultEntry[] {357 const view = new DataView(data.buffer, data.byteOffset, data.byteLength);358 const count = view.getUint32(0, true);359 const { strings, cursor: afterTable } = readStringTable(data, 4);360361 const out: VaultEntry[] = [];362 let cursor = afterTable;363 for (let i = 0; i < count; i++) {364 const { entry, cursor: next } = decodeEntry(data, cursor, strings);365 out.push(entry);366 cursor = next;367 }368 return out;369}370371372373374375function encodeEntry(entry: VaultEntry, table: StringTableBuilder): Uint8Array {376 const writer = new BinaryWriter();377 const type = entryTypeByte(entry);378 writer.u8(type);379 writer.bytes(uuidToBytes(entry.id));380381 382 const createdSec = Math.floor((entry.createdAt ?? Date.now()) / 1000);383 const updatedSec = Math.floor((entry.modifiedAt ?? entry.createdAt ?? Date.now()) / 1000);384 const updatedDelta = Math.max(0, updatedSec - createdSec);385 const deletedSec = entry.deletedAt ? Math.floor(entry.deletedAt / 1000) : 0;386387 388 const flagOffset = writer.length;389 writer.u8(0);390391 writer.u32(createdSec);392 writer.u32(updatedDelta);393 writer.u32(deletedSec);394 writer.u16(table.intern(entry.title));395396 let flags = 0;397 if (entry.deletedAt) flags |= F_HAS_DELETED;398399 if (type === ENTRY_TYPE_LOGIN) {400 writer.u16(table.intern(entry.url));401 writer.u16(table.intern(entry.username));402 writer.lpString(entry.password ?? "", 1);403404 if (entry.totp) {405 flags |= F_HAS_TOTP;406 writer.lpString(entry.totp, 1);407 }408 if (entry.tags && entry.tags.length > 0) {409 flags |= F_HAS_TAGS;410 writer.u8(Math.min(entry.tags.length, 0xff));411 for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {412 writer.u16(table.intern(entry.tags[i]));413 }414 }415 if (entry.notes) {416 flags |= F_HAS_NOTES;417 writer.u16(table.intern(entry.notes));418 }419 if (entry.canary) {420 flags |= F_HAS_CANARY;421 writer.u16(table.intern(entry.canary.webhookUrl));422 writer.u16(table.intern(entry.canary.label));423 }424 if (entry.passwordChangedAt) {425 flags |= F_HAS_PWCHANGED;426 const pwSec = Math.floor(entry.passwordChangedAt / 1000);427 writer.u32(Math.max(0, pwSec - createdSec));428 }429 if (typeof entry.breach === "number" && entry.breach > 0) {430 flags |= F_HAS_BREACH;431 writer.u32(entry.breach);432 }433 if (entry.additionalUrls && entry.additionalUrls.length > 0) {434 flags |= F_HAS_ADDITIONAL_URLS;435 writer.u8(Math.min(entry.additionalUrls.length, 0xff));436 for (let i = 0; i < Math.min(entry.additionalUrls.length, 0xff); i++) {437 writer.u16(table.intern(entry.additionalUrls[i]));438 }439 }440 } else if (type === ENTRY_TYPE_NOTE) {441 if (entry.notes) {442 flags |= F_HAS_NOTES;443 writer.u16(table.intern(entry.notes));444 }445 if (entry.tags && entry.tags.length > 0) {446 flags |= F_HAS_TAGS;447 writer.u8(Math.min(entry.tags.length, 0xff));448 for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {449 writer.u16(table.intern(entry.tags[i]));450 }451 }452 } else if (type === ENTRY_TYPE_CARD) {453 writer.u16(table.intern(entry.cardholderName));454 writer.lpString(entry.cardNumber ?? "", 1);455 writer.lpString(entry.expMonth ?? "", 1);456 writer.lpString(entry.expYear ?? "", 1);457 writer.lpString(entry.cvc ?? "", 1);458 writer.u16(table.intern(entry.cardNickname));459 writer.u8(networkToByte(entry.network));460 writer.u16(table.intern(entry.last4));461 if (entry.tags && entry.tags.length > 0) {462 flags |= F_HAS_TAGS;463 writer.u8(Math.min(entry.tags.length, 0xff));464 for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {465 writer.u16(table.intern(entry.tags[i]));466 }467 }468 if (entry.notes) {469 flags |= F_HAS_NOTES;470 writer.u16(table.intern(entry.notes));471 }472 } else if (type === ENTRY_TYPE_PASSKEY) {473 474 const blob = encodeJsonSection({475 rpId: entry.rpId,476 rpName: entry.rpName,477 credentialId: entry.credentialId,478 publicKeyCose: entry.publicKeyCose,479 privateKeyJwk: entry.privateKeyJwk,480 userHandle: entry.userHandle,481 userName: entry.userName,482 userDisplayName: entry.userDisplayName,483 algorithm: entry.algorithm,484 signCount: entry.signCount,485 aaguid: entry.aaguid,486 backupEligible: entry.backupEligible,487 backupState: entry.backupState,488 lastUsedAt: entry.lastUsedAt,489 });490 writer.u32(blob.length);491 writer.bytes(blob);492 }493494 writer.setU8(flagOffset, flags);495 return writer.toBytes();496}497498function decodeEntry(499 data: Uint8Array,500 start: number,501 strings: string[]502): { entry: VaultEntry; cursor: number } {503 const reader = new BinaryReader(data, start);504 const type = reader.u8();505 const id = bytesToUuid(reader.read(16));506 const flags = reader.u8();507 const createdSec = reader.u32();508 const updatedDelta = reader.u32();509 const deletedSec = reader.u32();510 const titleIdx = reader.u16();511512 const createdAt = createdSec * 1000;513 const modifiedAt = (createdSec + updatedDelta) * 1000;514 const deletedAt = deletedSec > 0 ? deletedSec * 1000 : undefined;515 const title = lookupString(strings, titleIdx) ?? "";516517 const baseEntry: Partial<VaultEntry> = {518 id,519 title,520 createdAt,521 modifiedAt,522 ...(deletedAt !== undefined ? { deletedAt } : {}),523 };524525 if (type === ENTRY_TYPE_LOGIN) {526 const urlIdx = reader.u16();527 const usernameIdx = reader.u16();528 const password = reader.lpString(1);529 const tail: Partial<VaultEntry> = {};530531 if (flags & F_HAS_TOTP) tail.totp = reader.lpString(1);532 if (flags & F_HAS_TAGS) {533 const cnt = reader.u8();534 const tags: string[] = [];535 for (let i = 0; i < cnt; i++) {536 const v = lookupString(strings, reader.u16());537 if (v !== undefined) tags.push(v);538 }539 tail.tags = tags;540 }541 if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());542 if (flags & F_HAS_CANARY) {543 const webhookUrl = lookupString(strings, reader.u16()) ?? "";544 const label = lookupString(strings, reader.u16());545 const canary: Canary = { webhookUrl };546 if (label !== undefined) canary.label = label;547 tail.canary = canary;548 }549 if (flags & F_HAS_PWCHANGED) {550 const pwDelta = reader.u32();551 tail.passwordChangedAt = (createdSec + pwDelta) * 1000;552 }553 if (flags & F_HAS_BREACH) tail.breach = reader.u32();554 if (flags & F_HAS_ADDITIONAL_URLS) {555 const cnt = reader.u8();556 const urls: string[] = [];557 for (let i = 0; i < cnt; i++) {558 const v = lookupString(strings, reader.u16());559 if (v !== undefined) urls.push(v);560 }561 tail.additionalUrls = urls;562 }563564 return {565 entry: {566 ...(baseEntry as VaultEntry),567 type: "login",568 url: lookupString(strings, urlIdx),569 username: lookupString(strings, usernameIdx),570 password,571 ...tail,572 } as VaultEntry,573 cursor: reader.cursor,574 };575 }576577 if (type === ENTRY_TYPE_NOTE) {578 const tail: Partial<VaultEntry> = {};579 if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());580 if (flags & F_HAS_TAGS) {581 const cnt = reader.u8();582 const tags: string[] = [];583 for (let i = 0; i < cnt; i++) {584 const v = lookupString(strings, reader.u16());585 if (v !== undefined) tags.push(v);586 }587 tail.tags = tags;588 }589 return {590 entry: { ...(baseEntry as VaultEntry), type: "note", ...tail } as VaultEntry,591 cursor: reader.cursor,592 };593 }594595 if (type === ENTRY_TYPE_CARD) {596 const cardholderIdx = reader.u16();597 const cardNumber = reader.lpString(1);598 const expMonth = reader.lpString(1);599 const expYear = reader.lpString(1);600 const cvc = reader.lpString(1);601 const nicknameIdx = reader.u16();602 const network = networkFromByte(reader.u8());603 const last4Idx = reader.u16();604 const tail: Partial<VaultEntry> = {};605 if (flags & F_HAS_TAGS) {606 const cnt = reader.u8();607 const tags: string[] = [];608 for (let i = 0; i < cnt; i++) {609 const v = lookupString(strings, reader.u16());610 if (v !== undefined) tags.push(v);611 }612 tail.tags = tags;613 }614 if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());615616 return {617 entry: {618 ...(baseEntry as VaultEntry),619 type: "card",620 cardholderName: lookupString(strings, cardholderIdx),621 cardNumber,622 expMonth,623 expYear,624 cvc,625 cardNickname: lookupString(strings, nicknameIdx),626 network,627 last4: lookupString(strings, last4Idx),628 ...tail,629 } as VaultEntry,630 cursor: reader.cursor,631 };632 }633634 if (type === ENTRY_TYPE_PASSKEY) {635 const blobLen = reader.u32();636 const blob = reader.read(blobLen);637 const obj = decodeJsonSection<Record<string, unknown>>(blob);638 return {639 entry: {640 ...(baseEntry as VaultEntry),641 type: "passkey",642 ...obj,643 } as VaultEntry,644 cursor: reader.cursor,645 };646 }647648 throw new Error(`UNKNOWN_ENTRY_TYPE:${type}`);649}650651function entryTypeByte(entry: VaultEntry): number {652 switch (entry.type) {653 case "note":654 return ENTRY_TYPE_NOTE;655 case "card":656 return ENTRY_TYPE_CARD;657 case "passkey":658 return ENTRY_TYPE_PASSKEY;659 default:660 return ENTRY_TYPE_LOGIN;661 }662}663664function networkToByte(network: CardNetwork | undefined): number {665 if (!network) return 0xff;666 const idx = NETWORK_VALUES.indexOf(network);667 return idx >= 0 ? idx : NETWORK_VALUES.indexOf("unknown");668}669670function networkFromByte(b: number): CardNetwork | undefined {671 if (b === 0xff) return undefined;672 return NETWORK_VALUES[b] ?? "unknown";673}674675676677678679export function uuidToBytes(uuid: string): Uint8Array {680 const hex = uuid.replace(/-/g, "");681 if (hex.length !== 32) throw new Error("UUID_FORMAT");682 const out = new Uint8Array(16);683 for (let i = 0; i < 16; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);684 return out;685}686687export function bytesToUuid(bytes: Uint8Array): string {688 if (bytes.length !== 16) throw new Error("UUID_LENGTH");689 const hex = Array.from(bytes)690 .map((b) => b.toString(16).padStart(2, "0"))691 .join("");692 return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;693}694695696697698699class BinaryWriter {700 private chunks: Uint8Array[] = [];701 private len = 0;702703 get length(): number {704 return this.len;705 }706707 u8(v: number): void {708 const b = new Uint8Array(1);709 b[0] = v & 0xff;710 this.push(b);711 }712713 setU8(offset: number, v: number): void {714 715 716 let cursor = 0;717 for (const c of this.chunks) {718 if (offset < cursor + c.length) {719 c[offset - cursor] = v & 0xff;720 return;721 }722 cursor += c.length;723 }724 throw new Error("setU8 out of range");725 }726727 u16(v: number): void {728 const b = new Uint8Array(2);729 new DataView(b.buffer).setUint16(0, v & 0xffff, true);730 this.push(b);731 }732733 u32(v: number): void {734 const b = new Uint8Array(4);735 new DataView(b.buffer).setUint32(0, v >>> 0, true);736 this.push(b);737 }738739 bytes(b: Uint8Array): void {740 this.push(b);741 }742743 744 lpString(value: string, lenSize: 1 | 2): void {745 const enc = new TextEncoder().encode(value);746 if (lenSize === 1) {747 if (enc.length > 0xff) throw new Error("STRING_TOO_LONG_U8");748 this.u8(enc.length);749 } else {750 if (enc.length > 0xffff) throw new Error("STRING_TOO_LONG_U16");751 this.u16(enc.length);752 }753 this.push(enc);754 }755756 toBytes(): Uint8Array {757 const out = new Uint8Array(this.len);758 let cursor = 0;759 for (const c of this.chunks) {760 out.set(c, cursor);761 cursor += c.length;762 }763 return out;764 }765766 private push(b: Uint8Array): void {767 this.chunks.push(b);768 this.len += b.length;769 }770}771772class BinaryReader {773 cursor: number;774 private view: DataView;775 constructor(private data: Uint8Array, start: number) {776 this.cursor = start;777 this.view = new DataView(data.buffer, data.byteOffset, data.byteLength);778 }779 u8(): number {780 return this.data[this.cursor++];781 }782 u16(): number {783 const v = this.view.getUint16(this.cursor, true);784 this.cursor += 2;785 return v;786 }787 u32(): number {788 const v = this.view.getUint32(this.cursor, true);789 this.cursor += 4;790 return v;791 }792 read(n: number): Uint8Array {793 const out = this.data.subarray(this.cursor, this.cursor + n);794 this.cursor += n;795 return out;796 }797 lpString(lenSize: 1 | 2): string {798 const len = lenSize === 1 ? this.u8() : this.u16();799 return new TextDecoder().decode(this.read(len));800 }801}802803804805806807async function deriveKey(passphrase: string, salt: Uint8Array): Promise<Uint8Array> {808 return argon2id({809 password: passphrase,810 salt,811 parallelism: ARGON2_PARALLELISM,812 iterations: ARGON2_ITERATIONS,813 memorySize: ARGON2_MEMORY_KIB,814 hashLength: 32,815 outputType: "binary",816 });817}818819async function aesGcmEncrypt(820 key: Uint8Array,821 nonce: Uint8Array,822 plaintext: Uint8Array823): Promise<{ ciphertext: Uint8Array; tag: Uint8Array }> {824 const cryptoKey = await crypto.subtle.importKey(825 "raw",826 key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,827 { name: "AES-GCM" },828 false,829 ["encrypt"]830 );831 const raw = await crypto.subtle.encrypt(832 { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, tagLength: 128 },833 cryptoKey,834 plaintext.buffer.slice(835 plaintext.byteOffset,836 plaintext.byteOffset + plaintext.byteLength837 ) as ArrayBuffer838 );839 const out = new Uint8Array(raw);840 return { ciphertext: out.slice(0, out.length - 16), tag: out.slice(out.length - 16) };841}842843async function aesGcmDecrypt(844 key: Uint8Array,845 nonce: Uint8Array,846 ciphertext: Uint8Array,847 tag: Uint8Array848): Promise<Uint8Array> {849 const cryptoKey = await crypto.subtle.importKey(850 "raw",851 key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,852 { name: "AES-GCM" },853 false,854 ["decrypt"]855 );856 const combined = concat([ciphertext, tag]);857 const raw = await crypto.subtle.decrypt(858 {859 name: "AES-GCM",860 iv: nonce.buffer.slice(861 nonce.byteOffset,862 nonce.byteOffset + nonce.byteLength863 ) as ArrayBuffer,864 tagLength: 128,865 },866 cryptoKey,867 combined.buffer.slice(868 combined.byteOffset,869 combined.byteOffset + combined.byteLength870 ) as ArrayBuffer871 );872 return new Uint8Array(raw);873}874875async function blake3Truncated(data: Uint8Array): Promise<Uint8Array> {876 const hex = await blake3(data);877 878 const out = new Uint8Array(CHECKSUM_LEN);879 for (let i = 0; i < CHECKSUM_LEN; i++) {880 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);881 }882 return out;883}884885function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {886 if (a.length !== b.length) return false;887 let diff = 0;888 for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];889 return diff === 0;890}891892function concat(parts: Uint8Array[]): Uint8Array {893 let total = 0;894 for (const p of parts) total += p.length;895 const out = new Uint8Array(total);896 let cursor = 0;897 for (const p of parts) {898 out.set(p, cursor);899 cursor += p.length;900 }901 return out;902}