diff --git a/web/src/auto-lock-settings.test.ts b/web/src/auto-lock-settings.test.ts new file mode 100644 index 00000000..da9b3000 --- /dev/null +++ b/web/src/auto-lock-settings.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { autoLockDraftFromCanonical, parseAutoLockDraft } from "./auto-lock-settings"; + +describe("automatic LOCK settings form semantics", () => { + it("maps canonical disabled state to an unchecked UI with a 1-day initial selector", () => { + expect(autoLockDraftFromCanonical(null)).toEqual({ enabled: false, days: 1 }); + }); + + it("maps canonical enabled values without changing them", () => { + expect(autoLockDraftFromCanonical(1)).toEqual({ enabled: true, days: 1 }); + expect(autoLockDraftFromCanonical(31)).toEqual({ enabled: true, days: 31 }); + }); + + it("persists disabled mode as null rather than a numeric sentinel", () => { + expect(parseAutoLockDraft(false, "0")).toBeNull(); + expect(parseAutoLockDraft(false, "31")).toBeNull(); + }); + + it("accepts only whole enabled values 1 through 31", () => { + expect(parseAutoLockDraft(true, "1")).toBe(1); + expect(parseAutoLockDraft(true, 31)).toBe(31); + for (const invalid of ["0", "32", "1.5", "", "NaN"]) { + expect(() => parseAutoLockDraft(true, invalid)).toThrow(/integer from 1 through 31/); + } + }); +}); diff --git a/web/src/auto-lock-settings.ts b/web/src/auto-lock-settings.ts new file mode 100644 index 00000000..4c2a8536 --- /dev/null +++ b/web/src/auto-lock-settings.ts @@ -0,0 +1,269 @@ +import type { CanonicalDeviceSnapshot } from "./canonical-management"; +import { getLanguage, onLanguageChange, type UiLanguage } from "./i18n"; + +export interface AutoLockDraft { + enabled: boolean; + days: number; +} + +export interface AutoLockSettingsController { + render(snapshot: CanonicalDeviceSnapshot | null, busy: boolean, recoveryMode: boolean): void; + dispose(): void; +} + +export type AutoLockSaveHandler = (days: number | null) => Promise; + +const copy = { + en: { + heading: "Automatic LOCK", + description: "Optionally limit one continuous UNLOCKED session to a whole number of days. Disabled means no automatic maximum lifetime is configured.", + enabled: "Enable automatic LOCK", + days: "Maximum UNLOCKED lifetime", + save: "Save automatic LOCK setting", + unavailable: "This Device does not advertise Vault Format 2 support. Update Device firmware before enabling automatic LOCK.", + disconnected: "Connect and unlock the active Trusted Browser to view this setting.", + locked: "Unlock the Device to view or change the canonical automatic LOCK setting.", + unprovisioned: "Automatic LOCK is available after initial provisioning. New Vaults remain disabled until you explicitly enable and save it.", + conflict: "Automatic LOCK settings are unavailable until canonical browser ownership is reconciled.", + disabled: "Current setting: disabled — no automatic maximum UNLOCKED lifetime is configured.", + enabledValue: (days: number) => `Current setting: ${days} day${days === 1 ? "" : "s"} maximum UNLOCKED lifetime.`, + unsaved: "Unsaved change. Saving uses the authenticated encrypted canonical Vault generation path.", + saving: "Saving automatic LOCK setting…", + saved: "Automatic LOCK setting saved.", + savedLocked: "Automatic LOCK setting saved. The Device is now LOCKED; unlock it to read the canonical setting again.", + failed: "Automatic LOCK setting was not confirmed. The previous canonical setting remains authoritative.", + dayOption: (days: number) => `${days} day${days === 1 ? "" : "s"}`, + }, + ja: { + heading: "自動LOCK", + description: "1回の連続したUNLOCKED状態に、日単位の最大期間を任意で設定します。無効の場合、自動的な最大UNLOCKED期間は設定されません。", + enabled: "自動LOCKを有効にする", + days: "最大UNLOCKED期間", + save: "自動LOCK設定を保存", + unavailable: "このDeviceはVault Format 2対応を通知していません。自動LOCKを有効にする前にDevice firmwareを更新してください。", + disconnected: "この設定を確認するには、Active Trusted Browserとして接続してDeviceをロック解除してください。", + locked: "Canonical自動LOCK設定を確認・変更するにはDeviceをロック解除してください。", + unprovisioned: "自動LOCKは初回プロビジョニング後に設定できます。明示的に有効化して保存するまでは、新しいVaultでも無効のままです。", + conflict: "Canonical browser ownershipの不整合が解消されるまで、自動LOCK設定は利用できません。", + disabled: "現在の設定: 無効 — 自動的な最大UNLOCKED期間は設定されていません。", + enabledValue: (days: number) => `現在の設定: 最大UNLOCKED期間 ${days}日。`, + unsaved: "未保存の変更があります。保存には認証済み・暗号化済みCanonical Vaultのgeneration更新経路を使用します。", + saving: "自動LOCK設定を保存しています…", + saved: "自動LOCK設定を保存しました。", + savedLocked: "自動LOCK設定を保存しました。DeviceはLOCKEDになりました。Canonical設定を再確認するにはロック解除してください。", + failed: "自動LOCK設定の反映を確認できませんでした。以前のCanonical設定が引き続き正です。", + dayOption: (days: number) => `${days}日`, + }, +} as const; + +export function autoLockDraftFromCanonical(days: number | null): AutoLockDraft { + return { enabled: days !== null, days: days ?? 1 }; +} + +export function parseAutoLockDraft(enabled: boolean, rawDays: string | number): number | null { + if (!enabled) return null; + const days = typeof rawDays === "number" ? rawDays : Number(rawDays); + if (!Number.isInteger(days) || days < 1 || days > 31) { + throw new Error("Automatic LOCK days must be an integer from 1 through 31"); + } + return days; +} + +function bytesIdentity(value: Uint8Array | null): string { + if (value === null) return "-"; + return Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +function canonicalContextIdentity(snapshot: CanonicalDeviceSnapshot | null): string | null { + if (snapshot === null) return null; + const hello = snapshot.hello; + return [ + hello.deviceId, + hello.vaultPresent ? bytesIdentity(hello.vaultId) : "no-vault", + hello.generation.toString(10), + String(hello.vaultFormat), + hello.registrationPresent ? bytesIdentity(hello.registrationId) : "no-registration", + String(hello.registrationEpoch), + snapshot.browserOwnership, + ].join(":"); +} + +export function createAutoLockSettingsController(onSave: AutoLockSaveHandler): AutoLockSettingsController { + const shell = document.querySelector("#app .shell"); + if (!shell) throw new Error("Automatic LOCK settings require the provisioner shell"); + + const panel = document.createElement("section"); + panel.className = "panel"; + panel.setAttribute("aria-labelledby", "auto-lock-heading"); + panel.innerHTML = ` +

+

+ + + +
+ +
+

+ `; + + const rekeyPanel = shell.querySelector('section[aria-labelledby="rekey-heading"]'); + if (rekeyPanel) shell.insertBefore(panel, rekeyPanel); + else shell.append(panel); + + const heading = required(panel, "#auto-lock-heading"); + const description = required(panel, "#auto-lock-description"); + const enabledInput = required(panel, "#auto-lock-enabled"); + const enabledLabel = required(panel, "#auto-lock-enabled-label"); + const daysLabel = required(panel, "#auto-lock-days-label"); + const daysSelect = required(panel, "#auto-lock-days"); + const saveButton = required(panel, "#save-auto-lock"); + const status = required(panel, "#auto-lock-status"); + + for (let days = 1; days <= 31; days += 1) { + const option = document.createElement("option"); + option.value = String(days); + daysSelect.append(option); + } + + let snapshot: CanonicalDeviceSnapshot | null = null; + let contextIdentity: string | null = null; + let busy = false; + let recoveryMode = false; + let dirty = false; + let saving = false; + let saveFailed = false; + let savedWhileLocked = false; + let draft = autoLockDraftFromCanonical(null); + + const render = ( + nextSnapshot: CanonicalDeviceSnapshot | null = snapshot, + nextBusy = busy, + nextRecoveryMode = recoveryMode, + ): void => { + const nextContextIdentity = canonicalContextIdentity(nextSnapshot); + if (nextContextIdentity !== contextIdentity) { + contextIdentity = nextContextIdentity; + dirty = false; + saveFailed = false; + savedWhileLocked = false; + draft = nextSnapshot?.autoLock.known + ? autoLockDraftFromCanonical(nextSnapshot.autoLock.days) + : autoLockDraftFromCanonical(null); + } + + snapshot = nextSnapshot; + busy = nextBusy; + recoveryMode = nextRecoveryMode; + + if (!dirty && snapshot?.autoLock.known) draft = autoLockDraftFromCanonical(snapshot.autoLock.days); + + const language = getLanguage(); + const text = copy[language]; + heading.textContent = text.heading; + description.textContent = text.description; + enabledLabel.textContent = text.enabled; + daysLabel.textContent = text.days; + saveButton.textContent = text.save; + for (let index = 0; index < daysSelect.options.length; index += 1) { + const option = daysSelect.options.item(index); + if (option) option.textContent = text.dayOption(index + 1); + } + + enabledInput.checked = draft.enabled; + daysSelect.value = String(draft.days); + + const writable = snapshot !== null && !recoveryMode && snapshot.browserOwnership === "active" && + snapshot.hello.state === "unlocked" && snapshot.autoLock.known && snapshot.autoLock.format2Writable; + enabledInput.disabled = busy || saving || !writable; + daysSelect.disabled = busy || saving || !writable || !draft.enabled; + saveButton.disabled = busy || saving || !writable || !dirty; + + status.textContent = statusText(language, snapshot, recoveryMode, dirty, saving, saveFailed, savedWhileLocked); + }; + + enabledInput.addEventListener("change", () => { + draft = { + enabled: enabledInput.checked, + days: draft.days >= 1 && draft.days <= 31 ? draft.days : 1, + }; + dirty = true; + saveFailed = false; + savedWhileLocked = false; + render(); + }); + + daysSelect.addEventListener("change", () => { + const parsed = parseAutoLockDraft(true, daysSelect.value); + draft = { enabled: true, days: parsed ?? 1 }; + dirty = true; + saveFailed = false; + savedWhileLocked = false; + render(); + }); + + saveButton.addEventListener("click", () => { + if (saveButton.disabled || saving) return; + const value = parseAutoLockDraft(draft.enabled, draft.days); + saving = true; + saveFailed = false; + savedWhileLocked = false; + render(); + void onSave(value).then(() => { + dirty = false; + saving = false; + saveFailed = false; + savedWhileLocked = snapshot?.hello.state === "locked"; + render(); + }).catch(() => { + saving = false; + saveFailed = true; + render(); + }); + }); + + const unsubscribe = onLanguageChange(() => render()); + render(); + + return { + render(nextSnapshot, nextBusy, nextRecoveryMode) { + render(nextSnapshot, nextBusy, nextRecoveryMode); + }, + dispose() { + unsubscribe(); + panel.remove(); + }, + }; +} + +function statusText( + language: UiLanguage, + snapshot: CanonicalDeviceSnapshot | null, + recoveryMode: boolean, + dirty: boolean, + saving: boolean, + saveFailed: boolean, + savedWhileLocked: boolean, +): string { + const text = copy[language]; + if (saving) return text.saving; + if (saveFailed) return text.failed; + if (savedWhileLocked) return text.savedLocked; + if (recoveryMode) return text.conflict; + if (!snapshot) return text.disconnected; + if (!snapshot.hello.vaultPresent) return text.unprovisioned; + if (snapshot.browserOwnership !== "active") return text.conflict; + if (snapshot.hello.state !== "unlocked" || !snapshot.autoLock.known) return text.locked; + if (!snapshot.autoLock.format2Writable) return text.unavailable; + if (dirty) return text.unsaved; + return snapshot.autoLock.days === null ? text.disabled : text.enabledValue(snapshot.autoLock.days); +} + +function required(root: ParentNode, selector: string): T { + const element = root.querySelector(selector); + if (!element) throw new Error(`Automatic LOCK control is missing: ${selector}`); + return element; +} diff --git a/web/src/canonical-auto-lock.test.ts b/web/src/canonical-auto-lock.test.ts new file mode 100644 index 00000000..573433d9 --- /dev/null +++ b/web/src/canonical-auto-lock.test.ts @@ -0,0 +1,403 @@ +import { describe, expect, it } from "vitest"; +import { + CanonicalDeviceManagement, +} from "./canonical-management"; +import type { + CanonicalHelloData, + CanonicalWireOperation, +} from "./canonical-protocol-v2"; +import { + IndexedDbBrowserVaultStore, + createBrowserCanonicalState, + sanitizeBrowserCanonicalState, + unwrapVmkForTrustedBrowser, + type BrowserCanonicalState, +} from "./security/browser-vault"; +import { + IndexedDbBrowserTransactionJournal, + PendingBrowserTransactionError, + type BrowserPendingTransaction, +} from "./security/browser-transaction-journal"; +import { + LEGACY_VAULT_FORMAT_VERSION, + VAULT_FORMAT_VERSION, + decodeVaultPlaintext, + encodeVaultPlaintext, + type VaultPlaintext, +} from "./security/vault-format"; +import { + decryptVault, + encryptVault, + wrapVmkWithPassphrase, +} from "./security/vault-crypto"; +import { + decodeBase64UrlCanonical, + encodeBase64UrlCanonical, + type SessionWireOperation, +} from "./security/session-protocol-v2"; +import type { CanonicalV2Transport } from "./serial"; + +const passphrase = ["synthetic", "automatic", "lock", "fixture"].join(" "); +const deviceId = "aabbccddeeff00112233445566778899"; + +function bytes(length: number, start: number): Uint8Array { + return Uint8Array.from({ length }, (_, index) => (start + index) & 0xff); +} + +function sameBytes(left: Uint8Array, right: Uint8Array): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +class MemoryBrowserStore { + private state: BrowserCanonicalState; + + constructor(initial: BrowserCanonicalState) { + this.state = sanitizeBrowserCanonicalState(initial); + } + + async get(vaultId: Uint8Array): Promise { + return sameBytes(this.state.vault.vaultId, vaultId) ? sanitizeBrowserCanonicalState(this.state) : null; + } + + async list(): Promise { + return [sanitizeBrowserCanonicalState(this.state)]; + } + + async put(state: BrowserCanonicalState, expectedGeneration?: bigint): Promise { + if (expectedGeneration === undefined || this.state.vault.generation !== expectedGeneration) { + throw new Error("generation conflict"); + } + this.state = sanitizeBrowserCanonicalState(state); + } + + async delete(): Promise { + throw new Error("delete is not expected in automatic LOCK tests"); + } + + current(): BrowserCanonicalState { + return sanitizeBrowserCanonicalState(this.state); + } + + asIndexedDb(): IndexedDbBrowserVaultStore { + return this as unknown as IndexedDbBrowserVaultStore; + } +} + +class MemoryJournal { + private pending: BrowserPendingTransaction | null = null; + + async get(vaultId: Uint8Array): Promise { + if (!this.pending || !sameBytes(this.pending.candidate.vault.vaultId, vaultId)) return null; + return this.clone(this.pending); + } + + async list(): Promise { + return this.pending ? [this.clone(this.pending)] : []; + } + + async listForDevice(targetDeviceId: string): Promise { + return this.pending?.candidate.deviceMetadata?.deviceId === targetDeviceId ? [this.clone(this.pending)] : []; + } + + async stage(value: BrowserPendingTransaction): Promise { + if (this.pending) throw new PendingBrowserTransactionError(); + this.pending = this.clone(value); + } + + async delete(vaultId: Uint8Array): Promise { + if (this.pending && sameBytes(this.pending.candidate.vault.vaultId, vaultId)) this.pending = null; + } + + current(): BrowserPendingTransaction | null { + return this.pending ? this.clone(this.pending) : null; + } + + asIndexedDb(): IndexedDbBrowserTransactionJournal { + return this as unknown as IndexedDbBrowserTransactionJournal; + } + + private clone(value: BrowserPendingTransaction): BrowserPendingTransaction { + return { + kind: value.kind, + expectedGeneration: value.expectedGeneration, + candidate: sanitizeBrowserCanonicalState(value.candidate), + }; + } +} + +class AutoLockDevice implements CanonicalV2Transport { + connected = true; + state: "unlocked" | "locked" = "unlocked"; + generation: bigint; + vaultFormat: 1 | 2; + supportedVaultFormats: number[]; + updateCalls = 0; + commitAndDisconnect = false; + lockAfterUpdate = false; + + constructor( + private readonly browserState: BrowserCanonicalState, + supportedVaultFormats: number[], + ) { + this.generation = browserState.vault.generation; + this.vaultFormat = browserState.vault.vaultFormatVersion; + this.supportedVaultFormats = [...supportedVaultFormats]; + } + + async requestCanonicalV2( + op: CanonicalWireOperation, + params: Record = {}, + ): Promise> { + if (!this.connected) throw new Error("synthetic transport disconnected"); + if (op === "hello") return this.hello(); + if (op === "time.status") { + return { + readiness: "ready", + source: "usb", + last_sync_unix_seconds: "1789156800", + age_seconds: 0, + resync_due: false, + }; + } + if (op === "vault.update") { + this.updateCalls += 1; + if (String(params.expected_generation) !== this.generation.toString(10)) { + throw new Error("generation mismatch"); + } + const nextGeneration = BigInt(String(params.generation)); + const nextVaultId = decodeBase64UrlCanonical(String(params.vault_id), 16); + const nextFormat = Number(params.vault_format_version); + if (nextGeneration !== this.generation + 1n || !sameBytes(nextVaultId, this.browserState.vault.vaultId)) { + throw new Error("invalid candidate"); + } + if (nextFormat !== 1 && nextFormat !== 2) throw new Error("invalid candidate format"); + if (!this.supportedVaultFormats.includes(nextFormat)) throw new Error("unsupported candidate format"); + this.generation = nextGeneration; + this.vaultFormat = nextFormat; + if (this.lockAfterUpdate) this.state = "locked"; + if (this.commitAndDisconnect) { + this.commitAndDisconnect = false; + this.connected = false; + throw new Error("synthetic response lost after commit"); + } + return {}; + } + throw new Error(`unexpected canonical operation ${op}`); + } + + async requestV2(op: SessionWireOperation): Promise> { + throw new Error(`unexpected session operation ${op}`); + } + + async close(): Promise { + this.connected = false; + } + + hello(): Record { + return { + device: "M5StickS3", + device_id: deviceId, + firmware: "synthetic", + protocol: 2, + storage_schema: 2, + vault_format: this.vaultFormat, + supported_vault_formats: [...this.supportedVaultFormats], + build_commit: "synthetic", + state: this.state, + storage_ready: true, + recovery_reset_required: false, + vault_present: true, + vault_id: encodeBase64UrlCanonical(this.browserState.vault.vaultId), + generation: this.generation.toString(10), + registration_present: true, + registration_id: encodeBase64UrlCanonical(this.browserState.trustedBrowser.registrationId), + registration_epoch: this.browserState.trustedBrowser.epoch, + brk_public_key: encodeBase64UrlCanonical(this.browserState.trustedBrowser.brkPublicKeyRaw), + }; + } + + typedHello(): CanonicalHelloData { + return { + device: "M5StickS3", + deviceId, + firmware: "synthetic", + protocol: 2, + storageSchema: 2, + vaultFormat: this.vaultFormat, + supportedVaultFormats: [...this.supportedVaultFormats], + buildCommit: "synthetic", + state: this.state, + storageReady: true, + recoveryResetRequired: false, + vaultPresent: true, + vaultId: this.browserState.vault.vaultId.slice(), + generation: this.generation, + registrationPresent: true, + registrationId: this.browserState.trustedBrowser.registrationId.slice(), + registrationEpoch: this.browserState.trustedBrowser.epoch, + brkPublicKey: this.browserState.trustedBrowser.brkPublicKeyRaw.slice(), + }; + } +} + +async function fixture(): Promise<{ state: BrowserCanonicalState; vmk: Uint8Array }> { + const vmk = bytes(32, 0x11); + const vaultId = bytes(16, 0x44); + const plaintext: VaultPlaintext = { + credentials: [{ + credentialId: bytes(16, 0x70), + secret: bytes(20, 0x90), + issuer: "Synthetic", + account: "automatic-lock@example.invalid", + displayName: "", + algorithm: "SHA1", + digits: 6, + periodSeconds: 30, + manualOrder: 0, + }], + wifi: null, + }; + const encoded = encodeVaultPlaintext(plaintext, LEGACY_VAULT_FORMAT_VERSION); + try { + const vault = await encryptVault(encoded, vmk, vaultId, 7n); + const recoveryWrappedVmk = await wrapVmkWithPassphrase(vmk, vaultId, passphrase); + const state = await createBrowserCanonicalState({ + vault, + recoveryWrappedVmk, + vmk, + registrationEpoch: 3, + status: "active", + deviceMetadata: { deviceId }, + }); + return { state, vmk }; + } finally { + encoded.fill(0); + plaintext.credentials[0]!.credentialId.fill(0); + plaintext.credentials[0]!.secret.fill(0); + } +} + +async function readAutoLock(state: BrowserCanonicalState): Promise { + const vmk = await unwrapVmkForTrustedBrowser(state); + const decrypted = await decryptVault(state.vault, vmk); + try { + return decodeVaultPlaintext(decrypted, state.vault.vaultFormatVersion).autoLockDays ?? null; + } finally { + decrypted.fill(0); + vmk.fill(0); + } +} + +describe("automatic LOCK canonical Web flow", () => { + it("reads Format 1 as disabled and never sends Format 2 without explicit Device capability", async () => { + const { state, vmk } = await fixture(); + const store = new MemoryBrowserStore(state); + const journal = new MemoryJournal(); + const device = new AutoLockDevice(state, [1]); + const management = new CanonicalDeviceManagement(device, device.typedHello(), store.asIndexedDb(), journal.asIndexedDb()); + await management.initialize(); + + const snapshot = await management.refresh(); + expect(snapshot.autoLock).toEqual({ known: true, days: null, format2Writable: false }); + await expect(management.setAutoLockDays(1)).rejects.toThrow(/Format 2/); + expect(device.updateCalls).toBe(0); + expect(store.current().vault.vaultFormatVersion).toBe(LEGACY_VAULT_FORMAT_VERSION); + expect(store.current().vault.generation).toBe(7n); + vmk.fill(0); + }); + + it("migrates F1 to F2 on save, preserves Vault identity/VMK, and remains F2 for disable and 31-day writes", async () => { + const { state, vmk } = await fixture(); + const store = new MemoryBrowserStore(state); + const journal = new MemoryJournal(); + const device = new AutoLockDevice(state, [1, 2]); + const management = new CanonicalDeviceManagement(device, device.typedHello(), store.asIndexedDb(), journal.asIndexedDb()); + await management.initialize(); + + const originalNonce = state.vault.nonce.slice(); + const originalVaultId = state.vault.vaultId.slice(); + const originalQuickVmk = await unwrapVmkForTrustedBrowser(state); + + await management.setAutoLockDays(1); + const enabled = store.current(); + expect(enabled.vault.vaultFormatVersion).toBe(VAULT_FORMAT_VERSION); + expect(enabled.vault.generation).toBe(8n); + expect(enabled.vault.vaultId).toEqual(originalVaultId); + expect(enabled.vault.nonce).not.toEqual(originalNonce); + expect(enabled.recoveryWrappedVmk.vaultFormatVersion).toBe(VAULT_FORMAT_VERSION); + expect(await readAutoLock(enabled)).toBe(1); + const migratedQuickVmk = await unwrapVmkForTrustedBrowser(enabled); + expect(migratedQuickVmk).toEqual(originalQuickVmk); + migratedQuickVmk.fill(0); + originalQuickVmk.fill(0); + + await management.setAutoLockDays(null); + const disabled = store.current(); + expect(disabled.vault.vaultFormatVersion).toBe(VAULT_FORMAT_VERSION); + expect(disabled.vault.generation).toBe(9n); + expect(await readAutoLock(disabled)).toBeNull(); + + await management.setAutoLockDays(31); + const maximum = store.current(); + expect(maximum.vault.vaultFormatVersion).toBe(VAULT_FORMAT_VERSION); + expect(maximum.vault.generation).toBe(10n); + expect(await readAutoLock(maximum)).toBe(31); + + const reconnect = new CanonicalDeviceManagement(device, device.typedHello(), store.asIndexedDb(), journal.asIndexedDb()); + await reconnect.initialize(); + const snapshot = await reconnect.refresh(); + expect(snapshot.autoLock).toEqual({ known: true, days: 31, format2Writable: true }); + vmk.fill(0); + }); + + it("treats a successful setting write followed by immediate Device LOCK as committed", async () => { + const { state, vmk } = await fixture(); + const store = new MemoryBrowserStore(state); + const journal = new MemoryJournal(); + const device = new AutoLockDevice(state, [1, 2]); + device.lockAfterUpdate = true; + const management = new CanonicalDeviceManagement(device, device.typedHello(), store.asIndexedDb(), journal.asIndexedDb()); + await management.initialize(); + + await expect(management.setAutoLockDays(1)).resolves.toBeUndefined(); + expect(store.current().vault.vaultFormatVersion).toBe(VAULT_FORMAT_VERSION); + expect(await readAutoLock(store.current())).toBe(1); + const snapshot = await management.refresh(); + expect(snapshot.hello.state).toBe("locked"); + expect(snapshot.unlockRequired).toBe(true); + expect(snapshot.autoLock.known).toBe(false); + vmk.fill(0); + }); + + it("keeps an ambiguous committed candidate journaled and refuses format-mismatched exact reconciliation", async () => { + const { state, vmk } = await fixture(); + const store = new MemoryBrowserStore(state); + const journal = new MemoryJournal(); + const device = new AutoLockDevice(state, [1, 2]); + device.commitAndDisconnect = true; + const management = new CanonicalDeviceManagement(device, device.typedHello(), store.asIndexedDb(), journal.asIndexedDb()); + await management.initialize(); + + await expect(management.setAutoLockDays(1)).rejects.toBeInstanceOf(PendingBrowserTransactionError); + expect(store.current().vault.vaultFormatVersion).toBe(LEGACY_VAULT_FORMAT_VERSION); + expect(store.current().vault.generation).toBe(7n); + expect(journal.current()?.candidate.vault.vaultFormatVersion).toBe(VAULT_FORMAT_VERSION); + expect(journal.current()?.candidate.vault.generation).toBe(8n); + + device.connected = true; + device.vaultFormat = LEGACY_VAULT_FORMAT_VERSION; + const mismatched = new CanonicalDeviceManagement(device, device.typedHello(), store.asIndexedDb(), journal.asIndexedDb()); + await expect(mismatched.initialize()).rejects.toThrow(/cannot be reconciled|generation conflict/i); + expect(store.current().vault.generation).toBe(7n); + expect(journal.current()).not.toBeNull(); + + device.vaultFormat = VAULT_FORMAT_VERSION; + const reconciled = new CanonicalDeviceManagement(device, device.typedHello(), store.asIndexedDb(), journal.asIndexedDb()); + await reconciled.initialize(); + expect(store.current().vault.vaultFormatVersion).toBe(VAULT_FORMAT_VERSION); + expect(store.current().vault.generation).toBe(8n); + expect(await readAutoLock(store.current())).toBe(1); + expect(journal.current()).toBeNull(); + vmk.fill(0); + }); +}); diff --git a/web/src/canonical-management-rekey.test.ts b/web/src/canonical-management-rekey.test.ts index 6a7efe7d..58853378 100644 --- a/web/src/canonical-management-rekey.test.ts +++ b/web/src/canonical-management-rekey.test.ts @@ -16,6 +16,7 @@ import { encryptVault, wrapVmkWithPassphrase, } from "./security/vault-crypto"; +import { encodeVaultPlaintext, type VaultPlaintext } from "./security/vault-format"; import { encodeBase64UrlCanonical, type SessionWireOperation, @@ -177,7 +178,11 @@ describe("canonical VMK re-key integration", () => { it("stages Browser state, requires a fresh authenticated session, and commits generation +1", async () => { const vaultId = bytes(16, 0x30); const oldVmk = bytes(32, 0x40); - const plaintext = new TextEncoder().encode("synthetic-rekey-payload"); + const plaintextModel: VaultPlaintext = { + credentials: [], + wifi: null, + }; + const plaintext = encodeVaultPlaintext(plaintextModel); const vault = await encryptVault(plaintext, oldVmk, vaultId, 4n); const recovery = await wrapVmkWithPassphrase(oldVmk, vaultId, recoveryPassphrase); const state = await createBrowserCanonicalState({ diff --git a/web/src/canonical-management.ts b/web/src/canonical-management.ts index f6285df2..9c6a26db 100644 --- a/web/src/canonical-management.ts +++ b/web/src/canonical-management.ts @@ -1,4 +1,5 @@ import { + deviceSupportsVaultFormat, encryptedVaultParams, parseCanonicalHelloData, parseCanonicalTimeStatus, @@ -30,16 +31,20 @@ import { rekeyTrustedBrowserState } from "./security/browser-vmk-rekey"; import { deliverVmkOverSessionV2, absentBrkIdentity, registrationMatches } from "./security/session-flow-v2"; import { CREDENTIAL_ID_BYTES, + LEGACY_VAULT_FORMAT_VERSION, MAX_VAULT_CREDENTIALS, + VAULT_FORMAT_VERSION, decodeVaultPlaintext, encodeVaultPlaintext, + normalizeAutoLockDays, + type SupportedVaultFormatVersion, type VaultPlaintext, } from "./security/vault-format"; import { decryptVault, - encryptVault, + encryptVaultForFormat, unwrapVmkWithPassphrase, - wrapVmkWithPassphrase, + wrapVmkWithPassphraseForFormat, } from "./security/vault-crypto"; import { encodeBase64UrlCanonical } from "./security/session-protocol-v2"; import type { CanonicalV2Transport } from "./serial"; @@ -54,6 +59,12 @@ export interface CanonicalAccountView { order: number; } +export interface CanonicalAutoLockView { + known: boolean; + days: number | null; + format2Writable: boolean; +} + export interface CanonicalDeviceSnapshot { hello: CanonicalHelloData; time: CanonicalTimeStatus; @@ -63,6 +74,7 @@ export interface CanonicalDeviceSnapshot { recoveryProvisioningCandidates: number; accounts: CanonicalAccountView[]; wifi: { configured: boolean; ssid: string }; + autoLock: CanonicalAutoLockView; } interface ImportedCredential { @@ -101,6 +113,7 @@ function wipeVaultPlaintext(plaintext: VaultPlaintext | null): void { plaintext.wifi.password = ""; plaintext.wifi = null; } + plaintext.autoLockDays = null; } function wipeImported(values: ImportedCredential[]): void { @@ -128,7 +141,11 @@ function assertActiveDeviceBinding(state: BrowserCanonicalState, hello: Canonica if (state.deviceMetadata?.deviceId !== hello.deviceId) { throw new Error("Browser Vault belongs to a different Device ID"); } - assertCanonicalGeneration(state, { vaultId: hello.vaultId, generation: hello.generation }); + assertCanonicalGeneration(state, { + vaultId: hello.vaultId, + generation: hello.generation, + vaultFormatVersion: hello.vaultFormat, + }); if (!registrationMatches( { registrationId: state.trustedBrowser.registrationId, @@ -154,6 +171,56 @@ function exactBindingMatches(state: BrowserCanonicalState, hello: CanonicalHello } } +function deviceCanWriteFormat2(hello: CanonicalHelloData): boolean { + return deviceSupportsVaultFormat(hello, VAULT_FORMAT_VERSION); +} + +function nextMutationVaultFormat( + state: BrowserCanonicalState, + hello: CanonicalHelloData, + requireFormat2: boolean, +): SupportedVaultFormatVersion { + if (state.vault.vaultFormatVersion === VAULT_FORMAT_VERSION) { + if (!deviceCanWriteFormat2(hello)) { + throw new Error("Device does not advertise Vault Format 2 write support for the active canonical Vault"); + } + return VAULT_FORMAT_VERSION; + } + if (state.vault.vaultFormatVersion !== LEGACY_VAULT_FORMAT_VERSION) { + throw new Error("Unsupported canonical Vault format"); + } + if (deviceCanWriteFormat2(hello)) return VAULT_FORMAT_VERSION; + if (requireFormat2) { + throw new Error("Automatic LOCK settings require Device firmware that explicitly supports Vault Format 2"); + } + return LEGACY_VAULT_FORMAT_VERSION; +} + +function initialVaultFormat(hello: CanonicalHelloData): SupportedVaultFormatVersion { + return deviceCanWriteFormat2(hello) ? VAULT_FORMAT_VERSION : LEGACY_VAULT_FORMAT_VERSION; +} + +function assertRecoveryVaultSupportedByDevice(state: BrowserCanonicalState, hello: CanonicalHelloData): void { + if (state.vault.vaultFormatVersion === VAULT_FORMAT_VERSION && !deviceCanWriteFormat2(hello)) { + throw new Error("This Recovery Package contains Vault Format 2, but the connected Device does not advertise Format 2 support"); + } +} + +function migratedRecoveryWrapper( + state: BrowserCanonicalState, + targetFormat: SupportedVaultFormatVersion, +): BrowserCanonicalState["recoveryWrappedVmk"] { + return { + ...state.recoveryWrappedVmk, + vaultFormatVersion: targetFormat, + vaultId: state.recoveryWrappedVmk.vaultId.slice(), + kdf: { ...state.recoveryWrappedVmk.kdf, salt: state.recoveryWrappedVmk.kdf.salt.slice() }, + nonce: state.recoveryWrappedVmk.nonce.slice(), + ciphertext: state.recoveryWrappedVmk.ciphertext.slice(), + tag: state.recoveryWrappedVmk.tag.slice(), + }; +} + export class CanonicalDeviceManagement { private hello: CanonicalHelloData; private state: BrowserCanonicalState | null = null; @@ -200,9 +267,6 @@ export class CanonicalDeviceManagement { this.ownership = "active"; if (this.hello.state === "locked") { - // Ordinary connection is read-only with respect to unlock. A valid - // Trusted Browser remains eligible, but VMK delivery/session.begin is - // reserved for the explicit requestUnlock() user action. this.unlockRequired = true; } else if (this.hello.state === "unlocked") { this.unlockRequired = false; @@ -252,10 +316,20 @@ export class CanonicalDeviceManagement { let accounts: CanonicalAccountView[] = []; let wifi = { configured: false, ssid: "" }; + let autoLock: CanonicalAutoLockView = { + known: false, + days: null, + format2Writable: deviceCanWriteFormat2(this.hello), + }; if (this.state && this.ownership === "active" && this.hello.state === "unlocked") { const view = await this.readBrowserVaultView(this.state); accounts = view.accounts; wifi = view.wifi; + autoLock = { + known: true, + days: view.autoLockDays, + format2Writable: deviceCanWriteFormat2(this.hello), + }; } const recoveryCandidates = !this.hello.vaultPresent && !this.hello.registrationPresent && this.hello.state === "unprovisioned" @@ -271,6 +345,7 @@ export class CanonicalDeviceManagement { recoveryProvisioningCandidates: recoveryCandidates.length, accounts, wifi, + autoLock, }; }); } @@ -289,15 +364,12 @@ export class CanonicalDeviceManagement { } const state = candidates[0]!; + assertRecoveryVaultSupportedByDevice(state, this.hello); const existingPending = await this.journal.get(state.vault.vaultId); if (existingPending) throw new PendingBrowserTransactionError(); const vmk = await unwrapVmkForTrustedBrowser(state); let candidate: BrowserCanonicalState | null = null; try { - // A clean replacement Device starts a new registration lifetime at epoch 1. - // BUK wrapping AAD binds registration ID + epoch, so never rewrite the - // imported epoch in-place: generate fresh BUK/BRK/registration and wrap - // the recovered VMK again under the new epoch-1 identity. candidate = await createBrowserCanonicalState({ vault: state.vault, recoveryWrappedVmk: state.recoveryWrappedVmk, @@ -428,16 +500,26 @@ export class CanonicalDeviceManagement { }); } + public async setAutoLockDays(days: number | null): Promise { + const normalized = normalizeAutoLockDays(days); + await this.mutateVault((plaintext) => { + plaintext.autoLockDays = normalized; + }, true); + } + public async rotateVmk(recoveryPassphrase: string): Promise { if (recoveryPassphrase.length === 0) throw new Error("VMK re-key requires the Recovery Passphrase"); await withCanonicalBrowserStateLock(async () => { await this.refreshHelloAndBrowserState(); this.requireActiveWriter(); const state = this.state!; + const targetFormat = nextMutationVaultFormat(state, this.hello, false); const currentVmk = await unwrapVmkForTrustedBrowser(state); const nextVmk = randomBytes(32); let verifiedRecoveryVmk: Uint8Array | null = null; let decrypted: Uint8Array | null = null; + let encoded: Uint8Array | null = null; + let plaintext: VaultPlaintext | null = null; let candidate: BrowserCanonicalState | null = null; const nextGeneration = state.vault.generation + 1n; if (nextGeneration > 0xffff_ffff_ffff_ffffn) { @@ -452,8 +534,21 @@ export class CanonicalDeviceManagement { throw new Error("Recovery Passphrase does not match the current canonical Vault"); } decrypted = await decryptVault(state.vault, currentVmk); - const nextVault = await encryptVault(decrypted, nextVmk, state.vault.vaultId, nextGeneration); - const nextRecoveryWrappedVmk = await wrapVmkWithPassphrase(nextVmk, state.vault.vaultId, recoveryPassphrase); + plaintext = decodeVaultPlaintext(decrypted, state.vault.vaultFormatVersion); + encoded = encodeVaultPlaintext(plaintext, targetFormat); + const nextVault = await encryptVaultForFormat( + encoded, + nextVmk, + state.vault.vaultId, + nextGeneration, + targetFormat, + ); + const nextRecoveryWrappedVmk = await wrapVmkWithPassphraseForFormat( + nextVmk, + state.vault.vaultId, + recoveryPassphrase, + targetFormat, + ); candidate = await rekeyTrustedBrowserState({ current: state, nextVault, @@ -493,8 +588,10 @@ export class CanonicalDeviceManagement { } finally { verifiedRecoveryVmk?.fill(0); decrypted?.fill(0); + encoded?.fill(0); currentVmk.fill(0); nextVmk.fill(0); + wipeVaultPlaintext(plaintext); candidate = null; } }); @@ -593,8 +690,6 @@ export class CanonicalDeviceManagement { const affected = intent.affectedVaults[0]!; const current = await this.store.get(affected.vaultId); if (current && current.vault.generation === affected.generation && exactBindingMatches(current, this.hello)) { - // Exact old binding proves the reset did not commit; the canonical state - // is still valid, so clear only the durable reset intent. await this.resetIntents.delete(intent.deviceId); return; } @@ -614,7 +709,6 @@ export class CanonicalDeviceManagement { const pendingForDevice = await this.journal.listForDevice(this.hello.deviceId); for (const pending of pendingForDevice) { if (pending.kind === "factory-reset") { - // Backward-compatible cleanup for pre-reset-intent builds. const current = await this.store.get(pending.candidate.vault.vaultId); if (current) { if (current.vault.generation !== pending.expectedGeneration) { @@ -805,7 +899,11 @@ export class CanonicalDeviceManagement { this.ownership = "conflict"; throw new Error("Recovery replacement requires an existing canonical Device registration"); } - assertCanonicalGeneration(state, { vaultId: this.hello.vaultId, generation: this.hello.generation }); + assertCanonicalGeneration(state, { + vaultId: this.hello.vaultId, + generation: this.hello.generation, + vaultFormatVersion: this.hello.vaultFormat, + }); const exactPendingAlreadyConfirmed = state.deviceMetadata?.deviceId === this.hello.deviceId && registrationMatches( @@ -884,6 +982,7 @@ export class CanonicalDeviceManagement { throw new Error("Initial provisioning requires 1 to 32 accounts"); } + const format = initialVaultFormat(this.hello); const vmk = randomBytes(32); const vaultId = randomBytes(16); const plaintext: VaultPlaintext = { @@ -899,13 +998,14 @@ export class CanonicalDeviceManagement { manualOrder: order, })), wifi: null, + autoLockDays: null, }; let encoded: Uint8Array | null = null; let candidate: BrowserCanonicalState | null = null; try { - encoded = encodeVaultPlaintext(plaintext); - const vault = await encryptVault(encoded, vmk, vaultId, 1n); - const recoveryWrappedVmk = await wrapVmkWithPassphrase(vmk, vaultId, passphrase); + encoded = encodeVaultPlaintext(plaintext, format); + const vault = await encryptVaultForFormat(encoded, vmk, vaultId, 1n, format); + const recoveryWrappedVmk = await wrapVmkWithPassphraseForFormat(vmk, vaultId, passphrase, format); candidate = await createBrowserCanonicalState({ vault, recoveryWrappedVmk, @@ -972,24 +1072,35 @@ export class CanonicalDeviceManagement { assertActiveDeviceBinding(this.state, this.hello); } - private async mutateVault(mutator: (plaintext: VaultPlaintext) => void): Promise { + private async mutateVault(mutator: (plaintext: VaultPlaintext) => void, requireFormat2 = false): Promise { await withCanonicalBrowserStateLock(async () => { await this.refreshHelloAndBrowserState(); this.requireActiveWriter(); const state = this.state!; + const targetFormat = nextMutationVaultFormat(state, this.hello, requireFormat2); const vmk = await unwrapVmkForTrustedBrowser(state); let decrypted: Uint8Array | null = null; let encoded: Uint8Array | null = null; let plaintext: VaultPlaintext | null = null; try { decrypted = await decryptVault(state.vault, vmk); - plaintext = decodeVaultPlaintext(decrypted); + plaintext = decodeVaultPlaintext(decrypted, state.vault.vaultFormatVersion); mutator(plaintext); - encoded = encodeVaultPlaintext(plaintext); + encoded = encodeVaultPlaintext(plaintext, targetFormat); const nextGeneration = state.vault.generation + 1n; if (nextGeneration > 0xffff_ffff_ffff_ffffn) throw new Error("Canonical Vault generation overflow"); - const nextVault = await encryptVault(encoded, vmk, state.vault.vaultId, nextGeneration); - const candidate = sanitizeBrowserCanonicalState({ ...state, vault: nextVault }); + const nextVault = await encryptVaultForFormat( + encoded, + vmk, + state.vault.vaultId, + nextGeneration, + targetFormat, + ); + const candidate = sanitizeBrowserCanonicalState({ + ...state, + vault: nextVault, + recoveryWrappedVmk: migratedRecoveryWrapper(state, targetFormat), + }); const pending: BrowserPendingTransaction = { kind: "vault-update", expectedGeneration: state.vault.generation, @@ -1019,13 +1130,14 @@ export class CanonicalDeviceManagement { private async readBrowserVaultView(state: BrowserCanonicalState): Promise<{ accounts: CanonicalAccountView[]; wifi: { configured: boolean; ssid: string }; + autoLockDays: number | null; }> { const vmk = await unwrapVmkForTrustedBrowser(state); let decrypted: Uint8Array | null = null; let plaintext: VaultPlaintext | null = null; try { decrypted = await decryptVault(state.vault, vmk); - plaintext = decodeVaultPlaintext(decrypted); + plaintext = decodeVaultPlaintext(decrypted, state.vault.vaultFormatVersion); const accounts = plaintext.credentials .map((credential) => ({ id: credentialViewId(credential.credentialId), @@ -1038,6 +1150,7 @@ export class CanonicalDeviceManagement { return { accounts, wifi: plaintext.wifi ? { configured: true, ssid: plaintext.wifi.ssid } : { configured: false, ssid: "" }, + autoLockDays: plaintext.autoLockDays ?? null, }; } finally { decrypted?.fill(0); diff --git a/web/src/canonical-protocol-v2.test.ts b/web/src/canonical-protocol-v2.test.ts index 0ef43dfb..a34b9670 100644 --- a/web/src/canonical-protocol-v2.test.ts +++ b/web/src/canonical-protocol-v2.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { buildCanonicalV2Request, + deviceSupportsVaultFormat, encryptedVaultParams, parseCanonicalHelloData, parseCanonicalTimeStatus, @@ -35,17 +36,42 @@ function helloData() { } describe("canonical Protocol v2 management", () => { - it("parses the exact 2/2/1 hello binding", () => { + it("parses shipped Format-1 hello without inventing Format-2 write capability", () => { const parsed = parseCanonicalHelloData(helloData()); expect(parsed.protocol).toBe(2); expect(parsed.storageSchema).toBe(2); expect(parsed.vaultFormat).toBe(1); + expect(parsed.supportedVaultFormats).toEqual([]); + expect(deviceSupportsVaultFormat(parsed, 2)).toBe(false); expect(parsed.generation).toBe(7n); expect(parsed.registrationEpoch).toBe(4); expect(parsed.recoveryResetRequired).toBe(false); expect(parsed.vaultId).toEqual(bytes(16, 0x10)); }); + it("accepts additive [1,2] capability metadata independently from the persisted format", () => { + const parsed = parseCanonicalHelloData({ ...helloData(), supported_vault_formats: [1, 2] }); + expect(parsed.vaultFormat).toBe(1); + expect(parsed.supportedVaultFormats).toEqual([1, 2]); + expect(deviceSupportsVaultFormat(parsed, 2)).toBe(true); + + const migrated = parseCanonicalHelloData({ + ...helloData(), + vault_format: 2, + supported_vault_formats: [1, 2], + }); + expect(migrated.vaultFormat).toBe(2); + }); + + it("rejects malformed capability metadata and unknown persisted formats", () => { + expect(() => parseCanonicalHelloData({ ...helloData(), supported_vault_formats: [1, 2, 2] })) + .toThrow(/supported_vault_formats/); + expect(() => parseCanonicalHelloData({ ...helloData(), supported_vault_formats: "1,2" })) + .toThrow(/supported_vault_formats/); + expect(() => parseCanonicalHelloData({ ...helloData(), vault_format: 3 })) + .toThrow(/incompatible canonical metadata/); + }); + it("rejects partial Vault / registration state unless Device exposes bounded recovery reset", () => { const partial = { ...helloData(), @@ -70,7 +96,7 @@ describe("canonical Protocol v2 management", () => { .toThrow(/invalid_state/); }); - it("parses trusted-time framing and serializes encrypted Vault metadata", () => { + it("parses trusted-time framing and serializes both supported encrypted Vault formats", () => { expect(parseCanonicalTimeStatus({ readiness: "ready", source: "usb", @@ -86,18 +112,21 @@ describe("canonical Protocol v2 management", () => { }); const vaultId = bytes(16, 0x10); - const params = encryptedVaultParams({ - vaultFormatVersion: 1, - storageSchemaVersion: 2, - vaultId, - generation: 8n, - nonce: bytes(12, 0x20), - ciphertext: bytes(32, 0x40), - tag: bytes(16, 0x60), - ciphertextLength: 32, - }); - expect(params.generation).toBe("8"); - expect(params.vault_id).toBe(encodeBase64UrlCanonical(vaultId)); - expect(params.ciphertext_length).toBe(32); + for (const vaultFormatVersion of [1, 2] as const) { + const params = encryptedVaultParams({ + vaultFormatVersion, + storageSchemaVersion: 2, + vaultId, + generation: 8n, + nonce: bytes(12, 0x20), + ciphertext: bytes(32, 0x40), + tag: bytes(16, 0x60), + ciphertextLength: 32, + }); + expect(params.vault_format_version).toBe(vaultFormatVersion); + expect(params.generation).toBe("8"); + expect(params.vault_id).toBe(encodeBase64UrlCanonical(vaultId)); + expect(params.ciphertext_length).toBe(32); + } }); }); diff --git a/web/src/canonical-protocol-v2.ts b/web/src/canonical-protocol-v2.ts index 223c786a..38f46865 100644 --- a/web/src/canonical-protocol-v2.ts +++ b/web/src/canonical-protocol-v2.ts @@ -5,11 +5,17 @@ import { SESSION_REGISTRATION_ID_BYTES, SESSION_VAULT_ID_BYTES, } from "./security/session-protocol-v2"; +import { + LEGACY_VAULT_FORMAT_VERSION, + VAULT_FORMAT_VERSION, + isSupportedVaultFormatVersion, + type SupportedVaultFormatVersion, +} from "./security/vault-format"; import type { EncryptedVaultEnvelope } from "./security/vault-crypto"; export const CANONICAL_PROTOCOL_VERSION = 2 as const; export const CANONICAL_STORAGE_SCHEMA_VERSION = 2 as const; -export const CANONICAL_VAULT_FORMAT_VERSION = 1 as const; +export const CANONICAL_VAULT_FORMAT_VERSION = VAULT_FORMAT_VERSION; export type CanonicalWireOperation = | "hello" @@ -37,7 +43,11 @@ export interface CanonicalHelloData { firmware: string; protocol: 2; storageSchema: 2; - vaultFormat: 1; + vaultFormat: SupportedVaultFormatVersion; + // parseCanonicalHelloData always returns an explicit array. This remains + // optional on the structural interface so pre-capability synthetic transports + // remain compatible; absence is conservatively treated as no Format-2 support. + supportedVaultFormats?: readonly number[]; buildCommit: string; state: DeviceRuntimeState; storageReady: boolean; @@ -105,6 +115,27 @@ export function parseCanonicalV2Response( return parsed.data; } +function parseSupportedVaultFormats(value: unknown): readonly number[] { + if (value === undefined) return []; + if (!Array.isArray(value) || value.length > 32) { + throw new Error("Device returned invalid supported_vault_formats"); + } + const result: number[] = []; + const seen = new Set(); + for (const item of value) { + if (typeof item !== "number" || !Number.isSafeInteger(item) || item < 1 || item > 0xffff || seen.has(item)) { + throw new Error("Device returned invalid supported_vault_formats"); + } + seen.add(item); + result.push(item); + } + return result; +} + +export function deviceSupportsVaultFormat(hello: Pick, version: number): boolean { + return hello.supportedVaultFormats?.includes(version) === true; +} + export function parseCanonicalHelloData(data: Record): CanonicalHelloData { if ( typeof data.device !== "string" || data.device.length === 0 || @@ -112,7 +143,7 @@ export function parseCanonicalHelloData(data: Record): Canonica typeof data.firmware !== "string" || data.protocol !== CANONICAL_PROTOCOL_VERSION || data.storage_schema !== CANONICAL_STORAGE_SCHEMA_VERSION || - data.vault_format !== CANONICAL_VAULT_FORMAT_VERSION || + typeof data.vault_format !== "number" || !isSupportedVaultFormatVersion(data.vault_format) || typeof data.build_commit !== "string" || !isRuntimeState(data.state) || typeof data.storage_ready !== "boolean" || @@ -125,6 +156,7 @@ export function parseCanonicalHelloData(data: Record): Canonica throw new Error("Device returned incompatible canonical metadata"); } const recoveryResetRequired = data.recovery_reset_required === true; + const supportedVaultFormats = parseSupportedVaultFormats(data.supported_vault_formats); const generation = parseU64Decimal(data.generation, "generation"); let vaultId: Uint8Array | null = null; @@ -157,7 +189,8 @@ export function parseCanonicalHelloData(data: Record): Canonica firmware: data.firmware, protocol: CANONICAL_PROTOCOL_VERSION, storageSchema: CANONICAL_STORAGE_SCHEMA_VERSION, - vaultFormat: CANONICAL_VAULT_FORMAT_VERSION, + vaultFormat: data.vault_format, + supportedVaultFormats, buildCommit: data.build_commit, state: data.state, storageReady: data.storage_ready, @@ -192,7 +225,7 @@ export function parseCanonicalTimeStatus(data: Record): Canonic export function encryptedVaultParams(envelope: EncryptedVaultEnvelope): Record { if ( - envelope.vaultFormatVersion !== CANONICAL_VAULT_FORMAT_VERSION || + !isSupportedVaultFormatVersion(envelope.vaultFormatVersion) || envelope.storageSchemaVersion !== CANONICAL_STORAGE_SCHEMA_VERSION || envelope.vaultId.length !== SESSION_VAULT_ID_BYTES || envelope.nonce.length !== 12 || envelope.tag.length !== 16 || @@ -213,6 +246,10 @@ export function encryptedVaultParams(envelope: EncryptedVaultEnvelope): Record { + if (deviceActionInProgress || recoveryReset || !management) { + throw new Error("Automatic LOCK settings require an active canonical Device connection"); + } + setDeviceBusy(true); + try { + await requireManagement().setAutoLockDays(days); + await refreshDevice(); + } catch (error) { + const errorMessage = userFacingError(error, "Automatic LOCK update failed."); + if (serialSession?.isClosed()) await disconnectDevice(false); + deviceNotice.textContent = errorMessage; + throw error; + } finally { + setDeviceBusy(false); + } +}); + renderDevice(); renderImportedAccounts([]); @@ -446,6 +465,7 @@ function updateControls(): void { recoveryFactoryResetButton.hidden = !recoveryMode; recoveryResetHint.hidden = !recoveryMode; recoveryFactoryResetButton.disabled = deviceActionInProgress || !recoveryMode; + autoLockSettings.render(snapshot, deviceActionInProgress, recoveryMode); } function renderDevice(): void { diff --git a/web/src/security/browser-vault.test.ts b/web/src/security/browser-vault.test.ts index df9ab000..2be72d6a 100644 --- a/web/src/security/browser-vault.test.ts +++ b/web/src/security/browser-vault.test.ts @@ -8,10 +8,24 @@ import { exportRecoveryPackage, importRecoveryPackage, mergeVaultAdvanceWithCurrentBrowserState, + parseRecoveryPackage, sanitizeBrowserCanonicalState, unwrapVmkForTrustedBrowser, } from "./browser-vault"; -import { decryptVault, encryptVault, unwrapVmkWithPassphrase, wrapVmkWithPassphrase } from "./vault-crypto"; +import { + decryptVault, + encryptVaultForFormat, + unwrapVmkWithPassphrase, + wrapVmkWithPassphraseForFormat, +} from "./vault-crypto"; +import { + LEGACY_VAULT_FORMAT_VERSION, + VAULT_FORMAT_VERSION, + decodeVaultPlaintext, + encodeVaultPlaintext, + type SupportedVaultFormatVersion, + type VaultPlaintext, +} from "./vault-format"; const oldPassphrase = ["synthetic", "recovery", "phrase", "alpha"].join(" "); const newPassphrase = ["synthetic", "recovery", "phrase", "beta"].join(" "); @@ -21,12 +35,31 @@ function bytes(length: number, start: number): Uint8Array { return Uint8Array.from({ length }, (_, index) => (start + index) & 0xff); } -async function fixture() { +function samplePlaintext(autoLockDays: number | null): VaultPlaintext { + return { + credentials: [{ + credentialId: bytes(16, 0x20), + secret: bytes(20, 0x40), + issuer: "synthetic-issuer-only", + account: "synthetic-account-only", + displayName: "synthetic-display-only", + algorithm: "SHA1", + digits: 6, + periodSeconds: 30, + manualOrder: 0, + }], + wifi: null, + autoLockDays, + }; +} + +async function fixture(format: SupportedVaultFormatVersion = VAULT_FORMAT_VERSION, autoLockDays: number | null = null) { const vmk = bytes(32, 3); const vaultId = bytes(16, 41); - const plaintext = new TextEncoder().encode("synthetic-only-vault-plaintext"); - const vault = await encryptVault(plaintext, vmk, vaultId, 7n); - const wrapped = await wrapVmkWithPassphrase(vmk, vaultId, oldPassphrase); + const logical = samplePlaintext(format === LEGACY_VAULT_FORMAT_VERSION ? null : autoLockDays); + const plaintext = encodeVaultPlaintext(logical, format); + const vault = await encryptVaultForFormat(plaintext, vmk, vaultId, 7n, format); + const wrapped = await wrapVmkWithPassphraseForFormat(vmk, vaultId, oldPassphrase, format); const state = await createBrowserCanonicalState({ vault, recoveryWrappedVmk: wrapped, @@ -53,30 +86,51 @@ describe("browser canonical Vault", () => { vmk.fill(0); }); - it("exports only encrypted recovery material and imports a fresh replacement-pending browser", async () => { - const { vmk, state } = await fixture(); + it.each([ + [LEGACY_VAULT_FORMAT_VERSION, null], + [VAULT_FORMAT_VERSION, 31], + ] as const)("exports/imports Recovery Package v1 carrying Vault Format %i", async (format, autoLockDays) => { + const { vmk, state } = await fixture(format, autoLockDays); const serialized = exportRecoveryPackage(state); expect(serialized).not.toContain("buk"); expect(serialized).not.toContain("brkPrivateKey"); - expect(serialized).not.toContain("synthetic-only-vault-plaintext"); + expect(JSON.parse(serialized).packageVersion).toBe(1); + expect(JSON.parse(serialized).vault.vaultFormatVersion).toBe(format); const imported = await importRecoveryPackage(serialized, oldPassphrase); + expect(imported.vault.vaultFormatVersion).toBe(format); + expect(imported.recoveryWrappedVmk.vaultFormatVersion).toBe(format); expect(imported.trustedBrowser.status).toBe("replacement-pending"); expect(imported.trustedBrowser.epoch).toBe(state.trustedBrowser.epoch + 1); - expect(imported.trustedBrowser.registrationId).not.toEqual(state.trustedBrowser.registrationId); - expect(imported.trustedBrowser.brkPublicKeyRaw).not.toEqual(state.trustedBrowser.brkPublicKeyRaw); - expect(imported.vault.generation).toBe(state.vault.generation); const importedVmk = await unwrapVmkForTrustedBrowser(imported); - expect(importedVmk).toEqual(vmk); + const decrypted = await decryptVault(imported.vault, importedVmk); + const logical = decodeVaultPlaintext(decrypted, format); + expect(logical.autoLockDays).toBe(format === 1 ? null : autoLockDays); + for (const credential of logical.credentials) credential.secret.fill(0); + decrypted.fill(0); importedVmk.fill(0); vmk.fill(0); }); - it("re-wraps the current VMK on Passphrase change without claiming old package revocation", async () => { + it("rejects unknown Recovery Vault formats and mismatched wrapper metadata", async () => { const { vmk, state } = await fixture(); + const parsed = JSON.parse(exportRecoveryPackage(state)); + parsed.vault.vaultFormatVersion = 3; + expect(() => parseRecoveryPackage(JSON.stringify(parsed))).toThrow(/unsupported vault.vaultFormatVersion/); + + const mismatched = JSON.parse(exportRecoveryPackage(state)); + mismatched.wrappedVmk.vaultFormatVersion = 1; + expect(() => parseRecoveryPackage(JSON.stringify(mismatched))).toThrow(/format mismatch/); + vmk.fill(0); + }); + + it("re-wraps the current VMK on Passphrase change without changing Vault format", async () => { + const { vmk, state } = await fixture(VAULT_FORMAT_VERSION, 7); const oldPackage = exportRecoveryPackage(state); const changed = await changeRecoveryPassphrase(state, oldPassphrase, newPassphrase); + expect(changed.vault.vaultFormatVersion).toBe(VAULT_FORMAT_VERSION); + expect(changed.recoveryWrappedVmk.vaultFormatVersion).toBe(VAULT_FORMAT_VERSION); const currentVmk = await unwrapVmkWithPassphrase(changed.recoveryWrappedVmk, newPassphrase); expect(currentVmk).toEqual(vmk); @@ -91,16 +145,29 @@ describe("browser canonical Vault", () => { vmk.fill(0); }); - it("preserves same-generation browser security state when the encrypted Vault advances", async () => { - const { vmk, state } = await fixture(); + it("preserves a concurrent Passphrase re-wrap while accepting an F1-to-F2 generation advance", async () => { + const { vmk, state } = await fixture(LEGACY_VAULT_FORMAT_VERSION); const changed = await changeRecoveryPassphrase(state, oldPassphrase, newPassphrase); const plaintext = await decryptVault(state.vault, vmk); + const logical = decodeVaultPlaintext(plaintext, LEGACY_VAULT_FORMAT_VERSION); + const encodedV2 = encodeVaultPlaintext({ ...logical, autoLockDays: 1 }, VAULT_FORMAT_VERSION); try { - const nextVault = await encryptVault(plaintext, vmk, state.vault.vaultId, state.vault.generation + 1n); - const staleVaultAdvance = sanitizeBrowserCanonicalState({ ...state, vault: nextVault }); - const merged = mergeVaultAdvanceWithCurrentBrowserState(changed, staleVaultAdvance, state.vault.generation); - + const nextVault = await encryptVaultForFormat( + encodedV2, + vmk, + state.vault.vaultId, + state.vault.generation + 1n, + VAULT_FORMAT_VERSION, + ); + const incoming = sanitizeBrowserCanonicalState({ + ...state, + vault: nextVault, + recoveryWrappedVmk: { ...state.recoveryWrappedVmk, vaultFormatVersion: VAULT_FORMAT_VERSION }, + }); + const merged = mergeVaultAdvanceWithCurrentBrowserState(changed, incoming, state.vault.generation); expect(merged.vault.generation).toBe(8n); + expect(merged.vault.vaultFormatVersion).toBe(VAULT_FORMAT_VERSION); + expect(merged.recoveryWrappedVmk.vaultFormatVersion).toBe(VAULT_FORMAT_VERSION); expect(merged.trustedBrowser.registrationId).toEqual(changed.trustedBrowser.registrationId); expect(merged.trustedBrowser.wrappedVmk).toEqual(changed.trustedBrowser.wrappedVmk); @@ -109,21 +176,29 @@ describe("browser canonical Vault", () => { recovered.fill(0); await expect(unwrapVmkWithPassphrase(merged.recoveryWrappedVmk, oldPassphrase)).rejects.toThrow(); } finally { + for (const credential of logical.credentials) credential.secret.fill(0); plaintext.fill(0); + encodedV2.fill(0); vmk.fill(0); } }); - it("fails closed on unexpected generation divergence", async () => { + it("fails closed on generation or format divergence", async () => { const { vmk, vaultId, state } = await fixture(); - expect(() => assertCanonicalGeneration(state, { vaultId, generation: 7n })).not.toThrow(); - expect(() => assertCanonicalGeneration(state, { vaultId, generation: 8n })).toThrow(GenerationConflictError); - expect(() => assertCanonicalGeneration(state, { vaultId: bytes(16, 99), generation: 7n })).toThrow(GenerationConflictError); + expect(() => assertCanonicalGeneration(state, { + vaultId, + generation: 7n, + vaultFormatVersion: 2, + })).not.toThrow(); + expect(() => assertCanonicalGeneration(state, { vaultId, generation: 8n, vaultFormatVersion: 2 })) + .toThrow(GenerationConflictError); + expect(() => assertCanonicalGeneration(state, { vaultId, generation: 7n, vaultFormatVersion: 1 })) + .toThrow(GenerationConflictError); vmk.fill(0); }); it("projects persistence through an allowlist so accidental plaintext properties are dropped", async () => { - const { vmk, state } = await fixture(); + const { vmk, state } = await fixture(VAULT_FORMAT_VERSION, 1); const tainted = Object.assign({}, state, { plaintextVmk: blockedPersistenceMarker, passphrase: blockedPersistenceMarker, @@ -136,7 +211,9 @@ describe("browser canonical Vault", () => { const quickVmk = await unwrapVmkForTrustedBrowser(safe); const decrypted = await decryptVault(safe.vault, quickVmk); - expect(new TextDecoder().decode(decrypted)).toBe("synthetic-only-vault-plaintext"); + const logical = decodeVaultPlaintext(decrypted, VAULT_FORMAT_VERSION); + expect(logical.autoLockDays).toBe(1); + for (const credential of logical.credentials) credential.secret.fill(0); decrypted.fill(0); quickVmk.fill(0); vmk.fill(0); diff --git a/web/src/security/browser-vault.ts b/web/src/security/browser-vault.ts index c0e38a79..45f0061b 100644 --- a/web/src/security/browser-vault.ts +++ b/web/src/security/browser-vault.ts @@ -8,9 +8,16 @@ import { type EncryptedVaultEnvelope, type PassphraseWrappedVmk, unwrapVmkWithPassphrase, - wrapVmkWithPassphrase, + wrapVmkWithPassphraseForFormat, } from "./vault-crypto"; -import { VAULT_FORMAT_VERSION, VAULT_ID_BYTES, VAULT_TARGET_STORAGE_SCHEMA_VERSION } from "./vault-format"; +import { + VAULT_ID_BYTES, + VAULT_TARGET_STORAGE_SCHEMA_VERSION, + decodeVaultPlaintext, + isSupportedVaultFormatVersion, + type SupportedVaultFormatVersion, + type VaultPlaintext, +} from "./vault-format"; const BROWSER_STATE_VERSION = 1; const BUK_WRAP_VERSION = 1; @@ -156,7 +163,7 @@ function cloneBrowserWrappedVmk(value: BrowserWrappedVmk): BrowserWrappedVmk { function validateStateFraming(state: BrowserCanonicalState): void { assert(state.stateVersion === BROWSER_STATE_VERSION, `unsupported browser state version: ${state.stateVersion}`); - assert(state.vault.vaultFormatVersion === VAULT_FORMAT_VERSION, "unsupported Vault format version"); + assert(isSupportedVaultFormatVersion(state.vault.vaultFormatVersion), "unsupported Vault format version"); assert(state.vault.storageSchemaVersion === VAULT_TARGET_STORAGE_SCHEMA_VERSION, "unsupported target storage schema"); assertLength(state.vault.vaultId, VAULT_ID_BYTES, "vaultId"); assertLength(state.vault.nonce, AES_GCM_NONCE_BYTES, "Vault nonce"); @@ -166,7 +173,8 @@ function validateStateFraming(state: BrowserCanonicalState): void { const wrapped = state.recoveryWrappedVmk; assert(wrapped.packageVersion === RECOVERY_PACKAGE_VERSION, "unsupported Recovery Package version"); assert(wrapped.wrapVersion === VMK_WRAP_VERSION, "unsupported VMK wrap version"); - assert(wrapped.vaultFormatVersion === VAULT_FORMAT_VERSION, "unsupported wrapped VMK Vault format"); + assert(isSupportedVaultFormatVersion(wrapped.vaultFormatVersion), "unsupported wrapped VMK Vault format"); + assert(wrapped.vaultFormatVersion === state.vault.vaultFormatVersion, "Vault and wrapped VMK format mismatch"); assertLength(wrapped.vaultId, VAULT_ID_BYTES, "wrapped VMK vaultId"); assert(sameBytes(state.vault.vaultId, wrapped.vaultId), "Vault and wrapped VMK vault_id mismatch"); assertLength(wrapped.nonce, AES_GCM_NONCE_BYTES, "wrapped VMK nonce"); @@ -332,6 +340,7 @@ export async function createBrowserCanonicalState( ): Promise { assertLength(input.vmk, AES_GCM_KEY_BYTES, "VMK"); assert(sameBytes(input.vault.vaultId, input.recoveryWrappedVmk.vaultId), "Vault and recovery wrapper vault_id mismatch"); + assert(input.vault.vaultFormatVersion === input.recoveryWrappedVmk.vaultFormatVersion, "Vault and recovery wrapper format mismatch"); const epoch = input.registrationEpoch ?? 1; const keys = await generateTrustedBrowserKeys(); const wrappedVmk = await wrapVmkWithBuk(input.vmk, keys.buk, input.vault.vaultId, keys.registrationId, epoch); @@ -354,9 +363,13 @@ export async function createBrowserCanonicalState( export function assertCanonicalGeneration( state: BrowserCanonicalState, - observed: { vaultId: Uint8Array; generation: bigint }, + observed: { vaultId: Uint8Array; generation: bigint; vaultFormatVersion?: number }, ): void { - if (!sameBytes(state.vault.vaultId, observed.vaultId) || state.vault.generation !== observed.generation) { + if ( + !sameBytes(state.vault.vaultId, observed.vaultId) || + state.vault.generation !== observed.generation || + (observed.vaultFormatVersion !== undefined && state.vault.vaultFormatVersion !== observed.vaultFormatVersion) + ) { throw new GenerationConflictError(); } } @@ -404,6 +417,29 @@ function parseGeneration(value: unknown): bigint { return generation; } +function parseVaultFormatVersion(value: unknown, field: string): SupportedVaultFormatVersion { + const version = asInteger(value, field, 1); + assert(isSupportedVaultFormatVersion(version), `unsupported ${field}`); + return version; +} + +function wipeDecodedVault(value: VaultPlaintext): void { + for (const credential of value.credentials) { + credential.credentialId.fill(0); + credential.secret.fill(0); + credential.issuer = ""; + credential.account = ""; + credential.displayName = ""; + } + value.credentials.length = 0; + if (value.wifi) { + value.wifi.ssid = ""; + value.wifi.password = ""; + value.wifi = null; + } + value.autoLockDays = null; +} + export function exportRecoveryPackage(state: BrowserCanonicalState): string { const safe = sanitizeBrowserCanonicalState(state); const packageJson: RecoveryPackageJson = { @@ -463,7 +499,7 @@ export function parseRecoveryPackage(serialized: string): { const vaultJson = asObject(root.vault, "vault"); const vault: EncryptedVaultEnvelope = { - vaultFormatVersion: asInteger(vaultJson.vaultFormatVersion, "vault.vaultFormatVersion", 1), + vaultFormatVersion: parseVaultFormatVersion(vaultJson.vaultFormatVersion, "vault.vaultFormatVersion"), storageSchemaVersion: asInteger(vaultJson.storageSchemaVersion, "vault.storageSchemaVersion", 1), vaultId: fromBase64Url(vaultJson.vaultId, "vault.vaultId"), generation: parseGeneration(vaultJson.generation), @@ -480,7 +516,7 @@ export function parseRecoveryPackage(serialized: string): { const wrappedVmk: PassphraseWrappedVmk = { packageVersion: asInteger(wrappedJson.packageVersion, "wrappedVmk.packageVersion", 1), wrapVersion: asInteger(wrappedJson.wrapVersion, "wrappedVmk.wrapVersion", 1), - vaultFormatVersion: asInteger(wrappedJson.vaultFormatVersion, "wrappedVmk.vaultFormatVersion", 1), + vaultFormatVersion: parseVaultFormatVersion(wrappedJson.vaultFormatVersion, "wrappedVmk.vaultFormatVersion"), vaultId: fromBase64Url(wrappedJson.vaultId, "wrappedVmk.vaultId"), kdf: { algorithm: "argon2id", @@ -511,35 +547,13 @@ export function parseRecoveryPackage(serialized: string): { deviceMetadata = { deviceId }; } - const placeholderKey = {} as CryptoKey; - const placeholderState = { - stateVersion: BROWSER_STATE_VERSION, - vault, - recoveryWrappedVmk: wrappedVmk, - trustedBrowser: { - registrationId: previousRegistration.registrationId, - epoch: previousRegistration.epoch, - status: "replacement-pending" as const, - buk: placeholderKey, - brkPrivateKey: placeholderKey, - brkPublicKeyRaw: new Uint8Array(BRK_PUBLIC_RAW_BYTES), - wrappedVmk: { - version: BUK_WRAP_VERSION, - nonce: new Uint8Array(AES_GCM_NONCE_BYTES), - ciphertext: new Uint8Array(AES_GCM_KEY_BYTES), - tag: new Uint8Array(AES_GCM_TAG_BYTES), - }, - }, - deviceMetadata, - }; - assert(vault.vaultFormatVersion === VAULT_FORMAT_VERSION, "unsupported Vault format version"); assert(vault.storageSchemaVersion === VAULT_TARGET_STORAGE_SCHEMA_VERSION, "unsupported storage schema version"); assertLength(vault.vaultId, VAULT_ID_BYTES, "vaultId"); assertLength(vault.nonce, AES_GCM_NONCE_BYTES, "Vault nonce"); assertLength(vault.tag, AES_GCM_TAG_BYTES, "Vault tag"); assert(vault.ciphertextLength === vault.ciphertext.length, "Vault ciphertext framing mismatch"); assert(sameBytes(vault.vaultId, wrappedVmk.vaultId), "Vault and wrapped VMK vault_id mismatch"); - void placeholderState; + assert(vault.vaultFormatVersion === wrappedVmk.vaultFormatVersion, "Vault and wrapped VMK format mismatch"); return { vault, wrappedVmk, previousRegistration, deviceMetadata }; } @@ -547,8 +561,10 @@ export async function importRecoveryPackage(serialized: string, passphrase: stri const parsed = parseRecoveryPackage(serialized); const vmk = await unwrapVmkWithPassphrase(parsed.wrappedVmk, passphrase); let plaintext: Uint8Array | null = null; + let decoded: VaultPlaintext | null = null; try { plaintext = await decryptVault(parsed.vault, vmk); + decoded = decodeVaultPlaintext(plaintext, parsed.vault.vaultFormatVersion); const nextEpoch = parsed.previousRegistration.epoch + 1; assert(Number.isSafeInteger(nextEpoch), "registration epoch overflow"); return await createBrowserCanonicalState({ @@ -560,6 +576,7 @@ export async function importRecoveryPackage(serialized: string, passphrase: stri deviceMetadata: parsed.deviceMetadata, }); } finally { + if (decoded) wipeDecodedVault(decoded); plaintext?.fill(0); vmk.fill(0); } @@ -575,7 +592,12 @@ export async function changeRecoveryPassphrase( let plaintext: Uint8Array | null = null; try { plaintext = await decryptVault(safe.vault, vmk); - const replacement = await wrapVmkWithPassphrase(vmk, safe.vault.vaultId, newPassphrase); + const replacement = await wrapVmkWithPassphraseForFormat( + vmk, + safe.vault.vaultId, + newPassphrase, + safe.vault.vaultFormatVersion, + ); return sanitizeBrowserCanonicalState({ ...safe, recoveryWrappedVmk: replacement }); } finally { plaintext?.fill(0); @@ -623,7 +645,18 @@ export function mergeVaultAdvanceWithCurrentBrowserState( safeIncoming.trustedBrowser.wrappedVmk, ); if (!isSingleGenerationAdvance || !sameVault || !sameBrowserVmk) return safeIncoming; - return sanitizeBrowserCanonicalState({ ...safeCurrent, vault: safeIncoming.vault }); + + // A same-VMK generation advance may race with a same-generation browser-only + // Recovery Passphrase re-wrap. Preserve the current wrapper crypto/KDF and + // advance only its associated Vault-format metadata to match the committed + // envelope. VMK wrap v1 does not bind Vault format in its AAD. + const recoveryWrappedVmk = cloneWrappedVmk(safeCurrent.recoveryWrappedVmk); + recoveryWrappedVmk.vaultFormatVersion = safeIncoming.vault.vaultFormatVersion; + return sanitizeBrowserCanonicalState({ + ...safeCurrent, + vault: safeIncoming.vault, + recoveryWrappedVmk, + }); } export class IndexedDbBrowserVaultStore { diff --git a/web/src/security/vault-crypto.test.ts b/web/src/security/vault-crypto.test.ts index 4f0664b1..48fe783e 100644 --- a/web/src/security/vault-crypto.test.ts +++ b/web/src/security/vault-crypto.test.ts @@ -10,14 +10,17 @@ import { VMK_WRAP_VERSION, decryptVault, derivePassphraseKek, + encryptLegacyVault, encryptVault, + encryptVaultForFormat, normalizeAndValidatePassphrase, unwrapVmkWithPassphrase, wrapVmkWithPassphrase, + wrapVmkWithPassphraseForFormat, type Argon2idKdfMetadata, type RandomSource, } from "./vault-crypto"; -import { VAULT_ID_BYTES } from "./vault-format"; +import { LEGACY_VAULT_FORMAT_VERSION, VAULT_FORMAT_VERSION, VAULT_ID_BYTES } from "./vault-format"; const PASSPHRASE = "synthetic-passphrase-only-51"; @@ -64,12 +67,12 @@ function kdfFixture(): Argon2idKdfMetadata { }; } -describe("V1 Web vault crypto", () => { - it("matches the synthetic AES-256-GCM Vault known-answer vector", async () => { +describe("Web vault crypto", () => { + it("preserves the shipped Format-1 AES-256-GCM known-answer vector", async () => { const keyBytes = sequence(32, 0x00); const vaultId = sequence(VAULT_ID_BYTES, 0x00); const plaintext = new TextEncoder().encode("synthetic-vault-payload-only"); - const envelope = await encryptVault( + const envelope = await encryptLegacyVault( plaintext, keyBytes, vaultId, @@ -77,14 +80,44 @@ describe("V1 Web vault crypto", () => { new FixedRandomSource([sequence(12, 0xa0)]), ); + expect(envelope.vaultFormatVersion).toBe(LEGACY_VAULT_FORMAT_VERSION); expect(hex(envelope.nonce)).toBe("a0a1a2a3a4a5a6a7a8a9aaab"); - expect(hex(envelope.ciphertext)).toBe( - "956112592dae76d60148f1b27216b4f300cd207cfdd62641f3604aff", - ); + expect(hex(envelope.ciphertext)).toBe("956112592dae76d60148f1b27216b4f300cd207cfdd62641f3604aff"); expect(hex(envelope.tag)).toBe("fe8172af15306428c847087428f8395c"); await expect(decryptVault(envelope, keyBytes)).resolves.toEqual(plaintext); }); + it("uses the distinct Format-2 authenticated domain and never cross-decrypts formats", async () => { + const keyBytes = sequence(32, 0x00); + const vaultId = sequence(VAULT_ID_BYTES, 0x00); + const plaintext = new TextEncoder().encode("synthetic-vault-payload-only"); + const sourceBytes = sequence(12, 0xa0); + const legacy = await encryptVaultForFormat( + plaintext, + keyBytes, + vaultId, + 7n, + LEGACY_VAULT_FORMAT_VERSION, + new FixedRandomSource([sourceBytes]), + ); + const current = await encryptVaultForFormat( + plaintext, + keyBytes, + vaultId, + 7n, + VAULT_FORMAT_VERSION, + new FixedRandomSource([sourceBytes]), + ); + expect(current.vaultFormatVersion).toBe(VAULT_FORMAT_VERSION); + // AES-GCM keystream encryption is independent of AAD, so identical + // key/nonce/plaintext yields identical ciphertext while the authenticated + // tag changes with the Format-2 AAD domain. + expect(current.ciphertext).toEqual(legacy.ciphertext); + expect(current.tag).not.toEqual(legacy.tag); + await expect(decryptVault(current, keyBytes)).resolves.toEqual(plaintext); + await expect(decryptVault({ ...current, vaultFormatVersion: LEGACY_VAULT_FORMAT_VERSION }, keyBytes)).rejects.toThrow(); + }); + it("fails closed when Vault AAD or authentication tag changes", async () => { const keyBytes = sequence(32, 0x00); const envelope = await encryptVault( @@ -103,34 +136,37 @@ describe("V1 Web vault crypto", () => { it("matches the Argon2id v19 synthetic known-answer vector", async () => { const derived = await derivePassphraseKek(PASSPHRASE, kdfFixture()); - expect(hex(derived)).toBe( - "fe495a7c9e2244d921169b177ad086861db297c9684f6a838bebc53765a65b97", - ); + expect(hex(derived)).toBe("fe495a7c9e2244d921169b177ad086861db297c9684f6a838bebc53765a65b97"); derived.fill(0); }); - it("matches the synthetic Passphrase-wrapped VMK known-answer vector", async () => { + it("keeps VMK wrap crypto at Version 1 while carrying the associated Vault format metadata", async () => { const keyBytes = sequence(32, 0x00); const vaultId = sequence(VAULT_ID_BYTES, 0x00); - const wrapped = await wrapVmkWithPassphrase( + const wrapped1 = await wrapVmkWithPassphraseForFormat( keyBytes, vaultId, PASSPHRASE, + LEGACY_VAULT_FORMAT_VERSION, kdfFixture(), new FixedRandomSource([sequence(12, 0xb0)]), ); - - expect(wrapped.packageVersion).toBe(RECOVERY_PACKAGE_VERSION); - expect(wrapped.wrapVersion).toBe(VMK_WRAP_VERSION); - expect(hex(wrapped.nonce)).toBe("b0b1b2b3b4b5b6b7b8b9babb"); - expect(hex(wrapped.ciphertext)).toBe( - "539fe562291b3e6503ec2f35c42cc8fd8b7e6ece98eed59410e780eabbd75fd5", + const wrapped2 = await wrapVmkWithPassphraseForFormat( + keyBytes, + vaultId, + PASSPHRASE, + VAULT_FORMAT_VERSION, + kdfFixture(), + new FixedRandomSource([sequence(12, 0xb0)]), ); - expect(hex(wrapped.tag)).toBe("04f7af67328726ba0bc6bc1c48a74f69"); - await expect(unwrapVmkWithPassphrase(wrapped, PASSPHRASE)).resolves.toEqual(keyBytes); - await expect( - unwrapVmkWithPassphrase(wrapped, "synthetic-different-passphrase-51"), - ).rejects.toThrow(); + + expect(wrapped1.packageVersion).toBe(RECOVERY_PACKAGE_VERSION); + expect(wrapped1.wrapVersion).toBe(VMK_WRAP_VERSION); + expect(wrapped1.vaultFormatVersion).toBe(1); + expect(wrapped2.vaultFormatVersion).toBe(2); + expect(wrapped2.ciphertext).toEqual(wrapped1.ciphertext); + expect(wrapped2.tag).toEqual(wrapped1.tag); + await expect(unwrapVmkWithPassphrase(wrapped2, PASSPHRASE)).resolves.toEqual(keyBytes); }); it("fails closed for unsupported Recovery/KDF/wrap parameters before use", async () => { @@ -144,15 +180,12 @@ describe("V1 Web vault crypto", () => { new FixedRandomSource([sequence(12, 0xb0)]), ); - await expect( - unwrapVmkWithPassphrase({ ...wrapped, packageVersion: RECOVERY_PACKAGE_VERSION + 1 }, PASSPHRASE), - ).rejects.toThrow(/Recovery Package version/); - await expect( - unwrapVmkWithPassphrase({ ...wrapped, wrapVersion: VMK_WRAP_VERSION + 1 }, PASSPHRASE), - ).rejects.toThrow(/VMK wrap version/); - await expect( - derivePassphraseKek(PASSPHRASE, { ...kdfFixture(), version: ARGON2ID_VERSION + 1 }), - ).rejects.toThrow(/Argon2id version/); + await expect(unwrapVmkWithPassphrase({ ...wrapped, packageVersion: RECOVERY_PACKAGE_VERSION + 1 }, PASSPHRASE)) + .rejects.toThrow(/Recovery Package version/); + await expect(unwrapVmkWithPassphrase({ ...wrapped, wrapVersion: VMK_WRAP_VERSION + 1 }, PASSPHRASE)) + .rejects.toThrow(/VMK wrap version/); + await expect(derivePassphraseKek(PASSPHRASE, { ...kdfFixture(), version: ARGON2ID_VERSION + 1 })) + .rejects.toThrow(/Argon2id version/); }); it("requests a fresh nonce for every Vault encryption instead of deriving it from generation", async () => { diff --git a/web/src/security/vault-crypto.ts b/web/src/security/vault-crypto.ts index 6651d6ed..d59eec3f 100644 --- a/web/src/security/vault-crypto.ts +++ b/web/src/security/vault-crypto.ts @@ -1,12 +1,14 @@ import { argon2id } from "hash-wasm"; import { + LEGACY_VAULT_FORMAT_VERSION, RECOVERY_PACKAGE_VERSION, - VAULT_FORMAT_VERSION, VAULT_ID_BYTES, VAULT_TARGET_STORAGE_SCHEMA_VERSION, VMK_WRAP_VERSION, + assertSupportedVaultFormatVersion, buildVaultAad, buildVmkWrapAad, + type SupportedVaultFormatVersion, } from "./vault-format"; export { RECOVERY_PACKAGE_VERSION, VMK_WRAP_VERSION } from "./vault-format"; @@ -24,7 +26,7 @@ export const ARGON2ID_OUTPUT_BYTES = 32; const textEncoder = new TextEncoder(); export interface EncryptedVaultEnvelope { - vaultFormatVersion: number; + vaultFormatVersion: SupportedVaultFormatVersion; storageSchemaVersion: number; vaultId: Uint8Array; generation: bigint; @@ -47,7 +49,7 @@ export interface Argon2idKdfMetadata { export interface PassphraseWrappedVmk { packageVersion: number; wrapVersion: number; - vaultFormatVersion: number; + vaultFormatVersion: SupportedVaultFormatVersion; vaultId: Uint8Array; kdf: Argon2idKdfMetadata; nonce: Uint8Array; @@ -155,9 +157,7 @@ async function aesGcmDecrypt( } function validateVaultEnvelope(envelope: EncryptedVaultEnvelope): void { - if (envelope.vaultFormatVersion !== VAULT_FORMAT_VERSION) { - throw new Error(`unsupported vault format version: ${envelope.vaultFormatVersion}`); - } + assertSupportedVaultFormatVersion(envelope.vaultFormatVersion); if (envelope.storageSchemaVersion !== VAULT_TARGET_STORAGE_SCHEMA_VERSION) { throw new Error(`unsupported storage schema version: ${envelope.storageSchemaVersion}`); } @@ -169,20 +169,22 @@ function validateVaultEnvelope(envelope: EncryptedVaultEnvelope): void { } } -export async function encryptVault( +export async function encryptVaultForFormat( plaintext: Uint8Array, vmk: Uint8Array, vaultId: Uint8Array, generation: bigint, + vaultFormatVersion: SupportedVaultFormatVersion, source: RandomSource = browserRandomSource, ): Promise { + assertSupportedVaultFormatVersion(vaultFormatVersion); assertLength(vmk, AES_GCM_KEY_BYTES, "VMK"); assertLength(vaultId, VAULT_ID_BYTES, "vaultId"); const nonce = randomBytes(AES_GCM_NONCE_BYTES, source); - const aad = buildVaultAad({ vaultId, generation }); + const aad = buildVaultAad({ vaultId, generation, vaultFormatVersion }); const encrypted = await aesGcmEncrypt(vmk, nonce, plaintext, aad); return { - vaultFormatVersion: VAULT_FORMAT_VERSION, + vaultFormatVersion, storageSchemaVersion: VAULT_TARGET_STORAGE_SCHEMA_VERSION, vaultId: vaultId.slice(), generation, @@ -193,6 +195,42 @@ export async function encryptVault( }; } +// Compatibility helper retained for existing V1 callers/fixtures. New canonical +// writes must select a format explicitly with encryptVaultForFormat(). +export async function encryptVault( + plaintext: Uint8Array, + vmk: Uint8Array, + vaultId: Uint8Array, + generation: bigint, + source: RandomSource = browserRandomSource, +): Promise { + return encryptVaultForFormat( + plaintext, + vmk, + vaultId, + generation, + LEGACY_VAULT_FORMAT_VERSION, + source, + ); +} + +export async function encryptLegacyVault( + plaintext: Uint8Array, + vmk: Uint8Array, + vaultId: Uint8Array, + generation: bigint, + source: RandomSource = browserRandomSource, +): Promise { + return encryptVaultForFormat( + plaintext, + vmk, + vaultId, + generation, + LEGACY_VAULT_FORMAT_VERSION, + source, + ); +} + export async function decryptVault( envelope: EncryptedVaultEnvelope, vmk: Uint8Array, @@ -281,9 +319,7 @@ function validateWrappedVmk(value: PassphraseWrappedVmk): void { if (value.wrapVersion !== VMK_WRAP_VERSION) { throw new Error(`unsupported VMK wrap version: ${value.wrapVersion}`); } - if (value.vaultFormatVersion !== VAULT_FORMAT_VERSION) { - throw new Error(`unsupported vault format version: ${value.vaultFormatVersion}`); - } + assertSupportedVaultFormatVersion(value.vaultFormatVersion); assertLength(value.vaultId, VAULT_ID_BYTES, "vaultId"); assertLength(value.nonce, AES_GCM_NONCE_BYTES, "VMK wrap nonce"); assertLength(value.tag, AES_GCM_TAG_BYTES, "VMK wrap tag"); @@ -293,13 +329,15 @@ function validateWrappedVmk(value: PassphraseWrappedVmk): void { validateKdfMetadata(value.kdf); } -export async function wrapVmkWithPassphrase( +export async function wrapVmkWithPassphraseForFormat( vmk: Uint8Array, vaultId: Uint8Array, passphrase: string, + vaultFormatVersion: SupportedVaultFormatVersion, kdf: Argon2idKdfMetadata = createArgon2idMetadata(), source: RandomSource = browserRandomSource, ): Promise { + assertSupportedVaultFormatVersion(vaultFormatVersion); assertLength(vmk, AES_GCM_KEY_BYTES, "VMK"); assertLength(vaultId, VAULT_ID_BYTES, "vaultId"); validateKdfMetadata(kdf); @@ -315,7 +353,7 @@ export async function wrapVmkWithPassphrase( return { packageVersion: RECOVERY_PACKAGE_VERSION, wrapVersion: VMK_WRAP_VERSION, - vaultFormatVersion: VAULT_FORMAT_VERSION, + vaultFormatVersion, vaultId: vaultId.slice(), kdf: { ...kdf, salt: kdf.salt.slice() }, nonce, @@ -327,6 +365,25 @@ export async function wrapVmkWithPassphrase( } } +// Compatibility helper retained for existing V1 callers/fixtures. New canonical +// writes must select a format explicitly with wrapVmkWithPassphraseForFormat(). +export async function wrapVmkWithPassphrase( + vmk: Uint8Array, + vaultId: Uint8Array, + passphrase: string, + kdf: Argon2idKdfMetadata = createArgon2idMetadata(), + source: RandomSource = browserRandomSource, +): Promise { + return wrapVmkWithPassphraseForFormat( + vmk, + vaultId, + passphrase, + LEGACY_VAULT_FORMAT_VERSION, + kdf, + source, + ); +} + export async function unwrapVmkWithPassphrase( wrapped: PassphraseWrappedVmk, passphrase: string, diff --git a/web/src/security/vault-format.test.ts b/web/src/security/vault-format.test.ts index 779c9034..5890e97e 100644 --- a/web/src/security/vault-format.test.ts +++ b/web/src/security/vault-format.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { CREDENTIAL_ID_BYTES, + LEGACY_VAULT_FORMAT_VERSION, VAULT_FORMAT_VERSION, VAULT_ID_BYTES, VAULT_TARGET_STORAGE_SCHEMA_VERSION, @@ -23,7 +24,7 @@ function hex(value: Uint8Array): string { return Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join(""); } -function sampleVault(): VaultPlaintext { +function sampleVault(autoLockDays: number | null = null): VaultPlaintext { return { credentials: [ { @@ -42,39 +43,62 @@ function sampleVault(): VaultPlaintext { ssid: syntheticText("ssid"), password: syntheticText("network-pass"), }, + autoLockDays, }; } -describe("V1 vault format", () => { - it("round-trips encrypted-boundary account and Wi-Fi fields", () => { +describe("Vault Format 1 / 2", () => { + it("keeps Format 1 byte-compatible and maps the missing automatic LOCK setting to disabled", () => { const original = sampleVault(); - const decoded = decodeVaultPlaintext(encodeVaultPlaintext(original)); + const encoded = encodeVaultPlaintext(original, LEGACY_VAULT_FORMAT_VERSION); + expect(new TextDecoder().decode(encoded.slice(0, 15))).toBe("M5AUTH-VLT-PT1\0"); + const decoded = decodeVaultPlaintext(encoded, LEGACY_VAULT_FORMAT_VERSION); expect(decoded.credentials).toHaveLength(1); expect(decoded.credentials[0]?.issuer).toBe(original.credentials[0]?.issuer); - expect(decoded.credentials[0]?.account).toBe(original.credentials[0]?.account); - expect(decoded.credentials[0]?.displayName).toBe(original.credentials[0]?.displayName); expect(decoded.credentials[0]?.secret).toEqual(original.credentials[0]?.secret); expect(decoded.wifi?.ssid).toBe(original.wifi?.ssid); - expect(decoded.wifi?.password).toBe(original.wifi?.password); + expect(decoded.autoLockDays).toBeNull(); + expect(() => encodeVaultPlaintext(sampleVault(1), LEGACY_VAULT_FORMAT_VERSION)).toThrow(/Format 1/); }); - it("builds fixed-order vault AAD without a Device identifier", () => { - const aad = buildVaultAad({ - vaultId: sequence(VAULT_ID_BYTES, 0x00), - generation: 7n, - }); + it.each([null, 1, 31] as const)("round-trips Format 2 autoLockDays=%s", (autoLockDays) => { + const original = sampleVault(autoLockDays); + const encoded = encodeVaultPlaintext(original, VAULT_FORMAT_VERSION); + expect(new TextDecoder().decode(encoded.slice(0, 15))).toBe("M5AUTH-VLT-PT2\0"); + const decoded = decodeVaultPlaintext(encoded, VAULT_FORMAT_VERSION); + expect(decoded.autoLockDays).toBe(autoLockDays); + expect(decoded.credentials[0]?.account).toBe(original.credentials[0]?.account); + }); - expect(hex(aad)).toBe( + it("rejects invalid Format 2 automatic LOCK values instead of coercing them", () => { + expect(() => encodeVaultPlaintext(sampleVault(0), VAULT_FORMAT_VERSION)).toThrow(/auto_lock_days/); + expect(() => encodeVaultPlaintext(sampleVault(32), VAULT_FORMAT_VERSION)).toThrow(/auto_lock_days/); + expect(() => encodeVaultPlaintext(sampleVault(1.5), VAULT_FORMAT_VERSION)).toThrow(/auto_lock_days/); + + const encoded = encodeVaultPlaintext(sampleVault(1), VAULT_FORMAT_VERSION); + const badPresence = encoded.slice(); + badPresence[badPresence.length - 2] = 2; + expect(() => decodeVaultPlaintext(badPresence, VAULT_FORMAT_VERSION)).toThrow(/auto_lock_present/); + }); + + it("uses separate authenticated AAD domains for Format 1 and Format 2", () => { + const vaultId = sequence(VAULT_ID_BYTES, 0x00); + const aad1 = buildVaultAad({ vaultId, generation: 7n, vaultFormatVersion: LEGACY_VAULT_FORMAT_VERSION }); + const aad2 = buildVaultAad({ vaultId, generation: 7n, vaultFormatVersion: VAULT_FORMAT_VERSION }); + + expect(hex(aad1)).toBe( "4d35415554482d564c542d4141443100" + "0001" + "0002" + "000102030405060708090a0b0c0d0e0f" + "0000000000000007", ); + expect(hex(aad2).startsWith("4d35415554482d564c542d4141443200")).toBe(true); + expect(aad2).not.toEqual(aad1); }); - it("builds fixed-order VMK wrapping AAD", () => { + it("builds the unchanged Version-1 VMK wrapping AAD", () => { const aad = buildVmkWrapAad({ vaultId: sequence(VAULT_ID_BYTES, 0x10) }); expect(hex(aad)).toBe( "4d35415554482d564d4b2d575241503100" + @@ -86,27 +110,21 @@ describe("V1 vault format", () => { it("fails closed for unknown format or storage schema versions", () => { const vaultId = sequence(VAULT_ID_BYTES, 0x00); - expect(() => - buildVaultAad({ vaultId, generation: 1n, vaultFormatVersion: VAULT_FORMAT_VERSION + 1 }), - ).toThrow(/unsupported vault format version/); - expect(() => - buildVaultAad({ - vaultId, - generation: 1n, - storageSchemaVersion: VAULT_TARGET_STORAGE_SCHEMA_VERSION + 1, - }), - ).toThrow(/unsupported storage schema version/); + expect(() => buildVaultAad({ vaultId, generation: 1n, vaultFormatVersion: VAULT_FORMAT_VERSION + 1 })) + .toThrow(/unsupported vault format version/); + expect(() => buildVaultAad({ + vaultId, + generation: 1n, + storageSchemaVersion: VAULT_TARGET_STORAGE_SCHEMA_VERSION + 1, + })).toThrow(/unsupported storage schema version/); }); - it("rejects trailing bytes and unsupported plaintext versions", () => { - const encoded = encodeVaultPlaintext(sampleVault()); - const trailing = new Uint8Array(encoded.length + 1); - trailing.set(encoded); - expect(() => decodeVaultPlaintext(trailing)).toThrow(/trailing/); - - const unknown = encoded.slice(); - const versionOffset = new TextEncoder().encode("M5AUTH-VLT-PT1\0").length; - unknown[versionOffset + 1] = 2; - expect(() => decodeVaultPlaintext(unknown)).toThrow(/unsupported vault format version/); + it("requires exact end-of-input for both formats", () => { + for (const version of [LEGACY_VAULT_FORMAT_VERSION, VAULT_FORMAT_VERSION] as const) { + const encoded = encodeVaultPlaintext(sampleVault(), version); + const trailing = new Uint8Array(encoded.length + 1); + trailing.set(encoded); + expect(() => decodeVaultPlaintext(trailing, version)).toThrow(/trailing/); + } }); }); diff --git a/web/src/security/vault-format.ts b/web/src/security/vault-format.ts index a4dc07cb..0bc787f6 100644 --- a/web/src/security/vault-format.ts +++ b/web/src/security/vault-format.ts @@ -1,7 +1,10 @@ const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder("utf-8", { fatal: true }); -export const VAULT_FORMAT_VERSION = 1; +export const LEGACY_VAULT_FORMAT_VERSION = 1 as const; +export const VAULT_FORMAT_VERSION = 2 as const; +export const SUPPORTED_VAULT_FORMAT_VERSIONS = [LEGACY_VAULT_FORMAT_VERSION, VAULT_FORMAT_VERSION] as const; +export type SupportedVaultFormatVersion = (typeof SUPPORTED_VAULT_FORMAT_VERSIONS)[number]; export const VAULT_TARGET_STORAGE_SCHEMA_VERSION = 2; export const RECOVERY_PACKAGE_VERSION = 1; export const VMK_WRAP_VERSION = 1; @@ -9,8 +12,10 @@ export const VAULT_ID_BYTES = 16; export const CREDENTIAL_ID_BYTES = 16; export const MAX_VAULT_CREDENTIALS = 32; -const VAULT_PLAINTEXT_MAGIC = textEncoder.encode("M5AUTH-VLT-PT1\0"); -const VAULT_AAD_MAGIC = textEncoder.encode("M5AUTH-VLT-AAD1\0"); +const VAULT_PLAINTEXT_MAGIC_V1 = textEncoder.encode("M5AUTH-VLT-PT1\0"); +const VAULT_PLAINTEXT_MAGIC_V2 = textEncoder.encode("M5AUTH-VLT-PT2\0"); +const VAULT_AAD_MAGIC_V1 = textEncoder.encode("M5AUTH-VLT-AAD1\0"); +const VAULT_AAD_MAGIC_V2 = textEncoder.encode("M5AUTH-VLT-AAD2\0"); const VMK_WRAP_AAD_MAGIC = textEncoder.encode("M5AUTH-VMK-WRAP1\0"); const ALGORITHM_SHA1 = 1; @@ -39,6 +44,7 @@ export interface VaultWifiRecord { export interface VaultPlaintext { credentials: VaultCredentialRecord[]; wifi: VaultWifiRecord | null; + autoLockDays?: number | null; } export interface VaultAadInput { @@ -155,10 +161,24 @@ function writeVersion(writer: ByteWriter, version: number, expected: number, fie writer.u16(version); } +export function isSupportedVaultFormatVersion(value: number): value is SupportedVaultFormatVersion { + return value === LEGACY_VAULT_FORMAT_VERSION || value === VAULT_FORMAT_VERSION; +} + +export function assertSupportedVaultFormatVersion(value: number): asserts value is SupportedVaultFormatVersion { + if (!isSupportedVaultFormatVersion(value)) throw new Error(`unsupported vault format version: ${value}`); +} + +export function normalizeAutoLockDays(value: number | null | undefined): number | null { + if (value === null || value === undefined) return null; + assertIntegerRange(value, 1, 31, "auto_lock_days"); + return value; +} + function validateCredential(record: VaultCredentialRecord): void { assertFixedLength(record.credentialId, CREDENTIAL_ID_BYTES, "credentialId"); if (record.secret.length < 1 || record.secret.length > MAX_SECRET_BYTES) { - throw new Error("secret length is outside the V1 vault limit"); + throw new Error("secret length is outside the vault limit"); } if (record.algorithm !== "SHA1") throw new Error(`unsupported TOTP algorithm: ${String(record.algorithm)}`); assertIntegerRange(record.digits, 1, 10, "digits"); @@ -166,17 +186,13 @@ function validateCredential(record: VaultCredentialRecord): void { assertIntegerRange(record.manualOrder, 0, 0xffff, "manualOrder"); } -export function encodeVaultPlaintext(value: VaultPlaintext): Uint8Array { +function writeCommonPlaintext(writer: ByteWriter, value: VaultPlaintext): void { if (value.credentials.length > MAX_VAULT_CREDENTIALS) { throw new Error(`vault supports at most ${MAX_VAULT_CREDENTIALS} credentials`); } const ids = new Set(); - const writer = new ByteWriter(); - writer.bytes(VAULT_PLAINTEXT_MAGIC); - writeVersion(writer, VAULT_FORMAT_VERSION, VAULT_FORMAT_VERSION, "vault format version"); writer.u16(value.credentials.length); - for (const record of value.credentials) { validateCredential(record); const idKey = Array.from(record.credentialId, (byte) => byte.toString(16).padStart(2, "0")).join(""); @@ -199,15 +215,9 @@ export function encodeVaultPlaintext(value: VaultPlaintext): Uint8Array { writer.sizedText(value.wifi.ssid, "wifi ssid"); writer.sizedText(value.wifi.password, "wifi password"); } - return writer.finish(); } -export function decodeVaultPlaintext(encoded: Uint8Array): VaultPlaintext { - const reader = new ByteReader(encoded); - expectMagic(reader, VAULT_PLAINTEXT_MAGIC, "vault plaintext magic"); - const version = reader.u16("vault format version"); - if (version !== VAULT_FORMAT_VERSION) throw new Error(`unsupported vault format version: ${version}`); - +function readCommonPlaintext(reader: ByteReader): Pick { const count = reader.u16("credential count"); if (count > MAX_VAULT_CREDENTIALS) { throw new Error(`vault supports at most ${MAX_VAULT_CREDENTIALS} credentials`); @@ -249,19 +259,92 @@ export function decodeVaultPlaintext(encoded: Uint8Array): VaultPlaintext { const wifi = wifiPresent === 1 ? { ssid: reader.sizedText("wifi ssid"), password: reader.sizedText("wifi password") } : null; - - reader.expectEnd(); return { credentials, wifi }; } +export function encodeVaultPlaintext( + value: VaultPlaintext, + vaultFormatVersion: SupportedVaultFormatVersion = LEGACY_VAULT_FORMAT_VERSION, +): Uint8Array { + assertSupportedVaultFormatVersion(vaultFormatVersion); + const writer = new ByteWriter(); + + if (vaultFormatVersion === LEGACY_VAULT_FORMAT_VERSION) { + if (normalizeAutoLockDays(value.autoLockDays) !== null) { + throw new Error("Vault Format 1 cannot encode auto_lock_days"); + } + writer.bytes(VAULT_PLAINTEXT_MAGIC_V1); + writeVersion(writer, LEGACY_VAULT_FORMAT_VERSION, LEGACY_VAULT_FORMAT_VERSION, "vault format version"); + writeCommonPlaintext(writer, value); + return writer.finish(); + } + + writer.bytes(VAULT_PLAINTEXT_MAGIC_V2); + writeVersion(writer, VAULT_FORMAT_VERSION, VAULT_FORMAT_VERSION, "vault format version"); + writeCommonPlaintext(writer, value); + const autoLockDays = normalizeAutoLockDays(value.autoLockDays); + writer.u8(autoLockDays === null ? 0 : 1); + if (autoLockDays !== null) writer.u8(autoLockDays); + return writer.finish(); +} + +export function decodeVaultPlaintext( + encoded: Uint8Array, + expectedVaultFormatVersion?: SupportedVaultFormatVersion, +): VaultPlaintext { + const candidates = expectedVaultFormatVersion === undefined + ? SUPPORTED_VAULT_FORMAT_VERSIONS + : [expectedVaultFormatVersion] as const; + + for (const version of candidates) { + try { + const reader = new ByteReader(encoded); + if (version === LEGACY_VAULT_FORMAT_VERSION) { + expectMagic(reader, VAULT_PLAINTEXT_MAGIC_V1, "vault plaintext magic"); + const encodedVersion = reader.u16("vault format version"); + if (encodedVersion !== LEGACY_VAULT_FORMAT_VERSION) { + throw new Error(`unsupported vault format version: ${encodedVersion}`); + } + const common = readCommonPlaintext(reader); + reader.expectEnd(); + return { ...common, autoLockDays: null }; + } + + expectMagic(reader, VAULT_PLAINTEXT_MAGIC_V2, "vault plaintext magic"); + const encodedVersion = reader.u16("vault format version"); + if (encodedVersion !== VAULT_FORMAT_VERSION) { + throw new Error(`unsupported vault format version: ${encodedVersion}`); + } + const common = readCommonPlaintext(reader); + const autoLockPresent = reader.u8("auto_lock_present"); + if (autoLockPresent !== 0 && autoLockPresent !== 1) { + throw new Error("unsupported auto_lock_present value"); + } + const autoLockDays = autoLockPresent === 1 + ? normalizeAutoLockDays(reader.u8("auto_lock_days")) + : null; + reader.expectEnd(); + return { ...common, autoLockDays }; + } catch (error) { + if (expectedVaultFormatVersion !== undefined) throw error; + } + } + + throw new Error("unsupported vault plaintext format"); +} + export function buildVaultAad(input: VaultAadInput): Uint8Array { const formatVersion = input.vaultFormatVersion ?? VAULT_FORMAT_VERSION; const storageSchemaVersion = input.storageSchemaVersion ?? VAULT_TARGET_STORAGE_SCHEMA_VERSION; + assertSupportedVaultFormatVersion(formatVersion); + if (storageSchemaVersion !== VAULT_TARGET_STORAGE_SCHEMA_VERSION) { + throw new Error(`unsupported storage schema version: ${storageSchemaVersion}`); + } assertFixedLength(input.vaultId, VAULT_ID_BYTES, "vaultId"); const writer = new ByteWriter(); - writer.bytes(VAULT_AAD_MAGIC); - writeVersion(writer, formatVersion, VAULT_FORMAT_VERSION, "vault format version"); + writer.bytes(formatVersion === LEGACY_VAULT_FORMAT_VERSION ? VAULT_AAD_MAGIC_V1 : VAULT_AAD_MAGIC_V2); + writeVersion(writer, formatVersion, formatVersion, "vault format version"); writeVersion(writer, storageSchemaVersion, VAULT_TARGET_STORAGE_SCHEMA_VERSION, "storage schema version"); writer.bytes(input.vaultId); writer.u64(input.generation); diff --git a/web/tests/browser/auto-lock-context-smoke.ts b/web/tests/browser/auto-lock-context-smoke.ts new file mode 100644 index 00000000..94c861e4 --- /dev/null +++ b/web/tests/browser/auto-lock-context-smoke.ts @@ -0,0 +1,134 @@ +import { createAutoLockSettingsController } from "../../src/auto-lock-settings"; +import type { CanonicalDeviceSnapshot } from "../../src/canonical-management"; +import { setLanguage } from "../../src/i18n"; + +function expectCondition(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +function required(selector: string): T { + const element = document.querySelector(selector); + if (!element) throw new Error(`Automatic LOCK context smoke fixture is missing ${selector}`); + return element; +} + +function bytes(length: number, start: number): Uint8Array { + return Uint8Array.from({ length }, (_, index) => (start + index) & 0xff); +} + +function snapshot( + deviceId: string, + vaultStart: number, + registrationStart: number, + generation: bigint, + autoLockDays: number | null, +): CanonicalDeviceSnapshot { + return { + hello: { + device: "M5StickS3", + deviceId, + firmware: "0.1.0-test", + protocol: 2, + storageSchema: 2, + vaultFormat: 2, + supportedVaultFormats: [1, 2], + buildCommit: "0123456789abcdef", + state: "unlocked", + storageReady: true, + recoveryResetRequired: false, + vaultPresent: true, + vaultId: bytes(16, vaultStart), + generation, + registrationPresent: true, + registrationId: bytes(16, registrationStart), + registrationEpoch: 1, + brkPublicKey: bytes(65, registrationStart + 32), + }, + time: { + readiness: "ready", + source: "usb", + lastSyncUnixSeconds: 1_800_000_000n, + ageSeconds: 0, + resyncDue: false, + }, + browserOwnership: "active", + unlockRequired: false, + recoveryProvisioningAvailable: false, + recoveryProvisioningCandidates: 0, + accounts: [], + wifi: { configured: false, ssid: "" }, + autoLock: { + known: true, + days: autoLockDays, + format2Writable: true, + }, + }; +} + +export async function runAutoLockContextSmoke(): Promise { + document.body.innerHTML = ` +
+
+
+

Synthetic re-key boundary

+
+
+
+ `; + setLanguage("en", false); + + const saves: Array = []; + const controller = createAutoLockSettingsController(async (days) => { + saves.push(days); + }); + const enabled = required("#auto-lock-enabled"); + const days = required("#auto-lock-days"); + const save = required("#save-auto-lock"); + + const deviceA = snapshot("device-a", 0x10, 0x30, 4n, null); + const deviceB = snapshot("device-b", 0x50, 0x70, 9n, 31); + + controller.render(deviceA, false, false); + expectCondition(!enabled.checked, "Device A canonical disabled state was not rendered"); + expectCondition(days.value === "1", "Disabled Device A did not keep the 1-day draft selector default"); + + enabled.click(); + expectCondition(enabled.checked, "Device A draft did not become enabled"); + expectCondition(!save.disabled, "Device A dirty draft did not enable Save"); + + // Re-rendering the same canonical context may preserve the unsaved edit. + controller.render(deviceA, false, false); + expectCondition(enabled.checked && !save.disabled, "Same-context Device A draft was discarded unexpectedly"); + + // Losing the canonical/Device context must invalidate the dirty draft. + controller.render(null, false, false); + expectCondition(save.disabled, "Disconnected automatic-LOCK form retained an actionable stale Save"); + + // Connecting a different canonical context must render Device B's authoritative + // setting and must not make Device A's stale draft actionable on Device B. + controller.render(deviceB, false, false); + expectCondition(enabled.checked, "Device B enabled canonical state was masked by Device A draft"); + expectCondition(days.value === "31", "Device B 31-day canonical setting was masked by Device A draft"); + expectCondition(save.disabled, "Device B exposed Save for Device A's stale dirty draft"); + save.click(); + await Promise.resolve(); + expectCondition(saves.length === 0, "Device A stale draft was saved into Device B context"); + + // Cover the security-significant reverse direction as well: a stale disabled + // draft must not be able to disable another Device's active policy. + const deviceAEnabled = snapshot("device-a", 0x10, 0x30, 5n, 1); + controller.render(deviceAEnabled, false, false); + expectCondition(enabled.checked && days.value === "1", "Device A enabled canonical state was not rendered"); + enabled.click(); + expectCondition(!enabled.checked && !save.disabled, "Device A disabled draft did not become dirty"); + controller.render(null, false, false); + controller.render(deviceB, false, false); + expectCondition(enabled.checked && days.value === "31", "Stale disabled draft masked Device B policy"); + expectCondition(save.disabled, "Stale disabled draft remained actionable on Device B"); + save.click(); + await Promise.resolve(); + expectCondition(saves.length === 0, "Stale disabled draft changed Device B automatic-LOCK policy"); + + controller.dispose(); + document.body.dataset.autoLockContextStatus = "pass"; +} diff --git a/web/tests/browser/combined-smoke.ts b/web/tests/browser/combined-smoke.ts index 2f255535..53c84daf 100644 --- a/web/tests/browser/combined-smoke.ts +++ b/web/tests/browser/combined-smoke.ts @@ -1,4 +1,5 @@ import { runArgon2CspSmoke, runProductionArgon2Smoke } from "./argon2-csp-smoke"; +import { runAutoLockContextSmoke } from "./auto-lock-context-smoke"; import { runLocalizationReworkSmoke } from "./localization-rework-smoke"; import { runDenseMigrationQrSmoke } from "./qr-dense-migration-smoke"; @@ -13,6 +14,12 @@ async function run(): Promise { throw new Error("Localization rework DOM smoke did not complete successfully"); } + document.body.dataset.stage = "auto-lock-context"; + await runAutoLockContextSmoke(); + if (document.body.dataset.autoLockContextStatus !== "pass") { + throw new Error("Automatic LOCK context DOM smoke did not complete successfully"); + } + document.body.dataset.stage = "qr-decode"; await import("./qr-smoke"); if (document.body.dataset.status !== "pass") {