1234567891011121314151617181920212223242526272829303132import { argon2id, blake3 } from "hash-wasm";3334import { compressZstd, decompressZstd } from "./zstd";35import { IMPORT_LIMITS, importTooLarge } from "../shared/import-limits";36import type {37 Canary,38 CardNetwork,39 ManualSummonRecord,40 PeachLicenseExport,41 VaultEntry,42} from "../shared/types";43import type { VaultSettings } from "../background/vault";444546474849export const MAGIC = 0x50434853; 50export const VERSION = 0x01;51export const HEADER_LEN = 8;52export const CHECKSUM_LEN = 4;5354export const SALT_LEN = 16;55export const NONCE_LEN = 12;56export const TAG_LEN = 16;5758596061const SIZE_BUCKETS = [1024, 4096, 16384, 65536, 262144] as const;62636465function padToNearestBucket(data: Uint8Array): Uint8Array {66 const bucket = SIZE_BUCKETS.find((b) => b >= data.length);67 let targetSize: number;68 if (bucket !== undefined) {69 targetSize = bucket;70 } else {71 72 targetSize = 262144;73 while (targetSize < data.length) targetSize *= 2;74 }75 if (targetSize === data.length) return data;76 const padded = new Uint8Array(targetSize);77 padded.set(data);78 return padded;79}80818283const FORMAT_V3 = 0x03;8485const ARGON2_MEMORY_KIB = 65536; 86const ARGON2_ITERATIONS = 3;87const ARGON2_PARALLELISM = 4;8889const SECTION_ENTRIES = 0x01;90const SECTION_SETTINGS = 0x02;91const SECTION_LICENSE = 0x03;92const SECTION_RECALL = 0x04;93const SECTION_DECLINED_CARDS = 0x05;9495const ENTRY_TYPE_LOGIN = 0x01;96const ENTRY_TYPE_NOTE = 0x02;97const ENTRY_TYPE_CARD = 0x03;98const ENTRY_TYPE_PASSKEY = 0x04;99100101const F_HAS_TOTP = 1 << 0;102const F_HAS_TAGS = 1 << 1;103const F_HAS_NOTES = 1 << 2;104const F_HAS_CANARY = 1 << 3;105const F_HAS_PWCHANGED = 1 << 4;106const F_HAS_BREACH = 1 << 5;107const F_HAS_ADDITIONAL_URLS = 1 << 6;108const F_HAS_DELETED = 1 << 7;109110111112113114115116117118119const F2_HAS_EMAIL = 1 << 0;120121const NETWORK_VALUES: CardNetwork[] = ["visa", "mastercard", "amex", "discover", "unknown"];122123124125126127export interface BuildPayloadInput {128 entries: VaultEntry[];129 settings: VaultSettings;130 license?: PeachLicenseExport;131 132 declinedCardFingerprints?: string[];133 134 manualSummonDomains?: Record<string, ManualSummonRecord>;135 passphrase: string;136}137138export interface ParsePayloadOutput {139 entries: VaultEntry[];140 settings: VaultSettings;141 license?: PeachLicenseExport;142 declinedCardFingerprints?: string[];143 manualSummonDomains?: Record<string, ManualSummonRecord>;144}145146147148149150151152export async function buildPayload(153 input: BuildPayloadInput,154): Promise<Uint8Array> {155 const sections: Uint8Array[] = [];156157 sections.push(await buildSection(SECTION_ENTRIES, encodeEntriesSection(input.entries, FORMAT_V3)));158 sections.push(await buildSection(SECTION_SETTINGS, encodeJsonSection(input.settings)));159 if (input.license) {160 sections.push(await buildSection(SECTION_LICENSE, encodeJsonSection(input.license)));161 }162 if (input.manualSummonDomains && Object.keys(input.manualSummonDomains).length > 0) {163 sections.push(164 await buildSection(SECTION_RECALL, encodeJsonSection(input.manualSummonDomains))165 );166 }167 if (input.declinedCardFingerprints && input.declinedCardFingerprints.length > 0) {168 sections.push(169 await buildSection(170 SECTION_DECLINED_CARDS,171 encodeJsonSection(input.declinedCardFingerprints)172 )173 );174 }175176 const header = new Uint8Array(HEADER_LEN);177 const headerView = new DataView(header.buffer);178 headerView.setUint32(0, MAGIC, false); 179 header[4] = VERSION;180 header[5] = sections.length;181 182183 const body = concat([header, ...sections]);184 const checksum = await blake3Truncated(body);185 const inner = concat([body, checksum]);186187 188 189 190 191 192 const header5 = new Uint8Array(5);193 header5[0] = FORMAT_V3;194 new DataView(header5.buffer).setUint32(1, inner.length, true);195 const wrapped = padToNearestBucket(concat([header5, inner]));196197 const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));198 const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN));199 const key = await deriveKey(input.passphrase, salt);200 try {201 const { ciphertext, tag } = await aesGcmEncrypt(key, nonce, wrapped);202 return concat([salt, nonce, ciphertext, tag]);203 } finally {204 key.fill(0);205 }206}207208209210211212213214export async function parsePayload(215 envelope: Uint8Array,216 passphrase: string217): Promise<ParsePayloadOutput> {218 if (envelope.length < SALT_LEN + NONCE_LEN + TAG_LEN + HEADER_LEN + CHECKSUM_LEN) {219 throw new Error("PAYLOAD_TOO_SHORT");220 }221222 const salt = envelope.subarray(0, SALT_LEN);223 const nonce = envelope.subarray(SALT_LEN, SALT_LEN + NONCE_LEN);224 const tag = envelope.subarray(envelope.length - TAG_LEN);225 const ciphertext = envelope.subarray(SALT_LEN + NONCE_LEN, envelope.length - TAG_LEN);226227 let decrypted: Uint8Array;228 const key = await deriveKey(passphrase, salt);229 try {230 decrypted = await aesGcmDecrypt(key, nonce, ciphertext, tag);231 } catch {232 throw new Error("INVALID_PASSPHRASE");233 } finally {234 key.fill(0);235 }236237 238 if (decrypted.length < 5 || decrypted[0] !== FORMAT_V3) {239 throw new Error("UNSUPPORTED_VERSION");240 }241 const innerLen = new DataView(decrypted.buffer, decrypted.byteOffset, decrypted.byteLength).getUint32(1, true);242 if (5 + innerLen > decrypted.length) throw new Error("PAYLOAD_TOO_SHORT");243 const inner = decrypted.subarray(5, 5 + innerLen);244245 if (inner.length < HEADER_LEN + CHECKSUM_LEN) throw new Error("PAYLOAD_TOO_SHORT");246247 const body = inner.subarray(0, inner.length - CHECKSUM_LEN);248 const stored = inner.subarray(inner.length - CHECKSUM_LEN);249 const computed = await blake3Truncated(body);250 if (!constantTimeEqual(stored, computed)) throw new Error("CHECKSUM_FAILED");251252 const view = new DataView(body.buffer, body.byteOffset, body.byteLength);253 if (view.getUint32(0, false) !== MAGIC) throw new Error("BAD_MAGIC");254 const version = body[4];255 if (version !== VERSION) throw new Error(`UNSUPPORTED_VERSION:${version}`);256 const sectionCount = body[5];257258 let cursor = HEADER_LEN;259 const out: ParsePayloadOutput = {260 entries: [],261 settings: {},262 };263 let totalDecompressed = 0;264 for (let i = 0; i < sectionCount; i++) {265 if (cursor + 9 > body.length) throw new Error("PAYLOAD_TOO_SHORT");266 const type = body[cursor];267 const length = view.getUint32(cursor + 1, true);268 const clength = view.getUint32(cursor + 5, true);269 270 271 if (length > IMPORT_LIMITS.MAX_DECLARED_LENGTH) {272 throw importTooLarge(273 `declared section size ${length} exceeds ${IMPORT_LIMITS.MAX_DECLARED_LENGTH}`,274 );275 }276 277 278 totalDecompressed += length;279 if (totalDecompressed > IMPORT_LIMITS.MAX_DECOMPRESSED_BYTES) {280 throw importTooLarge("total decompressed payload exceeds limit");281 }282 const dataStart = cursor + 9;283 const dataEnd = dataStart + clength;284 if (dataEnd > body.length) throw new Error("PAYLOAD_TOO_SHORT");285 const compressed = body.subarray(dataStart, dataEnd);286 const data = await decompressZstd(compressed);287 if (data.length !== length) throw new Error("SECTION_LENGTH_MISMATCH");288289 switch (type) {290 case SECTION_ENTRIES:291 out.entries = decodeEntriesSection(data, FORMAT_V3);292 break;293 case SECTION_SETTINGS:294 out.settings = decodeJsonSection<VaultSettings>(data);295 break;296 case SECTION_LICENSE:297 out.license = decodeJsonSection<PeachLicenseExport>(data);298 break;299 case SECTION_RECALL:300 out.manualSummonDomains = decodeJsonSection<Record<string, ManualSummonRecord>>(data);301 break;302 case SECTION_DECLINED_CARDS:303 out.declinedCardFingerprints = decodeJsonSection<string[]>(data);304 break;305 default:306 307 308 break;309 }310311 cursor = dataEnd;312 }313314 return out;315}316317318319320321async function buildSection(type: number, raw: Uint8Array): Promise<Uint8Array> {322 const compressed = await compressZstd(raw, 19);323 const out = new Uint8Array(9 + compressed.length);324 const view = new DataView(out.buffer);325 out[0] = type;326 view.setUint32(1, raw.length, true);327 view.setUint32(5, compressed.length, true);328 out.set(compressed, 9);329 return out;330}331332function encodeJsonSection(value: unknown): Uint8Array {333 return new TextEncoder().encode(JSON.stringify(value));334}335336function decodeJsonSection<T>(data: Uint8Array): T {337 return JSON.parse(new TextDecoder().decode(data)) as T;338}339340341342343344class StringTableBuilder {345 private map = new Map<string, number>();346 private list: string[] = [];347348 349 intern(value: string | undefined): number {350 if (!value) return 0;351 const existing = this.map.get(value);352 if (existing !== undefined) return existing;353 const idx = this.list.length + 1; 354 if (idx > 0xffff) throw new Error("STRING_TABLE_OVERFLOW");355 this.map.set(value, idx);356 this.list.push(value);357 return idx;358 }359360 serialize(): Uint8Array {361 const enc = new TextEncoder();362 const encoded = this.list.map((s) => enc.encode(s));363 let total = 2; 364 for (const e of encoded) total += 2 + e.length;365 const out = new Uint8Array(total);366 const view = new DataView(out.buffer);367 let cursor = 0;368 view.setUint16(cursor, encoded.length, true);369 cursor += 2;370 for (const e of encoded) {371 if (e.length > 0xffff) throw new Error("STRING_TOO_LONG");372 view.setUint16(cursor, e.length, true);373 cursor += 2;374 out.set(e, cursor);375 cursor += e.length;376 }377 return out;378 }379}380381function readStringTable(data: Uint8Array, cursor: number): { strings: string[]; cursor: number } {382 const view = new DataView(data.buffer, data.byteOffset, data.byteLength);383 const count = view.getUint16(cursor, true);384 cursor += 2;385 const strings: string[] = [];386 const dec = new TextDecoder();387 for (let i = 0; i < count; i++) {388 const len = view.getUint16(cursor, true);389 cursor += 2;390 strings.push(dec.decode(data.subarray(cursor, cursor + len)));391 cursor += len;392 }393 return { strings, cursor };394}395396function lookupString(strings: string[], idx: number): string | undefined {397 if (idx === 0) return undefined;398 return strings[idx - 1];399}400401function encodeEntriesSection(entries: VaultEntry[], formatVersion: number): Uint8Array {402 const table = new StringTableBuilder();403 const bodies: Uint8Array[] = [];404405 406 for (const entry of entries) table.intern(entry.title);407408 for (const entry of entries) {409 bodies.push(encodeEntry(entry, table, formatVersion));410 }411412 const tableBytes = table.serialize();413414 415 let bodiesLen = 0;416 for (const b of bodies) bodiesLen += b.length;417 const out = new Uint8Array(4 + tableBytes.length + bodiesLen);418 const view = new DataView(out.buffer);419 view.setUint32(0, entries.length, true);420 out.set(tableBytes, 4);421 let cursor = 4 + tableBytes.length;422 for (const b of bodies) {423 out.set(b, cursor);424 cursor += b.length;425 }426 return out;427}428429function decodeEntriesSection(data: Uint8Array, formatVersion: number): VaultEntry[] {430 const view = new DataView(data.buffer, data.byteOffset, data.byteLength);431 const count = view.getUint32(0, true);432 const { strings, cursor: afterTable } = readStringTable(data, 4);433434 const out: VaultEntry[] = [];435 let cursor = afterTable;436 for (let i = 0; i < count; i++) {437 const { entry, cursor: next } = decodeEntry(data, cursor, strings, formatVersion);438 out.push(entry);439 cursor = next;440 }441 return out;442}443444445446447448function encodeEntry(entry: VaultEntry, table: StringTableBuilder, formatVersion: number): Uint8Array {449 const writer = new BinaryWriter();450 const type = entryTypeByte(entry);451 writer.u8(type);452 writer.bytes(uuidToBytes(entry.id));453454 455 const createdSec = Math.floor((entry.createdAt ?? Date.now()) / 1000);456 const updatedSec = Math.floor((entry.modifiedAt ?? entry.createdAt ?? Date.now()) / 1000);457 const updatedDelta = Math.max(0, updatedSec - createdSec);458 const deletedSec = entry.deletedAt ? Math.floor(entry.deletedAt / 1000) : 0;459460 461 const flagOffset = writer.length;462 writer.u8(0);463 464 465 let flags2Offset = -1;466 if (formatVersion === FORMAT_V3) {467 flags2Offset = writer.length;468 writer.u8(0);469 }470471 writer.u32(createdSec);472 writer.u32(updatedDelta);473 writer.u32(deletedSec);474 writer.u16(table.intern(entry.title));475476 let flags = 0;477 let flags2 = 0;478 if (entry.deletedAt) flags |= F_HAS_DELETED;479480 if (type === ENTRY_TYPE_LOGIN) {481 writer.u16(table.intern(entry.url));482 writer.u16(table.intern(entry.username));483 writer.lpString(entry.password ?? "", 1);484 if (formatVersion === FORMAT_V3 && entry.email) {485 flags2 |= F2_HAS_EMAIL;486 writer.lpString(entry.email, 1);487 }488489 if (entry.totp) {490 flags |= F_HAS_TOTP;491 writer.lpString(entry.totp, 1);492 }493 if (entry.tags && entry.tags.length > 0) {494 flags |= F_HAS_TAGS;495 writer.u8(Math.min(entry.tags.length, 0xff));496 for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {497 writer.u16(table.intern(entry.tags[i]));498 }499 }500 if (entry.notes) {501 flags |= F_HAS_NOTES;502 writer.u16(table.intern(entry.notes));503 }504 if (entry.canary) {505 flags |= F_HAS_CANARY;506 writer.u16(table.intern(entry.canary.webhookUrl));507 writer.u16(table.intern(entry.canary.label));508 }509 if (entry.passwordChangedAt) {510 flags |= F_HAS_PWCHANGED;511 const pwSec = Math.floor(entry.passwordChangedAt / 1000);512 writer.u32(Math.max(0, pwSec - createdSec));513 }514 if (typeof entry.breach === "number" && entry.breach > 0) {515 flags |= F_HAS_BREACH;516 writer.u32(entry.breach);517 }518 if (entry.additionalUrls && entry.additionalUrls.length > 0) {519 flags |= F_HAS_ADDITIONAL_URLS;520 writer.u8(Math.min(entry.additionalUrls.length, 0xff));521 for (let i = 0; i < Math.min(entry.additionalUrls.length, 0xff); i++) {522 writer.u16(table.intern(entry.additionalUrls[i]));523 }524 }525 } else if (type === ENTRY_TYPE_NOTE) {526 if (entry.notes) {527 flags |= F_HAS_NOTES;528 writer.u16(table.intern(entry.notes));529 }530 if (entry.tags && entry.tags.length > 0) {531 flags |= F_HAS_TAGS;532 writer.u8(Math.min(entry.tags.length, 0xff));533 for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {534 writer.u16(table.intern(entry.tags[i]));535 }536 }537 } else if (type === ENTRY_TYPE_CARD) {538 writer.u16(table.intern(entry.cardholderName));539 writer.lpString(entry.cardNumber ?? "", 1);540 writer.lpString(entry.expMonth ?? "", 1);541 writer.lpString(entry.expYear ?? "", 1);542 writer.lpString(entry.cvc ?? "", 1);543 writer.u16(table.intern(entry.cardNickname));544 writer.u8(networkToByte(entry.network));545 writer.u16(table.intern(entry.last4));546 if (entry.tags && entry.tags.length > 0) {547 flags |= F_HAS_TAGS;548 writer.u8(Math.min(entry.tags.length, 0xff));549 for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {550 writer.u16(table.intern(entry.tags[i]));551 }552 }553 if (entry.notes) {554 flags |= F_HAS_NOTES;555 writer.u16(table.intern(entry.notes));556 }557 } else if (type === ENTRY_TYPE_PASSKEY) {558 559 const pk = entry.passkey;560 if (!pk) throw new Error("INVALID_PASSKEY_ENTRY");561 const blob = encodeJsonSection({562 passkey: pk,563 564 ...({565 ...(entry.password !== undefined ? { password: entry.password } : {}),566 ...(entry.url !== undefined ? { url: entry.url } : {}),567 ...(entry.username !== undefined ? { username: entry.username } : {}),568 ...(entry.email !== undefined ? { email: entry.email } : {}),569 ...(entry.totp !== undefined ? { totp: entry.totp } : {}),570 ...(entry.notes !== undefined ? { notes: entry.notes } : {}),571 ...(entry.tags !== undefined ? { tags: entry.tags } : {}),572 ...(entry.canary !== undefined ? { canary: entry.canary } : {}),573 ...(entry.passwordChangedAt !== undefined ? { passwordChangedAt: entry.passwordChangedAt } : {}),574 ...(entry.breach !== undefined ? { breach: entry.breach } : {}),575 ...(entry.additionalUrls !== undefined ? { additionalUrls: entry.additionalUrls } : {}),576 }),577 });578 writer.u32(blob.length);579 writer.bytes(blob);580 }581582 writer.setU8(flagOffset, flags);583 if (flags2Offset >= 0) writer.setU8(flags2Offset, flags2);584 return writer.toBytes();585}586587function decodeEntry(588 data: Uint8Array,589 start: number,590 strings: string[],591 formatVersion: number592): { entry: VaultEntry; cursor: number } {593 const reader = new BinaryReader(data, start);594 const type = reader.u8();595 const id = bytesToUuid(reader.read(16));596 const flags = reader.u8();597 const flags2 = formatVersion === FORMAT_V3 ? reader.u8() : 0;598 const createdSec = reader.u32();599 const updatedDelta = reader.u32();600 const deletedSec = reader.u32();601 const titleIdx = reader.u16();602603 const createdAt = createdSec * 1000;604 const modifiedAt = (createdSec + updatedDelta) * 1000;605 const deletedAt = deletedSec > 0 ? deletedSec * 1000 : undefined;606 const title = lookupString(strings, titleIdx) ?? "";607608 const baseEntry: Partial<VaultEntry> = {609 id,610 title,611 createdAt,612 modifiedAt,613 ...(deletedAt !== undefined ? { deletedAt } : {}),614 };615616 if (type === ENTRY_TYPE_LOGIN) {617 const urlIdx = reader.u16();618 const usernameIdx = reader.u16();619 const password = reader.lpString(1);620 const tail: Partial<VaultEntry> = {};621622 if (flags2 & F2_HAS_EMAIL) tail.email = reader.lpString(1);623624 if (flags & F_HAS_TOTP) tail.totp = reader.lpString(1);625 if (flags & F_HAS_TAGS) {626 const cnt = reader.u8();627 const tags: string[] = [];628 for (let i = 0; i < cnt; i++) {629 const v = lookupString(strings, reader.u16());630 if (v !== undefined) tags.push(v);631 }632 tail.tags = tags;633 }634 if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());635 if (flags & F_HAS_CANARY) {636 const webhookUrl = lookupString(strings, reader.u16()) ?? "";637 const label = lookupString(strings, reader.u16());638 const canary: Canary = { webhookUrl };639 if (label !== undefined) canary.label = label;640 tail.canary = canary;641 }642 if (flags & F_HAS_PWCHANGED) {643 const pwDelta = reader.u32();644 tail.passwordChangedAt = (createdSec + pwDelta) * 1000;645 }646 if (flags & F_HAS_BREACH) tail.breach = reader.u32();647 if (flags & F_HAS_ADDITIONAL_URLS) {648 const cnt = reader.u8();649 const urls: string[] = [];650 for (let i = 0; i < cnt; i++) {651 const v = lookupString(strings, reader.u16());652 if (v !== undefined) urls.push(v);653 }654 tail.additionalUrls = urls;655 }656657 return {658 entry: {659 ...(baseEntry as VaultEntry),660 type: "login",661 url: lookupString(strings, urlIdx),662 username: lookupString(strings, usernameIdx),663 password,664 ...tail,665 } as VaultEntry,666 cursor: reader.cursor,667 };668 }669670 if (type === ENTRY_TYPE_NOTE) {671 const tail: Partial<VaultEntry> = {};672 if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());673 if (flags & F_HAS_TAGS) {674 const cnt = reader.u8();675 const tags: string[] = [];676 for (let i = 0; i < cnt; i++) {677 const v = lookupString(strings, reader.u16());678 if (v !== undefined) tags.push(v);679 }680 tail.tags = tags;681 }682 return {683 entry: { ...(baseEntry as VaultEntry), type: "note", ...tail } as VaultEntry,684 cursor: reader.cursor,685 };686 }687688 if (type === ENTRY_TYPE_CARD) {689 const cardholderIdx = reader.u16();690 const cardNumber = reader.lpString(1);691 const expMonth = reader.lpString(1);692 const expYear = reader.lpString(1);693 const cvc = reader.lpString(1);694 const nicknameIdx = reader.u16();695 const network = networkFromByte(reader.u8());696 const last4Idx = reader.u16();697 const tail: Partial<VaultEntry> = {};698 if (flags & F_HAS_TAGS) {699 const cnt = reader.u8();700 const tags: string[] = [];701 for (let i = 0; i < cnt; i++) {702 const v = lookupString(strings, reader.u16());703 if (v !== undefined) tags.push(v);704 }705 tail.tags = tags;706 }707 if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());708709 return {710 entry: {711 ...(baseEntry as VaultEntry),712 type: "card",713 cardholderName: lookupString(strings, cardholderIdx),714 cardNumber,715 expMonth,716 expYear,717 cvc,718 cardNickname: lookupString(strings, nicknameIdx),719 network,720 last4: lookupString(strings, last4Idx),721 ...tail,722 } as VaultEntry,723 cursor: reader.cursor,724 };725 }726727 if (type === ENTRY_TYPE_PASSKEY) {728 const blobLen = reader.u32();729 const blob = reader.read(blobLen);730 const obj = decodeJsonSection<Record<string, unknown>>(blob);731 732 733 734 735 736 const picked: Partial<VaultEntry> = {};737 const allow = [738 "passkey", "username", "email", "password", "url", "additionalUrls",739 "totp", "notes", "tags", "passwordChangedAt", "passwordHistory",740 "canary", "deletedAt", "breach",741 ] as const;742 for (const k of allow) {743 if (k in obj) (picked as Record<string, unknown>)[k] = obj[k];744 }745 return {746 entry: {747 ...(baseEntry as VaultEntry),748 type: "passkey",749 ...picked,750 } as VaultEntry,751 cursor: reader.cursor,752 };753 }754755 throw new Error(`UNKNOWN_ENTRY_TYPE:${type}`);756}757758function entryTypeByte(entry: VaultEntry): number {759 760 761 762 if (entry.passkey) return ENTRY_TYPE_PASSKEY;763 switch (entry.type) {764 case "note":765 return ENTRY_TYPE_NOTE;766 case "card":767 return ENTRY_TYPE_CARD;768 case "passkey":769 return ENTRY_TYPE_PASSKEY;770 default:771 return ENTRY_TYPE_LOGIN;772 }773}774775function networkToByte(network: CardNetwork | undefined): number {776 if (!network) return 0xff;777 const idx = NETWORK_VALUES.indexOf(network);778 return idx >= 0 ? idx : NETWORK_VALUES.indexOf("unknown");779}780781function networkFromByte(b: number): CardNetwork | undefined {782 if (b === 0xff) return undefined;783 return NETWORK_VALUES[b] ?? "unknown";784}785786787788789790export function uuidToBytes(uuid: string): Uint8Array {791 const hex = uuid.replace(/-/g, "");792 if (hex.length !== 32) throw new Error("UUID_FORMAT");793 const out = new Uint8Array(16);794 for (let i = 0; i < 16; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);795 return out;796}797798export function bytesToUuid(bytes: Uint8Array): string {799 if (bytes.length !== 16) throw new Error("UUID_LENGTH");800 const hex = Array.from(bytes)801 .map((b) => b.toString(16).padStart(2, "0"))802 .join("");803 return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;804}805806807808809810class BinaryWriter {811 private chunks: Uint8Array[] = [];812 private len = 0;813814 get length(): number {815 return this.len;816 }817818 u8(v: number): void {819 const b = new Uint8Array(1);820 b[0] = v & 0xff;821 this.push(b);822 }823824 setU8(offset: number, v: number): void {825 826 827 let cursor = 0;828 for (const c of this.chunks) {829 if (offset < cursor + c.length) {830 c[offset - cursor] = v & 0xff;831 return;832 }833 cursor += c.length;834 }835 throw new Error("setU8 out of range");836 }837838 u16(v: number): void {839 const b = new Uint8Array(2);840 new DataView(b.buffer).setUint16(0, v & 0xffff, true);841 this.push(b);842 }843844 u32(v: number): void {845 const b = new Uint8Array(4);846 new DataView(b.buffer).setUint32(0, v >>> 0, true);847 this.push(b);848 }849850 bytes(b: Uint8Array): void {851 this.push(b);852 }853854 855 lpString(value: string, lenSize: 1 | 2): void {856 const enc = new TextEncoder().encode(value);857 if (lenSize === 1) {858 if (enc.length > 0xff) throw new Error("STRING_TOO_LONG_U8");859 this.u8(enc.length);860 } else {861 if (enc.length > 0xffff) throw new Error("STRING_TOO_LONG_U16");862 this.u16(enc.length);863 }864 this.push(enc);865 }866867 toBytes(): Uint8Array {868 const out = new Uint8Array(this.len);869 let cursor = 0;870 for (const c of this.chunks) {871 out.set(c, cursor);872 cursor += c.length;873 }874 return out;875 }876877 private push(b: Uint8Array): void {878 this.chunks.push(b);879 this.len += b.length;880 }881}882883884885export class BinaryReader {886 cursor: number;887 private view: DataView;888 constructor(private data: Uint8Array, start: number) {889 this.cursor = start;890 this.view = new DataView(data.buffer, data.byteOffset, data.byteLength);891 }892 893 894 private ensure(n: number): void {895 if (this.cursor + n > this.data.length) throw new Error("PAYLOAD_TOO_SHORT");896 }897 u8(): number {898 this.ensure(1);899 return this.data[this.cursor++];900 }901 u16(): number {902 this.ensure(2);903 const v = this.view.getUint16(this.cursor, true);904 this.cursor += 2;905 return v;906 }907 u32(): number {908 this.ensure(4);909 const v = this.view.getUint32(this.cursor, true);910 this.cursor += 4;911 return v;912 }913 read(n: number): Uint8Array {914 this.ensure(n);915 const out = this.data.subarray(this.cursor, this.cursor + n);916 this.cursor += n;917 return out;918 }919 lpString(lenSize: 1 | 2): string {920 const len = lenSize === 1 ? this.u8() : this.u16();921 return new TextDecoder().decode(this.read(len));922 }923}924925926927928929async function deriveKey(930 passphrase: string,931 salt: Uint8Array,932): Promise<Uint8Array> {933 const pw = passphrase.normalize("NFC");934 return argon2id({935 password: pw,936 salt,937 parallelism: ARGON2_PARALLELISM,938 iterations: ARGON2_ITERATIONS,939 memorySize: ARGON2_MEMORY_KIB,940 hashLength: 32,941 outputType: "binary",942 });943}944945async function aesGcmEncrypt(946 key: Uint8Array,947 nonce: Uint8Array,948 plaintext: Uint8Array949): Promise<{ ciphertext: Uint8Array; tag: Uint8Array }> {950 const cryptoKey = await crypto.subtle.importKey(951 "raw",952 key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,953 { name: "AES-GCM" },954 false,955 ["encrypt"]956 );957 const raw = await crypto.subtle.encrypt(958 { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, tagLength: 128 },959 cryptoKey,960 plaintext.buffer.slice(961 plaintext.byteOffset,962 plaintext.byteOffset + plaintext.byteLength963 ) as ArrayBuffer964 );965 const out = new Uint8Array(raw);966 return { ciphertext: out.slice(0, out.length - 16), tag: out.slice(out.length - 16) };967}968969async function aesGcmDecrypt(970 key: Uint8Array,971 nonce: Uint8Array,972 ciphertext: Uint8Array,973 tag: Uint8Array974): Promise<Uint8Array> {975 const cryptoKey = await crypto.subtle.importKey(976 "raw",977 key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,978 { name: "AES-GCM" },979 false,980 ["decrypt"]981 );982 const combined = concat([ciphertext, tag]);983 const raw = await crypto.subtle.decrypt(984 {985 name: "AES-GCM",986 iv: nonce.buffer.slice(987 nonce.byteOffset,988 nonce.byteOffset + nonce.byteLength989 ) as ArrayBuffer,990 tagLength: 128,991 },992 cryptoKey,993 combined.buffer.slice(994 combined.byteOffset,995 combined.byteOffset + combined.byteLength996 ) as ArrayBuffer997 );998 return new Uint8Array(raw);999}10001001async function blake3Truncated(data: Uint8Array): Promise<Uint8Array> {1002 const hex = await blake3(data);1003 1004 const out = new Uint8Array(CHECKSUM_LEN);1005 for (let i = 0; i < CHECKSUM_LEN; i++) {1006 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);1007 }1008 return out;1009}10101011function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {1012 if (a.length !== b.length) return false;1013 let diff = 0;1014 for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];1015 return diff === 0;1016}10171018function concat(parts: Uint8Array[]): Uint8Array {1019 let total = 0;1020 for (const p of parts) total += p.length;1021 const out = new Uint8Array(total);1022 let cursor = 0;1023 for (const p of parts) {1024 out.set(p, cursor);1025 cursor += p.length;1026 }1027 return out;1028}