Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions console/nginx/default.conf.template
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 10 additions & 1 deletion console/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';

Expand Down Expand Up @@ -56,6 +57,14 @@ function useLinkFlash() {
}

export function App() {
const location = useLocation();
if (location.pathname.startsWith('/s/')) {
return <Routes><Route path="/s/:shareID" element={<SharedArtifactPage />} /><Route path="*" element={<NotFoundPage />} /></Routes>;
}
return <AuthenticatedApp />;
}

function AuthenticatedApp() {
useLinkFlash();
return (
// The gate owns everything before a verified session exists: environment
Expand Down
125 changes: 125 additions & 0 deletions console/src/lib/artifactShareCrypto.ts
Original file line number Diff line number Diff line change
@@ -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<CryptoKey> {
return crypto.subtle.importKey('raw', exactArrayBuffer(key), 'AES-GCM', false, ['decrypt']);
}

async function decrypt(
key: CryptoKey,
nonce: Uint8Array,
ciphertext: Uint8Array,
aad: Uint8Array,
): Promise<Uint8Array> {
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');
}
}
72 changes: 72 additions & 0 deletions console/src/lib/artifactShareCrypto.vector.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
11 changes: 11 additions & 0 deletions console/src/nginxProxyConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 / {'));
});
});
21 changes: 21 additions & 0 deletions console/src/pages/SharedArtifactPage.module.css
Original file line number Diff line number Diff line change
@@ -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; } }
Loading
Loading