From 8e0ff6aff5a6b41a4eaf5d5bfdeb063791ea6c14 Mon Sep 17 00:00:00 2001 From: Sebastian Beyvers Date: Sun, 4 Oct 2026 12:01:09 +0200 Subject: [PATCH 01/86] feat: add bucket encryption requests --- src/lib/api.ts | 2 + src/lib/api/bucketCompression.ts | 21 ++ src/lib/api/bucketEncryption.test.ts | 105 +++++++++ src/lib/api/bucketEncryption.ts | 324 +++++++++++++++++++++++++++ 4 files changed, 452 insertions(+) create mode 100644 src/lib/api/bucketCompression.ts create mode 100644 src/lib/api/bucketEncryption.test.ts create mode 100644 src/lib/api/bucketEncryption.ts diff --git a/src/lib/api.ts b/src/lib/api.ts index c7f46a4b..de01f71a 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -15,6 +15,8 @@ export * from './api/connectors' export * from './api/staging' export * from './api/storageRouting' export * from './api/bucketUsage' +export * from './api/bucketCompression' +export * from './api/bucketEncryption' export * from './api/blobs' export * from './api/sync' export * from './api/notifications' diff --git a/src/lib/api/bucketCompression.ts b/src/lib/api/bucketCompression.ts new file mode 100644 index 00000000..fe4d67e0 --- /dev/null +++ b/src/lib/api/bucketCompression.ts @@ -0,0 +1,21 @@ +// ── Bucket compression (GET /data/buckets/{bucket}/storage/compression) ──── +// Node-local, asked of the bucket's own node. An encrypted bucket compresses +// inside Pithos at the nearest supported level, which `effective_level` names. +import { apiRequest, type ApiClientOptions } from './client' + +export interface BucketCompressionResponse { + bucket: string + mode: 'off' | 'zstd' + /** The requested zstd level; present only with `zstd`. */ + level?: number + /** The zstd level Pithos applies in an encrypted bucket; absent when not reported. */ + effective_level?: number +} + +export function getBucketCompression( + bucket: string, + client: ApiClientOptions, + signal?: AbortSignal, +): Promise { + return apiRequest(`/data/buckets/${encodeURIComponent(bucket)}/storage/compression`, { signal }, client) +} diff --git a/src/lib/api/bucketEncryption.test.ts b/src/lib/api/bucketEncryption.test.ts new file mode 100644 index 00000000..a50f7504 --- /dev/null +++ b/src/lib/api/bucketEncryption.test.ts @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ApiError } from './client' +import { + bucketLocked, + extendUnlock, + getMyCopies, + lockBucket, + removeBucketHolder, + unlockBucket, +} from './bucketEncryption' + +const NODE = { baseUrl: 'https://node-b.test/api/v1', token: 'bearer-1' } + +interface Call { + url: URL + method: string + contentType: string | null + body: unknown +} + +function stubFetch(payload: unknown, status = 200, code?: string) { + const calls: Call[] = [] + vi.stubGlobal('window', { location: { origin: 'https://portal.test' } }) + vi.stubGlobal('fetch', vi.fn(async (input: URL, init: RequestInit) => { + calls.push({ + url: new URL(String(input)), + method: init.method ?? 'GET', + contentType: new Headers(init.headers).get('Content-Type'), + body: init.body, + }) + if (status === 204) return new Response(null, { status }) + return new Response(JSON.stringify(code ? { error: 'refused', code } : payload), { status }) + })) + return calls +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +const UNLOCKED = { state: 'unlocked', session_id: 'S1', deadline_ms: 2_000 } + +describe('bucket encryption client', () => { + it('sends the key as 32 binary bytes and the other fields in the query', async () => { + const calls = stubFetch(UNLOCKED) + const key = new Uint8Array(32).fill(7) + + const status = await unlockBucket('reef', { bucket_id: 'B1', generation: 3, duration_ms: 60_000 }, key, NODE) + + expect(status).toEqual(UNLOCKED) + expect(calls[0].url.origin + calls[0].url.pathname).toBe( + 'https://node-b.test/api/v1/data/buckets/reef/storage/encryption/unlock', + ) + expect(Object.fromEntries(calls[0].url.searchParams)).toEqual({ bucket_id: 'B1', generation: '3', duration_ms: '60000' }) + expect(calls[0].method).toBe('POST') + expect(calls[0].contentType).toBe('application/octet-stream') + expect(calls[0].body).toBe(key) + }) + + it('refuses a key of another length before sending anything', async () => { + const calls = stubFetch(UNLOCKED) + + await expect(unlockBucket('reef', { bucket_id: 'B1', generation: 3 }, new Uint8Array(31), NODE)).rejects.toThrow( + '32 bytes', + ) + expect(calls).toHaveLength(0) + }) + + it('names the session to extend and locks without a body', async () => { + const calls = stubFetch(UNLOCKED) + + await extendUnlock('reef', { session_id: 'S1', duration_ms: 5_000 }, NODE) + await lockBucket('reef', NODE) + + expect(calls[0].url.pathname).toBe('/api/v1/data/buckets/reef/storage/encryption/extend') + expect(JSON.parse(String(calls[0].body))).toEqual({ session_id: 'S1', duration_ms: 5_000 }) + expect(calls[1].url.pathname).toBe('/api/v1/data/buckets/reef/storage/encryption/lock') + expect(calls[1].body).toBeUndefined() + }) + + it('asks for the copies of one generation and removes a holder with the observed revision', async () => { + const calls = stubFetch({ copies: [] }) + + await getMyCopies('reef', 4, NODE) + const removal = stubFetch(null, 204) + await removeBucketHolder('reef', 'U1@realm', 'rev-2', true, NODE) + await removeBucketHolder('reef', 'U1@realm', 'rev-2', false, NODE) + + expect(calls[0].url.searchParams.get('generation')).toBe('4') + expect(removal[0].url.pathname).toBe('/api/v1/data/buckets/reef/storage/encryption/holders/U1%40realm') + expect(Object.fromEntries(removal[0].url.searchParams)).toEqual({ revision: 'rev-2', confirm_recovery: 'true' }) + expect(Object.fromEntries(removal[1].url.searchParams)).toEqual({ revision: 'rev-2' }) + }) + + it('tells a locked bucket from other conflicts', async () => { + stubFetch(null, 409, 'bucket_locked') + const locked = await getMyCopies('reef', 1, NODE).catch((error: unknown) => error) + stubFetch(null, 409, 'stale_generation') + const stale = await getMyCopies('reef', 1, NODE).catch((error: unknown) => error) + + expect(locked).toBeInstanceOf(ApiError) + expect(bucketLocked(locked)).toBe(true) + expect(bucketLocked(stale)).toBe(false) + }) +}) diff --git a/src/lib/api/bucketEncryption.ts b/src/lib/api/bucketEncryption.ts new file mode 100644 index 00000000..e6941ea9 --- /dev/null +++ b/src/lib/api/bucketEncryption.ts @@ -0,0 +1,324 @@ +// ── Bucket encryption (/data/buckets/{bucket}/storage/encryption) ────────── +// Node-local: every request goes to the API base of the node hosting the bucket, +// never through S3. An older node answers 404: "not reported here", never "off". +import { ApiError, apiRequest, type ApiClientOptions } from './client' + +export type EncryptionMode = 'off' | 'node_managed' | 'vault_locked' +export type BlockCipher = 'chacha20_poly1305' | 'aes256_gcm' +export type BlockKeys = 'content_derived' | 'unique' +/** Why a bucket is locked: a holder locked it, its timed unlock ended, or the node restarted. */ +export type LockReason = 'manual' | 'timed' | 'restart' + +export interface BucketUnlockStatus { + state: 'locked' | 'unlocked' + /** Null while unlocked, and for a bucket that has not been locked since it was created. */ + lock_reason: LockReason | null + locked_at_ms: number | null + /** The unlock session an extension must name; null while locked. */ + session_id: string | null + unlocked_at_ms: number | null + /** End of a timed unlock; null means until lock or restart. */ + deadline_ms: number | null + /** The latest end an extension may reach, from the bucket maximum; null without one. */ + max_deadline_ms: number | null +} + +/** Distinct users per holder state for the active key generation. Null counts are unknown. */ +export interface HolderReadiness { + ready: number | null + pending: number | null + /** Holders whose user key the directory lacks or did not return. */ + missing_key: number | null +} + +/** + * `met`: two ready holders, or one whose directory record declares a recovery code. + * `unknown`: the key directory did not answer, so the rule could not be checked. + */ +export interface RecoveryStatus { + state: 'met' | 'degraded' | 'unknown' + ready_holders: number | null + ready_with_recovery: number | null +} + +export type TransitionKind = 'encrypt' | 'decrypt' | 'reencode' | 'rotate' +export type TransitionState = 'running' | 'awaiting_key' | 'cleanup' | 'blocked' | 'finished' + +/** This node's rewrite of stored versions after a mode change, a format change or a rotation. */ +export interface EncryptionTransition { + kind: TransitionKind + state: TransitionState + source_generation: number | null + target_generation: number | null + /** Versions already written in the target form. */ + done: number + /** Versions still to rewrite; null while the node has not counted them. */ + remaining: number | null + failed: number + /** Old copies still to remove; the change is complete only when this reaches zero. */ + cleanup_remaining: number | null + started_at_ms: number + finished_at_ms: number | null + /** Why the work cannot go on, such as a backend that keeps old copies. */ + blocked_reason: string | null +} + +/** What the caller may do on this bucket. */ +export interface EncryptionCaller { + /** Current key holder: creator, group admin or explicit grant. */ + holder: boolean + /** Has a ready sealed copy of the active generation. */ + ready_copy: boolean + /** Group-admin WRITE: settings, holders and rotation. */ + admin: boolean +} + +export interface BucketEncryptionResponse { + bucket: string + mode: EncryptionMode + /** Stable id the bucket keys bind to; null until encryption was first enabled. */ + bucket_id: string | null + storage_generation: number + /** Generation new writes seal to; zero until the first key exists. */ + key_generation: number + /** Standard base64 of the 32-byte X25519 public key of the active generation. */ + public_key: string | null + /** Lowercase hex SHA-256 of the public key. */ + fingerprint: string | null + cipher: BlockCipher + block_keys: BlockKeys + /** Longest unlock a holder may ask for; null means until lock or restart. */ + max_unlock_ms: number | null + /** Null while encryption is off. */ + unlock: BucketUnlockStatus | null + holders: HolderReadiness | null + recovery: RecoveryStatus | null + transition: EncryptionTransition | null + caller: EncryptionCaller +} + +export interface PutBucketEncryptionRequest { + mode: EncryptionMode + cipher?: BlockCipher + block_keys?: BlockKeys + /** Null removes the maximum; absent keeps it. */ + max_unlock_ms?: number | null + /** The key generation the caller saw; another current one is a 409 `stale_generation`. */ + expected_generation: number +} + +/** The non-secret unlock fields. The 32 key bytes travel as the binary body. */ +export interface UnlockBucketRequest { + bucket_id: string + generation: number + /** Absent means the bucket maximum, or until lock or restart without one. */ + duration_ms?: number +} + +export interface ExtendUnlockRequest { + session_id: string + /** Measured from now; absent means the bucket maximum, or until lock or restart. */ + duration_ms?: number +} + +export type HolderOrigin = 'creator' | 'admin' | 'explicit' +/** `missing_key`: no usable user key in the directory. `unavailable`: the directory did not answer. */ +export type HolderState = 'ready' | 'pending' | 'missing_key' | 'unavailable' + +export interface BucketHolderEntry { + user_id: string + name?: string | null + origin: HolderOrigin + state: HolderState + /** From the user's key directory record; null when unknown. */ + has_recovery: boolean | null + granted_by: string | null + granted_at_ms: number | null +} + +export interface BucketHoldersResponse { + holders: BucketHolderEntry[] + recovery: RecoveryStatus + /** Opaque revision of the holder set; a removal names it so a changed set is refused. */ + revision: string +} + +/** A bucket private key sealed with HPKE to one of the caller's user keys. */ +export interface SealedCopyEntry { + bucket_id: string + generation: number + /** The key directory record the copy is sealed to. */ + key_record: string + /** The vault keypair id of that record. */ + key_id: string + /** Standard base64 of the 32-byte encapsulated key. */ + enc: string + /** Standard base64 of the AES-256-GCM ciphertext. */ + ciphertext: string + created_at_ms: number +} + +export interface SealedCopiesResponse { + copies: SealedCopyEntry[] +} + +export interface BucketAuditEvent { + event_id: string + at_ms: number + /** unlock, extend, lock, timed_lock, restart_lock, mode_change, holder_grant, holder_remove, rotate. */ + action: string + /** Null for the node itself, such as a timed or restart lock. */ + actor_user_id: string | null + node_id: string + generation: number | null + deadline_ms: number | null + reason: string | null + /** applied, intent (no outcome recorded) or failed. */ + outcome: string +} + +export interface BucketAuditResponse { + events: BucketAuditEvent[] + /** Opaque; omitted on the last page. */ + next_cursor?: string +} + +export const BUCKET_KEY_BYTES = 32 + +/** Machine-readable codes of the encryption routes. */ +export const ENCRYPTION_CODES = { + locked: 'bucket_locked', + wrongKey: 'wrong_key', + invalidDuration: 'invalid_duration', + staleGeneration: 'stale_generation', + openUploads: 'open_uploads', + sessionMismatch: 'session_mismatch', + staleHolders: 'stale_holders', + confirmRecovery: 'recovery_confirmation_required', + recoveryUnmet: 'recovery_unmet', + capacity: 'unlock_capacity', +} as const + +function base(bucket: string): string { + return `/data/buckets/${encodeURIComponent(bucket)}/storage/encryption` +} + +/** True when the node refused because the bucket is locked. */ +export function bucketLocked(error: unknown): boolean { + return encryptionRefusal(error, 409, ENCRYPTION_CODES.locked) +} + +export function encryptionRefusal(error: unknown, status: number, code: string): boolean { + return error instanceof ApiError && error.status === status && error.code === code +} + +export function getBucketEncryption( + bucket: string, + client: ApiClientOptions, + signal?: AbortSignal, +): Promise { + return apiRequest(base(bucket), { signal }, client) +} + +export function putBucketEncryption( + bucket: string, + request: PutBucketEncryptionRequest, + client: ApiClientOptions, +): Promise { + return apiRequest(base(bucket), { method: 'PUT', body: JSON.stringify(request) }, client) +} + +/** Sends exactly the 32 key bytes as the body; the caller clears its buffer afterwards. */ +export function unlockBucket( + bucket: string, + request: UnlockBucketRequest, + key: Uint8Array, + client: ApiClientOptions, +): Promise { + if (key.length !== BUCKET_KEY_BYTES) { + return Promise.reject(new Error('A bucket key has exactly 32 bytes.')) + } + return apiRequest( + `${base(bucket)}/unlock`, + { + method: 'POST', + body: key, + headers: { 'Content-Type': 'application/octet-stream' }, + query: { ...request }, + }, + client, + ) +} + +export function extendUnlock( + bucket: string, + request: ExtendUnlockRequest, + client: ApiClientOptions, +): Promise { + return apiRequest(`${base(bucket)}/extend`, { method: 'POST', body: JSON.stringify(request) }, client) +} + +export function lockBucket(bucket: string, client: ApiClientOptions): Promise { + return apiRequest(`${base(bucket)}/lock`, { method: 'POST' }, client) +} + +export function listBucketHolders( + bucket: string, + client: ApiClientOptions, + signal?: AbortSignal, +): Promise { + return apiRequest(`${base(bucket)}/holders`, { signal }, client) +} + +export function grantBucketHolder( + bucket: string, + userId: string, + client: ApiClientOptions, +): Promise { + return apiRequest(`${base(bucket)}/holders`, { method: 'POST', body: JSON.stringify({ user_id: userId }) }, client) +} + +/** `confirmRecovery` accepts that the removal breaks the recovery rule. */ +export function removeBucketHolder( + bucket: string, + userId: string, + revision: string, + confirmRecovery: boolean, + client: ApiClientOptions, +): Promise { + return apiRequest( + `${base(bucket)}/holders/${encodeURIComponent(userId)}`, + { method: 'DELETE', query: { revision, confirm_recovery: confirmRecovery || undefined } }, + client, + ) +} + +export function getMyCopies( + bucket: string, + generation: number, + client: ApiClientOptions, + signal?: AbortSignal, +): Promise { + return apiRequest(`${base(bucket)}/copies/me`, { signal, query: { generation } }, client) +} + +export function rotateBucketKey( + bucket: string, + expectedGeneration: number, + client: ApiClientOptions, +): Promise { + return apiRequest( + `${base(bucket)}/rotate`, + { method: 'POST', body: JSON.stringify({ expected_generation: expectedGeneration }) }, + client, + ) +} + +export function getBucketAudit( + bucket: string, + client: ApiClientOptions, + page: { limit?: number; cursor?: string } = {}, + signal?: AbortSignal, +): Promise { + return apiRequest(`${base(bucket)}/audit`, { signal, query: { ...page } }, client) +} From 4a571aa1077d1357c93a4656fee4266312bdcf3f Mon Sep 17 00:00:00 2001 From: Sebastian Beyvers Date: Sun, 4 Oct 2026 12:02:59 +0200 Subject: [PATCH 02/86] feat: await the vault key check and require twelve characters --- .../settings/VaultCreateForm.test.ts | 5 +- src/components/settings/VaultCreateForm.vue | 9 +-- src/components/settings/VaultSettings.test.ts | 14 ++-- src/components/settings/VaultSettings.vue | 6 +- src/composables/useUserVault.test.ts | 67 +++++++++++++++---- src/composables/useUserVault.ts | 33 ++++++++- 6 files changed, 104 insertions(+), 30 deletions(-) diff --git a/src/components/settings/VaultCreateForm.test.ts b/src/components/settings/VaultCreateForm.test.ts index dec2f68c..4258811e 100644 --- a/src/components/settings/VaultCreateForm.test.ts +++ b/src/components/settings/VaultCreateForm.test.ts @@ -43,7 +43,7 @@ const VaultCreateForm = compileClientComponent(new URL('./VaultCreateForm.vue', '@/components/ui/Input.vue': moduleDefault(InputStub), '@/components/ui/Notice.vue': moduleDefault(NoticeStub), '@/composables/useUserVault': { useUserVault: () => ({ create }) }, - '@/lib/vault/crypto': { MIN_PASSPHRASE_LENGTH: 8 }, + '@/lib/vault/crypto': { MIN_KEY_HOLDER_PASSPHRASE_LENGTH: 12 }, '@/lib/utils': { errorMessage }, }) @@ -70,6 +70,9 @@ describe('VaultCreateForm', () => { await fill(root, 'short', 'short') expect(button(root, 'Create').props.disabled).toBe(true) + await fill(root, 'eleven char') + expect(button(root, 'Create').props.disabled).toBe(true) + await fill(root, 'correct horse', 'correct house') expect(button(root, 'Create').props.disabled).toBe(true) diff --git a/src/components/settings/VaultCreateForm.vue b/src/components/settings/VaultCreateForm.vue index ae387934..a7d30c21 100644 --- a/src/components/settings/VaultCreateForm.vue +++ b/src/components/settings/VaultCreateForm.vue @@ -7,7 +7,7 @@ import CopyButton from '@/components/ui/CopyButton.vue' import Input from '@/components/ui/Input.vue' import Notice from '@/components/ui/Notice.vue' import { useUserVault } from '@/composables/useUserVault' -import { MIN_PASSPHRASE_LENGTH } from '@/lib/vault/crypto' +import { MIN_KEY_HOLDER_PASSPHRASE_LENGTH } from '@/lib/vault/crypto' import { errorMessage } from '@/lib/utils' const emit = defineEmits<{ (e: 'done'): void }>() @@ -20,10 +20,10 @@ const busy = ref(false) const failure = ref(null) const recoveryCode = ref(null) -const tooShort = computed(() => passphrase.value.length > 0 && passphrase.value.length < MIN_PASSPHRASE_LENGTH) +const tooShort = computed(() => passphrase.value.length > 0 && passphrase.value.length < MIN_KEY_HOLDER_PASSPHRASE_LENGTH) const mismatch = computed(() => repeat.value.length > 0 && repeat.value !== passphrase.value) const canCreate = computed(() => - passphrase.value.length >= MIN_PASSPHRASE_LENGTH && repeat.value === passphrase.value && !busy.value) + passphrase.value.length >= MIN_KEY_HOLDER_PASSPHRASE_LENGTH && repeat.value === passphrase.value && !busy.value) async function submit() { if (!canCreate.value) return @@ -66,7 +66,7 @@ async function submit() { type="password" class="mt-1.5" autocomplete="new-password" - :placeholder="`At least ${MIN_PASSPHRASE_LENGTH} characters`" + :placeholder="`At least ${MIN_KEY_HOLDER_PASSPHRASE_LENGTH} characters`" :invalid="tooShort ? 'error' : undefined" @keydown.enter.prevent="submit" /> @@ -91,6 +91,7 @@ async function submit() {

The passphrase never leaves this browser. Without it, or the recovery code, the keys on the node cannot be opened again. + It needs {{ MIN_KEY_HOLDER_PASSPHRASE_LENGTH }} characters because the same vault opens encrypted buckets.

{{ failure }}
diff --git a/src/components/settings/VaultSettings.test.ts b/src/components/settings/VaultSettings.test.ts index 0e19cb5f..af16876d 100644 --- a/src/components/settings/VaultSettings.test.ts +++ b/src/components/settings/VaultSettings.test.ts @@ -57,7 +57,7 @@ const VaultSettings = compileClientComponent(new URL('./VaultSettings.vue', impo './VaultUnlockForm.vue': moduleDefault(UnlockStub), './VaultCreateForm.vue': moduleDefault(CreateStub), '@/composables/useUserVault': { useUserVault: () => ({ state, error, lock, changePassphrase, reset }) }, - '@/lib/vault/crypto': { MIN_PASSPHRASE_LENGTH: 8 }, + '@/lib/vault/crypto': { MIN_KEY_HOLDER_PASSPHRASE_LENGTH: 12 }, '@/lib/utils': { errorMessage }, }) @@ -118,12 +118,12 @@ describe('VaultSettings', () => { const box = dialog(root) await typeValue(field(box, 'vault-current'), 'old horse') - await typeValue(field(box, 'vault-next'), 'new horse!') + await typeValue(field(box, 'vault-next'), 'new horse battery') expect(button(box, 'Change passphrase').props.disabled).toBe(true) - await typeValue(field(box, 'vault-next-repeat'), 'new horse!') + await typeValue(field(box, 'vault-next-repeat'), 'new horse battery') await click(button(box, 'Change passphrase')) - expect(changePassphrase).toHaveBeenCalledWith({ passphrase: 'old horse' }, 'new horse!') + expect(changePassphrase).toHaveBeenCalledWith({ passphrase: 'old horse' }, 'new horse battery') expect(() => dialog(root)).toThrow() }) @@ -133,11 +133,11 @@ describe('VaultSettings', () => { await click(button(dialog(root), 'Use the recovery code')) await typeValue(field(dialog(root), 'vault-current'), 'ABCD-EFGH') - await typeValue(field(dialog(root), 'vault-next'), 'new horse!') - await typeValue(field(dialog(root), 'vault-next-repeat'), 'new horse!') + await typeValue(field(dialog(root), 'vault-next'), 'new horse battery') + await typeValue(field(dialog(root), 'vault-next-repeat'), 'new horse battery') await click(button(dialog(root), 'Change passphrase')) - expect(changePassphrase).toHaveBeenCalledWith({ recoveryCode: 'ABCD-EFGH' }, 'new horse!') + expect(changePassphrase).toHaveBeenCalledWith({ recoveryCode: 'ABCD-EFGH' }, 'new horse battery') }) it('names what a reset loses before deleting the keys', async () => { diff --git a/src/components/settings/VaultSettings.vue b/src/components/settings/VaultSettings.vue index 8a4300ac..485e3125 100644 --- a/src/components/settings/VaultSettings.vue +++ b/src/components/settings/VaultSettings.vue @@ -15,7 +15,7 @@ import Notice from '@/components/ui/Notice.vue' import VaultCreateForm from './VaultCreateForm.vue' import VaultUnlockForm from './VaultUnlockForm.vue' import { useUserVault } from '@/composables/useUserVault' -import { MIN_PASSPHRASE_LENGTH } from '@/lib/vault/crypto' +import { MIN_KEY_HOLDER_PASSPHRASE_LENGTH } from '@/lib/vault/crypto' import { errorMessage } from '@/lib/utils' const { state, error, lock, changePassphrase, reset } = useUserVault() @@ -31,7 +31,7 @@ const failure = ref(null) const canChange = computed(() => current.value.trim().length > 0 - && next.value.length >= MIN_PASSPHRASE_LENGTH + && next.value.length >= MIN_KEY_HOLDER_PASSPHRASE_LENGTH && repeat.value === next.value && !busy.value) @@ -155,7 +155,7 @@ async function confirmReset() { type="password" class="mt-1.5" autocomplete="new-password" - :placeholder="`At least ${MIN_PASSPHRASE_LENGTH} characters`" + :placeholder="`At least ${MIN_KEY_HOLDER_PASSPHRASE_LENGTH} characters`" />
diff --git a/src/composables/useUserVault.test.ts b/src/composables/useUserVault.test.ts index c94baa4d..dbc6f6fe 100644 --- a/src/composables/useUserVault.test.ts +++ b/src/composables/useUserVault.test.ts @@ -244,14 +244,14 @@ describe('useUserVault', () => { // while this one still saved on the old vault. const other = await boot() await other.vault.reset() - await other.vault.create('new horse', false) + await other.vault.create('new horse battery', false) await vault.saveProviders([work]) expect(node.heads).toHaveLength(2) expect(vault.providers.value).toEqual([work]) const again = await boot() again.vault.lock() - await again.vault.unlock('new horse') + await again.vault.unlock('new horse battery') expect(again.vault.providers.value).toEqual([]) expect(node.heads).toHaveLength(2) }) @@ -269,7 +269,7 @@ describe('useUserVault', () => { it('opens the keys with the recovery code and sets a new passphrase from it', async () => { const { vault } = await boot() - const code = await vault.create('forgotten', true) + const code = await vault.create('forgotten passphrase', true) await vault.saveProviders([work]) vault.lock() @@ -277,24 +277,24 @@ describe('useUserVault', () => { await vault.unlockWithRecovery(code ?? '') expect(vault.providers.value).toEqual([work]) - await vault.changePassphrase({ recoveryCode: code ?? '' }, 'remembered') + await vault.changePassphrase({ recoveryCode: code ?? '' }, 'remembered passphrase') vault.lock() - await vault.unlock('remembered') + await vault.unlock('remembered passphrase') expect(vault.state.value).toBe('unlocked') - expect(await nodeProviders('remembered')).toEqual([work]) + expect(await nodeProviders('remembered passphrase')).toEqual([work]) }) it('changes the passphrase and refuses the old one', async () => { const { vault } = await boot() - await vault.create('first', false) + await vault.create('first passphrase', false) - await expect(vault.changePassphrase({ passphrase: 'wrong' }, 'second')).rejects.toThrow('Wrong passphrase.') - await vault.changePassphrase({ passphrase: 'first' }, 'second') + await expect(vault.changePassphrase({ passphrase: 'wrong' }, 'second passphrase')).rejects.toThrow('Wrong passphrase.') + await vault.changePassphrase({ passphrase: 'first passphrase' }, 'second passphrase') const restarted = await boot() restarted.vault.lock() - await expect(restarted.vault.unlock('first')).rejects.toThrow('Wrong passphrase.') - await restarted.vault.unlock('second') + await expect(restarted.vault.unlock('first passphrase')).rejects.toThrow('Wrong passphrase.') + await restarted.vault.unlock('second passphrase') expect(restarted.vault.state.value).toBe('unlocked') }) @@ -317,11 +317,11 @@ describe('useUserVault', () => { await vault.saveProviders([work]) await vault.reset() - await vault.create('new horse', false) + await vault.create('new horse battery', false) expect(vault.state.value).toBe('unlocked') expect(saveVault.mock.calls.at(-1)?.[0].predecessors).toEqual([]) - expect(await nodeProviders('new horse')).toEqual([]) + expect(await nodeProviders('new horse battery')).toEqual([]) }) it('creates a keypair with the vault and publishes it', async () => { @@ -394,6 +394,47 @@ describe('useUserVault', () => { expect(publishUserKey).not.toHaveBeenCalled() }) + it('finishes the directory check before create and unlock resolve', async () => { + const { vault } = await boot() + await vault.create('correct horse', true) + expect(vault.ownKey.value).toBe('matches') + expect(publishUserKey).toHaveBeenCalledTimes(1) + + vault.lock() + directory.length = 0 + await vault.unlock('correct horse') + + expect(vault.ownKey.value).toBe('matches') + expect(publishUserKey).toHaveBeenCalledTimes(2) + }) + + it('checks the directory on request and publishes a missing key once', async () => { + const { vault } = await boot() + expect(await vault.checkKey()).toBe('unknown') + await vault.create('correct horse', false) + directory.length = 0 + listUserKeys.mockClear() + + const answers = await Promise.all([vault.checkKey(), vault.checkKey()]) + + expect(answers).toEqual(['matches', 'matches']) + expect(listUserKeys).toHaveBeenCalledTimes(1) + expect(publishUserKey).toHaveBeenCalledTimes(2) + expect(directory).toHaveLength(1) + }) + + it('refuses a holder passphrase shorter than twelve characters', async () => { + const { vault } = await boot() + await expect(vault.create('eleven char', false)).rejects.toThrow('at least 12 characters') + expect(saveVault).not.toHaveBeenCalled() + + await vault.create('twelve chars', false) + await expect(vault.changePassphrase({ passphrase: 'twelve chars' }, 'too short')).rejects.toThrow( + 'at least 12 characters', + ) + expect(saveVault).toHaveBeenCalledTimes(1) + }) + it('opens the cached vault when no holder answers', async () => { const { vault } = await boot() await vault.create('correct horse', false) diff --git a/src/composables/useUserVault.ts b/src/composables/useUserVault.ts index 59d39293..3ab6a50f 100644 --- a/src/composables/useUserVault.ts +++ b/src/composables/useUserVault.ts @@ -19,6 +19,7 @@ import { import { validateBrowserProvider, type BrowserProvider } from '@/lib/assistant/browserProviders' import { assistantChatScopeKey } from '@/lib/assistant/chatHistory' import { + MIN_KEY_HOLDER_PASSPHRASE_LENGTH, VaultUnlockError, activeKeypair, changePassphrase as rewrapMaster, @@ -26,6 +27,7 @@ import { openData, openKeypair, parseVaultPayload, + passphraseLongEnough, rotateKeypair, sealData, unlockVault, @@ -81,6 +83,7 @@ let scopeKey = '' let generation = 0 let requested = false let inFlight: Promise | null = null +let keyCheck: Promise | null = null function client() { return { baseUrl: apiBaseUrl.value, token: authToken.value } @@ -168,6 +171,7 @@ async function openProviders(current: VaultPayload, key: CryptoKey): Promise { + const check = keyCheck ?? checkOwnKey(run).finally(() => { + if (keyCheck === check) keyCheck = null + }) + keyCheck = check + return check +} + /** Adds a keypair when the vault has none, saves it with any merge, then checks the directory. */ async function finishUnlock(run: number, saveDue: boolean) { if (payload && masterKey && !activeKeypair(payload.keys)) { @@ -328,7 +341,7 @@ async function finishUnlock(run: number, saveDue: boolean) { saveDue = true } if (saveDue) await saveMerge(run) - if (run === generation) void checkOwnKey(run) + if (run === generation) await checkOnce(run) } /** Saves a merge once; when that fails the next save carries the merge. */ @@ -446,6 +459,7 @@ export function reapplyChange( function lock() { const scope = scopeKey + keyCheck = null masterKey = null providers.value = [] ownKey.value = 'unknown' @@ -453,6 +467,12 @@ function lock() { forgetKey(scope) } +function requireHolderLength(passphrase: string) { + if (!passphraseLongEnough(passphrase, true)) { + throw new Error(`The passphrase needs at least ${MIN_KEY_HOLDER_PASSPHRASE_LENGTH} characters.`) + } +} + async function create(passphrase: string, withRecovery: boolean): Promise { const scope = requireScope() if (state.value === 'unsupported') throw new Error('This node cannot keep provider keys.') @@ -461,6 +481,7 @@ async function create(passphrase: string, withRecovery: boolean): Promise { /** The current passphrase or the recovery code proves the change; the keys stay unlocked. */ async function changePassphrase(secret: VaultSecret, newPassphrase: string): Promise { + requireHolderLength(newPassphrase) requirePayload() await saveWithRetry((current) => rewrapMaster(current, secret, newPassphrase)) } @@ -516,7 +538,13 @@ async function rotateKey(): Promise { const { key } = requireUnlocked() const run = generation await saveWithRetry(async (current) => ({ ...current, keys: await rotateKeypair(key, current.keys) })) - if (run === generation) void checkOwnKey(run) + if (run === generation) await checkOnce(run) +} + +/** Checks the key directory again and waits for the answer and any publication. */ +async function checkKey(): Promise { + if (state.value === 'unlocked') await checkOnce(generation) + return ownKey.value } async function reset(): Promise { @@ -582,6 +610,7 @@ export function useUserVault() { reset, saveProviders, rotateKey, + checkKey, dismissRecreateNotice, } } From 8a7eee0891199208a68341f8e2cf0bc098e2203d Mon Sep 17 00:00:00 2001 From: Sebastian Beyvers Date: Sun, 4 Oct 2026 12:05:12 +0200 Subject: [PATCH 03/86] feat: open sealed bucket key copies with shared test vectors --- src/lib/vault/__fixtures__/bucket-copy.json | 24 ++++ src/lib/vault/bucketCopy.test.ts | 97 ++++++++++++++ src/lib/vault/bucketCopy.ts | 133 ++++++++++++++++++++ 3 files changed, 254 insertions(+) create mode 100644 src/lib/vault/__fixtures__/bucket-copy.json create mode 100644 src/lib/vault/bucketCopy.test.ts create mode 100644 src/lib/vault/bucketCopy.ts diff --git a/src/lib/vault/__fixtures__/bucket-copy.json b/src/lib/vault/__fixtures__/bucket-copy.json new file mode 100644 index 00000000..a1b18bd5 --- /dev/null +++ b/src/lib/vault/__fixtures__/bucket-copy.json @@ -0,0 +1,24 @@ +{ + "description": "Shared vector for a bucket private key sealed to a user key. Portal src/lib/vault/bucketCopy.ts and Aruna core copy_info must agree on every field.", + "suite": "DHKEM(X25519, HKDF-SHA256), HKDF-SHA256, AES-256-GCM, base mode", + "info_layout": "purpose (UTF-8) | 0x00 | realm id (32) | node id (32) | bucket id ULID (16) | generation u64 big-endian (8) | user storage key: realm id (32) then user ULID (16) | key record ULID (16)", + "purpose": "aruna bucket key copy v1", + "realm_id": "EBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8", + "node_id": "8139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b394", + "bucket_id": "01JB2S7YQ4M8N3V6K9R1T5W0XZ", + "generation": 258, + "user_id": "01J9ZK4D6W3P8E2H5Q7R9S1T0V@EBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8", + "key_record": "01JB2S8A0C3E5G7J9M1P3R5T7V", + "key_id": "01J0000000000000000000KEY1", + "info": "6172756e61206275636b6574206b657920636f707920763100101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f8139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b3940192c593fae4a22a3d9a69c0745e03bf0000000000000102101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f01927f3234dc1d90e144b73e1390e81b0192c594280c1b8b03c9340d8782e8fb", + "aad": "", + "recipient_private": "3ec17a33432faf84bad4286c70904c097240368770f9784400d60bb28c2bc9b1", + "recipient_public": "41b588dc8fe632bcd1f31b336c57266175d2e12664bf8c9d271dd163ea013c2d", + "ephemeral_ikm": "875b208b906da78d7a10ca41ff17f2538797fbac562c29a95b0f85ee1b9b3122", + "ephemeral_private": "65202c769608eaa942b376fc2047e4d6825332c90a35b6eacc00711daff6493f", + "enc": "aee9c0672c2023f98d84568431ede938686511600b90996e64a193d7e10efb75", + "bucket_private": "ba1dc83021b688eb3f2a80cb102f1377bd411f19189c31c8f2447131c55345f5", + "bucket_public": "ec3995a8c576b0ae8c7aabfdd0701cd00ecdcbd38d833053dc3c5ad752c72153", + "bucket_fingerprint": "0e98b4503e1dfd284db1c3c6d34335c3e721f329b6a6b8f30af436be01cdd330", + "ciphertext": "4f5ccad33e0fe68941f58ea0af38016bb08aa59aacc2da2762374cc303c4ec70e4823b7b3f1b43e446a0ca8fdc4e4f8c" +} diff --git a/src/lib/vault/bucketCopy.test.ts b/src/lib/vault/bucketCopy.test.ts new file mode 100644 index 00000000..8480af44 --- /dev/null +++ b/src/lib/vault/bucketCopy.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' +import vector from './__fixtures__/bucket-copy.json' +import vault from './__fixtures__/vault.json' +import { + BucketKeyMismatchError, + COPY_PURPOSE, + CopyContextError, + copyInfo, + openBucketCopy, + type CopyContext, +} from './bucketCopy' +import { openKeypair, parseVaultPayload, unlockVault } from './crypto' +import { SealOpenError, importPrivateKey, sealTo } from './hpke' + +function hex(text: string): Uint8Array { + return Uint8Array.from(text.match(/../g) ?? [], (pair) => parseInt(pair, 16)) +} + +function toHex(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') +} + +const context: CopyContext = { + realmId: vector.realm_id, + nodeId: vector.node_id, + bucketId: vector.bucket_id, + generation: vector.generation, + userId: vector.user_id, + keyRecord: vector.key_record, +} +const sealed = { enc: hex(vector.enc), ciphertext: hex(vector.ciphertext) } +const bucketPublic = hex(vector.bucket_public) + +describe('bucket key copies', () => { + it('encodes the copy context exactly as the shared vector', () => { + expect(vector.purpose).toBe(COPY_PURPOSE) + expect(toHex(copyInfo(context))).toBe(vector.info) + }) + + it('reproduces the vector seal with the fixed ephemeral key', async () => { + const ephemeral = await importPrivateKey(hex(vector.ephemeral_private)) + + const again = await sealTo(hex(vector.recipient_public), copyInfo(context), new Uint8Array(0), hex(vector.bucket_private), ephemeral) + + expect(toHex(again.enc)).toBe(vector.enc) + expect(toHex(again.ciphertext)).toBe(vector.ciphertext) + }) + + it('opens the copy with the vault keypair the copy names', async () => { + const payload = parseVaultPayload(vault.vault.payload) + const master = await unlockVault(payload, vault.vault.passphrase) + const entry = payload.keys.find((key) => key.id === vector.key_id) + const recipient = await openKeypair(master, entry!) + + const key = await openBucketCopy(recipient, sealed, context, bucketPublic) + + expect(toHex(key)).toBe(vector.bucket_private) + }) + + it('refuses a copy opened under any other context', async () => { + const recipient = await importPrivateKey(hex(vector.recipient_private)) + const otherRealm = 'AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE' + const changed: CopyContext[] = [ + { ...context, realmId: otherRealm }, + { ...context, nodeId: vector.node_id.replace(/^81/, '82') }, + { ...context, bucketId: '01JB2S7YQ4M8N3V6K9R1T5W0Y0' }, + { ...context, generation: vector.generation + 1 }, + { ...context, userId: vector.user_id.replace(/^01J9/, '01JA') }, + { ...context, userId: `${vector.user_id.split('@')[0]}@${otherRealm}` }, + { ...context, keyRecord: '01JB2S8A0C3E5G7J9M1P3R5T7W' }, + ] + + for (const other of changed) { + await expect(openBucketCopy(recipient, sealed, other, bucketPublic)).rejects.toThrow(SealOpenError) + } + const tampered = { ...sealed, ciphertext: sealed.ciphertext.slice() } + tampered.ciphertext[0] ^= 1 + await expect(openBucketCopy(recipient, tampered, context, bucketPublic)).rejects.toThrow(SealOpenError) + }) + + it('refuses a copy that holds another bucket key', async () => { + const recipient = await importPrivateKey(hex(vector.recipient_private)) + const otherPublic = bucketPublic.slice() + otherPublic[0] ^= 1 + + await expect(openBucketCopy(recipient, sealed, context, otherPublic)).rejects.toThrow(BucketKeyMismatchError) + }) + + it('refuses ids that are not in the node form', () => { + expect(() => copyInfo({ ...context, realmId: 'not base64url!' })).toThrow(CopyContextError) + expect(() => copyInfo({ ...context, nodeId: vector.node_id.toUpperCase() })).toThrow(CopyContextError) + expect(() => copyInfo({ ...context, bucketId: '01JB2S7YQ4M8N3V6K9R1T5W0XU' })).toThrow(CopyContextError) + expect(() => copyInfo({ ...context, bucketId: '81JB2S7YQ4M8N3V6K9R1T5W0XZ' })).toThrow(CopyContextError) + expect(() => copyInfo({ ...context, generation: -1 })).toThrow(CopyContextError) + expect(() => copyInfo({ ...context, userId: vector.user_id.split('@')[0] })).toThrow(CopyContextError) + }) +}) diff --git a/src/lib/vault/bucketCopy.ts b/src/lib/vault/bucketCopy.ts new file mode 100644 index 00000000..2af1cf9c --- /dev/null +++ b/src/lib/vault/bucketCopy.ts @@ -0,0 +1,133 @@ +// A bucket private key sealed with HPKE to one user key. The info binds the copy +// to its realm, node, bucket, key generation, user and user key record, byte for +// byte as Aruna's `copy_info`; the associated data is empty. +import { fromBase64Url, importPrivateKey, openSealed, type SealedSecret, type X25519Pair } from './hpke' + +export const COPY_PURPOSE = 'aruna bucket key copy v1' +const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ' +const ID_BYTES = 32 +const ULID_BYTES = 16 +const KEY_BYTES = 32 + +export interface CopyContext { + /** Unpadded base64url of the 32-byte realm id. */ + realmId: string + /** Lowercase hex of the 32-byte node id. */ + nodeId: string + bucketId: string + generation: number + /** `@`, as the node prints a user id. */ + userId: string + /** The key directory record the copy is sealed to. */ + keyRecord: string +} + +/** An id in the context is not in the form the node uses. */ +export class CopyContextError extends Error { + constructor(part: string) { + super(`The ${part} of the bucket key copy is not readable.`) + this.name = 'CopyContextError' + } +} + +/** The copy opened, but the key in it is not this bucket's key. */ +export class BucketKeyMismatchError extends Error { + constructor() { + super('The opened key does not belong to this bucket.') + this.name = 'BucketKeyMismatchError' + } +} + +function ulidBytes(text: string, part: string): Uint8Array { + if (text.length !== 26) throw new CopyContextError(part) + let value = 0n + for (const char of text.toUpperCase()) { + const digit = CROCKFORD.indexOf(char) + if (digit < 0) throw new CopyContextError(part) + value = (value << 5n) | BigInt(digit) + } + if (value >> 128n) throw new CopyContextError(part) + const bytes = new Uint8Array(ULID_BYTES) + for (let at = ULID_BYTES - 1; at >= 0; at -= 1) { + bytes[at] = Number(value & 255n) + value >>= 8n + } + return bytes +} + +function realmBytes(text: string): Uint8Array { + let bytes: Uint8Array + try { + bytes = fromBase64Url(text) + } catch { + throw new CopyContextError('realm id') + } + if (bytes.length !== ID_BYTES || /[^A-Za-z0-9_-]/.test(text)) throw new CopyContextError('realm id') + return bytes +} + +function nodeBytes(text: string): Uint8Array { + if (!/^[0-9a-f]{64}$/.test(text)) throw new CopyContextError('node id') + return Uint8Array.from(text.match(/../g) ?? [], (pair) => parseInt(pair, 16)) +} + +function generationBytes(generation: number): Uint8Array { + if (!Number.isSafeInteger(generation) || generation < 0) throw new CopyContextError('key generation') + const bytes = new Uint8Array(8) + new DataView(bytes.buffer).setBigUint64(0, BigInt(generation)) + return bytes +} + +/** The node's user storage key: realm id, then the user ULID. */ +function userBytes(userId: string): Uint8Array[] { + const [user, realm, extra] = userId.split('@') + if (!user || !realm || extra !== undefined) throw new CopyContextError('user id') + return [realmBytes(realm), ulidBytes(user, 'user id')] +} + +/** The HPKE info of a copy: purpose label, a zero byte, then every id at its fixed length. */ +export function copyInfo(context: CopyContext): Uint8Array { + const parts = [ + new TextEncoder().encode(COPY_PURPOSE), + Uint8Array.of(0), + realmBytes(context.realmId), + nodeBytes(context.nodeId), + ulidBytes(context.bucketId, 'bucket id'), + generationBytes(context.generation), + ...userBytes(context.userId), + ulidBytes(context.keyRecord, 'key record'), + ] + const info = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0)) + let at = 0 + for (const part of parts) { + info.set(part, at) + at += part.length + } + return info +} + +function sameBytes(a: Uint8Array, b: Uint8Array): boolean { + return a.length === b.length && a.every((byte, index) => byte === b[index]) +} + +/** + * Opens a copy and checks that the key in it belongs to `bucketPublic`. The + * caller owns the returned raw key and must clear it. + */ +export async function openBucketCopy( + recipient: X25519Pair, + sealed: SealedSecret, + context: CopyContext, + bucketPublic: Uint8Array, +): Promise> { + const plain = await openSealed(recipient, sealed, copyInfo(context), new Uint8Array(0)) + try { + if (plain.length !== KEY_BYTES) throw new BucketKeyMismatchError() + const { publicKey } = await importPrivateKey(plain) + if (!sameBytes(publicKey, bucketPublic)) throw new BucketKeyMismatchError() + return plain + } catch (cause) { + plain.fill(0) + throw cause + } +} From f5557213843063cf2a628f2f39d9e9c044b4c98c Mon Sep 17 00:00:00 2001 From: Sebastian Beyvers Date: Sun, 4 Oct 2026 12:07:29 +0200 Subject: [PATCH 04/86] feat: unlock a bucket with the key from a sealed copy --- src/composables/useUserVault.test.ts | 14 +++ src/composables/useUserVault.ts | 9 ++ src/lib/vault/bucketUnlock.test.ts | 155 +++++++++++++++++++++++++++ src/lib/vault/bucketUnlock.ts | 121 +++++++++++++++++++++ src/lib/vault/crypto.ts | 11 +- src/lib/vault/hpke.ts | 12 ++- 6 files changed, 315 insertions(+), 7 deletions(-) create mode 100644 src/lib/vault/bucketUnlock.test.ts create mode 100644 src/lib/vault/bucketUnlock.ts diff --git a/src/composables/useUserVault.test.ts b/src/composables/useUserVault.test.ts index dbc6f6fe..77f663e0 100644 --- a/src/composables/useUserVault.test.ts +++ b/src/composables/useUserVault.test.ts @@ -423,6 +423,20 @@ describe('useUserVault', () => { expect(directory).toHaveLength(1) }) + it('opens a retired keypair by id for an older bucket copy', async () => { + const { vault } = await boot() + await vault.create('correct horse', false) + const first = Crypto.parseVaultPayload(node.heads[0].payload).keys[0] + await vault.rotateKey() + + const pair = await vault.openUserKey(first.id) + + expect(pair && btoa(String.fromCharCode(...pair.publicKey))).toBe(first.public) + expect(await vault.openUserKey('not-in-this-vault')).toBeNull() + vault.lock() + await expect(vault.openUserKey(first.id)).rejects.toThrow('Unlock your provider keys first.') + }) + it('refuses a holder passphrase shorter than twelve characters', async () => { const { vault } = await boot() await expect(vault.create('eleven char', false)).rejects.toThrow('at least 12 characters') diff --git a/src/composables/useUserVault.ts b/src/composables/useUserVault.ts index 3ab6a50f..03a2da6e 100644 --- a/src/composables/useUserVault.ts +++ b/src/composables/useUserVault.ts @@ -35,6 +35,7 @@ import { type VaultPayload, type VaultSecret, } from '@/lib/vault/crypto' +import type { X25519Pair } from '@/lib/vault/hpke' import { browserKeyStore } from '@/lib/vault/keyStore' import { apiBaseUrl, authToken, realmInfo, sessionEpoch, userInfo } from './aruna/state' @@ -547,6 +548,13 @@ async function checkKey(): Promise { return ownKey.value } +/** Opens a keypair of the unlocked vault by id, retired ones included; null when it has none. */ +async function openUserKey(keyId: string): Promise { + const { current, key } = requireUnlocked() + const entry = current.keys.find((candidate) => candidate.id === keyId) + return entry ? openKeypair(key, entry) : null +} + async function reset(): Promise { const scope = scopeKey await deleteVault(client()) @@ -611,6 +619,7 @@ export function useUserVault() { saveProviders, rotateKey, checkKey, + openUserKey, dismissRecreateNotice, } } diff --git a/src/lib/vault/bucketUnlock.test.ts b/src/lib/vault/bucketUnlock.test.ts new file mode 100644 index 00000000..f64cf1c9 --- /dev/null +++ b/src/lib/vault/bucketUnlock.test.ts @@ -0,0 +1,155 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ApiError } from '@/lib/api' +import vector from './__fixtures__/bucket-copy.json' +import { importPrivateKey } from './hpke' +import { NoUsableCopyError, UnlockStaleError, unlockWithVault, type UnlockTarget } from './bucketUnlock' + +function hex(text: string): Uint8Array { + return Uint8Array.from(text.match(/../g) ?? [], (pair) => parseInt(pair, 16)) +} + +function base64(bytes: Uint8Array): string { + return btoa(String.fromCharCode(...bytes)) +} + +const COPY = { + bucket_id: vector.bucket_id, + generation: vector.generation, + key_record: vector.key_record, + key_id: vector.key_id, + enc: base64(hex(vector.enc)), + ciphertext: base64(hex(vector.ciphertext)), + created_at_ms: 10, +} + +const TARGET: UnlockTarget = { + bucket: 'reef', + client: { baseUrl: 'https://node-b.test/api/v1', token: 'bearer-1' }, + context: { + realmId: vector.realm_id, + nodeId: vector.node_id, + bucketId: vector.bucket_id, + generation: vector.generation, + userId: vector.user_id, + }, + publicKey: base64(hex(vector.bucket_public)), + durationMs: 3_600_000, +} + +interface Sent { + url: URL + method: string + body: Uint8Array | undefined + copy: string +} + +function node(copies: unknown[], unlock: () => Response | Promise) { + const sent: Sent[] = [] + vi.stubGlobal('window', { location: { origin: 'https://portal.test' } }) + vi.stubGlobal('fetch', vi.fn(async (input: URL, init: RequestInit) => { + const url = new URL(String(input)) + const body = init.body instanceof Uint8Array ? init.body : undefined + sent.push({ url, method: init.method ?? 'GET', body, copy: body ? Array.from(body, (b) => b.toString(16).padStart(2, '0')).join('') : '' }) + if (url.pathname.endsWith('/copies/me')) return new Response(JSON.stringify({ copies })) + return unlock() + })) + return sent +} + +function vault(keyIds = [vector.key_id]) { + const order: string[] = [] + return { + order, + checkKey: vi.fn(async () => { + order.push('check') + return 'matches' + }), + openUserKey: vi.fn(async (keyId: string) => { + order.push(`open ${keyId}`) + return keyIds.includes(keyId) ? importPrivateKey(hex(vector.recipient_private)) : null + }), + } +} + +const UNLOCKED = () => new Response(JSON.stringify({ state: 'unlocked', session_id: 'S1' })) +const always = () => true + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('bucket unlock from the browser', () => { + it('sends only the opened bucket key to the bucket node and clears it', async () => { + const sent = node([COPY], UNLOCKED) + const keys = vault() + + const outcome = await unlockWithVault(TARGET, keys, always) + + expect(outcome).toEqual({ kind: 'unlocked', status: { state: 'unlocked', session_id: 'S1' }, ownKey: 'matches' }) + expect(keys.order[0]).toBe('check') + const unlock = sent[1] + expect(unlock.url.origin).toBe('https://node-b.test') + expect(unlock.url.pathname).toBe('/api/v1/data/buckets/reef/storage/encryption/unlock') + expect(Object.fromEntries(unlock.url.searchParams)).toEqual({ + bucket_id: vector.bucket_id, + generation: String(vector.generation), + duration_ms: '3600000', + }) + expect(unlock.copy).toBe(vector.bucket_private) + expect(unlock.body?.every((byte) => byte === 0)).toBe(true) + }) + + it('uses the copy whose key this vault holds, an older key included', async () => { + const foreign = { ...COPY, key_id: 'KEY-OTHER-VAULT', created_at_ms: 20 } + const otherBucket = { ...COPY, bucket_id: '01JB2S7YQ4M8N3V6K9R1T5W0Y0', created_at_ms: 30 } + const sent = node([COPY, foreign, otherBucket], UNLOCKED) + const keys = vault() + + expect((await unlockWithVault(TARGET, keys, always)).kind).toBe('unlocked') + expect(keys.order).toEqual(['check', 'open KEY-OTHER-VAULT', `open ${vector.key_id}`]) + expect(sent[0].url.searchParams.get('generation')).toBe(String(vector.generation)) + }) + + it('sends nothing when the bucket changes before the key leaves the browser', async () => { + const sent = node([COPY], UNLOCKED) + let checks = 0 + + await expect(unlockWithVault(TARGET, vault(), () => ++checks < 4)).rejects.toThrow(UnlockStaleError) + + expect(checks).toBe(4) + expect(sent.map((call) => call.method)).toEqual(['GET']) + }) + + it('reports an unknown outcome once and does not send the key again', async () => { + const sent = node([COPY], () => Promise.reject(new TypeError('network down'))) + + const outcome = await unlockWithVault(TARGET, vault(), always) + + expect(outcome).toEqual({ kind: 'unknown', ownKey: 'matches' }) + expect(sent.filter((call) => call.method === 'POST')).toHaveLength(1) + expect(sent[1].body?.every((byte) => byte === 0)).toBe(true) + }) + + it('passes on a refusal from the node and still clears the key', async () => { + const sent = node([COPY], () => new Response(JSON.stringify({ error: 'no', code: 'wrong_key' }), { status: 400 })) + + const failure = await unlockWithVault(TARGET, vault(), always).catch((error: unknown) => error) + + expect(failure).toBeInstanceOf(ApiError) + expect((failure as ApiError).code).toBe('wrong_key') + expect(sent[1].body?.every((byte) => byte === 0)).toBe(true) + }) + + it('explains a missing, foreign or unopenable copy without sending anything', async () => { + node([], UNLOCKED) + await expect(unlockWithVault(TARGET, vault(), always)).rejects.toThrow('no ready copy') + + node([COPY], UNLOCKED) + await expect(unlockWithVault(TARGET, vault([]), always)).rejects.toThrow('this vault does not hold') + + const sent = node([COPY], UNLOCKED) + const wrongBucket = { ...TARGET, publicKey: base64(hex(vector.recipient_public)) } + await expect(unlockWithVault(wrongBucket, vault(), always)).rejects.toThrow(NoUsableCopyError) + expect(sent.filter((call) => call.method === 'POST')).toHaveLength(0) + }) +}) diff --git a/src/lib/vault/bucketUnlock.ts b/src/lib/vault/bucketUnlock.ts new file mode 100644 index 00000000..0f69e1c7 --- /dev/null +++ b/src/lib/vault/bucketUnlock.ts @@ -0,0 +1,121 @@ +// Unlocks an encrypted bucket from this browser: the caller's sealed copy is +// opened with a vault keypair and only the bucket private key goes to the node. +// The raw key lives in one local buffer that is cleared whatever happens. +import { + ApiError, + getMyCopies, + unlockBucket, + type ApiClientOptions, + type BucketUnlockStatus, + type SealedCopyEntry, +} from '@/lib/api' +import { BucketKeyMismatchError, openBucketCopy, type CopyContext } from './bucketCopy' +import { SealOpenError, fromBase64Url, type X25519Pair } from './hpke' + +/** The vault operations the flow needs; `checkKey` waits for the directory check. */ +export interface UnlockVault { + checkKey(): Promise + openUserKey(keyId: string): Promise +} + +export interface UnlockTarget { + bucket: string + /** The API base of the node that hosts the bucket. */ + client: ApiClientOptions + context: Omit + /** Standard base64 of the bucket public key the node reported. */ + publicKey: string + durationMs?: number +} + +export type UnlockOutcome = + | { kind: 'unlocked'; status: BucketUnlockStatus; ownKey: string } + /** The key was sent and no answer came back; read the status before trying again. */ + | { kind: 'unknown'; ownKey: string } + +/** The bucket, node, account or key generation changed, so nothing was sent. */ +export class UnlockStaleError extends Error { + constructor() { + super('The bucket changed while it was being unlocked. Nothing was sent.') + this.name = 'UnlockStaleError' + } +} + +/** No copy of the caller opens with this vault. */ +export class NoUsableCopyError extends Error { + constructor(message: string) { + super(message) + this.name = 'NoUsableCopyError' + } +} + +function bytes(text: string): Uint8Array { + try { + return fromBase64Url(text) + } catch { + throw new NoUsableCopyError('The node returned a copy that is not readable.') + } +} + +async function openFirst( + copies: SealedCopyEntry[], + target: UnlockTarget, + vault: UnlockVault, + current: () => boolean, +): Promise> { + const publicKey = bytes(target.publicKey) + let held = false + for (const copy of copies) { + const pair = await vault.openUserKey(copy.key_id) + if (!current()) throw new UnlockStaleError() + if (!pair) continue + held = true + try { + const sealed = { enc: bytes(copy.enc), ciphertext: bytes(copy.ciphertext) } + return await openBucketCopy(pair, sealed, { ...target.context, keyRecord: copy.key_record }, publicKey) + } catch (cause) { + if (!(cause instanceof SealOpenError || cause instanceof BucketKeyMismatchError)) throw cause + } + } + if (!copies.length) { + throw new NoUsableCopyError('You have no ready copy of this bucket key yet. Another key holder must unlock the bucket first.') + } + throw new NoUsableCopyError( + held + ? 'Your copy of the bucket key does not open. It may belong to another bucket or node.' + : 'Your copy is sealed to a key this vault does not hold.', + ) +} + +/** + * `current` must stay true for the account, session, realm, API base, node, group, + * bucket id, key generation and request that started the unlock. + */ +export async function unlockWithVault( + target: UnlockTarget, + vault: UnlockVault, + current: () => boolean, +): Promise { + const ownKey = await vault.checkKey() + if (!current()) throw new UnlockStaleError() + const { bucketId, generation } = target.context + const response = await getMyCopies(target.bucket, generation, target.client) + if (!current()) throw new UnlockStaleError() + const copies = response.copies + .filter((copy) => copy.bucket_id === bucketId && copy.generation === generation) + .sort((a, b) => b.created_at_ms - a.created_at_ms) + const key = await openFirst(copies, target, vault, current) + try { + if (!current()) throw new UnlockStaleError() + const request = { bucket_id: bucketId, generation, duration_ms: target.durationMs } + const status = await unlockBucket(target.bucket, request, key, target.client) + return { kind: 'unlocked', status, ownKey } + } catch (cause) { + // A gateway error or a lost answer does not say whether the node applied the key. + const answered = cause instanceof ApiError && cause.status !== 502 && cause.status !== 504 + if (answered || cause instanceof UnlockStaleError) throw cause + return { kind: 'unknown', ownKey } + } finally { + key.fill(0) + } +} diff --git a/src/lib/vault/crypto.ts b/src/lib/vault/crypto.ts index 701697cb..62d38894 100644 --- a/src/lib/vault/crypto.ts +++ b/src/lib/vault/crypto.ts @@ -342,9 +342,14 @@ export async function openKeypair(masterKey: CryptoKey, entry: VaultKeyEntry): P } catch { throw new VaultUnlockError('This key does not open the vault.') } - const pair = await importPrivateKey(new Uint8Array(plain)) - if (toBase64(pair.publicKey) !== entry.public) throw new VaultFormatError('The vault key is not readable.') - return pair + const raw = new Uint8Array(plain) + try { + const pair = await importPrivateKey(raw) + if (toBase64(pair.publicKey) !== entry.public) throw new VaultFormatError('The vault key is not readable.') + return pair + } finally { + raw.fill(0) + } } /** Lowercase hex SHA-256 of a raw public key, the fingerprint the key directory stores. */ diff --git a/src/lib/vault/hpke.ts b/src/lib/vault/hpke.ts index 9fee91d0..3badebf4 100644 --- a/src/lib/vault/hpke.ts +++ b/src/lib/vault/hpke.ts @@ -82,10 +82,14 @@ async function labeledExpand( /** Imports a raw X25519 private key and computes its public key. */ export async function importPrivateKey(raw: Uint8Array): Promise { const pkcs8 = concat(PKCS8_PREFIX, raw) - const probe = await crypto.subtle.importKey('pkcs8', pkcs8, X25519, true, ['deriveBits']) - const jwk = await crypto.subtle.exportKey('jwk', probe) - const privateKey = await crypto.subtle.importKey('jwk', jwk, X25519, false, ['deriveBits']) - return { privateKey, publicKey: fromBase64Url(jwk.x ?? '') } + try { + const probe = await crypto.subtle.importKey('pkcs8', pkcs8, X25519, true, ['deriveBits']) + const jwk = await crypto.subtle.exportKey('jwk', probe) + const privateKey = await crypto.subtle.importKey('jwk', jwk, X25519, false, ['deriveBits']) + return { privateKey, publicKey: fromBase64Url(jwk.x ?? '') } + } finally { + pkcs8.fill(0) + } } /** DeriveKeyPair of RFC 9180, which the test vectors use for the ephemeral key. */ From e75b634a04b8ff33106afc056a8d8f0d2af56e24 Mon Sep 17 00:00:00 2001 From: Sebastian Beyvers Date: Sun, 4 Oct 2026 12:09:35 +0200 Subject: [PATCH 05/86] feat: keep bucket encryption state bound to its node and session --- src/composables/useBucketEncryption.test.ts | 174 +++++++++++++++++ src/composables/useBucketEncryption.ts | 196 ++++++++++++++++++++ 2 files changed, 370 insertions(+) create mode 100644 src/composables/useBucketEncryption.test.ts create mode 100644 src/composables/useBucketEncryption.ts diff --git a/src/composables/useBucketEncryption.test.ts b/src/composables/useBucketEncryption.test.ts new file mode 100644 index 00000000..89a0422a --- /dev/null +++ b/src/composables/useBucketEncryption.test.ts @@ -0,0 +1,174 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import * as Api from '@/lib/api' +import { authToken, sessionEpoch, userInfo } from './aruna/state' +import { useBucketEncryption } from './useBucketEncryption' + +const getBucketEncryption = vi.fn() +const getBucketCompression = vi.fn() +const lockBucket = vi.fn() +const unlockWithVault = vi.fn() + +vi.mock('@/lib/api', async (importOriginal) => ({ + ...(await importOriginal()), + getBucketEncryption: (...args: unknown[]) => getBucketEncryption(...args), + getBucketCompression: (...args: unknown[]) => getBucketCompression(...args), + lockBucket: (...args: unknown[]) => lockBucket(...args), +})) +vi.mock('@/lib/vault/bucketUnlock', () => ({ unlockWithVault: (...args: unknown[]) => unlockWithVault(...args) })) +vi.mock('./useUserVault', () => ({ useUserVault: () => ({ name: 'vault' }) })) +vi.mock('./s3/endpoints', () => ({ + localNodeId: () => 'node-a', + nodeApiBase: (id: string) => ({ 'node-a': 'https://a.test/api/v1', 'node-b': 'https://b.test/api/v1' })[id] ?? null, +})) + +function status(overrides: Partial = {}): Api.BucketEncryptionResponse { + return { + bucket: 'reef', + mode: 'vault_locked', + bucket_id: 'B1', + storage_generation: 1, + key_generation: 2, + public_key: 'PK', + fingerprint: 'ff', + cipher: 'chacha20_poly1305', + block_keys: 'content_derived', + max_unlock_ms: null, + unlock: { state: 'locked', lock_reason: 'restart', locked_at_ms: 1, session_id: null, unlocked_at_ms: null, deadline_ms: null, max_deadline_ms: null }, + holders: { ready: 2, pending: 0, missing_key: 0 }, + recovery: { state: 'met', ready_holders: 2, ready_with_recovery: 1 }, + transition: null, + caller: { holder: true, ready_copy: true, admin: false }, + ...overrides, + } +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((yes, no) => { + resolve = yes + reject = no + }) + return { promise, resolve, reject } +} + +function setup(node: string | null = 'node-b') { + const bucket = ref('reef') + const nodeId = ref(node) + const encryption = useBucketEncryption(bucket, nodeId, ref('g-1')) + return { encryption, bucket, nodeId } +} + +beforeEach(() => { + getBucketEncryption.mockReset().mockResolvedValue(status()) + getBucketCompression.mockReset().mockResolvedValue({ bucket: 'reef', mode: 'zstd', level: 3, effective_level: 4 }) + lockBucket.mockReset() + unlockWithVault.mockReset() + authToken.value = 'bearer-1' + userInfo.value = { user: { user_id: 'u-1' }, realm: { realm_id: 'r-1' } } as never +}) + +describe('bucket encryption state', () => { + it('asks the node that hosts the bucket, the connected one only for its own buckets', async () => { + const remote = setup('node-b') + await vi.waitFor(() => expect(remote.encryption.state.value).toBe('ready')) + const local = setup(null) + await vi.waitFor(() => expect(local.encryption.state.value).toBe('ready')) + + expect(getBucketEncryption.mock.calls[0]).toEqual(['reef', { baseUrl: 'https://b.test/api/v1', token: 'bearer-1' }]) + expect(getBucketEncryption.mock.calls[1]).toEqual(['reef', { baseUrl: 'https://a.test/api/v1', token: 'bearer-1' }]) + expect(remote.encryption.compression.value?.effective_level).toBe(4) + }) + + it('drops an answer for a bucket that is no longer shown', async () => { + const first = deferred() + getBucketEncryption.mockReturnValueOnce(first.promise) + const { encryption, bucket } = setup() + + bucket.value = 'other' + await vi.waitFor(() => expect(encryption.state.value).toBe('ready')) + first.resolve(status({ bucket_id: 'STALE' })) + await first.promise + + expect(encryption.status.value?.bucket_id).toBe('B1') + }) + + it('tells a node without the report and a refusal apart from encryption off', async () => { + getBucketEncryption.mockRejectedValueOnce(new Api.ApiError(404, 'Not found')) + const missing = setup() + await vi.waitFor(() => expect(missing.encryption.state.value).toBe('missing')) + getBucketEncryption.mockRejectedValueOnce(new Api.ApiError(403, 'Forbidden')) + const refused = setup() + await vi.waitFor(() => expect(refused.encryption.state.value).toBe('refused')) + + expect(missing.encryption.status.value).toBeNull() + expect(refused.encryption.status.value).toBeNull() + }) + + it('unlocks with the context of the shown key and reads the status before answering', async () => { + const { encryption } = setup() + await vi.waitFor(() => expect(encryption.state.value).toBe('ready')) + unlockWithVault.mockResolvedValue({ kind: 'unlocked', status: { state: 'unlocked' }, ownKey: 'matches' }) + getBucketEncryption.mockResolvedValue(status({ unlock: { ...status().unlock!, state: 'unlocked', session_id: 'S2' } })) + + const outcome = await encryption.unlock(60_000) + + expect(outcome?.kind).toBe('unlocked') + const [target, vault, live] = unlockWithVault.mock.calls[0] + expect(target).toEqual({ + bucket: 'reef', + client: { baseUrl: 'https://b.test/api/v1', token: 'bearer-1' }, + context: { realmId: 'r-1', nodeId: 'node-b', bucketId: 'B1', generation: 2, userId: 'u-1' }, + publicKey: 'PK', + durationMs: 60_000, + }) + expect(vault).toEqual({ name: 'vault' }) + expect(live()).toBe(true) + expect(encryption.status.value?.unlock?.session_id).toBe('S2') + expect(encryption.busy.value).toBeNull() + }) + + it('keeps an unconfirmed unlock blocked until the node reports the status again', async () => { + const { encryption } = setup() + await vi.waitFor(() => expect(encryption.state.value).toBe('ready')) + unlockWithVault.mockResolvedValue({ kind: 'unknown', ownKey: 'matches' }) + getBucketEncryption.mockRejectedValueOnce(new TypeError('network down')) + + await encryption.unlock() + + expect(encryption.outcomeUnknown.value).toBe(true) + await expect(encryption.unlock()).rejects.toThrow('not confirmed') + expect(unlockWithVault).toHaveBeenCalledTimes(1) + await encryption.load() + expect(encryption.outcomeUnknown.value).toBe(false) + }) + + it('forgets an unlock that ends after the session changed', async () => { + const { encryption } = setup() + await vi.waitFor(() => expect(encryption.state.value).toBe('ready')) + const pending = deferred() + unlockWithVault.mockReturnValue(pending.promise) + + const answer = encryption.unlock() + await vi.waitFor(() => expect(unlockWithVault).toHaveBeenCalled()) + const live = unlockWithVault.mock.calls[0][2] as () => boolean + sessionEpoch.value += 1 + pending.resolve({ kind: 'unlocked', status: {}, ownKey: 'matches' }) + + expect(await answer).toBeNull() + expect(live()).toBe(false) + }) + + it('stops the unlock when the bucket key changes under it', async () => { + const { encryption } = setup() + await vi.waitFor(() => expect(encryption.state.value).toBe('ready')) + unlockWithVault.mockImplementation(async (_target, _vault, live: () => boolean) => { + getBucketEncryption.mockResolvedValue(status({ key_generation: 3 })) + await encryption.load() + return { kind: live() ? 'unlocked' : 'stale' } + }) + + expect((await encryption.unlock())?.kind).toBe('stale') + }) +}) diff --git a/src/composables/useBucketEncryption.ts b/src/composables/useBucketEncryption.ts new file mode 100644 index 00000000..5efbb49a --- /dev/null +++ b/src/composables/useBucketEncryption.ts @@ -0,0 +1,196 @@ +// Encryption state of one bucket, asked of the node that hosts it. An answer is +// kept only while the account, session, realm, node API base, group and bucket +// it was asked for are still current; anything else is dropped unseen. +import { computed, ref, watch, type Ref } from 'vue' +import { + ApiError, + apiErrorMessage, + extendUnlock, + getBucketCompression, + getBucketEncryption, + lockBucket, + putBucketEncryption, + rotateBucketKey, + type ApiClientOptions, + type BucketCompressionResponse, + type BucketEncryptionResponse, + type PutBucketEncryptionRequest, +} from '@/lib/api' +import { unlockWithVault, type UnlockOutcome } from '@/lib/vault/bucketUnlock' +import { authToken, nodeInfo, realmInfo, sessionEpoch, userInfo } from './aruna/state' +import { localNodeId, nodeApiBase } from './s3/endpoints' +import { useUserVault } from './useUserVault' + +/** `missing`: the node does not report encryption for this bucket, which is not "off". */ +export type EncryptionLoadState = 'loading' | 'ready' | 'missing' | 'refused' | 'unresolved' | 'failed' +export type EncryptionAction = 'unlock' | 'extend' | 'lock' | 'save' | 'rotate' + +export function useBucketEncryption(bucket: Ref, nodeId: Ref, groupId: Ref) { + const vault = useUserVault() + const status = ref(null) + const compression = ref(null) + const state = ref('loading') + const error = ref(null) + const refreshing = ref(false) + const busy = ref(null) + /** An unlock whose answer never came; only a successful status read clears it. */ + const outcomeUnknown = ref(false) + let loads = 0 + + const scope = computed(() => { + const node = nodeId.value ?? localNodeId() ?? '' + return { + userId: userInfo.value?.user.user_id ?? '', + realmId: userInfo.value?.realm.realm_id ?? realmInfo.value?.realm_id ?? '', + epoch: sessionEpoch.value, + nodeId: node, + apiBase: node ? (nodeApiBase(node) ?? '') : '', + groupId: groupId.value ?? '', + bucket: bucket.value, + } + }) + const scopeKey = computed(() => JSON.stringify(scope.value)) + + function client(): ApiClientOptions { + return { baseUrl: scope.value.apiBase, token: authToken.value } + } + + /** A check that stays true while the scope a request started in is current. */ + function binder(): () => boolean { + const key = scopeKey.value + return () => scopeKey.value === key + } + + function failed(cause: unknown): EncryptionLoadState { + if (!(cause instanceof ApiError)) return 'failed' + if (cause.status === 404 || cause.status === 405) return 'missing' + return cause.status === 401 || cause.status === 403 ? 'refused' : 'failed' + } + + async function load(): Promise { + const run = ++loads + const bound = binder() + const live = () => bound() && run === loads + const { apiBase, nodeId: node } = scope.value + if (!apiBase) { + status.value = null + state.value = node || nodeInfo.value ? 'unresolved' : 'loading' + return + } + refreshing.value = true + const [encryption, compressed] = await Promise.allSettled([ + getBucketEncryption(scope.value.bucket, client()), + getBucketCompression(scope.value.bucket, client()), + ]) + if (!live()) return + refreshing.value = false + compression.value = compressed.status === 'fulfilled' ? compressed.value : null + if (encryption.status === 'fulfilled') { + status.value = encryption.value + state.value = 'ready' + error.value = null + outcomeUnknown.value = false + return + } + status.value = null + state.value = failed(encryption.reason) + error.value = apiErrorMessage(encryption.reason) + } + + async function run(action: EncryptionAction, work: (bound: () => boolean) => Promise): Promise { + if (busy.value) throw new Error('Another change to this bucket is still running.') + const bound = binder() + busy.value = action + try { + const result = await work(bound) + return bound() ? result : null + } finally { + // The status is read again before any outcome is shown, also after a failure. + if (bound()) { + await load() + busy.value = null + } + } + } + + /** Opens the caller's copy in this browser and sends only the bucket key to the node. */ + function unlock(durationMs?: number): Promise { + const seen = status.value + if (outcomeUnknown.value) { + return Promise.reject(new Error('The last unlock was not confirmed. Reload the status before trying again.')) + } + if (!seen?.bucket_id || !seen.public_key) return Promise.reject(new Error('This bucket has no key to unlock.')) + const { bucket_id: bucketId, key_generation: generation, public_key: publicKey } = seen + return run('unlock', async (bound) => { + const live = () => bound() && status.value?.bucket_id === bucketId && status.value.key_generation === generation + const { realmId, nodeId: node, userId } = scope.value + const outcome = await unlockWithVault( + { + bucket: scope.value.bucket, + client: client(), + context: { realmId, nodeId: node, bucketId, generation, userId }, + publicKey, + durationMs, + }, + vault, + live, + ) + if (outcome.kind === 'unknown' && bound()) outcomeUnknown.value = true + return outcome + }) + } + + function extend(durationMs?: number) { + const session = status.value?.unlock?.session_id + if (!session) return Promise.reject(new Error('The bucket is not unlocked.')) + return run('extend', () => extendUnlock(scope.value.bucket, { session_id: session, duration_ms: durationMs }, client())) + } + + function lock() { + return run('lock', () => lockBucket(scope.value.bucket, client())) + } + + function save(request: PutBucketEncryptionRequest) { + return run('save', () => putBucketEncryption(scope.value.bucket, request, client())) + } + + function rotate() { + const generation = status.value?.key_generation + if (generation === undefined) return Promise.reject(new Error('The bucket status is not known.')) + return run('rotate', () => rotateBucketKey(scope.value.bucket, generation, client())) + } + + watch( + scopeKey, + () => { + status.value = null + compression.value = null + error.value = null + busy.value = null + refreshing.value = false + outcomeUnknown.value = false + state.value = 'loading' + void load() + }, + { immediate: true }, + ) + + return { + status, + compression, + state, + error, + refreshing, + busy, + outcomeUnknown, + nodeId: computed(() => scope.value.nodeId || null), + load, + unlock, + extend, + lock, + save, + rotate, + client, + binder, + } +} From 323760d58091dffa2adc83cd7bb1083a80b03a9a Mon Sep 17 00:00:00 2001 From: Sebastian Beyvers Date: Sun, 4 Oct 2026 12:11:26 +0200 Subject: [PATCH 06/86] feat: describe bucket lock, recovery and transition states --- src/lib/bucketEncryption.test.ts | 81 ++++++++++++++++ src/lib/bucketEncryption.ts | 155 +++++++++++++++++++++++++++++++ src/lib/stateBadge.ts | 13 +++ 3 files changed, 249 insertions(+) create mode 100644 src/lib/bucketEncryption.test.ts create mode 100644 src/lib/bucketEncryption.ts diff --git a/src/lib/bucketEncryption.test.ts b/src/lib/bucketEncryption.test.ts new file mode 100644 index 00000000..f88b1325 --- /dev/null +++ b/src/lib/bucketEncryption.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import type { BucketEncryptionResponse, BucketUnlockStatus, EncryptionTransition } from './api' +import { + compressionSummary, + holderSummary, + lockView, + modeLabel, + recoverySummary, + transitionView, +} from './bucketEncryption' +import { stateTone } from './stateBadge' + +const LOCKED: BucketUnlockStatus = { + state: 'locked', + lock_reason: 'manual', + locked_at_ms: null, + session_id: null, + unlocked_at_ms: null, + deadline_ms: null, + max_deadline_ms: null, +} + +function bucket(unlock: BucketUnlockStatus | null, mode: BucketEncryptionResponse['mode'] = 'vault_locked') { + return { mode, unlock } as BucketEncryptionResponse +} + +const TRANSITION: EncryptionTransition = { + kind: 'encrypt', + state: 'running', + source_generation: null, + target_generation: 1, + done: 40, + remaining: 10, + failed: 0, + cleanup_remaining: 50, + started_at_ms: 1, + finished_at_ms: null, + blocked_reason: null, +} + +describe('bucket encryption wording', () => { + it('names the four lock states and an unreported one', () => { + expect(lockView(bucket(LOCKED)).label).toBe('Locked') + expect(lockView(bucket({ ...LOCKED, lock_reason: 'restart' })).label).toBe('Locked since restart') + expect(lockView(bucket({ ...LOCKED, state: 'unlocked', lock_reason: null })).label).toBe('Unlocked') + expect(lockView(bucket({ ...LOCKED, state: 'unlocked', deadline_ms: 5_000 })).label).toBe('Timed unlock') + expect(lockView(bucket(null)).label).toBe('Unknown') + expect(lockView(bucket(null, 'off')).label).toBe('Not encrypted') + expect(stateTone('Locked since restart')).toBe('attention') + }) + + it('never turns an unknown count into zero', () => { + expect(holderSummary(null)).toBe('Unknown') + expect(holderSummary({ ready: null, pending: null, missing_key: null })).toBe('Unknown') + expect(holderSummary({ ready: 2, pending: null, missing_key: 0 })).toBe('2 ready, 0 without a key, others unknown') + expect(holderSummary({ ready: 2, pending: 1, missing_key: 0 })).toBe('2 ready, 1 pending, 0 without a key') + expect(recoverySummary(null).label).toBe('Unknown') + expect(recoverySummary({ state: 'degraded', ready_holders: 1, ready_with_recovery: 0 }).label).toBe('Not met') + expect(modeLabel(undefined)).toBe('Unknown') + }) + + it('reports the effective Pithos level only for an encrypted bucket that names it', () => { + expect(compressionSummary(null, true)).toBe('Unknown') + expect(compressionSummary({ bucket: 'b', mode: 'off' }, true)).toBe('Off') + expect(compressionSummary({ bucket: 'b', mode: 'zstd', level: 3, effective_level: 4 }, true)).toBe( + 'zstd level 3, applied as level 4 in Pithos', + ) + expect(compressionSummary({ bucket: 'b', mode: 'zstd', level: 3 }, true)).toContain('not reported') + expect(compressionSummary({ bucket: 'b', mode: 'zstd', level: 3, effective_level: 4 }, false)).toBe('zstd level 3') + }) + + it('calls a transition complete only after cleanup finished', () => { + expect(transitionView(TRANSITION)).toMatchObject({ state: 'Running', complete: false }) + expect(transitionView(TRANSITION).detail).toBe('40 done, 10 left, 50 old copies to remove.') + const visited = { ...TRANSITION, state: 'finished' as const, remaining: 0 } + expect(transitionView(visited)).toMatchObject({ state: 'Cleanup open', complete: false }) + expect(transitionView({ ...visited, cleanup_remaining: null }).complete).toBe(false) + expect(transitionView({ ...visited, cleanup_remaining: 0 })).toMatchObject({ state: 'Finished', complete: true }) + expect(transitionView({ ...TRANSITION, remaining: null }).detail).toContain('an unknown number left') + }) +}) diff --git a/src/lib/bucketEncryption.ts b/src/lib/bucketEncryption.ts new file mode 100644 index 00000000..67ad3e95 --- /dev/null +++ b/src/lib/bucketEncryption.ts @@ -0,0 +1,155 @@ +// Words for the encryption state of a bucket. A value the node did not report +// reads as unknown, never as zero, empty, off or complete. +import type { + BlockCipher, + BlockKeys, + BucketCompressionResponse, + BucketEncryptionResponse, + EncryptionMode, + EncryptionTransition, + HolderReadiness, + RecoveryStatus, +} from './api' +import { formatDuration } from './utils' + +export const MODE_LABEL: Record = { + off: 'Off', + node_managed: 'Node-managed', + vault_locked: 'Vault-locked', +} + +export const CIPHER_LABEL: Record = { + chacha20_poly1305: 'ChaCha20-Poly1305', + aes256_gcm: 'AES-256-GCM', +} + +export const BLOCK_KEYS_LABEL: Record = { + content_derived: 'Derived from block content', + unique: 'A new key for every block', +} + +const UNKNOWN = 'Unknown' + +function known(labels: Record, value: T | null | undefined): string { + return (value && labels[value]) || UNKNOWN +} + +export const modeLabel = (mode: EncryptionMode | null | undefined) => known(MODE_LABEL, mode) +export const cipherLabel = (cipher: BlockCipher | null | undefined) => known(CIPHER_LABEL, cipher) +export const blockKeysLabel = (keys: BlockKeys | null | undefined) => known(BLOCK_KEYS_LABEL, keys) + +export interface LockView { + /** Also the `stateBadge` key. */ + label: string + detail: string +} + +function at(ms: number): string { + return new Date(ms).toLocaleString() +} + +/** Locked, unlocked, timed unlock or locked since restart; unknown when the node gave no lock state. */ +export function lockView(status: BucketEncryptionResponse): LockView { + if (status.mode === 'off') return { label: 'Not encrypted', detail: 'Stored bytes are readable without a key.' } + const unlock = status.unlock + if (!unlock) return { label: UNKNOWN, detail: 'The node did not report whether the bucket is unlocked.' } + if (unlock.state === 'unlocked') { + return unlock.deadline_ms !== null + ? { label: 'Timed unlock', detail: `Locks itself at ${at(unlock.deadline_ms)}.` } + : { label: 'Unlocked', detail: 'Until a key holder locks it or the node restarts.' } + } + if (unlock.lock_reason === 'restart') { + return { label: 'Locked since restart', detail: 'The node restarted. A key holder must unlock it again.' } + } + const since = unlock.locked_at_ms !== null ? ` since ${at(unlock.locked_at_ms)}` : '' + const why = unlock.lock_reason === 'timed' ? 'The timed unlock ended' : 'Locked' + return { label: 'Locked', detail: `${why}${since}.` } +} + +function count(value: number | null | undefined, noun: string): string | null { + if (value === null || value === undefined) return null + return `${value} ${noun}` +} + +/** "2 ready, 1 pending, 1 without a key"; unknown parts are said to be unknown. */ +export function holderSummary(holders: HolderReadiness | null): string { + if (!holders) return UNKNOWN + const parts = [ + count(holders.ready, 'ready'), + count(holders.pending, 'pending'), + count(holders.missing_key, 'without a key'), + ] + if (parts.every((part) => part === null)) return UNKNOWN + const shown = parts.filter((part): part is string => part !== null) + return parts.includes(null) ? `${shown.join(', ')}, others unknown` : shown.join(', ') +} + +export function recoverySummary(recovery: RecoveryStatus | null): LockView { + if (!recovery || recovery.state === 'unknown') { + return { label: UNKNOWN, detail: 'The key directory did not answer, so the recovery rule could not be checked.' } + } + if (recovery.state === 'met') { + return { label: 'Met', detail: 'Two ready key holders, or one with a recovery code.' } + } + return { + label: 'Not met', + detail: 'Recovery needs a usable recovery code or another ready key holder. Losing the last key loses the data.', + } +} + +export function compressionSummary( + compression: BucketCompressionResponse | null, + encrypted: boolean, +): string { + if (!compression) return UNKNOWN + if (compression.mode === 'off') return 'Off' + const requested = compression.level === undefined ? 'zstd' : `zstd level ${compression.level}` + if (!encrypted) return requested + return compression.effective_level === undefined + ? `${requested}; the level Pithos applies is not reported` + : `${requested}, applied as level ${compression.effective_level} in Pithos` +} + +export function maxUnlockLabel(ms: number | null): string { + return ms === null ? 'Until lock or restart' : formatDuration(ms) +} + +const KIND_LABEL: Record = { + encrypt: 'Encrypting stored versions', + decrypt: 'Decrypting stored versions', + reencode: 'Writing stored versions in the new format', + rotate: 'Granting stored versions to the new key', +} + +const STATE_LABEL: Record = { + running: 'Running', + awaiting_key: 'Waiting for an unlock', + cleanup: 'Removing old copies', + blocked: 'Blocked', + finished: 'Finished', +} + +export interface TransitionView { + title: string + state: string + detail: string + /** Every version rewritten and every old copy removed. */ + complete: boolean +} + +export function transitionView(transition: EncryptionTransition): TransitionView { + const remaining = transition.remaining === null ? 'an unknown number' : String(transition.remaining) + const cleanup = + transition.cleanup_remaining === null + ? 'old copies still to count' + : `${transition.cleanup_remaining} old ${transition.cleanup_remaining === 1 ? 'copy' : 'copies'} to remove` + const failed = transition.failed ? `, ${transition.failed} failed` : '' + const complete = + transition.state === 'finished' && transition.remaining === 0 && transition.cleanup_remaining === 0 + return { + title: known(KIND_LABEL, transition.kind), + state: complete ? 'Finished' : transition.state === 'finished' ? 'Cleanup open' : known(STATE_LABEL, transition.state), + detail: `${transition.done} done, ${remaining} left${failed}, ${cleanup}.`, + complete, + } +} diff --git a/src/lib/stateBadge.ts b/src/lib/stateBadge.ts index 961574ac..a3cb71e4 100644 --- a/src/lib/stateBadge.ts +++ b/src/lib/stateBadge.ts @@ -95,6 +95,19 @@ const STATE_TONE: Record = { offline: 'attention', unreachable: 'attention', reachable: 'done', + // Bucket encryption. + 'not encrypted': 'idle', + locked: 'idle', + unlocked: 'done', + 'timed unlock': 'progress', + 'locked since restart': 'attention', + met: 'done', + 'not met': 'attention', + finished: 'done', + 'cleanup open': 'progress', + 'removing old copies': 'progress', + 'waiting for an unlock': 'attention', + blocked: 'attention', } /** The tone for a state string; anything unknown reads as a neutral count. */ From 90f6525423cbcc149004ac25c6a8dfaa1d657395 Mon Sep 17 00:00:00 2001 From: Sebastian Beyvers Date: Sun, 4 Oct 2026 12:13:02 +0200 Subject: [PATCH 07/86] feat: show bucket encryption on its own settings tab --- .../storage/BucketEncryptionTab.test.ts | 140 ++++++++++++++++++ .../storage/BucketEncryptionTab.vue | 127 ++++++++++++++++ src/views/BucketStorageView.test.ts | 12 +- src/views/BucketStorageView.vue | 8 +- 4 files changed, 281 insertions(+), 6 deletions(-) create mode 100644 src/components/storage/BucketEncryptionTab.test.ts create mode 100644 src/components/storage/BucketEncryptionTab.vue diff --git a/src/components/storage/BucketEncryptionTab.test.ts b/src/components/storage/BucketEncryptionTab.test.ts new file mode 100644 index 00000000..df5faef5 --- /dev/null +++ b/src/components/storage/BucketEncryptionTab.test.ts @@ -0,0 +1,140 @@ +import { defineComponent, h, ref } from 'vue' +import * as VueRuntime from 'vue' +import { describe, expect, it } from 'vitest' +import type { BucketCompressionResponse, BucketEncryptionResponse } from '@/lib/api' +import * as Wording from '@/lib/bucketEncryption' +import * as StateBadge from '@/lib/stateBadge' +import type { EncryptionLoadState } from '@/composables/useBucketEncryption' +import { compileClientComponent, content, mountApp, moduleDefault } from '@/test/clientRender' + +const status = ref(null) +const compression = ref(null) +const state = ref('loading') + +const Slotted = (tag: string) => + defineComponent({ inheritAttrs: false, setup: (_, { attrs, slots }) => () => h(tag, attrs, slots.default?.()) }) +const NoticeStub = defineComponent({ + props: { title: String, lines: Array }, + setup: (props, { slots }) => () => + h('aside', [props.title, slots.default?.(), ...((props.lines as string[] | undefined) ?? []).map((line) => h('p', line))]), +}) +const DetailStub = defineComponent({ + props: { items: Array }, + setup: (props) => () => + h('dl', (props.items as { label: string; value: string }[]).map((item) => h('p', `${item.label}: ${item.value}`))), +}) + +const tab = compileClientComponent(new URL('./BucketEncryptionTab.vue', import.meta.url), { + vue: VueRuntime, + '@lucide/vue': new Proxy({}, { get: () => Slotted('i') }), + '@/components/ui/Badge.vue': moduleDefault(Slotted('span')), + '@/components/ui/DetailList.vue': moduleDefault(DetailStub), + '@/components/ui/NodeLabel.vue': moduleDefault(Slotted('span')), + '@/components/ui/Notice.vue': moduleDefault(NoticeStub), + '@/components/ui/RefreshButton.vue': moduleDefault(Slotted('button')), + '@/components/ui/SectionSkeleton.vue': moduleDefault(defineComponent(() => () => h('section', 'loading placeholder'))), + '@/composables/useBucketEncryption': { + useBucketEncryption: () => ({ + status, + compression, + state, + error: ref('The node did not answer.'), + refreshing: ref(false), + nodeId: ref('node-b'), + load: () => undefined, + }), + }, + '@/lib/bucketEncryption': Wording, + '@/lib/stateBadge': StateBadge, +}) + +function encrypted(overrides: Partial = {}): BucketEncryptionResponse { + return { + bucket: 'reef', + mode: 'vault_locked', + bucket_id: 'B1', + storage_generation: 1, + key_generation: 2, + public_key: 'PK', + fingerprint: 'abcdef0123', + cipher: 'chacha20_poly1305', + block_keys: 'content_derived', + max_unlock_ms: null, + unlock: { + state: 'locked', + lock_reason: 'restart', + locked_at_ms: 1, + session_id: null, + unlocked_at_ms: null, + deadline_ms: null, + max_deadline_ms: null, + }, + holders: { ready: 1, pending: null, missing_key: 0 }, + recovery: { state: 'degraded', ready_holders: 1, ready_with_recovery: 0 }, + transition: null, + caller: { holder: true, ready_copy: true, admin: false }, + ...overrides, + } +} + +async function render(next: EncryptionLoadState, value: BucketEncryptionResponse | null = null) { + state.value = next + status.value = value + compression.value = { bucket: 'reef', mode: 'zstd', level: 3, effective_level: 4 } + const { root } = await mountApp(tab, { props: { bucket: 'reef', nodeId: 'node-b', groupId: 'g-1' } }) + return content(root) +} + +describe('bucket encryption tab', () => { + it('never shows an unreported state as encryption off', async () => { + expect(await render('loading')).toContain('loading placeholder') + const missing = await render('missing') + expect(missing).toContain('does not report its encryption') + expect(missing).not.toContain('Off') + expect(await render('failed')).toContain('unknown until the node answers') + }) + + it('shows the lock state, key, format and recovery of a vault-locked bucket', async () => { + const text = await render('ready', encrypted()) + + expect(text).toContain('Locked since restart') + expect(text).toContain('Key fingerprint: abcdef0123') + expect(text).toContain('Cipher: ChaCha20-Poly1305') + expect(text).toContain('Compression: zstd level 3, applied as level 4 in Pithos') + expect(text).toContain('Key holders: 1 ready, 0 without a key, others unknown') + expect(text).toContain('Not met') + expect(text).toContain('you may unlock, extend and lock') + expect(text).toContain('A node restart locks vault-locked buckets.') + }) + + it('keeps a rewrite open until its old copies are removed', async () => { + const transition = { + kind: 'encrypt' as const, + state: 'finished' as const, + source_generation: null, + target_generation: 2, + done: 12, + remaining: 0, + failed: 0, + cleanup_remaining: 3, + started_at_ms: 1, + finished_at_ms: 2, + blocked_reason: null, + } + const text = await render('ready', encrypted({ transition })) + + expect(text).toContain('Encrypting stored versions') + expect(text).toContain('Cleanup open') + expect(text).toContain('3 old copies to remove') + }) + + it('shows a plain bucket without key details', async () => { + const text = await render('ready', encrypted({ mode: 'off', unlock: null, public_key: null, fingerprint: null })) + + expect(text).toContain('Mode: Off') + expect(text).toContain('Not encrypted') + expect(text).not.toContain('Key fingerprint') + expect(text).toContain('Compression: zstd level 3') + expect(text).not.toContain('applied as level') + }) +}) diff --git a/src/components/storage/BucketEncryptionTab.vue b/src/components/storage/BucketEncryptionTab.vue new file mode 100644 index 00000000..03cb4d77 --- /dev/null +++ b/src/components/storage/BucketEncryptionTab.vue @@ -0,0 +1,127 @@ + + + diff --git a/src/views/BucketStorageView.test.ts b/src/views/BucketStorageView.test.ts index 0a2a3610..563d1325 100644 --- a/src/views/BucketStorageView.test.ts +++ b/src/views/BucketStorageView.test.ts @@ -75,6 +75,7 @@ const view = compileClientComponent(new URL('./BucketStorageView.vue', import.me '@/components/data/SyncBucketDialog.vue': moduleDefault(Marker('')), '@/components/storage/BucketBackendTab.vue': moduleDefault(Marker('backend rules')), '@/components/storage/BucketComplianceSection.vue': moduleDefault(Marker('compliance on this node')), + '@/components/storage/BucketEncryptionTab.vue': moduleDefault(Marker('encryption state')), '@/components/storage/BucketPolicySection.vue': moduleDefault(Marker('where copies may be stored')), '@/components/storage/StorageOverviewTab.vue': moduleDefault(Marker('overview details')), '@/components/storage/SyncsTab.vue': moduleDefault(Marker('sync rows')), @@ -130,12 +131,13 @@ describe('bucket storage page', () => { const root = await render() expect(content(root)).toContain('Settings for reef-survey') - expect(tabs(root)).toEqual(['overview', 'syncs']) + expect(tabs(root)).toEqual(['overview', 'encryption', 'syncs']) expect(content(root)).toContain('overview details') }) it('opens the tab named in the query', async () => { expect(content(await render({ query: { tab: 'syncs' } }))).toContain('sync rows') + expect(content(await render({ query: { tab: 'encryption' } }))).toContain('encryption state') expect(content(await render({ admin: true, query: { tab: 'backend' } }))).toContain('backend rules') }) @@ -149,7 +151,7 @@ describe('bucket storage page', () => { it('offers the backend and placement tabs to a group admin', async () => { const root = await render({ admin: true, query: { tab: 'placement' } }) - expect(tabs(root)).toEqual(['overview', 'backend', 'placement', 'syncs']) + expect(tabs(root)).toEqual(['overview', 'backend', 'placement', 'encryption', 'syncs']) expect(content(root)).toContain('where copies may be stored') expect(content(root)).toContain('compliance on this node') }) @@ -157,14 +159,14 @@ describe('bucket storage page', () => { it('offers a realm admin placement but not the group routing rules', async () => { const root = await render({ realmAdmin: true, query: { tab: 'placement' } }) - expect(tabs(root)).toEqual(['overview', 'placement', 'syncs']) + expect(tabs(root)).toEqual(['overview', 'placement', 'encryption', 'syncs']) expect(content(root)).toContain('where copies may be stored') }) it('names every section the way the settings page calls it', async () => { const root = await render({ admin: true }) - expect(tabLabels(root)).toEqual(['Overview', 'Storage backend', 'Placement', 'Syncs']) + expect(tabLabels(root)).toEqual(['Overview', 'Storage backend', 'Placement', 'Encryption', 'Syncs']) }) it('offers no bucket deletion under the settings tabs', async () => { @@ -174,7 +176,7 @@ describe('bucket storage page', () => { it('hides the node-local tabs for a bucket hosted elsewhere', async () => { const root = await render({ admin: true, realmAdmin: true, query: { node: 'node-far', tab: 'syncs' } }) - expect(tabs(root)).toEqual(['overview', 'syncs']) + expect(tabs(root)).toEqual(['overview', 'encryption', 'syncs']) expect(content(root)).toContain('this node can only show') }) }) diff --git a/src/views/BucketStorageView.vue b/src/views/BucketStorageView.vue index 425cef7c..14d09542 100644 --- a/src/views/BucketStorageView.vue +++ b/src/views/BucketStorageView.vue @@ -18,6 +18,7 @@ import TabsTrigger from '@/components/ui/TabsTrigger.vue' import SyncBucketDialog from '@/components/data/SyncBucketDialog.vue' import BucketBackendTab from '@/components/storage/BucketBackendTab.vue' import BucketComplianceSection from '@/components/storage/BucketComplianceSection.vue' +import BucketEncryptionTab from '@/components/storage/BucketEncryptionTab.vue' import BucketPolicySection from '@/components/storage/BucketPolicySection.vue' import StorageOverviewTab from '@/components/storage/StorageOverviewTab.vue' import SyncsTab from '@/components/storage/SyncsTab.vue' @@ -61,7 +62,7 @@ const local = computed(() => !nodeId.value) const backendVisible = computed(() => local.value && canAdminGroup.value) const placementVisible = computed(() => local.value && (canAdminGroup.value || isRealmAdmin.value)) -const TAB_IDS = ['overview', 'backend', 'placement', 'syncs'] +const TAB_IDS = ['overview', 'backend', 'placement', 'encryption', 'syncs'] const routeTab = useRouteTab(TAB_IDS, 'overview') const tab = computed({ get() { @@ -122,6 +123,7 @@ const browserLink = computed(() => ({ Overview Storage backend Placement + Encryption Syncs
@@ -149,6 +151,10 @@ const browserLink = computed(() => ({ /> + + + + This bucket is hosted on {{ realmNodes.displayName(nodeId) }}, so this node can only show From 0798cea9236f4ad8c09b40baa5881567f30d9fd5 Mon Sep 17 00:00:00 2001 From: Sebastian Beyvers Date: Sun, 4 Oct 2026 12:15:36 +0200 Subject: [PATCH 08/86] feat: unlock, extend and lock a bucket from its settings --- .../storage/BucketEncryptionTab.test.ts | 85 +++++++++++- .../storage/BucketEncryptionTab.vue | 71 +++++++++- .../storage/BucketUnlockDialog.test.ts | 125 ++++++++++++++++++ src/components/storage/BucketUnlockDialog.vue | 122 +++++++++++++++++ src/lib/bucketEncryption.ts | 54 ++++++-- 5 files changed, 442 insertions(+), 15 deletions(-) create mode 100644 src/components/storage/BucketUnlockDialog.test.ts create mode 100644 src/components/storage/BucketUnlockDialog.vue diff --git a/src/components/storage/BucketEncryptionTab.test.ts b/src/components/storage/BucketEncryptionTab.test.ts index df5faef5..49a068c5 100644 --- a/src/components/storage/BucketEncryptionTab.test.ts +++ b/src/components/storage/BucketEncryptionTab.test.ts @@ -1,15 +1,20 @@ import { defineComponent, h, ref } from 'vue' import * as VueRuntime from 'vue' -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as Api from '@/lib/api' import type { BucketCompressionResponse, BucketEncryptionResponse } from '@/lib/api' import * as Wording from '@/lib/bucketEncryption' import * as StateBadge from '@/lib/stateBadge' import type { EncryptionLoadState } from '@/composables/useBucketEncryption' -import { compileClientComponent, content, mountApp, moduleDefault } from '@/test/clientRender' +import { button, click, compileClientComponent, content, mountApp, moduleDefault } from '@/test/clientRender' const status = ref(null) const compression = ref(null) const state = ref('loading') +const outcomeUnknown = ref(false) +const lock = vi.fn() +const extend = vi.fn() +const unlock = vi.fn() const Slotted = (tag: string) => defineComponent({ inheritAttrs: false, setup: (_, { attrs, slots }) => () => h(tag, attrs, slots.default?.()) }) @@ -24,14 +29,26 @@ const DetailStub = defineComponent({ h('dl', (props.items as { label: string; value: string }[]).map((item) => h('p', `${item.label}: ${item.value}`))), }) +const ButtonStub = defineComponent({ + inheritAttrs: false, + setup: (_, { attrs, slots }) => () => h('button', attrs, slots.default?.()), +}) +const DialogStub = defineComponent({ + props: { open: Boolean }, + setup: (props) => () => h('div', props.open ? 'unlock dialog open' : ''), +}) + const tab = compileClientComponent(new URL('./BucketEncryptionTab.vue', import.meta.url), { vue: VueRuntime, '@lucide/vue': new Proxy({}, { get: () => Slotted('i') }), + '@/components/storage/BucketUnlockDialog.vue': moduleDefault(DialogStub), '@/components/ui/Badge.vue': moduleDefault(Slotted('span')), + '@/components/ui/Button.vue': moduleDefault(ButtonStub), '@/components/ui/DetailList.vue': moduleDefault(DetailStub), '@/components/ui/NodeLabel.vue': moduleDefault(Slotted('span')), '@/components/ui/Notice.vue': moduleDefault(NoticeStub), '@/components/ui/RefreshButton.vue': moduleDefault(Slotted('button')), + '@/components/ui/Select.vue': moduleDefault(Slotted('select')), '@/components/ui/SectionSkeleton.vue': moduleDefault(defineComponent(() => () => h('section', 'loading placeholder'))), '@/composables/useBucketEncryption': { useBucketEncryption: () => ({ @@ -40,8 +57,13 @@ const tab = compileClientComponent(new URL('./BucketEncryptionTab.vue', import.m state, error: ref('The node did not answer.'), refreshing: ref(false), + busy: ref(null), + outcomeUnknown, nodeId: ref('node-b'), load: () => undefined, + lock, + extend, + unlock, }), }, '@/lib/bucketEncryption': Wording, @@ -77,14 +99,33 @@ function encrypted(overrides: Partial = {}): BucketEnc } } -async function render(next: EncryptionLoadState, value: BucketEncryptionResponse | null = null) { +async function mount(next: EncryptionLoadState, value: BucketEncryptionResponse | null = null) { state.value = next status.value = value compression.value = { bucket: 'reef', mode: 'zstd', level: 3, effective_level: 4 } const { root } = await mountApp(tab, { props: { bucket: 'reef', nodeId: 'node-b', groupId: 'g-1' } }) - return content(root) + return root +} + +async function render(next: EncryptionLoadState, value: BucketEncryptionResponse | null = null) { + return content(await mount(next, value)) +} + +const OPEN = { + state: 'unlocked' as const, + lock_reason: null, + locked_at_ms: null, + session_id: 'S1', + unlocked_at_ms: 1, + deadline_ms: 9_000, + max_deadline_ms: null, } +beforeEach(() => { + outcomeUnknown.value = false + lock.mockReset().mockResolvedValue(null) +}) + describe('bucket encryption tab', () => { it('never shows an unreported state as encryption off', async () => { expect(await render('loading')).toContain('loading placeholder') @@ -137,4 +178,40 @@ describe('bucket encryption tab', () => { expect(text).toContain('Compression: zstd level 3') expect(text).not.toContain('applied as level') }) + + it('offers a ready key holder the unlock dialog for a locked bucket', async () => { + const root = await mount('ready', encrypted()) + + expect(content(root)).not.toContain('Lock now') + await click(button(root, 'Unlock')) + expect(content(root)).toContain('unlock dialog open') + }) + + it('offers extend and lock while unlocked and words a refusal', async () => { + lock.mockRejectedValueOnce(new Api.ApiError(409, 'conflict', 'session_mismatch')) + const root = await mount('ready', encrypted({ unlock: OPEN })) + + expect(content(root)).toContain('Timed unlock') + expect(content(root)).toContain('Extend') + await click(button(root, 'Lock now')) + + expect(lock).toHaveBeenCalledOnce() + expect(content(root)).toContain('locked or unlocked again meanwhile') + }) + + it('holds back a second unlock while the last one is unconfirmed', async () => { + outcomeUnknown.value = true + const root = await mount('ready', encrypted()) + + expect(content(root)).toContain('The last unlock was not confirmed') + expect(button(root, 'Unlock').props.disabled).toBe(true) + }) + + it('offers a reader no key action', async () => { + const reader = { holder: false, ready_copy: false, admin: false } + const text = await render('ready', encrypted({ unlock: OPEN, caller: reader })) + + expect(text).toContain('No key action is open to you right now.') + expect(text).not.toContain('Lock now') + }) }) diff --git a/src/components/storage/BucketEncryptionTab.vue b/src/components/storage/BucketEncryptionTab.vue index 03cb4d77..c7c160b7 100644 --- a/src/components/storage/BucketEncryptionTab.vue +++ b/src/components/storage/BucketEncryptionTab.vue @@ -1,18 +1,23 @@ + + diff --git a/src/lib/bucketEncryption.ts b/src/lib/bucketEncryption.ts index 67ad3e95..9d18948c 100644 --- a/src/lib/bucketEncryption.ts +++ b/src/lib/bucketEncryption.ts @@ -1,14 +1,17 @@ // Words for the encryption state of a bucket. A value the node did not report // reads as unknown, never as zero, empty, off or complete. -import type { - BlockCipher, - BlockKeys, - BucketCompressionResponse, - BucketEncryptionResponse, - EncryptionMode, - EncryptionTransition, - HolderReadiness, - RecoveryStatus, +import { + ApiError, + apiErrorMessage, + ENCRYPTION_CODES, + type BlockCipher, + type BlockKeys, + type BucketCompressionResponse, + type BucketEncryptionResponse, + type EncryptionMode, + type EncryptionTransition, + type HolderReadiness, + type RecoveryStatus, } from './api' import { formatDuration } from './utils' @@ -153,3 +156,36 @@ export function transitionView(transition: EncryptionTransition): TransitionView complete, } } + +const HOUR = 3_600_000 +const DURATIONS: [number, string][] = [ + [HOUR / 4, '15 minutes'], + [HOUR, '1 hour'], + [8 * HOUR, '8 hours'], + [24 * HOUR, '24 hours'], +] + +/** Unlock lengths within the bucket maximum; '' leaves it to the node: the maximum, or until lock. */ +export function durationOptions(maxMs: number | null): { value: string; label: string }[] { + const fallback = maxMs === null ? 'Until lock or restart' : `The bucket maximum (${formatDuration(maxMs)})` + const shorter = DURATIONS.filter(([ms]) => maxMs === null || ms < maxMs) + return [{ value: '', label: fallback }, ...shorter.map(([ms, label]) => ({ value: String(ms), label }))] +} + +const REFUSALS: Record = { + [ENCRYPTION_CODES.wrongKey]: 'The node says this key does not belong to the bucket.', + [ENCRYPTION_CODES.invalidDuration]: 'The node refused this unlock length.', + [ENCRYPTION_CODES.staleGeneration]: 'The bucket key changed meanwhile. Read the state again and retry.', + [ENCRYPTION_CODES.sessionMismatch]: 'The bucket was locked or unlocked again meanwhile. Read the state again.', + [ENCRYPTION_CODES.capacity]: 'The node holds as many unlocked buckets as it can. Lock another one first.', + [ENCRYPTION_CODES.openUploads]: 'Uploads to this bucket are still open. Finish or abort them first.', + [ENCRYPTION_CODES.locked]: 'The bucket is locked. A key holder must unlock it first.', + [ENCRYPTION_CODES.recoveryUnmet]: + 'Vault-locked needs two ready key holders, or one with a recovery code.', +} + +/** A node refusal in plain words, by its code; anything else keeps the node's message. */ +export function encryptionError(error: unknown): string { + const code = error instanceof ApiError ? (error.code ?? '') : '' + return REFUSALS[code] ?? apiErrorMessage(error) +} From 4de525ceb347475788cfe52993d8f952c7da5da7 Mon Sep 17 00:00:00 2001 From: Sebastian Beyvers Date: Sun, 4 Oct 2026 12:16:45 +0200 Subject: [PATCH 09/86] feat: grant and remove bucket key holders with recovery confirmation --- src/composables/useBucketHolders.test.ts | 104 +++++++++++++++++++++++ src/composables/useBucketHolders.ts | 93 ++++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 src/composables/useBucketHolders.test.ts create mode 100644 src/composables/useBucketHolders.ts diff --git a/src/composables/useBucketHolders.test.ts b/src/composables/useBucketHolders.test.ts new file mode 100644 index 00000000..e2d48780 --- /dev/null +++ b/src/composables/useBucketHolders.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import * as Api from '@/lib/api' +import type { BucketHolderEntry, BucketHoldersResponse } from '@/lib/api' +import { breaksRecovery, useBucketHolders } from './useBucketHolders' + +const listBucketHolders = vi.fn() +const removeBucketHolder = vi.fn() +const grantBucketHolder = vi.fn() + +vi.mock('@/lib/api', async (importOriginal) => ({ + ...(await importOriginal()), + listBucketHolders: (...args: unknown[]) => listBucketHolders(...args), + removeBucketHolder: (...args: unknown[]) => removeBucketHolder(...args), + grantBucketHolder: (...args: unknown[]) => grantBucketHolder(...args), +})) + +function holder(userId: string, overrides: Partial = {}): BucketHolderEntry { + return { + user_id: userId, + origin: 'explicit', + state: 'ready', + has_recovery: false, + granted_by: null, + granted_at_ms: null, + ...overrides, + } +} + +function listing(entries: BucketHolderEntry[], revision = 'rev-1'): BucketHoldersResponse { + return { holders: entries, recovery: { state: 'met', ready_holders: 2, ready_with_recovery: 0 }, revision } +} + +const NODE = { baseUrl: 'https://b.test/api/v1', token: 't' } +let current = true +const source = { client: () => NODE, binder: () => () => current, load: vi.fn(async () => undefined) } + +function refusal(code: string) { + return new Api.ApiError(409, 'conflict', code) +} + +beforeEach(() => { + current = true + source.load.mockClear() + listBucketHolders.mockReset().mockResolvedValue(listing([holder('A'), holder('B')])) + removeBucketHolder.mockReset().mockResolvedValue(undefined) + grantBucketHolder.mockReset().mockResolvedValue(holder('C', { state: 'pending' })) +}) + +describe('bucket key holders', () => { + it('foresees a removal that breaks recovery', () => { + expect(breaksRecovery([holder('A'), holder('B')], 'A')).toBe(true) + expect(breaksRecovery([holder('A'), holder('B'), holder('C')], 'A')).toBe(false) + expect(breaksRecovery([holder('A'), holder('B', { has_recovery: true })], 'A')).toBe(false) + expect(breaksRecovery([holder('A'), holder('B'), holder('C', { state: 'pending' })], 'A')).toBe(true) + expect(breaksRecovery([holder('A'), holder('B', { has_recovery: null })], 'A')).toBe(true) + }) + + it('drops a holder list that arrives after the bucket changed', async () => { + const holders = useBucketHolders(source, ref('reef')) + current = false + + await holders.load() + + expect(holders.holders.value).toBeNull() + expect(holders.state.value).toBe('loading') + }) + + it('asks for confirmation when the node says the removal breaks recovery', async () => { + const holders = useBucketHolders(source, ref('reef')) + await holders.load() + removeBucketHolder.mockRejectedValueOnce(refusal('recovery_confirmation_required')) + + expect(await holders.remove('A', false)).toBe('confirm') + expect(await holders.remove('A', true)).toBe('removed') + + expect(removeBucketHolder.mock.calls.map((call) => call.slice(0, 4))).toEqual([ + ['reef', 'A', 'rev-1', false], + ['reef', 'A', 'rev-1', true], + ]) + expect(source.load).toHaveBeenCalledOnce() + }) + + it('reloads a holder set that changed meanwhile instead of removing from it', async () => { + const holders = useBucketHolders(source, ref('reef')) + await holders.load() + removeBucketHolder.mockRejectedValueOnce(refusal('stale_holders')) + listBucketHolders.mockResolvedValue(listing([holder('A'), holder('B'), holder('C')], 'rev-2')) + + await expect(holders.remove('A', false)).rejects.toThrow('changed meanwhile') + + expect(holders.holders.value?.revision).toBe('rev-2') + }) + + it('grants a holder and reads both the list and the bucket status again', async () => { + const holders = useBucketHolders(source, ref('reef')) + + await holders.grant('C') + + expect(grantBucketHolder.mock.calls[0].slice(0, 2)).toEqual(['reef', 'C']) + expect(listBucketHolders).toHaveBeenCalledOnce() + expect(source.load).toHaveBeenCalledOnce() + }) +}) diff --git a/src/composables/useBucketHolders.ts b/src/composables/useBucketHolders.ts new file mode 100644 index 00000000..50f3b8c9 --- /dev/null +++ b/src/composables/useBucketHolders.ts @@ -0,0 +1,93 @@ +// Key holders of one encrypted bucket, asked of the node that hosts it. Removal +// names the holder revision it saw, so a changed set is refused, and breaking +// the recovery rule needs an explicit confirmation. +import { ref, type Ref } from 'vue' +import { + ApiError, + ENCRYPTION_CODES, + encryptionRefusal, + grantBucketHolder, + listBucketHolders, + removeBucketHolder, + type ApiClientOptions, + type BucketHolderEntry, + type BucketHoldersResponse, +} from '@/lib/api' +import { encryptionError } from '@/lib/bucketEncryption' + +export interface HolderSource { + client(): ApiClientOptions + binder(): () => boolean + load(): Promise +} + +export type HoldersState = 'loading' | 'ready' | 'refused' | 'failed' +export type RemovalResult = 'removed' | 'confirm' + +/** True when removing `userId` would leave fewer than two ready holders and no ready recovery code. */ +export function breaksRecovery(holders: BucketHolderEntry[], userId: string): boolean { + const remaining = holders.filter((holder) => holder.user_id !== userId && holder.state === 'ready') + const ready = new Set(remaining.map((holder) => holder.user_id)) + return ready.size < 2 && !remaining.some((holder) => holder.has_recovery === true) +} + +export function useBucketHolders(source: HolderSource, bucket: Ref) { + const holders = ref(null) + const state = ref('loading') + const error = ref(null) + let loads = 0 + + async function load(): Promise { + const run = ++loads + const bound = source.binder() + state.value = holders.value ? state.value : 'loading' + try { + const response = await listBucketHolders(bucket.value, source.client()) + if (!bound() || run !== loads) return + holders.value = response + state.value = 'ready' + error.value = null + } catch (cause) { + if (!bound() || run !== loads) return + holders.value = null + const refused = cause instanceof ApiError && (cause.status === 401 || cause.status === 403) + state.value = refused ? 'refused' : 'failed' + error.value = encryptionError(cause) + } + } + + async function refresh(bound: () => boolean) { + if (!bound()) return + await Promise.all([load(), source.load()]) + } + + async function grant(userId: string): Promise { + const bound = source.binder() + try { + await grantBucketHolder(bucket.value, userId, source.client()) + } finally { + await refresh(bound) + } + } + + /** Without `confirmRecovery`, a removal that breaks recovery comes back as `confirm`. */ + async function remove(userId: string, confirmRecovery: boolean): Promise { + const revision = holders.value?.revision + if (!revision) throw new Error('The key holders are not known yet.') + const bound = source.binder() + try { + await removeBucketHolder(bucket.value, userId, revision, confirmRecovery, source.client()) + } catch (cause) { + if (encryptionRefusal(cause, 409, ENCRYPTION_CODES.confirmRecovery)) return 'confirm' + if (encryptionRefusal(cause, 409, ENCRYPTION_CODES.staleHolders)) { + await refresh(bound) + throw new Error('The key holders changed meanwhile. Check the list and try again.') + } + throw cause + } + await refresh(bound) + return 'removed' + } + + return { holders, state, error, load, grant, remove } +} From 868866e08b1a464bf9c5f799176272c9a7e24f9e Mon Sep 17 00:00:00 2001 From: Sebastian Beyvers Date: Sun, 4 Oct 2026 12:18:29 +0200 Subject: [PATCH 10/86] feat: list bucket key holders and confirm a removal that breaks recovery --- .../storage/BucketEncryptionTab.test.ts | 3 + .../storage/BucketEncryptionTab.vue | 8 + .../storage/BucketHoldersSection.test.ts | 125 ++++++++++++ .../storage/BucketHoldersSection.vue | 181 ++++++++++++++++++ src/lib/bucketEncryption.ts | 15 ++ src/lib/stateBadge.ts | 2 + 6 files changed, 334 insertions(+) create mode 100644 src/components/storage/BucketHoldersSection.test.ts create mode 100644 src/components/storage/BucketHoldersSection.vue diff --git a/src/components/storage/BucketEncryptionTab.test.ts b/src/components/storage/BucketEncryptionTab.test.ts index 49a068c5..8e02bdd9 100644 --- a/src/components/storage/BucketEncryptionTab.test.ts +++ b/src/components/storage/BucketEncryptionTab.test.ts @@ -41,6 +41,7 @@ const DialogStub = defineComponent({ const tab = compileClientComponent(new URL('./BucketEncryptionTab.vue', import.meta.url), { vue: VueRuntime, '@lucide/vue': new Proxy({}, { get: () => Slotted('i') }), + '@/components/storage/BucketHoldersSection.vue': moduleDefault(defineComponent(() => () => h('section', 'holder list'))), '@/components/storage/BucketUnlockDialog.vue': moduleDefault(DialogStub), '@/components/ui/Badge.vue': moduleDefault(Slotted('span')), '@/components/ui/Button.vue': moduleDefault(ButtonStub), @@ -213,5 +214,7 @@ describe('bucket encryption tab', () => { expect(text).toContain('No key action is open to you right now.') expect(text).not.toContain('Lock now') + expect(text).not.toContain('holder list') + expect(await render('ready', encrypted())).toContain('holder list') }) }) diff --git a/src/components/storage/BucketEncryptionTab.vue b/src/components/storage/BucketEncryptionTab.vue index c7c160b7..b6811a29 100644 --- a/src/components/storage/BucketEncryptionTab.vue +++ b/src/components/storage/BucketEncryptionTab.vue @@ -2,6 +2,7 @@ // The encryption of one bucket as the node that hosts it reports it: mode, lock // state, key fingerprint, format, holders, recovery and any rewrite that runs. import { computed, ref, toRef } from 'vue' +import BucketHoldersSection from '@/components/storage/BucketHoldersSection.vue' import BucketUnlockDialog from '@/components/storage/BucketUnlockDialog.vue' import Badge from '@/components/ui/Badge.vue' import Button from '@/components/ui/Button.vue' @@ -181,6 +182,13 @@ const WARNINGS = [ + + (null) +const state = ref('ready') +const remove = vi.fn() + +const Slotted = (tag: string) => + defineComponent({ inheritAttrs: false, setup: (_, { attrs, slots }) => () => h(tag, attrs, slots.default?.()) }) +const DialogStub = defineComponent({ + props: { open: Boolean }, + setup: (props, { slots }) => () => (props.open ? h('div', { 'data-dialog': true }, slots.default?.()) : null), +}) + +const section = compileClientComponent(new URL('./BucketHoldersSection.vue', import.meta.url), { + vue: VueRuntime, + '@vueuse/core': { useDebounceFn: (run: (...args: unknown[]) => unknown) => run }, + '@/components/ui/Badge.vue': moduleDefault(Slotted('span')), + '@/components/ui/Button.vue': moduleDefault(Slotted('button')), + '@/components/ui/Dialog.vue': moduleDefault(DialogStub), + '@/components/ui/DialogContent.vue': moduleDefault(Slotted('div')), + '@/components/ui/DialogDescription.vue': moduleDefault(Slotted('p')), + '@/components/ui/DialogFooter.vue': moduleDefault(Slotted('footer')), + '@/components/ui/DialogHeader.vue': moduleDefault(Slotted('header')), + '@/components/ui/DialogTitle.vue': moduleDefault(Slotted('h2')), + '@/components/ui/Input.vue': moduleDefault(Slotted('input')), + '@/components/ui/Notice.vue': moduleDefault(Slotted('aside')), + '@/components/ui/Spinner.vue': moduleDefault(defineComponent(() => () => h('span', 'loading'))), + '@/composables/useAruna': { useAruna: () => ({ searchUsers: async () => ({ users: [] }) }) }, + '@/composables/useBucketHolders': { + breaksRecovery, + useBucketHolders: () => ({ holders, state, error: ref('The directory did not answer.'), load: vi.fn(), grant: vi.fn(), remove }), + }, + '@/lib/bucketEncryption': Wording, + '@/lib/stateBadge': StateBadge, + '@/lib/utils': Utils, +}) + +function holder(userId: string, overrides: Partial = {}): BucketHolderEntry { + return { user_id: userId, name: userId, origin: 'explicit', state: 'ready', has_recovery: false, granted_by: null, granted_at_ms: null, ...overrides } +} + +async function render(entries: BucketHolderEntry[], canManage = true) { + holders.value = { holders: entries, recovery: { state: 'met', ready_holders: 2, ready_with_recovery: 0 }, revision: 'rev-1' } + const source = { client: () => ({}), binder: () => () => true, load: async () => undefined } + const { root } = await mountApp(section, { props: { bucket: 'reef', canManage, source } }) + return root +} + +function rowButton(root: Awaited>, name: string) { + const row = element(root, (node) => node.tag === 'li' && content(node).startsWith(name)) + return button(row, 'Remove') +} + +beforeEach(() => { + state.value = 'ready' + remove.mockReset().mockResolvedValue('removed') +}) + +describe('bucket key holder list', () => { + it('shows each holder with origin, readiness and recovery code, and removes only grants', async () => { + const root = await render([ + holder('Ada', { origin: 'creator', has_recovery: true }), + holder('Bo', { state: 'missing_key', has_recovery: null }), + holder('Cy', { state: 'pending' }), + ]) + const text = content(root) + + expect(text).toContain('Creator') + expect(text).toContain('No usable key') + expect(text).toContain('Recovery code: unknown') + expect(text).toContain('Recovery: Met') + expect(text).toContain('does not erase copies') + expect(() => rowButton(root, 'Ada')).toThrow() + await click(rowButton(root, 'Cy')) + expect(remove).toHaveBeenCalledWith('Cy', false) + }) + + it('asks before a removal that breaks recovery and sends the confirmation', async () => { + const root = await render([holder('Ada'), holder('Bo')]) + + await click(rowButton(root, 'Bo')) + expect(remove).not.toHaveBeenCalled() + expect(content(root)).toContain('cannot be read again') + expect(button(root, 'Remove anyway').props.disabled).toBe(true) + await click(element(root, (node) => node.tag === 'input' && node.props.type === 'checkbox')) + await click(button(root, 'Remove anyway')) + + expect(remove).toHaveBeenCalledWith('Bo', true) + }) + + it('asks when the node says the removal breaks recovery', async () => { + remove.mockResolvedValueOnce('confirm') + const root = await render([holder('Ada'), holder('Bo'), holder('Cy')]) + + await click(rowButton(root, 'Cy')) + + expect(remove).toHaveBeenCalledWith('Cy', false) + expect(content(root)).toContain('Remove Cy?') + }) + + it('offers no changes to a key holder who is not a group admin', async () => { + const root = await render([holder('Ada'), holder('Bo')], false) + + expect(content(root)).not.toContain('Grant a key holder') + expect(() => rowButton(root, 'Bo')).toThrow() + }) + + it('never shows a holder list it could not read as empty', async () => { + state.value = 'failed' + holders.value = null + const source = { client: () => ({}), binder: () => () => true, load: async () => undefined } + const { root } = await mountApp(section, { props: { bucket: 'reef', canManage: true, source } }) + + expect(content(root)).toContain('The key holders are unknown.') + expect(content(root)).not.toContain('no key holders') + }) +}) diff --git a/src/components/storage/BucketHoldersSection.vue b/src/components/storage/BucketHoldersSection.vue new file mode 100644 index 00000000..af6759f6 --- /dev/null +++ b/src/components/storage/BucketHoldersSection.vue @@ -0,0 +1,181 @@ + + + diff --git a/src/lib/bucketEncryption.ts b/src/lib/bucketEncryption.ts index 9d18948c..8c52e71b 100644 --- a/src/lib/bucketEncryption.ts +++ b/src/lib/bucketEncryption.ts @@ -10,7 +10,9 @@ import { type BucketEncryptionResponse, type EncryptionMode, type EncryptionTransition, + type HolderOrigin, type HolderReadiness, + type HolderState, type RecoveryStatus, } from './api' import { formatDuration } from './utils' @@ -31,6 +33,19 @@ export const BLOCK_KEYS_LABEL: Record = { unique: 'A new key for every block', } +export const ORIGIN_LABEL: Record = { + creator: 'Creator', + admin: 'Group admin', + explicit: 'Granted', +} + +export const HOLDER_STATE_LABEL: Record = { + ready: 'Ready', + pending: 'Pending', + missing_key: 'No usable key', + unavailable: 'Directory unavailable', +} + const UNKNOWN = 'Unknown' function known(labels: Record, value: T | null | undefined): string { diff --git a/src/lib/stateBadge.ts b/src/lib/stateBadge.ts index a3cb71e4..d752e185 100644 --- a/src/lib/stateBadge.ts +++ b/src/lib/stateBadge.ts @@ -108,6 +108,8 @@ const STATE_TONE: Record = { 'removing old copies': 'progress', 'waiting for an unlock': 'attention', blocked: 'attention', + 'no usable key': 'attention', + 'directory unavailable': 'attention', } /** The tone for a state string; anything unknown reads as a neutral count. */ From cfb4ae12cc82602aa9ad2e3b82d1b470e7e164e4 Mon Sep 17 00:00:00 2001 From: Sebastian Beyvers Date: Sun, 4 Oct 2026 12:20:51 +0200 Subject: [PATCH 11/86] feat: change the bucket encryption mode and rotate its key --- .../storage/BucketEncryptionSettings.test.ts | 122 +++++++++++++++ .../storage/BucketEncryptionSettings.vue | 144 ++++++++++++++++++ .../storage/BucketEncryptionTab.test.ts | 4 + .../storage/BucketEncryptionTab.vue | 9 ++ src/lib/bucketEncryption.test.ts | 16 ++ src/lib/bucketEncryption.ts | 58 +++++++ 6 files changed, 353 insertions(+) create mode 100644 src/components/storage/BucketEncryptionSettings.test.ts create mode 100644 src/components/storage/BucketEncryptionSettings.vue diff --git a/src/components/storage/BucketEncryptionSettings.test.ts b/src/components/storage/BucketEncryptionSettings.test.ts new file mode 100644 index 00000000..39ff088e --- /dev/null +++ b/src/components/storage/BucketEncryptionSettings.test.ts @@ -0,0 +1,122 @@ +import { defineComponent, h } from 'vue' +import * as VueRuntime from 'vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as Api from '@/lib/api' +import type { BucketEncryptionResponse } from '@/lib/api' +import * as Wording from '@/lib/bucketEncryption' +import { button, click, compileClientComponent, content, element, flush, mountApp, moduleDefault } from '@/test/clientRender' + +const save = vi.fn() +const rotate = vi.fn() + +const Slotted = (tag: string) => + defineComponent({ inheritAttrs: false, setup: (_, { attrs, slots }) => () => h(tag, attrs, slots.default?.()) }) +const NoticeStub = defineComponent({ + props: { lines: Array }, + setup: (props, { slots }) => () => + h('aside', [slots.default?.(), ...((props.lines as string[] | undefined) ?? []).map((line) => h('p', line))]), +}) +const DialogStub = defineComponent({ + props: { open: Boolean }, + setup: (props, { slots }) => () => (props.open ? h('div', { 'data-dialog': true }, slots.default?.()) : null), +}) + +const settings = compileClientComponent(new URL('./BucketEncryptionSettings.vue', import.meta.url), { + vue: VueRuntime, + '@/components/ui/Button.vue': moduleDefault(Slotted('button')), + '@/components/ui/Dialog.vue': moduleDefault(DialogStub), + '@/components/ui/DialogContent.vue': moduleDefault(Slotted('div')), + '@/components/ui/DialogDescription.vue': moduleDefault(Slotted('p')), + '@/components/ui/DialogFooter.vue': moduleDefault(Slotted('footer')), + '@/components/ui/DialogHeader.vue': moduleDefault(Slotted('header')), + '@/components/ui/DialogTitle.vue': moduleDefault(Slotted('h2')), + '@/components/ui/Notice.vue': moduleDefault(NoticeStub), + '@/components/ui/Select.vue': moduleDefault(Slotted('select')), + '@/lib/bucketEncryption': Wording, +}) + +function status(overrides: Partial = {}): BucketEncryptionResponse { + return { + bucket: 'reef', + mode: 'node_managed', + bucket_id: 'B1', + storage_generation: 1, + key_generation: 5, + public_key: 'PK', + fingerprint: 'ff', + cipher: 'chacha20_poly1305', + block_keys: 'content_derived', + max_unlock_ms: null, + unlock: { state: 'locked', lock_reason: 'manual', locked_at_ms: 1, session_id: null, unlocked_at_ms: null, deadline_ms: null, max_deadline_ms: null }, + holders: null, + recovery: null, + transition: null, + caller: { holder: true, ready_copy: true, admin: true }, + ...overrides, + } +} + +const UNLOCKED = { state: 'unlocked' as const, lock_reason: null, locked_at_ms: null, session_id: 'S', unlocked_at_ms: 1, deadline_ms: null, max_deadline_ms: null } + +async function render(value: BucketEncryptionResponse) { + const { root } = await mountApp(settings, { props: { status: value, busy: false, save, rotate } }) + return root +} + +function dialog(root: Awaited>) { + return element(root, (node) => node.props['data-dialog'] === true) +} + +async function choose(root: Awaited>, label: string, value: string) { + const select = element(root, (node) => node.tag === 'select' && node.props['aria-label'] === label) + ;(select.props['onUpdate:modelValue'] as (next: string) => void)(value) + await flush() +} + +beforeEach(() => { + save.mockReset().mockResolvedValue(undefined) + rotate.mockReset().mockResolvedValue(undefined) +}) + +describe('bucket encryption settings', () => { + it('holds back a change that needs the key while the bucket is locked', async () => { + const root = await render(status()) + + await choose(root, 'Encryption mode', 'vault_locked') + + expect(content(root)).toContain('Unlock the bucket first') + expect(content(root)).toContain('the node copy is removed') + expect(button(root, 'Save changes').props.disabled).toBe(true) + expect(button(root, 'Rotate key').props.disabled).toBe(true) + }) + + it('confirms a change with its notes and sends the key generation it saw', async () => { + const root = await render(status({ mode: 'off', unlock: null })) + + await choose(root, 'Encryption mode', 'vault_locked') + await click(button(root, 'Save changes')) + expect(content(root)).toContain('no unlock is needed') + expect(content(root)).toContain('A node restart locks the bucket.') + await click(button(dialog(root), 'Save')) + + expect(save).toHaveBeenCalledWith({ + mode: 'vault_locked', + cipher: 'chacha20_poly1305', + block_keys: 'content_derived', + max_unlock_ms: null, + expected_generation: 5, + }) + }) + + it('rotates an unlocked bucket after the backup warning and words a refusal', async () => { + rotate.mockRejectedValueOnce(new Api.ApiError(409, 'busy', 'open_uploads')) + const root = await render(status({ unlock: UNLOCKED })) + + await click(button(root, 'Rotate key')) + expect(content(root)).toContain('does not invalidate an old backup') + await click(button(dialog(root), 'Rotate')) + + expect(rotate).toHaveBeenCalledOnce() + expect(content(root)).toContain('Uploads to this bucket are still open') + }) +}) diff --git a/src/components/storage/BucketEncryptionSettings.vue b/src/components/storage/BucketEncryptionSettings.vue new file mode 100644 index 00000000..6c8f97f6 --- /dev/null +++ b/src/components/storage/BucketEncryptionSettings.vue @@ -0,0 +1,144 @@ + + + diff --git a/src/components/storage/BucketEncryptionTab.test.ts b/src/components/storage/BucketEncryptionTab.test.ts index 8e02bdd9..a7e76ee5 100644 --- a/src/components/storage/BucketEncryptionTab.test.ts +++ b/src/components/storage/BucketEncryptionTab.test.ts @@ -41,6 +41,7 @@ const DialogStub = defineComponent({ const tab = compileClientComponent(new URL('./BucketEncryptionTab.vue', import.meta.url), { vue: VueRuntime, '@lucide/vue': new Proxy({}, { get: () => Slotted('i') }), + '@/components/storage/BucketEncryptionSettings.vue': moduleDefault(defineComponent(() => () => h('section', 'mode settings'))), '@/components/storage/BucketHoldersSection.vue': moduleDefault(defineComponent(() => () => h('section', 'holder list'))), '@/components/storage/BucketUnlockDialog.vue': moduleDefault(DialogStub), '@/components/ui/Badge.vue': moduleDefault(Slotted('span')), @@ -215,6 +216,9 @@ describe('bucket encryption tab', () => { expect(text).toContain('No key action is open to you right now.') expect(text).not.toContain('Lock now') expect(text).not.toContain('holder list') + expect(text).not.toContain('mode settings') expect(await render('ready', encrypted())).toContain('holder list') + const admin = { holder: false, ready_copy: false, admin: true } + expect(await render('ready', encrypted({ mode: 'off', caller: admin }))).toContain('mode settings') }) }) diff --git a/src/components/storage/BucketEncryptionTab.vue b/src/components/storage/BucketEncryptionTab.vue index b6811a29..29468d8b 100644 --- a/src/components/storage/BucketEncryptionTab.vue +++ b/src/components/storage/BucketEncryptionTab.vue @@ -2,6 +2,7 @@ // The encryption of one bucket as the node that hosts it reports it: mode, lock // state, key fingerprint, format, holders, recovery and any rewrite that runs. import { computed, ref, toRef } from 'vue' +import BucketEncryptionSettings from '@/components/storage/BucketEncryptionSettings.vue' import BucketHoldersSection from '@/components/storage/BucketHoldersSection.vue' import BucketUnlockDialog from '@/components/storage/BucketUnlockDialog.vue' import Badge from '@/components/ui/Badge.vue' @@ -189,6 +190,14 @@ const WARNINGS = [ :source="encryption" /> + + { expect(transitionView({ ...visited, cleanup_remaining: 0 })).toMatchObject({ state: 'Finished', complete: true }) expect(transitionView({ ...TRANSITION, remaining: null }).detail).toContain('an unknown number left') }) + + it('says which mode changes need an unlock and what they do', () => { + const plain = { mode: 'off', cipher: 'chacha20_poly1305', block_keys: 'content_derived' } as BucketEncryptionResponse + const managed = { ...plain, mode: 'node_managed' } as BucketEncryptionResponse + const draft = { mode: 'vault_locked' as const, cipher: plain.cipher, block_keys: plain.block_keys, max_unlock_ms: null } + + expect(changeNeedsUnlock(plain, draft)).toBe(false) + expect(changeNotes(plain, draft).join(' ')).toContain('no unlock is needed') + expect(changeNeedsUnlock(managed, draft)).toBe(true) + expect(changeNotes(managed, draft).join(' ')).toContain('An old backup of the node still holds the old key.') + expect(changeNeedsUnlock(managed, { ...draft, mode: 'node_managed', max_unlock_ms: 5 })).toBe(false) + expect(changeNeedsUnlock(managed, { ...draft, mode: 'node_managed', cipher: 'aes256_gcm' })).toBe(true) + expect(changeNeedsUnlock(managed, { ...draft, mode: 'off' })).toBe(true) + }) }) diff --git a/src/lib/bucketEncryption.ts b/src/lib/bucketEncryption.ts index 8c52e71b..174a2bfc 100644 --- a/src/lib/bucketEncryption.ts +++ b/src/lib/bucketEncryption.ts @@ -204,3 +204,61 @@ export function encryptionError(error: unknown): string { const code = error instanceof ApiError ? (error.code ?? '') : '' return REFUSALS[code] ?? apiErrorMessage(error) } + +/** The settings an admin may change with PUT; the key generation is the one shown. */ +export interface EncryptionDraft { + mode: EncryptionMode + cipher: BlockCipher + block_keys: BlockKeys + max_unlock_ms: number | null +} + +function formatChanged(from: BucketEncryptionResponse, to: EncryptionDraft): boolean { + return from.cipher !== to.cipher || from.block_keys !== to.block_keys +} + +/** Changes that read stored data or the private key need the bucket unlocked first. */ +export function changeNeedsUnlock(from: BucketEncryptionResponse, to: EncryptionDraft): boolean { + if (from.mode === 'off') return false + return to.mode !== from.mode || formatChanged(from, to) +} + +/** Short notes on what a change does, shown before it is saved. */ +export function changeNotes(from: BucketEncryptionResponse, to: EncryptionDraft): string[] { + const notes: string[] = [] + if (from.mode === 'off' && to.mode !== 'off') { + notes.push('Stored versions are encrypted in the background with the public key; no unlock is needed.') + notes.push('Old plaintext copies are removed where the storage backend allows it; old backups keep them.') + } else if (from.mode !== 'off' && to.mode === 'off') { + notes.push('Stored versions are decrypted in the background; the keys stay until every version is rewritten.') + } else if (from.mode === 'vault_locked' && to.mode === 'node_managed') { + notes.push('The node keeps its own copy of the key and unlocks the bucket at startup.') + notes.push('This protects only against the storage provider, not against this node.') + } else if (from.mode === 'node_managed' && to.mode === 'vault_locked') { + notes.push('A new key is made, stored versions are granted to it, and the node copy is removed.') + notes.push('An old backup of the node still holds the old key.') + } + if (to.mode !== 'off' && from.mode !== 'off' && formatChanged(from, to)) { + notes.push('Stored versions are written again in the new format.') + } + if (to.mode === 'vault_locked') { + notes.push('A node restart locks the bucket. Recovery needs a usable recovery code or another ready key holder.') + } + return notes +} + +export const ROTATION_NOTES = [ + 'A new key is made and every stored version is granted to it; the data itself is not rewritten.', + 'Rotation does not invalidate an old backup that still holds the old key.', +] + +export function maxUnlockOptions(current: number | null): { value: string; label: string }[] { + const options = [ + { value: '', label: 'No limit: until lock or restart' }, + ...[HOUR, 8 * HOUR, 24 * HOUR, 7 * 24 * HOUR].map((ms) => ({ value: String(ms), label: formatDuration(ms) })), + ] + if (current !== null && !options.some((option) => option.value === String(current))) { + options.push({ value: String(current), label: formatDuration(current) }) + } + return options +} From 09d56a9f64ae394b7cbcb63696008cfb58c451bb Mon Sep 17 00:00:00 2001 From: Sebastian Beyvers Date: Sun, 4 Oct 2026 12:23:41 +0200 Subject: [PATCH 12/86] feat: show jobs waiting for a bucket key and restart lock notices --- src/components/jobs/JobDetailPanel.vue | 2 + .../jobs/JobDistributedDetail.test.ts | 24 +++++++++++ src/components/jobs/JobKeyWait.test.ts | 41 +++++++++++++++++++ src/components/jobs/JobKeyWait.vue | 27 ++++++++++++ src/components/jobs/JobsPanel.vue | 2 +- src/lib/bucketEncryption.ts | 10 +++++ src/lib/jobs.ts | 12 ++++++ src/lib/notifications.test.ts | 16 ++++++++ src/lib/notifications.ts | 11 ++++- src/lib/stateBadge.ts | 1 + 10 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 src/components/jobs/JobKeyWait.test.ts create mode 100644 src/components/jobs/JobKeyWait.vue diff --git a/src/components/jobs/JobDetailPanel.vue b/src/components/jobs/JobDetailPanel.vue index 72739319..786cde13 100644 --- a/src/components/jobs/JobDetailPanel.vue +++ b/src/components/jobs/JobDetailPanel.vue @@ -12,6 +12,7 @@ import CopyButton from '@/components/ui/CopyButton.vue' import JobArtifactButton from '@/components/jobs/JobArtifactButton.vue' import JobAuditTrail from '@/components/jobs/JobAuditTrail.vue' import JobFamilySection from '@/components/jobs/JobFamilySection.vue' +import JobKeyWait from '@/components/jobs/JobKeyWait.vue' import JobReportPanel from '@/components/jobs/JobReportPanel.vue' import JobStateBadge from '@/components/jobs/JobStateBadge.vue' import { useJobDetail } from '@/composables/useJobs' @@ -96,6 +97,7 @@ async function confirmCancel() {
+

Progress

() => h('section', 'native family detail')), ), + '@/components/jobs/JobKeyWait.vue': moduleDefault(defineComponent(() => () => h('aside', 'waits for an unlock'))), '@/components/jobs/JobReportPanel.vue': moduleDefault(PassThroughStub), '@/components/jobs/JobStateBadge.vue': moduleDefault(JobStateBadgeStub), '@/composables/useJobs': { @@ -390,6 +391,29 @@ describe('distributed job detail components', () => { { destination_key: 'in/config.yaml', bytes: 2048, source_node_id: null, transfer_ms: 0 }, ] + it('tells a job that waits for a bucket key apart from a failed one', async () => { + const waiting: JobStatusResponse = { + job_id: '01JJRSTVWXYZ0123456789ABCE', + kind: 'staging', + state: 'awaiting_key', + attempts: 0, + cancel_requested: false, + created_at: '2026-04-09T14:23:11.123+00:00', + updated_at: '2026-04-09T14:31:47.902+00:00', + progress: { current: 0, unit: 'inputs' }, + workspace_mode: 'none', + awaiting_keys: [{ node_id: 'node-b', bucket: 'reef' }], + } + + const mounted = await mount(jobPanel(waiting), { jobId: waiting.job_id, open: true }) + + expect(mounted.errors).toEqual([]) + expect(content(mounted.root)).toContain('waits for an unlock') + expect(Jobs.JOB_STATE_META.awaiting_key).toEqual({ label: 'Waiting for a key', variant: 'warn' }) + expect(Jobs.isTerminalJobState('awaiting_key')).toBe(false) + mounted.app.unmount() + }) + it('states no workspace detail, whatever the node reports', async () => { // A node may still serve a mode and a bucket; a run owns neither any more. const JobDetailPanel = jobPanel({ diff --git a/src/components/jobs/JobKeyWait.test.ts b/src/components/jobs/JobKeyWait.test.ts new file mode 100644 index 00000000..1ca187ec --- /dev/null +++ b/src/components/jobs/JobKeyWait.test.ts @@ -0,0 +1,41 @@ +import { defineComponent, h } from 'vue' +import * as VueRuntime from 'vue' +import { describe, expect, it } from 'vitest' +import * as Wording from '@/lib/bucketEncryption' +import { compileClientComponent, content, element, mountApp, moduleDefault } from '@/test/clientRender' + +const Slotted = (tag: string) => + defineComponent({ inheritAttrs: false, setup: (_, { attrs, slots }) => () => h(tag, attrs, slots.default?.()) }) +const LinkStub = defineComponent({ + props: { to: Object }, + setup: (props, { slots }) => () => h('a', { 'data-to': JSON.stringify(props.to) }, slots.default?.()), +}) + +const wait = compileClientComponent(new URL('./JobKeyWait.vue', import.meta.url), { + vue: VueRuntime, + 'vue-router': { RouterLink: LinkStub }, + '@/components/ui/NodeLabel.vue': moduleDefault(defineComponent({ props: { nodeId: String }, setup: (props) => () => h('span', props.nodeId) })), + '@/components/ui/Notice.vue': moduleDefault(Slotted('aside')), + '@/lib/bucketEncryption': Wording, +}) + +describe('job waiting for a bucket key', () => { + it('links every locked bucket to its Encryption tab on its own node', async () => { + const { root } = await mountApp(wait, { props: { waits: [{ node_id: 'node-b', bucket: 'reef', group_id: 'g-1' }] } }) + const link = element(root, (node) => node.tag === 'a') + + expect(content(root)).toContain('neither fails nor') + expect(content(root)).toContain('reef on node-b') + expect(JSON.parse(String(link.props['data-to']))).toEqual({ + name: 'bucket-storage', + params: { bucketId: 'reef' }, + query: { tab: 'encryption', node: 'node-b', group: 'g-1' }, + }) + }) + + it('says so when the node named no bucket', async () => { + const { root } = await mountApp(wait, { props: { waits: [] } }) + + expect(content(root)).toContain('did not say which bucket') + }) +}) diff --git a/src/components/jobs/JobKeyWait.vue b/src/components/jobs/JobKeyWait.vue new file mode 100644 index 00000000..936788df --- /dev/null +++ b/src/components/jobs/JobKeyWait.vue @@ -0,0 +1,27 @@ + + + diff --git a/src/components/jobs/JobsPanel.vue b/src/components/jobs/JobsPanel.vue index d61ea213..19b576f5 100644 --- a/src/components/jobs/JobsPanel.vue +++ b/src/components/jobs/JobsPanel.vue @@ -59,7 +59,7 @@ const { // Grouped state chips over the loaded list (the tasks-panel treatment). type StateGroup = 'all' | 'active' | 'done' | 'failed' | 'cancelled' const GROUP_STATES: Record, JobState[]> = { - active: ['queued', 'claimed', 'preparing', 'ready', 'running', 'cancelling'], + active: ['queued', 'awaiting_key', 'claimed', 'preparing', 'ready', 'running', 'cancelling'], done: ['succeeded'], failed: ['failed', 'indeterminate'], cancelled: ['cancelled'], diff --git a/src/lib/bucketEncryption.ts b/src/lib/bucketEncryption.ts index 174a2bfc..89213ccc 100644 --- a/src/lib/bucketEncryption.ts +++ b/src/lib/bucketEncryption.ts @@ -15,6 +15,7 @@ import { type HolderState, type RecoveryStatus, } from './api' +import type { RouteLocationRaw } from 'vue-router' import { formatDuration } from './utils' export const MODE_LABEL: Record = { @@ -262,3 +263,12 @@ export function maxUnlockOptions(current: number | null): { value: string; label } return options } + +/** The Encryption tab of a bucket on the node that hosts it, where a key holder unlocks it. */ +export function bucketUnlockLink(bucket: string, nodeId?: string | null, groupId?: string | null): RouteLocationRaw { + return { + name: 'bucket-storage', + params: { bucketId: bucket }, + query: { tab: 'encryption', ...(nodeId ? { node: nodeId } : {}), ...(groupId ? { group: groupId } : {}) }, + } +} diff --git a/src/lib/jobs.ts b/src/lib/jobs.ts index 35fae634..1e3b5ce7 100644 --- a/src/lib/jobs.ts +++ b/src/lib/jobs.ts @@ -11,6 +11,7 @@ import { errorMessage } from './utils' // JobState::name(): stable machine-readable names, closed set. export type JobState = | 'queued' + | 'awaiting_key' | 'claimed' | 'preparing' | 'ready' @@ -148,6 +149,8 @@ export interface JobStatusResponse { workspace_mode: string /** Present for a notebook session: the catalog runtime it runs. */ session_runtime?: string + /** The locked buckets an `awaiting_key` job waits for; omitted in other states. */ + awaiting_keys?: JobKeyWait[] // This node spent its attempts without a job-specific verdict; not a proven // failure. Served with a default, so an older node omits it. locally_exhausted?: boolean @@ -156,6 +159,13 @@ export interface JobStatusResponse { family?: JobFamilyResponse } +/** A locked bucket a job needs; an unlock of it lets the job go on. */ +export interface JobKeyWait { + node_id: string + bucket: string + group_id?: string +} + export interface JobListResponse { jobs: JobStatusResponse[] // Opaque base64url cursor; omitted on the last page. Pass back verbatim. @@ -689,6 +699,7 @@ export function placementVerdict(placement?: PlacementLike | null): PlacementVer export const JOB_STATE_ORDER: JobState[] = [ 'queued', + 'awaiting_key', 'claimed', 'preparing', 'ready', @@ -709,6 +720,7 @@ export function isTerminalJobState(state: JobState): boolean { const JOB_STATE_LABEL: Record = { queued: 'Queued', + awaiting_key: 'Waiting for a key', claimed: 'Claimed', preparing: 'Preparing', ready: 'Ready', diff --git a/src/lib/notifications.test.ts b/src/lib/notifications.test.ts index d056ae13..8f8b1f8f 100644 --- a/src/lib/notifications.test.ts +++ b/src/lib/notifications.test.ts @@ -2,6 +2,22 @@ import { describe, expect, it } from 'vitest' import { describeNotification } from './notifications' import type { ApiNotification } from '@/lib/api' +describe('restart lock notifications', () => { + it('opens the Encryption tab of the bucket on the node that restarted', () => { + const notification: ApiNotification = { + id: 'notification-2', category: 'storage.encryption', kind: 'bucket_locked_by_restart', + class: 'direct', created_at_ms: 1, read: false, bucket: 'reef', node_id: 'node-b', group_id: 'group-1', + } + const display = describeNotification(notification, { groupName: () => undefined }) + expect(display.title).toBe('A node restart locked reef') + expect(display.link).toEqual({ + name: 'bucket-storage', + params: { bucketId: 'reef' }, + query: { tab: 'encryption', node: 'node-b', group: 'group-1' }, + }) + }) +}) + describe('join request notifications', () => { it('names the group and opens its Members tab', () => { const notification: ApiNotification = { diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index 4e67b678..eb268a75 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -1,7 +1,8 @@ import type { Component } from 'vue' import type { RouteLocationRaw } from 'vue-router' -import { AlertTriangle, ArrowLeftRight, Bell, FileJson2, HardDrive, Upload, UserMinus, UserPlus, Users } from '@lucide/vue' +import { AlertTriangle, ArrowLeftRight, Bell, FileJson2, HardDrive, Lock, Upload, UserMinus, UserPlus, Users } from '@lucide/vue' import type { ApiNotification } from '@/lib/api' +import { bucketUnlockLink } from '@/lib/bucketEncryption' import { formatBytes, truncateMiddle } from '@/lib/utils' // Lookup helpers the caller can provide so titles use display names the portal @@ -103,6 +104,14 @@ export const NOTIFICATION_KINDS: Record = { ? { name: 'bucket', params: { bucketId: n.bucket }, query: n.node_id ? { node: n.node_id } : {} } : null, }, + // Encryption stage 4: sent to each key holder when a node restart locked a + // vault-locked bucket that was unlocked before; opens its Encryption tab. + bucket_locked_by_restart: { + icon: Lock, + title: (n) => (n.bucket ? `A node restart locked ${n.bucket}` : 'A node restart locked a bucket'), + detail: () => 'A key holder must unlock it again before its data can be read.', + link: (n) => (n.bucket ? bucketUnlockLink(n.bucket, n.node_id, n.group_id) : null), + }, sync_failed: { icon: AlertTriangle, title: (n) => (n.bucket ? `Bucket sync failed for ${n.bucket}` : 'Bucket sync failed'), diff --git a/src/lib/stateBadge.ts b/src/lib/stateBadge.ts index d752e185..88fd10a9 100644 --- a/src/lib/stateBadge.ts +++ b/src/lib/stateBadge.ts @@ -60,6 +60,7 @@ const STATE_TONE: Record = { preempted: 'attention', // System job states. claimed: 'progress', + awaiting_key: 'attention', preparing: 'progress', ready: 'info', cancelling: 'attention', From 85c4064b1746fecd8226243a71d58ed7ad251124 Mon Sep 17 00:00:00 2001 From: Sebastian Beyvers Date: Sun, 4 Oct 2026 12:26:25 +0200 Subject: [PATCH 13/86] test: unlock a vault-locked bucket through the whole browser flow --- .../useBucketEncryption.flow.test.ts | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 src/composables/useBucketEncryption.flow.test.ts diff --git a/src/composables/useBucketEncryption.flow.test.ts b/src/composables/useBucketEncryption.flow.test.ts new file mode 100644 index 00000000..c9112a4b --- /dev/null +++ b/src/composables/useBucketEncryption.flow.test.ts @@ -0,0 +1,167 @@ +// The whole browser unlock against fake nodes: the real vault, copy opening, +// unlock flow and bucket state, with the shared vectors as the node's data. +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import copy from '@/lib/vault/__fixtures__/bucket-copy.json' +import { createVault, sealKeypair } from '@/lib/vault/crypto' + +vi.mock('./s3/endpoints', () => ({ + localNodeId: () => 'node-a', + nodeApiBase: (id: string) => (id === copy.node_id ? 'https://b.test/api/v1' : 'https://a.test/api/v1'), +})) + +const KEY_ID = copy.key_id +const PASSPHRASE = 'correct horse battery' + +function bytes(hex: string): Uint8Array { + return Uint8Array.from(hex.match(/../g) ?? [], (pair) => parseInt(pair, 16)) +} + +function base64(hex: string): string { + return btoa(String.fromCharCode(...bytes(hex))) +} + +/** A vault whose only keypair is the vector user key, retired as after a rotation elsewhere. */ +async function retiredVault(): Promise { + const created = await createVault(PASSPHRASE, false, { iterations: 500 }) + const entry = await sealKeypair(created.masterKey, KEY_ID, bytes(copy.recipient_private), '2026-10-01T00:00:00Z') + return JSON.stringify({ ...created.payload, keys: [{ ...entry, retired_at: '2026-10-02T00:00:00Z' }] }) +} + +interface NodeCall { + method: string + url: URL + key?: string +} + +async function fakeNodes() { + const calls: NodeCall[] = [] + const stored: string[] = [] + const directory = [{ record_id: 'R1', key_id: KEY_ID, public_key: base64(copy.recipient_public), fingerprint: '', has_recovery: true, created_at: '' }] + let heads = [{ revision: 'R0001', predecessors: [], payload: await retiredVault(), updated_at: '' }] + let unlocked = false + const status = () => ({ + bucket: 'reef', + mode: 'vault_locked', + bucket_id: copy.bucket_id, + storage_generation: 1, + key_generation: copy.generation, + public_key: base64(copy.bucket_public), + fingerprint: copy.bucket_fingerprint, + cipher: 'chacha20_poly1305', + block_keys: 'content_derived', + max_unlock_ms: null, + unlock: unlocked + ? { state: 'unlocked', lock_reason: null, locked_at_ms: null, session_id: 'S1', unlocked_at_ms: 2, deadline_ms: 3_600_002, max_deadline_ms: null } + : { state: 'locked', lock_reason: 'restart', locked_at_ms: 1, session_id: null, unlocked_at_ms: null, deadline_ms: null, max_deadline_ms: null }, + holders: { ready: 1, pending: 0, missing_key: 0 }, + recovery: { state: 'met', ready_holders: 1, ready_with_recovery: 1 }, + transition: null, + caller: { holder: true, ready_copy: true, admin: false }, + }) + const sealedCopy = { + bucket_id: copy.bucket_id, + generation: copy.generation, + key_record: copy.key_record, + key_id: KEY_ID, + enc: base64(copy.enc), + ciphertext: base64(copy.ciphertext), + created_at_ms: 1, + } + vi.stubGlobal('window', { location: { origin: 'https://portal.test' } }) + vi.stubGlobal('localStorage', { getItem: () => null, setItem: (_: string, value: string) => stored.push(value) }) + vi.stubGlobal('fetch', vi.fn(async (input: URL, init: RequestInit) => { + const url = new URL(String(input)) + const method = init.method ?? 'GET' + const body = init.body instanceof Uint8Array ? Array.from(init.body, (b) => b.toString(16).padStart(2, '0')).join('') : undefined + calls.push({ method, url, key: body }) + const path = url.pathname.replace('/api/v1', '') + const json = (value: unknown) => new Response(JSON.stringify(value)) + if (path === '/access/users/me/vault' && method === 'GET') return json({ heads }) + if (path === '/access/users/me/vault' && method === 'PUT') { + const request = JSON.parse(String(init.body)) + heads = [{ revision: 'R0002', predecessors: request.predecessors, payload: request.payload, updated_at: '' }] + return json({ heads }) + } + if (path.startsWith('/access/users/') && path.endsWith('/keys') && method === 'GET') return json({ keys: directory }) + if (path === '/access/users/me/keys') { + const request = JSON.parse(String(init.body)) + directory.unshift({ ...request, record_id: 'R2', fingerprint: '', created_at: '' }) + return json(directory[0]) + } + if (path.endsWith('/storage/encryption')) return json(status()) + if (path.endsWith('/storage/compression')) return json({ bucket: 'reef', mode: 'off' }) + if (path.endsWith('/copies/me')) return json({ copies: [sealedCopy] }) + if (path.endsWith('/unlock')) { + unlocked = true + return json(status().unlock) + } + return new Response('{}', { status: 404 }) + })) + return { calls, stored } +} + +async function signIn() { + vi.resetModules() + const state = await import('./aruna/state') + state.apiBaseUrl.value = 'https://a.test/api/v1' + state.authToken.value = 'bearer-1' + state.userInfo.value = { user: { user_id: copy.user_id }, realm: { realm_id: copy.realm_id } } as never + const { useUserVault } = await import('./useUserVault') + const { useBucketEncryption } = await import('./useBucketEncryption') + return { state, vault: useUserVault(), useBucketEncryption } +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('unlocking a vault-locked bucket from the browser', () => { + it('opens the copy with a retired vault key and sends only that bucket key to its node', async () => { + const { calls, stored } = await fakeNodes() + const { vault: keys, useBucketEncryption } = await signIn() + await keys.load() + await keys.unlock(PASSPHRASE) + const encryption = useBucketEncryption(ref('reef'), ref(copy.node_id), ref('g-1')) + await vi.waitFor(() => expect(encryption.state.value).toBe('ready')) + + const outcome = await encryption.unlock(3_600_000) + + expect(outcome).toMatchObject({ kind: 'unlocked', ownKey: 'matches' }) + expect(encryption.status.value?.unlock?.state).toBe('unlocked') + const unlock = calls.find((call) => call.url.pathname.endsWith('/unlock'))! + expect(unlock.url.origin).toBe('https://b.test') + expect(unlock.key).toBe(copy.bucket_private) + expect(Object.fromEntries(unlock.url.searchParams)).toEqual({ + bucket_id: copy.bucket_id, + generation: String(copy.generation), + duration_ms: '3600000', + }) + // The new vault key was published before the copy was fetched. + const published = calls.findIndex((call) => call.url.pathname.endsWith('/users/me/keys')) + const fetched = calls.findIndex((call) => call.url.pathname.endsWith('/copies/me')) + expect(published).toBeGreaterThanOrEqual(0) + expect(published).toBeLessThan(fetched) + // Nothing the browser stored holds the bucket key in any encoding. + for (const value of stored) { + expect(value).not.toContain(copy.bucket_private) + expect(value).not.toContain(base64(copy.bucket_private)) + } + }) + + it('sends nothing for a bucket shown under another session', async () => { + const { calls } = await fakeNodes() + const { state, vault: keys, useBucketEncryption } = await signIn() + await keys.load() + await keys.unlock(PASSPHRASE) + const encryption = useBucketEncryption(ref('reef'), ref(copy.node_id), ref('g-1')) + await vi.waitFor(() => expect(encryption.state.value).toBe('ready')) + + const pending = encryption.unlock() + state.sessionEpoch.value += 1 + const outcome = await pending.catch((error: unknown) => error) + + expect(outcome === null || outcome instanceof Error).toBe(true) + expect(calls.some((call) => call.url.pathname.endsWith('/unlock'))).toBe(false) + }) +}) From 5c3f66644ca79a7fc31168d3d6630e3b9bb30182 Mon Sep 17 00:00:00 2001 From: Sebastian Beyvers Date: Sun, 4 Oct 2026 12:26:26 +0200 Subject: [PATCH 14/86] feat: document encrypted buckets --- .../storage/BucketEncryptionTab.test.ts | 1 + .../storage/BucketEncryptionTab.vue | 2 + src/docs/v1.ts | 64 +++++++++++++++++++ src/views/DocsView.test.ts | 1 + 4 files changed, 68 insertions(+) diff --git a/src/components/storage/BucketEncryptionTab.test.ts b/src/components/storage/BucketEncryptionTab.test.ts index a7e76ee5..92b38034 100644 --- a/src/components/storage/BucketEncryptionTab.test.ts +++ b/src/components/storage/BucketEncryptionTab.test.ts @@ -47,6 +47,7 @@ const tab = compileClientComponent(new URL('./BucketEncryptionTab.vue', import.m '@/components/ui/Badge.vue': moduleDefault(Slotted('span')), '@/components/ui/Button.vue': moduleDefault(ButtonStub), '@/components/ui/DetailList.vue': moduleDefault(DetailStub), + '@/components/ui/DocsLink.vue': moduleDefault(Slotted('a')), '@/components/ui/NodeLabel.vue': moduleDefault(Slotted('span')), '@/components/ui/Notice.vue': moduleDefault(NoticeStub), '@/components/ui/RefreshButton.vue': moduleDefault(Slotted('button')), diff --git a/src/components/storage/BucketEncryptionTab.vue b/src/components/storage/BucketEncryptionTab.vue index 29468d8b..d9c7c241 100644 --- a/src/components/storage/BucketEncryptionTab.vue +++ b/src/components/storage/BucketEncryptionTab.vue @@ -8,6 +8,7 @@ import BucketUnlockDialog from '@/components/storage/BucketUnlockDialog.vue' import Badge from '@/components/ui/Badge.vue' import Button from '@/components/ui/Button.vue' import DetailList, { type Detail } from '@/components/ui/DetailList.vue' +import DocsLink from '@/components/ui/DocsLink.vue' import NodeLabel from '@/components/ui/NodeLabel.vue' import Notice from '@/components/ui/Notice.vue' import RefreshButton from '@/components/ui/RefreshButton.vue' @@ -118,6 +119,7 @@ const WARNINGS = [

Encryption

+ {{ lock.label }}