Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
2944c4c
feat(web): add Vault Format 2 codec
miso-develop Sep 16, 2026
fe235e1
feat(web): support Vault Format 1 and 2 crypto
miso-develop Sep 16, 2026
5291ca8
feat(web): parse Vault Format 2 capability metadata
miso-develop Sep 16, 2026
6fc4407
feat(web): support Vault Format 2 browser and recovery state
miso-develop Sep 16, 2026
e17d377
feat(web): integrate capability-gated Vault Format 2 writes
miso-develop Sep 16, 2026
14f25df
test(web): cover Vault Format 1 and 2 framing
miso-develop Sep 16, 2026
a2902a9
test(web): cover Vault Format capability metadata
miso-develop Sep 16, 2026
2cbe064
test(web): preserve Format 1 crypto and cover Format 2
miso-develop Sep 16, 2026
249ee2e
test(web): cover Recovery Package Vault Format 1 and 2
miso-develop Sep 16, 2026
17069a6
fix(web): preserve legacy default Vault codec helper
miso-develop Sep 16, 2026
e3b220c
fix(web): preserve legacy default Vault crypto helpers
miso-develop Sep 16, 2026
9264883
test(web): correct Vault Format 2 AAD expectations
miso-develop Sep 16, 2026
67f21a5
test(web): use canonical Vault plaintext in rekey fixture
miso-develop Sep 16, 2026
7833ea6
feat(web): add automatic LOCK settings controller
miso-develop Sep 16, 2026
215f5fb
test(web): cover automatic LOCK form semantics
miso-develop Sep 16, 2026
66d5067
feat(web): wire automatic LOCK settings into provisioner
miso-develop Sep 16, 2026
17aca87
test(web): cover automatic LOCK canonical migration and reconciliation
miso-develop Sep 16, 2026
bcbceb8
fix(web): keep Vault format capability structurally additive
miso-develop Sep 16, 2026
affcec9
refactor(web): restore Vault codec readability
miso-develop Sep 16, 2026
52f3cb3
refactor(web): restore Vault crypto readability
miso-develop Sep 16, 2026
e35aeff
fix(web): simplify Recovery VMK unwrap call
miso-develop Sep 16, 2026
5e4fa7f
fix(web): preserve Recovery wrapper across Vault format promotion
miso-develop Sep 16, 2026
7816dab
test(web): preserve Passphrase rewrap during F1 to F2 promotion
miso-develop Sep 16, 2026
7aa3be7
fix(web): bind automatic LOCK draft to canonical context
miso-develop Sep 16, 2026
0f061c1
test(web): cover automatic LOCK draft context changes
miso-develop Sep 16, 2026
c09cb6b
test(web): run automatic LOCK context smoke
miso-develop Sep 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions web/src/auto-lock-settings.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
}
});
});
269 changes: 269 additions & 0 deletions web/src/auto-lock-settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
import type { CanonicalDeviceSnapshot } from "./canonical-management";
import { getLanguage, onLanguageChange, type UiLanguage } from "./i18n";

export interface AutoLockDraft {
enabled: boolean;
days: number;
}

export interface AutoLockSettingsController {
render(snapshot: CanonicalDeviceSnapshot | null, busy: boolean, recoveryMode: boolean): void;
dispose(): void;
}

export type AutoLockSaveHandler = (days: number | null) => Promise<void>;

const copy = {
en: {
heading: "Automatic LOCK",
description: "Optionally limit one continuous UNLOCKED session to a whole number of days. Disabled means no automatic maximum lifetime is configured.",
enabled: "Enable automatic LOCK",
days: "Maximum UNLOCKED lifetime",
save: "Save automatic LOCK setting",
unavailable: "This Device does not advertise Vault Format 2 support. Update Device firmware before enabling automatic LOCK.",
disconnected: "Connect and unlock the active Trusted Browser to view this setting.",
locked: "Unlock the Device to view or change the canonical automatic LOCK setting.",
unprovisioned: "Automatic LOCK is available after initial provisioning. New Vaults remain disabled until you explicitly enable and save it.",
conflict: "Automatic LOCK settings are unavailable until canonical browser ownership is reconciled.",
disabled: "Current setting: disabled — no automatic maximum UNLOCKED lifetime is configured.",
enabledValue: (days: number) => `Current setting: ${days} day${days === 1 ? "" : "s"} maximum UNLOCKED lifetime.`,
unsaved: "Unsaved change. Saving uses the authenticated encrypted canonical Vault generation path.",
saving: "Saving automatic LOCK setting…",
saved: "Automatic LOCK setting saved.",
savedLocked: "Automatic LOCK setting saved. The Device is now LOCKED; unlock it to read the canonical setting again.",
failed: "Automatic LOCK setting was not confirmed. The previous canonical setting remains authoritative.",
dayOption: (days: number) => `${days} day${days === 1 ? "" : "s"}`,
},
ja: {
heading: "自動LOCK",
description: "1回の連続したUNLOCKED状態に、日単位の最大期間を任意で設定します。無効の場合、自動的な最大UNLOCKED期間は設定されません。",
enabled: "自動LOCKを有効にする",
days: "最大UNLOCKED期間",
save: "自動LOCK設定を保存",
unavailable: "このDeviceはVault Format 2対応を通知していません。自動LOCKを有効にする前にDevice firmwareを更新してください。",
disconnected: "この設定を確認するには、Active Trusted Browserとして接続してDeviceをロック解除してください。",
locked: "Canonical自動LOCK設定を確認・変更するにはDeviceをロック解除してください。",
unprovisioned: "自動LOCKは初回プロビジョニング後に設定できます。明示的に有効化して保存するまでは、新しいVaultでも無効のままです。",
conflict: "Canonical browser ownershipの不整合が解消されるまで、自動LOCK設定は利用できません。",
disabled: "現在の設定: 無効 — 自動的な最大UNLOCKED期間は設定されていません。",
enabledValue: (days: number) => `現在の設定: 最大UNLOCKED期間 ${days}日。`,
unsaved: "未保存の変更があります。保存には認証済み・暗号化済みCanonical Vaultのgeneration更新経路を使用します。",
saving: "自動LOCK設定を保存しています…",
saved: "自動LOCK設定を保存しました。",
savedLocked: "自動LOCK設定を保存しました。DeviceはLOCKEDになりました。Canonical設定を再確認するにはロック解除してください。",
failed: "自動LOCK設定の反映を確認できませんでした。以前のCanonical設定が引き続き正です。",
dayOption: (days: number) => `${days}日`,
},
} as const;

export function autoLockDraftFromCanonical(days: number | null): AutoLockDraft {
return { enabled: days !== null, days: days ?? 1 };
}

export function parseAutoLockDraft(enabled: boolean, rawDays: string | number): number | null {
if (!enabled) return null;
const days = typeof rawDays === "number" ? rawDays : Number(rawDays);
if (!Number.isInteger(days) || days < 1 || days > 31) {
throw new Error("Automatic LOCK days must be an integer from 1 through 31");
}
return days;
}

function bytesIdentity(value: Uint8Array | null): string {
if (value === null) return "-";
return Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join("");
}

function canonicalContextIdentity(snapshot: CanonicalDeviceSnapshot | null): string | null {
if (snapshot === null) return null;
const hello = snapshot.hello;
return [
hello.deviceId,
hello.vaultPresent ? bytesIdentity(hello.vaultId) : "no-vault",
hello.generation.toString(10),
String(hello.vaultFormat),
hello.registrationPresent ? bytesIdentity(hello.registrationId) : "no-registration",
String(hello.registrationEpoch),
snapshot.browserOwnership,
].join(":");
}

export function createAutoLockSettingsController(onSave: AutoLockSaveHandler): AutoLockSettingsController {
const shell = document.querySelector<HTMLElement>("#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 = `
<h2 id="auto-lock-heading"></h2>
<p id="auto-lock-description" class="hint"></p>
<label class="checkbox-label" for="auto-lock-enabled">
<input id="auto-lock-enabled" type="checkbox" disabled />
<span id="auto-lock-enabled-label"></span>
</label>
<label id="auto-lock-days-label" for="auto-lock-days"></label>
<select id="auto-lock-days" disabled></select>
<div class="actions">
<button id="save-auto-lock" type="button" disabled></button>
</div>
<p id="auto-lock-status" class="notice" aria-live="polite"></p>
`;

const rekeyPanel = shell.querySelector<HTMLElement>('section[aria-labelledby="rekey-heading"]');
if (rekeyPanel) shell.insertBefore(panel, rekeyPanel);
else shell.append(panel);

const heading = required<HTMLElement>(panel, "#auto-lock-heading");
const description = required<HTMLElement>(panel, "#auto-lock-description");
const enabledInput = required<HTMLInputElement>(panel, "#auto-lock-enabled");
const enabledLabel = required<HTMLElement>(panel, "#auto-lock-enabled-label");
const daysLabel = required<HTMLElement>(panel, "#auto-lock-days-label");
const daysSelect = required<HTMLSelectElement>(panel, "#auto-lock-days");
const saveButton = required<HTMLButtonElement>(panel, "#save-auto-lock");
const status = required<HTMLElement>(panel, "#auto-lock-status");

for (let days = 1; days <= 31; days += 1) {
const option = document.createElement("option");
option.value = String(days);
daysSelect.append(option);
}

let snapshot: CanonicalDeviceSnapshot | null = null;
let contextIdentity: string | null = null;
let busy = false;
let recoveryMode = false;
let dirty = false;
let saving = false;
let saveFailed = false;
let savedWhileLocked = false;
let draft = autoLockDraftFromCanonical(null);

const render = (
nextSnapshot: CanonicalDeviceSnapshot | null = snapshot,
nextBusy = busy,
nextRecoveryMode = recoveryMode,
): void => {
const nextContextIdentity = canonicalContextIdentity(nextSnapshot);
if (nextContextIdentity !== contextIdentity) {
contextIdentity = nextContextIdentity;
dirty = false;
saveFailed = false;
savedWhileLocked = false;
draft = nextSnapshot?.autoLock.known
? autoLockDraftFromCanonical(nextSnapshot.autoLock.days)
: autoLockDraftFromCanonical(null);
}

snapshot = nextSnapshot;
busy = nextBusy;
recoveryMode = nextRecoveryMode;

if (!dirty && snapshot?.autoLock.known) draft = autoLockDraftFromCanonical(snapshot.autoLock.days);

const language = getLanguage();
const text = copy[language];
heading.textContent = text.heading;
description.textContent = text.description;
enabledLabel.textContent = text.enabled;
daysLabel.textContent = text.days;
saveButton.textContent = text.save;
for (let index = 0; index < daysSelect.options.length; index += 1) {
const option = daysSelect.options.item(index);
if (option) option.textContent = text.dayOption(index + 1);
}

enabledInput.checked = draft.enabled;
daysSelect.value = String(draft.days);

const writable = snapshot !== null && !recoveryMode && snapshot.browserOwnership === "active" &&
snapshot.hello.state === "unlocked" && snapshot.autoLock.known && snapshot.autoLock.format2Writable;
enabledInput.disabled = busy || saving || !writable;
daysSelect.disabled = busy || saving || !writable || !draft.enabled;
saveButton.disabled = busy || saving || !writable || !dirty;

status.textContent = statusText(language, snapshot, recoveryMode, dirty, saving, saveFailed, savedWhileLocked);
};

enabledInput.addEventListener("change", () => {
draft = {
enabled: enabledInput.checked,
days: draft.days >= 1 && draft.days <= 31 ? draft.days : 1,
};
dirty = true;
saveFailed = false;
savedWhileLocked = false;
render();
});

daysSelect.addEventListener("change", () => {
const parsed = parseAutoLockDraft(true, daysSelect.value);
draft = { enabled: true, days: parsed ?? 1 };
dirty = true;
saveFailed = false;
savedWhileLocked = false;
render();
});

saveButton.addEventListener("click", () => {
if (saveButton.disabled || saving) return;
const value = parseAutoLockDraft(draft.enabled, draft.days);
saving = true;
saveFailed = false;
savedWhileLocked = false;
render();
void onSave(value).then(() => {
dirty = false;
saving = false;
saveFailed = false;
savedWhileLocked = snapshot?.hello.state === "locked";
render();
}).catch(() => {
saving = false;
saveFailed = true;
render();
});
});

const unsubscribe = onLanguageChange(() => render());
render();

return {
render(nextSnapshot, nextBusy, nextRecoveryMode) {
render(nextSnapshot, nextBusy, nextRecoveryMode);
},
dispose() {
unsubscribe();
panel.remove();
},
};
}

function statusText(
language: UiLanguage,
snapshot: CanonicalDeviceSnapshot | null,
recoveryMode: boolean,
dirty: boolean,
saving: boolean,
saveFailed: boolean,
savedWhileLocked: boolean,
): string {
const text = copy[language];
if (saving) return text.saving;
if (saveFailed) return text.failed;
if (savedWhileLocked) return text.savedLocked;
if (recoveryMode) return text.conflict;
if (!snapshot) return text.disconnected;
if (!snapshot.hello.vaultPresent) return text.unprovisioned;
if (snapshot.browserOwnership !== "active") return text.conflict;
if (snapshot.hello.state !== "unlocked" || !snapshot.autoLock.known) return text.locked;
if (!snapshot.autoLock.format2Writable) return text.unavailable;
if (dirty) return text.unsaved;
return snapshot.autoLock.days === null ? text.disabled : text.enabledValue(snapshot.autoLock.days);
}

function required<T extends Element>(root: ParentNode, selector: string): T {
const element = root.querySelector<T>(selector);
if (!element) throw new Error(`Automatic LOCK control is missing: ${selector}`);
return element;
}
Loading