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 475 const pk = entry.passkey;476 const flat = entry as unknown as Record<string, unknown>;477 const blob = encodeJsonSection({478 rpId: pk ? pk.rpId : flat["rpId"],479 rpName: pk ? pk.rpName : flat["rpName"],480 credentialId: pk ? pk.credentialId : flat["credentialId"],481 publicKeyCose: pk ? pk.publicKeyCose : flat["publicKeyCose"],482 privateKeyJwk: pk ? pk.privateKeyJwk : flat["privateKeyJwk"],483 userHandle: pk ? pk.userHandle : flat["userHandle"],484 userName: pk ? pk.userName : flat["userName"],485 userDisplayName: pk ? pk.userDisplayName : flat["userDisplayName"],486 algorithm: pk ? pk.algorithm : flat["algorithm"],487 signCount: pk ? pk.signCount : flat["signCount"],488 aaguid: pk ? pk.aaguid : flat["aaguid"],489 backupEligible: pk ? pk.backupEligible : flat["backupEligible"],490 backupState: pk ? pk.backupState : flat["backupState"],491 lastUsedAt: pk ? pk.lastUsedAt : flat["lastUsedAt"],492 493 ...(pk && entry.password !== undefined ? { password: entry.password } : {}),494 ...(pk && entry.url !== undefined ? { url: entry.url } : {}),495 ...(pk && entry.username !== undefined ? { username: entry.username } : {}),496 });497 writer.u32(blob.length);498 writer.bytes(blob);499 }500501 writer.setU8(flagOffset, flags);502 return writer.toBytes();503}504505function decodeEntry(506 data: Uint8Array,507 start: number,508 strings: string[]509): { entry: VaultEntry; cursor: number } {510 const reader = new BinaryReader(data, start);511 const type = reader.u8();512 const id = bytesToUuid(reader.read(16));513 const flags = reader.u8();514 const createdSec = reader.u32();515 const updatedDelta = reader.u32();516 const deletedSec = reader.u32();517 const titleIdx = reader.u16();518519 const createdAt = createdSec * 1000;520 const modifiedAt = (createdSec + updatedDelta) * 1000;521 const deletedAt = deletedSec > 0 ? deletedSec * 1000 : undefined;522 const title = lookupString(strings, titleIdx) ?? "";523524 const baseEntry: Partial<VaultEntry> = {525 id,526 title,527 createdAt,528 modifiedAt,529 ...(deletedAt !== undefined ? { deletedAt } : {}),530 };531532 if (type === ENTRY_TYPE_LOGIN) {533 const urlIdx = reader.u16();534 const usernameIdx = reader.u16();535 const password = reader.lpString(1);536 const tail: Partial<VaultEntry> = {};537538 if (flags & F_HAS_TOTP) tail.totp = reader.lpString(1);539 if (flags & F_HAS_TAGS) {540 const cnt = reader.u8();541 const tags: string[] = [];542 for (let i = 0; i < cnt; i++) {543 const v = lookupString(strings, reader.u16());544 if (v !== undefined) tags.push(v);545 }546 tail.tags = tags;547 }548 if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());549 if (flags & F_HAS_CANARY) {550 const webhookUrl = lookupString(strings, reader.u16()) ?? "";551 const label = lookupString(strings, reader.u16());552 const canary: Canary = { webhookUrl };553 if (label !== undefined) canary.label = label;554 tail.canary = canary;555 }556 if (flags & F_HAS_PWCHANGED) {557 const pwDelta = reader.u32();558 tail.passwordChangedAt = (createdSec + pwDelta) * 1000;559 }560 if (flags & F_HAS_BREACH) tail.breach = reader.u32();561 if (flags & F_HAS_ADDITIONAL_URLS) {562 const cnt = reader.u8();563 const urls: string[] = [];564 for (let i = 0; i < cnt; i++) {565 const v = lookupString(strings, reader.u16());566 if (v !== undefined) urls.push(v);567 }568 tail.additionalUrls = urls;569 }570571 return {572 entry: {573 ...(baseEntry as VaultEntry),574 type: "login",575 url: lookupString(strings, urlIdx),576 username: lookupString(strings, usernameIdx),577 password,578 ...tail,579 } as VaultEntry,580 cursor: reader.cursor,581 };582 }583584 if (type === ENTRY_TYPE_NOTE) {585 const tail: Partial<VaultEntry> = {};586 if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());587 if (flags & F_HAS_TAGS) {588 const cnt = reader.u8();589 const tags: string[] = [];590 for (let i = 0; i < cnt; i++) {591 const v = lookupString(strings, reader.u16());592 if (v !== undefined) tags.push(v);593 }594 tail.tags = tags;595 }596 return {597 entry: { ...(baseEntry as VaultEntry), type: "note", ...tail } as VaultEntry,598 cursor: reader.cursor,599 };600 }601602 if (type === ENTRY_TYPE_CARD) {603 const cardholderIdx = reader.u16();604 const cardNumber = reader.lpString(1);605 const expMonth = reader.lpString(1);606 const expYear = reader.lpString(1);607 const cvc = reader.lpString(1);608 const nicknameIdx = reader.u16();609 const network = networkFromByte(reader.u8());610 const last4Idx = reader.u16();611 const tail: Partial<VaultEntry> = {};612 if (flags & F_HAS_TAGS) {613 const cnt = reader.u8();614 const tags: string[] = [];615 for (let i = 0; i < cnt; i++) {616 const v = lookupString(strings, reader.u16());617 if (v !== undefined) tags.push(v);618 }619 tail.tags = tags;620 }621 if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());622623 return {624 entry: {625 ...(baseEntry as VaultEntry),626 type: "card",627 cardholderName: lookupString(strings, cardholderIdx),628 cardNumber,629 expMonth,630 expYear,631 cvc,632 cardNickname: lookupString(strings, nicknameIdx),633 network,634 last4: lookupString(strings, last4Idx),635 ...tail,636 } as VaultEntry,637 cursor: reader.cursor,638 };639 }640641 if (type === ENTRY_TYPE_PASSKEY) {642 const blobLen = reader.u32();643 const blob = reader.read(blobLen);644 const obj = decodeJsonSection<Record<string, unknown>>(blob);645 return {646 entry: {647 ...(baseEntry as VaultEntry),648 type: "passkey",649 ...obj,650 } as VaultEntry,651 cursor: reader.cursor,652 };653 }654655 throw new Error(`UNKNOWN_ENTRY_TYPE:${type}`);656}657658function entryTypeByte(entry: VaultEntry): number {659 660 661 662 if (entry.passkey) return ENTRY_TYPE_PASSKEY;663 switch (entry.type) {664 case "note":665 return ENTRY_TYPE_NOTE;666 case "card":667 return ENTRY_TYPE_CARD;668 case "passkey":669 return ENTRY_TYPE_PASSKEY;670 default:671 return ENTRY_TYPE_LOGIN;672 }673}674675function networkToByte(network: CardNetwork | undefined): number {676 if (!network) return 0xff;677 const idx = NETWORK_VALUES.indexOf(network);678 return idx >= 0 ? idx : NETWORK_VALUES.indexOf("unknown");679}680681function networkFromByte(b: number): CardNetwork | undefined {682 if (b === 0xff) return undefined;683 return NETWORK_VALUES[b] ?? "unknown";684}685686687688689690export function uuidToBytes(uuid: string): Uint8Array {691 const hex = uuid.replace(/-/g, "");692 if (hex.length !== 32) throw new Error("UUID_FORMAT");693 const out = new Uint8Array(16);694 for (let i = 0; i < 16; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);695 return out;696}697698export function bytesToUuid(bytes: Uint8Array): string {699 if (bytes.length !== 16) throw new Error("UUID_LENGTH");700 const hex = Array.from(bytes)701 .map((b) => b.toString(16).padStart(2, "0"))702 .join("");703 return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;704}705706707708709710class BinaryWriter {711 private chunks: Uint8Array[] = [];712 private len = 0;713714 get length(): number {715 return this.len;716 }717718 u8(v: number): void {719 const b = new Uint8Array(1);720 b[0] = v & 0xff;721 this.push(b);722 }723724 setU8(offset: number, v: number): void {725 726 727 let cursor = 0;728 for (const c of this.chunks) {729 if (offset < cursor + c.length) {730 c[offset - cursor] = v & 0xff;731 return;732 }733 cursor += c.length;734 }735 throw new Error("setU8 out of range");736 }737738 u16(v: number): void {739 const b = new Uint8Array(2);740 new DataView(b.buffer).setUint16(0, v & 0xffff, true);741 this.push(b);742 }743744 u32(v: number): void {745 const b = new Uint8Array(4);746 new DataView(b.buffer).setUint32(0, v >>> 0, true);747 this.push(b);748 }749750 bytes(b: Uint8Array): void {751 this.push(b);752 }753754 755 lpString(value: string, lenSize: 1 | 2): void {756 const enc = new TextEncoder().encode(value);757 if (lenSize === 1) {758 if (enc.length > 0xff) throw new Error("STRING_TOO_LONG_U8");759 this.u8(enc.length);760 } else {761 if (enc.length > 0xffff) throw new Error("STRING_TOO_LONG_U16");762 this.u16(enc.length);763 }764 this.push(enc);765 }766767 toBytes(): Uint8Array {768 const out = new Uint8Array(this.len);769 let cursor = 0;770 for (const c of this.chunks) {771 out.set(c, cursor);772 cursor += c.length;773 }774 return out;775 }776777 private push(b: Uint8Array): void {778 this.chunks.push(b);779 this.len += b.length;780 }781}782783class BinaryReader {784 cursor: number;785 private view: DataView;786 constructor(private data: Uint8Array, start: number) {787 this.cursor = start;788 this.view = new DataView(data.buffer, data.byteOffset, data.byteLength);789 }790 u8(): number {791 return this.data[this.cursor++];792 }793 u16(): number {794 const v = this.view.getUint16(this.cursor, true);795 this.cursor += 2;796 return v;797 }798 u32(): number {799 const v = this.view.getUint32(this.cursor, true);800 this.cursor += 4;801 return v;802 }803 read(n: number): Uint8Array {804 const out = this.data.subarray(this.cursor, this.cursor + n);805 this.cursor += n;806 return out;807 }808 lpString(lenSize: 1 | 2): string {809 const len = lenSize === 1 ? this.u8() : this.u16();810 return new TextDecoder().decode(this.read(len));811 }812}813814815816817818async function deriveKey(passphrase: string, salt: Uint8Array): Promise<Uint8Array> {819 return argon2id({820 password: passphrase,821 salt,822 parallelism: ARGON2_PARALLELISM,823 iterations: ARGON2_ITERATIONS,824 memorySize: ARGON2_MEMORY_KIB,825 hashLength: 32,826 outputType: "binary",827 });828}829830async function aesGcmEncrypt(831 key: Uint8Array,832 nonce: Uint8Array,833 plaintext: Uint8Array834): Promise<{ ciphertext: Uint8Array; tag: Uint8Array }> {835 const cryptoKey = await crypto.subtle.importKey(836 "raw",837 key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,838 { name: "AES-GCM" },839 false,840 ["encrypt"]841 );842 const raw = await crypto.subtle.encrypt(843 { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, tagLength: 128 },844 cryptoKey,845 plaintext.buffer.slice(846 plaintext.byteOffset,847 plaintext.byteOffset + plaintext.byteLength848 ) as ArrayBuffer849 );850 const out = new Uint8Array(raw);851 return { ciphertext: out.slice(0, out.length - 16), tag: out.slice(out.length - 16) };852}853854async function aesGcmDecrypt(855 key: Uint8Array,856 nonce: Uint8Array,857 ciphertext: Uint8Array,858 tag: Uint8Array859): Promise<Uint8Array> {860 const cryptoKey = await crypto.subtle.importKey(861 "raw",862 key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,863 { name: "AES-GCM" },864 false,865 ["decrypt"]866 );867 const combined = concat([ciphertext, tag]);868 const raw = await crypto.subtle.decrypt(869 {870 name: "AES-GCM",871 iv: nonce.buffer.slice(872 nonce.byteOffset,873 nonce.byteOffset + nonce.byteLength874 ) as ArrayBuffer,875 tagLength: 128,876 },877 cryptoKey,878 combined.buffer.slice(879 combined.byteOffset,880 combined.byteOffset + combined.byteLength881 ) as ArrayBuffer882 );883 return new Uint8Array(raw);884}885886async function blake3Truncated(data: Uint8Array): Promise<Uint8Array> {887 const hex = await blake3(data);888 889 const out = new Uint8Array(CHECKSUM_LEN);890 for (let i = 0; i < CHECKSUM_LEN; i++) {891 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);892 }893 return out;894}895896function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {897 if (a.length !== b.length) return false;898 let diff = 0;899 for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];900 return diff === 0;901}902903function concat(parts: Uint8Array[]): Uint8Array {904 let total = 0;905 for (const p of parts) total += p.length;906 const out = new Uint8Array(total);907 let cursor = 0;908 for (const p of parts) {909 out.set(p, cursor);910 cursor += p.length;911 }912 return out;913}