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(157 input: BuildPayloadInput,158 159 160 161 opts: { raw?: boolean } = {},162): Promise<Uint8Array> {163 const sections: Uint8Array[] = [];164165 sections.push(await buildSection(SECTION_ENTRIES, encodeEntriesSection(input.entries, FORMAT_V3)));166 sections.push(await buildSection(SECTION_SETTINGS, encodeJsonSection(input.settings)));167 if (input.license) {168 sections.push(await buildSection(SECTION_LICENSE, encodeJsonSection(input.license)));169 }170 if (input.manualSummonDomains && Object.keys(input.manualSummonDomains).length > 0) {171 sections.push(172 await buildSection(SECTION_RECALL, encodeJsonSection(input.manualSummonDomains))173 );174 }175 if (input.declinedCardFingerprints && input.declinedCardFingerprints.length > 0) {176 sections.push(177 await buildSection(178 SECTION_DECLINED_CARDS,179 encodeJsonSection(input.declinedCardFingerprints)180 )181 );182 }183184 const header = new Uint8Array(HEADER_LEN);185 const headerView = new DataView(header.buffer);186 headerView.setUint32(0, MAGIC, false); 187 header[4] = VERSION;188 header[5] = sections.length;189 190191 const body = concat([header, ...sections]);192 const checksum = await blake3Truncated(body);193 const inner = concat([body, checksum]);194195 196 197 198 199 200 const header5 = new Uint8Array(5);201 header5[0] = FORMAT_V3;202 new DataView(header5.buffer).setUint32(1, inner.length, true);203 const wrapped = padToNearestBucket(concat([header5, inner]));204205 const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));206 const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN));207 const key = await deriveKey(input.passphrase, salt, opts);208 try {209 const { ciphertext, tag } = await aesGcmEncrypt(key, nonce, wrapped);210 return concat([salt, nonce, ciphertext, tag]);211 } finally {212 key.fill(0);213 }214}215216217218219220221222export async function parsePayload(223 envelope: Uint8Array,224 passphrase: string225): Promise<ParsePayloadOutput> {226 if (envelope.length < SALT_LEN + NONCE_LEN + TAG_LEN + HEADER_LEN + CHECKSUM_LEN) {227 throw new Error("PAYLOAD_TOO_SHORT");228 }229230 const salt = envelope.subarray(0, SALT_LEN);231 const nonce = envelope.subarray(SALT_LEN, SALT_LEN + NONCE_LEN);232 const tag = envelope.subarray(envelope.length - TAG_LEN);233 const ciphertext = envelope.subarray(SALT_LEN + NONCE_LEN, envelope.length - TAG_LEN);234235 let decrypted: Uint8Array;236 const key = await deriveKey(passphrase, salt);237 try {238 decrypted = await aesGcmDecrypt(key, nonce, ciphertext, tag);239 } catch {240 241 242 243 244 if (passphrase === passphrase.normalize("NFC")) {245 throw new Error("INVALID_PASSPHRASE");246 }247 const rawKey = await deriveKey(passphrase, salt, { raw: true });248 try {249 decrypted = await aesGcmDecrypt(rawKey, nonce, ciphertext, tag);250 } catch {251 throw new Error("INVALID_PASSPHRASE");252 } finally {253 rawKey.fill(0);254 }255 } finally {256 key.fill(0);257 }258259 260 261 let inner: Uint8Array;262 let formatVersion: number = 1;263 if (264 decrypted.length >= 5 &&265 (decrypted[0] === FORMAT_V2 || decrypted[0] === FORMAT_V3)266 ) {267 formatVersion = decrypted[0];268 const innerLen = new DataView(decrypted.buffer, decrypted.byteOffset, decrypted.byteLength).getUint32(1, true);269 if (5 + innerLen > decrypted.length) throw new Error("PAYLOAD_TOO_SHORT");270 inner = decrypted.subarray(5, 5 + innerLen);271 } else {272 inner = decrypted; 273 }274275 if (inner.length < HEADER_LEN + CHECKSUM_LEN) throw new Error("PAYLOAD_TOO_SHORT");276277 const body = inner.subarray(0, inner.length - CHECKSUM_LEN);278 const stored = inner.subarray(inner.length - CHECKSUM_LEN);279 const computed = await blake3Truncated(body);280 if (!constantTimeEqual(stored, computed)) throw new Error("CHECKSUM_FAILED");281282 const view = new DataView(body.buffer, body.byteOffset, body.byteLength);283 if (view.getUint32(0, false) !== MAGIC) throw new Error("BAD_MAGIC");284 const version = body[4];285 if (version !== VERSION) throw new Error(`UNSUPPORTED_VERSION:${version}`);286 const sectionCount = body[5];287288 let cursor = HEADER_LEN;289 const out: ParsePayloadOutput = {290 entries: [],291 settings: {},292 };293 let totalDecompressed = 0;294 for (let i = 0; i < sectionCount; i++) {295 if (cursor + 9 > body.length) throw new Error("PAYLOAD_TOO_SHORT");296 const type = body[cursor];297 const length = view.getUint32(cursor + 1, true);298 const clength = view.getUint32(cursor + 5, true);299 300 301 if (length > IMPORT_LIMITS.MAX_DECLARED_LENGTH) {302 throw importTooLarge(303 `declared section size ${length} exceeds ${IMPORT_LIMITS.MAX_DECLARED_LENGTH}`,304 );305 }306 307 308 totalDecompressed += length;309 if (totalDecompressed > IMPORT_LIMITS.MAX_DECOMPRESSED_BYTES) {310 throw importTooLarge("total decompressed payload exceeds limit");311 }312 const dataStart = cursor + 9;313 const dataEnd = dataStart + clength;314 if (dataEnd > body.length) throw new Error("PAYLOAD_TOO_SHORT");315 const compressed = body.subarray(dataStart, dataEnd);316 const data = await decompressZstd(compressed);317 if (data.length !== length) throw new Error("SECTION_LENGTH_MISMATCH");318319 switch (type) {320 case SECTION_ENTRIES:321 out.entries = decodeEntriesSection(data, formatVersion);322 break;323 case SECTION_SETTINGS:324 out.settings = decodeJsonSection<VaultSettings>(data);325 break;326 case SECTION_LICENSE:327 out.license = decodeJsonSection<PeachLicenseExport>(data);328 break;329 case SECTION_RECALL:330 out.manualSummonDomains = decodeJsonSection<Record<string, ManualSummonRecord>>(data);331 break;332 case SECTION_DECLINED_CARDS:333 out.declinedCardFingerprints = decodeJsonSection<string[]>(data);334 break;335 default:336 337 338 break;339 }340341 cursor = dataEnd;342 }343344 return out;345}346347348349350351async function buildSection(type: number, raw: Uint8Array): Promise<Uint8Array> {352 const compressed = await compressZstd(raw, 19);353 const out = new Uint8Array(9 + compressed.length);354 const view = new DataView(out.buffer);355 out[0] = type;356 view.setUint32(1, raw.length, true);357 view.setUint32(5, compressed.length, true);358 out.set(compressed, 9);359 return out;360}361362function encodeJsonSection(value: unknown): Uint8Array {363 return new TextEncoder().encode(JSON.stringify(value));364}365366function decodeJsonSection<T>(data: Uint8Array): T {367 return JSON.parse(new TextDecoder().decode(data)) as T;368}369370371372373374class StringTableBuilder {375 private map = new Map<string, number>();376 private list: string[] = [];377378 379 intern(value: string | undefined): number {380 if (!value) return 0;381 const existing = this.map.get(value);382 if (existing !== undefined) return existing;383 const idx = this.list.length + 1; 384 if (idx > 0xffff) throw new Error("STRING_TABLE_OVERFLOW");385 this.map.set(value, idx);386 this.list.push(value);387 return idx;388 }389390 serialize(): Uint8Array {391 const enc = new TextEncoder();392 const encoded = this.list.map((s) => enc.encode(s));393 let total = 2; 394 for (const e of encoded) total += 2 + e.length;395 const out = new Uint8Array(total);396 const view = new DataView(out.buffer);397 let cursor = 0;398 view.setUint16(cursor, encoded.length, true);399 cursor += 2;400 for (const e of encoded) {401 if (e.length > 0xffff) throw new Error("STRING_TOO_LONG");402 view.setUint16(cursor, e.length, true);403 cursor += 2;404 out.set(e, cursor);405 cursor += e.length;406 }407 return out;408 }409}410411function readStringTable(data: Uint8Array, cursor: number): { strings: string[]; cursor: number } {412 const view = new DataView(data.buffer, data.byteOffset, data.byteLength);413 const count = view.getUint16(cursor, true);414 cursor += 2;415 const strings: string[] = [];416 const dec = new TextDecoder();417 for (let i = 0; i < count; i++) {418 const len = view.getUint16(cursor, true);419 cursor += 2;420 strings.push(dec.decode(data.subarray(cursor, cursor + len)));421 cursor += len;422 }423 return { strings, cursor };424}425426function lookupString(strings: string[], idx: number): string | undefined {427 if (idx === 0) return undefined;428 return strings[idx - 1];429}430431function encodeEntriesSection(entries: VaultEntry[], formatVersion: number): Uint8Array {432 const table = new StringTableBuilder();433 const bodies: Uint8Array[] = [];434435 436 for (const entry of entries) table.intern(entry.title);437438 for (const entry of entries) {439 bodies.push(encodeEntry(entry, table, formatVersion));440 }441442 const tableBytes = table.serialize();443444 445 let bodiesLen = 0;446 for (const b of bodies) bodiesLen += b.length;447 const out = new Uint8Array(4 + tableBytes.length + bodiesLen);448 const view = new DataView(out.buffer);449 view.setUint32(0, entries.length, true);450 out.set(tableBytes, 4);451 let cursor = 4 + tableBytes.length;452 for (const b of bodies) {453 out.set(b, cursor);454 cursor += b.length;455 }456 return out;457}458459function decodeEntriesSection(data: Uint8Array, formatVersion: number): VaultEntry[] {460 const view = new DataView(data.buffer, data.byteOffset, data.byteLength);461 const count = view.getUint32(0, true);462 const { strings, cursor: afterTable } = readStringTable(data, 4);463464 const out: VaultEntry[] = [];465 let cursor = afterTable;466 for (let i = 0; i < count; i++) {467 const { entry, cursor: next } = decodeEntry(data, cursor, strings, formatVersion);468 out.push(entry);469 cursor = next;470 }471 return out;472}473474475476477478function encodeEntry(entry: VaultEntry, table: StringTableBuilder, formatVersion: number): Uint8Array {479 const writer = new BinaryWriter();480 const type = entryTypeByte(entry);481 writer.u8(type);482 writer.bytes(uuidToBytes(entry.id));483484 485 const createdSec = Math.floor((entry.createdAt ?? Date.now()) / 1000);486 const updatedSec = Math.floor((entry.modifiedAt ?? entry.createdAt ?? Date.now()) / 1000);487 const updatedDelta = Math.max(0, updatedSec - createdSec);488 const deletedSec = entry.deletedAt ? Math.floor(entry.deletedAt / 1000) : 0;489490 491 const flagOffset = writer.length;492 writer.u8(0);493 494 495 let flags2Offset = -1;496 if (formatVersion === FORMAT_V3) {497 flags2Offset = writer.length;498 writer.u8(0);499 }500501 writer.u32(createdSec);502 writer.u32(updatedDelta);503 writer.u32(deletedSec);504 writer.u16(table.intern(entry.title));505506 let flags = 0;507 let flags2 = 0;508 if (entry.deletedAt) flags |= F_HAS_DELETED;509510 if (type === ENTRY_TYPE_LOGIN) {511 writer.u16(table.intern(entry.url));512 writer.u16(table.intern(entry.username));513 writer.lpString(entry.password ?? "", 1);514 if (formatVersion === FORMAT_V3 && entry.email) {515 flags2 |= F2_HAS_EMAIL;516 writer.lpString(entry.email, 1);517 }518519 if (entry.totp) {520 flags |= F_HAS_TOTP;521 writer.lpString(entry.totp, 1);522 }523 if (entry.tags && entry.tags.length > 0) {524 flags |= F_HAS_TAGS;525 writer.u8(Math.min(entry.tags.length, 0xff));526 for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {527 writer.u16(table.intern(entry.tags[i]));528 }529 }530 if (entry.notes) {531 flags |= F_HAS_NOTES;532 writer.u16(table.intern(entry.notes));533 }534 if (entry.canary) {535 flags |= F_HAS_CANARY;536 writer.u16(table.intern(entry.canary.webhookUrl));537 writer.u16(table.intern(entry.canary.label));538 }539 if (entry.passwordChangedAt) {540 flags |= F_HAS_PWCHANGED;541 const pwSec = Math.floor(entry.passwordChangedAt / 1000);542 writer.u32(Math.max(0, pwSec - createdSec));543 }544 if (typeof entry.breach === "number" && entry.breach > 0) {545 flags |= F_HAS_BREACH;546 writer.u32(entry.breach);547 }548 if (entry.additionalUrls && entry.additionalUrls.length > 0) {549 flags |= F_HAS_ADDITIONAL_URLS;550 writer.u8(Math.min(entry.additionalUrls.length, 0xff));551 for (let i = 0; i < Math.min(entry.additionalUrls.length, 0xff); i++) {552 writer.u16(table.intern(entry.additionalUrls[i]));553 }554 }555 } else if (type === ENTRY_TYPE_NOTE) {556 if (entry.notes) {557 flags |= F_HAS_NOTES;558 writer.u16(table.intern(entry.notes));559 }560 if (entry.tags && entry.tags.length > 0) {561 flags |= F_HAS_TAGS;562 writer.u8(Math.min(entry.tags.length, 0xff));563 for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {564 writer.u16(table.intern(entry.tags[i]));565 }566 }567 } else if (type === ENTRY_TYPE_CARD) {568 writer.u16(table.intern(entry.cardholderName));569 writer.lpString(entry.cardNumber ?? "", 1);570 writer.lpString(entry.expMonth ?? "", 1);571 writer.lpString(entry.expYear ?? "", 1);572 writer.lpString(entry.cvc ?? "", 1);573 writer.u16(table.intern(entry.cardNickname));574 writer.u8(networkToByte(entry.network));575 writer.u16(table.intern(entry.last4));576 if (entry.tags && entry.tags.length > 0) {577 flags |= F_HAS_TAGS;578 writer.u8(Math.min(entry.tags.length, 0xff));579 for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {580 writer.u16(table.intern(entry.tags[i]));581 }582 }583 if (entry.notes) {584 flags |= F_HAS_NOTES;585 writer.u16(table.intern(entry.notes));586 }587 } else if (type === ENTRY_TYPE_PASSKEY) {588 589 590 const pk = entry.passkey;591 const flat = entry as unknown as Record<string, unknown>;592 const blob = encodeJsonSection({593 rpId: pk ? pk.rpId : flat["rpId"],594 rpName: pk ? pk.rpName : flat["rpName"],595 credentialId: pk ? pk.credentialId : flat["credentialId"],596 publicKeyCose: pk ? pk.publicKeyCose : flat["publicKeyCose"],597 privateKeyJwk: pk ? pk.privateKeyJwk : flat["privateKeyJwk"],598 userHandle: pk ? pk.userHandle : flat["userHandle"],599 userName: pk ? pk.userName : flat["userName"],600 userDisplayName: pk ? pk.userDisplayName : flat["userDisplayName"],601 algorithm: pk ? pk.algorithm : flat["algorithm"],602 signCount: pk ? pk.signCount : flat["signCount"],603 aaguid: pk ? pk.aaguid : flat["aaguid"],604 backupEligible: pk ? pk.backupEligible : flat["backupEligible"],605 backupState: pk ? pk.backupState : flat["backupState"],606 lastUsedAt: pk ? pk.lastUsedAt : flat["lastUsedAt"],607 608 609 610 611 612 613 614 615 ...(pk ? {616 ...(entry.password !== undefined ? { password: entry.password } : {}),617 ...(entry.url !== undefined ? { url: entry.url } : {}),618 ...(entry.username !== undefined ? { username: entry.username } : {}),619 ...(entry.email !== undefined ? { email: entry.email } : {}),620 ...(entry.totp !== undefined ? { totp: entry.totp } : {}),621 ...(entry.notes !== undefined ? { notes: entry.notes } : {}),622 ...(entry.tags !== undefined ? { tags: entry.tags } : {}),623 ...(entry.canary !== undefined ? { canary: entry.canary } : {}),624 ...(entry.passwordChangedAt !== undefined ? { passwordChangedAt: entry.passwordChangedAt } : {}),625 ...(entry.breach !== undefined ? { breach: entry.breach } : {}),626 ...(entry.additionalUrls !== undefined ? { additionalUrls: entry.additionalUrls } : {}),627 } : {}),628 });629 writer.u32(blob.length);630 writer.bytes(blob);631 }632633 writer.setU8(flagOffset, flags);634 if (flags2Offset >= 0) writer.setU8(flags2Offset, flags2);635 return writer.toBytes();636}637638function decodeEntry(639 data: Uint8Array,640 start: number,641 strings: string[],642 formatVersion: number643): { entry: VaultEntry; cursor: number } {644 const reader = new BinaryReader(data, start);645 const type = reader.u8();646 const id = bytesToUuid(reader.read(16));647 const flags = reader.u8();648 const flags2 = formatVersion === FORMAT_V3 ? reader.u8() : 0;649 const createdSec = reader.u32();650 const updatedDelta = reader.u32();651 const deletedSec = reader.u32();652 const titleIdx = reader.u16();653654 const createdAt = createdSec * 1000;655 const modifiedAt = (createdSec + updatedDelta) * 1000;656 const deletedAt = deletedSec > 0 ? deletedSec * 1000 : undefined;657 const title = lookupString(strings, titleIdx) ?? "";658659 const baseEntry: Partial<VaultEntry> = {660 id,661 title,662 createdAt,663 modifiedAt,664 ...(deletedAt !== undefined ? { deletedAt } : {}),665 };666667 if (type === ENTRY_TYPE_LOGIN) {668 const urlIdx = reader.u16();669 const usernameIdx = reader.u16();670 const password = reader.lpString(1);671 const tail: Partial<VaultEntry> = {};672673 if (flags2 & F2_HAS_EMAIL) tail.email = reader.lpString(1);674675 if (flags & F_HAS_TOTP) tail.totp = reader.lpString(1);676 if (flags & F_HAS_TAGS) {677 const cnt = reader.u8();678 const tags: string[] = [];679 for (let i = 0; i < cnt; i++) {680 const v = lookupString(strings, reader.u16());681 if (v !== undefined) tags.push(v);682 }683 tail.tags = tags;684 }685 if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());686 if (flags & F_HAS_CANARY) {687 const webhookUrl = lookupString(strings, reader.u16()) ?? "";688 const label = lookupString(strings, reader.u16());689 const canary: Canary = { webhookUrl };690 if (label !== undefined) canary.label = label;691 tail.canary = canary;692 }693 if (flags & F_HAS_PWCHANGED) {694 const pwDelta = reader.u32();695 tail.passwordChangedAt = (createdSec + pwDelta) * 1000;696 }697 if (flags & F_HAS_BREACH) tail.breach = reader.u32();698 if (flags & F_HAS_ADDITIONAL_URLS) {699 const cnt = reader.u8();700 const urls: string[] = [];701 for (let i = 0; i < cnt; i++) {702 const v = lookupString(strings, reader.u16());703 if (v !== undefined) urls.push(v);704 }705 tail.additionalUrls = urls;706 }707708 return {709 entry: {710 ...(baseEntry as VaultEntry),711 type: "login",712 url: lookupString(strings, urlIdx),713 username: lookupString(strings, usernameIdx),714 password,715 ...tail,716 } as VaultEntry,717 cursor: reader.cursor,718 };719 }720721 if (type === ENTRY_TYPE_NOTE) {722 const tail: Partial<VaultEntry> = {};723 if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());724 if (flags & F_HAS_TAGS) {725 const cnt = reader.u8();726 const tags: string[] = [];727 for (let i = 0; i < cnt; i++) {728 const v = lookupString(strings, reader.u16());729 if (v !== undefined) tags.push(v);730 }731 tail.tags = tags;732 }733 return {734 entry: { ...(baseEntry as VaultEntry), type: "note", ...tail } as VaultEntry,735 cursor: reader.cursor,736 };737 }738739 if (type === ENTRY_TYPE_CARD) {740 const cardholderIdx = reader.u16();741 const cardNumber = reader.lpString(1);742 const expMonth = reader.lpString(1);743 const expYear = reader.lpString(1);744 const cvc = reader.lpString(1);745 const nicknameIdx = reader.u16();746 const network = networkFromByte(reader.u8());747 const last4Idx = reader.u16();748 const tail: Partial<VaultEntry> = {};749 if (flags & F_HAS_TAGS) {750 const cnt = reader.u8();751 const tags: string[] = [];752 for (let i = 0; i < cnt; i++) {753 const v = lookupString(strings, reader.u16());754 if (v !== undefined) tags.push(v);755 }756 tail.tags = tags;757 }758 if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());759760 return {761 entry: {762 ...(baseEntry as VaultEntry),763 type: "card",764 cardholderName: lookupString(strings, cardholderIdx),765 cardNumber,766 expMonth,767 expYear,768 cvc,769 cardNickname: lookupString(strings, nicknameIdx),770 network,771 last4: lookupString(strings, last4Idx),772 ...tail,773 } as VaultEntry,774 cursor: reader.cursor,775 };776 }777778 if (type === ENTRY_TYPE_PASSKEY) {779 const blobLen = reader.u32();780 const blob = reader.read(blobLen);781 const obj = decodeJsonSection<Record<string, unknown>>(blob);782 783 784 785 786 787 const picked: Partial<VaultEntry> = {};788 const allow = [789 "passkey", "username", "email", "password", "url", "additionalUrls",790 "totp", "notes", "tags", "passwordChangedAt", "passwordHistory",791 "canary", "deletedAt", "breach",792 793 794 "rpId", "rpName", "credentialId", "publicKeyCose", "privateKeyJwk",795 "userHandle", "userName", "userDisplayName", "algorithm", "signCount",796 "aaguid", "backupEligible", "backupState", "lastUsedAt", "browser",797 "transports",798 ] as const;799 for (const k of allow) {800 if (k in obj) (picked as Record<string, unknown>)[k] = obj[k];801 }802 return {803 entry: {804 ...(baseEntry as VaultEntry),805 type: "passkey",806 ...picked,807 } as VaultEntry,808 cursor: reader.cursor,809 };810 }811812 throw new Error(`UNKNOWN_ENTRY_TYPE:${type}`);813}814815function entryTypeByte(entry: VaultEntry): number {816 817 818 819 if (entry.passkey) return ENTRY_TYPE_PASSKEY;820 switch (entry.type) {821 case "note":822 return ENTRY_TYPE_NOTE;823 case "card":824 return ENTRY_TYPE_CARD;825 case "passkey":826 return ENTRY_TYPE_PASSKEY;827 default:828 return ENTRY_TYPE_LOGIN;829 }830}831832function networkToByte(network: CardNetwork | undefined): number {833 if (!network) return 0xff;834 const idx = NETWORK_VALUES.indexOf(network);835 return idx >= 0 ? idx : NETWORK_VALUES.indexOf("unknown");836}837838function networkFromByte(b: number): CardNetwork | undefined {839 if (b === 0xff) return undefined;840 return NETWORK_VALUES[b] ?? "unknown";841}842843844845846847export function uuidToBytes(uuid: string): Uint8Array {848 const hex = uuid.replace(/-/g, "");849 if (hex.length !== 32) throw new Error("UUID_FORMAT");850 const out = new Uint8Array(16);851 for (let i = 0; i < 16; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);852 return out;853}854855export function bytesToUuid(bytes: Uint8Array): string {856 if (bytes.length !== 16) throw new Error("UUID_LENGTH");857 const hex = Array.from(bytes)858 .map((b) => b.toString(16).padStart(2, "0"))859 .join("");860 return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;861}862863864865866867class BinaryWriter {868 private chunks: Uint8Array[] = [];869 private len = 0;870871 get length(): number {872 return this.len;873 }874875 u8(v: number): void {876 const b = new Uint8Array(1);877 b[0] = v & 0xff;878 this.push(b);879 }880881 setU8(offset: number, v: number): void {882 883 884 let cursor = 0;885 for (const c of this.chunks) {886 if (offset < cursor + c.length) {887 c[offset - cursor] = v & 0xff;888 return;889 }890 cursor += c.length;891 }892 throw new Error("setU8 out of range");893 }894895 u16(v: number): void {896 const b = new Uint8Array(2);897 new DataView(b.buffer).setUint16(0, v & 0xffff, true);898 this.push(b);899 }900901 u32(v: number): void {902 const b = new Uint8Array(4);903 new DataView(b.buffer).setUint32(0, v >>> 0, true);904 this.push(b);905 }906907 bytes(b: Uint8Array): void {908 this.push(b);909 }910911 912 lpString(value: string, lenSize: 1 | 2): void {913 const enc = new TextEncoder().encode(value);914 if (lenSize === 1) {915 if (enc.length > 0xff) throw new Error("STRING_TOO_LONG_U8");916 this.u8(enc.length);917 } else {918 if (enc.length > 0xffff) throw new Error("STRING_TOO_LONG_U16");919 this.u16(enc.length);920 }921 this.push(enc);922 }923924 toBytes(): Uint8Array {925 const out = new Uint8Array(this.len);926 let cursor = 0;927 for (const c of this.chunks) {928 out.set(c, cursor);929 cursor += c.length;930 }931 return out;932 }933934 private push(b: Uint8Array): void {935 this.chunks.push(b);936 this.len += b.length;937 }938}939940941942export class BinaryReader {943 cursor: number;944 private view: DataView;945 constructor(private data: Uint8Array, start: number) {946 this.cursor = start;947 this.view = new DataView(data.buffer, data.byteOffset, data.byteLength);948 }949 950 951 private ensure(n: number): void {952 if (this.cursor + n > this.data.length) throw new Error("PAYLOAD_TOO_SHORT");953 }954 u8(): number {955 this.ensure(1);956 return this.data[this.cursor++];957 }958 u16(): number {959 this.ensure(2);960 const v = this.view.getUint16(this.cursor, true);961 this.cursor += 2;962 return v;963 }964 u32(): number {965 this.ensure(4);966 const v = this.view.getUint32(this.cursor, true);967 this.cursor += 4;968 return v;969 }970 read(n: number): Uint8Array {971 this.ensure(n);972 const out = this.data.subarray(this.cursor, this.cursor + n);973 this.cursor += n;974 return out;975 }976 lpString(lenSize: 1 | 2): string {977 const len = lenSize === 1 ? this.u8() : this.u16();978 return new TextDecoder().decode(this.read(len));979 }980}981982983984985986async function deriveKey(987 passphrase: string,988 salt: Uint8Array,989 opts: { raw?: boolean } = {},990): Promise<Uint8Array> {991 992 993 994 995 996 const pw = opts.raw ? passphrase : passphrase.normalize("NFC");997 return argon2id({998 password: pw,999 salt,1000 parallelism: ARGON2_PARALLELISM,1001 iterations: ARGON2_ITERATIONS,1002 memorySize: ARGON2_MEMORY_KIB,1003 hashLength: 32,1004 outputType: "binary",1005 });1006}10071008async function aesGcmEncrypt(1009 key: Uint8Array,1010 nonce: Uint8Array,1011 plaintext: Uint8Array1012): Promise<{ ciphertext: Uint8Array; tag: Uint8Array }> {1013 const cryptoKey = await crypto.subtle.importKey(1014 "raw",1015 key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,1016 { name: "AES-GCM" },1017 false,1018 ["encrypt"]1019 );1020 const raw = await crypto.subtle.encrypt(1021 { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, tagLength: 128 },1022 cryptoKey,1023 plaintext.buffer.slice(1024 plaintext.byteOffset,1025 plaintext.byteOffset + plaintext.byteLength1026 ) as ArrayBuffer1027 );1028 const out = new Uint8Array(raw);1029 return { ciphertext: out.slice(0, out.length - 16), tag: out.slice(out.length - 16) };1030}10311032async function aesGcmDecrypt(1033 key: Uint8Array,1034 nonce: Uint8Array,1035 ciphertext: Uint8Array,1036 tag: Uint8Array1037): Promise<Uint8Array> {1038 const cryptoKey = await crypto.subtle.importKey(1039 "raw",1040 key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,1041 { name: "AES-GCM" },1042 false,1043 ["decrypt"]1044 );1045 const combined = concat([ciphertext, tag]);1046 const raw = await crypto.subtle.decrypt(1047 {1048 name: "AES-GCM",1049 iv: nonce.buffer.slice(1050 nonce.byteOffset,1051 nonce.byteOffset + nonce.byteLength1052 ) as ArrayBuffer,1053 tagLength: 128,1054 },1055 cryptoKey,1056 combined.buffer.slice(1057 combined.byteOffset,1058 combined.byteOffset + combined.byteLength1059 ) as ArrayBuffer1060 );1061 return new Uint8Array(raw);1062}10631064async function blake3Truncated(data: Uint8Array): Promise<Uint8Array> {1065 const hex = await blake3(data);1066 1067 const out = new Uint8Array(CHECKSUM_LEN);1068 for (let i = 0; i < CHECKSUM_LEN; i++) {1069 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);1070 }1071 return out;1072}10731074function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {1075 if (a.length !== b.length) return false;1076 let diff = 0;1077 for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];1078 return diff === 0;1079}10801081function concat(parts: Uint8Array[]): Uint8Array {1082 let total = 0;1083 for (const p of parts) total += p.length;1084 const out = new Uint8Array(total);1085 let cursor = 0;1086 for (const p of parts) {1087 out.set(p, cursor);1088 cursor += p.length;1089 }1090 return out;1091}