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;83848586const FORMAT_V3 = 0x03;8788const ARGON2_MEMORY_KIB = 65536; 89const ARGON2_ITERATIONS = 3;90const ARGON2_PARALLELISM = 4;9192const SECTION_ENTRIES = 0x01;93const SECTION_SETTINGS = 0x02;94const SECTION_LICENSE = 0x03;95const SECTION_RECALL = 0x04;96const SECTION_DECLINED_CARDS = 0x05;9798const ENTRY_TYPE_LOGIN = 0x01;99const ENTRY_TYPE_NOTE = 0x02;100const ENTRY_TYPE_CARD = 0x03;101const ENTRY_TYPE_PASSKEY = 0x04;102103104const F_HAS_TOTP = 1 << 0;105const F_HAS_TAGS = 1 << 1;106const F_HAS_NOTES = 1 << 2;107const F_HAS_CANARY = 1 << 3;108const F_HAS_PWCHANGED = 1 << 4;109const F_HAS_BREACH = 1 << 5;110const F_HAS_ADDITIONAL_URLS = 1 << 6;111const F_HAS_DELETED = 1 << 7;112113114115116117118119120121122const F2_HAS_EMAIL = 1 << 0;123124const NETWORK_VALUES: CardNetwork[] = ["visa", "mastercard", "amex", "discover", "unknown"];125126127128129130export interface BuildPayloadInput {131 entries: VaultEntry[];132 settings: VaultSettings;133 license?: PeachLicenseExport;134 135 declinedCardFingerprints?: string[];136 137 manualSummonDomains?: Record<string, ManualSummonRecord>;138 passphrase: string;139}140141export interface ParsePayloadOutput {142 entries: VaultEntry[];143 settings: VaultSettings;144 license?: PeachLicenseExport;145 declinedCardFingerprints?: string[];146 manualSummonDomains?: Record<string, ManualSummonRecord>;147}148149150151152153154155export async function buildPayload(input: BuildPayloadInput): Promise<Uint8Array> {156 const sections: Uint8Array[] = [];157158 sections.push(await buildSection(SECTION_ENTRIES, encodeEntriesSection(input.entries, FORMAT_V3)));159 sections.push(await buildSection(SECTION_SETTINGS, encodeJsonSection(input.settings)));160 if (input.license) {161 sections.push(await buildSection(SECTION_LICENSE, encodeJsonSection(input.license)));162 }163 if (input.manualSummonDomains && Object.keys(input.manualSummonDomains).length > 0) {164 sections.push(165 await buildSection(SECTION_RECALL, encodeJsonSection(input.manualSummonDomains))166 );167 }168 if (input.declinedCardFingerprints && input.declinedCardFingerprints.length > 0) {169 sections.push(170 await buildSection(171 SECTION_DECLINED_CARDS,172 encodeJsonSection(input.declinedCardFingerprints)173 )174 );175 }176177 const header = new Uint8Array(HEADER_LEN);178 const headerView = new DataView(header.buffer);179 headerView.setUint32(0, MAGIC, false); 180 header[4] = VERSION;181 header[5] = sections.length;182 183184 const body = concat([header, ...sections]);185 const checksum = await blake3Truncated(body);186 const inner = concat([body, checksum]);187188 189 190 191 192 193 const header5 = new Uint8Array(5);194 header5[0] = FORMAT_V3;195 new DataView(header5.buffer).setUint32(1, inner.length, true);196 const wrapped = padToNearestBucket(concat([header5, inner]));197198 const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));199 const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN));200 const key = await deriveKey(input.passphrase, salt);201 try {202 const { ciphertext, tag } = await aesGcmEncrypt(key, nonce, wrapped);203 return concat([salt, nonce, ciphertext, tag]);204 } finally {205 key.fill(0);206 }207}208209210211212213214215export async function parsePayload(216 envelope: Uint8Array,217 passphrase: string218): Promise<ParsePayloadOutput> {219 if (envelope.length < SALT_LEN + NONCE_LEN + TAG_LEN + HEADER_LEN + CHECKSUM_LEN) {220 throw new Error("PAYLOAD_TOO_SHORT");221 }222223 const salt = envelope.subarray(0, SALT_LEN);224 const nonce = envelope.subarray(SALT_LEN, SALT_LEN + NONCE_LEN);225 const tag = envelope.subarray(envelope.length - TAG_LEN);226 const ciphertext = envelope.subarray(SALT_LEN + NONCE_LEN, envelope.length - TAG_LEN);227228 const key = await deriveKey(passphrase, salt);229 let decrypted: Uint8Array;230 try {231 decrypted = await aesGcmDecrypt(key, nonce, ciphertext, tag);232 } catch {233 throw new Error("INVALID_PASSPHRASE");234 } finally {235 key.fill(0);236 }237238 239 240 let inner: Uint8Array;241 let formatVersion: number = 1;242 if (243 decrypted.length >= 5 &&244 (decrypted[0] === FORMAT_V2 || decrypted[0] === FORMAT_V3)245 ) {246 formatVersion = decrypted[0];247 const innerLen = new DataView(decrypted.buffer, decrypted.byteOffset, decrypted.byteLength).getUint32(1, true);248 if (5 + innerLen > decrypted.length) throw new Error("PAYLOAD_TOO_SHORT");249 inner = decrypted.subarray(5, 5 + innerLen);250 } else {251 inner = decrypted; 252 }253254 if (inner.length < HEADER_LEN + CHECKSUM_LEN) throw new Error("PAYLOAD_TOO_SHORT");255256 const body = inner.subarray(0, inner.length - CHECKSUM_LEN);257 const stored = inner.subarray(inner.length - CHECKSUM_LEN);258 const computed = await blake3Truncated(body);259 if (!constantTimeEqual(stored, computed)) throw new Error("CHECKSUM_FAILED");260261 const view = new DataView(body.buffer, body.byteOffset, body.byteLength);262 if (view.getUint32(0, false) !== MAGIC) throw new Error("BAD_MAGIC");263 const version = body[4];264 if (version !== VERSION) throw new Error(`UNSUPPORTED_VERSION:${version}`);265 const sectionCount = body[5];266267 let cursor = HEADER_LEN;268 const out: ParsePayloadOutput = {269 entries: [],270 settings: {},271 };272 for (let i = 0; i < sectionCount; i++) {273 if (cursor + 9 > body.length) throw new Error("PAYLOAD_TOO_SHORT");274 const type = body[cursor];275 const length = view.getUint32(cursor + 1, true);276 const clength = view.getUint32(cursor + 5, true);277 const dataStart = cursor + 9;278 const dataEnd = dataStart + clength;279 if (dataEnd > body.length) throw new Error("PAYLOAD_TOO_SHORT");280 const compressed = body.subarray(dataStart, dataEnd);281 const data = await decompressZstd(compressed);282 if (data.length !== length) throw new Error("SECTION_LENGTH_MISMATCH");283284 switch (type) {285 case SECTION_ENTRIES:286 out.entries = decodeEntriesSection(data, formatVersion);287 break;288 case SECTION_SETTINGS:289 out.settings = decodeJsonSection<VaultSettings>(data);290 break;291 case SECTION_LICENSE:292 out.license = decodeJsonSection<PeachLicenseExport>(data);293 break;294 case SECTION_RECALL:295 out.manualSummonDomains = decodeJsonSection<Record<string, ManualSummonRecord>>(data);296 break;297 case SECTION_DECLINED_CARDS:298 out.declinedCardFingerprints = decodeJsonSection<string[]>(data);299 break;300 default:301 302 303 break;304 }305306 cursor = dataEnd;307 }308309 return out;310}311312313314315316async function buildSection(type: number, raw: Uint8Array): Promise<Uint8Array> {317 const compressed = await compressZstd(raw, 19);318 const out = new Uint8Array(9 + compressed.length);319 const view = new DataView(out.buffer);320 out[0] = type;321 view.setUint32(1, raw.length, true);322 view.setUint32(5, compressed.length, true);323 out.set(compressed, 9);324 return out;325}326327function encodeJsonSection(value: unknown): Uint8Array {328 return new TextEncoder().encode(JSON.stringify(value));329}330331function decodeJsonSection<T>(data: Uint8Array): T {332 return JSON.parse(new TextDecoder().decode(data)) as T;333}334335336337338339class StringTableBuilder {340 private map = new Map<string, number>();341 private list: string[] = [];342343 344 intern(value: string | undefined): number {345 if (!value) return 0;346 const existing = this.map.get(value);347 if (existing !== undefined) return existing;348 const idx = this.list.length + 1; 349 if (idx > 0xffff) throw new Error("STRING_TABLE_OVERFLOW");350 this.map.set(value, idx);351 this.list.push(value);352 return idx;353 }354355 serialize(): Uint8Array {356 const enc = new TextEncoder();357 const encoded = this.list.map((s) => enc.encode(s));358 let total = 2; 359 for (const e of encoded) total += 2 + e.length;360 const out = new Uint8Array(total);361 const view = new DataView(out.buffer);362 let cursor = 0;363 view.setUint16(cursor, encoded.length, true);364 cursor += 2;365 for (const e of encoded) {366 if (e.length > 0xffff) throw new Error("STRING_TOO_LONG");367 view.setUint16(cursor, e.length, true);368 cursor += 2;369 out.set(e, cursor);370 cursor += e.length;371 }372 return out;373 }374}375376function readStringTable(data: Uint8Array, cursor: number): { strings: string[]; cursor: number } {377 const view = new DataView(data.buffer, data.byteOffset, data.byteLength);378 const count = view.getUint16(cursor, true);379 cursor += 2;380 const strings: string[] = [];381 const dec = new TextDecoder();382 for (let i = 0; i < count; i++) {383 const len = view.getUint16(cursor, true);384 cursor += 2;385 strings.push(dec.decode(data.subarray(cursor, cursor + len)));386 cursor += len;387 }388 return { strings, cursor };389}390391function lookupString(strings: string[], idx: number): string | undefined {392 if (idx === 0) return undefined;393 return strings[idx - 1];394}395396function encodeEntriesSection(entries: VaultEntry[], formatVersion: number): Uint8Array {397 const table = new StringTableBuilder();398 const bodies: Uint8Array[] = [];399400 401 for (const entry of entries) table.intern(entry.title);402403 for (const entry of entries) {404 bodies.push(encodeEntry(entry, table, formatVersion));405 }406407 const tableBytes = table.serialize();408409 410 let bodiesLen = 0;411 for (const b of bodies) bodiesLen += b.length;412 const out = new Uint8Array(4 + tableBytes.length + bodiesLen);413 const view = new DataView(out.buffer);414 view.setUint32(0, entries.length, true);415 out.set(tableBytes, 4);416 let cursor = 4 + tableBytes.length;417 for (const b of bodies) {418 out.set(b, cursor);419 cursor += b.length;420 }421 return out;422}423424function decodeEntriesSection(data: Uint8Array, formatVersion: number): VaultEntry[] {425 const view = new DataView(data.buffer, data.byteOffset, data.byteLength);426 const count = view.getUint32(0, true);427 const { strings, cursor: afterTable } = readStringTable(data, 4);428429 const out: VaultEntry[] = [];430 let cursor = afterTable;431 for (let i = 0; i < count; i++) {432 const { entry, cursor: next } = decodeEntry(data, cursor, strings, formatVersion);433 out.push(entry);434 cursor = next;435 }436 return out;437}438439440441442443function encodeEntry(entry: VaultEntry, table: StringTableBuilder, formatVersion: number): Uint8Array {444 const writer = new BinaryWriter();445 const type = entryTypeByte(entry);446 writer.u8(type);447 writer.bytes(uuidToBytes(entry.id));448449 450 const createdSec = Math.floor((entry.createdAt ?? Date.now()) / 1000);451 const updatedSec = Math.floor((entry.modifiedAt ?? entry.createdAt ?? Date.now()) / 1000);452 const updatedDelta = Math.max(0, updatedSec - createdSec);453 const deletedSec = entry.deletedAt ? Math.floor(entry.deletedAt / 1000) : 0;454455 456 const flagOffset = writer.length;457 writer.u8(0);458 459 460 let flags2Offset = -1;461 if (formatVersion === FORMAT_V3) {462 flags2Offset = writer.length;463 writer.u8(0);464 }465466 writer.u32(createdSec);467 writer.u32(updatedDelta);468 writer.u32(deletedSec);469 writer.u16(table.intern(entry.title));470471 let flags = 0;472 let flags2 = 0;473 if (entry.deletedAt) flags |= F_HAS_DELETED;474475 if (type === ENTRY_TYPE_LOGIN) {476 writer.u16(table.intern(entry.url));477 writer.u16(table.intern(entry.username));478 writer.lpString(entry.password ?? "", 1);479 if (formatVersion === FORMAT_V3 && entry.email) {480 flags2 |= F2_HAS_EMAIL;481 writer.lpString(entry.email, 1);482 }483484 if (entry.totp) {485 flags |= F_HAS_TOTP;486 writer.lpString(entry.totp, 1);487 }488 if (entry.tags && entry.tags.length > 0) {489 flags |= F_HAS_TAGS;490 writer.u8(Math.min(entry.tags.length, 0xff));491 for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {492 writer.u16(table.intern(entry.tags[i]));493 }494 }495 if (entry.notes) {496 flags |= F_HAS_NOTES;497 writer.u16(table.intern(entry.notes));498 }499 if (entry.canary) {500 flags |= F_HAS_CANARY;501 writer.u16(table.intern(entry.canary.webhookUrl));502 writer.u16(table.intern(entry.canary.label));503 }504 if (entry.passwordChangedAt) {505 flags |= F_HAS_PWCHANGED;506 const pwSec = Math.floor(entry.passwordChangedAt / 1000);507 writer.u32(Math.max(0, pwSec - createdSec));508 }509 if (typeof entry.breach === "number" && entry.breach > 0) {510 flags |= F_HAS_BREACH;511 writer.u32(entry.breach);512 }513 if (entry.additionalUrls && entry.additionalUrls.length > 0) {514 flags |= F_HAS_ADDITIONAL_URLS;515 writer.u8(Math.min(entry.additionalUrls.length, 0xff));516 for (let i = 0; i < Math.min(entry.additionalUrls.length, 0xff); i++) {517 writer.u16(table.intern(entry.additionalUrls[i]));518 }519 }520 } else if (type === ENTRY_TYPE_NOTE) {521 if (entry.notes) {522 flags |= F_HAS_NOTES;523 writer.u16(table.intern(entry.notes));524 }525 if (entry.tags && entry.tags.length > 0) {526 flags |= F_HAS_TAGS;527 writer.u8(Math.min(entry.tags.length, 0xff));528 for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {529 writer.u16(table.intern(entry.tags[i]));530 }531 }532 } else if (type === ENTRY_TYPE_CARD) {533 writer.u16(table.intern(entry.cardholderName));534 writer.lpString(entry.cardNumber ?? "", 1);535 writer.lpString(entry.expMonth ?? "", 1);536 writer.lpString(entry.expYear ?? "", 1);537 writer.lpString(entry.cvc ?? "", 1);538 writer.u16(table.intern(entry.cardNickname));539 writer.u8(networkToByte(entry.network));540 writer.u16(table.intern(entry.last4));541 if (entry.tags && entry.tags.length > 0) {542 flags |= F_HAS_TAGS;543 writer.u8(Math.min(entry.tags.length, 0xff));544 for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {545 writer.u16(table.intern(entry.tags[i]));546 }547 }548 if (entry.notes) {549 flags |= F_HAS_NOTES;550 writer.u16(table.intern(entry.notes));551 }552 } else if (type === ENTRY_TYPE_PASSKEY) {553 554 555 const pk = entry.passkey;556 const flat = entry as unknown as Record<string, unknown>;557 const blob = encodeJsonSection({558 rpId: pk ? pk.rpId : flat["rpId"],559 rpName: pk ? pk.rpName : flat["rpName"],560 credentialId: pk ? pk.credentialId : flat["credentialId"],561 publicKeyCose: pk ? pk.publicKeyCose : flat["publicKeyCose"],562 privateKeyJwk: pk ? pk.privateKeyJwk : flat["privateKeyJwk"],563 userHandle: pk ? pk.userHandle : flat["userHandle"],564 userName: pk ? pk.userName : flat["userName"],565 userDisplayName: pk ? pk.userDisplayName : flat["userDisplayName"],566 algorithm: pk ? pk.algorithm : flat["algorithm"],567 signCount: pk ? pk.signCount : flat["signCount"],568 aaguid: pk ? pk.aaguid : flat["aaguid"],569 backupEligible: pk ? pk.backupEligible : flat["backupEligible"],570 backupState: pk ? pk.backupState : flat["backupState"],571 lastUsedAt: pk ? pk.lastUsedAt : flat["lastUsedAt"],572 573 ...(pk && entry.password !== undefined ? { password: entry.password } : {}),574 ...(pk && entry.url !== undefined ? { url: entry.url } : {}),575 ...(pk && entry.username !== undefined ? { username: entry.username } : {}),576 });577 writer.u32(blob.length);578 writer.bytes(blob);579 }580581 writer.setU8(flagOffset, flags);582 if (flags2Offset >= 0) writer.setU8(flags2Offset, flags2);583 return writer.toBytes();584}585586function decodeEntry(587 data: Uint8Array,588 start: number,589 strings: string[],590 formatVersion: number591): { entry: VaultEntry; cursor: number } {592 const reader = new BinaryReader(data, start);593 const type = reader.u8();594 const id = bytesToUuid(reader.read(16));595 const flags = reader.u8();596 const flags2 = formatVersion === FORMAT_V3 ? reader.u8() : 0;597 const createdSec = reader.u32();598 const updatedDelta = reader.u32();599 const deletedSec = reader.u32();600 const titleIdx = reader.u16();601602 const createdAt = createdSec * 1000;603 const modifiedAt = (createdSec + updatedDelta) * 1000;604 const deletedAt = deletedSec > 0 ? deletedSec * 1000 : undefined;605 const title = lookupString(strings, titleIdx) ?? "";606607 const baseEntry: Partial<VaultEntry> = {608 id,609 title,610 createdAt,611 modifiedAt,612 ...(deletedAt !== undefined ? { deletedAt } : {}),613 };614615 if (type === ENTRY_TYPE_LOGIN) {616 const urlIdx = reader.u16();617 const usernameIdx = reader.u16();618 const password = reader.lpString(1);619 const tail: Partial<VaultEntry> = {};620621 if (flags2 & F2_HAS_EMAIL) tail.email = reader.lpString(1);622623 if (flags & F_HAS_TOTP) tail.totp = reader.lpString(1);624 if (flags & F_HAS_TAGS) {625 const cnt = reader.u8();626 const tags: string[] = [];627 for (let i = 0; i < cnt; i++) {628 const v = lookupString(strings, reader.u16());629 if (v !== undefined) tags.push(v);630 }631 tail.tags = tags;632 }633 if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());634 if (flags & F_HAS_CANARY) {635 const webhookUrl = lookupString(strings, reader.u16()) ?? "";636 const label = lookupString(strings, reader.u16());637 const canary: Canary = { webhookUrl };638 if (label !== undefined) canary.label = label;639 tail.canary = canary;640 }641 if (flags & F_HAS_PWCHANGED) {642 const pwDelta = reader.u32();643 tail.passwordChangedAt = (createdSec + pwDelta) * 1000;644 }645 if (flags & F_HAS_BREACH) tail.breach = reader.u32();646 if (flags & F_HAS_ADDITIONAL_URLS) {647 const cnt = reader.u8();648 const urls: string[] = [];649 for (let i = 0; i < cnt; i++) {650 const v = lookupString(strings, reader.u16());651 if (v !== undefined) urls.push(v);652 }653 tail.additionalUrls = urls;654 }655656 return {657 entry: {658 ...(baseEntry as VaultEntry),659 type: "login",660 url: lookupString(strings, urlIdx),661 username: lookupString(strings, usernameIdx),662 password,663 ...tail,664 } as VaultEntry,665 cursor: reader.cursor,666 };667 }668669 if (type === ENTRY_TYPE_NOTE) {670 const tail: Partial<VaultEntry> = {};671 if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());672 if (flags & F_HAS_TAGS) {673 const cnt = reader.u8();674 const tags: string[] = [];675 for (let i = 0; i < cnt; i++) {676 const v = lookupString(strings, reader.u16());677 if (v !== undefined) tags.push(v);678 }679 tail.tags = tags;680 }681 return {682 entry: { ...(baseEntry as VaultEntry), type: "note", ...tail } as VaultEntry,683 cursor: reader.cursor,684 };685 }686687 if (type === ENTRY_TYPE_CARD) {688 const cardholderIdx = reader.u16();689 const cardNumber = reader.lpString(1);690 const expMonth = reader.lpString(1);691 const expYear = reader.lpString(1);692 const cvc = reader.lpString(1);693 const nicknameIdx = reader.u16();694 const network = networkFromByte(reader.u8());695 const last4Idx = reader.u16();696 const tail: Partial<VaultEntry> = {};697 if (flags & F_HAS_TAGS) {698 const cnt = reader.u8();699 const tags: string[] = [];700 for (let i = 0; i < cnt; i++) {701 const v = lookupString(strings, reader.u16());702 if (v !== undefined) tags.push(v);703 }704 tail.tags = tags;705 }706 if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());707708 return {709 entry: {710 ...(baseEntry as VaultEntry),711 type: "card",712 cardholderName: lookupString(strings, cardholderIdx),713 cardNumber,714 expMonth,715 expYear,716 cvc,717 cardNickname: lookupString(strings, nicknameIdx),718 network,719 last4: lookupString(strings, last4Idx),720 ...tail,721 } as VaultEntry,722 cursor: reader.cursor,723 };724 }725726 if (type === ENTRY_TYPE_PASSKEY) {727 const blobLen = reader.u32();728 const blob = reader.read(blobLen);729 const obj = decodeJsonSection<Record<string, unknown>>(blob);730 return {731 entry: {732 ...(baseEntry as VaultEntry),733 type: "passkey",734 ...obj,735 } as VaultEntry,736 cursor: reader.cursor,737 };738 }739740 throw new Error(`UNKNOWN_ENTRY_TYPE:${type}`);741}742743function entryTypeByte(entry: VaultEntry): number {744 745 746 747 if (entry.passkey) return ENTRY_TYPE_PASSKEY;748 switch (entry.type) {749 case "note":750 return ENTRY_TYPE_NOTE;751 case "card":752 return ENTRY_TYPE_CARD;753 case "passkey":754 return ENTRY_TYPE_PASSKEY;755 default:756 return ENTRY_TYPE_LOGIN;757 }758}759760function networkToByte(network: CardNetwork | undefined): number {761 if (!network) return 0xff;762 const idx = NETWORK_VALUES.indexOf(network);763 return idx >= 0 ? idx : NETWORK_VALUES.indexOf("unknown");764}765766function networkFromByte(b: number): CardNetwork | undefined {767 if (b === 0xff) return undefined;768 return NETWORK_VALUES[b] ?? "unknown";769}770771772773774775export function uuidToBytes(uuid: string): Uint8Array {776 const hex = uuid.replace(/-/g, "");777 if (hex.length !== 32) throw new Error("UUID_FORMAT");778 const out = new Uint8Array(16);779 for (let i = 0; i < 16; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);780 return out;781}782783export function bytesToUuid(bytes: Uint8Array): string {784 if (bytes.length !== 16) throw new Error("UUID_LENGTH");785 const hex = Array.from(bytes)786 .map((b) => b.toString(16).padStart(2, "0"))787 .join("");788 return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;789}790791792793794795class BinaryWriter {796 private chunks: Uint8Array[] = [];797 private len = 0;798799 get length(): number {800 return this.len;801 }802803 u8(v: number): void {804 const b = new Uint8Array(1);805 b[0] = v & 0xff;806 this.push(b);807 }808809 setU8(offset: number, v: number): void {810 811 812 let cursor = 0;813 for (const c of this.chunks) {814 if (offset < cursor + c.length) {815 c[offset - cursor] = v & 0xff;816 return;817 }818 cursor += c.length;819 }820 throw new Error("setU8 out of range");821 }822823 u16(v: number): void {824 const b = new Uint8Array(2);825 new DataView(b.buffer).setUint16(0, v & 0xffff, true);826 this.push(b);827 }828829 u32(v: number): void {830 const b = new Uint8Array(4);831 new DataView(b.buffer).setUint32(0, v >>> 0, true);832 this.push(b);833 }834835 bytes(b: Uint8Array): void {836 this.push(b);837 }838839 840 lpString(value: string, lenSize: 1 | 2): void {841 const enc = new TextEncoder().encode(value);842 if (lenSize === 1) {843 if (enc.length > 0xff) throw new Error("STRING_TOO_LONG_U8");844 this.u8(enc.length);845 } else {846 if (enc.length > 0xffff) throw new Error("STRING_TOO_LONG_U16");847 this.u16(enc.length);848 }849 this.push(enc);850 }851852 toBytes(): Uint8Array {853 const out = new Uint8Array(this.len);854 let cursor = 0;855 for (const c of this.chunks) {856 out.set(c, cursor);857 cursor += c.length;858 }859 return out;860 }861862 private push(b: Uint8Array): void {863 this.chunks.push(b);864 this.len += b.length;865 }866}867868class BinaryReader {869 cursor: number;870 private view: DataView;871 constructor(private data: Uint8Array, start: number) {872 this.cursor = start;873 this.view = new DataView(data.buffer, data.byteOffset, data.byteLength);874 }875 u8(): number {876 return this.data[this.cursor++];877 }878 u16(): number {879 const v = this.view.getUint16(this.cursor, true);880 this.cursor += 2;881 return v;882 }883 u32(): number {884 const v = this.view.getUint32(this.cursor, true);885 this.cursor += 4;886 return v;887 }888 read(n: number): Uint8Array {889 const out = this.data.subarray(this.cursor, this.cursor + n);890 this.cursor += n;891 return out;892 }893 lpString(lenSize: 1 | 2): string {894 const len = lenSize === 1 ? this.u8() : this.u16();895 return new TextDecoder().decode(this.read(len));896 }897}898899900901902903async function deriveKey(passphrase: string, salt: Uint8Array): Promise<Uint8Array> {904 return argon2id({905 password: passphrase,906 salt,907 parallelism: ARGON2_PARALLELISM,908 iterations: ARGON2_ITERATIONS,909 memorySize: ARGON2_MEMORY_KIB,910 hashLength: 32,911 outputType: "binary",912 });913}914915async function aesGcmEncrypt(916 key: Uint8Array,917 nonce: Uint8Array,918 plaintext: Uint8Array919): Promise<{ ciphertext: Uint8Array; tag: Uint8Array }> {920 const cryptoKey = await crypto.subtle.importKey(921 "raw",922 key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,923 { name: "AES-GCM" },924 false,925 ["encrypt"]926 );927 const raw = await crypto.subtle.encrypt(928 { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, tagLength: 128 },929 cryptoKey,930 plaintext.buffer.slice(931 plaintext.byteOffset,932 plaintext.byteOffset + plaintext.byteLength933 ) as ArrayBuffer934 );935 const out = new Uint8Array(raw);936 return { ciphertext: out.slice(0, out.length - 16), tag: out.slice(out.length - 16) };937}938939async function aesGcmDecrypt(940 key: Uint8Array,941 nonce: Uint8Array,942 ciphertext: Uint8Array,943 tag: Uint8Array944): Promise<Uint8Array> {945 const cryptoKey = await crypto.subtle.importKey(946 "raw",947 key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,948 { name: "AES-GCM" },949 false,950 ["decrypt"]951 );952 const combined = concat([ciphertext, tag]);953 const raw = await crypto.subtle.decrypt(954 {955 name: "AES-GCM",956 iv: nonce.buffer.slice(957 nonce.byteOffset,958 nonce.byteOffset + nonce.byteLength959 ) as ArrayBuffer,960 tagLength: 128,961 },962 cryptoKey,963 combined.buffer.slice(964 combined.byteOffset,965 combined.byteOffset + combined.byteLength966 ) as ArrayBuffer967 );968 return new Uint8Array(raw);969}970971async function blake3Truncated(data: Uint8Array): Promise<Uint8Array> {972 const hex = await blake3(data);973 974 const out = new Uint8Array(CHECKSUM_LEN);975 for (let i = 0; i < CHECKSUM_LEN; i++) {976 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);977 }978 return out;979}980981function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {982 if (a.length !== b.length) return false;983 let diff = 0;984 for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];985 return diff === 0;986}987988function concat(parts: Uint8Array[]): Uint8Array {989 let total = 0;990 for (const p of parts) total += p.length;991 const out = new Uint8Array(total);992 let cursor = 0;993 for (const p of parts) {994 out.set(p, cursor);995 cursor += p.length;996 }997 return out;998}