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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
47 changes: 47 additions & 0 deletions apps/backend/src/services/artifact-signing.service.ts
Original file line number Diff line number Diff line change
@@ -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();
300 changes: 300 additions & 0 deletions apps/backend/src/services/deployment-pipeline.artifact-signing.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading