diff --git a/.env.example b/.env.example index df09e3a..2fa9438 100644 --- a/.env.example +++ b/.env.example @@ -52,3 +52,8 @@ ALLOWED_ORIGINS=https://craft.app # Generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" # See docs/field-encryption.md for key rotation procedure. FIELD_ENCRYPTION_KEY=your_64_char_hex_key_here + +# Artifact signing +# ARTIFACT_SIGNING_SECRET=your-signing-secret-here +# Used to sign and verify generated artifacts before deployment. +# Generate with: openssl rand -hex 32 diff --git a/apps/backend/src/services/artifact-signing.service.ts b/apps/backend/src/services/artifact-signing.service.ts new file mode 100644 index 0000000..09bc2fe --- /dev/null +++ b/apps/backend/src/services/artifact-signing.service.ts @@ -0,0 +1,47 @@ +/** + * ArtifactSigningService + * + * Signs and verifies deployment artifacts using SHA-256 + HMAC-SHA256. + * The signing secret is read from process.env.ARTIFACT_SIGNING_SECRET. + * + * Issue: #496 + */ + +import { createHash, createHmac, timingSafeEqual } from 'crypto'; + +export class ArtifactSigningService { + private get secret(): string { + const s = process.env.ARTIFACT_SIGNING_SECRET; + if (!s) throw new Error('ARTIFACT_SIGNING_SECRET environment variable is not set'); + return s; + } + + signArtifact(artifact: Buffer | string): { checksum: string; signature: string } { + const buf = Buffer.isBuffer(artifact) ? artifact : Buffer.from(artifact, 'utf8'); + const checksum = 'sha256:' + createHash('sha256').update(buf).digest('hex'); + const signature = createHmac('sha256', this.secret).update(checksum).digest('hex'); + return { checksum, signature }; + } + + verifyArtifact(artifact: Buffer | string, checksum: string, signature: string): boolean { + try { + const buf = Buffer.isBuffer(artifact) ? artifact : Buffer.from(artifact, 'utf8'); + const expectedChecksum = 'sha256:' + createHash('sha256').update(buf).digest('hex'); + const expectedSignature = createHmac('sha256', this.secret).update(expectedChecksum).digest('hex'); + + const checksumMatch = timingSafeEqual( + Buffer.from(checksum), + Buffer.from(expectedChecksum), + ); + const signatureMatch = timingSafeEqual( + Buffer.from(signature), + Buffer.from(expectedSignature), + ); + return checksumMatch && signatureMatch; + } catch { + return false; + } + } +} + +export const artifactSigningService = new ArtifactSigningService(); diff --git a/apps/backend/src/services/deployment-pipeline.artifact-signing.test.ts b/apps/backend/src/services/deployment-pipeline.artifact-signing.test.ts new file mode 100644 index 0000000..ff642c0 --- /dev/null +++ b/apps/backend/src/services/deployment-pipeline.artifact-signing.test.ts @@ -0,0 +1,300 @@ +/** + * DeploymentPipelineService — Artifact Signing & Verification Tests + * + * Covers: + * - Valid artifact with correct signature proceeds to push + * - Tampered artifact (modified after signing) aborts pipeline + * - Missing signature aborts pipeline + * - Checksum is present in deployment_logs metadata after successful run + * + * Example log metadata written to deployment_logs: + * // { checksum: "sha256:abc123...", timestamp: "...", deploymentId: "..." } + * + * Issue: #496 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('./template-generator.service', () => ({ + templateGeneratorService: { generate: vi.fn() }, + mapCategoryToFamily: vi.fn().mockReturnValue('stellar-dex'), +})); + +import { DeploymentPipelineService } from './deployment-pipeline.service'; +import type { DeploymentPipelineRequest } from './deployment-pipeline.service'; +import type { CustomizationConfig } from '@craft/types'; + +// ── Supabase mock ───────────────────────────────────────────────────────────── + +const mockUpdate = vi.fn().mockReturnValue({ eq: vi.fn().mockResolvedValue({ error: null }) }); +const mockInsert = vi.fn().mockResolvedValue({ error: null }); + +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => ({ + from: (table: string) => ({ + insert: mockInsert, + update: mockUpdate, + select: () => ({ + eq: () => ({ + single: () => { + if (table === 'templates') { + return Promise.resolve({ data: { category: 'dex' }, error: null }); + } + return Promise.resolve({ data: null, error: null }); + }, + }), + }), + }), + auth: { getUser: vi.fn() }, + }), +})); + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const customization: CustomizationConfig = { + branding: { + appName: 'TestApp', + primaryColor: '#000000', + secondaryColor: '#ffffff', + fontFamily: 'Inter', + }, + features: { + enableCharts: true, + enableTransactionHistory: true, + enableAnalytics: false, + enableNotifications: false, + }, + stellar: { + network: 'testnet', + horizonUrl: 'https://horizon-testnet.stellar.org', + }, +}; + +const request: DeploymentPipelineRequest = { + userId: 'user-123', + templateId: 'template-abc', + name: 'my-dex-app', + customization, +}; + +// ── Mock factories ──────────────────────────────────────────────────────────── + +function makeGeneratorMock() { + return { + generate: vi.fn().mockResolvedValue({ + success: true, + generatedFiles: [{ path: 'src/index.ts', content: 'export {}', type: 'code' }], + errors: [], + }), + }; +} + +function makeSyntaxValidatorMock() { + return { validate: vi.fn().mockReturnValue({ valid: true, errors: [] }) }; +} + +function makeGithubMock() { + return { + createRepository: vi.fn().mockResolvedValue({ + repository: { + id: 1, + url: 'https://github.com/org/my-dex-app', + cloneUrl: 'https://github.com/org/my-dex-app.git', + sshUrl: 'git@github.com:org/my-dex-app.git', + fullName: 'org/my-dex-app', + defaultBranch: 'main', + private: true, + }, + resolvedName: 'my-dex-app', + }), + }; +} + +function makeGithubPushMock() { + return { + pushGeneratedCode: vi.fn().mockResolvedValue({ + owner: 'org', + repo: 'my-dex-app', + branch: 'main', + commitSha: 'abc1234', + treeSha: 'def5678', + commitUrl: 'https://github.com/org/my-dex-app/commit/abc1234', + previousCommitSha: '000', + createdBranch: false, + fileCount: 1, + }), + }; +} + +function makeVercelMock() { + return { + createProject: vi.fn().mockResolvedValue({ id: 'prj_abc', name: 'craft-my-dex-app', url: 'craft-my-dex-app.vercel.app' }), + triggerDeployment: vi.fn().mockResolvedValue({ + deploymentId: 'dpl_xyz', + deploymentUrl: 'https://craft-my-dex-app.vercel.app', + status: 'QUEUED', + }), + }; +} + +/** Signing service that always returns a valid sign/verify pair. */ +function makeSigningMock() { + return { + signArtifact: vi.fn().mockReturnValue({ checksum: 'sha256:abc123', signature: 'sig-abc' }), + verifyArtifact: vi.fn().mockReturnValue(true), + }; +} + +/** Signing service whose verifyArtifact always returns false (tampered / missing). */ +function makeFailingVerifyMock() { + return { + signArtifact: vi.fn().mockReturnValue({ checksum: 'sha256:abc123', signature: 'sig-abc' }), + verifyArtifact: vi.fn().mockReturnValue(false), + }; +} + +// ── Helper ──────────────────────────────────────────────────────────────────── + +function getLogInserts(): any[] { + return mockInsert.mock.calls + .map((call: any[]) => call[0]) + .filter((p: any) => p.deployment_id && p.stage && p.message); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('DeploymentPipelineService — artifact signing & verification (#496)', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockInsert.mockResolvedValue({ error: null }); + mockUpdate.mockReturnValue({ eq: vi.fn().mockResolvedValue({ error: null }) }); + }); + + it('valid artifact with correct signature proceeds to push', async () => { + const signingMock = makeSigningMock(); + const pushMock = makeGithubPushMock(); + + const svc = new DeploymentPipelineService( + makeGeneratorMock(), + makeGithubMock(), + pushMock, + makeVercelMock(), + makeSyntaxValidatorMock(), + signingMock, + ); + + const result = await svc.deploy(request); + + expect(result.success).toBe(true); + expect(signingMock.signArtifact).toHaveBeenCalledOnce(); + expect(signingMock.verifyArtifact).toHaveBeenCalledOnce(); + expect(pushMock.pushGeneratedCode).toHaveBeenCalledOnce(); + }); + + it('tampered artifact (verifyArtifact returns false) aborts pipeline before push', async () => { + const pushMock = makeGithubPushMock(); + + const svc = new DeploymentPipelineService( + makeGeneratorMock(), + makeGithubMock(), + pushMock, + makeVercelMock(), + makeSyntaxValidatorMock(), + makeFailingVerifyMock(), + ); + + const result = await svc.deploy(request); + + expect(result.success).toBe(false); + expect(result.errorMessage).toContain('Artifact verification failed'); + expect(pushMock.pushGeneratedCode).not.toHaveBeenCalled(); + }); + + it('missing signature (verifyArtifact returns false) aborts pipeline', async () => { + const svc = new DeploymentPipelineService( + makeGeneratorMock(), + makeGithubMock(), + makeGithubPushMock(), + makeVercelMock(), + makeSyntaxValidatorMock(), + makeFailingVerifyMock(), + ); + + const result = await svc.deploy(request); + + expect(result.success).toBe(false); + expect(result.failedStage).toBe('pushing_code'); + }); + + it('checksum is present in deployment_logs metadata after successful run', async () => { + const svc = new DeploymentPipelineService( + makeGeneratorMock(), + makeGithubMock(), + makeGithubPushMock(), + makeVercelMock(), + makeSyntaxValidatorMock(), + makeSigningMock(), + ); + + await svc.deploy(request); + + // Find the log entry that carries the checksum in metadata + // Example: { checksum: "sha256:abc123...", timestamp: "...", deploymentId: "..." } + const checksumLog = getLogInserts().find( + (l: any) => l.metadata?.checksum !== undefined, + ); + + expect(checksumLog).toBeDefined(); + expect(checksumLog.metadata.checksum).toBe('sha256:abc123'); + }); + + it('signing stage appears between validating and creating_repo in status sequence', async () => { + const svc = new DeploymentPipelineService( + makeGeneratorMock(), + makeGithubMock(), + makeGithubPushMock(), + makeVercelMock(), + makeSyntaxValidatorMock(), + makeSigningMock(), + ); + + await svc.deploy(request); + + const statusUpdates = mockUpdate.mock.calls + .map((call: any[]) => call[0]) + .filter((p: any) => p.status) + .map((p: any) => p.status); + + const valIdx = statusUpdates.indexOf('validating'); + const signIdx = statusUpdates.indexOf('signing'); + const repoIdx = statusUpdates.indexOf('creating_repo'); + + expect(signIdx).not.toBe(-1); + expect(valIdx).toBeLessThan(signIdx); + expect(signIdx).toBeLessThan(repoIdx); + }); + + it('verifyArtifact is called with the same content and credentials produced by signArtifact', async () => { + const signingMock = makeSigningMock(); + + const svc = new DeploymentPipelineService( + makeGeneratorMock(), + makeGithubMock(), + makeGithubPushMock(), + makeVercelMock(), + makeSyntaxValidatorMock(), + signingMock, + ); + + await svc.deploy(request); + + const signCall = signingMock.signArtifact.mock.calls[0]; + const verifyCall = signingMock.verifyArtifact.mock.calls[0]; + + // Same artifact content passed to both + expect(verifyCall[0]).toBe(signCall[0]); + // Checksum and signature from signArtifact forwarded to verifyArtifact + expect(verifyCall[1]).toBe('sha256:abc123'); + expect(verifyCall[2]).toBe('sig-abc'); + }); +}); diff --git a/apps/backend/src/services/deployment-pipeline.service.ts b/apps/backend/src/services/deployment-pipeline.service.ts index 0b4bc7f..5e0d4cf 100644 --- a/apps/backend/src/services/deployment-pipeline.service.ts +++ b/apps/backend/src/services/deployment-pipeline.service.ts @@ -49,6 +49,7 @@ import { buildVercelEnvVars } from '@/lib/env/env-template-generator'; import { mapCategoryToFamily } from './template-generator.service'; import type { TemplateFamilyId } from './code-generator.service'; import { syntaxValidator, type SyntaxValidator } from './syntax-validator'; +import { artifactSigningService, type ArtifactSigningService } from './artifact-signing.service'; // ── Request / result types ──────────────────────────────────────────────────── @@ -88,6 +89,7 @@ export class DeploymentPipelineService { private readonly _githubPushService: Pick = githubPushService, private readonly _vercelService: Pick = vercelService, private readonly _syntaxValidator: Pick = syntaxValidator, + private readonly _artifactSigningService: Pick = artifactSigningService, ) {} /** @@ -179,6 +181,19 @@ export class DeploymentPipelineService { { correlationId, fileCount: generationResult.generatedFiles.length }, ); + // ── Step 2c: Sign artifact ───────────────────────────────────────────── + await this.setStatus(deploymentId, 'signing'); + await this.log(deploymentId, 'signing', 'Signing generated artifact', 'info', { correlationId }); + + const artifactContent = JSON.stringify(generationResult.generatedFiles); + const { checksum: artifactChecksum, signature: artifactSignature } = + this._artifactSigningService.signArtifact(artifactContent); + + await this.log(deploymentId, 'signing', 'Artifact signed', 'info', { + correlationId, + checksum: artifactChecksum, + }); + // ── Step 3: Create GitHub repository ───────────────────────────────── await this.setStatus(deploymentId, 'creating_repo'); await this.log(deploymentId, 'creating_repo', 'Creating GitHub repository', 'info', { correlationId }); @@ -229,6 +244,28 @@ export class DeploymentPipelineService { await this.setStatus(deploymentId, 'pushing_code'); await this.log(deploymentId, 'pushing_code', 'Pushing generated code to repository', 'info', { correlationId }); + const isArtifactValid = this._artifactSigningService.verifyArtifact( + artifactContent, + artifactChecksum, + artifactSignature, + ); + + if (!isArtifactValid) { + return this.fail( + deploymentId, + 'pushing_code', + 'Artifact verification failed: checksum or signature mismatch — aborting push', + { correlationId, checksum: artifactChecksum }, + ); + } + + await this.log(deploymentId, 'pushing_code', 'Artifact verified', 'info', { + correlationId, + checksum: artifactChecksum, + deploymentId, + timestamp: new Date().toISOString(), + }); + const githubToken = process.env.GITHUB_TOKEN ?? ''; const [owner, repo] = repoFullName.split('/'); @@ -426,4 +463,5 @@ export const deploymentPipelineService = new DeploymentPipelineService( githubPushService, vercelService, syntaxValidator, + artifactSigningService, ); diff --git a/packages/types/src/deployment.ts b/packages/types/src/deployment.ts index cab135f..d6010e4 100644 --- a/packages/types/src/deployment.ts +++ b/packages/types/src/deployment.ts @@ -4,6 +4,7 @@ export type DeploymentStatusType = | 'pending' | 'generating' | 'validating' + | 'signing' | 'creating_repo' | 'pushing_code' | 'deploying' diff --git a/vitest.config.ts b/vitest.config.ts index ec72c3d..d97b7b0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -31,6 +31,9 @@ export default defineConfig({ test: { globals: true, environment: 'node', + env: { + ARTIFACT_SIGNING_SECRET: 'test-artifact-signing-secret-32b!!', + }, coverage: { provider: 'v8', reporter: ['text', 'json', 'html'],