Merge branch 'pr/fix/lan-http-secure-uuid(6928c931)' into work/post190-source-acceptance
Demo images / Build & push demo images (push) Failing after 33s

This commit is contained in:
archipelago
2026-10-08 12:10:15 -04:00
7 changed files with 79 additions and 13 deletions
@@ -41,6 +41,7 @@
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { secureUuid } from '@/utils/secureUuid'
import { fileBrowserClient, type FileBrowserItem } from '@/api/filebrowser-client'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
import type { RegistrationRequest, CloudSelection } from '@/composables/useMediaRegistrationBridge'
@@ -48,7 +49,7 @@ const props = defineProps<{ request: RegistrationRequest | null; phase: 'select'
const emit = defineEmits<{ approve: [selection: CloudSelection]; cancel: []; resolve: [] }>()
const modal = ref<HTMLElement | null>(null), directory = ref('/'), files = ref<FileBrowserItem[]>([])
const selected = ref<FileBrowserItem | null>(null), loading = ref(false), localError = ref('')
const titleId = `media-registration-${crypto.randomUUID()}`
const titleId = `media-registration-${secureUuid()}`
let generation = 0
const visible = computed(() => files.value.filter(item => item.isDir || /\.(mp4|m4v|webm|mov)$/i.test(item.name)))
const duration = computed(() => { const seconds = props.request?.intent.viewingSeconds ?? 0; return seconds % 3600 === 0 ? `${seconds / 3600} hours` : `${Math.ceil(seconds / 60)} minutes` })
@@ -0,0 +1,19 @@
import { mount, flushPromises } from '@vue/test-utils'
import { afterEach, expect, it, vi } from 'vitest'
import { webcrypto } from 'node:crypto'
const files = vi.hoisted(() => ({ login: vi.fn(async () => true), listDirectory: vi.fn(async () => []) }))
vi.mock('@/api/filebrowser-client', () => ({ fileBrowserClient: files }))
import MediaRegistrationConsent from '../MediaRegistrationConsent.vue'
afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks() })
it('mounts the inactive dashboard consent and opens its labelled dialog on LAN HTTP', async () => {
vi.stubGlobal('crypto', { getRandomValues: webcrypto.getRandomValues.bind(webcrypto) })
const wrapper = mount(MediaRegistrationConsent, { props: { request: null, phase: 'select', error: '' } })
expect(files.login).not.toHaveBeenCalled()
await wrapper.setProps({ request: { intent: { viewingSeconds: 3600, priceSats: 15 } } as any })
await flushPromises()
const dialog = wrapper.get('[role="dialog"]')
const label = dialog.attributes('aria-labelledby')
expect(label).toMatch(/^media-registration-[0-9a-f-]{36}$/)
expect(wrapper.get('#' + label).text()).toBeTruthy()
wrapper.unmount()
})
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const rpc = vi.hoisted(() => ({ call: vi.fn() }))
vi.mock('@/api/rpc-client', () => ({ rpcClient: rpc }))
import { webcrypto } from 'node:crypto'
import { installedOriginMatches, useMediaRegistrationBridge } from '../useMediaRegistrationBridge'
const intent = { version: 1 as const, requestId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', nonce: 'a'.repeat(64),
@@ -27,6 +28,17 @@ beforeEach(() => {
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() })
describe('native Cloud registration ownership and interrupted operation boundary', () => {
it('persists and resumes the same secure approval on LAN HTTP without randomUUID', async () => {
vi.stubGlobal('crypto', { getRandomValues: webcrypto.getRandomValues.bind(webcrypto) })
const f = fixture(); await f.send('select'); f.bridge.approve(selection)
const approved = f.child.postMessage.mock.lastCall![0].result
expect(approved.approvalId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
f.bridge.dispose()
const resumed = fixture(); await resumed.send('resume')
expect(resumed.child.postMessage.mock.lastCall![0].result).toEqual(approved)
resumed.bridge.dispose()
})
it('ignores other windows/origins and checks installer scope before showing Cloud selection', async () => {
const f = fixture()
await f.send('select', {}, 'https://evil.test'); await f.send('select', {}, undefined, {})
@@ -93,6 +105,7 @@ describe('native Cloud registration ownership and interrupted operation boundary
})
it('recovers resolution approval across reload and cannot use it to prepare a file', async () => {
vi.mocked(Date.now).mockReturnValue(1700_000)
vi.stubGlobal('crypto', { getRandomValues: webcrypto.getRandomValues.bind(webcrypto) })
const first = fixture(); await first.send('resolve')
expect(first.bridge.phase.value).toBe('resolve')
first.bridge.approveResolution()
+2 -10
View File
@@ -1,18 +1,10 @@
import { onBeforeUnmount, watch } from 'vue'
import { useAudioPlayer } from './useAudioPlayer'
import { secureUuid } from '@/utils/secureUuid'
type AudioBridge = { postMessage: (message: string) => void; onmessage: ((event: { data: string }) => void) | null }
type NativeWindow = Window & { ArchipelagoAudio?: AudioBridge }
/** LAN HTTP WebViews expose getRandomValues, but not secure-context randomUUID. */
function audioSessionId(): string {
const bytes = crypto.getRandomValues(new Uint8Array(16))
bytes[6] = (bytes[6]! & 0x0f) | 0x40
bytes[8] = (bytes[8]! & 0x3f) | 0x80
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
/** Send a small decoded thumbnail, never a protected URL or authorization data. */
async function thumbnail(url: string, admittedOrigin: string, signal: AbortSignal): Promise<string> {
if (!url) return ''
@@ -100,7 +92,7 @@ export function useCompanionAudio(player = useAudioPlayer()) {
if (!player.currentSrc.value || !player.currentName.value) { release(); return }
if (!session || source !== player.currentSrc.value) {
if (!player.playing.value) { release(); return }
release(); session = audioSessionId(); source = player.currentSrc.value; sequence = 0
release(); session = secureUuid(); source = player.currentSrc.value; sequence = 0
loadArtwork()
}
const duration = Number.isFinite(player.duration.value) ? Math.max(0, Math.min(player.duration.value, 604800)) : 0
@@ -1,4 +1,5 @@
import { ref, shallowRef } from 'vue'
import { secureUuid } from '@/utils/secureUuid'
import { rpcClient } from '@/api/rpc-client'
import { appPortIsGateFronted } from '@/views/appSession/appSessionConfig'
@@ -102,7 +103,7 @@ export function useMediaRegistrationBridge(context: FrameContext) {
const old = savedApproval(pending.intent)
if (old && !exact(old.selection, selection)) throw new Error('This operation already approved another file. Resume its original selection.')
const saved: SavedApproval = old ?? { version: 1, intent: pending.intent, selection: structuredClone(selection),
approvalId: crypto.randomUUID(), event: { kind: 27236, created_at: Math.floor(Date.now() / 1000),
approvalId: secureUuid(), event: { kind: 27236, created_at: Math.floor(Date.now() / 1000),
tags: [['d', REGISTRATION_SCOPE]], content: JSON.stringify(approvalContent(pending.intent, selection), null, 2) } }
// Persist owner approval before replying. Storage failure cannot silently
// turn a later retry into a newly approved file or a replacement operation.
@@ -114,7 +115,7 @@ export function useMediaRegistrationBridge(context: FrameContext) {
}
function approveResolution() {
if (!pending || pending.mode !== 'resolve' || phase.value !== 'resolve' || !current(pending)) return
const approved = { intent: pending.intent, approvalId: crypto.randomUUID(), event: {
const approved = { intent: pending.intent, approvalId: secureUuid(), event: {
kind: 27237, created_at: Math.floor(Date.now() / 1000), tags: [['d', 'archipelago.media-registration.resolution.v1']],
content: JSON.stringify({ action: 'Recover prepared video or retire this expired incomplete registration',
scope: 'archipelago.media-registration.resolution.v1', intent: pending.intent }, null, 2),
@@ -0,0 +1,27 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { webcrypto } from 'node:crypto'
import { secureUuid } from '../secureUuid'
afterEach(() => vi.unstubAllGlobals())
describe('secure UUIDs across dashboard origins', () => {
it('uses the native secure-context API with its receiver', () => {
const source = { randomUUID() { expect(this).toBe(source); return 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, getRandomValues: vi.fn() }
vi.stubGlobal('crypto', source)
expect(secureUuid()).toBe('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa')
expect(source.getRandomValues).not.toHaveBeenCalled()
})
it('creates distinct UUIDs on LAN HTTP using only getRandomValues', () => {
vi.stubGlobal('crypto', { getRandomValues: webcrypto.getRandomValues.bind(webcrypto) })
const ids = Array.from({ length: 32 }, secureUuid)
expect(new Set(ids).size).toBe(ids.length)
for (const id of ids) expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
})
it('sets the UUID version and variant without losing other random bits', () => {
vi.stubGlobal('crypto', { getRandomValues: (bytes: Uint8Array) => bytes.fill(255) })
expect(secureUuid()).toBe('ffffffff-ffff-4fff-bfff-ffffffffffff')
})
it.each([undefined, {}])('refuses when secure randomness is unavailable (%s)', source => {
vi.stubGlobal('crypto', source)
expect(() => secureUuid()).toThrow('Secure randomness is unavailable.')
})
})
+13
View File
@@ -0,0 +1,13 @@
/** Secure UUIDs also work on LAN HTTP, where randomUUID is unavailable. */
export function secureUuid(): string {
const source = globalThis.crypto
if (typeof source?.randomUUID === 'function') return source.randomUUID()
if (typeof source?.getRandomValues !== 'function') {
throw new Error('Secure randomness is unavailable.')
}
const bytes = source.getRandomValues(new Uint8Array(16))
bytes[6] = (bytes[6]! & 0x0f) | 0x40
bytes[8] = (bytes[8]! & 0x3f) | 0x80
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}