1234567891011121314151617181920212223242526272829303132import { argon2id, blake3 } from "hash-wasm";3334import { compressZstd, decompressZstd } from "./zstd";35import type {36 Canary,37 CardNetwork,38 ManualSummonRecord,39 PeachLicenseExport,40 VaultEntry,41} from "../shared/types";42import type { VaultSettings } from "../background/vault";434445464748export const MAGIC = 0x50434853; 49export const VERSION = 0x01;50export const HEADER_LEN = 8;51export const CHECKSUM_LEN = 4;5253export const SALT_LEN = 16;54export const NONCE_LEN = 12;55export const TAG_LEN = 16;5657585960const SIZE_BUCKETS = [1024, 4096, 16384, 65536, 262144] as const;61626364function padToNearestBucket(data: Uint8Array): Uint8Array {65 const bucket = SIZE_BUCKETS.find((b) => b >= data.length);66 let targetSize: number;67 if (bucket !== undefined) {68 targetSize = bucket;69 } else {70 71 targetSize = 262144;72 while (targetSize < data.length) targetSize *= 2;73 }74 if (targetSize === data.length) return data;75 const padded = new Uint8Array(targetSize);76 padded.set(data);77 return padded;78}79808182const FORMAT_V2 = 0x02;83848586const FORMAT_V3 = 0x03;8788const ARGON2_MEMORY_KIB = 65536; 89const ARGON2_ITERATIONS = 3;90const ARGON2_PARALLELISM = 4;9192const SECTION_ENTRIES = 0x01;93const SECTION_SETTINGS = 0x02;94const SECTION_LICENSE = 0x03;95const SECTION_RECALL = 0x04;96const SECTION_DECLINED_CARDS = 0x05;9798const ENTRY_TYPE_LOGIN = 0x01;99const ENTRY_TYPE_NOTE = 0x02;100const ENTRY_TYPE_CARD = 0x03;101const ENTRY_TYPE_PASSKEY = 0x04;102103104const F_HAS_TOTP = 1 << 0;105const F_HAS_TAGS = 1 << 1;106const F_HAS_NOTES = 1 << 2;107const F_HAS_CANARY = 1 << 3;108const F_HAS_PWCHANGED = 1 << 4;109const F_HAS_BREACH = 1 << 5;110const F_HAS_ADDITIONAL_URLS = 1 << 6;111const F_HAS_DELETED = 1 << 7;112113114115116117118119120121122const F2_HAS_EMAIL = 1 << 0;123124const NETWORK_VALUES: CardNetwork[] = ["visa", "mastercard", "amex", "discover", "unknown"];125126127128129130export interface BuildPayloadInput {131 entries: VaultEntry[];132 settings: VaultSettings;133 license?: PeachLicenseExport;134 135 declinedCardFingerprints?: string[];136 137 manualSummonDomains?: Record<string, ManualSummonRecord>;138 passphrase: string;139}140141export interface ParsePayloadOutput {142 entries: VaultEntry[];143 settings: VaultSettings;144 license?: PeachLicenseExport;145 declinedCardFingerprints?: string[];146 manualSummonDomains?: Record<string, ManualSummonRecord>;147}148149150151152153154155export async function buildPayload(input: BuildPayloadInput): Promise<Uint8Array> {156 const sections: Uint8Array[] = [];157158 sections.push(await buildSection(SECTION_ENTRIES, encodeEntriesSection(input.entries, FORMAT_V3)));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_V3;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 const key = await deriveKey(passphrase, salt);229 let decrypted: Uint8Array;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 240 let inner: Uint8Array;241 let formatVersion: number = 1;242 if (243 decrypted.length >= 5 &&244 (decrypted[0] === FORMAT_V2 || decrypted[0] === FORMAT_V3)245 ) {246 formatVersion = decrypted[0];247 const innerLen = new DataView(decrypted.buffer, decrypted.byteOffset, decrypted.byteLength).getUint32(1, true);248 if (5 + innerLen > decrypted.length) throw new Error("PAYLOAD_TOO_SHORT");249 inner = decrypted.subarray(5, 5 + innerLen);250 } else {251 inner = decrypted; 252 }253254 if (inner.length < HEADER_LEN + CHECKSUM_LEN) throw new Error("PAYLOAD_TOO_SHORT");255256 const body = inner.subarray(0, inner.length - CHECKSUM_LEN);257 const stored = inner.subarray(inner.length - CHECKSUM_LEN);258 const computed = await blake3Truncated(body);259 if (!constantTimeEqual(stored, computed)) throw new Error("CHECKSUM_FAILED");260261 const view = new DataView(body.buffer, body.byteOffset, body.byteLength);262 if (view.getUint32(0, false) !== MAGIC) throw new Error("BAD_MAGIC");263 const version = body[4];264 if (version !== VERSION) throw new Error(`UNSUPPORTED_VERSION:${version}`);265 const sectionCount = body[5];266267 let cursor = HEADER_LEN;268 const out: ParsePayloadOutput = {269 entries: [],270 settings: {},271 };272 for (let i = 0; i < sectionCount; i++) {273 if (cursor + 9 > body.length) throw new Error("PAYLOAD_TOO_SHORT");274 const type = body[cursor];275 const length = view.getUint32(cursor + 1, true);276 const clength = view.getUint32(cursor + 5, true);277 const dataStart = cursor + 9;278 const dataEnd = dataStart + clength;279 if (dataEnd > body.length) throw new Error("PAYLOAD_TOO_SHORT");280 const compressed = body.subarray(dataStart, dataEnd);281 const data = await decompressZstd(compressed);282 if (data.length !== length) throw new Error("SECTION_LENGTH_MISMATCH");283284 switch (type) {285 case SECTION_ENTRIES:286 out.entries = decodeEntriesSection(data, formatVersion);287 break;288 case SECTION_SETTINGS:289 out.settings = decodeJsonSection<VaultSettings>(data);290 break;291 case SECTION_LICENSE:292 out.license = decodeJsonSection<PeachLicenseExport>(data);293 break;294 case SECTION_RECALL:295 out.manualSummonDomains = decodeJsonSection<Record<string, ManualSummonRecord>>(data);296 break;297 case SECTION_DECLINED_CARDS:298 out.declinedCardFingerprints = decodeJsonSection<string[]>(data);299 break;300 default:301 302 303 break;304 }305306 cursor = dataEnd;307 }308309 return out;310}311312313314315316async function buildSection(type: number, raw: Uint8Array): Promise<Uint8Array> {317 const compressed = await compressZstd(raw, 19);318 const out = new Uint8Array(9 + compressed.length);319 const view = new DataView(out.buffer);320 out[0] = type;321 view.setUint32(1, raw.length, true);322 view.setUint32(5, compressed.length, true);323 out.set(compressed, 9);324 return out;325}326327function encodeJsonSection(value: unknown): Uint8Array {328 return new TextEncoder().encode(JSON.stringify(value));329}330331function decodeJsonSection<T>(data: Uint8Array): T {332 return JSON.parse(new TextDecoder().decode(data)) as T;333}334335336337338339class StringTableBuilder {340 private map = new Map<string, number>();341 private list: string[] = [];342343 344 intern(value: string | undefined): number {345 if (!value) return 0;346 const existing = this.map.get(value);347 if (existing !== undefined) return existing;348 const idx = this.list.length + 1; 349 if (idx > 0xffff) throw new Error("STRING_TABLE_OVERFLOW");350 this.map.set(value, idx);351 this.list.push(value);352 return idx;353 }354355 serialize(): Uint8Array {356 const enc = new TextEncoder();357 const encoded = this.list.map((s) => enc.encode(s));358 let total = 2; 359 for (const e of encoded) total += 2 + e.length;360 const out = new Uint8Array(total);361 const view = new DataView(out.buffer);362 let cursor = 0;363 view.setUint16(cursor, encoded.length, true);364 cursor += 2;365 for (const e of encoded) {366 if (e.length > 0xffff) throw new Error("STRING_TOO_LONG");367 view.setUint16(cursor, e.length, true);368 cursor += 2;369 out.set(e, cursor);370 cursor += e.length;371 }372 return out;373 }374}375376function readStringTable(data: Uint8Array, cursor: number): { strings: string[]; cursor: number } {377 const view = new DataView(data.buffer, data.byteOffset, data.byteLength);378 const count = view.getUint16(cursor, true);379 cursor += 2;380 const strings: string[] = [];381 const dec = new TextDecoder();382 for (let i = 0; i < count; i++) {383 const len = view.getUint16(cursor, true);384 cursor += 2;385 strings.push(dec.decode(data.subarray(cursor, cursor + len)));386 cursor += len;387 }388 return { strings, cursor };389}390391function lookupString(strings: string[], idx: number): string | undefined {392 if (idx === 0) return undefined;393 return strings[idx - 1];394}395396function encodeEntriesSection(entries: VaultEntry[], formatVersion: number): Uint8Array {397 const table = new StringTableBuilder();398 const bodies: Uint8Array[] = [];399400 401 for (const entry of entries) table.intern(entry.title);402403 for (const entry of entries) {404 bodies.push(encodeEntry(entry, table, formatVersion));405 }406407 const tableBytes = table.serialize();408409 410 let bodiesLen = 0;411 for (const b of bodies) bodiesLen += b.length;412 const out = new Uint8Array(4 + tableBytes.length + bodiesLen);413 const view = new DataView(out.buffer);414 view.setUint32(0, entries.length, true);415 out.set(tableBytes, 4);416 let cursor = 4 + tableBytes.length;417 for (const b of bodies) {418 out.set(b, cursor);419 cursor += b.length;420 }421 return out;422}423424function decodeEntriesSection(data: Uint8Array, formatVersion: number): VaultEntry[] {425 const view = new DataView(data.buffer, data.byteOffset, data.byteLength);426 const count = view.getUint32(0, true);427 const { strings, cursor: afterTable } = readStringTable(data, 4);428429 const out: VaultEntry[] = [];430 let cursor = afterTable;431 for (let i = 0; i < count; i++) {432 const { entry, cursor: next } = decodeEntry(data, cursor, strings, formatVersion);433 out.push(entry);434 cursor = next;435 }436 return out;437}438439440441442443function encodeEntry(entry: VaultEntry, table: StringTableBuilder, formatVersion: number): Uint8Array {444 const writer = new BinaryWriter();445 const type = entryTypeByte(entry);446 writer.u8(type);447 writer.bytes(uuidToBytes(entry.id));448449 450 const createdSec = Math.floor((entry.createdAt ?? Date.now()) / 1000);451 const updatedSec = Math.floor((entry.modifiedAt ?? entry.createdAt ?? Date.now()) / 1000);452 const updatedDelta = Math.max(0, updatedSec - createdSec);453 const deletedSec = entry.deletedAt ? Math.floor(entry.deletedAt / 1000) : 0;454455 456 const flagOffset = writer.length;457 writer.u8(0);458 459 460 let flags2Offset = -1;461 if (formatVersion === FORMAT_V3) {462 flags2Offset = writer.length;463 writer.u8(0);464 }465466 writer.u32(createdSec);467 writer.u32(updatedDelta);468 writer.u32(deletedSec);469 writer.u16(table.intern(entry.title));470471 let flags = 0;472 let flags2 = 0;473 if (entry.deletedAt) flags |= F_HAS_DELETED;474475 if (type === ENTRY_TYPE_LOGIN) {476 writer.u16(table.intern(entry.url));477 writer.u16(table.intern(entry.username));478 writer.lpString(entry.password ?? "", 1);479 if (formatVersion === FORMAT_V3 && entry.email) {480 flags2 |= F2_HAS_EMAIL;481 writer.lpString(entry.email, 1);482 }483484 if (entry.totp) {485 flags |= F_HAS_TOTP;486 writer.lpString(entry.totp, 1);487 }488 if (entry.tags && entry.tags.length > 0) {489 flags |= F_HAS_TAGS;490 writer.u8(Math.min(entry.tags.length, 0xff));491 for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {492 writer.u16(table.intern(entry.tags[i]));493 }494 }495 if (entry.notes) {496 flags |= F_HAS_NOTES;497 writer.u16(table.intern(entry.notes));498 }499 if (entry.canary) {500 flags |= F_HAS_CANARY;501 writer.u16(table.intern(entry.canary.webhookUrl));502 writer.u16(table.intern(entry.canary.label));503 }504 if (entry.passwordChangedAt) {505 flags |= F_HAS_PWCHANGED;506 const pwSec = Math.floor(entry.passwordChangedAt / 1000);507 writer.u32(Math.max(0, pwSec - createdSec));508 }509 if (typeof entry.breach === "number" && entry.breach > 0) {510 flags |= F_HAS_BREACH;511 writer.u32(entry.breach);512 }513 if (entry.additionalUrls && entry.additionalUrls.length > 0) {514 flags |= F_HAS_ADDITIONAL_URLS;515 writer.u8(Math.min(entry.additionalUrls.length, 0xff));516 for (let i = 0; i < Math.min(entry.additionalUrls.length, 0xff); i++) {517 writer.u16(table.intern(entry.additionalUrls[i]));518 }519 }520 } else if (type === ENTRY_TYPE_NOTE) {521 if (entry.notes) {522 flags |= F_HAS_NOTES;523 writer.u16(table.intern(entry.notes));524 }525 if (entry.tags && entry.tags.length > 0) {526 flags |= F_HAS_TAGS;527 writer.u8(Math.min(entry.tags.length, 0xff));528 for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {529 writer.u16(table.intern(entry.tags[i]));530 }531 }532 } else if (type === ENTRY_TYPE_CARD) {533 writer.u16(table.intern(entry.cardholderName));534 writer.lpString(entry.cardNumber ?? "", 1);535 writer.lpString(entry.expMonth ?? "", 1);536 writer.lpString(entry.expYear ?? "", 1);537 writer.lpString(entry.cvc ?? "", 1);538 writer.u16(table.intern(entry.cardNickname));539 writer.u8(networkToByte(entry.network));540 writer.u16(table.intern(entry.last4));541 if (entry.tags && entry.tags.length > 0) {542 flags |= F_HAS_TAGS;543 writer.u8(Math.min(entry.tags.length, 0xff));544 for (let i = 0; i < Math.min(entry.tags.length, 0xff); i++) {545 writer.u16(table.intern(entry.tags[i]));546 }547 }548 if (entry.notes) {549 flags |= F_HAS_NOTES;550 writer.u16(table.intern(entry.notes));551 }552 } else if (type === ENTRY_TYPE_PASSKEY) {553 554 555 const pk = entry.passkey;556 const flat = entry as unknown as Record<string, unknown>;557 const blob = encodeJsonSection({558 rpId: pk ? pk.rpId : flat["rpId"],559 rpName: pk ? pk.rpName : flat["rpName"],560 credentialId: pk ? pk.credentialId : flat["credentialId"],561 publicKeyCose: pk ? pk.publicKeyCose : flat["publicKeyCose"],562 privateKeyJwk: pk ? pk.privateKeyJwk : flat["privateKeyJwk"],563 userHandle: pk ? pk.userHandle : flat["userHandle"],564 userName: pk ? pk.userName : flat["userName"],565 userDisplayName: pk ? pk.userDisplayName : flat["userDisplayName"],566 algorithm: pk ? pk.algorithm : flat["algorithm"],567 signCount: pk ? pk.signCount : flat["signCount"],568 aaguid: pk ? pk.aaguid : flat["aaguid"],569 backupEligible: pk ? pk.backupEligible : flat["backupEligible"],570 backupState: pk ? pk.backupState : flat["backupState"],571 lastUsedAt: pk ? pk.lastUsedAt : flat["lastUsedAt"],572 573 574 575 576 577 578 579 580 ...(pk ? {581 ...(entry.password !== undefined ? { password: entry.password } : {}),582 ...(entry.url !== undefined ? { url: entry.url } : {}),583 ...(entry.username !== undefined ? { username: entry.username } : {}),584 ...(entry.email !== undefined ? { email: entry.email } : {}),585 ...(entry.totp !== undefined ? { totp: entry.totp } : {}),586 ...(entry.notes !== undefined ? { notes: entry.notes } : {}),587 ...(entry.tags !== undefined ? { tags: entry.tags } : {}),588 ...(entry.canary !== undefined ? { canary: entry.canary } : {}),589 ...(entry.passwordChangedAt !== undefined ? { passwordChangedAt: entry.passwordChangedAt } : {}),590 ...(entry.breach !== undefined ? { breach: entry.breach } : {}),591 ...(entry.additionalUrls !== undefined ? { additionalUrls: entry.additionalUrls } : {}),592 } : {}),593 });594 writer.u32(blob.length);595 writer.bytes(blob);596 }597598 writer.setU8(flagOffset, flags);599 if (flags2Offset >= 0) writer.setU8(flags2Offset, flags2);600 return writer.toBytes();601}602603function decodeEntry(604 data: Uint8Array,605 start: number,606 strings: string[],607 formatVersion: number608): { entry: VaultEntry; cursor: number } {609 const reader = new BinaryReader(data, start);610 const type = reader.u8();611 const id = bytesToUuid(reader.read(16));612 const flags = reader.u8();613 const flags2 = formatVersion === FORMAT_V3 ? reader.u8() : 0;614 const createdSec = reader.u32();615 const updatedDelta = reader.u32();616 const deletedSec = reader.u32();617 const titleIdx = reader.u16();618619 const createdAt = createdSec * 1000;620 const modifiedAt = (createdSec + updatedDelta) * 1000;621 const deletedAt = deletedSec > 0 ? deletedSec * 1000 : undefined;622 const title = lookupString(strings, titleIdx) ?? "";623624 const baseEntry: Partial<VaultEntry> = {625 id,626 title,627 createdAt,628 modifiedAt,629 ...(deletedAt !== undefined ? { deletedAt } : {}),630 };631632 if (type === ENTRY_TYPE_LOGIN) {633 const urlIdx = reader.u16();634 const usernameIdx = reader.u16();635 const password = reader.lpString(1);636 const tail: Partial<VaultEntry> = {};637638 if (flags2 & F2_HAS_EMAIL) tail.email = reader.lpString(1);639640 if (flags & F_HAS_TOTP) tail.totp = reader.lpString(1);641 if (flags & F_HAS_TAGS) {642 const cnt = reader.u8();643 const tags: string[] = [];644 for (let i = 0; i < cnt; i++) {645 const v = lookupString(strings, reader.u16());646 if (v !== undefined) tags.push(v);647 }648 tail.tags = tags;649 }650 if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());651 if (flags & F_HAS_CANARY) {652 const webhookUrl = lookupString(strings, reader.u16()) ?? "";653 const label = lookupString(strings, reader.u16());654 const canary: Canary = { webhookUrl };655 if (label !== undefined) canary.label = label;656 tail.canary = canary;657 }658 if (flags & F_HAS_PWCHANGED) {659 const pwDelta = reader.u32();660 tail.passwordChangedAt = (createdSec + pwDelta) * 1000;661 }662 if (flags & F_HAS_BREACH) tail.breach = reader.u32();663 if (flags & F_HAS_ADDITIONAL_URLS) {664 const cnt = reader.u8();665 const urls: string[] = [];666 for (let i = 0; i < cnt; i++) {667 const v = lookupString(strings, reader.u16());668 if (v !== undefined) urls.push(v);669 }670 tail.additionalUrls = urls;671 }672673 return {674 entry: {675 ...(baseEntry as VaultEntry),676 type: "login",677 url: lookupString(strings, urlIdx),678 username: lookupString(strings, usernameIdx),679 password,680 ...tail,681 } as VaultEntry,682 cursor: reader.cursor,683 };684 }685686 if (type === ENTRY_TYPE_NOTE) {687 const tail: Partial<VaultEntry> = {};688 if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());689 if (flags & F_HAS_TAGS) {690 const cnt = reader.u8();691 const tags: string[] = [];692 for (let i = 0; i < cnt; i++) {693 const v = lookupString(strings, reader.u16());694 if (v !== undefined) tags.push(v);695 }696 tail.tags = tags;697 }698 return {699 entry: { ...(baseEntry as VaultEntry), type: "note", ...tail } as VaultEntry,700 cursor: reader.cursor,701 };702 }703704 if (type === ENTRY_TYPE_CARD) {705 const cardholderIdx = reader.u16();706 const cardNumber = reader.lpString(1);707 const expMonth = reader.lpString(1);708 const expYear = reader.lpString(1);709 const cvc = reader.lpString(1);710 const nicknameIdx = reader.u16();711 const network = networkFromByte(reader.u8());712 const last4Idx = reader.u16();713 const tail: Partial<VaultEntry> = {};714 if (flags & F_HAS_TAGS) {715 const cnt = reader.u8();716 const tags: string[] = [];717 for (let i = 0; i < cnt; i++) {718 const v = lookupString(strings, reader.u16());719 if (v !== undefined) tags.push(v);720 }721 tail.tags = tags;722 }723 if (flags & F_HAS_NOTES) tail.notes = lookupString(strings, reader.u16());724725 return {726 entry: {727 ...(baseEntry as VaultEntry),728 type: "card",729 cardholderName: lookupString(strings, cardholderIdx),730 cardNumber,731 expMonth,732 expYear,733 cvc,734 cardNickname: lookupString(strings, nicknameIdx),735 network,736 last4: lookupString(strings, last4Idx),737 ...tail,738 } as VaultEntry,739 cursor: reader.cursor,740 };741 }742743 if (type === ENTRY_TYPE_PASSKEY) {744 const blobLen = reader.u32();745 const blob = reader.read(blobLen);746 const obj = decodeJsonSection<Record<string, unknown>>(blob);747 return {748 entry: {749 ...(baseEntry as VaultEntry),750 type: "passkey",751 ...obj,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}884885class BinaryReader {886 cursor: number;887 private view: DataView;888 constructor(private data: Uint8Array, start: number) {889 this.cursor = start;890 this.view = new DataView(data.buffer, data.byteOffset, data.byteLength);891 }892 u8(): number {893 return this.data[this.cursor++];894 }895 u16(): number {896 const v = this.view.getUint16(this.cursor, true);897 this.cursor += 2;898 return v;899 }900 u32(): number {901 const v = this.view.getUint32(this.cursor, true);902 this.cursor += 4;903 return v;904 }905 read(n: number): Uint8Array {906 const out = this.data.subarray(this.cursor, this.cursor + n);907 this.cursor += n;908 return out;909 }910 lpString(lenSize: 1 | 2): string {911 const len = lenSize === 1 ? this.u8() : this.u16();912 return new TextDecoder().decode(this.read(len));913 }914}915916917918919920async function deriveKey(passphrase: string, salt: Uint8Array): Promise<Uint8Array> {921 return argon2id({922 password: passphrase,923 salt,924 parallelism: ARGON2_PARALLELISM,925 iterations: ARGON2_ITERATIONS,926 memorySize: ARGON2_MEMORY_KIB,927 hashLength: 32,928 outputType: "binary",929 });930}931932async function aesGcmEncrypt(933 key: Uint8Array,934 nonce: Uint8Array,935 plaintext: Uint8Array936): Promise<{ ciphertext: Uint8Array; tag: Uint8Array }> {937 const cryptoKey = await crypto.subtle.importKey(938 "raw",939 key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,940 { name: "AES-GCM" },941 false,942 ["encrypt"]943 );944 const raw = await crypto.subtle.encrypt(945 { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, tagLength: 128 },946 cryptoKey,947 plaintext.buffer.slice(948 plaintext.byteOffset,949 plaintext.byteOffset + plaintext.byteLength950 ) as ArrayBuffer951 );952 const out = new Uint8Array(raw);953 return { ciphertext: out.slice(0, out.length - 16), tag: out.slice(out.length - 16) };954}955956async function aesGcmDecrypt(957 key: Uint8Array,958 nonce: Uint8Array,959 ciphertext: Uint8Array,960 tag: Uint8Array961): Promise<Uint8Array> {962 const cryptoKey = await crypto.subtle.importKey(963 "raw",964 key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,965 { name: "AES-GCM" },966 false,967 ["decrypt"]968 );969 const combined = concat([ciphertext, tag]);970 const raw = await crypto.subtle.decrypt(971 {972 name: "AES-GCM",973 iv: nonce.buffer.slice(974 nonce.byteOffset,975 nonce.byteOffset + nonce.byteLength976 ) as ArrayBuffer,977 tagLength: 128,978 },979 cryptoKey,980 combined.buffer.slice(981 combined.byteOffset,982 combined.byteOffset + combined.byteLength983 ) as ArrayBuffer984 );985 return new Uint8Array(raw);986}987988async function blake3Truncated(data: Uint8Array): Promise<Uint8Array> {989 const hex = await blake3(data);990 991 const out = new Uint8Array(CHECKSUM_LEN);992 for (let i = 0; i < CHECKSUM_LEN; i++) {993 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);994 }995 return out;996}997998function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {999 if (a.length !== b.length) return false;1000 let diff = 0;1001 for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];1002 return diff === 0;1003}10041005function concat(parts: Uint8Array[]): Uint8Array {1006 let total = 0;1007 for (const p of parts) total += p.length;1008 const out = new Uint8Array(total);1009 let cursor = 0;1010 for (const p of parts) {1011 out.set(p, cursor);1012 cursor += p.length;1013 }1014 return out;1015}