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;5657585960const SIZE_BUCKETS = [1024, 4096, 16384, 65536, 262144] as const;61626364function padToNearestBucket(data: Uint8Array): Uint8Array {65 const bucket = SIZE_BUCKETS.find((b) => b >= data.length);66 let targetSize: number;67 if (bucket !== undefined) {68 targetSize = bucket;69 } else {70 71 targetSize = 262144;72 while (targetSize < data.length) targetSize *= 2;73 }74 if (targetSize === data.length) return data;75 const padded = new Uint8Array(targetSize);76 padded.set(data);77 return padded;78}79808182const FORMAT_V2 = 0x02;8384const ARGON2_MEMORY_KIB = 65536; 85const ARGON2_ITERATIONS = 3;86const ARGON2_PARALLELISM = 4;8788const SECTION_ENTRIES = 0x01;89const SECTION_SETTINGS = 0x02;90const SECTION_LICENSE = 0x03;91const SECTION_RECALL = 0x04;92const SECTION_DECLINED_CARDS = 0x05;9394const ENTRY_TYPE_LOGIN = 0x01;95const ENTRY_TYPE_NOTE = 0x02;96const ENTRY_TYPE_CARD = 0x03;97const ENTRY_TYPE_PASSKEY = 0x04;9899100const F_HAS_TOTP = 1 << 0;101const F_HAS_TAGS = 1 << 1;102const F_HAS_NOTES = 1 << 2;103const F_HAS_CANARY = 1 << 3;104const F_HAS_PWCHANGED = 1 << 4;105const F_HAS_BREACH = 1 << 5;106const F_HAS_ADDITIONAL_URLS = 1 << 6;107const F_HAS_DELETED = 1 << 7;108109const NETWORK_VALUES: CardNetwork[] = ["visa", "mastercard", "amex", "discover", "unknown"];110111112113114115export interface BuildPayloadInput {116 entries: VaultEntry[];117 settings: VaultSettings;118 license?: PeachLicenseExport;119 120 declinedCardFingerprints?: string[];121 122 manualSummonDomains?: Record<string, ManualSummonRecord>;123 passphrase: string;124}125126export interface ParsePayloadOutput {127 entries: VaultEntry[];128 settings: VaultSettings;129 license?: PeachLicenseExport;130 declinedCardFingerprints?: string[];131 manualSummonDomains?: Record<string, ManualSummonRecord>;132}133134135136137138139140export async function buildPayload(input: BuildPayloadInput): Promise<Uint8Array> {141 const sections: Uint8Array[] = [];142143 sections.push(await buildSection(SECTION_ENTRIES, encodeEntriesSection(input.entries)));144 sections.push(await buildSection(SECTION_SETTINGS, encodeJsonSection(input.settings)));145 if (input.license) {146 sections.push(await buildSection(SECTION_LICENSE, encodeJsonSection(input.license)));147 }148 if (input.manualSummonDomains && Object.keys(input.manualSummonDomains).length > 0) {149 sections.push(150 await buildSection(SECTION_RECALL, encodeJsonSection(input.manualSummonDomains))151 );152 }153 if (input.declinedCardFingerprints && input.declinedCardFingerprints.length > 0) {154 sections.push(155 await buildSection(156 SECTION_DECLINED_CARDS,157 encodeJsonSection(input.declinedCardFingerprints)158 )159 );160 }161162 const header = new Uint8Array(HEADER_LEN);163 const headerView = new DataView(header.buffer);164 headerView.setUint32(0, MAGIC, false); 165 header[4] = VERSION;166 header[5] = sections.length;167 168169 const body = concat([header, ...sections]);170 const checksum = await blake3Truncated(body);171 const inner = concat([body, checksum]);172173 174 175 176 177 const header5 = new Uint8Array(5);178 header5[0] = FORMAT_V2;179 new DataView(header5.buffer).setUint32(1, inner.length, true);180 const wrapped = padToNearestBucket(concat([header5, inner]));181182 const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));183 const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN));184 const key = await deriveKey(input.passphrase, salt);185 try {186 const { ciphertext, tag } = await aesGcmEncrypt(key, nonce, wrapped);187 return concat([salt, nonce, ciphertext, tag]);188 } finally {189 key.fill(0);190 }191}192193194195196197198199export async function parsePayload(200 envelope: Uint8Array,201 passphrase: string202): Promise<ParsePayloadOutput> {203 if (envelope.length < SALT_LEN + NONCE_LEN + TAG_LEN + HEADER_LEN + CHECKSUM_LEN) {204 throw new Error("PAYLOAD_TOO_SHORT");205 }206207 const salt = envelope.subarray(0, SALT_LEN);208 const nonce = envelope.subarray(SALT_LEN, SALT_LEN + NONCE_LEN);209 const tag = envelope.subarray(envelope.length - TAG_LEN);210 const ciphertext = envelope.subarray(SALT_LEN + NONCE_LEN, envelope.length - TAG_LEN);211212 const key = await deriveKey(passphrase, salt);213 let decrypted: Uint8Array;214 try {215 decrypted = await aesGcmDecrypt(key, nonce, ciphertext, tag);216 } catch {217 throw new Error("INVALID_PASSPHRASE");218 } finally {219 key.fill(0);220 }221222 223 let inner: Uint8Array;224 if (decrypted.length >= 5 && decrypted[0] === FORMAT_V2) {225 const innerLen = new DataView(decrypted.buffer, decrypted.byteOffset, decrypted.byteLength).getUint32(1, true);226 if (5 + innerLen > decrypted.length) throw new Error("PAYLOAD_TOO_SHORT");227 inner = decrypted.subarray(5, 5 + innerLen);228 } else {229 inner = decrypted; 230 }231232 if (inner.length < HEADER_LEN + CHECKSUM_LEN) throw new Error("PAYLOAD_TOO_SHORT");233234 const body = inner.subarray(0, inner.length - CHECKSUM_LEN);235 const stored = inner.subarray(inner.length - CHECKSUM_LEN);236 const computed = await blake3Truncated(body);237 if (!constantTimeEqual(stored, computed)) throw new Error("CHECKSUM_FAILED");238239 const view = new DataView(body.buffer, body.byteOffset, body.byteLength);240 if (view.getUint32(0, false) !== MAGIC) throw new Error("BAD_MAGIC");241 const version = body[4];242 if (version !== VERSION) throw new Error(`UNSUPPORTED_VERSION:${version}`);243 const sectionCount = body[5];244245 let cursor = HEADER_LEN;246 const out: ParsePayloadOutput = {247 entries: [],248 settings: {},249 };250 for (let i = 0; i < sectionCount; i++) {251 if (cursor + 9 > body.length) throw new Error("PAYLOAD_TOO_SHORT");252 const type = body[cursor];253 const length = view.getUint32(cursor + 1, true);254 const clength = view.getUint32(cursor + 5, true);255 const dataStart = cursor + 9;256 const dataEnd = dataStart + clength;257 if (dataEnd > body.length) throw new Error("PAYLOAD_TOO_SHORT");258 const compressed = body.subarray(dataStart, dataEnd);259 const data = await decompressZstd(compressed);260 if (data.length !== length) throw new Error("SECTION_LENGTH_MISMATCH");261262 switch (type) {263 case SECTION_ENTRIES:264 out.entries = decodeEntriesSection(data);265 break;266 case SECTION_SETTINGS:267 out.settings = decodeJsonSection<VaultSettings>(data);268 break;269 case SECTION_LICENSE:270 out.license = decodeJsonSection<PeachLicenseExport>(data);271 break;272 case SECTION_RECALL:273 out.manualSummonDomains = decodeJsonSection<Record<string, ManualSummonRecord>>(data);274 break;275 case SECTION_DECLINED_CARDS:276 out.declinedCardFingerprints = decodeJsonSection<string[]>(data);277 break;278 default:279 280 281 break;282 }283284 cursor = dataEnd;285 }286287 return out;288}289290291292293294async function buildSection(type: number, raw: Uint8Array): Promise<Uint8Array> {295 const compressed = await compressZstd(raw, 19);296 const out = new Uint8Array(9 + compressed.length);297 const view = new DataView(out.buffer);298 out[0] = type;299 view.setUint32(1, raw.length, true);300 view.setUint32(5, compressed.length, true);301 out.set(compressed, 9);302 return out;303}304305function encodeJsonSection(value: unknown): Uint8Array {306 return new TextEncoder().encode(JSON.stringify(value));307}308309function decodeJsonSection<T>(data: Uint8Array): T {310 return JSON.parse(new TextDecoder().decode(data)) as T;311}312313314315316317class StringTableBuilder {318 private map = new Map<string, number>();319 private list: string[] = [];320321 322 intern(value: string | undefined): number {323 if (!value) return 0;324 const existing = this.map.get(value);325 if (existing !== undefined) return existing;326 const idx = this.list.length + 1; 327 if (idx > 0xffff) throw new Error("STRING_TABLE_OVERFLOW");328 this.map.set(value, idx);329 this.list.push(value);330 return idx;331 }332333 serialize(): Uint8Array {334 const enc = new TextEncoder();335 const encoded = this.list.map((s) => enc.encode(s));336 let total = 2; 337 for (const e of encoded) total += 2 + e.length;338 const out = new Uint8Array(total);339 const view = new DataView(out.buffer);340 let cursor = 0;341 view.setUint16(cursor, encoded.length, true);342 cursor += 2;343 for (const e of encoded) {344 if (e.length > 0xffff) throw new Error("STRING_TOO_LONG");345 view.setUint16(cursor, e.length, true);346 cursor += 2;347 out.set(e, cursor);348 cursor += e.length;349 }350 return out;351 }352}353354function readStringTable(data: Uint8Array, cursor: number): { strings: string[]; cursor: number } {355 const view = new DataView(data.buffer, data.byteOffset, data.byteLength);356 const count = view.getUint16(cursor, true);357 cursor += 2;358 const strings: string[] = [];359 const dec = new TextDecoder();360 for (let i = 0; i < count; i++) {361 const len = view.getUint16(cursor, true);362 cursor += 2;363 strings.push(dec.decode(data.subarray(cursor, cursor + len)));364 cursor += len;365 }366 return { strings, cursor };367}368369function lookupString(strings: string[], idx: number): string | undefined {370 if (idx === 0) return undefined;371 return strings[idx - 1];372}373374function encodeEntriesSection(entries: VaultEntry[]): Uint8Array {375 const table = new StringTableBuilder();376 const bodies: Uint8Array[] = [];377378 379 for (const entry of entries) table.intern(entry.title);380381 for (const entry of entries) {382 bodies.push(encodeEntry(entry, table));383 }384385 const tableBytes = table.serialize();386387 388 let bodiesLen = 0;389 for (const b of bodies) bodiesLen += b.length;390 const out = new Uint8Array(4 + tableBytes.length + bodiesLen);391 const view = new DataView(out.buffer);392 view.setUint32(0, entries.length, true);393 out.set(tableBytes, 4);394 let cursor = 4 + tableBytes.length;395 for (const b of bodies) {396 out.set(b, cursor);397 cursor += b.length;398 }399 return out;400}401402function decodeEntriesSection(data: Uint8Array): VaultEntry[] {403 const view = new DataView(data.buffer, data.byteOffset, data.byteLength);404 const count = view.getUint32(0, true);405 const { strings, cursor: afterTable } = readStringTable(data, 4);406407 const out: VaultEntry[] = [];408 let cursor = afterTable;409 for (let i = 0; i < count; i++) {410 const { entry, cursor: next } = decodeEntry(data, cursor, strings);411 out.push(entry);412 cursor = next;413 }414 return out;415}416417418419420421function encodeEntry(entry: VaultEntry, table: StringTableBuilder): Uint8Array {422 const writer = new BinaryWriter();423 const type = entryTypeByte(entry);424 writer.u8(type);425 writer.bytes(uuidToBytes(entry.id));426427 428 const createdSec = Math.floor((entry.createdAt ?? Date.now()) / 1000);429 const updatedSec = Math.floor((entry.modifiedAt ?? entry.createdAt ?? Date.now()) / 1000);430 const updatedDelta = Math.max(0, updatedSec - createdSec);431 const deletedSec = entry.deletedAt ? Math.floor(entry.deletedAt / 1000) : 0;432433 434 const flagOffset = writer.length;435 writer.u8(0);436437 writer.u32(createdSec);438 writer.u32(updatedDelta);439 writer.u32(deletedSec);440 writer.u16(table.intern(entry.title));441442 let flags = 0;443 if (entry.deletedAt) flags |= F_HAS_DELETED;444445 if (type === ENTRY_TYPE_LOGIN) {446 writer.u16(table.intern(entry.url));447 writer.u16(table.intern(entry.username));448 writer.lpString(entry.password ?? "", 1);449450 if (entry.totp) {451 flags |= F_HAS_TOTP;452 writer.lpString(entry.totp, 1);453 }454 if (entry.tags && entry.tags.length > 0) {455 flags |= F_HAS_TAGS;456 writer.u8(Math.min(entry.tags.length, 0xff));457 for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {458 writer.u16(table.intern(entry.tags[i]));459 }460 }461 if (entry.notes) {462 flags |= F_HAS_NOTES;463 writer.u16(table.intern(entry.notes));464 }465 if (entry.canary) {466 flags |= F_HAS_CANARY;467 writer.u16(table.intern(entry.canary.webhookUrl));468 writer.u16(table.intern(entry.canary.label));469 }470 if (entry.passwordChangedAt) {471 flags |= F_HAS_PWCHANGED;472 const pwSec = Math.floor(entry.passwordChangedAt / 1000);473 writer.u32(Math.max(0, pwSec - createdSec));474 }475 if (typeof entry.breach === "number" && entry.breach > 0) {476 flags |= F_HAS_BREACH;477 writer.u32(entry.breach);478 }479 if (entry.additionalUrls && entry.additionalUrls.length > 0) {480 flags |= F_HAS_ADDITIONAL_URLS;481 writer.u8(Math.min(entry.additionalUrls.length, 0xff));482 for (let i = 0; i < Math.min(entry.additionalUrls.length, 0xff); i++) {483 writer.u16(table.intern(entry.additionalUrls[i]));484 }485 }486 } else if (type === ENTRY_TYPE_NOTE) {487 if (entry.notes) {488 flags |= F_HAS_NOTES;489 writer.u16(table.intern(entry.notes));490 }491 if (entry.tags && entry.tags.length > 0) {492 flags |= F_HAS_TAGS;493 writer.u8(Math.min(entry.tags.length, 0xff));494 for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {495 writer.u16(table.intern(entry.tags[i]));496 }497 }498 } else if (type === ENTRY_TYPE_CARD) {499 writer.u16(table.intern(entry.cardholderName));500 writer.lpString(entry.cardNumber ?? "", 1);501 writer.lpString(entry.expMonth ?? "", 1);502 writer.lpString(entry.expYear ?? "", 1);503 writer.lpString(entry.cvc ?? "", 1);504 writer.u16(table.intern(entry.cardNickname));505 writer.u8(networkToByte(entry.network));506 writer.u16(table.intern(entry.last4));507 if (entry.tags && entry.tags.length > 0) {508 flags |= F_HAS_TAGS;509 writer.u8(Math.min(entry.tags.length, 0xff));510 for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {511 writer.u16(table.intern(entry.tags[i]));512 }513 }514 if (entry.notes) {515 flags |= F_HAS_NOTES;516 writer.u16(table.intern(entry.notes));517 }518 } else if (type === ENTRY_TYPE_PASSKEY) {519 520 521 const pk = entry.passkey;522 const flat = entry as unknown as Record<string, unknown>;523 const blob = encodeJsonSection({524 rpId: pk ? pk.rpId : flat["rpId"],525 rpName: pk ? pk.rpName : flat["rpName"],526 credentialId: pk ? pk.credentialId : flat["credentialId"],527 publicKeyCose: pk ? pk.publicKeyCose : flat["publicKeyCose"],528 privateKeyJwk: pk ? pk.privateKeyJwk : flat["privateKeyJwk"],529 userHandle: pk ? pk.userHandle : flat["userHandle"],530 userName: pk ? pk.userName : flat["userName"],531 userDisplayName: pk ? pk.userDisplayName : flat["userDisplayName"],532 algorithm: pk ? pk.algorithm : flat["algorithm"],533 signCount: pk ? pk.signCount : flat["signCount"],534 aaguid: pk ? pk.aaguid : flat["aaguid"],535 backupEligible: pk ? pk.backupEligible : flat["backupEligible"],536 backupState: pk ? pk.backupState : flat["backupState"],537 lastUsedAt: pk ? pk.lastUsedAt : flat["lastUsedAt"],538 539 ...(pk && entry.password !== undefined ? { password: entry.password } : {}),540 ...(pk && entry.url !== undefined ? { url: entry.url } : {}),541 ...(pk && entry.username !== undefined ? { username: entry.username } : {}),542 });543 writer.u32(blob.length);544 writer.bytes(blob);545 }546547 writer.setU8(flagOffset, flags);548 return writer.toBytes();549}550551function decodeEntry(552 data: Uint8Array,553 start: number,554 strings: string[]555): { entry: VaultEntry; cursor: number } {556 const reader = new BinaryReader(data, start);557 const type = reader.u8();558 const id = bytesToUuid(reader.read(16));559 const flags = reader.u8();560 const createdSec = reader.u32();561 const updatedDelta = reader.u32();562 const deletedSec = reader.u32();563 const titleIdx = reader.u16();564565 const createdAt = createdSec * 1000;566 const modifiedAt = (createdSec + updatedDelta) * 1000;567 const deletedAt = deletedSec > 0 ? deletedSec * 1000 : undefined;568 const title = lookupString(strings, titleIdx) ?? "";569570 const baseEntry: Partial<VaultEntry> = {571 id,572 title,573 createdAt,574 modifiedAt,575 ...(deletedAt !== undefined ? { deletedAt } : {}),576 };577578 if (type === ENTRY_TYPE_LOGIN) {579 const urlIdx = reader.u16();580 const usernameIdx = reader.u16();581 const password = reader.lpString(1);582 const tail: Partial<VaultEntry> = {};583584 if (flags & F_HAS_TOTP) tail.totp = reader.lpString(1);585 if (flags & F_HAS_TAGS) {586 const cnt = reader.u8();587 const tags: string[] = [];588 for (let i = 0; i < cnt; i++) {589 const v = lookupString(strings, reader.u16());590 if (v !== undefined) tags.push(v);591 }592 tail.tags = tags;593 }594 if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());595 if (flags & F_HAS_CANARY) {596 const webhookUrl = lookupString(strings, reader.u16()) ?? "";597 const label = lookupString(strings, reader.u16());598 const canary: Canary = { webhookUrl };599 if (label !== undefined) canary.label = label;600 tail.canary = canary;601 }602 if (flags & F_HAS_PWCHANGED) {603 const pwDelta = reader.u32();604 tail.passwordChangedAt = (createdSec + pwDelta) * 1000;605 }606 if (flags & F_HAS_BREACH) tail.breach = reader.u32();607 if (flags & F_HAS_ADDITIONAL_URLS) {608 const cnt = reader.u8();609 const urls: string[] = [];610 for (let i = 0; i < cnt; i++) {611 const v = lookupString(strings, reader.u16());612 if (v !== undefined) urls.push(v);613 }614 tail.additionalUrls = urls;615 }616617 return {618 entry: {619 ...(baseEntry as VaultEntry),620 type: "login",621 url: lookupString(strings, urlIdx),622 username: lookupString(strings, usernameIdx),623 password,624 ...tail,625 } as VaultEntry,626 cursor: reader.cursor,627 };628 }629630 if (type === ENTRY_TYPE_NOTE) {631 const tail: Partial<VaultEntry> = {};632 if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());633 if (flags & F_HAS_TAGS) {634 const cnt = reader.u8();635 const tags: string[] = [];636 for (let i = 0; i < cnt; i++) {637 const v = lookupString(strings, reader.u16());638 if (v !== undefined) tags.push(v);639 }640 tail.tags = tags;641 }642 return {643 entry: { ...(baseEntry as VaultEntry), type: "note", ...tail } as VaultEntry,644 cursor: reader.cursor,645 };646 }647648 if (type === ENTRY_TYPE_CARD) {649 const cardholderIdx = reader.u16();650 const cardNumber = reader.lpString(1);651 const expMonth = reader.lpString(1);652 const expYear = reader.lpString(1);653 const cvc = reader.lpString(1);654 const nicknameIdx = reader.u16();655 const network = networkFromByte(reader.u8());656 const last4Idx = reader.u16();657 const tail: Partial<VaultEntry> = {};658 if (flags & F_HAS_TAGS) {659 const cnt = reader.u8();660 const tags: string[] = [];661 for (let i = 0; i < cnt; i++) {662 const v = lookupString(strings, reader.u16());663 if (v !== undefined) tags.push(v);664 }665 tail.tags = tags;666 }667 if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());668669 return {670 entry: {671 ...(baseEntry as VaultEntry),672 type: "card",673 cardholderName: lookupString(strings, cardholderIdx),674 cardNumber,675 expMonth,676 expYear,677 cvc,678 cardNickname: lookupString(strings, nicknameIdx),679 network,680 last4: lookupString(strings, last4Idx),681 ...tail,682 } as VaultEntry,683 cursor: reader.cursor,684 };685 }686687 if (type === ENTRY_TYPE_PASSKEY) {688 const blobLen = reader.u32();689 const blob = reader.read(blobLen);690 const obj = decodeJsonSection<Record<string, unknown>>(blob);691 return {692 entry: {693 ...(baseEntry as VaultEntry),694 type: "passkey",695 ...obj,696 } as VaultEntry,697 cursor: reader.cursor,698 };699 }700701 throw new Error(`UNKNOWN_ENTRY_TYPE:${type}`);702}703704function entryTypeByte(entry: VaultEntry): number {705 706 707 708 if (entry.passkey) return ENTRY_TYPE_PASSKEY;709 switch (entry.type) {710 case "note":711 return ENTRY_TYPE_NOTE;712 case "card":713 return ENTRY_TYPE_CARD;714 case "passkey":715 return ENTRY_TYPE_PASSKEY;716 default:717 return ENTRY_TYPE_LOGIN;718 }719}720721function networkToByte(network: CardNetwork | undefined): number {722 if (!network) return 0xff;723 const idx = NETWORK_VALUES.indexOf(network);724 return idx >= 0 ? idx : NETWORK_VALUES.indexOf("unknown");725}726727function networkFromByte(b: number): CardNetwork | undefined {728 if (b === 0xff) return undefined;729 return NETWORK_VALUES[b] ?? "unknown";730}731732733734735736export function uuidToBytes(uuid: string): Uint8Array {737 const hex = uuid.replace(/-/g, "");738 if (hex.length !== 32) throw new Error("UUID_FORMAT");739 const out = new Uint8Array(16);740 for (let i = 0; i < 16; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);741 return out;742}743744export function bytesToUuid(bytes: Uint8Array): string {745 if (bytes.length !== 16) throw new Error("UUID_LENGTH");746 const hex = Array.from(bytes)747 .map((b) => b.toString(16).padStart(2, "0"))748 .join("");749 return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;750}751752753754755756class BinaryWriter {757 private chunks: Uint8Array[] = [];758 private len = 0;759760 get length(): number {761 return this.len;762 }763764 u8(v: number): void {765 const b = new Uint8Array(1);766 b[0] = v & 0xff;767 this.push(b);768 }769770 setU8(offset: number, v: number): void {771 772 773 let cursor = 0;774 for (const c of this.chunks) {775 if (offset < cursor + c.length) {776 c[offset - cursor] = v & 0xff;777 return;778 }779 cursor += c.length;780 }781 throw new Error("setU8 out of range");782 }783784 u16(v: number): void {785 const b = new Uint8Array(2);786 new DataView(b.buffer).setUint16(0, v & 0xffff, true);787 this.push(b);788 }789790 u32(v: number): void {791 const b = new Uint8Array(4);792 new DataView(b.buffer).setUint32(0, v >>> 0, true);793 this.push(b);794 }795796 bytes(b: Uint8Array): void {797 this.push(b);798 }799800 801 lpString(value: string, lenSize: 1 | 2): void {802 const enc = new TextEncoder().encode(value);803 if (lenSize === 1) {804 if (enc.length > 0xff) throw new Error("STRING_TOO_LONG_U8");805 this.u8(enc.length);806 } else {807 if (enc.length > 0xffff) throw new Error("STRING_TOO_LONG_U16");808 this.u16(enc.length);809 }810 this.push(enc);811 }812813 toBytes(): Uint8Array {814 const out = new Uint8Array(this.len);815 let cursor = 0;816 for (const c of this.chunks) {817 out.set(c, cursor);818 cursor += c.length;819 }820 return out;821 }822823 private push(b: Uint8Array): void {824 this.chunks.push(b);825 this.len += b.length;826 }827}828829class BinaryReader {830 cursor: number;831 private view: DataView;832 constructor(private data: Uint8Array, start: number) {833 this.cursor = start;834 this.view = new DataView(data.buffer, data.byteOffset, data.byteLength);835 }836 u8(): number {837 return this.data[this.cursor++];838 }839 u16(): number {840 const v = this.view.getUint16(this.cursor, true);841 this.cursor += 2;842 return v;843 }844 u32(): number {845 const v = this.view.getUint32(this.cursor, true);846 this.cursor += 4;847 return v;848 }849 read(n: number): Uint8Array {850 const out = this.data.subarray(this.cursor, this.cursor + n);851 this.cursor += n;852 return out;853 }854 lpString(lenSize: 1 | 2): string {855 const len = lenSize === 1 ? this.u8() : this.u16();856 return new TextDecoder().decode(this.read(len));857 }858}859860861862863864async function deriveKey(passphrase: string, salt: Uint8Array): Promise<Uint8Array> {865 return argon2id({866 password: passphrase,867 salt,868 parallelism: ARGON2_PARALLELISM,869 iterations: ARGON2_ITERATIONS,870 memorySize: ARGON2_MEMORY_KIB,871 hashLength: 32,872 outputType: "binary",873 });874}875876async function aesGcmEncrypt(877 key: Uint8Array,878 nonce: Uint8Array,879 plaintext: Uint8Array880): Promise<{ ciphertext: Uint8Array; tag: Uint8Array }> {881 const cryptoKey = await crypto.subtle.importKey(882 "raw",883 key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,884 { name: "AES-GCM" },885 false,886 ["encrypt"]887 );888 const raw = await crypto.subtle.encrypt(889 { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, tagLength: 128 },890 cryptoKey,891 plaintext.buffer.slice(892 plaintext.byteOffset,893 plaintext.byteOffset + plaintext.byteLength894 ) as ArrayBuffer895 );896 const out = new Uint8Array(raw);897 return { ciphertext: out.slice(0, out.length - 16), tag: out.slice(out.length - 16) };898}899900async function aesGcmDecrypt(901 key: Uint8Array,902 nonce: Uint8Array,903 ciphertext: Uint8Array,904 tag: Uint8Array905): Promise<Uint8Array> {906 const cryptoKey = await crypto.subtle.importKey(907 "raw",908 key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,909 { name: "AES-GCM" },910 false,911 ["decrypt"]912 );913 const combined = concat([ciphertext, tag]);914 const raw = await crypto.subtle.decrypt(915 {916 name: "AES-GCM",917 iv: nonce.buffer.slice(918 nonce.byteOffset,919 nonce.byteOffset + nonce.byteLength920 ) as ArrayBuffer,921 tagLength: 128,922 },923 cryptoKey,924 combined.buffer.slice(925 combined.byteOffset,926 combined.byteOffset + combined.byteLength927 ) as ArrayBuffer928 );929 return new Uint8Array(raw);930}931932async function blake3Truncated(data: Uint8Array): Promise<Uint8Array> {933 const hex = await blake3(data);934 935 const out = new Uint8Array(CHECKSUM_LEN);936 for (let i = 0; i < CHECKSUM_LEN; i++) {937 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);938 }939 return out;940}941942function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {943 if (a.length !== b.length) return false;944 let diff = 0;945 for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];946 return diff === 0;947}948949function concat(parts: Uint8Array[]): Uint8Array {950 let total = 0;951 for (const p of parts) total += p.length;952 const out = new Uint8Array(total);953 let cursor = 0;954 for (const p of parts) {955 out.set(p, cursor);956 cursor += p.length;957 }958 return out;959}