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;84const FORMAT_V4 = 0x04;8586const ARGON2_MEMORY_KIB = 65536; 87const ARGON2_ITERATIONS = 3;88const ARGON2_PARALLELISM = 4;8990const SECTION_ENTRIES = 0x01;91const SECTION_SETTINGS = 0x02;92const SECTION_LICENSE = 0x03;93const SECTION_RECALL = 0x04;94const SECTION_DECLINED_CARDS = 0x05;9596const ENTRY_TYPE_LOGIN = 0x01;97const ENTRY_TYPE_NOTE = 0x02;98const ENTRY_TYPE_CARD = 0x03;99const ENTRY_TYPE_PASSKEY = 0x04;100101102const F_HAS_TOTP = 1 << 0;103const F_HAS_TAGS = 1 << 1;104const F_HAS_NOTES = 1 << 2;105const F_HAS_CANARY = 1 << 3;106const F_HAS_PWCHANGED = 1 << 4;107const F_HAS_BREACH = 1 << 5;108const F_HAS_ADDITIONAL_URLS = 1 << 6;109const F_HAS_DELETED = 1 << 7;110111112113114115116117118119120const F2_HAS_EMAIL = 1 << 0;121122const NETWORK_VALUES: CardNetwork[] = ["visa", "mastercard", "amex", "discover", "unknown"];123124125126127128export interface BuildPayloadInput {129 entries: VaultEntry[];130 settings: VaultSettings;131 license?: PeachLicenseExport;132 133 declinedCardFingerprints?: string[];134 135 manualSummonDomains?: Record<string, ManualSummonRecord>;136 passphrase: string;137}138139export interface ParsePayloadOutput {140 entries: VaultEntry[];141 settings: VaultSettings;142 license?: PeachLicenseExport;143 declinedCardFingerprints?: string[];144 manualSummonDomains?: Record<string, ManualSummonRecord>;145}146147148149150151152153export async function buildPayload(154 input: BuildPayloadInput,155): Promise<Uint8Array> {156 const sections: Uint8Array[] = [];157158 sections.push(await buildSection(SECTION_ENTRIES, encodeEntriesSection(input.entries, FORMAT_V4)));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_V4;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 let decrypted: Uint8Array;229 const key = await deriveKey(passphrase, salt);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 if (decrypted.length < 5 || (decrypted[0] !== FORMAT_V3 && decrypted[0] !== FORMAT_V4)) {240 throw new Error("UNSUPPORTED_VERSION");241 }242 const formatVersion = decrypted[0];243 const innerLen = new DataView(decrypted.buffer, decrypted.byteOffset, decrypted.byteLength).getUint32(1, true);244 if (5 + innerLen > decrypted.length) throw new Error("PAYLOAD_TOO_SHORT");245 const inner = decrypted.subarray(5, 5 + innerLen);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 let totalDecompressed = 0;266 for (let i = 0; i < sectionCount; i++) {267 if (cursor + 9 > body.length) throw new Error("PAYLOAD_TOO_SHORT");268 const type = body[cursor];269 const length = view.getUint32(cursor + 1, true);270 const clength = view.getUint32(cursor + 5, true);271 272 273 if (length > IMPORT_LIMITS.MAX_DECLARED_LENGTH) {274 throw importTooLarge(275 `declared section size ${length} exceeds ${IMPORT_LIMITS.MAX_DECLARED_LENGTH}`,276 );277 }278 279 280 totalDecompressed += length;281 if (totalDecompressed > IMPORT_LIMITS.MAX_DECOMPRESSED_BYTES) {282 throw importTooLarge("total decompressed payload exceeds limit");283 }284 const dataStart = cursor + 9;285 const dataEnd = dataStart + clength;286 if (dataEnd > body.length) throw new Error("PAYLOAD_TOO_SHORT");287 const compressed = body.subarray(dataStart, dataEnd);288 const data = await decompressZstd(compressed);289 if (data.length !== length) throw new Error("SECTION_LENGTH_MISMATCH");290291 switch (type) {292 case SECTION_ENTRIES:293 out.entries = decodeEntriesSection(data, formatVersion);294 break;295 case SECTION_SETTINGS:296 out.settings = decodeJsonSection<VaultSettings>(data);297 break;298 case SECTION_LICENSE:299 out.license = decodeJsonSection<PeachLicenseExport>(data);300 break;301 case SECTION_RECALL:302 out.manualSummonDomains = decodeJsonSection<Record<string, ManualSummonRecord>>(data);303 break;304 case SECTION_DECLINED_CARDS:305 out.declinedCardFingerprints = decodeJsonSection<string[]>(data);306 break;307 default:308 309 310 break;311 }312313 cursor = dataEnd;314 }315316 return out;317}318319320321322323async function buildSection(type: number, raw: Uint8Array): Promise<Uint8Array> {324 const compressed = await compressZstd(raw, 19);325 const out = new Uint8Array(9 + compressed.length);326 const view = new DataView(out.buffer);327 out[0] = type;328 view.setUint32(1, raw.length, true);329 view.setUint32(5, compressed.length, true);330 out.set(compressed, 9);331 return out;332}333334function encodeJsonSection(value: unknown): Uint8Array {335 return new TextEncoder().encode(JSON.stringify(value));336}337338function decodeJsonSection<T>(data: Uint8Array): T {339 return JSON.parse(new TextDecoder().decode(data)) as T;340}341342343344345346class StringTableBuilder {347 private map = new Map<string, number>();348 private list: string[] = [];349350 351 intern(value: string | undefined): number {352 if (!value) return 0;353 const existing = this.map.get(value);354 if (existing !== undefined) return existing;355 const idx = this.list.length + 1; 356 if (idx > 0xffff) throw new Error("STRING_TABLE_OVERFLOW");357 this.map.set(value, idx);358 this.list.push(value);359 return idx;360 }361362 serialize(): Uint8Array {363 const enc = new TextEncoder();364 const encoded = this.list.map((s) => enc.encode(s));365 let total = 2; 366 for (const e of encoded) total += 2 + e.length;367 const out = new Uint8Array(total);368 const view = new DataView(out.buffer);369 let cursor = 0;370 view.setUint16(cursor, encoded.length, true);371 cursor += 2;372 for (const e of encoded) {373 if (e.length > 0xffff) throw new Error("STRING_TOO_LONG");374 view.setUint16(cursor, e.length, true);375 cursor += 2;376 out.set(e, cursor);377 cursor += e.length;378 }379 return out;380 }381}382383function readStringTable(data: Uint8Array, cursor: number): { strings: string[]; cursor: number } {384 const view = new DataView(data.buffer, data.byteOffset, data.byteLength);385 const count = view.getUint16(cursor, true);386 cursor += 2;387 const strings: string[] = [];388 const dec = new TextDecoder();389 for (let i = 0; i < count; i++) {390 const len = view.getUint16(cursor, true);391 cursor += 2;392 strings.push(dec.decode(data.subarray(cursor, cursor + len)));393 cursor += len;394 }395 return { strings, cursor };396}397398function lookupString(strings: string[], idx: number): string | undefined {399 if (idx === 0) return undefined;400 return strings[idx - 1];401}402403function encodeEntriesSection(entries: VaultEntry[], formatVersion: number): Uint8Array {404 const table = new StringTableBuilder();405 const bodies: Uint8Array[] = [];406407 408 for (const entry of entries) table.intern(entry.title);409410 for (const entry of entries) {411 bodies.push(encodeEntry(entry, table, formatVersion));412 }413414 const tableBytes = table.serialize();415416 417 let bodiesLen = 0;418 for (const b of bodies) bodiesLen += b.length;419 const out = new Uint8Array(4 + tableBytes.length + bodiesLen);420 const view = new DataView(out.buffer);421 view.setUint32(0, entries.length, true);422 out.set(tableBytes, 4);423 let cursor = 4 + tableBytes.length;424 for (const b of bodies) {425 out.set(b, cursor);426 cursor += b.length;427 }428 return out;429}430431function decodeEntriesSection(data: Uint8Array, formatVersion: number): VaultEntry[] {432 const view = new DataView(data.buffer, data.byteOffset, data.byteLength);433 const count = view.getUint32(0, true);434 const { strings, cursor: afterTable } = readStringTable(data, 4);435436 const out: VaultEntry[] = [];437 let cursor = afterTable;438 for (let i = 0; i < count; i++) {439 const { entry, cursor: next } = decodeEntry(data, cursor, strings, formatVersion);440 out.push(entry);441 cursor = next;442 }443 return out;444}445446447448449450function encodeEntry(entry: VaultEntry, table: StringTableBuilder, formatVersion: number): Uint8Array {451 const writer = new BinaryWriter();452 const type = entryTypeByte(entry);453 writer.u8(type);454 writer.bytes(uuidToBytes(entry.id));455456 457 const createdSec = Math.floor((entry.createdAt ?? Date.now()) / 1000);458 const updatedSec = Math.floor((entry.modifiedAt ?? entry.createdAt ?? Date.now()) / 1000);459 const updatedDelta = Math.max(0, updatedSec - createdSec);460 const deletedSec = entry.deletedAt ? Math.floor(entry.deletedAt / 1000) : 0;461462 463 const flagOffset = writer.length;464 writer.u8(0);465 466 467 let flags2Offset = -1;468 if (formatVersion >= FORMAT_V3) {469 flags2Offset = writer.length;470 writer.u8(0);471 }472473 writer.u32(createdSec);474 writer.u32(updatedDelta);475 writer.u32(deletedSec);476 writer.u16(table.intern(entry.title));477478 let flags = 0;479 let flags2 = 0;480 if (entry.deletedAt) flags |= F_HAS_DELETED;481482 if (type === ENTRY_TYPE_LOGIN) {483 writer.u16(table.intern(entry.url));484 writer.u16(table.intern(entry.username));485 writer.lpString(entry.password ?? "", formatVersion >= FORMAT_V4 ? 2 : 1);486 if (formatVersion >= FORMAT_V3 && entry.email) {487 flags2 |= F2_HAS_EMAIL;488 writer.lpString(entry.email, 1);489 }490491 if (entry.totp) {492 flags |= F_HAS_TOTP;493 writer.lpString(entry.totp, 1);494 }495 if (entry.tags && entry.tags.length > 0) {496 flags |= F_HAS_TAGS;497 writer.u8(Math.min(entry.tags.length, 0xff));498 for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {499 writer.u16(table.intern(entry.tags[i]));500 }501 }502 if (entry.notes) {503 flags |= F_HAS_NOTES;504 writer.u16(table.intern(entry.notes));505 }506 if (entry.canary) {507 flags |= F_HAS_CANARY;508 writer.u16(table.intern(entry.canary.webhookUrl));509 writer.u16(table.intern(entry.canary.label));510 }511 if (entry.passwordChangedAt) {512 flags |= F_HAS_PWCHANGED;513 const pwSec = Math.floor(entry.passwordChangedAt / 1000);514 writer.u32(Math.max(0, pwSec - createdSec));515 }516 if (typeof entry.breach === "number" && entry.breach > 0) {517 flags |= F_HAS_BREACH;518 writer.u32(entry.breach);519 }520 if (entry.additionalUrls && entry.additionalUrls.length > 0) {521 flags |= F_HAS_ADDITIONAL_URLS;522 writer.u8(Math.min(entry.additionalUrls.length, 0xff));523 for (let i = 0; i < Math.min(entry.additionalUrls.length, 0xff); i++) {524 writer.u16(table.intern(entry.additionalUrls[i]));525 }526 }527 } else if (type === ENTRY_TYPE_NOTE) {528 if (entry.notes) {529 flags |= F_HAS_NOTES;530 writer.u16(table.intern(entry.notes));531 }532 if (entry.tags && entry.tags.length > 0) {533 flags |= F_HAS_TAGS;534 writer.u8(Math.min(entry.tags.length, 0xff));535 for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {536 writer.u16(table.intern(entry.tags[i]));537 }538 }539 } else if (type === ENTRY_TYPE_CARD) {540 writer.u16(table.intern(entry.cardholderName));541 writer.lpString(entry.cardNumber ?? "", 1);542 writer.lpString(entry.expMonth ?? "", 1);543 writer.lpString(entry.expYear ?? "", 1);544 writer.lpString(entry.cvc ?? "", 1);545 writer.u16(table.intern(entry.cardNickname));546 writer.u8(networkToByte(entry.network));547 writer.u16(table.intern(entry.last4));548 if (entry.tags && entry.tags.length > 0) {549 flags |= F_HAS_TAGS;550 writer.u8(Math.min(entry.tags.length, 0xff));551 for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {552 writer.u16(table.intern(entry.tags[i]));553 }554 }555 if (entry.notes) {556 flags |= F_HAS_NOTES;557 writer.u16(table.intern(entry.notes));558 }559 } else if (type === ENTRY_TYPE_PASSKEY) {560 561 const pk = entry.passkey;562 if (!pk) throw new Error("INVALID_PASSKEY_ENTRY");563 const blob = encodeJsonSection({564 passkey: pk,565 566 ...({567 ...(entry.password !== undefined ? { password: entry.password } : {}),568 ...(entry.url !== undefined ? { url: entry.url } : {}),569 ...(entry.username !== undefined ? { username: entry.username } : {}),570 ...(entry.email !== undefined ? { email: entry.email } : {}),571 ...(entry.totp !== undefined ? { totp: entry.totp } : {}),572 ...(entry.notes !== undefined ? { notes: entry.notes } : {}),573 ...(entry.tags !== undefined ? { tags: entry.tags } : {}),574 ...(entry.canary !== undefined ? { canary: entry.canary } : {}),575 ...(entry.passwordChangedAt !== undefined ? { passwordChangedAt: entry.passwordChangedAt } : {}),576 ...(entry.breach !== undefined ? { breach: entry.breach } : {}),577 ...(entry.additionalUrls !== undefined ? { additionalUrls: entry.additionalUrls } : {}),578 }),579 });580 writer.u32(blob.length);581 writer.bytes(blob);582 }583584 writer.setU8(flagOffset, flags);585 if (flags2Offset >= 0) writer.setU8(flags2Offset, flags2);586 return writer.toBytes();587}588589function decodeEntry(590 data: Uint8Array,591 start: number,592 strings: string[],593 formatVersion: number594): { entry: VaultEntry; cursor: number } {595 const reader = new BinaryReader(data, start);596 const type = reader.u8();597 const id = bytesToUuid(reader.read(16));598 const flags = reader.u8();599 const flags2 = formatVersion >= FORMAT_V3 ? reader.u8() : 0;600 const createdSec = reader.u32();601 const updatedDelta = reader.u32();602 const deletedSec = reader.u32();603 const titleIdx = reader.u16();604605 const createdAt = createdSec * 1000;606 const modifiedAt = (createdSec + updatedDelta) * 1000;607 const deletedAt = deletedSec > 0 ? deletedSec * 1000 : undefined;608 const title = lookupString(strings, titleIdx) ?? "";609610 const baseEntry: Partial<VaultEntry> = {611 id,612 title,613 createdAt,614 modifiedAt,615 ...(deletedAt !== undefined ? { deletedAt } : {}),616 };617618 if (type === ENTRY_TYPE_LOGIN) {619 const urlIdx = reader.u16();620 const usernameIdx = reader.u16();621 const password = reader.lpString(formatVersion >= FORMAT_V4 ? 2 : 1);622 const tail: Partial<VaultEntry> = {};623624 if (flags2 & F2_HAS_EMAIL) tail.email = reader.lpString(1);625626 if (flags & F_HAS_TOTP) tail.totp = reader.lpString(1);627 if (flags & F_HAS_TAGS) {628 const cnt = reader.u8();629 const tags: string[] = [];630 for (let i = 0; i < cnt; i++) {631 const v = lookupString(strings, reader.u16());632 if (v !== undefined) tags.push(v);633 }634 tail.tags = tags;635 }636 if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());637 if (flags & F_HAS_CANARY) {638 const webhookUrl = lookupString(strings, reader.u16()) ?? "";639 const label = lookupString(strings, reader.u16());640 const canary: Canary = { webhookUrl };641 if (label !== undefined) canary.label = label;642 tail.canary = canary;643 }644 if (flags & F_HAS_PWCHANGED) {645 const pwDelta = reader.u32();646 tail.passwordChangedAt = (createdSec + pwDelta) * 1000;647 }648 if (flags & F_HAS_BREACH) tail.breach = reader.u32();649 if (flags & F_HAS_ADDITIONAL_URLS) {650 const cnt = reader.u8();651 const urls: string[] = [];652 for (let i = 0; i < cnt; i++) {653 const v = lookupString(strings, reader.u16());654 if (v !== undefined) urls.push(v);655 }656 tail.additionalUrls = urls;657 }658659 return {660 entry: {661 ...(baseEntry as VaultEntry),662 type: "login",663 url: lookupString(strings, urlIdx),664 username: lookupString(strings, usernameIdx),665 password,666 ...tail,667 } as VaultEntry,668 cursor: reader.cursor,669 };670 }671672 if (type === ENTRY_TYPE_NOTE) {673 const tail: Partial<VaultEntry> = {};674 if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());675 if (flags & F_HAS_TAGS) {676 const cnt = reader.u8();677 const tags: string[] = [];678 for (let i = 0; i < cnt; i++) {679 const v = lookupString(strings, reader.u16());680 if (v !== undefined) tags.push(v);681 }682 tail.tags = tags;683 }684 return {685 entry: { ...(baseEntry as VaultEntry), type: "note", ...tail } as VaultEntry,686 cursor: reader.cursor,687 };688 }689690 if (type === ENTRY_TYPE_CARD) {691 const cardholderIdx = reader.u16();692 const cardNumber = reader.lpString(1);693 const expMonth = reader.lpString(1);694 const expYear = reader.lpString(1);695 const cvc = reader.lpString(1);696 const nicknameIdx = reader.u16();697 const network = networkFromByte(reader.u8());698 const last4Idx = reader.u16();699 const tail: Partial<VaultEntry> = {};700 if (flags & F_HAS_TAGS) {701 const cnt = reader.u8();702 const tags: string[] = [];703 for (let i = 0; i < cnt; i++) {704 const v = lookupString(strings, reader.u16());705 if (v !== undefined) tags.push(v);706 }707 tail.tags = tags;708 }709 if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());710711 return {712 entry: {713 ...(baseEntry as VaultEntry),714 type: "card",715 cardholderName: lookupString(strings, cardholderIdx),716 cardNumber,717 expMonth,718 expYear,719 cvc,720 cardNickname: lookupString(strings, nicknameIdx),721 network,722 last4: lookupString(strings, last4Idx),723 ...tail,724 } as VaultEntry,725 cursor: reader.cursor,726 };727 }728729 if (type === ENTRY_TYPE_PASSKEY) {730 const blobLen = reader.u32();731 const blob = reader.read(blobLen);732 const obj = decodeJsonSection<Record<string, unknown>>(blob);733 734 735 736 737 738 const picked: Partial<VaultEntry> = {};739 const allow = [740 "passkey", "username", "email", "password", "url", "additionalUrls",741 "totp", "notes", "tags", "passwordChangedAt", "passwordHistory",742 "canary", "deletedAt", "breach",743 ] as const;744 for (const k of allow) {745 if (k in obj) (picked as Record<string, unknown>)[k] = obj[k];746 }747 return {748 entry: {749 ...(baseEntry as VaultEntry),750 type: "passkey",751 ...picked,752 } as VaultEntry,753 cursor: reader.cursor,754 };755 }756757 throw new Error(`UNKNOWN_ENTRY_TYPE:${type}`);758}759760function entryTypeByte(entry: VaultEntry): number {761 762 763 764 if (entry.passkey) return ENTRY_TYPE_PASSKEY;765 switch (entry.type) {766 case "note":767 return ENTRY_TYPE_NOTE;768 case "card":769 return ENTRY_TYPE_CARD;770 case "passkey":771 return ENTRY_TYPE_PASSKEY;772 default:773 return ENTRY_TYPE_LOGIN;774 }775}776777function networkToByte(network: CardNetwork | undefined): number {778 if (!network) return 0xff;779 const idx = NETWORK_VALUES.indexOf(network);780 return idx >= 0 ? idx : NETWORK_VALUES.indexOf("unknown");781}782783function networkFromByte(b: number): CardNetwork | undefined {784 if (b === 0xff) return undefined;785 return NETWORK_VALUES[b] ?? "unknown";786}787788789790791792export function uuidToBytes(uuid: string): Uint8Array {793 const hex = uuid.replace(/-/g, "");794 if (hex.length !== 32) throw new Error("UUID_FORMAT");795 const out = new Uint8Array(16);796 for (let i = 0; i < 16; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);797 return out;798}799800export function bytesToUuid(bytes: Uint8Array): string {801 if (bytes.length !== 16) throw new Error("UUID_LENGTH");802 const hex = Array.from(bytes)803 .map((b) => b.toString(16).padStart(2, "0"))804 .join("");805 return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;806}807808809810811812class BinaryWriter {813 private chunks: Uint8Array[] = [];814 private len = 0;815816 get length(): number {817 return this.len;818 }819820 u8(v: number): void {821 const b = new Uint8Array(1);822 b[0] = v & 0xff;823 this.push(b);824 }825826 setU8(offset: number, v: number): void {827 828 829 let cursor = 0;830 for (const c of this.chunks) {831 if (offset < cursor + c.length) {832 c[offset - cursor] = v & 0xff;833 return;834 }835 cursor += c.length;836 }837 throw new Error("setU8 out of range");838 }839840 u16(v: number): void {841 const b = new Uint8Array(2);842 new DataView(b.buffer).setUint16(0, v & 0xffff, true);843 this.push(b);844 }845846 u32(v: number): void {847 const b = new Uint8Array(4);848 new DataView(b.buffer).setUint32(0, v >>> 0, true);849 this.push(b);850 }851852 bytes(b: Uint8Array): void {853 this.push(b);854 }855856 857 lpString(value: string, lenSize: 1 | 2): void {858 const enc = new TextEncoder().encode(value);859 if (lenSize === 1) {860 if (enc.length > 0xff) throw new Error("STRING_TOO_LONG_U8");861 this.u8(enc.length);862 } else {863 if (enc.length > 0xffff) throw new Error("STRING_TOO_LONG_U16");864 this.u16(enc.length);865 }866 this.push(enc);867 }868869 toBytes(): Uint8Array {870 const out = new Uint8Array(this.len);871 let cursor = 0;872 for (const c of this.chunks) {873 out.set(c, cursor);874 cursor += c.length;875 }876 return out;877 }878879 private push(b: Uint8Array): void {880 this.chunks.push(b);881 this.len += b.length;882 }883}884885886887export class BinaryReader {888 cursor: number;889 private view: DataView;890 constructor(private data: Uint8Array, start: number) {891 this.cursor = start;892 this.view = new DataView(data.buffer, data.byteOffset, data.byteLength);893 }894 895 896 private ensure(n: number): void {897 if (this.cursor + n > this.data.length) throw new Error("PAYLOAD_TOO_SHORT");898 }899 u8(): number {900 this.ensure(1);901 return this.data[this.cursor++];902 }903 u16(): number {904 this.ensure(2);905 const v = this.view.getUint16(this.cursor, true);906 this.cursor += 2;907 return v;908 }909 u32(): number {910 this.ensure(4);911 const v = this.view.getUint32(this.cursor, true);912 this.cursor += 4;913 return v;914 }915 read(n: number): Uint8Array {916 this.ensure(n);917 const out = this.data.subarray(this.cursor, this.cursor + n);918 this.cursor += n;919 return out;920 }921 lpString(lenSize: 1 | 2): string {922 const len = lenSize === 1 ? this.u8() : this.u16();923 return new TextDecoder().decode(this.read(len));924 }925}926927928929930931async function deriveKey(932 passphrase: string,933 salt: Uint8Array,934): Promise<Uint8Array> {935 const pw = passphrase.normalize("NFC");936 return argon2id({937 password: pw,938 salt,939 parallelism: ARGON2_PARALLELISM,940 iterations: ARGON2_ITERATIONS,941 memorySize: ARGON2_MEMORY_KIB,942 hashLength: 32,943 outputType: "binary",944 });945}946947async function aesGcmEncrypt(948 key: Uint8Array,949 nonce: Uint8Array,950 plaintext: Uint8Array951): Promise<{ ciphertext: Uint8Array; tag: Uint8Array }> {952 const cryptoKey = await crypto.subtle.importKey(953 "raw",954 key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,955 { name: "AES-GCM" },956 false,957 ["encrypt"]958 );959 const raw = await crypto.subtle.encrypt(960 { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, tagLength: 128 },961 cryptoKey,962 plaintext.buffer.slice(963 plaintext.byteOffset,964 plaintext.byteOffset + plaintext.byteLength965 ) as ArrayBuffer966 );967 const out = new Uint8Array(raw);968 return { ciphertext: out.slice(0, out.length - 16), tag: out.slice(out.length - 16) };969}970971async function aesGcmDecrypt(972 key: Uint8Array,973 nonce: Uint8Array,974 ciphertext: Uint8Array,975 tag: Uint8Array976): Promise<Uint8Array> {977 const cryptoKey = await crypto.subtle.importKey(978 "raw",979 key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,980 { name: "AES-GCM" },981 false,982 ["decrypt"]983 );984 const combined = concat([ciphertext, tag]);985 const raw = await crypto.subtle.decrypt(986 {987 name: "AES-GCM",988 iv: nonce.buffer.slice(989 nonce.byteOffset,990 nonce.byteOffset + nonce.byteLength991 ) as ArrayBuffer,992 tagLength: 128,993 },994 cryptoKey,995 combined.buffer.slice(996 combined.byteOffset,997 combined.byteOffset + combined.byteLength998 ) as ArrayBuffer999 );1000 return new Uint8Array(raw);1001}10021003async function blake3Truncated(data: Uint8Array): Promise<Uint8Array> {1004 const hex = await blake3(data);1005 1006 const out = new Uint8Array(CHECKSUM_LEN);1007 for (let i = 0; i < CHECKSUM_LEN; i++) {1008 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);1009 }1010 return out;1011}10121013function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {1014 if (a.length !== b.length) return false;1015 let diff = 0;1016 for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];1017 return diff === 0;1018}10191020function concat(parts: Uint8Array[]): Uint8Array {1021 let total = 0;1022 for (const p of parts) total += p.length;1023 const out = new Uint8Array(total);1024 let cursor = 0;1025 for (const p of parts) {1026 out.set(p, cursor);1027 cursor += p.length;1028 }1029 return out;1030}