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 763 764 765 766 767 const picked: Partial<VaultEntry> = {};768 const allow = [769 "passkey", "username", "email", "password", "url", "additionalUrls",770 "totp", "notes", "tags", "passwordChangedAt", "passwordHistory",771 "canary", "deletedAt", "breach",772 773 774 "rpId", "rpName", "credentialId", "publicKeyCose", "privateKeyJwk",775 "userHandle", "userName", "userDisplayName", "algorithm", "signCount",776 "aaguid", "backupEligible", "backupState", "lastUsedAt", "browser",777 "transports",778 ] as const;779 for (const k of allow) {780 if (k in obj) (picked as Record<string, unknown>)[k] = obj[k];781 }782 return {783 entry: {784 ...(baseEntry as VaultEntry),785 type: "passkey",786 ...picked,787 } as VaultEntry,788 cursor: reader.cursor,789 };790 }791792 throw new Error(`UNKNOWN_ENTRY_TYPE:${type}`);793}794795function entryTypeByte(entry: VaultEntry): number {796 797 798 799 if (entry.passkey) return ENTRY_TYPE_PASSKEY;800 switch (entry.type) {801 case "note":802 return ENTRY_TYPE_NOTE;803 case "card":804 return ENTRY_TYPE_CARD;805 case "passkey":806 return ENTRY_TYPE_PASSKEY;807 default:808 return ENTRY_TYPE_LOGIN;809 }810}811812function networkToByte(network: CardNetwork | undefined): number {813 if (!network) return 0xff;814 const idx = NETWORK_VALUES.indexOf(network);815 return idx >= 0 ? idx : NETWORK_VALUES.indexOf("unknown");816}817818function networkFromByte(b: number): CardNetwork | undefined {819 if (b === 0xff) return undefined;820 return NETWORK_VALUES[b] ?? "unknown";821}822823824825826827export function uuidToBytes(uuid: string): Uint8Array {828 const hex = uuid.replace(/-/g, "");829 if (hex.length !== 32) throw new Error("UUID_FORMAT");830 const out = new Uint8Array(16);831 for (let i = 0; i < 16; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);832 return out;833}834835export function bytesToUuid(bytes: Uint8Array): string {836 if (bytes.length !== 16) throw new Error("UUID_LENGTH");837 const hex = Array.from(bytes)838 .map((b) => b.toString(16).padStart(2, "0"))839 .join("");840 return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;841}842843844845846847class BinaryWriter {848 private chunks: Uint8Array[] = [];849 private len = 0;850851 get length(): number {852 return this.len;853 }854855 u8(v: number): void {856 const b = new Uint8Array(1);857 b[0] = v & 0xff;858 this.push(b);859 }860861 setU8(offset: number, v: number): void {862 863 864 let cursor = 0;865 for (const c of this.chunks) {866 if (offset < cursor + c.length) {867 c[offset - cursor] = v & 0xff;868 return;869 }870 cursor += c.length;871 }872 throw new Error("setU8 out of range");873 }874875 u16(v: number): void {876 const b = new Uint8Array(2);877 new DataView(b.buffer).setUint16(0, v & 0xffff, true);878 this.push(b);879 }880881 u32(v: number): void {882 const b = new Uint8Array(4);883 new DataView(b.buffer).setUint32(0, v >>> 0, true);884 this.push(b);885 }886887 bytes(b: Uint8Array): void {888 this.push(b);889 }890891 892 lpString(value: string, lenSize: 1 | 2): void {893 const enc = new TextEncoder().encode(value);894 if (lenSize === 1) {895 if (enc.length > 0xff) throw new Error("STRING_TOO_LONG_U8");896 this.u8(enc.length);897 } else {898 if (enc.length > 0xffff) throw new Error("STRING_TOO_LONG_U16");899 this.u16(enc.length);900 }901 this.push(enc);902 }903904 toBytes(): Uint8Array {905 const out = new Uint8Array(this.len);906 let cursor = 0;907 for (const c of this.chunks) {908 out.set(c, cursor);909 cursor += c.length;910 }911 return out;912 }913914 private push(b: Uint8Array): void {915 this.chunks.push(b);916 this.len += b.length;917 }918}919920class BinaryReader {921 cursor: number;922 private view: DataView;923 constructor(private data: Uint8Array, start: number) {924 this.cursor = start;925 this.view = new DataView(data.buffer, data.byteOffset, data.byteLength);926 }927 u8(): number {928 return this.data[this.cursor++];929 }930 u16(): number {931 const v = this.view.getUint16(this.cursor, true);932 this.cursor += 2;933 return v;934 }935 u32(): number {936 const v = this.view.getUint32(this.cursor, true);937 this.cursor += 4;938 return v;939 }940 read(n: number): Uint8Array {941 const out = this.data.subarray(this.cursor, this.cursor + n);942 this.cursor += n;943 return out;944 }945 lpString(lenSize: 1 | 2): string {946 const len = lenSize === 1 ? this.u8() : this.u16();947 return new TextDecoder().decode(this.read(len));948 }949}950951952953954955async function deriveKey(passphrase: string, salt: Uint8Array): Promise<Uint8Array> {956 return argon2id({957 password: passphrase,958 salt,959 parallelism: ARGON2_PARALLELISM,960 iterations: ARGON2_ITERATIONS,961 memorySize: ARGON2_MEMORY_KIB,962 hashLength: 32,963 outputType: "binary",964 });965}966967async function aesGcmEncrypt(968 key: Uint8Array,969 nonce: Uint8Array,970 plaintext: Uint8Array971): Promise<{ ciphertext: Uint8Array; tag: Uint8Array }> {972 const cryptoKey = await crypto.subtle.importKey(973 "raw",974 key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,975 { name: "AES-GCM" },976 false,977 ["encrypt"]978 );979 const raw = await crypto.subtle.encrypt(980 { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, tagLength: 128 },981 cryptoKey,982 plaintext.buffer.slice(983 plaintext.byteOffset,984 plaintext.byteOffset + plaintext.byteLength985 ) as ArrayBuffer986 );987 const out = new Uint8Array(raw);988 return { ciphertext: out.slice(0, out.length - 16), tag: out.slice(out.length - 16) };989}990991async function aesGcmDecrypt(992 key: Uint8Array,993 nonce: Uint8Array,994 ciphertext: Uint8Array,995 tag: Uint8Array996): Promise<Uint8Array> {997 const cryptoKey = await crypto.subtle.importKey(998 "raw",999 key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,1000 { name: "AES-GCM" },1001 false,1002 ["decrypt"]1003 );1004 const combined = concat([ciphertext, tag]);1005 const raw = await crypto.subtle.decrypt(1006 {1007 name: "AES-GCM",1008 iv: nonce.buffer.slice(1009 nonce.byteOffset,1010 nonce.byteOffset + nonce.byteLength1011 ) as ArrayBuffer,1012 tagLength: 128,1013 },1014 cryptoKey,1015 combined.buffer.slice(1016 combined.byteOffset,1017 combined.byteOffset + combined.byteLength1018 ) as ArrayBuffer1019 );1020 return new Uint8Array(raw);1021}10221023async function blake3Truncated(data: Uint8Array): Promise<Uint8Array> {1024 const hex = await blake3(data);1025 1026 const out = new Uint8Array(CHECKSUM_LEN);1027 for (let i = 0; i < CHECKSUM_LEN; i++) {1028 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);1029 }1030 return out;1031}10321033function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {1034 if (a.length !== b.length) return false;1035 let diff = 0;1036 for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];1037 return diff === 0;1038}10391040function concat(parts: Uint8Array[]): Uint8Array {1041 let total = 0;1042 for (const p of parts) total += p.length;1043 const out = new Uint8Array(total);1044 let cursor = 0;1045 for (const p of parts) {1046 out.set(p, cursor);1047 cursor += p.length;1048 }1049 return out;1050}