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_V2 = 0x02;84858687const FORMAT_V3 = 0x03;8889const ARGON2_MEMORY_KIB = 65536; 90const ARGON2_ITERATIONS = 3;91const ARGON2_PARALLELISM = 4;9293const SECTION_ENTRIES = 0x01;94const SECTION_SETTINGS = 0x02;95const SECTION_LICENSE = 0x03;96const SECTION_RECALL = 0x04;97const SECTION_DECLINED_CARDS = 0x05;9899const ENTRY_TYPE_LOGIN = 0x01;100const ENTRY_TYPE_NOTE = 0x02;101const ENTRY_TYPE_CARD = 0x03;102const ENTRY_TYPE_PASSKEY = 0x04;103104105const F_HAS_TOTP = 1 << 0;106const F_HAS_TAGS = 1 << 1;107const F_HAS_NOTES = 1 << 2;108const F_HAS_CANARY = 1 << 3;109const F_HAS_PWCHANGED = 1 << 4;110const F_HAS_BREACH = 1 << 5;111const F_HAS_ADDITIONAL_URLS = 1 << 6;112const F_HAS_DELETED = 1 << 7;113114115116117118119120121122123const F2_HAS_EMAIL = 1 << 0;124125const NETWORK_VALUES: CardNetwork[] = ["visa", "mastercard", "amex", "discover", "unknown"];126127128129130131export interface BuildPayloadInput {132 entries: VaultEntry[];133 settings: VaultSettings;134 license?: PeachLicenseExport;135 136 declinedCardFingerprints?: string[];137 138 manualSummonDomains?: Record<string, ManualSummonRecord>;139 passphrase: string;140}141142export interface ParsePayloadOutput {143 entries: VaultEntry[];144 settings: VaultSettings;145 license?: PeachLicenseExport;146 declinedCardFingerprints?: string[];147 manualSummonDomains?: Record<string, ManualSummonRecord>;148}149150151152153154155156export async function buildPayload(input: BuildPayloadInput): Promise<Uint8Array> {157 const sections: Uint8Array[] = [];158159 sections.push(await buildSection(SECTION_ENTRIES, encodeEntriesSection(input.entries, FORMAT_V3)));160 sections.push(await buildSection(SECTION_SETTINGS, encodeJsonSection(input.settings)));161 if (input.license) {162 sections.push(await buildSection(SECTION_LICENSE, encodeJsonSection(input.license)));163 }164 if (input.manualSummonDomains && Object.keys(input.manualSummonDomains).length > 0) {165 sections.push(166 await buildSection(SECTION_RECALL, encodeJsonSection(input.manualSummonDomains))167 );168 }169 if (input.declinedCardFingerprints && input.declinedCardFingerprints.length > 0) {170 sections.push(171 await buildSection(172 SECTION_DECLINED_CARDS,173 encodeJsonSection(input.declinedCardFingerprints)174 )175 );176 }177178 const header = new Uint8Array(HEADER_LEN);179 const headerView = new DataView(header.buffer);180 headerView.setUint32(0, MAGIC, false); 181 header[4] = VERSION;182 header[5] = sections.length;183 184185 const body = concat([header, ...sections]);186 const checksum = await blake3Truncated(body);187 const inner = concat([body, checksum]);188189 190 191 192 193 194 const header5 = new Uint8Array(5);195 header5[0] = FORMAT_V3;196 new DataView(header5.buffer).setUint32(1, inner.length, true);197 const wrapped = padToNearestBucket(concat([header5, inner]));198199 const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));200 const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN));201 const key = await deriveKey(input.passphrase, salt);202 try {203 const { ciphertext, tag } = await aesGcmEncrypt(key, nonce, wrapped);204 return concat([salt, nonce, ciphertext, tag]);205 } finally {206 key.fill(0);207 }208}209210211212213214215216export async function parsePayload(217 envelope: Uint8Array,218 passphrase: string219): Promise<ParsePayloadOutput> {220 if (envelope.length < SALT_LEN + NONCE_LEN + TAG_LEN + HEADER_LEN + CHECKSUM_LEN) {221 throw new Error("PAYLOAD_TOO_SHORT");222 }223224 const salt = envelope.subarray(0, SALT_LEN);225 const nonce = envelope.subarray(SALT_LEN, SALT_LEN + NONCE_LEN);226 const tag = envelope.subarray(envelope.length - TAG_LEN);227 const ciphertext = envelope.subarray(SALT_LEN + NONCE_LEN, envelope.length - TAG_LEN);228229 const key = await deriveKey(passphrase, salt);230 let decrypted: Uint8Array;231 try {232 decrypted = await aesGcmDecrypt(key, nonce, ciphertext, tag);233 } catch {234 throw new Error("INVALID_PASSPHRASE");235 } finally {236 key.fill(0);237 }238239 240 241 let inner: Uint8Array;242 let formatVersion: number = 1;243 if (244 decrypted.length >= 5 &&245 (decrypted[0] === FORMAT_V2 || decrypted[0] === FORMAT_V3)246 ) {247 formatVersion = decrypted[0];248 const innerLen = new DataView(decrypted.buffer, decrypted.byteOffset, decrypted.byteLength).getUint32(1, true);249 if (5 + innerLen > decrypted.length) throw new Error("PAYLOAD_TOO_SHORT");250 inner = decrypted.subarray(5, 5 + innerLen);251 } else {252 inner = decrypted; 253 }254255 if (inner.length < HEADER_LEN + CHECKSUM_LEN) throw new Error("PAYLOAD_TOO_SHORT");256257 const body = inner.subarray(0, inner.length - CHECKSUM_LEN);258 const stored = inner.subarray(inner.length - CHECKSUM_LEN);259 const computed = await blake3Truncated(body);260 if (!constantTimeEqual(stored, computed)) throw new Error("CHECKSUM_FAILED");261262 const view = new DataView(body.buffer, body.byteOffset, body.byteLength);263 if (view.getUint32(0, false) !== MAGIC) throw new Error("BAD_MAGIC");264 const version = body[4];265 if (version !== VERSION) throw new Error(`UNSUPPORTED_VERSION:${version}`);266 const sectionCount = body[5];267268 let cursor = HEADER_LEN;269 const out: ParsePayloadOutput = {270 entries: [],271 settings: {},272 };273 let totalDecompressed = 0;274 for (let i = 0; i < sectionCount; i++) {275 if (cursor + 9 > body.length) throw new Error("PAYLOAD_TOO_SHORT");276 const type = body[cursor];277 const length = view.getUint32(cursor + 1, true);278 const clength = view.getUint32(cursor + 5, true);279 280 281 if (length > IMPORT_LIMITS.MAX_DECLARED_LENGTH) {282 throw importTooLarge(283 `declared section size ${length} exceeds ${IMPORT_LIMITS.MAX_DECLARED_LENGTH}`,284 );285 }286 287 288 totalDecompressed += length;289 if (totalDecompressed > IMPORT_LIMITS.MAX_DECOMPRESSED_BYTES) {290 throw importTooLarge("total decompressed payload exceeds limit");291 }292 const dataStart = cursor + 9;293 const dataEnd = dataStart + clength;294 if (dataEnd > body.length) throw new Error("PAYLOAD_TOO_SHORT");295 const compressed = body.subarray(dataStart, dataEnd);296 const data = await decompressZstd(compressed);297 if (data.length !== length) throw new Error("SECTION_LENGTH_MISMATCH");298299 switch (type) {300 case SECTION_ENTRIES:301 out.entries = decodeEntriesSection(data, formatVersion);302 break;303 case SECTION_SETTINGS:304 out.settings = decodeJsonSection<VaultSettings>(data);305 break;306 case SECTION_LICENSE:307 out.license = decodeJsonSection<PeachLicenseExport>(data);308 break;309 case SECTION_RECALL:310 out.manualSummonDomains = decodeJsonSection<Record<string, ManualSummonRecord>>(data);311 break;312 case SECTION_DECLINED_CARDS:313 out.declinedCardFingerprints = decodeJsonSection<string[]>(data);314 break;315 default:316 317 318 break;319 }320321 cursor = dataEnd;322 }323324 return out;325}326327328329330331async function buildSection(type: number, raw: Uint8Array): Promise<Uint8Array> {332 const compressed = await compressZstd(raw, 19);333 const out = new Uint8Array(9 + compressed.length);334 const view = new DataView(out.buffer);335 out[0] = type;336 view.setUint32(1, raw.length, true);337 view.setUint32(5, compressed.length, true);338 out.set(compressed, 9);339 return out;340}341342function encodeJsonSection(value: unknown): Uint8Array {343 return new TextEncoder().encode(JSON.stringify(value));344}345346function decodeJsonSection<T>(data: Uint8Array): T {347 return JSON.parse(new TextDecoder().decode(data)) as T;348}349350351352353354class StringTableBuilder {355 private map = new Map<string, number>();356 private list: string[] = [];357358 359 intern(value: string | undefined): number {360 if (!value) return 0;361 const existing = this.map.get(value);362 if (existing !== undefined) return existing;363 const idx = this.list.length + 1; 364 if (idx > 0xffff) throw new Error("STRING_TABLE_OVERFLOW");365 this.map.set(value, idx);366 this.list.push(value);367 return idx;368 }369370 serialize(): Uint8Array {371 const enc = new TextEncoder();372 const encoded = this.list.map((s) => enc.encode(s));373 let total = 2; 374 for (const e of encoded) total += 2 + e.length;375 const out = new Uint8Array(total);376 const view = new DataView(out.buffer);377 let cursor = 0;378 view.setUint16(cursor, encoded.length, true);379 cursor += 2;380 for (const e of encoded) {381 if (e.length > 0xffff) throw new Error("STRING_TOO_LONG");382 view.setUint16(cursor, e.length, true);383 cursor += 2;384 out.set(e, cursor);385 cursor += e.length;386 }387 return out;388 }389}390391function readStringTable(data: Uint8Array, cursor: number): { strings: string[]; cursor: number } {392 const view = new DataView(data.buffer, data.byteOffset, data.byteLength);393 const count = view.getUint16(cursor, true);394 cursor += 2;395 const strings: string[] = [];396 const dec = new TextDecoder();397 for (let i = 0; i < count; i++) {398 const len = view.getUint16(cursor, true);399 cursor += 2;400 strings.push(dec.decode(data.subarray(cursor, cursor + len)));401 cursor += len;402 }403 return { strings, cursor };404}405406function lookupString(strings: string[], idx: number): string | undefined {407 if (idx === 0) return undefined;408 return strings[idx - 1];409}410411function encodeEntriesSection(entries: VaultEntry[], formatVersion: number): Uint8Array {412 const table = new StringTableBuilder();413 const bodies: Uint8Array[] = [];414415 416 for (const entry of entries) table.intern(entry.title);417418 for (const entry of entries) {419 bodies.push(encodeEntry(entry, table, formatVersion));420 }421422 const tableBytes = table.serialize();423424 425 let bodiesLen = 0;426 for (const b of bodies) bodiesLen += b.length;427 const out = new Uint8Array(4 + tableBytes.length + bodiesLen);428 const view = new DataView(out.buffer);429 view.setUint32(0, entries.length, true);430 out.set(tableBytes, 4);431 let cursor = 4 + tableBytes.length;432 for (const b of bodies) {433 out.set(b, cursor);434 cursor += b.length;435 }436 return out;437}438439function decodeEntriesSection(data: Uint8Array, formatVersion: number): VaultEntry[] {440 const view = new DataView(data.buffer, data.byteOffset, data.byteLength);441 const count = view.getUint32(0, true);442 const { strings, cursor: afterTable } = readStringTable(data, 4);443444 const out: VaultEntry[] = [];445 let cursor = afterTable;446 for (let i = 0; i < count; i++) {447 const { entry, cursor: next } = decodeEntry(data, cursor, strings, formatVersion);448 out.push(entry);449 cursor = next;450 }451 return out;452}453454455456457458function encodeEntry(entry: VaultEntry, table: StringTableBuilder, formatVersion: number): Uint8Array {459 const writer = new BinaryWriter();460 const type = entryTypeByte(entry);461 writer.u8(type);462 writer.bytes(uuidToBytes(entry.id));463464 465 const createdSec = Math.floor((entry.createdAt ?? Date.now()) / 1000);466 const updatedSec = Math.floor((entry.modifiedAt ?? entry.createdAt ?? Date.now()) / 1000);467 const updatedDelta = Math.max(0, updatedSec - createdSec);468 const deletedSec = entry.deletedAt ? Math.floor(entry.deletedAt / 1000) : 0;469470 471 const flagOffset = writer.length;472 writer.u8(0);473 474 475 let flags2Offset = -1;476 if (formatVersion === FORMAT_V3) {477 flags2Offset = writer.length;478 writer.u8(0);479 }480481 writer.u32(createdSec);482 writer.u32(updatedDelta);483 writer.u32(deletedSec);484 writer.u16(table.intern(entry.title));485486 let flags = 0;487 let flags2 = 0;488 if (entry.deletedAt) flags |= F_HAS_DELETED;489490 if (type === ENTRY_TYPE_LOGIN) {491 writer.u16(table.intern(entry.url));492 writer.u16(table.intern(entry.username));493 writer.lpString(entry.password ?? "", 1);494 if (formatVersion === FORMAT_V3 && entry.email) {495 flags2 |= F2_HAS_EMAIL;496 writer.lpString(entry.email, 1);497 }498499 if (entry.totp) {500 flags |= F_HAS_TOTP;501 writer.lpString(entry.totp, 1);502 }503 if (entry.tags && entry.tags.length > 0) {504 flags |= F_HAS_TAGS;505 writer.u8(Math.min(entry.tags.length, 0xff));506 for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {507 writer.u16(table.intern(entry.tags[i]));508 }509 }510 if (entry.notes) {511 flags |= F_HAS_NOTES;512 writer.u16(table.intern(entry.notes));513 }514 if (entry.canary) {515 flags |= F_HAS_CANARY;516 writer.u16(table.intern(entry.canary.webhookUrl));517 writer.u16(table.intern(entry.canary.label));518 }519 if (entry.passwordChangedAt) {520 flags |= F_HAS_PWCHANGED;521 const pwSec = Math.floor(entry.passwordChangedAt / 1000);522 writer.u32(Math.max(0, pwSec - createdSec));523 }524 if (typeof entry.breach === "number" && entry.breach > 0) {525 flags |= F_HAS_BREACH;526 writer.u32(entry.breach);527 }528 if (entry.additionalUrls && entry.additionalUrls.length > 0) {529 flags |= F_HAS_ADDITIONAL_URLS;530 writer.u8(Math.min(entry.additionalUrls.length, 0xff));531 for (let i = 0; i < Math.min(entry.additionalUrls.length, 0xff); i++) {532 writer.u16(table.intern(entry.additionalUrls[i]));533 }534 }535 } else if (type === ENTRY_TYPE_NOTE) {536 if (entry.notes) {537 flags |= F_HAS_NOTES;538 writer.u16(table.intern(entry.notes));539 }540 if (entry.tags && entry.tags.length > 0) {541 flags |= F_HAS_TAGS;542 writer.u8(Math.min(entry.tags.length, 0xff));543 for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {544 writer.u16(table.intern(entry.tags[i]));545 }546 }547 } else if (type === ENTRY_TYPE_CARD) {548 writer.u16(table.intern(entry.cardholderName));549 writer.lpString(entry.cardNumber ?? "", 1);550 writer.lpString(entry.expMonth ?? "", 1);551 writer.lpString(entry.expYear ?? "", 1);552 writer.lpString(entry.cvc ?? "", 1);553 writer.u16(table.intern(entry.cardNickname));554 writer.u8(networkToByte(entry.network));555 writer.u16(table.intern(entry.last4));556 if (entry.tags && entry.tags.length > 0) {557 flags |= F_HAS_TAGS;558 writer.u8(Math.min(entry.tags.length, 0xff));559 for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {560 writer.u16(table.intern(entry.tags[i]));561 }562 }563 if (entry.notes) {564 flags |= F_HAS_NOTES;565 writer.u16(table.intern(entry.notes));566 }567 } else if (type === ENTRY_TYPE_PASSKEY) {568 569 570 const pk = entry.passkey;571 const flat = entry as unknown as Record<string, unknown>;572 const blob = encodeJsonSection({573 rpId: pk ? pk.rpId : flat["rpId"],574 rpName: pk ? pk.rpName : flat["rpName"],575 credentialId: pk ? pk.credentialId : flat["credentialId"],576 publicKeyCose: pk ? pk.publicKeyCose : flat["publicKeyCose"],577 privateKeyJwk: pk ? pk.privateKeyJwk : flat["privateKeyJwk"],578 userHandle: pk ? pk.userHandle : flat["userHandle"],579 userName: pk ? pk.userName : flat["userName"],580 userDisplayName: pk ? pk.userDisplayName : flat["userDisplayName"],581 algorithm: pk ? pk.algorithm : flat["algorithm"],582 signCount: pk ? pk.signCount : flat["signCount"],583 aaguid: pk ? pk.aaguid : flat["aaguid"],584 backupEligible: pk ? pk.backupEligible : flat["backupEligible"],585 backupState: pk ? pk.backupState : flat["backupState"],586 lastUsedAt: pk ? pk.lastUsedAt : flat["lastUsedAt"],587 588 589 590 591 592 593 594 595 ...(pk ? {596 ...(entry.password !== undefined ? { password: entry.password } : {}),597 ...(entry.url !== undefined ? { url: entry.url } : {}),598 ...(entry.username !== undefined ? { username: entry.username } : {}),599 ...(entry.email !== undefined ? { email: entry.email } : {}),600 ...(entry.totp !== undefined ? { totp: entry.totp } : {}),601 ...(entry.notes !== undefined ? { notes: entry.notes } : {}),602 ...(entry.tags !== undefined ? { tags: entry.tags } : {}),603 ...(entry.canary !== undefined ? { canary: entry.canary } : {}),604 ...(entry.passwordChangedAt !== undefined ? { passwordChangedAt: entry.passwordChangedAt } : {}),605 ...(entry.breach !== undefined ? { breach: entry.breach } : {}),606 ...(entry.additionalUrls !== undefined ? { additionalUrls: entry.additionalUrls } : {}),607 } : {}),608 });609 writer.u32(blob.length);610 writer.bytes(blob);611 }612613 writer.setU8(flagOffset, flags);614 if (flags2Offset >= 0) writer.setU8(flags2Offset, flags2);615 return writer.toBytes();616}617618function decodeEntry(619 data: Uint8Array,620 start: number,621 strings: string[],622 formatVersion: number623): { entry: VaultEntry; cursor: number } {624 const reader = new BinaryReader(data, start);625 const type = reader.u8();626 const id = bytesToUuid(reader.read(16));627 const flags = reader.u8();628 const flags2 = formatVersion === FORMAT_V3 ? reader.u8() : 0;629 const createdSec = reader.u32();630 const updatedDelta = reader.u32();631 const deletedSec = reader.u32();632 const titleIdx = reader.u16();633634 const createdAt = createdSec * 1000;635 const modifiedAt = (createdSec + updatedDelta) * 1000;636 const deletedAt = deletedSec > 0 ? deletedSec * 1000 : undefined;637 const title = lookupString(strings, titleIdx) ?? "";638639 const baseEntry: Partial<VaultEntry> = {640 id,641 title,642 createdAt,643 modifiedAt,644 ...(deletedAt !== undefined ? { deletedAt } : {}),645 };646647 if (type === ENTRY_TYPE_LOGIN) {648 const urlIdx = reader.u16();649 const usernameIdx = reader.u16();650 const password = reader.lpString(1);651 const tail: Partial<VaultEntry> = {};652653 if (flags2 & F2_HAS_EMAIL) tail.email = reader.lpString(1);654655 if (flags & F_HAS_TOTP) tail.totp = reader.lpString(1);656 if (flags & F_HAS_TAGS) {657 const cnt = reader.u8();658 const tags: string[] = [];659 for (let i = 0; i < cnt; i++) {660 const v = lookupString(strings, reader.u16());661 if (v !== undefined) tags.push(v);662 }663 tail.tags = tags;664 }665 if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());666 if (flags & F_HAS_CANARY) {667 const webhookUrl = lookupString(strings, reader.u16()) ?? "";668 const label = lookupString(strings, reader.u16());669 const canary: Canary = { webhookUrl };670 if (label !== undefined) canary.label = label;671 tail.canary = canary;672 }673 if (flags & F_HAS_PWCHANGED) {674 const pwDelta = reader.u32();675 tail.passwordChangedAt = (createdSec + pwDelta) * 1000;676 }677 if (flags & F_HAS_BREACH) tail.breach = reader.u32();678 if (flags & F_HAS_ADDITIONAL_URLS) {679 const cnt = reader.u8();680 const urls: string[] = [];681 for (let i = 0; i < cnt; i++) {682 const v = lookupString(strings, reader.u16());683 if (v !== undefined) urls.push(v);684 }685 tail.additionalUrls = urls;686 }687688 return {689 entry: {690 ...(baseEntry as VaultEntry),691 type: "login",692 url: lookupString(strings, urlIdx),693 username: lookupString(strings, usernameIdx),694 password,695 ...tail,696 } as VaultEntry,697 cursor: reader.cursor,698 };699 }700701 if (type === ENTRY_TYPE_NOTE) {702 const tail: Partial<VaultEntry> = {};703 if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());704 if (flags & F_HAS_TAGS) {705 const cnt = reader.u8();706 const tags: string[] = [];707 for (let i = 0; i < cnt; i++) {708 const v = lookupString(strings, reader.u16());709 if (v !== undefined) tags.push(v);710 }711 tail.tags = tags;712 }713 return {714 entry: { ...(baseEntry as VaultEntry), type: "note", ...tail } as VaultEntry,715 cursor: reader.cursor,716 };717 }718719 if (type === ENTRY_TYPE_CARD) {720 const cardholderIdx = reader.u16();721 const cardNumber = reader.lpString(1);722 const expMonth = reader.lpString(1);723 const expYear = reader.lpString(1);724 const cvc = reader.lpString(1);725 const nicknameIdx = reader.u16();726 const network = networkFromByte(reader.u8());727 const last4Idx = reader.u16();728 const tail: Partial<VaultEntry> = {};729 if (flags & F_HAS_TAGS) {730 const cnt = reader.u8();731 const tags: string[] = [];732 for (let i = 0; i < cnt; i++) {733 const v = lookupString(strings, reader.u16());734 if (v !== undefined) tags.push(v);735 }736 tail.tags = tags;737 }738 if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());739740 return {741 entry: {742 ...(baseEntry as VaultEntry),743 type: "card",744 cardholderName: lookupString(strings, cardholderIdx),745 cardNumber,746 expMonth,747 expYear,748 cvc,749 cardNickname: lookupString(strings, nicknameIdx),750 network,751 last4: lookupString(strings, last4Idx),752 ...tail,753 } as VaultEntry,754 cursor: reader.cursor,755 };756 }757758 if (type === ENTRY_TYPE_PASSKEY) {759 const blobLen = reader.u32();760 const blob = reader.read(blobLen);761 const obj = decodeJsonSection<Record<string, unknown>>(blob);762 return {763 entry: {764 ...(baseEntry as VaultEntry),765 type: "passkey",766 ...obj,767 } as VaultEntry,768 cursor: reader.cursor,769 };770 }771772 throw new Error(`UNKNOWN_ENTRY_TYPE:${type}`);773}774775function entryTypeByte(entry: VaultEntry): number {776 777 778 779 if (entry.passkey) return ENTRY_TYPE_PASSKEY;780 switch (entry.type) {781 case "note":782 return ENTRY_TYPE_NOTE;783 case "card":784 return ENTRY_TYPE_CARD;785 case "passkey":786 return ENTRY_TYPE_PASSKEY;787 default:788 return ENTRY_TYPE_LOGIN;789 }790}791792function networkToByte(network: CardNetwork | undefined): number {793 if (!network) return 0xff;794 const idx = NETWORK_VALUES.indexOf(network);795 return idx >= 0 ? idx : NETWORK_VALUES.indexOf("unknown");796}797798function networkFromByte(b: number): CardNetwork | undefined {799 if (b === 0xff) return undefined;800 return NETWORK_VALUES[b] ?? "unknown";801}802803804805806807export function uuidToBytes(uuid: string): Uint8Array {808 const hex = uuid.replace(/-/g, "");809 if (hex.length !== 32) throw new Error("UUID_FORMAT");810 const out = new Uint8Array(16);811 for (let i = 0; i < 16; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);812 return out;813}814815export function bytesToUuid(bytes: Uint8Array): string {816 if (bytes.length !== 16) throw new Error("UUID_LENGTH");817 const hex = Array.from(bytes)818 .map((b) => b.toString(16).padStart(2, "0"))819 .join("");820 return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;821}822823824825826827class BinaryWriter {828 private chunks: Uint8Array[] = [];829 private len = 0;830831 get length(): number {832 return this.len;833 }834835 u8(v: number): void {836 const b = new Uint8Array(1);837 b[0] = v & 0xff;838 this.push(b);839 }840841 setU8(offset: number, v: number): void {842 843 844 let cursor = 0;845 for (const c of this.chunks) {846 if (offset < cursor + c.length) {847 c[offset - cursor] = v & 0xff;848 return;849 }850 cursor += c.length;851 }852 throw new Error("setU8 out of range");853 }854855 u16(v: number): void {856 const b = new Uint8Array(2);857 new DataView(b.buffer).setUint16(0, v & 0xffff, true);858 this.push(b);859 }860861 u32(v: number): void {862 const b = new Uint8Array(4);863 new DataView(b.buffer).setUint32(0, v >>> 0, true);864 this.push(b);865 }866867 bytes(b: Uint8Array): void {868 this.push(b);869 }870871 872 lpString(value: string, lenSize: 1 | 2): void {873 const enc = new TextEncoder().encode(value);874 if (lenSize === 1) {875 if (enc.length > 0xff) throw new Error("STRING_TOO_LONG_U8");876 this.u8(enc.length);877 } else {878 if (enc.length > 0xffff) throw new Error("STRING_TOO_LONG_U16");879 this.u16(enc.length);880 }881 this.push(enc);882 }883884 toBytes(): Uint8Array {885 const out = new Uint8Array(this.len);886 let cursor = 0;887 for (const c of this.chunks) {888 out.set(c, cursor);889 cursor += c.length;890 }891 return out;892 }893894 private push(b: Uint8Array): void {895 this.chunks.push(b);896 this.len += b.length;897 }898}899900class BinaryReader {901 cursor: number;902 private view: DataView;903 constructor(private data: Uint8Array, start: number) {904 this.cursor = start;905 this.view = new DataView(data.buffer, data.byteOffset, data.byteLength);906 }907 u8(): number {908 return this.data[this.cursor++];909 }910 u16(): number {911 const v = this.view.getUint16(this.cursor, true);912 this.cursor += 2;913 return v;914 }915 u32(): number {916 const v = this.view.getUint32(this.cursor, true);917 this.cursor += 4;918 return v;919 }920 read(n: number): Uint8Array {921 const out = this.data.subarray(this.cursor, this.cursor + n);922 this.cursor += n;923 return out;924 }925 lpString(lenSize: 1 | 2): string {926 const len = lenSize === 1 ? this.u8() : this.u16();927 return new TextDecoder().decode(this.read(len));928 }929}930931932933934935async function deriveKey(passphrase: string, salt: Uint8Array): Promise<Uint8Array> {936 return argon2id({937 password: passphrase,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}