diff --git a/console/nginx/default.conf.template b/console/nginx/default.conf.template index df5a26d4..f6523d73 100644 --- a/console/nginx/default.conf.template +++ b/console/nginx/default.conf.template @@ -58,6 +58,17 @@ server { proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; } + # Public artifact viewer. The AES key lives only in the URL fragment, but + # these page-scoped headers also prevent referrer leakage, framing, stale + # decrypted navigation state, and unexpected network capabilities. + location ^~ /s/ { + add_header Referrer-Policy "no-referrer" always; + add_header Cache-Control "no-store" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Content-Security-Policy "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; frame-src blob:; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" always; + try_files $uri /index.html; + } + # SPA fallback. location / { try_files $uri /index.html; diff --git a/console/src/App.tsx b/console/src/App.tsx index ffdf8ae6..9ca6fa95 100644 --- a/console/src/App.tsx +++ b/console/src/App.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { Navigate, Route, Routes } from 'react-router-dom'; +import { Navigate, Route, Routes, useLocation } from 'react-router-dom'; import { AppShell } from './components/AppShell'; import { DeviceApiProvider } from './api/DeviceApiProvider'; import { OnboardingGate } from './pages/OnboardingGate'; @@ -21,6 +21,7 @@ import { ClusterConnectionsPage } from './pages/ClusterConnectionsPage'; import { DeviceAuthorizePage } from './pages/DeviceAuthorizePage'; import { NotFoundPage } from './pages/NotFoundPage'; import { SetupPage } from './pages/SetupPage'; +import { SharedArtifactPage } from './pages/SharedArtifactPage'; import { useToast } from './components/Toast'; import { readQueryParam, stripQueryParams } from './lib/url'; @@ -56,6 +57,14 @@ function useLinkFlash() { } export function App() { + const location = useLocation(); + if (location.pathname.startsWith('/s/')) { + return } />} />; + } + return ; +} + +function AuthenticatedApp() { useLinkFlash(); return ( // The gate owns everything before a verified session exists: environment diff --git a/console/src/lib/artifactShareCrypto.ts b/console/src/lib/artifactShareCrypto.ts new file mode 100644 index 00000000..ad04ecf3 --- /dev/null +++ b/console/src/lib/artifactShareCrypto.ts @@ -0,0 +1,125 @@ +export const ARTIFACT_SHARE_PROTOCOL = 'jcode-artifact-share-v1' as const; + +export type ArtifactSharePart = 'metadata' | 'content'; + +export type EncryptedArtifactMetadata = { + nonce: string; + ciphertext: string; + plaintext_length: number; +}; + +export type SharedArtifactEnvelope = { + share_id: string; + protocol: typeof ARTIFACT_SHARE_PROTOCOL; + artifact_id: string; + revision: number; + encrypted_metadata: EncryptedArtifactMetadata; + ciphertext_size: number; + ciphertext_sha256: string; + expires_at: string; +}; + +export type SharedArtifactMetadata = { + title: string; + relative_path: string; + media_type: string; + kind: string; + size: number; +}; + +const encoder = new TextEncoder(); + +function exactArrayBuffer(bytes: Uint8Array): ArrayBuffer { + return Uint8Array.from(bytes).buffer; +} + +export function artifactShareAAD( + shareId: string, + part: ArtifactSharePart, + artifactId: string, + revision: number, + plaintextLength: number, +): Uint8Array { + if (!Number.isSafeInteger(revision) || revision <= 0 || !Number.isSafeInteger(plaintextLength) || plaintextLength < 0) { + throw new Error('artifact_decrypt_failed'); + } + return encoder.encode(`${ARTIFACT_SHARE_PROTOCOL}\n${shareId}\n${part}\n${artifactId}\n${revision}\n${plaintextLength}`); +} + +function fromBase64URL(value: string): Uint8Array { + if (!/^[A-Za-z0-9_-]+$/.test(value)) throw new Error('artifact_decrypt_failed'); + const padded = value.replaceAll('-', '+').replaceAll('_', '/') + '='.repeat((4 - (value.length % 4)) % 4); + const binary = atob(padded); + return Uint8Array.from(binary, (char) => char.charCodeAt(0)); +} + +export function parseArtifactShareKey(hash: string): Uint8Array { + const match = /^#k=v1\.([A-Za-z0-9_-]{43})$/.exec(hash); + if (!match) throw new Error('artifact_decrypt_failed'); + const key = fromBase64URL(match[1]!); + if (key.length !== 32) throw new Error('artifact_decrypt_failed'); + return key; +} + +async function importShareKey(key: Uint8Array): Promise { + return crypto.subtle.importKey('raw', exactArrayBuffer(key), 'AES-GCM', false, ['decrypt']); +} + +async function decrypt( + key: CryptoKey, + nonce: Uint8Array, + ciphertext: Uint8Array, + aad: Uint8Array, +): Promise { + if (nonce.length !== 12 || ciphertext.length < 16) throw new Error('artifact_decrypt_failed'); + try { + return new Uint8Array(await crypto.subtle.decrypt({ + name: 'AES-GCM', + iv: exactArrayBuffer(nonce), + additionalData: exactArrayBuffer(aad), + }, key, exactArrayBuffer(ciphertext))); + } catch { + throw new Error('artifact_decrypt_failed'); + } +} + +export async function decryptArtifactShare( + envelope: SharedArtifactEnvelope, + contentWire: Uint8Array, + keyBytes: Uint8Array, +): Promise<{ metadata: SharedArtifactMetadata; content: Uint8Array }> { + if (envelope.protocol !== ARTIFACT_SHARE_PROTOCOL || contentWire.length !== envelope.ciphertext_size || contentWire.length < 28) { + throw new Error('artifact_decrypt_failed'); + } + const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', exactArrayBuffer(contentWire))); + const digestHex = Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join(''); + if (!/^[a-fA-F0-9]{64}$/.test(envelope.ciphertext_sha256) || digestHex !== envelope.ciphertext_sha256.toLowerCase()) { + throw new Error('artifact_decrypt_failed'); + } + const key = await importShareKey(keyBytes); + const metadataEnvelope = envelope.encrypted_metadata; + const metadataPlaintext = await decrypt( + key, + fromBase64URL(metadataEnvelope.nonce), + fromBase64URL(metadataEnvelope.ciphertext), + artifactShareAAD(envelope.share_id, 'metadata', envelope.artifact_id, envelope.revision, metadataEnvelope.plaintext_length), + ); + if (metadataPlaintext.length !== metadataEnvelope.plaintext_length) throw new Error('artifact_decrypt_failed'); + const contentLength = envelope.ciphertext_size - 28; + const content = await decrypt( + key, + contentWire.slice(0, 12), + contentWire.slice(12), + artifactShareAAD(envelope.share_id, 'content', envelope.artifact_id, envelope.revision, contentLength), + ); + if (content.length !== contentLength) throw new Error('artifact_decrypt_failed'); + try { + const metadata = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(metadataPlaintext)) as SharedArtifactMetadata; + if (!metadata.title || !metadata.media_type || !metadata.kind || metadata.size !== content.length) { + throw new Error('artifact_decrypt_failed'); + } + return { metadata, content }; + } catch { + throw new Error('artifact_decrypt_failed'); + } +} diff --git a/console/src/lib/artifactShareCrypto.vector.test.ts b/console/src/lib/artifactShareCrypto.vector.test.ts new file mode 100644 index 00000000..7ef19bce --- /dev/null +++ b/console/src/lib/artifactShareCrypto.vector.test.ts @@ -0,0 +1,72 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { decryptArtifactShare, parseArtifactShareKey, type SharedArtifactEnvelope } from './artifactShareCrypto'; + +type Vector = { + protocol: 'jcode-artifact-share-v1'; + share_id: string; + artifact_id: string; + revision: number; + key_b64url: string; + metadata_plaintext: string; + metadata_nonce_b64url: string; + metadata_ciphertext_b64url: string; + content_plaintext_b64url: string; + content_wire_b64url: string; + content_wire_sha256: string; +}; + +const decode = (value: string) => { + const padded = value.replaceAll('-', '+').replaceAll('_', '/') + '='.repeat((4 - value.length % 4) % 4); + return Uint8Array.from(atob(padded), (char) => char.charCodeAt(0)); +}; + +describe('artifact share cross-runtime vector', () => { + it('decrypts the canonical JCode AES-GCM wire format in WebCrypto', async () => { + const vector = JSON.parse(readFileSync(resolve(process.cwd(), '../shared/artifact-share-v1.json'), 'utf8')) as Vector; + const metadataLength = new TextEncoder().encode(vector.metadata_plaintext).length; + const wire = decode(vector.content_wire_b64url); + const envelope: SharedArtifactEnvelope = { + share_id: vector.share_id, + protocol: vector.protocol, + artifact_id: vector.artifact_id, + revision: vector.revision, + encrypted_metadata: { + nonce: vector.metadata_nonce_b64url, + ciphertext: vector.metadata_ciphertext_b64url, + plaintext_length: metadataLength, + }, + ciphertext_size: wire.length, + ciphertext_sha256: vector.content_wire_sha256, + expires_at: '2030-01-01T00:00:00Z', + }; + + const result = await decryptArtifactShare(envelope, wire, parseArtifactShareKey(`#k=v1.${vector.key_b64url}`)); + expect(result.metadata).toEqual(JSON.parse(vector.metadata_plaintext)); + expect(result.content).toEqual(decode(vector.content_plaintext_b64url)); + }); + + it('fails closed when the ciphertext digest is absent', async () => { + const vector = JSON.parse(readFileSync(resolve(process.cwd(), '../shared/artifact-share-v1.json'), 'utf8')) as Vector; + const metadataLength = new TextEncoder().encode(vector.metadata_plaintext).length; + const wire = decode(vector.content_wire_b64url); + const envelope: SharedArtifactEnvelope = { + share_id: vector.share_id, + protocol: vector.protocol, + artifact_id: vector.artifact_id, + revision: vector.revision, + encrypted_metadata: { + nonce: vector.metadata_nonce_b64url, + ciphertext: vector.metadata_ciphertext_b64url, + plaintext_length: metadataLength, + }, + ciphertext_size: wire.length, + ciphertext_sha256: '', + expires_at: '2030-01-01T00:00:00Z', + }; + + await expect(decryptArtifactShare(envelope, wire, parseArtifactShareKey(`#k=v1.${vector.key_b64url}`))) + .rejects.toThrow('artifact_decrypt_failed'); + }); +}); diff --git a/console/src/nginxProxyConfig.test.ts b/console/src/nginxProxyConfig.test.ts index 6e201769..22194b31 100644 --- a/console/src/nginxProxyConfig.test.ts +++ b/console/src/nginxProxyConfig.test.ts @@ -14,4 +14,15 @@ describe('console nginx edge contract', () => { expect(webhookLocation).toContain('proxy_pass ${ORCHESTRATOR_URL};'); expect(config.indexOf('location /webhooks/')).toBeLessThan(config.indexOf('location / {')); }); + + it('serves public artifact routes with fragment-safe browser headers', () => { + const shareLocation = config.match(/location \^~ \/s\/ \{([\s\S]*?)\n \}/)?.[1]; + + expect(shareLocation).toContain("try_files $uri /index.html;"); + expect(shareLocation).toContain('Referrer-Policy "no-referrer" always'); + expect(shareLocation).toContain('Cache-Control "no-store" always'); + expect(shareLocation).toContain('Content-Security-Policy'); + expect(shareLocation).toContain("frame-ancestors 'none'"); + expect(config.indexOf('location ^~ /s/')).toBeLessThan(config.indexOf('location / {')); + }); }); diff --git a/console/src/pages/SharedArtifactPage.module.css b/console/src/pages/SharedArtifactPage.module.css new file mode 100644 index 00000000..377e7871 --- /dev/null +++ b/console/src/pages/SharedArtifactPage.module.css @@ -0,0 +1,21 @@ +.page { min-height: 100%; padding: var(--space-6); background: var(--color-bg); color: var(--color-text); } +.header { display: flex; align-items: center; justify-content: space-between; max-width: 1120px; margin: 0 auto var(--space-6); } +.brand { display: inline-flex; align-items: center; gap: var(--space-2); font-size: var(--fs-body); font-weight: var(--fw-semibold); } +.brand svg { color: var(--color-accent); } +.security { color: var(--color-text-dim); font-size: var(--fs-caption); } +.status { display: grid; place-items: center; align-content: center; min-height: 70vh; gap: var(--space-3); text-align: center; } +.status h1 { font-size: var(--fs-h1); } +.status p { max-width: 36rem; color: var(--color-text-dim); } +.spinner { width: var(--space-6); height: var(--space-6); border: 2px solid var(--color-border); border-top-color: var(--color-accent); border-radius: 50%; animation: spin 800ms linear infinite; } +.artifact { max-width: 1120px; margin: 0 auto; overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-panel); box-shadow: var(--shadow-2); } +.meta { display: flex; align-items: end; justify-content: space-between; gap: var(--space-6); padding: var(--space-6); border-bottom: 1px solid var(--color-border-subtle); } +.meta p, .meta > span { color: var(--color-text-dim); font-family: var(--font-mono); font-size: var(--fs-caption); } +.meta h1 { margin-top: var(--space-1); font-size: var(--fs-h1); } +.viewer { min-height: min(72vh, 760px); padding: var(--space-6); } +.text { overflow: auto; margin: 0; white-space: pre-wrap; font-family: var(--font-mono); line-height: 1.6; } +.frame { width: 100%; min-height: min(66vh, 700px); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-bg-inset); } +.image { display: block; max-width: 100%; max-height: 70vh; margin: 0 auto; object-fit: contain; } +.download { display: inline-flex; align-items: center; gap: var(--space-2); color: var(--color-accent); } +@keyframes spin { to { transform: rotate(360deg); } } +@media (max-width: 640px) { .page { padding: var(--space-4); } .security, .meta > span { display: none; } .meta, .viewer { padding: var(--space-4); } } +@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } } diff --git a/console/src/pages/SharedArtifactPage.test.tsx b/console/src/pages/SharedArtifactPage.test.tsx new file mode 100644 index 00000000..08f6e81d --- /dev/null +++ b/console/src/pages/SharedArtifactPage.test.tsx @@ -0,0 +1,117 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { App } from '../App'; +import { artifactShareAAD } from '../lib/artifactShareCrypto'; + +const raw = (bytes: Uint8Array) => + btoa(String.fromCharCode(...bytes)).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, ''); + +async function encrypt( + key: CryptoKey, + nonce: Uint8Array, + plaintext: Uint8Array, + aad: Uint8Array, +): Promise { + return new Uint8Array(await crypto.subtle.encrypt({ + name: 'AES-GCM', + iv: Uint8Array.from(nonce).buffer, + additionalData: Uint8Array.from(aad).buffer, + }, key, Uint8Array.from(plaintext).buffer)); +} + +async function mockEncryptedShare(options: { + shareId: string; + kind: string; + mediaType: string; + content: string; + title?: string; +}) { + const artifactId = 'artifact-public'; + const revision = 2; + const title = options.title ?? 'Artifact report'; + const keyBytes = Uint8Array.from({ length: 32 }, (_, i) => i + 1); + const key = await crypto.subtle.importKey('raw', keyBytes, 'AES-GCM', false, ['encrypt']); + const contentPlaintext = new TextEncoder().encode(options.content); + const metadataPlaintext = new TextEncoder().encode(JSON.stringify({ + title, + relative_path: `reports/result.${options.kind}`, + media_type: options.mediaType, + kind: options.kind, + size: contentPlaintext.length, + })); + const metadataNonce = Uint8Array.from({ length: 12 }, (_, i) => i + 11); + const contentNonce = Uint8Array.from({ length: 12 }, (_, i) => i + 31); + const metadataCiphertext = await encrypt(key, metadataNonce, metadataPlaintext, artifactShareAAD(options.shareId, 'metadata', artifactId, revision, metadataPlaintext.length)); + const contentCiphertext = await encrypt(key, contentNonce, contentPlaintext, artifactShareAAD(options.shareId, 'content', artifactId, revision, contentPlaintext.length)); + const contentWire = new Uint8Array(contentNonce.length + contentCiphertext.length); + contentWire.set(contentNonce); + contentWire.set(contentCiphertext, contentNonce.length); + const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', Uint8Array.from(contentWire).buffer)); + const digestHex = Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join(''); + + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(new Response(JSON.stringify({ + share_id: options.shareId, + protocol: 'jcode-artifact-share-v1', + artifact_id: artifactId, + revision, + encrypted_metadata: { + nonce: raw(metadataNonce), + ciphertext: raw(metadataCiphertext), + plaintext_length: metadataPlaintext.length, + }, + ciphertext_size: contentWire.length, + ciphertext_sha256: digestHex, + expires_at: '2026-08-02T12:00:00Z', + }), { status: 200, headers: { 'Content-Type': 'application/json' } })) + .mockResolvedValueOnce(new Response(contentWire, { status: 200, headers: { 'Content-Type': 'application/octet-stream' } })); + + return keyBytes; +} + +function renderShare(shareId: string, keyBytes: Uint8Array) { + render(); +} + +describe('SharedArtifactPage', () => { + afterEach(() => vi.restoreAllMocks()); + + it('bypasses authenticated console boot, decrypts from the fragment, and renders safe text', async () => { + const shareId = 'share-public'; + const keyBytes = await mockEncryptedShare({ + shareId, + kind: 'markdown', + mediaType: 'text/markdown', + content: '# Result\n\nCiphertext only.', + }); + renderShare(shareId, keyBytes); + + expect(await screen.findByRole('heading', { name: 'Artifact report' })).toBeTruthy(); + expect(screen.getByText(/Ciphertext only/)).toBeTruthy(); + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); + for (const call of vi.mocked(fetch).mock.calls) { + expect(String(call[0])).not.toContain('#k='); + expect(call[1]).toMatchObject({ credentials: 'omit', cache: 'no-store', referrerPolicy: 'no-referrer' }); + } + expect(vi.mocked(fetch).mock.calls.some(([request]) => String(request).includes('/api/v1/me'))).toBe(false); + }); + + it('renders HTML artifacts in a script-capable sandbox without same-origin access', async () => { + const shareId = 'share-html'; + const keyBytes = await mockEncryptedShare({ + shareId, + kind: 'html', + mediaType: 'text/html', + content: '', + title: 'HTML demo', + }); + renderShare(shareId, keyBytes); + + const frame = await screen.findByTitle('HTML demo'); + expect(frame.getAttribute('sandbox')).toBe('allow-scripts'); + expect(frame.getAttribute('sandbox')).not.toContain('allow-same-origin'); + expect(frame.getAttribute('srcdoc')).toContain("default-src 'none'"); + expect(frame.getAttribute('srcdoc')).toContain(''); + }); +}); diff --git a/console/src/pages/SharedArtifactPage.tsx b/console/src/pages/SharedArtifactPage.tsx new file mode 100644 index 00000000..fdc3cd16 --- /dev/null +++ b/console/src/pages/SharedArtifactPage.tsx @@ -0,0 +1,103 @@ +import { DownloadSimple, LockKeyOpen } from '@phosphor-icons/react'; +import { useEffect, useMemo, useState } from 'react'; +import { useLocation, useParams } from 'react-router-dom'; +import { Markdown } from '../components/Markdown'; +import { + decryptArtifactShare, + parseArtifactShareKey, + type SharedArtifactEnvelope, + type SharedArtifactMetadata, +} from '../lib/artifactShareCrypto'; +import styles from './SharedArtifactPage.module.css'; + +type ViewState = + | { phase: 'loading' } + | { phase: 'error'; message: string } + | { phase: 'ready'; metadata: SharedArtifactMetadata; content: Uint8Array }; + +const htmlCSP = "default-src 'none'; img-src data: blob:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'"; + +function escapedMarkdown(source: string): string { + return source.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); +} + +function ArtifactContent({ metadata, content }: { metadata: SharedArtifactMetadata; content: Uint8Array }) { + const text = useMemo(() => { + if (!['markdown', 'text', 'code', 'csv'].includes(metadata.kind)) return ''; + try { return new TextDecoder('utf-8', { fatal: true }).decode(content); } catch { return ''; } + }, [content, metadata.kind]); + const blobURL = useMemo(() => { + if (!['image', 'pdf', 'binary'].includes(metadata.kind) || typeof URL.createObjectURL !== 'function') return ''; + return URL.createObjectURL(new Blob([Uint8Array.from(content).buffer], { type: metadata.media_type || 'application/octet-stream' })); + }, [content, metadata.kind, metadata.media_type]); + useEffect(() => () => { if (blobURL) URL.revokeObjectURL(blobURL); }, [blobURL]); + + if (metadata.kind === 'markdown') return ; + if (['text', 'code', 'csv'].includes(metadata.kind)) return
{text}
; + if (metadata.kind === 'html') { + const source = new TextDecoder().decode(content); + return