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;112113114115const F2_HAS_EMAIL = 1 << 0;116117const NETWORK_VALUES: CardNetwork[] = ["visa", "mastercard", "amex", "discover", "unknown"];118119120121122123export interface BuildPayloadInput {124 entries: VaultEntry[];125 settings: VaultSettings;126 license?: PeachLicenseExport;127 128 declinedCardFingerprints?: string[];129 130 manualSummonDomains?: Record<string, ManualSummonRecord>;131 passphrase: string;132}133134export interface ParsePayloadOutput {135 entries: VaultEntry[];136 settings: VaultSettings;137 license?: PeachLicenseExport;138 declinedCardFingerprints?: string[];139 manualSummonDomains?: Record<string, ManualSummonRecord>;140}141142143144145146147148export async function buildPayload(input: BuildPayloadInput): Promise<Uint8Array> {149 const sections: Uint8Array[] = [];150151 sections.push(await buildSection(SECTION_ENTRIES, encodeEntriesSection(input.entries, FORMAT_V3)));152 sections.push(await buildSection(SECTION_SETTINGS, encodeJsonSection(input.settings)));153 if (input.license) {154 sections.push(await buildSection(SECTION_LICENSE, encodeJsonSection(input.license)));155 }156 if (input.manualSummonDomains && Object.keys(input.manualSummonDomains).length > 0) {157 sections.push(158 await buildSection(SECTION_RECALL, encodeJsonSection(input.manualSummonDomains))159 );160 }161 if (input.declinedCardFingerprints && input.declinedCardFingerprints.length > 0) {162 sections.push(163 await buildSection(164 SECTION_DECLINED_CARDS,165 encodeJsonSection(input.declinedCardFingerprints)166 )167 );168 }169170 const header = new Uint8Array(HEADER_LEN);171 const headerView = new DataView(header.buffer);172 headerView.setUint32(0, MAGIC, false); 173 header[4] = VERSION;174 header[5] = sections.length;175 176177 const body = concat([header, ...sections]);178 const checksum = await blake3Truncated(body);179 const inner = concat([body, checksum]);180181 182 183 184 185 186 const header5 = new Uint8Array(5);187 header5[0] = FORMAT_V3;188 new DataView(header5.buffer).setUint32(1, inner.length, true);189 const wrapped = padToNearestBucket(concat([header5, inner]));190191 const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));192 const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN));193 const key = await deriveKey(input.passphrase, salt);194 try {195 const { ciphertext, tag } = await aesGcmEncrypt(key, nonce, wrapped);196 return concat([salt, nonce, ciphertext, tag]);197 } finally {198 key.fill(0);199 }200}201202203204205206207208export async function parsePayload(209 envelope: Uint8Array,210 passphrase: string211): Promise<ParsePayloadOutput> {212 if (envelope.length < SALT_LEN + NONCE_LEN + TAG_LEN + HEADER_LEN + CHECKSUM_LEN) {213 throw new Error("PAYLOAD_TOO_SHORT");214 }215216 const salt = envelope.subarray(0, SALT_LEN);217 const nonce = envelope.subarray(SALT_LEN, SALT_LEN + NONCE_LEN);218 const tag = envelope.subarray(envelope.length - TAG_LEN);219 const ciphertext = envelope.subarray(SALT_LEN + NONCE_LEN, envelope.length - TAG_LEN);220221 const key = await deriveKey(passphrase, salt);222 let decrypted: Uint8Array;223 try {224 decrypted = await aesGcmDecrypt(key, nonce, ciphertext, tag);225 } catch {226 throw new Error("INVALID_PASSPHRASE");227 } finally {228 key.fill(0);229 }230231 232 233 let inner: Uint8Array;234 let formatVersion: number = 1;235 if (236 decrypted.length >= 5 &&237 (decrypted[0] === FORMAT_V2 || decrypted[0] === FORMAT_V3)238 ) {239 formatVersion = decrypted[0];240 const innerLen = new DataView(decrypted.buffer, decrypted.byteOffset, decrypted.byteLength).getUint32(1, true);241 if (5 + innerLen > decrypted.length) throw new Error("PAYLOAD_TOO_SHORT");242 inner = decrypted.subarray(5, 5 + innerLen);243 } else {244 inner = decrypted; 245 }246247 if (inner.length < HEADER_LEN + CHECKSUM_LEN) throw new Error("PAYLOAD_TOO_SHORT");248249 const body = inner.subarray(0, inner.length - CHECKSUM_LEN);250 const stored = inner.subarray(inner.length - CHECKSUM_LEN);251 const computed = await blake3Truncated(body);252 if (!constantTimeEqual(stored, computed)) throw new Error("CHECKSUM_FAILED");253254 const view = new DataView(body.buffer, body.byteOffset, body.byteLength);255 if (view.getUint32(0, false) !== MAGIC) throw new Error("BAD_MAGIC");256 const version = body[4];257 if (version !== VERSION) throw new Error(`UNSUPPORTED_VERSION:${version}`);258 const sectionCount = body[5];259260 let cursor = HEADER_LEN;261 const out: ParsePayloadOutput = {262 entries: [],263 settings: {},264 };265 for (let i = 0; i < sectionCount; i++) {266 if (cursor + 9 > body.length) throw new Error("PAYLOAD_TOO_SHORT");267 const type = body[cursor];268 const length = view.getUint32(cursor + 1, true);269 const clength = view.getUint32(cursor + 5, true);270 const dataStart = cursor + 9;271 const dataEnd = dataStart + clength;272 if (dataEnd > body.length) throw new Error("PAYLOAD_TOO_SHORT");273 const compressed = body.subarray(dataStart, dataEnd);274 const data = await decompressZstd(compressed);275 if (data.length !== length) throw new Error("SECTION_LENGTH_MISMATCH");276277 switch (type) {278 case SECTION_ENTRIES:279 out.entries = decodeEntriesSection(data, formatVersion);280 break;281 case SECTION_SETTINGS:282 out.settings = decodeJsonSection<VaultSettings>(data);283 break;284 case SECTION_LICENSE:285 out.license = decodeJsonSection<PeachLicenseExport>(data);286 break;287 case SECTION_RECALL:288 out.manualSummonDomains = decodeJsonSection<Record<string, ManualSummonRecord>>(data);289 break;290 case SECTION_DECLINED_CARDS:291 out.declinedCardFingerprints = decodeJsonSection<string[]>(data);292 break;293 default:294 295 296 break;297 }298299 cursor = dataEnd;300 }301302 return out;303}304305306307308309async function buildSection(type: number, raw: Uint8Array): Promise<Uint8Array> {310 const compressed = await compressZstd(raw, 19);311 const out = new Uint8Array(9 + compressed.length);312 const view = new DataView(out.buffer);313 out[0] = type;314 view.setUint32(1, raw.length, true);315 view.setUint32(5, compressed.length, true);316 out.set(compressed, 9);317 return out;318}319320function encodeJsonSection(value: unknown): Uint8Array {321 return new TextEncoder().encode(JSON.stringify(value));322}323324function decodeJsonSection<T>(data: Uint8Array): T {325 return JSON.parse(new TextDecoder().decode(data)) as T;326}327328329330331332class StringTableBuilder {333 private map = new Map<string, number>();334 private list: string[] = [];335336 337 intern(value: string | undefined): number {338 if (!value) return 0;339 const existing = this.map.get(value);340 if (existing !== undefined) return existing;341 const idx = this.list.length + 1; 342 if (idx > 0xffff) throw new Error("STRING_TABLE_OVERFLOW");343 this.map.set(value, idx);344 this.list.push(value);345 return idx;346 }347348 serialize(): Uint8Array {349 const enc = new TextEncoder();350 const encoded = this.list.map((s) => enc.encode(s));351 let total = 2; 352 for (const e of encoded) total += 2 + e.length;353 const out = new Uint8Array(total);354 const view = new DataView(out.buffer);355 let cursor = 0;356 view.setUint16(cursor, encoded.length, true);357 cursor += 2;358 for (const e of encoded) {359 if (e.length > 0xffff) throw new Error("STRING_TOO_LONG");360 view.setUint16(cursor, e.length, true);361 cursor += 2;362 out.set(e, cursor);363 cursor += e.length;364 }365 return out;366 }367}368369function readStringTable(data: Uint8Array, cursor: number): { strings: string[]; cursor: number } {370 const view = new DataView(data.buffer, data.byteOffset, data.byteLength);371 const count = view.getUint16(cursor, true);372 cursor += 2;373 const strings: string[] = [];374 const dec = new TextDecoder();375 for (let i = 0; i < count; i++) {376 const len = view.getUint16(cursor, true);377 cursor += 2;378 strings.push(dec.decode(data.subarray(cursor, cursor + len)));379 cursor += len;380 }381 return { strings, cursor };382}383384function lookupString(strings: string[], idx: number): string | undefined {385 if (idx === 0) return undefined;386 return strings[idx - 1];387}388389function encodeEntriesSection(entries: VaultEntry[], formatVersion: number): Uint8Array {390 const table = new StringTableBuilder();391 const bodies: Uint8Array[] = [];392393 394 for (const entry of entries) table.intern(entry.title);395396 for (const entry of entries) {397 bodies.push(encodeEntry(entry, table, formatVersion));398 }399400 const tableBytes = table.serialize();401402 403 let bodiesLen = 0;404 for (const b of bodies) bodiesLen += b.length;405 const out = new Uint8Array(4 + tableBytes.length + bodiesLen);406 const view = new DataView(out.buffer);407 view.setUint32(0, entries.length, true);408 out.set(tableBytes, 4);409 let cursor = 4 + tableBytes.length;410 for (const b of bodies) {411 out.set(b, cursor);412 cursor += b.length;413 }414 return out;415}416417function decodeEntriesSection(data: Uint8Array, formatVersion: number): VaultEntry[] {418 const view = new DataView(data.buffer, data.byteOffset, data.byteLength);419 const count = view.getUint32(0, true);420 const { strings, cursor: afterTable } = readStringTable(data, 4);421422 const out: VaultEntry[] = [];423 let cursor = afterTable;424 for (let i = 0; i < count; i++) {425 const { entry, cursor: next } = decodeEntry(data, cursor, strings, formatVersion);426 out.push(entry);427 cursor = next;428 }429 return out;430}431432433434435436function encodeEntry(entry: VaultEntry, table: StringTableBuilder, formatVersion: number): Uint8Array {437 const writer = new BinaryWriter();438 const type = entryTypeByte(entry);439 writer.u8(type);440 writer.bytes(uuidToBytes(entry.id));441442 443 const createdSec = Math.floor((entry.createdAt ?? Date.now()) / 1000);444 const updatedSec = Math.floor((entry.modifiedAt ?? entry.createdAt ?? Date.now()) / 1000);445 const updatedDelta = Math.max(0, updatedSec - createdSec);446 const deletedSec = entry.deletedAt ? Math.floor(entry.deletedAt / 1000) : 0;447448 449 const flagOffset = writer.length;450 writer.u8(0);451 452 453 let flags2Offset = -1;454 if (formatVersion === FORMAT_V3) {455 flags2Offset = writer.length;456 writer.u8(0);457 }458459 writer.u32(createdSec);460 writer.u32(updatedDelta);461 writer.u32(deletedSec);462 writer.u16(table.intern(entry.title));463464 let flags = 0;465 let flags2 = 0;466 if (entry.deletedAt) flags |= F_HAS_DELETED;467468 if (type === ENTRY_TYPE_LOGIN) {469 writer.u16(table.intern(entry.url));470 writer.u16(table.intern(entry.username));471 writer.lpString(entry.password ?? "", 1);472 if (formatVersion === FORMAT_V3 && entry.email) {473 flags2 |= F2_HAS_EMAIL;474 writer.lpString(entry.email, 1);475 }476477 if (entry.totp) {478 flags |= F_HAS_TOTP;479 writer.lpString(entry.totp, 1);480 }481 if (entry.tags && entry.tags.length > 0) {482 flags |= F_HAS_TAGS;483 writer.u8(Math.min(entry.tags.length, 0xff));484 for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {485 writer.u16(table.intern(entry.tags[i]));486 }487 }488 if (entry.notes) {489 flags |= F_HAS_NOTES;490 writer.u16(table.intern(entry.notes));491 }492 if (entry.canary) {493 flags |= F_HAS_CANARY;494 writer.u16(table.intern(entry.canary.webhookUrl));495 writer.u16(table.intern(entry.canary.label));496 }497 if (entry.passwordChangedAt) {498 flags |= F_HAS_PWCHANGED;499 const pwSec = Math.floor(entry.passwordChangedAt / 1000);500 writer.u32(Math.max(0, pwSec - createdSec));501 }502 if (typeof entry.breach === "number" && entry.breach > 0) {503 flags |= F_HAS_BREACH;504 writer.u32(entry.breach);505 }506 if (entry.additionalUrls && entry.additionalUrls.length > 0) {507 flags |= F_HAS_ADDITIONAL_URLS;508 writer.u8(Math.min(entry.additionalUrls.length, 0xff));509 for (let i = 0; i < Math.min(entry.additionalUrls.length, 0xff); i++) {510 writer.u16(table.intern(entry.additionalUrls[i]));511 }512 }513 } else if (type === ENTRY_TYPE_NOTE) {514 if (entry.notes) {515 flags |= F_HAS_NOTES;516 writer.u16(table.intern(entry.notes));517 }518 if (entry.tags && entry.tags.length > 0) {519 flags |= F_HAS_TAGS;520 writer.u8(Math.min(entry.tags.length, 0xff));521 for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {522 writer.u16(table.intern(entry.tags[i]));523 }524 }525 } else if (type === ENTRY_TYPE_CARD) {526 writer.u16(table.intern(entry.cardholderName));527 writer.lpString(entry.cardNumber ?? "", 1);528 writer.lpString(entry.expMonth ?? "", 1);529 writer.lpString(entry.expYear ?? "", 1);530 writer.lpString(entry.cvc ?? "", 1);531 writer.u16(table.intern(entry.cardNickname));532 writer.u8(networkToByte(entry.network));533 writer.u16(table.intern(entry.last4));534 if (entry.tags && entry.tags.length > 0) {535 flags |= F_HAS_TAGS;536 writer.u8(Math.min(entry.tags.length, 0xff));537 for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {538 writer.u16(table.intern(entry.tags[i]));539 }540 }541 if (entry.notes) {542 flags |= F_HAS_NOTES;543 writer.u16(table.intern(entry.notes));544 }545 } else if (type === ENTRY_TYPE_PASSKEY) {546 547 548 const pk = entry.passkey;549 const flat = entry as unknown as Record<string, unknown>;550 const blob = encodeJsonSection({551 rpId: pk ? pk.rpId : flat["rpId"],552 rpName: pk ? pk.rpName : flat["rpName"],553 credentialId: pk ? pk.credentialId : flat["credentialId"],554 publicKeyCose: pk ? pk.publicKeyCose : flat["publicKeyCose"],555 privateKeyJwk: pk ? pk.privateKeyJwk : flat["privateKeyJwk"],556 userHandle: pk ? pk.userHandle : flat["userHandle"],557 userName: pk ? pk.userName : flat["userName"],558 userDisplayName: pk ? pk.userDisplayName : flat["userDisplayName"],559 algorithm: pk ? pk.algorithm : flat["algorithm"],560 signCount: pk ? pk.signCount : flat["signCount"],561 aaguid: pk ? pk.aaguid : flat["aaguid"],562 backupEligible: pk ? pk.backupEligible : flat["backupEligible"],563 backupState: pk ? pk.backupState : flat["backupState"],564 lastUsedAt: pk ? pk.lastUsedAt : flat["lastUsedAt"],565 566 ...(pk && entry.password !== undefined ? { password: entry.password } : {}),567 ...(pk && entry.url !== undefined ? { url: entry.url } : {}),568 ...(pk && entry.username !== undefined ? { username: entry.username } : {}),569 });570 writer.u32(blob.length);571 writer.bytes(blob);572 }573574 writer.setU8(flagOffset, flags);575 if (flags2Offset >= 0) writer.setU8(flags2Offset, flags2);576 return writer.toBytes();577}578579function decodeEntry(580 data: Uint8Array,581 start: number,582 strings: string[],583 formatVersion: number584): { entry: VaultEntry; cursor: number } {585 const reader = new BinaryReader(data, start);586 const type = reader.u8();587 const id = bytesToUuid(reader.read(16));588 const flags = reader.u8();589 const flags2 = formatVersion === FORMAT_V3 ? reader.u8() : 0;590 const createdSec = reader.u32();591 const updatedDelta = reader.u32();592 const deletedSec = reader.u32();593 const titleIdx = reader.u16();594595 const createdAt = createdSec * 1000;596 const modifiedAt = (createdSec + updatedDelta) * 1000;597 const deletedAt = deletedSec > 0 ? deletedSec * 1000 : undefined;598 const title = lookupString(strings, titleIdx) ?? "";599600 const baseEntry: Partial<VaultEntry> = {601 id,602 title,603 createdAt,604 modifiedAt,605 ...(deletedAt !== undefined ? { deletedAt } : {}),606 };607608 if (type === ENTRY_TYPE_LOGIN) {609 const urlIdx = reader.u16();610 const usernameIdx = reader.u16();611 const password = reader.lpString(1);612 const tail: Partial<VaultEntry> = {};613614 if (flags2 & F2_HAS_EMAIL) tail.email = reader.lpString(1);615616 if (flags & F_HAS_TOTP) tail.totp = reader.lpString(1);617 if (flags & F_HAS_TAGS) {618 const cnt = reader.u8();619 const tags: string[] = [];620 for (let i = 0; i < cnt; i++) {621 const v = lookupString(strings, reader.u16());622 if (v !== undefined) tags.push(v);623 }624 tail.tags = tags;625 }626 if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());627 if (flags & F_HAS_CANARY) {628 const webhookUrl = lookupString(strings, reader.u16()) ?? "";629 const label = lookupString(strings, reader.u16());630 const canary: Canary = { webhookUrl };631 if (label !== undefined) canary.label = label;632 tail.canary = canary;633 }634 if (flags & F_HAS_PWCHANGED) {635 const pwDelta = reader.u32();636 tail.passwordChangedAt = (createdSec + pwDelta) * 1000;637 }638 if (flags & F_HAS_BREACH) tail.breach = reader.u32();639 if (flags & F_HAS_ADDITIONAL_URLS) {640 const cnt = reader.u8();641 const urls: string[] = [];642 for (let i = 0; i < cnt; i++) {643 const v = lookupString(strings, reader.u16());644 if (v !== undefined) urls.push(v);645 }646 tail.additionalUrls = urls;647 }648649 return {650 entry: {651 ...(baseEntry as VaultEntry),652 type: "login",653 url: lookupString(strings, urlIdx),654 username: lookupString(strings, usernameIdx),655 password,656 ...tail,657 } as VaultEntry,658 cursor: reader.cursor,659 };660 }661662 if (type === ENTRY_TYPE_NOTE) {663 const tail: Partial<VaultEntry> = {};664 if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());665 if (flags & F_HAS_TAGS) {666 const cnt = reader.u8();667 const tags: string[] = [];668 for (let i = 0; i < cnt; i++) {669 const v = lookupString(strings, reader.u16());670 if (v !== undefined) tags.push(v);671 }672 tail.tags = tags;673 }674 return {675 entry: { ...(baseEntry as VaultEntry), type: "note", ...tail } as VaultEntry,676 cursor: reader.cursor,677 };678 }679680 if (type === ENTRY_TYPE_CARD) {681 const cardholderIdx = reader.u16();682 const cardNumber = reader.lpString(1);683 const expMonth = reader.lpString(1);684 const expYear = reader.lpString(1);685 const cvc = reader.lpString(1);686 const nicknameIdx = reader.u16();687 const network = networkFromByte(reader.u8());688 const last4Idx = reader.u16();689 const tail: Partial<VaultEntry> = {};690 if (flags & F_HAS_TAGS) {691 const cnt = reader.u8();692 const tags: string[] = [];693 for (let i = 0; i < cnt; i++) {694 const v = lookupString(strings, reader.u16());695 if (v !== undefined) tags.push(v);696 }697 tail.tags = tags;698 }699 if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());700701 return {702 entry: {703 ...(baseEntry as VaultEntry),704 type: "card",705 cardholderName: lookupString(strings, cardholderIdx),706 cardNumber,707 expMonth,708 expYear,709 cvc,710 cardNickname: lookupString(strings, nicknameIdx),711 network,712 last4: lookupString(strings, last4Idx),713 ...tail,714 } as VaultEntry,715 cursor: reader.cursor,716 };717 }718719 if (type === ENTRY_TYPE_PASSKEY) {720 const blobLen = reader.u32();721 const blob = reader.read(blobLen);722 const obj = decodeJsonSection<Record<string, unknown>>(blob);723 return {724 entry: {725 ...(baseEntry as VaultEntry),726 type: "passkey",727 ...obj,728 } as VaultEntry,729 cursor: reader.cursor,730 };731 }732733 throw new Error(`UNKNOWN_ENTRY_TYPE:${type}`);734}735736function entryTypeByte(entry: VaultEntry): number {737 738 739 740 if (entry.passkey) return ENTRY_TYPE_PASSKEY;741 switch (entry.type) {742 case "note":743 return ENTRY_TYPE_NOTE;744 case "card":745 return ENTRY_TYPE_CARD;746 case "passkey":747 return ENTRY_TYPE_PASSKEY;748 default:749 return ENTRY_TYPE_LOGIN;750 }751}752753function networkToByte(network: CardNetwork | undefined): number {754 if (!network) return 0xff;755 const idx = NETWORK_VALUES.indexOf(network);756 return idx >= 0 ? idx : NETWORK_VALUES.indexOf("unknown");757}758759function networkFromByte(b: number): CardNetwork | undefined {760 if (b === 0xff) return undefined;761 return NETWORK_VALUES[b] ?? "unknown";762}763764765766767768export function uuidToBytes(uuid: string): Uint8Array {769 const hex = uuid.replace(/-/g, "");770 if (hex.length !== 32) throw new Error("UUID_FORMAT");771 const out = new Uint8Array(16);772 for (let i = 0; i < 16; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);773 return out;774}775776export function bytesToUuid(bytes: Uint8Array): string {777 if (bytes.length !== 16) throw new Error("UUID_LENGTH");778 const hex = Array.from(bytes)779 .map((b) => b.toString(16).padStart(2, "0"))780 .join("");781 return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;782}783784785786787788class BinaryWriter {789 private chunks: Uint8Array[] = [];790 private len = 0;791792 get length(): number {793 return this.len;794 }795796 u8(v: number): void {797 const b = new Uint8Array(1);798 b[0] = v & 0xff;799 this.push(b);800 }801802 setU8(offset: number, v: number): void {803 804 805 let cursor = 0;806 for (const c of this.chunks) {807 if (offset < cursor + c.length) {808 c[offset - cursor] = v & 0xff;809 return;810 }811 cursor += c.length;812 }813 throw new Error("setU8 out of range");814 }815816 u16(v: number): void {817 const b = new Uint8Array(2);818 new DataView(b.buffer).setUint16(0, v & 0xffff, true);819 this.push(b);820 }821822 u32(v: number): void {823 const b = new Uint8Array(4);824 new DataView(b.buffer).setUint32(0, v >>> 0, true);825 this.push(b);826 }827828 bytes(b: Uint8Array): void {829 this.push(b);830 }831832 833 lpString(value: string, lenSize: 1 | 2): void {834 const enc = new TextEncoder().encode(value);835 if (lenSize === 1) {836 if (enc.length > 0xff) throw new Error("STRING_TOO_LONG_U8");837 this.u8(enc.length);838 } else {839 if (enc.length > 0xffff) throw new Error("STRING_TOO_LONG_U16");840 this.u16(enc.length);841 }842 this.push(enc);843 }844845 toBytes(): Uint8Array {846 const out = new Uint8Array(this.len);847 let cursor = 0;848 for (const c of this.chunks) {849 out.set(c, cursor);850 cursor += c.length;851 }852 return out;853 }854855 private push(b: Uint8Array): void {856 this.chunks.push(b);857 this.len += b.length;858 }859}860861class BinaryReader {862 cursor: number;863 private view: DataView;864 constructor(private data: Uint8Array, start: number) {865 this.cursor = start;866 this.view = new DataView(data.buffer, data.byteOffset, data.byteLength);867 }868 u8(): number {869 return this.data[this.cursor++];870 }871 u16(): number {872 const v = this.view.getUint16(this.cursor, true);873 this.cursor += 2;874 return v;875 }876 u32(): number {877 const v = this.view.getUint32(this.cursor, true);878 this.cursor += 4;879 return v;880 }881 read(n: number): Uint8Array {882 const out = this.data.subarray(this.cursor, this.cursor + n);883 this.cursor += n;884 return out;885 }886 lpString(lenSize: 1 | 2): string {887 const len = lenSize === 1 ? this.u8() : this.u16();888 return new TextDecoder().decode(this.read(len));889 }890}891892893894895896async function deriveKey(passphrase: string, salt: Uint8Array): Promise<Uint8Array> {897 return argon2id({898 password: passphrase,899 salt,900 parallelism: ARGON2_PARALLELISM,901 iterations: ARGON2_ITERATIONS,902 memorySize: ARGON2_MEMORY_KIB,903 hashLength: 32,904 outputType: "binary",905 });906}907908async function aesGcmEncrypt(909 key: Uint8Array,910 nonce: Uint8Array,911 plaintext: Uint8Array912): Promise<{ ciphertext: Uint8Array; tag: Uint8Array }> {913 const cryptoKey = await crypto.subtle.importKey(914 "raw",915 key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,916 { name: "AES-GCM" },917 false,918 ["encrypt"]919 );920 const raw = await crypto.subtle.encrypt(921 { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, tagLength: 128 },922 cryptoKey,923 plaintext.buffer.slice(924 plaintext.byteOffset,925 plaintext.byteOffset + plaintext.byteLength926 ) as ArrayBuffer927 );928 const out = new Uint8Array(raw);929 return { ciphertext: out.slice(0, out.length - 16), tag: out.slice(out.length - 16) };930}931932async function aesGcmDecrypt(933 key: Uint8Array,934 nonce: Uint8Array,935 ciphertext: Uint8Array,936 tag: Uint8Array937): Promise<Uint8Array> {938 const cryptoKey = await crypto.subtle.importKey(939 "raw",940 key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,941 { name: "AES-GCM" },942 false,943 ["decrypt"]944 );945 const combined = concat([ciphertext, tag]);946 const raw = await crypto.subtle.decrypt(947 {948 name: "AES-GCM",949 iv: nonce.buffer.slice(950 nonce.byteOffset,951 nonce.byteOffset + nonce.byteLength952 ) as ArrayBuffer,953 tagLength: 128,954 },955 cryptoKey,956 combined.buffer.slice(957 combined.byteOffset,958 combined.byteOffset + combined.byteLength959 ) as ArrayBuffer960 );961 return new Uint8Array(raw);962}963964async function blake3Truncated(data: Uint8Array): Promise<Uint8Array> {965 const hex = await blake3(data);966 967 const out = new Uint8Array(CHECKSUM_LEN);968 for (let i = 0; i < CHECKSUM_LEN; i++) {969 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);970 }971 return out;972}973974function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {975 if (a.length !== b.length) return false;976 let diff = 0;977 for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];978 return diff === 0;979}980981function concat(parts: Uint8Array[]): Uint8Array {982 let total = 0;983 for (const p of parts) total += p.length;984 const out = new Uint8Array(total);985 let cursor = 0;986 for (const p of parts) {987 out.set(p, cursor);988 cursor += p.length;989 }990 return out;991}