From 2944c4c442afe20677f7274da0d547141a8667ba Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:34:14 +0900 Subject: [PATCH 01/26] feat(web): add Vault Format 2 codec --- web/src/security/vault-format.ts | 117 +++++++++++++++++++++++++------ 1 file changed, 97 insertions(+), 20 deletions(-) diff --git a/web/src/security/vault-format.ts b/web/src/security/vault-format.ts index a4dc07cb..054a3c94 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,86 @@ 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 = 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); From fe235e1052b2f25fdfd896dea400b4c6d1a187e0 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:35:07 +0900 Subject: [PATCH 02/26] feat(web): support Vault Format 1 and 2 crypto --- web/src/security/vault-crypto.ts | 59 +++++++++++++++++++++++++------- 1 file changed, 46 insertions(+), 13 deletions(-) diff --git a/web/src/security/vault-crypto.ts b/web/src/security/vault-crypto.ts index 6651d6ed..148bb013 100644 --- a/web/src/security/vault-crypto.ts +++ b/web/src/security/vault-crypto.ts @@ -1,12 +1,15 @@ 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 +27,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 +50,7 @@ export interface Argon2idKdfMetadata { export interface PassphraseWrappedVmk { packageVersion: number; wrapVersion: number; - vaultFormatVersion: number; + vaultFormatVersion: SupportedVaultFormatVersion; vaultId: Uint8Array; kdf: Argon2idKdfMetadata; nonce: Uint8Array; @@ -155,9 +158,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 +170,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 +196,26 @@ export async function encryptVault( }; } +export async function encryptVault( + plaintext: Uint8Array, + vmk: Uint8Array, + vaultId: Uint8Array, + generation: bigint, + source: RandomSource = browserRandomSource, +): Promise { + return encryptVaultForFormat(plaintext, vmk, vaultId, generation, 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 +304,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 +314,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 +338,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 +350,16 @@ export async function wrapVmkWithPassphrase( } } +export async function wrapVmkWithPassphrase( + vmk: Uint8Array, + vaultId: Uint8Array, + passphrase: string, + kdf: Argon2idKdfMetadata = createArgon2idMetadata(), + source: RandomSource = browserRandomSource, +): Promise { + return wrapVmkWithPassphraseForFormat(vmk, vaultId, passphrase, VAULT_FORMAT_VERSION, kdf, source); +} + export async function unwrapVmkWithPassphrase( wrapped: PassphraseWrappedVmk, passphrase: string, From 5291ca8ecf34e3fd970f80830ec1eb716e4e4d89 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:35:39 +0900 Subject: [PATCH 03/26] feat(web): parse Vault Format 2 capability metadata --- web/src/canonical-protocol-v2.ts | 44 ++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/web/src/canonical-protocol-v2.ts b/web/src/canonical-protocol-v2.ts index 223c786a..abca69b5 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,8 @@ export interface CanonicalHelloData { firmware: string; protocol: 2; storageSchema: 2; - vaultFormat: 1; + vaultFormat: SupportedVaultFormatVersion; + supportedVaultFormats: readonly number[]; buildCommit: string; state: DeviceRuntimeState; storageReady: boolean; @@ -105,6 +112,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); +} + export function parseCanonicalHelloData(data: Record): CanonicalHelloData { if ( typeof data.device !== "string" || data.device.length === 0 || @@ -112,7 +140,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 +153,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 +186,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 +222,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 +243,10 @@ export function encryptedVaultParams(envelope: EncryptedVaultEnvelope): Record Date: Thu, 17 Sep 2026 00:37:44 +0900 Subject: [PATCH 04/26] feat(web): support Vault Format 2 browser and recovery state --- web/src/security/browser-vault.ts | 92 ++++++++++++++++++++----------- 1 file changed, 59 insertions(+), 33 deletions(-) diff --git a/web/src/security/browser-vault.ts b/web/src/security/browser-vault.ts index c0e38a79..c9e26133 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,11 @@ export function mergeVaultAdvanceWithCurrentBrowserState( safeIncoming.trustedBrowser.wrappedVmk, ); if (!isSingleGenerationAdvance || !sameVault || !sameBrowserVmk) return safeIncoming; - return sanitizeBrowserCanonicalState({ ...safeCurrent, vault: safeIncoming.vault }); + return sanitizeBrowserCanonicalState({ + ...safeCurrent, + vault: safeIncoming.vault, + recoveryWrappedVmk: safeIncoming.recoveryWrappedVmk, + }); } export class IndexedDbBrowserVaultStore { From e17d377035eaecc73c4ebd170635f84eadf542f3 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:44:39 +0900 Subject: [PATCH 05/26] feat(web): integrate capability-gated Vault Format 2 writes --- web/src/canonical-management.ts | 163 +++++++++++++++++++++++++++----- 1 file changed, 138 insertions(+), 25 deletions(-) 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); From 14f25dfd070ded30ef21eab4c9943c6c624527f9 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:45:32 +0900 Subject: [PATCH 06/26] test(web): cover Vault Format 1 and 2 framing --- web/src/security/vault-format.test.ts | 86 ++++++++++++++++----------- 1 file changed, 52 insertions(+), 34 deletions(-) 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/); + } }); }); From a2902a91294bc43e8f2968c693c57b44229b63dc Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:45:56 +0900 Subject: [PATCH 07/26] test(web): cover Vault Format capability metadata --- web/src/canonical-protocol-v2.test.ts | 59 ++++++++++++++++++++------- 1 file changed, 44 insertions(+), 15 deletions(-) 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); + } }); }); From 2cbe06431533b4a3c5cc9c1882393cb3481d3655 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:46:30 +0900 Subject: [PATCH 08/26] test(web): preserve Format 1 crypto and cover Format 2 --- web/src/security/vault-crypto.test.ts | 93 ++++++++++++++++++--------- 1 file changed, 61 insertions(+), 32 deletions(-) diff --git a/web/src/security/vault-crypto.test.ts b/web/src/security/vault-crypto.test.ts index 4f0664b1..f567c93f 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,40 @@ 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); + expect(current.ciphertext).not.toEqual(legacy.ciphertext); + 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 +132,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 +176,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 () => { From 249ee2ea60abe3fd0c70ad5792ad805581ce1dd0 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:47:07 +0900 Subject: [PATCH 09/26] test(web): cover Recovery Package Vault Format 1 and 2 --- web/src/security/browser-vault.test.ts | 136 +++++++++++++++++++------ 1 file changed, 103 insertions(+), 33 deletions(-) diff --git a/web/src/security/browser-vault.test.ts b/web/src/security/browser-vault.test.ts index df9ab000..a4b8f810 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,39 +145,53 @@ 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(); - const changed = await changeRecoveryPassphrase(state, oldPassphrase, newPassphrase); + it("preserves current browser security state while accepting an F1-to-F2 generation advance", async () => { + const { vmk, state } = await fixture(LEGACY_VAULT_FORMAT_VERSION); 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(state, incoming, state.vault.generation); expect(merged.vault.generation).toBe(8n); - expect(merged.trustedBrowser.registrationId).toEqual(changed.trustedBrowser.registrationId); - expect(merged.trustedBrowser.wrappedVmk).toEqual(changed.trustedBrowser.wrappedVmk); - - const recovered = await unwrapVmkWithPassphrase(merged.recoveryWrappedVmk, newPassphrase); - expect(recovered).toEqual(vmk); - recovered.fill(0); - await expect(unwrapVmkWithPassphrase(merged.recoveryWrappedVmk, oldPassphrase)).rejects.toThrow(); + expect(merged.vault.vaultFormatVersion).toBe(2); + expect(merged.recoveryWrappedVmk.vaultFormatVersion).toBe(2); + expect(merged.trustedBrowser.registrationId).toEqual(state.trustedBrowser.registrationId); } 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 +204,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); From 17069a65edff7d247d0f31e9b6f565e41ceb0e83 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:51:08 +0900 Subject: [PATCH 10/26] fix(web): preserve legacy default Vault codec helper --- web/src/security/vault-format.ts | 281 +++++++------------------------ 1 file changed, 62 insertions(+), 219 deletions(-) diff --git a/web/src/security/vault-format.ts b/web/src/security/vault-format.ts index 054a3c94..d5143ecd 100644 --- a/web/src/security/vault-format.ts +++ b/web/src/security/vault-format.ts @@ -62,124 +62,62 @@ export interface VmkWrapAadInput { class ByteWriter { private readonly values: number[] = []; - - bytes(value: Uint8Array): void { - for (const byte of value) this.values.push(byte); - } - - u8(value: number): void { - assertIntegerRange(value, 0, 0xff, "u8"); - this.values.push(value); - } - - u16(value: number): void { - assertIntegerRange(value, 0, 0xffff, "u16"); - this.values.push((value >>> 8) & 0xff, value & 0xff); - } - + bytes(value: Uint8Array): void { for (const byte of value) this.values.push(byte); } + u8(value: number): void { assertIntegerRange(value, 0, 0xff, "u8"); this.values.push(value); } + u16(value: number): void { assertIntegerRange(value, 0, 0xffff, "u16"); this.values.push((value >>> 8) & 0xff, value & 0xff); } u64(value: bigint): void { if (value < 0n || value > 0xffff_ffff_ffff_ffffn) throw new Error("u64 out of range"); - for (let shift = 56n; shift >= 0n; shift -= 8n) { - this.values.push(Number((value >> shift) & 0xffn)); - } + for (let shift = 56n; shift >= 0n; shift -= 8n) this.values.push(Number((value >> shift) & 0xffn)); } - sizedBytes(value: Uint8Array, maxLength: number, field: string): void { - if (value.length > maxLength || value.length > 0xffff) { - throw new Error(`${field} exceeds encoded length limit`); - } - this.u16(value.length); - this.bytes(value); - } - - sizedText(value: string, field: string): void { - this.sizedBytes(textEncoder.encode(value), MAX_FIELD_BYTES, field); - } - - finish(): Uint8Array { - return Uint8Array.from(this.values); + if (value.length > maxLength || value.length > 0xffff) throw new Error(`${field} exceeds encoded length limit`); + this.u16(value.length); this.bytes(value); } + sizedText(value: string, field: string): void { this.sizedBytes(textEncoder.encode(value), MAX_FIELD_BYTES, field); } + finish(): Uint8Array { return Uint8Array.from(this.values); } } class ByteReader { private offset = 0; - constructor(private readonly bytes: Uint8Array) {} - take(length: number, field: string): Uint8Array { - if (!Number.isInteger(length) || length < 0 || this.offset + length > this.bytes.length) { - throw new Error(`truncated ${field}`); - } - const value = this.bytes.slice(this.offset, this.offset + length); - this.offset += length; - return value; - } - - u8(field: string): number { - return this.take(1, field)[0] ?? fail(`truncated ${field}`); + if (!Number.isInteger(length) || length < 0 || this.offset + length > this.bytes.length) throw new Error(`truncated ${field}`); + const value = this.bytes.slice(this.offset, this.offset + length); this.offset += length; return value; } - - u16(field: string): number { - const value = this.take(2, field); - return ((value[0] ?? 0) << 8) | (value[1] ?? 0); - } - + u8(field: string): number { return this.take(1, field)[0] ?? fail(`truncated ${field}`); } + u16(field: string): number { const value = this.take(2, field); return ((value[0] ?? 0) << 8) | (value[1] ?? 0); } sizedBytes(maxLength: number, field: string): Uint8Array { - const length = this.u16(`${field} length`); - if (length > maxLength) throw new Error(`${field} exceeds encoded length limit`); - return this.take(length, field); + const length = this.u16(`${field} length`); if (length > maxLength) throw new Error(`${field} exceeds encoded length limit`); return this.take(length, field); } - - sizedText(field: string): string { - return textDecoder.decode(this.sizedBytes(MAX_FIELD_BYTES, field)); - } - - expectEnd(): void { - if (this.offset !== this.bytes.length) throw new Error("unexpected trailing vault plaintext data"); - } -} - -function fail(message: string): never { - throw new Error(message); + sizedText(field: string): string { return textDecoder.decode(this.sizedBytes(MAX_FIELD_BYTES, field)); } + expectEnd(): void { if (this.offset !== this.bytes.length) throw new Error("unexpected trailing vault plaintext data"); } } +function fail(message: string): never { throw new Error(message); } function assertIntegerRange(value: number, min: number, max: number, field: string): void { if (!Number.isInteger(value) || value < min || value > max) throw new Error(`${field} out of range`); } - -function assertFixedLength(value: Uint8Array, length: number, field: string): void { - if (value.length !== length) throw new Error(`${field} must be ${length} bytes`); -} - +function assertFixedLength(value: Uint8Array, length: number, field: string): void { if (value.length !== length) throw new Error(`${field} must be ${length} bytes`); } function expectMagic(reader: ByteReader, expected: Uint8Array, field: string): void { - const actual = reader.take(expected.length, field); - if (!actual.every((byte, index) => byte === expected[index])) throw new Error(`unsupported ${field}`); + const actual = reader.take(expected.length, field); if (!actual.every((byte, index) => byte === expected[index])) throw new Error(`unsupported ${field}`); } - function writeVersion(writer: ByteWriter, version: number, expected: number, field: string): void { - if (version !== expected) throw new Error(`unsupported ${field}: ${version}`); - writer.u16(version); + if (version !== expected) throw new Error(`unsupported ${field}: ${version}`); 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; + 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 vault limit"); - } + if (record.secret.length < 1 || record.secret.length > MAX_SECRET_BYTES) 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"); assertIntegerRange(record.periodSeconds, 1, 0xffff, "periodSeconds"); @@ -187,173 +125,78 @@ function validateCredential(record: VaultCredentialRecord): void { } 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(); - writer.u16(value.credentials.length); + if (value.credentials.length > MAX_VAULT_CREDENTIALS) throw new Error(`vault supports at most ${MAX_VAULT_CREDENTIALS} credentials`); + const ids = new Set(); 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(""); - if (ids.has(idKey)) throw new Error("duplicate credentialId"); - ids.add(idKey); - - writer.bytes(record.credentialId); - writer.sizedBytes(record.secret, MAX_SECRET_BYTES, "secret"); - writer.sizedText(record.issuer, "issuer"); - writer.sizedText(record.account, "account"); - writer.sizedText(record.displayName, "displayName"); - writer.u8(ALGORITHM_SHA1); - writer.u8(record.digits); - writer.u16(record.periodSeconds); - writer.u16(record.manualOrder); + if (ids.has(idKey)) throw new Error("duplicate credentialId"); ids.add(idKey); + writer.bytes(record.credentialId); writer.sizedBytes(record.secret, MAX_SECRET_BYTES, "secret"); writer.sizedText(record.issuer, "issuer"); + writer.sizedText(record.account, "account"); writer.sizedText(record.displayName, "displayName"); writer.u8(ALGORITHM_SHA1); writer.u8(record.digits); + writer.u16(record.periodSeconds); writer.u16(record.manualOrder); } - writer.u8(value.wifi === null ? 0 : 1); - if (value.wifi !== null) { - writer.sizedText(value.wifi.ssid, "wifi ssid"); - writer.sizedText(value.wifi.password, "wifi password"); - } + if (value.wifi !== null) { writer.sizedText(value.wifi.ssid, "wifi ssid"); writer.sizedText(value.wifi.password, "wifi password"); } } 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`); - } - - const credentials: VaultCredentialRecord[] = []; - const ids = new Set(); + const count = reader.u16("credential count"); if (count > MAX_VAULT_CREDENTIALS) throw new Error(`vault supports at most ${MAX_VAULT_CREDENTIALS} credentials`); + const credentials: VaultCredentialRecord[] = []; const ids = new Set(); for (let index = 0; index < count; index += 1) { const credentialId = reader.take(CREDENTIAL_ID_BYTES, "credentialId"); const idKey = Array.from(credentialId, (byte) => byte.toString(16).padStart(2, "0")).join(""); - if (ids.has(idKey)) throw new Error("duplicate credentialId"); - ids.add(idKey); - - const secret = reader.sizedBytes(MAX_SECRET_BYTES, "secret"); - if (secret.length < 1) throw new Error("secret must not be empty"); - const issuer = reader.sizedText("issuer"); - const account = reader.sizedText("account"); - const displayName = reader.sizedText("displayName"); - const algorithmCode = reader.u8("algorithm"); - if (algorithmCode !== ALGORITHM_SHA1) throw new Error(`unsupported TOTP algorithm code: ${algorithmCode}`); - - const record: VaultCredentialRecord = { - credentialId, - secret, - issuer, - account, - displayName, - algorithm: "SHA1", - digits: reader.u8("digits"), - periodSeconds: reader.u16("periodSeconds"), - manualOrder: reader.u16("manualOrder"), - }; - validateCredential(record); - credentials.push(record); - } - - const wifiPresent = reader.u8("wifi presence"); - if (wifiPresent !== 0 && wifiPresent !== 1) throw new Error("unsupported wifi presence value"); - const wifi = wifiPresent === 1 - ? { ssid: reader.sizedText("wifi ssid"), password: reader.sizedText("wifi password") } - : null; + if (ids.has(idKey)) throw new Error("duplicate credentialId"); ids.add(idKey); + const secret = reader.sizedBytes(MAX_SECRET_BYTES, "secret"); if (secret.length < 1) throw new Error("secret must not be empty"); + const issuer = reader.sizedText("issuer"); const account = reader.sizedText("account"); const displayName = reader.sizedText("displayName"); + const algorithmCode = reader.u8("algorithm"); if (algorithmCode !== ALGORITHM_SHA1) throw new Error(`unsupported TOTP algorithm code: ${algorithmCode}`); + const record: VaultCredentialRecord = { credentialId, secret, issuer, account, displayName, algorithm: "SHA1", digits: reader.u8("digits"), periodSeconds: reader.u16("periodSeconds"), manualOrder: reader.u16("manualOrder") }; + validateCredential(record); credentials.push(record); + } + const wifiPresent = reader.u8("wifi presence"); if (wifiPresent !== 0 && wifiPresent !== 1) throw new Error("unsupported wifi presence value"); + const wifi = wifiPresent === 1 ? { ssid: reader.sizedText("wifi ssid"), password: reader.sizedText("wifi password") } : null; return { credentials, wifi }; } -export function encodeVaultPlaintext( - value: VaultPlaintext, - vaultFormatVersion: SupportedVaultFormatVersion = VAULT_FORMAT_VERSION, -): Uint8Array { - assertSupportedVaultFormatVersion(vaultFormatVersion); - const writer = new ByteWriter(); +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(); + 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(); + 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; - +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_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"); + 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"); + 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; - } + 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(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); - return writer.finish(); + 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(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); return writer.finish(); } export function buildVmkWrapAad(input: VmkWrapAadInput): Uint8Array { - const packageVersion = input.packageVersion ?? RECOVERY_PACKAGE_VERSION; - const wrapVersion = input.wrapVersion ?? VMK_WRAP_VERSION; - assertFixedLength(input.vaultId, VAULT_ID_BYTES, "vaultId"); - - const writer = new ByteWriter(); - writer.bytes(VMK_WRAP_AAD_MAGIC); - writeVersion(writer, packageVersion, RECOVERY_PACKAGE_VERSION, "Recovery Package version"); - writeVersion(writer, wrapVersion, VMK_WRAP_VERSION, "VMK wrap version"); - writer.bytes(input.vaultId); - return writer.finish(); + const packageVersion = input.packageVersion ?? RECOVERY_PACKAGE_VERSION; const wrapVersion = input.wrapVersion ?? VMK_WRAP_VERSION; assertFixedLength(input.vaultId, VAULT_ID_BYTES, "vaultId"); + const writer = new ByteWriter(); writer.bytes(VMK_WRAP_AAD_MAGIC); writeVersion(writer, packageVersion, RECOVERY_PACKAGE_VERSION, "Recovery Package version"); + writeVersion(writer, wrapVersion, VMK_WRAP_VERSION, "VMK wrap version"); writer.bytes(input.vaultId); return writer.finish(); } From e3b220c80a941e4481ae9214cb3bb1f3319be007 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:52:02 +0900 Subject: [PATCH 11/26] fix(web): preserve legacy default Vault crypto helpers --- web/src/security/vault-crypto.ts | 288 ++++++------------------------- 1 file changed, 54 insertions(+), 234 deletions(-) diff --git a/web/src/security/vault-crypto.ts b/web/src/security/vault-crypto.ts index 148bb013..031cfb34 100644 --- a/web/src/security/vault-crypto.ts +++ b/web/src/security/vault-crypto.ts @@ -58,9 +58,7 @@ export interface PassphraseWrappedVmk { tag: Uint8Array; } -export interface RandomSource { - fill(target: Uint8Array): void; -} +export interface RandomSource { fill(target: Uint8Array): void; } const browserRandomSource: RandomSource = { fill(target) { @@ -71,103 +69,44 @@ const browserRandomSource: RandomSource = { }, }; -function copyBuffer(value: Uint8Array): ArrayBuffer { - return value.slice().buffer; -} - +function copyBuffer(value: Uint8Array): ArrayBuffer { return value.slice().buffer; } function assertLength(value: Uint8Array, expected: number, field: string): void { if (value.length !== expected) throw new Error(`${field} must be ${expected} bytes`); } - function randomBytes(length: number, source: RandomSource): Uint8Array { - const value = new Uint8Array(length); - source.fill(value); - return value; + const value = new Uint8Array(length); source.fill(value); return value; } - function splitCiphertextAndTag(combined: ArrayBuffer): { ciphertext: Uint8Array; tag: Uint8Array } { const bytes = new Uint8Array(combined); - if (bytes.length < AES_GCM_TAG_BYTES) { - throw new Error("AES-GCM result is shorter than the authentication tag"); - } - return { - ciphertext: bytes.slice(0, bytes.length - AES_GCM_TAG_BYTES), - tag: bytes.slice(bytes.length - AES_GCM_TAG_BYTES), - }; + if (bytes.length < AES_GCM_TAG_BYTES) throw new Error("AES-GCM result is shorter than the authentication tag"); + return { ciphertext: bytes.slice(0, bytes.length - AES_GCM_TAG_BYTES), tag: bytes.slice(bytes.length - AES_GCM_TAG_BYTES) }; } - function joinCiphertextAndTag(ciphertext: Uint8Array, tag: Uint8Array): Uint8Array { assertLength(tag, AES_GCM_TAG_BYTES, "AES-GCM tag"); - const combined = new Uint8Array(ciphertext.length + tag.length); - combined.set(ciphertext, 0); - combined.set(tag, ciphertext.length); - return combined; + const combined = new Uint8Array(ciphertext.length + tag.length); combined.set(ciphertext, 0); combined.set(tag, ciphertext.length); return combined; } - async function importAesKey(rawKey: Uint8Array, usage: KeyUsage): Promise { assertLength(rawKey, AES_GCM_KEY_BYTES, "AES-256 key"); return crypto.subtle.importKey("raw", copyBuffer(rawKey), { name: "AES-GCM" }, false, [usage]); } - -async function aesGcmEncrypt( - rawKey: Uint8Array, - nonce: Uint8Array, - plaintext: Uint8Array, - aad: Uint8Array, -): Promise<{ ciphertext: Uint8Array; tag: Uint8Array }> { +async function aesGcmEncrypt(rawKey: Uint8Array, nonce: Uint8Array, plaintext: Uint8Array, aad: Uint8Array) { assertLength(nonce, AES_GCM_NONCE_BYTES, "AES-GCM nonce"); const key = await importAesKey(rawKey, "encrypt"); - const combined = await crypto.subtle.encrypt( - { - name: "AES-GCM", - iv: copyBuffer(nonce), - additionalData: copyBuffer(aad), - tagLength: 128, - }, - key, - copyBuffer(plaintext), - ); - return splitCiphertextAndTag(combined); + return splitCiphertextAndTag(await crypto.subtle.encrypt({ name: "AES-GCM", iv: copyBuffer(nonce), additionalData: copyBuffer(aad), tagLength: 128 }, key, copyBuffer(plaintext))); } - -async function aesGcmDecrypt( - rawKey: Uint8Array, - nonce: Uint8Array, - ciphertext: Uint8Array, - tag: Uint8Array, - aad: Uint8Array, -): Promise { +async function aesGcmDecrypt(rawKey: Uint8Array, nonce: Uint8Array, ciphertext: Uint8Array, tag: Uint8Array, aad: Uint8Array): Promise { assertLength(nonce, AES_GCM_NONCE_BYTES, "AES-GCM nonce"); - const key = await importAesKey(rawKey, "decrypt"); - const combined = joinCiphertextAndTag(ciphertext, tag); + const key = await importAesKey(rawKey, "decrypt"); const combined = joinCiphertextAndTag(ciphertext, tag); try { - const plaintext = await crypto.subtle.decrypt( - { - name: "AES-GCM", - iv: copyBuffer(nonce), - additionalData: copyBuffer(aad), - tagLength: 128, - }, - key, - copyBuffer(combined), - ); - return new Uint8Array(plaintext); - } finally { - combined.fill(0); - } + return new Uint8Array(await crypto.subtle.decrypt({ name: "AES-GCM", iv: copyBuffer(nonce), additionalData: copyBuffer(aad), tagLength: 128 }, key, copyBuffer(combined))); + } finally { combined.fill(0); } } function validateVaultEnvelope(envelope: EncryptedVaultEnvelope): void { assertSupportedVaultFormatVersion(envelope.vaultFormatVersion); - if (envelope.storageSchemaVersion !== VAULT_TARGET_STORAGE_SCHEMA_VERSION) { - throw new Error(`unsupported storage schema version: ${envelope.storageSchemaVersion}`); - } - assertLength(envelope.vaultId, VAULT_ID_BYTES, "vaultId"); - assertLength(envelope.nonce, AES_GCM_NONCE_BYTES, "vault nonce"); - assertLength(envelope.tag, AES_GCM_TAG_BYTES, "vault tag"); - if (envelope.ciphertextLength !== envelope.ciphertext.length) { - throw new Error("vault ciphertext length framing mismatch"); - } + if (envelope.storageSchemaVersion !== VAULT_TARGET_STORAGE_SCHEMA_VERSION) throw new Error(`unsupported storage schema version: ${envelope.storageSchemaVersion}`); + assertLength(envelope.vaultId, VAULT_ID_BYTES, "vaultId"); assertLength(envelope.nonce, AES_GCM_NONCE_BYTES, "vault nonce"); assertLength(envelope.tag, AES_GCM_TAG_BYTES, "vault tag"); + if (envelope.ciphertextLength !== envelope.ciphertext.length) throw new Error("vault ciphertext length framing mismatch"); } export async function encryptVaultForFormat( @@ -178,142 +117,54 @@ export async function encryptVaultForFormat( 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, vaultFormatVersion }); + 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, vaultFormatVersion }); const encrypted = await aesGcmEncrypt(vmk, nonce, plaintext, aad); - return { - vaultFormatVersion, - storageSchemaVersion: VAULT_TARGET_STORAGE_SCHEMA_VERSION, - vaultId: vaultId.slice(), - generation, - nonce, - ciphertext: encrypted.ciphertext, - tag: encrypted.tag, - ciphertextLength: encrypted.ciphertext.length, - }; + return { vaultFormatVersion, storageSchemaVersion: VAULT_TARGET_STORAGE_SCHEMA_VERSION, vaultId: vaultId.slice(), generation, nonce, ciphertext: encrypted.ciphertext, tag: encrypted.tag, ciphertextLength: encrypted.ciphertext.length }; } -export async function encryptVault( - plaintext: Uint8Array, - vmk: Uint8Array, - vaultId: Uint8Array, - generation: bigint, - source: RandomSource = browserRandomSource, -): Promise { - return encryptVaultForFormat(plaintext, vmk, vaultId, generation, VAULT_FORMAT_VERSION, source); +// 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 { +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, -): Promise { - validateVaultEnvelope(envelope); - assertLength(vmk, AES_GCM_KEY_BYTES, "VMK"); - const aad = buildVaultAad({ - vaultId: envelope.vaultId, - generation: envelope.generation, - storageSchemaVersion: envelope.storageSchemaVersion, - vaultFormatVersion: envelope.vaultFormatVersion, - }); +export async function decryptVault(envelope: EncryptedVaultEnvelope, vmk: Uint8Array): Promise { + validateVaultEnvelope(envelope); assertLength(vmk, AES_GCM_KEY_BYTES, "VMK"); + const aad = buildVaultAad({ vaultId: envelope.vaultId, generation: envelope.generation, storageSchemaVersion: envelope.storageSchemaVersion, vaultFormatVersion: envelope.vaultFormatVersion }); return aesGcmDecrypt(vmk, envelope.nonce, envelope.ciphertext, envelope.tag, aad); } export function normalizeAndValidatePassphrase(passphrase: string): Uint8Array { - const normalized = passphrase.normalize("NFC"); - const codePoints = Array.from(normalized).length; - if (codePoints < 15 || codePoints > 128) { - throw new Error("Passphrase must contain 15 to 128 Unicode code points after NFC normalization"); - } + const normalized = passphrase.normalize("NFC"); const codePoints = Array.from(normalized).length; + if (codePoints < 15 || codePoints > 128) throw new Error("Passphrase must contain 15 to 128 Unicode code points after NFC normalization"); const encoded = textEncoder.encode(normalized); - if (encoded.length > 512) { - encoded.fill(0); - throw new Error("Passphrase exceeds the 512-byte UTF-8 limit after NFC normalization"); - } + if (encoded.length > 512) { encoded.fill(0); throw new Error("Passphrase exceeds the 512-byte UTF-8 limit after NFC normalization"); } return encoded; } - export function createArgon2idMetadata(source: RandomSource = browserRandomSource): Argon2idKdfMetadata { - return { - algorithm: "argon2id", - version: ARGON2ID_VERSION, - memoryKiB: ARGON2ID_MEMORY_KIB, - iterations: ARGON2ID_ITERATIONS, - parallelism: ARGON2ID_PARALLELISM, - salt: randomBytes(ARGON2ID_SALT_BYTES, source), - outputBytes: ARGON2ID_OUTPUT_BYTES, - }; + return { algorithm: "argon2id", version: ARGON2ID_VERSION, memoryKiB: ARGON2ID_MEMORY_KIB, iterations: ARGON2ID_ITERATIONS, parallelism: ARGON2ID_PARALLELISM, salt: randomBytes(ARGON2ID_SALT_BYTES, source), outputBytes: ARGON2ID_OUTPUT_BYTES }; } - function validateKdfMetadata(metadata: Argon2idKdfMetadata): void { - if (metadata.algorithm !== "argon2id") { - throw new Error(`unsupported KDF algorithm: ${String(metadata.algorithm)}`); - } - if (metadata.version !== ARGON2ID_VERSION) { - throw new Error(`unsupported Argon2id version: ${metadata.version}`); - } - if ( - metadata.memoryKiB !== ARGON2ID_MEMORY_KIB || - metadata.iterations !== ARGON2ID_ITERATIONS || - metadata.parallelism !== ARGON2ID_PARALLELISM || - metadata.outputBytes !== ARGON2ID_OUTPUT_BYTES - ) { - throw new Error("unsupported Argon2id parameter set"); - } + if (metadata.algorithm !== "argon2id") throw new Error(`unsupported KDF algorithm: ${String(metadata.algorithm)}`); + if (metadata.version !== ARGON2ID_VERSION) throw new Error(`unsupported Argon2id version: ${metadata.version}`); + if (metadata.memoryKiB !== ARGON2ID_MEMORY_KIB || metadata.iterations !== ARGON2ID_ITERATIONS || metadata.parallelism !== ARGON2ID_PARALLELISM || metadata.outputBytes !== ARGON2ID_OUTPUT_BYTES) throw new Error("unsupported Argon2id parameter set"); assertLength(metadata.salt, ARGON2ID_SALT_BYTES, "Argon2id salt"); } - -export async function derivePassphraseKek( - passphrase: string, - metadata: Argon2idKdfMetadata, -): Promise { - validateKdfMetadata(metadata); - const encoded = normalizeAndValidatePassphrase(passphrase); +export async function derivePassphraseKek(passphrase: string, metadata: Argon2idKdfMetadata): Promise { + validateKdfMetadata(metadata); const encoded = normalizeAndValidatePassphrase(passphrase); try { - const result = await argon2id({ - password: encoded, - salt: metadata.salt, - parallelism: metadata.parallelism, - iterations: metadata.iterations, - memorySize: metadata.memoryKiB, - hashLength: metadata.outputBytes, - outputType: "binary", - }); - return result.slice(); - } finally { - encoded.fill(0); - } + return (await argon2id({ password: encoded, salt: metadata.salt, parallelism: metadata.parallelism, iterations: metadata.iterations, memorySize: metadata.memoryKiB, hashLength: metadata.outputBytes, outputType: "binary" })).slice(); + } finally { encoded.fill(0); } } - function validateWrappedVmk(value: PassphraseWrappedVmk): void { - if (value.packageVersion !== RECOVERY_PACKAGE_VERSION) { - throw new Error(`unsupported Recovery Package version: ${value.packageVersion}`); - } - if (value.wrapVersion !== VMK_WRAP_VERSION) { - throw new Error(`unsupported VMK wrap version: ${value.wrapVersion}`); - } - 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"); - if (value.ciphertext.length !== AES_GCM_KEY_BYTES) { - throw new Error("wrapped VMK ciphertext must be 32 bytes"); - } - validateKdfMetadata(value.kdf); + if (value.packageVersion !== RECOVERY_PACKAGE_VERSION) throw new Error(`unsupported Recovery Package version: ${value.packageVersion}`); + if (value.wrapVersion !== VMK_WRAP_VERSION) throw new Error(`unsupported VMK wrap version: ${value.wrapVersion}`); + 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"); + if (value.ciphertext.length !== AES_GCM_KEY_BYTES) throw new Error("wrapped VMK ciphertext must be 32 bytes"); validateKdfMetadata(value.kdf); } - export async function wrapVmkWithPassphraseForFormat( vmk: Uint8Array, vaultId: Uint8Array, @@ -322,34 +173,16 @@ export async function wrapVmkWithPassphraseForFormat( kdf: Argon2idKdfMetadata = createArgon2idMetadata(), source: RandomSource = browserRandomSource, ): Promise { - assertSupportedVaultFormatVersion(vaultFormatVersion); - assertLength(vmk, AES_GCM_KEY_BYTES, "VMK"); - assertLength(vaultId, VAULT_ID_BYTES, "vaultId"); - validateKdfMetadata(kdf); - const nonce = randomBytes(AES_GCM_NONCE_BYTES, source); - const kek = await derivePassphraseKek(passphrase, kdf); + assertSupportedVaultFormatVersion(vaultFormatVersion); assertLength(vmk, AES_GCM_KEY_BYTES, "VMK"); assertLength(vaultId, VAULT_ID_BYTES, "vaultId"); validateKdfMetadata(kdf); + const nonce = randomBytes(AES_GCM_NONCE_BYTES, source); const kek = await derivePassphraseKek(passphrase, kdf); try { - const aad = buildVmkWrapAad({ - vaultId, - packageVersion: RECOVERY_PACKAGE_VERSION, - wrapVersion: VMK_WRAP_VERSION, - }); - const encrypted = await aesGcmEncrypt(kek, nonce, vmk, aad); - return { - packageVersion: RECOVERY_PACKAGE_VERSION, - wrapVersion: VMK_WRAP_VERSION, - vaultFormatVersion, - vaultId: vaultId.slice(), - kdf: { ...kdf, salt: kdf.salt.slice() }, - nonce, - ciphertext: encrypted.ciphertext, - tag: encrypted.tag, - }; - } finally { - kek.fill(0); - } + const encrypted = await aesGcmEncrypt(kek, nonce, vmk, buildVmkWrapAad({ vaultId, packageVersion: RECOVERY_PACKAGE_VERSION, wrapVersion: VMK_WRAP_VERSION })); + return { packageVersion: RECOVERY_PACKAGE_VERSION, wrapVersion: VMK_WRAP_VERSION, vaultFormatVersion, vaultId: vaultId.slice(), kdf: { ...kdf, salt: kdf.salt.slice() }, nonce, ciphertext: encrypted.ciphertext, tag: encrypted.tag }; + } finally { kek.fill(0); } } +// 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, @@ -357,25 +190,12 @@ export async function wrapVmkWithPassphrase( kdf: Argon2idKdfMetadata = createArgon2idMetadata(), source: RandomSource = browserRandomSource, ): Promise { - return wrapVmkWithPassphraseForFormat(vmk, vaultId, passphrase, VAULT_FORMAT_VERSION, kdf, source); + return wrapVmkWithPassphraseForFormat(vmk, vaultId, passphrase, LEGACY_VAULT_FORMAT_VERSION, kdf, source); } - -export async function unwrapVmkWithPassphrase( - wrapped: PassphraseWrappedVmk, - passphrase: string, -): Promise { - validateWrappedVmk(wrapped); - const kek = await derivePassphraseKek(passphrase, wrapped.kdf); +export async function unwrapVmkWithPassphrase(wrapped: PassphraseWrappedVmk, passphrase: string): Promise { + validateWrappedVmk(wrapped); const kek = await derivePassphraseKek(passphrase, wrapped.kdf); try { - const aad = buildVmkWrapAad({ - vaultId: wrapped.vaultId, - packageVersion: wrapped.packageVersion, - wrapVersion: wrapped.wrapVersion, - }); - const vmk = await aesGcmDecrypt(kek, wrapped.nonce, wrapped.ciphertext, wrapped.tag, aad); - assertLength(vmk, AES_GCM_KEY_BYTES, "unwrapped VMK"); - return vmk; - } finally { - kek.fill(0); - } + const vmk = await aesGcmDecrypt(kek, wrapped.nonce, wrapped.ciphertext, wrapped.tag, buildVmkWrapAad({ vaultId: wrapped.vaultId, packageVersion: wrapped.packageVersion, wrapVersion: wrapped.wrapVersion })); + assertLength(vmk, AES_GCM_KEY_BYTES, "unwrapped VMK"); return vmk; + } finally { kek.fill(0); } } From 92648835b4ad8fff37fc862b90eb72b4fc482e03 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:17:39 +0900 Subject: [PATCH 12/26] test(web): correct Vault Format 2 AAD expectations --- web/src/security/vault-crypto.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/src/security/vault-crypto.test.ts b/web/src/security/vault-crypto.test.ts index f567c93f..48fe783e 100644 --- a/web/src/security/vault-crypto.test.ts +++ b/web/src/security/vault-crypto.test.ts @@ -109,7 +109,11 @@ describe("Web vault crypto", () => { new FixedRandomSource([sourceBytes]), ); expect(current.vaultFormatVersion).toBe(VAULT_FORMAT_VERSION); - expect(current.ciphertext).not.toEqual(legacy.ciphertext); + // 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(); }); From 67f21a5d9b9799b4fab30a9fb7cfcccb6ecbb11e Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:18:17 +0900 Subject: [PATCH 13/26] test(web): use canonical Vault plaintext in rekey fixture --- web/src/canonical-management-rekey.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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({ From 7833ea6ecf048b3fae66c6ea0379b213fe5e0b6f Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:20:43 +0900 Subject: [PATCH 14/26] feat(web): add automatic LOCK settings controller --- web/src/auto-lock-settings.ts | 238 ++++++++++++++++++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 web/src/auto-lock-settings.ts diff --git a/web/src/auto-lock-settings.ts b/web/src/auto-lock-settings.ts new file mode 100644 index 00000000..4868c7c7 --- /dev/null +++ b/web/src/auto-lock-settings.ts @@ -0,0 +1,238 @@ +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; +} + +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 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 => { + 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; +} From 215f5fb7d82309e3c25e1e39983223f40007e896 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:20:57 +0900 Subject: [PATCH 15/26] test(web): cover automatic LOCK form semantics --- web/src/auto-lock-settings.test.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 web/src/auto-lock-settings.test.ts 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/); + } + }); +}); From 66d50673ca28e9aec7bef6d393616ad128b7f6af Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:22:26 +0900 Subject: [PATCH 16/26] feat(web): wire automatic LOCK settings into provisioner --- web/src/main.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/web/src/main.ts b/web/src/main.ts index 3fd5ea12..1f170204 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -1,4 +1,5 @@ import "./style.css"; +import { createAutoLockSettingsController } from "./auto-lock-settings"; import { CANONICAL_BROWSER_STATE_CHANGED_EVENT, CanonicalDeviceManagement, @@ -136,6 +137,24 @@ let recoveryReset: CanonicalRecoveryResetController | null = null; let snapshot: CanonicalDeviceSnapshot | null = null; let deviceActionInProgress = false; +const autoLockSettings = createAutoLockSettingsController(async (days) => { + 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 { From 17aca870da88433bbe5d7c9ff7efda9fac3face9 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:23:39 +0900 Subject: [PATCH 17/26] test(web): cover automatic LOCK canonical migration and reconciliation --- web/src/canonical-auto-lock.test.ts | 403 ++++++++++++++++++++++++++++ 1 file changed, 403 insertions(+) create mode 100644 web/src/canonical-auto-lock.test.ts 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); + }); +}); From bcbceb88a8ff5df1a3ab024797ae32166615e25a Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:25:55 +0900 Subject: [PATCH 18/26] fix(web): keep Vault format capability structurally additive --- web/src/canonical-protocol-v2.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/web/src/canonical-protocol-v2.ts b/web/src/canonical-protocol-v2.ts index abca69b5..38f46865 100644 --- a/web/src/canonical-protocol-v2.ts +++ b/web/src/canonical-protocol-v2.ts @@ -44,7 +44,10 @@ export interface CanonicalHelloData { protocol: 2; storageSchema: 2; vaultFormat: SupportedVaultFormatVersion; - supportedVaultFormats: readonly number[]; + // 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; @@ -130,7 +133,7 @@ function parseSupportedVaultFormats(value: unknown): readonly number[] { } export function deviceSupportsVaultFormat(hello: Pick, version: number): boolean { - return hello.supportedVaultFormats.includes(version); + return hello.supportedVaultFormats?.includes(version) === true; } export function parseCanonicalHelloData(data: Record): CanonicalHelloData { From affcec945512ff7bfc9d6dddd708b168dab43d56 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:28:58 +0900 Subject: [PATCH 19/26] refactor(web): restore Vault codec readability --- web/src/security/vault-format.ts | 291 ++++++++++++++++++++++++------- 1 file changed, 227 insertions(+), 64 deletions(-) diff --git a/web/src/security/vault-format.ts b/web/src/security/vault-format.ts index d5143ecd..0bc787f6 100644 --- a/web/src/security/vault-format.ts +++ b/web/src/security/vault-format.ts @@ -62,62 +62,124 @@ export interface VmkWrapAadInput { class ByteWriter { private readonly values: number[] = []; - bytes(value: Uint8Array): void { for (const byte of value) this.values.push(byte); } - u8(value: number): void { assertIntegerRange(value, 0, 0xff, "u8"); this.values.push(value); } - u16(value: number): void { assertIntegerRange(value, 0, 0xffff, "u16"); this.values.push((value >>> 8) & 0xff, value & 0xff); } + + bytes(value: Uint8Array): void { + for (const byte of value) this.values.push(byte); + } + + u8(value: number): void { + assertIntegerRange(value, 0, 0xff, "u8"); + this.values.push(value); + } + + u16(value: number): void { + assertIntegerRange(value, 0, 0xffff, "u16"); + this.values.push((value >>> 8) & 0xff, value & 0xff); + } + u64(value: bigint): void { if (value < 0n || value > 0xffff_ffff_ffff_ffffn) throw new Error("u64 out of range"); - for (let shift = 56n; shift >= 0n; shift -= 8n) this.values.push(Number((value >> shift) & 0xffn)); + for (let shift = 56n; shift >= 0n; shift -= 8n) { + this.values.push(Number((value >> shift) & 0xffn)); + } } + sizedBytes(value: Uint8Array, maxLength: number, field: string): void { - if (value.length > maxLength || value.length > 0xffff) throw new Error(`${field} exceeds encoded length limit`); - this.u16(value.length); this.bytes(value); + if (value.length > maxLength || value.length > 0xffff) { + throw new Error(`${field} exceeds encoded length limit`); + } + this.u16(value.length); + this.bytes(value); + } + + sizedText(value: string, field: string): void { + this.sizedBytes(textEncoder.encode(value), MAX_FIELD_BYTES, field); + } + + finish(): Uint8Array { + return Uint8Array.from(this.values); } - sizedText(value: string, field: string): void { this.sizedBytes(textEncoder.encode(value), MAX_FIELD_BYTES, field); } - finish(): Uint8Array { return Uint8Array.from(this.values); } } class ByteReader { private offset = 0; + constructor(private readonly bytes: Uint8Array) {} + take(length: number, field: string): Uint8Array { - if (!Number.isInteger(length) || length < 0 || this.offset + length > this.bytes.length) throw new Error(`truncated ${field}`); - const value = this.bytes.slice(this.offset, this.offset + length); this.offset += length; return value; + if (!Number.isInteger(length) || length < 0 || this.offset + length > this.bytes.length) { + throw new Error(`truncated ${field}`); + } + const value = this.bytes.slice(this.offset, this.offset + length); + this.offset += length; + return value; } - u8(field: string): number { return this.take(1, field)[0] ?? fail(`truncated ${field}`); } - u16(field: string): number { const value = this.take(2, field); return ((value[0] ?? 0) << 8) | (value[1] ?? 0); } + + u8(field: string): number { + return this.take(1, field)[0] ?? fail(`truncated ${field}`); + } + + u16(field: string): number { + const value = this.take(2, field); + return ((value[0] ?? 0) << 8) | (value[1] ?? 0); + } + sizedBytes(maxLength: number, field: string): Uint8Array { - const length = this.u16(`${field} length`); if (length > maxLength) throw new Error(`${field} exceeds encoded length limit`); return this.take(length, field); + const length = this.u16(`${field} length`); + if (length > maxLength) throw new Error(`${field} exceeds encoded length limit`); + return this.take(length, field); + } + + sizedText(field: string): string { + return textDecoder.decode(this.sizedBytes(MAX_FIELD_BYTES, field)); } - sizedText(field: string): string { return textDecoder.decode(this.sizedBytes(MAX_FIELD_BYTES, field)); } - expectEnd(): void { if (this.offset !== this.bytes.length) throw new Error("unexpected trailing vault plaintext data"); } + + expectEnd(): void { + if (this.offset !== this.bytes.length) throw new Error("unexpected trailing vault plaintext data"); + } +} + +function fail(message: string): never { + throw new Error(message); } -function fail(message: string): never { throw new Error(message); } function assertIntegerRange(value: number, min: number, max: number, field: string): void { if (!Number.isInteger(value) || value < min || value > max) throw new Error(`${field} out of range`); } -function assertFixedLength(value: Uint8Array, length: number, field: string): void { if (value.length !== length) throw new Error(`${field} must be ${length} bytes`); } + +function assertFixedLength(value: Uint8Array, length: number, field: string): void { + if (value.length !== length) throw new Error(`${field} must be ${length} bytes`); +} + function expectMagic(reader: ByteReader, expected: Uint8Array, field: string): void { - const actual = reader.take(expected.length, field); if (!actual.every((byte, index) => byte === expected[index])) throw new Error(`unsupported ${field}`); + const actual = reader.take(expected.length, field); + if (!actual.every((byte, index) => byte === expected[index])) throw new Error(`unsupported ${field}`); } + function writeVersion(writer: ByteWriter, version: number, expected: number, field: string): void { - if (version !== expected) throw new Error(`unsupported ${field}: ${version}`); writer.u16(version); + if (version !== expected) throw new Error(`unsupported ${field}: ${version}`); + 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; + 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 vault limit"); + if (record.secret.length < 1 || record.secret.length > MAX_SECRET_BYTES) { + 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"); assertIntegerRange(record.periodSeconds, 1, 0xffff, "periodSeconds"); @@ -125,78 +187,179 @@ function validateCredential(record: VaultCredentialRecord): void { } 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(); writer.u16(value.credentials.length); + if (value.credentials.length > MAX_VAULT_CREDENTIALS) { + throw new Error(`vault supports at most ${MAX_VAULT_CREDENTIALS} credentials`); + } + + const ids = new Set(); + 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(""); - if (ids.has(idKey)) throw new Error("duplicate credentialId"); ids.add(idKey); - writer.bytes(record.credentialId); writer.sizedBytes(record.secret, MAX_SECRET_BYTES, "secret"); writer.sizedText(record.issuer, "issuer"); - writer.sizedText(record.account, "account"); writer.sizedText(record.displayName, "displayName"); writer.u8(ALGORITHM_SHA1); writer.u8(record.digits); - writer.u16(record.periodSeconds); writer.u16(record.manualOrder); + if (ids.has(idKey)) throw new Error("duplicate credentialId"); + ids.add(idKey); + + writer.bytes(record.credentialId); + writer.sizedBytes(record.secret, MAX_SECRET_BYTES, "secret"); + writer.sizedText(record.issuer, "issuer"); + writer.sizedText(record.account, "account"); + writer.sizedText(record.displayName, "displayName"); + writer.u8(ALGORITHM_SHA1); + writer.u8(record.digits); + writer.u16(record.periodSeconds); + writer.u16(record.manualOrder); } + writer.u8(value.wifi === null ? 0 : 1); - if (value.wifi !== null) { writer.sizedText(value.wifi.ssid, "wifi ssid"); writer.sizedText(value.wifi.password, "wifi password"); } + if (value.wifi !== null) { + writer.sizedText(value.wifi.ssid, "wifi ssid"); + writer.sizedText(value.wifi.password, "wifi password"); + } } 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`); - const credentials: VaultCredentialRecord[] = []; const ids = new Set(); + const count = reader.u16("credential count"); + if (count > MAX_VAULT_CREDENTIALS) { + throw new Error(`vault supports at most ${MAX_VAULT_CREDENTIALS} credentials`); + } + + const credentials: VaultCredentialRecord[] = []; + const ids = new Set(); for (let index = 0; index < count; index += 1) { const credentialId = reader.take(CREDENTIAL_ID_BYTES, "credentialId"); const idKey = Array.from(credentialId, (byte) => byte.toString(16).padStart(2, "0")).join(""); - if (ids.has(idKey)) throw new Error("duplicate credentialId"); ids.add(idKey); - const secret = reader.sizedBytes(MAX_SECRET_BYTES, "secret"); if (secret.length < 1) throw new Error("secret must not be empty"); - const issuer = reader.sizedText("issuer"); const account = reader.sizedText("account"); const displayName = reader.sizedText("displayName"); - const algorithmCode = reader.u8("algorithm"); if (algorithmCode !== ALGORITHM_SHA1) throw new Error(`unsupported TOTP algorithm code: ${algorithmCode}`); - const record: VaultCredentialRecord = { credentialId, secret, issuer, account, displayName, algorithm: "SHA1", digits: reader.u8("digits"), periodSeconds: reader.u16("periodSeconds"), manualOrder: reader.u16("manualOrder") }; - validateCredential(record); credentials.push(record); - } - const wifiPresent = reader.u8("wifi presence"); if (wifiPresent !== 0 && wifiPresent !== 1) throw new Error("unsupported wifi presence value"); - const wifi = wifiPresent === 1 ? { ssid: reader.sizedText("wifi ssid"), password: reader.sizedText("wifi password") } : null; + if (ids.has(idKey)) throw new Error("duplicate credentialId"); + ids.add(idKey); + + const secret = reader.sizedBytes(MAX_SECRET_BYTES, "secret"); + if (secret.length < 1) throw new Error("secret must not be empty"); + const issuer = reader.sizedText("issuer"); + const account = reader.sizedText("account"); + const displayName = reader.sizedText("displayName"); + const algorithmCode = reader.u8("algorithm"); + if (algorithmCode !== ALGORITHM_SHA1) throw new Error(`unsupported TOTP algorithm code: ${algorithmCode}`); + + const record: VaultCredentialRecord = { + credentialId, + secret, + issuer, + account, + displayName, + algorithm: "SHA1", + digits: reader.u8("digits"), + periodSeconds: reader.u16("periodSeconds"), + manualOrder: reader.u16("manualOrder"), + }; + validateCredential(record); + credentials.push(record); + } + + const wifiPresent = reader.u8("wifi presence"); + if (wifiPresent !== 0 && wifiPresent !== 1) throw new Error("unsupported wifi presence value"); + const wifi = wifiPresent === 1 + ? { ssid: reader.sizedText("wifi ssid"), password: reader.sizedText("wifi password") } + : null; return { credentials, wifi }; } -export function encodeVaultPlaintext(value: VaultPlaintext, vaultFormatVersion: SupportedVaultFormatVersion = LEGACY_VAULT_FORMAT_VERSION): Uint8Array { - assertSupportedVaultFormatVersion(vaultFormatVersion); const writer = new ByteWriter(); +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(); + 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(); + + 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; +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_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; } + + 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(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); return writer.finish(); + 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(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); + return writer.finish(); } export function buildVmkWrapAad(input: VmkWrapAadInput): Uint8Array { - const packageVersion = input.packageVersion ?? RECOVERY_PACKAGE_VERSION; const wrapVersion = input.wrapVersion ?? VMK_WRAP_VERSION; assertFixedLength(input.vaultId, VAULT_ID_BYTES, "vaultId"); - const writer = new ByteWriter(); writer.bytes(VMK_WRAP_AAD_MAGIC); writeVersion(writer, packageVersion, RECOVERY_PACKAGE_VERSION, "Recovery Package version"); - writeVersion(writer, wrapVersion, VMK_WRAP_VERSION, "VMK wrap version"); writer.bytes(input.vaultId); return writer.finish(); + const packageVersion = input.packageVersion ?? RECOVERY_PACKAGE_VERSION; + const wrapVersion = input.wrapVersion ?? VMK_WRAP_VERSION; + assertFixedLength(input.vaultId, VAULT_ID_BYTES, "vaultId"); + + const writer = new ByteWriter(); + writer.bytes(VMK_WRAP_AAD_MAGIC); + writeVersion(writer, packageVersion, RECOVERY_PACKAGE_VERSION, "Recovery Package version"); + writeVersion(writer, wrapVersion, VMK_WRAP_VERSION, "VMK wrap version"); + writer.bytes(input.vaultId); + return writer.finish(); } From 52f3cb3922598028f02db0090ef5403fbc7c34cf Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:29:36 +0900 Subject: [PATCH 20/26] refactor(web): restore Vault crypto readability --- web/src/security/vault-crypto.ts | 308 +++++++++++++++++++++++++------ 1 file changed, 256 insertions(+), 52 deletions(-) diff --git a/web/src/security/vault-crypto.ts b/web/src/security/vault-crypto.ts index 031cfb34..944c5e7d 100644 --- a/web/src/security/vault-crypto.ts +++ b/web/src/security/vault-crypto.ts @@ -2,7 +2,6 @@ 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, @@ -58,7 +57,9 @@ export interface PassphraseWrappedVmk { tag: Uint8Array; } -export interface RandomSource { fill(target: Uint8Array): void; } +export interface RandomSource { + fill(target: Uint8Array): void; +} const browserRandomSource: RandomSource = { fill(target) { @@ -69,44 +70,103 @@ const browserRandomSource: RandomSource = { }, }; -function copyBuffer(value: Uint8Array): ArrayBuffer { return value.slice().buffer; } +function copyBuffer(value: Uint8Array): ArrayBuffer { + return value.slice().buffer; +} + function assertLength(value: Uint8Array, expected: number, field: string): void { if (value.length !== expected) throw new Error(`${field} must be ${expected} bytes`); } + function randomBytes(length: number, source: RandomSource): Uint8Array { - const value = new Uint8Array(length); source.fill(value); return value; + const value = new Uint8Array(length); + source.fill(value); + return value; } + function splitCiphertextAndTag(combined: ArrayBuffer): { ciphertext: Uint8Array; tag: Uint8Array } { const bytes = new Uint8Array(combined); - if (bytes.length < AES_GCM_TAG_BYTES) throw new Error("AES-GCM result is shorter than the authentication tag"); - return { ciphertext: bytes.slice(0, bytes.length - AES_GCM_TAG_BYTES), tag: bytes.slice(bytes.length - AES_GCM_TAG_BYTES) }; + if (bytes.length < AES_GCM_TAG_BYTES) { + throw new Error("AES-GCM result is shorter than the authentication tag"); + } + return { + ciphertext: bytes.slice(0, bytes.length - AES_GCM_TAG_BYTES), + tag: bytes.slice(bytes.length - AES_GCM_TAG_BYTES), + }; } + function joinCiphertextAndTag(ciphertext: Uint8Array, tag: Uint8Array): Uint8Array { assertLength(tag, AES_GCM_TAG_BYTES, "AES-GCM tag"); - const combined = new Uint8Array(ciphertext.length + tag.length); combined.set(ciphertext, 0); combined.set(tag, ciphertext.length); return combined; + const combined = new Uint8Array(ciphertext.length + tag.length); + combined.set(ciphertext, 0); + combined.set(tag, ciphertext.length); + return combined; } + async function importAesKey(rawKey: Uint8Array, usage: KeyUsage): Promise { assertLength(rawKey, AES_GCM_KEY_BYTES, "AES-256 key"); return crypto.subtle.importKey("raw", copyBuffer(rawKey), { name: "AES-GCM" }, false, [usage]); } -async function aesGcmEncrypt(rawKey: Uint8Array, nonce: Uint8Array, plaintext: Uint8Array, aad: Uint8Array) { + +async function aesGcmEncrypt( + rawKey: Uint8Array, + nonce: Uint8Array, + plaintext: Uint8Array, + aad: Uint8Array, +): Promise<{ ciphertext: Uint8Array; tag: Uint8Array }> { assertLength(nonce, AES_GCM_NONCE_BYTES, "AES-GCM nonce"); const key = await importAesKey(rawKey, "encrypt"); - return splitCiphertextAndTag(await crypto.subtle.encrypt({ name: "AES-GCM", iv: copyBuffer(nonce), additionalData: copyBuffer(aad), tagLength: 128 }, key, copyBuffer(plaintext))); + const combined = await crypto.subtle.encrypt( + { + name: "AES-GCM", + iv: copyBuffer(nonce), + additionalData: copyBuffer(aad), + tagLength: 128, + }, + key, + copyBuffer(plaintext), + ); + return splitCiphertextAndTag(combined); } -async function aesGcmDecrypt(rawKey: Uint8Array, nonce: Uint8Array, ciphertext: Uint8Array, tag: Uint8Array, aad: Uint8Array): Promise { + +async function aesGcmDecrypt( + rawKey: Uint8Array, + nonce: Uint8Array, + ciphertext: Uint8Array, + tag: Uint8Array, + aad: Uint8Array, +): Promise { assertLength(nonce, AES_GCM_NONCE_BYTES, "AES-GCM nonce"); - const key = await importAesKey(rawKey, "decrypt"); const combined = joinCiphertextAndTag(ciphertext, tag); + const key = await importAesKey(rawKey, "decrypt"); + const combined = joinCiphertextAndTag(ciphertext, tag); try { - return new Uint8Array(await crypto.subtle.decrypt({ name: "AES-GCM", iv: copyBuffer(nonce), additionalData: copyBuffer(aad), tagLength: 128 }, key, copyBuffer(combined))); - } finally { combined.fill(0); } + const plaintext = await crypto.subtle.decrypt( + { + name: "AES-GCM", + iv: copyBuffer(nonce), + additionalData: copyBuffer(aad), + tagLength: 128, + }, + key, + copyBuffer(combined), + ); + return new Uint8Array(plaintext); + } finally { + combined.fill(0); + } } function validateVaultEnvelope(envelope: EncryptedVaultEnvelope): void { assertSupportedVaultFormatVersion(envelope.vaultFormatVersion); - if (envelope.storageSchemaVersion !== VAULT_TARGET_STORAGE_SCHEMA_VERSION) throw new Error(`unsupported storage schema version: ${envelope.storageSchemaVersion}`); - assertLength(envelope.vaultId, VAULT_ID_BYTES, "vaultId"); assertLength(envelope.nonce, AES_GCM_NONCE_BYTES, "vault nonce"); assertLength(envelope.tag, AES_GCM_TAG_BYTES, "vault tag"); - if (envelope.ciphertextLength !== envelope.ciphertext.length) throw new Error("vault ciphertext length framing mismatch"); + if (envelope.storageSchemaVersion !== VAULT_TARGET_STORAGE_SCHEMA_VERSION) { + throw new Error(`unsupported storage schema version: ${envelope.storageSchemaVersion}`); + } + assertLength(envelope.vaultId, VAULT_ID_BYTES, "vaultId"); + assertLength(envelope.nonce, AES_GCM_NONCE_BYTES, "vault nonce"); + assertLength(envelope.tag, AES_GCM_TAG_BYTES, "vault tag"); + if (envelope.ciphertextLength !== envelope.ciphertext.length) { + throw new Error("vault ciphertext length framing mismatch"); + } } export async function encryptVaultForFormat( @@ -117,54 +177,158 @@ export async function encryptVaultForFormat( 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, vaultFormatVersion }); + 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, vaultFormatVersion }); const encrypted = await aesGcmEncrypt(vmk, nonce, plaintext, aad); - return { vaultFormatVersion, storageSchemaVersion: VAULT_TARGET_STORAGE_SCHEMA_VERSION, vaultId: vaultId.slice(), generation, nonce, ciphertext: encrypted.ciphertext, tag: encrypted.tag, ciphertextLength: encrypted.ciphertext.length }; + return { + vaultFormatVersion, + storageSchemaVersion: VAULT_TARGET_STORAGE_SCHEMA_VERSION, + vaultId: vaultId.slice(), + generation, + nonce, + ciphertext: encrypted.ciphertext, + tag: encrypted.tag, + ciphertextLength: encrypted.ciphertext.length, + }; } // 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 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 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): Promise { - validateVaultEnvelope(envelope); assertLength(vmk, AES_GCM_KEY_BYTES, "VMK"); - const aad = buildVaultAad({ vaultId: envelope.vaultId, generation: envelope.generation, storageSchemaVersion: envelope.storageSchemaVersion, vaultFormatVersion: envelope.vaultFormatVersion }); + +export async function decryptVault( + envelope: EncryptedVaultEnvelope, + vmk: Uint8Array, +): Promise { + validateVaultEnvelope(envelope); + assertLength(vmk, AES_GCM_KEY_BYTES, "VMK"); + const aad = buildVaultAad({ + vaultId: envelope.vaultId, + generation: envelope.generation, + storageSchemaVersion: envelope.storageSchemaVersion, + vaultFormatVersion: envelope.vaultFormatVersion, + }); return aesGcmDecrypt(vmk, envelope.nonce, envelope.ciphertext, envelope.tag, aad); } export function normalizeAndValidatePassphrase(passphrase: string): Uint8Array { - const normalized = passphrase.normalize("NFC"); const codePoints = Array.from(normalized).length; - if (codePoints < 15 || codePoints > 128) throw new Error("Passphrase must contain 15 to 128 Unicode code points after NFC normalization"); + const normalized = passphrase.normalize("NFC"); + const codePoints = Array.from(normalized).length; + if (codePoints < 15 || codePoints > 128) { + throw new Error("Passphrase must contain 15 to 128 Unicode code points after NFC normalization"); + } const encoded = textEncoder.encode(normalized); - if (encoded.length > 512) { encoded.fill(0); throw new Error("Passphrase exceeds the 512-byte UTF-8 limit after NFC normalization"); } + if (encoded.length > 512) { + encoded.fill(0); + throw new Error("Passphrase exceeds the 512-byte UTF-8 limit after NFC normalization"); + } return encoded; } + export function createArgon2idMetadata(source: RandomSource = browserRandomSource): Argon2idKdfMetadata { - return { algorithm: "argon2id", version: ARGON2ID_VERSION, memoryKiB: ARGON2ID_MEMORY_KIB, iterations: ARGON2ID_ITERATIONS, parallelism: ARGON2ID_PARALLELISM, salt: randomBytes(ARGON2ID_SALT_BYTES, source), outputBytes: ARGON2ID_OUTPUT_BYTES }; + return { + algorithm: "argon2id", + version: ARGON2ID_VERSION, + memoryKiB: ARGON2ID_MEMORY_KIB, + iterations: ARGON2ID_ITERATIONS, + parallelism: ARGON2ID_PARALLELISM, + salt: randomBytes(ARGON2ID_SALT_BYTES, source), + outputBytes: ARGON2ID_OUTPUT_BYTES, + }; } + function validateKdfMetadata(metadata: Argon2idKdfMetadata): void { - if (metadata.algorithm !== "argon2id") throw new Error(`unsupported KDF algorithm: ${String(metadata.algorithm)}`); - if (metadata.version !== ARGON2ID_VERSION) throw new Error(`unsupported Argon2id version: ${metadata.version}`); - if (metadata.memoryKiB !== ARGON2ID_MEMORY_KIB || metadata.iterations !== ARGON2ID_ITERATIONS || metadata.parallelism !== ARGON2ID_PARALLELISM || metadata.outputBytes !== ARGON2ID_OUTPUT_BYTES) throw new Error("unsupported Argon2id parameter set"); + if (metadata.algorithm !== "argon2id") { + throw new Error(`unsupported KDF algorithm: ${String(metadata.algorithm)}`); + } + if (metadata.version !== ARGON2ID_VERSION) { + throw new Error(`unsupported Argon2id version: ${metadata.version}`); + } + if ( + metadata.memoryKiB !== ARGON2ID_MEMORY_KIB || + metadata.iterations !== ARGON2ID_ITERATIONS || + metadata.parallelism !== ARGON2ID_PARALLELISM || + metadata.outputBytes !== ARGON2ID_OUTPUT_BYTES + ) { + throw new Error("unsupported Argon2id parameter set"); + } assertLength(metadata.salt, ARGON2ID_SALT_BYTES, "Argon2id salt"); } -export async function derivePassphraseKek(passphrase: string, metadata: Argon2idKdfMetadata): Promise { - validateKdfMetadata(metadata); const encoded = normalizeAndValidatePassphrase(passphrase); + +export async function derivePassphraseKek( + passphrase: string, + metadata: Argon2idKdfMetadata, +): Promise { + validateKdfMetadata(metadata); + const encoded = normalizeAndValidatePassphrase(passphrase); try { - return (await argon2id({ password: encoded, salt: metadata.salt, parallelism: metadata.parallelism, iterations: metadata.iterations, memorySize: metadata.memoryKiB, hashLength: metadata.outputBytes, outputType: "binary" })).slice(); - } finally { encoded.fill(0); } + const result = await argon2id({ + password: encoded, + salt: metadata.salt, + parallelism: metadata.parallelism, + iterations: metadata.iterations, + memorySize: metadata.memoryKiB, + hashLength: metadata.outputBytes, + outputType: "binary", + }); + return result.slice(); + } finally { + encoded.fill(0); + } } + function validateWrappedVmk(value: PassphraseWrappedVmk): void { - if (value.packageVersion !== RECOVERY_PACKAGE_VERSION) throw new Error(`unsupported Recovery Package version: ${value.packageVersion}`); - if (value.wrapVersion !== VMK_WRAP_VERSION) throw new Error(`unsupported VMK wrap version: ${value.wrapVersion}`); - 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"); - if (value.ciphertext.length !== AES_GCM_KEY_BYTES) throw new Error("wrapped VMK ciphertext must be 32 bytes"); validateKdfMetadata(value.kdf); + if (value.packageVersion !== RECOVERY_PACKAGE_VERSION) { + throw new Error(`unsupported Recovery Package version: ${value.packageVersion}`); + } + if (value.wrapVersion !== VMK_WRAP_VERSION) { + throw new Error(`unsupported VMK wrap version: ${value.wrapVersion}`); + } + 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"); + if (value.ciphertext.length !== AES_GCM_KEY_BYTES) { + throw new Error("wrapped VMK ciphertext must be 32 bytes"); + } + validateKdfMetadata(value.kdf); } + export async function wrapVmkWithPassphraseForFormat( vmk: Uint8Array, vaultId: Uint8Array, @@ -173,12 +337,32 @@ export async function wrapVmkWithPassphraseForFormat( kdf: Argon2idKdfMetadata = createArgon2idMetadata(), source: RandomSource = browserRandomSource, ): Promise { - assertSupportedVaultFormatVersion(vaultFormatVersion); assertLength(vmk, AES_GCM_KEY_BYTES, "VMK"); assertLength(vaultId, VAULT_ID_BYTES, "vaultId"); validateKdfMetadata(kdf); - const nonce = randomBytes(AES_GCM_NONCE_BYTES, source); const kek = await derivePassphraseKek(passphrase, kdf); + assertSupportedVaultFormatVersion(vaultFormatVersion); + assertLength(vmk, AES_GCM_KEY_BYTES, "VMK"); + assertLength(vaultId, VAULT_ID_BYTES, "vaultId"); + validateKdfMetadata(kdf); + const nonce = randomBytes(AES_GCM_NONCE_BYTES, source); + const kek = await derivePassphraseKek(passphrase, kdf); try { - const encrypted = await aesGcmEncrypt(kek, nonce, vmk, buildVmkWrapAad({ vaultId, packageVersion: RECOVERY_PACKAGE_VERSION, wrapVersion: VMK_WRAP_VERSION })); - return { packageVersion: RECOVERY_PACKAGE_VERSION, wrapVersion: VMK_WRAP_VERSION, vaultFormatVersion, vaultId: vaultId.slice(), kdf: { ...kdf, salt: kdf.salt.slice() }, nonce, ciphertext: encrypted.ciphertext, tag: encrypted.tag }; - } finally { kek.fill(0); } + const aad = buildVmkWrapAad({ + vaultId, + packageVersion: RECOVERY_PACKAGE_VERSION, + wrapVersion: VMK_WRAP_VERSION, + }); + const encrypted = await aesGcmEncrypt(kek, nonce, vmk, aad); + return { + packageVersion: RECOVERY_PACKAGE_VERSION, + wrapVersion: VMK_WRAP_VERSION, + vaultFormatVersion, + vaultId: vaultId.slice(), + kdf: { ...kdf, salt: kdf.salt.slice() }, + nonce, + ciphertext: encrypted.ciphertext, + tag: encrypted.tag, + }; + } finally { + kek.fill(0); + } } // Compatibility helper retained for existing V1 callers/fixtures. New canonical @@ -190,12 +374,32 @@ export async function wrapVmkWithPassphrase( kdf: Argon2idKdfMetadata = createArgon2idMetadata(), source: RandomSource = browserRandomSource, ): Promise { - return wrapVmkWithPassphraseForFormat(vmk, vaultId, passphrase, LEGACY_VAULT_FORMAT_VERSION, kdf, source); + return wrapVmkWithPassphraseForFormat( + vmk, + vaultId, + passphrase, + LEGACY_VAULT_FORMAT_VERSION, + kdf, + source, + ); } -export async function unwrapVmkWithPassphrase(wrapped: PassphraseWrappedVmk, passphrase: string): Promise { - validateWrappedVmk(wrapped); const kek = await derivePassphraseKek(passphrase, wrapped.kdf); + +export async function unwrapVmkWithPassphrase( + wrapped: PassphraseWrappedVmk, + passphrase: string, +): Promise { + validateWrappedVmk(wrapped); + const kek = await derivePassphraseKek(passphrase, wrapped.kdf); try { - const vmk = await aesGcmDecrypt(kek, wrapped.nonce, wrapped.ciphertext, wrapped.tag, buildVmkWrapAad({ vaultId: wrapped.vaultId, packageVersion: wrapped.packageVersion, wrapVersion: wrapped.wrapVersion })); - assertLength(vmk, AES_GCM_KEY_BYTES, "unwrapped VMK"); return vmk; - } finally { kek.fill(0); } + const aad = buildVmkWrapAad({ + vaultId: wrapped.vaultId, + packageVersion: wrapped.packageVersion, + wrapVersion: wrapped.wrapVersion, + }); + const vmk = await aesGcmDecrypt(wrapped.kdf ? kek : kek, wrapped.nonce, wrapped.ciphertext, wrapped.tag, aad); + assertLength(vmk, AES_GCM_KEY_BYTES, "unwrapped VMK"); + return vmk; + } finally { + kek.fill(0); + } } From e35aeff652684bf8a77347e11f1abb6b72a440e9 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:30:24 +0900 Subject: [PATCH 21/26] fix(web): simplify Recovery VMK unwrap call --- web/src/security/vault-crypto.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/security/vault-crypto.ts b/web/src/security/vault-crypto.ts index 944c5e7d..d59eec3f 100644 --- a/web/src/security/vault-crypto.ts +++ b/web/src/security/vault-crypto.ts @@ -396,7 +396,7 @@ export async function unwrapVmkWithPassphrase( packageVersion: wrapped.packageVersion, wrapVersion: wrapped.wrapVersion, }); - const vmk = await aesGcmDecrypt(wrapped.kdf ? kek : kek, wrapped.nonce, wrapped.ciphertext, wrapped.tag, aad); + const vmk = await aesGcmDecrypt(kek, wrapped.nonce, wrapped.ciphertext, wrapped.tag, aad); assertLength(vmk, AES_GCM_KEY_BYTES, "unwrapped VMK"); return vmk; } finally { From 5e4fa7f85cc5b418a8e2cbc360199dcf4944cbe5 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:33:36 +0900 Subject: [PATCH 22/26] fix(web): preserve Recovery wrapper across Vault format promotion --- web/src/security/browser-vault.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/web/src/security/browser-vault.ts b/web/src/security/browser-vault.ts index c9e26133..45f0061b 100644 --- a/web/src/security/browser-vault.ts +++ b/web/src/security/browser-vault.ts @@ -645,10 +645,17 @@ export function mergeVaultAdvanceWithCurrentBrowserState( safeIncoming.trustedBrowser.wrappedVmk, ); if (!isSingleGenerationAdvance || !sameVault || !sameBrowserVmk) return safeIncoming; + + // 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: safeIncoming.recoveryWrappedVmk, + recoveryWrappedVmk, }); } From 7816dab6f6b8bdef9ac062d8a3dfe201999cbd05 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:34:08 +0900 Subject: [PATCH 23/26] test(web): preserve Passphrase rewrap during F1 to F2 promotion --- web/src/security/browser-vault.test.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/web/src/security/browser-vault.test.ts b/web/src/security/browser-vault.test.ts index a4b8f810..2be72d6a 100644 --- a/web/src/security/browser-vault.test.ts +++ b/web/src/security/browser-vault.test.ts @@ -145,8 +145,9 @@ describe("browser canonical Vault", () => { vmk.fill(0); }); - it("preserves current browser security state while accepting an F1-to-F2 generation advance", async () => { + 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); @@ -163,11 +164,17 @@ describe("browser canonical Vault", () => { vault: nextVault, recoveryWrappedVmk: { ...state.recoveryWrappedVmk, vaultFormatVersion: VAULT_FORMAT_VERSION }, }); - const merged = mergeVaultAdvanceWithCurrentBrowserState(state, incoming, state.vault.generation); + const merged = mergeVaultAdvanceWithCurrentBrowserState(changed, incoming, state.vault.generation); expect(merged.vault.generation).toBe(8n); - expect(merged.vault.vaultFormatVersion).toBe(2); - expect(merged.recoveryWrappedVmk.vaultFormatVersion).toBe(2); - expect(merged.trustedBrowser.registrationId).toEqual(state.trustedBrowser.registrationId); + 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); + + const recovered = await unwrapVmkWithPassphrase(merged.recoveryWrappedVmk, newPassphrase); + expect(recovered).toEqual(vmk); + 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); From 7aa3be755d5c8afbdfbe03daaf60cc9ad8b31b19 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 02:07:02 +0900 Subject: [PATCH 24/26] fix(web): bind automatic LOCK draft to canonical context --- web/src/auto-lock-settings.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/web/src/auto-lock-settings.ts b/web/src/auto-lock-settings.ts index 4868c7c7..4c2a8536 100644 --- a/web/src/auto-lock-settings.ts +++ b/web/src/auto-lock-settings.ts @@ -69,6 +69,25 @@ export function parseAutoLockDraft(enabled: boolean, rawDays: string | number): 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"); @@ -111,6 +130,7 @@ export function createAutoLockSettingsController(onSave: AutoLockSaveHandler): A } let snapshot: CanonicalDeviceSnapshot | null = null; + let contextIdentity: string | null = null; let busy = false; let recoveryMode = false; let dirty = false; @@ -124,6 +144,17 @@ export function createAutoLockSettingsController(onSave: AutoLockSaveHandler): A 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; From 0f061c1f02fe7cc3a4c1f1bc86c3351626b66baf Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 02:07:35 +0900 Subject: [PATCH 25/26] test(web): cover automatic LOCK draft context changes --- web/tests/browser/auto-lock-context-smoke.ts | 134 +++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 web/tests/browser/auto-lock-context-smoke.ts 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"; +} From c09cb6b6809fbffe92f5076d9e262db40005ad77 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 02:07:47 +0900 Subject: [PATCH 26/26] test(web): run automatic LOCK context smoke --- web/tests/browser/combined-smoke.ts | 7 +++++++ 1 file changed, 7 insertions(+) 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") {