From ce2b38475a54c1caf4e5950152a276587944b616 Mon Sep 17 00:00:00 2001 From: Nicolas Ritouet Date: Thu, 26 Mar 2026 01:12:37 +0100 Subject: [PATCH 1/7] perf(dashboard): eliminate barrel imports, lazy-load modals, defer analytics - Remove barrel file (index.ts with 25+ re-exports), replace with direct imports across 18 files for better tree-shaking - Lazy-load 6 modals with next/dynamic (SecretModal, ViewSecretModal, DeleteVaultModal, NewVaultModal, BulkImportModal, ConnectOrgModal) - Change PostHog script strategy from afterInteractive to lazyOnload Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/(dashboard)/activity/page.tsx | 7 +--- .../app/(dashboard)/api-keys/page.tsx | 7 +--- .../(dashboard)/orgs/[org]/billing/page.tsx | 3 +- .../(dashboard)/orgs/[org]/exposure/page.tsx | 11 ++---- .../(dashboard)/orgs/[org]/members/page.tsx | 4 +- .../app/(dashboard)/orgs/[org]/page.tsx | 3 +- .../(dashboard)/orgs/[org]/settings/page.tsx | 3 +- .../dashboard/app/(dashboard)/orgs/page.tsx | 10 ++++- packages/dashboard/app/(dashboard)/page.tsx | 20 +++++----- .../_components/SecurityAccessLogTab.tsx | 2 +- .../_components/SecurityAlertsTab.tsx | 2 +- .../_components/SecurityExposureTab.tsx | 3 +- .../_components/SecurityOverviewTab.tsx | 2 +- .../app/(dashboard)/security/page.tsx | 2 +- .../app/(dashboard)/settings/page.tsx | 2 +- .../[owner]/[repo]/collaborators/page.tsx | 10 ++--- .../[owner]/[repo]/environments/page.tsx | 8 ++-- .../vaults/[owner]/[repo]/page.tsx | 38 ++++++++++++------- .../app/components/dashboard/index.ts | 24 ------------ packages/dashboard/app/layout.tsx | 4 +- packages/dashboard/next-env.d.ts | 2 +- packages/dashboard/tsconfig.json | 2 +- 22 files changed, 77 insertions(+), 92 deletions(-) delete mode 100644 packages/dashboard/app/components/dashboard/index.ts diff --git a/packages/dashboard/app/(dashboard)/activity/page.tsx b/packages/dashboard/app/(dashboard)/activity/page.tsx index 6925569..fe9c577 100644 --- a/packages/dashboard/app/(dashboard)/activity/page.tsx +++ b/packages/dashboard/app/(dashboard)/activity/page.tsx @@ -13,11 +13,8 @@ import Link from 'next/link' import Image from 'next/image' import { api } from '@/lib/api' import type { ActivityEvent, ActivityCategory } from '@/lib/types' -import { - DashboardLayout, - ErrorState, - EmptyState, -} from '@/app/components/dashboard' +import { DashboardLayout } from '@/app/components/dashboard/Layout' +import { ErrorState, EmptyState } from '@/app/components/dashboard/ErrorState' import { trackEvent, AnalyticsEvents } from '@/lib/analytics' import { Card, CardContent } from '@/components/ui/card' import { Skeleton } from '@/components/ui/skeleton' diff --git a/packages/dashboard/app/(dashboard)/api-keys/page.tsx b/packages/dashboard/app/(dashboard)/api-keys/page.tsx index 6c49992..49deeb0 100644 --- a/packages/dashboard/app/(dashboard)/api-keys/page.tsx +++ b/packages/dashboard/app/(dashboard)/api-keys/page.tsx @@ -17,11 +17,8 @@ import { import { api } from '@/lib/api' import type { ApiKey, ApiKeyScope, CreateApiKeyResponse } from '@/lib/types' import { apiKeySchema } from '@/lib/validations' -import { - DashboardLayout, - ErrorState, - EmptyState, -} from '@/app/components/dashboard' +import { DashboardLayout } from '@/app/components/dashboard/Layout' +import { ErrorState, EmptyState } from '@/app/components/dashboard/ErrorState' import { trackEvent, AnalyticsEvents } from '@/lib/analytics' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' diff --git a/packages/dashboard/app/(dashboard)/orgs/[org]/billing/page.tsx b/packages/dashboard/app/(dashboard)/orgs/[org]/billing/page.tsx index 8e43eec..ac01ce5 100644 --- a/packages/dashboard/app/(dashboard)/orgs/[org]/billing/page.tsx +++ b/packages/dashboard/app/(dashboard)/orgs/[org]/billing/page.tsx @@ -15,7 +15,8 @@ import { } from 'lucide-react' import { api } from '@/lib/api' import type { OrganizationDetails, OrganizationBillingStatus } from '@/lib/types' -import { DashboardLayout, ErrorState } from '@/app/components/dashboard' +import { DashboardLayout } from '@/app/components/dashboard/Layout' +import { ErrorState } from '@/app/components/dashboard/ErrorState' import { TrialBanner, TrialExpiredBanner } from '@/app/components/dashboard/TrialBanner' import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' diff --git a/packages/dashboard/app/(dashboard)/orgs/[org]/exposure/page.tsx b/packages/dashboard/app/(dashboard)/orgs/[org]/exposure/page.tsx index 30a1eae..1a8392b 100644 --- a/packages/dashboard/app/(dashboard)/orgs/[org]/exposure/page.tsx +++ b/packages/dashboard/app/(dashboard)/orgs/[org]/exposure/page.tsx @@ -8,13 +8,10 @@ import { toast } from 'sonner' import { api } from '@/lib/api' import { trackEvent, AnalyticsEvents } from '@/lib/analytics' import type { ExposureOrgSummary, ExposureUserReport } from '@/lib/types' -import { - DashboardLayout, - ErrorState, - ExposureStatCard, - ExposureUserRow, - ExposureUserRowSkeleton, -} from '@/app/components/dashboard' +import { DashboardLayout } from '@/app/components/dashboard/Layout' +import { ErrorState } from '@/app/components/dashboard/ErrorState' +import { ExposureStatCard } from '@/app/components/dashboard/ExposureStatCard' +import { ExposureUserRow, ExposureUserRowSkeleton } from '@/app/components/dashboard/ExposureUserRow' import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' diff --git a/packages/dashboard/app/(dashboard)/orgs/[org]/members/page.tsx b/packages/dashboard/app/(dashboard)/orgs/[org]/members/page.tsx index a3b04bd..efc5f77 100644 --- a/packages/dashboard/app/(dashboard)/orgs/[org]/members/page.tsx +++ b/packages/dashboard/app/(dashboard)/orgs/[org]/members/page.tsx @@ -6,7 +6,9 @@ import Link from 'next/link' import { ArrowLeft, Users, RefreshCw, Crown, ExternalLink } from 'lucide-react' import { api } from '@/lib/api' import type { OrganizationDetails, OrganizationMember } from '@/lib/types' -import { DashboardLayout, ErrorState, LoadingSpinner } from '@/app/components/dashboard' +import { DashboardLayout } from '@/app/components/dashboard/Layout' +import { ErrorState } from '@/app/components/dashboard/ErrorState' +import { LoadingSpinner } from '@/app/components/dashboard/LoadingSpinner' import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar' import { Badge } from '@/components/ui/badge' diff --git a/packages/dashboard/app/(dashboard)/orgs/[org]/page.tsx b/packages/dashboard/app/(dashboard)/orgs/[org]/page.tsx index 50b2e17..908ee59 100644 --- a/packages/dashboard/app/(dashboard)/orgs/[org]/page.tsx +++ b/packages/dashboard/app/(dashboard)/orgs/[org]/page.tsx @@ -16,7 +16,8 @@ import { } from 'lucide-react' import { api } from '@/lib/api' import type { OrganizationDetails } from '@/lib/types' -import { DashboardLayout, ErrorState } from '@/app/components/dashboard' +import { DashboardLayout } from '@/app/components/dashboard/Layout' +import { ErrorState } from '@/app/components/dashboard/ErrorState' import { TrialBanner, TrialExpiredBanner } from '@/app/components/dashboard/TrialBanner' import { OnboardingBanner } from '@/app/components/dashboard/OnboardingBanner' import { Card, CardContent } from '@/components/ui/card' diff --git a/packages/dashboard/app/(dashboard)/orgs/[org]/settings/page.tsx b/packages/dashboard/app/(dashboard)/orgs/[org]/settings/page.tsx index 48c98c8..ceab86e 100644 --- a/packages/dashboard/app/(dashboard)/orgs/[org]/settings/page.tsx +++ b/packages/dashboard/app/(dashboard)/orgs/[org]/settings/page.tsx @@ -6,7 +6,8 @@ import Link from 'next/link' import { ArrowLeft, Settings, Save, Building2 } from 'lucide-react' import { api } from '@/lib/api' import type { OrganizationDetails } from '@/lib/types' -import { DashboardLayout, ErrorState } from '@/app/components/dashboard' +import { DashboardLayout } from '@/app/components/dashboard/Layout' +import { ErrorState } from '@/app/components/dashboard/ErrorState' import { trackEvent, AnalyticsEvents } from '@/lib/analytics' import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from '@/components/ui/card' import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar' diff --git a/packages/dashboard/app/(dashboard)/orgs/page.tsx b/packages/dashboard/app/(dashboard)/orgs/page.tsx index 36cdb3f..8b4dde0 100644 --- a/packages/dashboard/app/(dashboard)/orgs/page.tsx +++ b/packages/dashboard/app/(dashboard)/orgs/page.tsx @@ -6,8 +6,14 @@ import { useRouter } from 'next/navigation' import { Building2, Users, Box, Sparkles, ChevronRight, Plus } from 'lucide-react' import { api } from '@/lib/api' import type { Organization } from '@/lib/types' -import { DashboardLayout, ErrorState, EmptyState } from '@/app/components/dashboard' -import { ConnectOrgModal } from '@/app/components/dashboard/ConnectOrgModal' +import dynamic from 'next/dynamic' +import { DashboardLayout } from '@/app/components/dashboard/Layout' +import { ErrorState, EmptyState } from '@/app/components/dashboard/ErrorState' + +const ConnectOrgModal = dynamic( + () => import('@/app/components/dashboard/ConnectOrgModal').then(m => m.ConnectOrgModal), + { ssr: false } +) import { trackEvent, AnalyticsEvents } from '@/lib/analytics' import { Card, CardContent } from '@/components/ui/card' import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar' diff --git a/packages/dashboard/app/(dashboard)/page.tsx b/packages/dashboard/app/(dashboard)/page.tsx index aebb546..3b04a63 100644 --- a/packages/dashboard/app/(dashboard)/page.tsx +++ b/packages/dashboard/app/(dashboard)/page.tsx @@ -7,15 +7,17 @@ import Link from 'next/link' import { User, Building2, AlertTriangle, X } from 'lucide-react' import { api } from '@/lib/api' import type { Vault, UserPlan } from '@/lib/types' -import { - DashboardLayout, - VaultCard, - VaultCardSkeleton, - ErrorState, - DeleteVaultModal, - GitHubAppNotInstalledState, - CreateVaultCard, -} from '@/app/components/dashboard' +import dynamic from 'next/dynamic' +import { DashboardLayout } from '@/app/components/dashboard/Layout' +import { VaultCard, VaultCardSkeleton } from '@/app/components/dashboard/VaultCard' +import { ErrorState } from '@/app/components/dashboard/ErrorState' +import { GitHubAppNotInstalledState } from '@/app/components/dashboard/GitHubAppNotInstalledState' +import { CreateVaultCard } from '@/app/components/dashboard/CreateVaultCard' + +const DeleteVaultModal = dynamic( + () => import('@/app/components/dashboard/DeleteVaultModal').then(m => m.DeleteVaultModal), + { ssr: false } +) import { CLICommand } from '@/app/components/cli-command' import { trackEvent, AnalyticsEvents } from '@/lib/analytics' import { useAuth } from '@/lib/auth' diff --git a/packages/dashboard/app/(dashboard)/security/_components/SecurityAccessLogTab.tsx b/packages/dashboard/app/(dashboard)/security/_components/SecurityAccessLogTab.tsx index 15b5f3f..6c59a55 100644 --- a/packages/dashboard/app/(dashboard)/security/_components/SecurityAccessLogTab.tsx +++ b/packages/dashboard/app/(dashboard)/security/_components/SecurityAccessLogTab.tsx @@ -15,7 +15,7 @@ import { } from 'lucide-react' import { api } from '@/lib/api' import type { AccessLogEvent, AccessLogResponse, Vault } from '@/lib/types' -import { ErrorState, EmptyState } from '@/app/components/dashboard' +import { ErrorState, EmptyState } from '@/app/components/dashboard/ErrorState' import { trackEvent, AnalyticsEvents } from '@/lib/analytics' import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' import { Button } from '@/components/ui/button' diff --git a/packages/dashboard/app/(dashboard)/security/_components/SecurityAlertsTab.tsx b/packages/dashboard/app/(dashboard)/security/_components/SecurityAlertsTab.tsx index 5d2cf52..dd6b40f 100644 --- a/packages/dashboard/app/(dashboard)/security/_components/SecurityAlertsTab.tsx +++ b/packages/dashboard/app/(dashboard)/security/_components/SecurityAlertsTab.tsx @@ -13,7 +13,7 @@ import { import Link from 'next/link' import { api } from '@/lib/api' import type { SecurityAlert, SecurityAlertType } from '@/lib/types' -import { ErrorState, EmptyState } from '@/app/components/dashboard' +import { ErrorState, EmptyState } from '@/app/components/dashboard/ErrorState' import { trackEvent, AnalyticsEvents } from '@/lib/analytics' import { Card, CardContent } from '@/components/ui/card' import { Skeleton } from '@/components/ui/skeleton' diff --git a/packages/dashboard/app/(dashboard)/security/_components/SecurityExposureTab.tsx b/packages/dashboard/app/(dashboard)/security/_components/SecurityExposureTab.tsx index a2b0bb3..d3ee326 100644 --- a/packages/dashboard/app/(dashboard)/security/_components/SecurityExposureTab.tsx +++ b/packages/dashboard/app/(dashboard)/security/_components/SecurityExposureTab.tsx @@ -27,7 +27,8 @@ import type { ExposureUserSummary, Organization, } from '@/lib/types' -import { ErrorState, ExposureStatCard } from '@/app/components/dashboard' +import { ErrorState } from '@/app/components/dashboard/ErrorState' +import { ExposureStatCard } from '@/app/components/dashboard/ExposureStatCard' import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' diff --git a/packages/dashboard/app/(dashboard)/security/_components/SecurityOverviewTab.tsx b/packages/dashboard/app/(dashboard)/security/_components/SecurityOverviewTab.tsx index 7bfa416..46e4e8d 100644 --- a/packages/dashboard/app/(dashboard)/security/_components/SecurityOverviewTab.tsx +++ b/packages/dashboard/app/(dashboard)/security/_components/SecurityOverviewTab.tsx @@ -12,7 +12,7 @@ import { import Link from 'next/link' import { api } from '@/lib/api' import type { SecurityOverview } from '@/lib/types' -import { ErrorState, EmptyState } from '@/app/components/dashboard' +import { ErrorState, EmptyState } from '@/app/components/dashboard/ErrorState' import { trackEvent, AnalyticsEvents } from '@/lib/analytics' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Skeleton } from '@/components/ui/skeleton' diff --git a/packages/dashboard/app/(dashboard)/security/page.tsx b/packages/dashboard/app/(dashboard)/security/page.tsx index 78ece7d..9e2178b 100644 --- a/packages/dashboard/app/(dashboard)/security/page.tsx +++ b/packages/dashboard/app/(dashboard)/security/page.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react' import { LayoutDashboard, AlertTriangle, Users, History } from 'lucide-react' -import { DashboardLayout } from '@/app/components/dashboard' +import { DashboardLayout } from '@/app/components/dashboard/Layout' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Skeleton } from '@/components/ui/skeleton' import { trackEvent, AnalyticsEvents } from '@/lib/analytics' diff --git a/packages/dashboard/app/(dashboard)/settings/page.tsx b/packages/dashboard/app/(dashboard)/settings/page.tsx index fb4b7a4..6b241ac 100644 --- a/packages/dashboard/app/(dashboard)/settings/page.tsx +++ b/packages/dashboard/app/(dashboard)/settings/page.tsx @@ -5,7 +5,7 @@ import { useTheme } from 'next-themes' import { useQuery, useMutation } from '@tanstack/react-query' import { Moon, Sun, Monitor, ExternalLink, Github, Palette, CreditCard, Loader2, Sparkles, BarChart3 } from 'lucide-react' import Link from 'next/link' -import { DashboardLayout } from '@/app/components/dashboard' +import { DashboardLayout } from '@/app/components/dashboard/Layout' import { useAuth } from '@/lib/auth' import { trackEvent, AnalyticsEvents } from '@/lib/analytics' import { api } from '@/lib/api' diff --git a/packages/dashboard/app/(dashboard)/vaults/[owner]/[repo]/collaborators/page.tsx b/packages/dashboard/app/(dashboard)/vaults/[owner]/[repo]/collaborators/page.tsx index 7f98373..2a84b77 100644 --- a/packages/dashboard/app/(dashboard)/vaults/[owner]/[repo]/collaborators/page.tsx +++ b/packages/dashboard/app/(dashboard)/vaults/[owner]/[repo]/collaborators/page.tsx @@ -6,13 +6,9 @@ import Link from 'next/link' import { ChevronLeft, Users, ExternalLink } from 'lucide-react' import { api } from '@/lib/api' import type { Collaborator } from '@/lib/types' -import { - DashboardLayout, - CollaboratorRow, - CollaboratorRowSkeleton, - ErrorState, - EmptyState, -} from '@/app/components/dashboard' +import { DashboardLayout } from '@/app/components/dashboard/Layout' +import { CollaboratorRow, CollaboratorRowSkeleton } from '@/app/components/dashboard/CollaboratorRow' +import { ErrorState, EmptyState } from '@/app/components/dashboard/ErrorState' import { trackEvent, AnalyticsEvents } from '@/lib/analytics' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' diff --git a/packages/dashboard/app/(dashboard)/vaults/[owner]/[repo]/environments/page.tsx b/packages/dashboard/app/(dashboard)/vaults/[owner]/[repo]/environments/page.tsx index 5b75d6c..71639e1 100644 --- a/packages/dashboard/app/(dashboard)/vaults/[owner]/[repo]/environments/page.tsx +++ b/packages/dashboard/app/(dashboard)/vaults/[owner]/[repo]/environments/page.tsx @@ -7,11 +7,9 @@ import { AlertTriangle } from 'lucide-react' import { api } from '@/lib/api' import { trackEvent, AnalyticsEvents } from '@/lib/analytics' import type { Vault, Secret } from '@/lib/types' -import { - DashboardLayout, - ErrorState, - LoadingSpinner, -} from '@/app/components/dashboard' +import { DashboardLayout } from '@/app/components/dashboard/Layout' +import { ErrorState } from '@/app/components/dashboard/ErrorState' +import { LoadingSpinner } from '@/app/components/dashboard/LoadingSpinner' import { AlertDialog, AlertDialogContent, diff --git a/packages/dashboard/app/(dashboard)/vaults/[owner]/[repo]/page.tsx b/packages/dashboard/app/(dashboard)/vaults/[owner]/[repo]/page.tsx index 16a347a..da2b442 100644 --- a/packages/dashboard/app/(dashboard)/vaults/[owner]/[repo]/page.tsx +++ b/packages/dashboard/app/(dashboard)/vaults/[owner]/[repo]/page.tsx @@ -8,20 +8,30 @@ import { ChevronLeft, Upload, Search, X, Settings, Users, RefreshCw, AlertTriang import { api } from '@/lib/api' import { useAuth } from '@/lib/auth' import type { Vault, Secret, TrashedSecret, VaultPermission, UserPlan } from '@/lib/types' -import { - DashboardLayout, - SecretRow, - SecretRowSkeleton, - SecretModal, - ViewSecretModal, - BulkImportModal, - ErrorState, - EmptyState, - TrashSection, - SyncButton, - VaultDetailHeader, - DeleteVaultModal, -} from '@/app/components/dashboard' +import dynamic from 'next/dynamic' +import { DashboardLayout } from '@/app/components/dashboard/Layout' +import { SecretRow, SecretRowSkeleton } from '@/app/components/dashboard/SecretRow' +import { ErrorState, EmptyState } from '@/app/components/dashboard/ErrorState' +import { TrashSection } from '@/app/components/dashboard/TrashSection' +import { SyncButton } from '@/app/components/dashboard/SyncButton' +import { VaultDetailHeader } from '@/app/components/dashboard/VaultDetailHeader' + +const SecretModal = dynamic( + () => import('@/app/components/dashboard/SecretModal').then(m => m.SecretModal), + { ssr: false } +) +const ViewSecretModal = dynamic( + () => import('@/app/components/dashboard/ViewSecretModal').then(m => m.ViewSecretModal), + { ssr: false } +) +const BulkImportModal = dynamic( + () => import('@/app/components/dashboard/BulkImportModal').then(m => m.BulkImportModal), + { ssr: false } +) +const DeleteVaultModal = dynamic( + () => import('@/app/components/dashboard/DeleteVaultModal').then(m => m.DeleteVaultModal), + { ssr: false } +) import { trackEvent, AnalyticsEvents } from '@/lib/analytics' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' diff --git a/packages/dashboard/app/components/dashboard/index.ts b/packages/dashboard/app/components/dashboard/index.ts deleted file mode 100644 index 3119a01..0000000 --- a/packages/dashboard/app/components/dashboard/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -export { DashboardLayout } from './Layout' -export { Sidebar } from './Sidebar' -export { Topbar } from './Topbar' -export { LoadingSpinner } from './LoadingSpinner' -export { ErrorState, EmptyState } from './ErrorState' -export { VaultCard, VaultCardSkeleton } from './VaultCard' -export { SecretRow, SecretRowSkeleton } from './SecretRow' -export { SecretModal } from './SecretModal' -export { ViewSecretModal } from './ViewSecretModal' -export { DeleteVaultModal } from './DeleteVaultModal' -export { BulkImportModal } from './BulkImportModal' -export { CollaboratorRow, CollaboratorRowSkeleton, PermissionBadge } from './CollaboratorRow' -export { TrashSection } from './TrashSection' -export { OrgSwitcher, OrgSwitcherSkeleton, type OrgContext } from './OrgSwitcher' -export { TrialBanner } from './TrialBanner' -export { SyncButton } from './SyncButton' -export { ExposureStatCard } from './ExposureStatCard' -export { ExposureUserRow, ExposureUserRowSkeleton } from './ExposureUserRow' -export { ConnectOrgModal } from './ConnectOrgModal' -export { OnboardingBanner } from './OnboardingBanner' -export { GitHubAppNotInstalledState } from './GitHubAppNotInstalledState' -export { NewVaultModal } from './NewVaultModal' -export { CreateVaultCard } from './CreateVaultCard' -export { VaultDetailHeader, VaultDetailHeaderSkeleton } from './VaultDetailHeader' diff --git a/packages/dashboard/app/layout.tsx b/packages/dashboard/app/layout.tsx index ac56aea..728592e 100644 --- a/packages/dashboard/app/layout.tsx +++ b/packages/dashboard/app/layout.tsx @@ -56,11 +56,11 @@ export default function RootLayout({ - + capture_performance: true, + }) + }} + /> ) : null} From de2e4474a81e364a72f70af1a836decf18d64d2e Mon Sep 17 00:00:00 2001 From: Nicolas Ritouet Date: Sat, 28 Mar 2026 23:12:35 +0100 Subject: [PATCH 3/7] fix(dashboard): update test mocks to use direct imports instead of barrel The barrel file was removed but 9 test files still mocked the old barrel path. Update mocks to target individual component files, fixing all 89 failing tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/SecurityAccessLogTab.test.tsx | 2 +- .../components/SecurityAlertsTab.test.tsx | 2 +- .../components/SecurityExposureTab.test.tsx | 5 ++- .../components/SecurityOverviewTab.test.tsx | 2 +- .../tests/pages/ActivityPage.test.tsx | 5 ++- .../tests/pages/ApiKeysPage.test.tsx | 5 ++- .../tests/pages/SecurityPage.test.tsx | 2 +- .../tests/pages/SettingsPage.test.tsx | 3 +- .../tests/pages/VaultDetailPage.test.tsx | 42 +++++++++++++++---- 9 files changed, 53 insertions(+), 15 deletions(-) diff --git a/packages/dashboard/tests/components/SecurityAccessLogTab.test.tsx b/packages/dashboard/tests/components/SecurityAccessLogTab.test.tsx index 444c4a9..c4a672e 100644 --- a/packages/dashboard/tests/components/SecurityAccessLogTab.test.tsx +++ b/packages/dashboard/tests/components/SecurityAccessLogTab.test.tsx @@ -20,7 +20,7 @@ vi.mock('../../lib/analytics', () => ({ })) // Mock dashboard components -vi.mock('../../app/components/dashboard', () => ({ +vi.mock('../../app/components/dashboard/ErrorState', () => ({ ErrorState: ({ message, onRetry }: { message: string; onRetry: () => void }) => (
{message} diff --git a/packages/dashboard/tests/components/SecurityAlertsTab.test.tsx b/packages/dashboard/tests/components/SecurityAlertsTab.test.tsx index 908eff1..568d4c1 100644 --- a/packages/dashboard/tests/components/SecurityAlertsTab.test.tsx +++ b/packages/dashboard/tests/components/SecurityAlertsTab.test.tsx @@ -19,7 +19,7 @@ vi.mock('../../lib/analytics', () => ({ })) // Mock dashboard components -vi.mock('../../app/components/dashboard', () => ({ +vi.mock('../../app/components/dashboard/ErrorState', () => ({ ErrorState: ({ message, onRetry }: { message: string; onRetry: () => void }) => (
{message} diff --git a/packages/dashboard/tests/components/SecurityExposureTab.test.tsx b/packages/dashboard/tests/components/SecurityExposureTab.test.tsx index 7b25fe5..ccbc35f 100644 --- a/packages/dashboard/tests/components/SecurityExposureTab.test.tsx +++ b/packages/dashboard/tests/components/SecurityExposureTab.test.tsx @@ -35,7 +35,7 @@ vi.mock('../../lib/date-utils', () => ({ })) // Mock dashboard components -vi.mock('../../app/components/dashboard', () => ({ +vi.mock('../../app/components/dashboard/ErrorState', () => ({ ErrorState: ({ title, message, onRetry }: { title?: string; message: string; onRetry?: () => void }) => (
{title &&

{title}

} @@ -43,6 +43,9 @@ vi.mock('../../app/components/dashboard', () => ({ {onRetry && }
), +})) + +vi.mock('../../app/components/dashboard/ExposureStatCard', () => ({ ExposureStatCard: ({ icon, label, value }: { icon: unknown; label: string; value: number }) => (
{label} diff --git a/packages/dashboard/tests/components/SecurityOverviewTab.test.tsx b/packages/dashboard/tests/components/SecurityOverviewTab.test.tsx index 8a5f66c..e8fbe63 100644 --- a/packages/dashboard/tests/components/SecurityOverviewTab.test.tsx +++ b/packages/dashboard/tests/components/SecurityOverviewTab.test.tsx @@ -19,7 +19,7 @@ vi.mock('../../lib/analytics', () => ({ })) // Mock dashboard components -vi.mock('../../app/components/dashboard', () => ({ +vi.mock('../../app/components/dashboard/ErrorState', () => ({ ErrorState: ({ message, onRetry }: { message: string; onRetry: () => void }) => (
{message} diff --git a/packages/dashboard/tests/pages/ActivityPage.test.tsx b/packages/dashboard/tests/pages/ActivityPage.test.tsx index 9856764..97d42f3 100644 --- a/packages/dashboard/tests/pages/ActivityPage.test.tsx +++ b/packages/dashboard/tests/pages/ActivityPage.test.tsx @@ -28,8 +28,11 @@ vi.mock('../../lib/analytics', () => ({ })) // Mock dashboard components -vi.mock('../../app/components/dashboard', () => ({ +vi.mock('../../app/components/dashboard/Layout', () => ({ DashboardLayout: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +vi.mock('../../app/components/dashboard/ErrorState', () => ({ ErrorState: ({ message, onRetry }: { message: string; onRetry: () => void }) => (
{message} diff --git a/packages/dashboard/tests/pages/ApiKeysPage.test.tsx b/packages/dashboard/tests/pages/ApiKeysPage.test.tsx index 3439517..77c37aa 100644 --- a/packages/dashboard/tests/pages/ApiKeysPage.test.tsx +++ b/packages/dashboard/tests/pages/ApiKeysPage.test.tsx @@ -114,8 +114,11 @@ vi.mock('../../lib/api', () => ({ })) // Mock dashboard components -vi.mock('../../app/components/dashboard', () => ({ +vi.mock('../../app/components/dashboard/Layout', () => ({ DashboardLayout: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +vi.mock('../../app/components/dashboard/ErrorState', () => ({ ErrorState: ({ message, onRetry }: { message: string; onRetry: () => void }) => (
{message} diff --git a/packages/dashboard/tests/pages/SecurityPage.test.tsx b/packages/dashboard/tests/pages/SecurityPage.test.tsx index f164ba3..84d9ba5 100644 --- a/packages/dashboard/tests/pages/SecurityPage.test.tsx +++ b/packages/dashboard/tests/pages/SecurityPage.test.tsx @@ -11,7 +11,7 @@ vi.mock('../../lib/analytics', () => ({ })) // Mock DashboardLayout -vi.mock('../../app/components/dashboard', () => ({ +vi.mock('../../app/components/dashboard/Layout', () => ({ DashboardLayout: ({ children }: { children: React.ReactNode }) =>
{children}
, })) diff --git a/packages/dashboard/tests/pages/SettingsPage.test.tsx b/packages/dashboard/tests/pages/SettingsPage.test.tsx index 77af468..d3612f6 100644 --- a/packages/dashboard/tests/pages/SettingsPage.test.tsx +++ b/packages/dashboard/tests/pages/SettingsPage.test.tsx @@ -46,7 +46,7 @@ vi.mock('../../lib/analytics', () => ({ })) // Mock dashboard components -vi.mock('../../app/components/dashboard', () => ({ +vi.mock('../../app/components/dashboard/Layout', () => ({ DashboardLayout: ({ children }: { children: React.ReactNode }) =>
{children}
, })) @@ -135,6 +135,7 @@ vi.mock('../../lib/api', () => ({ getSubscription: vi.fn(), getUsage: vi.fn(), createPortalSession: vi.fn(), + getOrganizations: vi.fn(() => Promise.resolve([])), }, })) diff --git a/packages/dashboard/tests/pages/VaultDetailPage.test.tsx b/packages/dashboard/tests/pages/VaultDetailPage.test.tsx index 0e1089b..9ccea41 100644 --- a/packages/dashboard/tests/pages/VaultDetailPage.test.tsx +++ b/packages/dashboard/tests/pages/VaultDetailPage.test.tsx @@ -13,6 +13,7 @@ vi.mock('next/navigation', () => ({ replace: vi.fn(), back: vi.fn(), }), + usePathname: () => '/vaults/testowner/testrepo', })) // Mock next/link @@ -190,8 +191,11 @@ vi.mock('../../lib/api', () => ({ })) // Mock dashboard components -vi.mock('../../app/components/dashboard', () => ({ +vi.mock('../../app/components/dashboard/Layout', () => ({ DashboardLayout: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +vi.mock('../../app/components/dashboard/VaultDetailHeader', () => ({ VaultDetailHeader: ({ vault, isLoading, canWrite, onAddSecret, onBulkImport }: { vault: Vault | undefined isLoading: boolean @@ -221,6 +225,9 @@ vi.mock('../../app/components/dashboard', () => ({
) }, +})) + +vi.mock('../../app/components/dashboard/SecretRow', () => ({ SecretRow: ({ secret, onView, onEdit, onDelete }: { secret: Secret; onView?: (s: Secret) => void; onEdit?: (s: Secret) => void; onDelete?: (s: Secret) => void }) => (
{secret.name} @@ -231,12 +238,9 @@ vi.mock('../../app/components/dashboard', () => ({
), SecretRowSkeleton: () =>
, - SecretModal: ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) => - isOpen ?
: null, - ViewSecretModal: ({ isOpen, onClose, secret }: { isOpen: boolean; onClose: () => void; secret: Secret | null }) => - isOpen ?
{secret?.name}
: null, - BulkImportModal: ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) => - isOpen ?
: null, +})) + +vi.mock('../../app/components/dashboard/ErrorState', () => ({ ErrorState: ({ message, onRetry }: { message: string; onRetry: () => void }) => (
{message} @@ -250,10 +254,34 @@ vi.mock('../../app/components/dashboard', () => ({ {action}
), +})) + +vi.mock('../../app/components/dashboard/TrashSection', () => ({ TrashSection: ({ trashedSecrets }: { trashedSecrets: TrashedSecret[] }) => (
Trash: {trashedSecrets.length}
), +})) + +vi.mock('../../app/components/dashboard/SyncButton', () => ({ SyncButton: () => , +})) + +vi.mock('../../app/components/dashboard/SecretModal', () => ({ + SecretModal: ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) => + isOpen ?
: null, +})) + +vi.mock('../../app/components/dashboard/ViewSecretModal', () => ({ + ViewSecretModal: ({ isOpen, onClose, secret }: { isOpen: boolean; onClose: () => void; secret: Secret | null }) => + isOpen ?
{secret?.name}
: null, +})) + +vi.mock('../../app/components/dashboard/BulkImportModal', () => ({ + BulkImportModal: ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) => + isOpen ?
: null, +})) + +vi.mock('../../app/components/dashboard/DeleteVaultModal', () => ({ DeleteVaultModal: ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) => isOpen ?
: null, })) From 9d25ef04d47213b8acbf7e6481944682214f585a Mon Sep 17 00:00:00 2001 From: Nicolas Ritouet Date: Thu, 9 Apr 2026 21:45:09 +0200 Subject: [PATCH 4/7] feat(crypto): add TLS + shared-secret auth to gRPC crypto service Defense-in-depth for the crypto gRPC service that encrypts/decrypts all secrets in the platform. Adds two opt-in security layers: - Shared-secret interceptor: validates x-crypto-auth-token metadata header using SHA-256 + constant-time comparison (prevents timing and length-leaking attacks). gRPC health probes are exempt. - TLS with auto-generated self-signed ECDSA P-256 cert: encrypts traffic between backend and crypto service. Cert is generated at first startup and persisted via Docker volume. Backend verifies via cert pinning (PEM as rootCerts). Both layers are backward-compatible: without env vars configured, the service runs in the current insecure mode with log warnings. CRYPTO_TLS_REQUIRED=true makes TLS failures fatal for production. Also fixes audit finding V22: removes ENV ENCRYPTION_KEY="" from the Dockerfile (was leaking in docker inspect). Co-Authored-By: Claude Opus 4.6 (1M context) --- docker-compose.yml | 12 +- packages/backend/.env.example | 12 + packages/backend/src/config/index.ts | 6 + packages/backend/src/index.ts | 12 +- packages/backend/src/utils/encryption.ts | 6 +- .../backend/src/utils/remoteEncryption.ts | 209 ++++++++++------ packages/crypto/Dockerfile | 4 +- packages/crypto/auth_test.go | 236 ++++++++++++++++++ packages/crypto/main.go | 157 +++++++++++- packages/crypto/tls_test.go | 184 ++++++++++++++ 10 files changed, 754 insertions(+), 84 deletions(-) create mode 100644 packages/crypto/auth_test.go create mode 100644 packages/crypto/tls_test.go diff --git a/docker-compose.yml b/docker-compose.yml index 3836782..cd9b858 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,8 +20,13 @@ services: environment: - ENCRYPTION_KEY=${ENCRYPTION_KEY} - GRPC_PORT=${GRPC_PORT:-50051} + - CRYPTO_AUTH_TOKEN=${CRYPTO_AUTH_TOKEN:-} + - CRYPTO_TLS_CERT_PATH=${CRYPTO_TLS_CERT_PATH:-} + - CRYPTO_TLS_KEY_PATH=${CRYPTO_TLS_KEY_PATH:-} + volumes: + - crypto_certs:/data/crypto healthcheck: - test: ["CMD-SHELL", "nc -z localhost ${GRPC_PORT:-50051} || exit 1"] + test: ["CMD-SHELL", "(test -z \"${CRYPTO_TLS_CERT_PATH}\" || test -f /data/crypto/tls.crt) && nc -z localhost ${GRPC_PORT:-50051} || exit 1"] interval: 10s timeout: 5s retries: 3 @@ -36,6 +41,8 @@ services: - NODE_ENV=production - DATABASE_URL=${DATABASE_URL:-postgresql://keyway:keyway@db:5432/keyway} - CRYPTO_SERVICE_URL=${CRYPTO_SERVICE_URL:-crypto:50051} + - CRYPTO_AUTH_TOKEN=${CRYPTO_AUTH_TOKEN:-} + - CRYPTO_TLS_CA_PATH=${CRYPTO_TLS_CA_PATH:-} - ENCRYPTION_KEY=${ENCRYPTION_KEY} - JWT_SECRET=${JWT_SECRET} # GitHub App (unified auth + repo access) @@ -60,6 +67,8 @@ services: # Optional: Analytics - POSTHOG_API_KEY=${POSTHOG_API_KEY:-} - POSTHOG_HOST=${POSTHOG_HOST:-} + volumes: + - crypto_certs:/data/crypto:ro depends_on: db: condition: service_healthy @@ -103,3 +112,4 @@ volumes: postgres_data: caddy_data: caddy_config: + crypto_certs: diff --git a/packages/backend/.env.example b/packages/backend/.env.example index ca55edc..a66be8d 100644 --- a/packages/backend/.env.example +++ b/packages/backend/.env.example @@ -24,6 +24,18 @@ DATABASE_URL=postgresql://user:password@localhost:5432/keyway # The encryption key is stored in keyway-crypto, NOT here CRYPTO_SERVICE_URL=localhost:50051 +# Security: shared secret for crypto service authentication (optional but recommended) +# Must match CRYPTO_AUTH_TOKEN on the crypto service +# Generate with: openssl rand -hex 32 +# CRYPTO_AUTH_TOKEN= + +# TLS for crypto service (optional - for encrypted gRPC transport) +# The crypto service auto-generates a self-signed cert on first start. +# Docker Compose: use shared volume path: +# CRYPTO_TLS_CA_PATH=/data/crypto/tls.crt +# Railway/env-based: base64-encoded PEM cert: +# CRYPTO_TLS_CA=LS0tLS1CRUdJTi... + # ----------------------------------------- # JWT (for device flow tokens) # ----------------------------------------- diff --git a/packages/backend/src/config/index.ts b/packages/backend/src/config/index.ts index 612fd2a..c1fd7a9 100644 --- a/packages/backend/src/config/index.ts +++ b/packages/backend/src/config/index.ts @@ -32,6 +32,9 @@ const envSchema = z // Encryption (remote crypto service) CRYPTO_SERVICE_URL: z.string().min(1, "CRYPTO_SERVICE_URL is required (e.g., localhost:50051)"), + CRYPTO_AUTH_TOKEN: z.preprocess(emptyToUndefined, z.string().optional()), + CRYPTO_TLS_CA: z.preprocess(emptyToUndefined, z.string().optional()), + CRYPTO_TLS_CA_PATH: z.preprocess(emptyToUndefined, z.string().optional()), // JWT for Keyway tokens JWT_SECRET: z.string().min(32, "JWT_SECRET must be at least 32 characters"), @@ -167,6 +170,9 @@ export const config = { crypto: { serviceUrl: env.CRYPTO_SERVICE_URL, + authToken: env.CRYPTO_AUTH_TOKEN, + tlsCa: env.CRYPTO_TLS_CA, + tlsCaPath: env.CRYPTO_TLS_CA_PATH, }, jwt: { diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 47afe00..2ed0ab0 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -128,7 +128,11 @@ fastify.get("/health", async (request, reply) => { // Check crypto service connectivity with a 3s timeout try { - const health = await withTimeout(checkCryptoService(config.crypto.serviceUrl), 3000); + const health = await withTimeout(checkCryptoService(config.crypto.serviceUrl, { + authToken: config.crypto.authToken, + tlsCa: config.crypto.tlsCa, + tlsCaPath: config.crypto.tlsCaPath, + }), 3000); cryptoVersion = health.version; } catch { cryptoStatus = "disconnected"; @@ -271,7 +275,11 @@ const start = async () => { // Check crypto service connectivity (warning only, don't crash) fastify.log.info(`Checking crypto service connectivity at ${config.crypto.serviceUrl}...`); try { - const health = await checkCryptoService(config.crypto.serviceUrl); + const health = await checkCryptoService(config.crypto.serviceUrl, { + authToken: config.crypto.authToken, + tlsCa: config.crypto.tlsCa, + tlsCaPath: config.crypto.tlsCaPath, + }); fastify.log.info({ version: health.version }, "Crypto service connection successful"); } catch (cryptoError) { fastify.log.warn( diff --git a/packages/backend/src/utils/encryption.ts b/packages/backend/src/utils/encryption.ts index 1643268..ed2d54b 100644 --- a/packages/backend/src/utils/encryption.ts +++ b/packages/backend/src/utils/encryption.ts @@ -33,7 +33,11 @@ let encryptionService: IEncryptionService | null = null; export async function getEncryptionService(): Promise { if (!encryptionService) { const { RemoteEncryptionService } = await import("./remoteEncryption.js"); - encryptionService = new RemoteEncryptionService(config.crypto.serviceUrl); + encryptionService = new RemoteEncryptionService(config.crypto.serviceUrl, { + authToken: config.crypto.authToken, + tlsCa: config.crypto.tlsCa, + tlsCaPath: config.crypto.tlsCaPath, + }); logger.info({ serviceUrl: config.crypto.serviceUrl }, "Using remote encryption service"); } return encryptionService; diff --git a/packages/backend/src/utils/remoteEncryption.ts b/packages/backend/src/utils/remoteEncryption.ts index 0916d51..42c201b 100644 --- a/packages/backend/src/utils/remoteEncryption.ts +++ b/packages/backend/src/utils/remoteEncryption.ts @@ -1,21 +1,27 @@ import * as grpc from "@grpc/grpc-js"; import * as protoLoader from "@grpc/proto-loader"; +import * as fs from "fs"; import path from "path"; import type { IEncryptionService, EncryptedData } from "./encryption"; import { DEFAULT_ENCRYPTION_VERSION } from "./encryption"; +type RpcCallback = (err: Error | null, response: T) => void; + interface CryptoClient { Encrypt: ( request: { plaintext: Buffer; version: number }, - callback: (err: Error | null, response: EncryptResponse) => void + metadataOrCallback: grpc.Metadata | RpcCallback, + callback?: RpcCallback ) => void; Decrypt: ( request: { ciphertext: Buffer; iv: Buffer; authTag: Buffer; version: number }, - callback: (err: Error | null, response: DecryptResponse) => void + metadataOrCallback: grpc.Metadata | RpcCallback, + callback?: RpcCallback ) => void; HealthCheck: ( request: Record, - callback: (err: Error | null, response: HealthResponse) => void + metadataOrCallback: grpc.Metadata | RpcCallback, + callback?: RpcCallback ) => void; } @@ -124,28 +130,39 @@ function formatGrpcError( ); } +export interface CryptoServiceOptions { + authToken?: string; + /** PEM-encoded CA cert for TLS, base64-encoded (same pattern as GITHUB_APP_PRIVATE_KEY) */ + tlsCa?: string; + /** Path to CA cert PEM file (alternative to tlsCa, for Docker shared volumes) */ + tlsCaPath?: string; +} + export class RemoteEncryptionService implements IEncryptionService { private client: CryptoClient; private serviceUrl: string; + private metadata: grpc.Metadata | null = null; - constructor(address: string = "localhost:50051") { + constructor(address: string = "localhost:50051", options?: CryptoServiceOptions) { this.serviceUrl = address; - // Security: Only allow insecure gRPC for trusted networks - // Railway private networking doesn't provide TLS, but traffic is isolated - // Docker container names are also trusted (internal Docker network) - // (CRIT-2 fix: Validate address to prevent accidental exposure) - const isTrustedNetwork = - address.startsWith("localhost") || - address.startsWith("127.0.0.1") || - address.includes(".railway.internal") || - address.startsWith("crypto:"); // Docker container name for local dev + // Set up auth metadata if token provided + if (options?.authToken) { + this.metadata = new grpc.Metadata(); + this.metadata.set("x-crypto-auth-token", options.authToken); + } - if (!isTrustedNetwork) { - throw new Error( - `Crypto service address "${address}" is not on a trusted network. ` + - "Only localhost, 127.0.0.1, *.railway.internal, and crypto:* (Docker) are allowed without TLS." - ); + // Determine channel credentials (TLS or insecure) + let channelCreds: grpc.ChannelCredentials; + const tlsCaPem = this.loadTlsCa(options); + + if (tlsCaPem) { + // TLS mode: use the CA cert to verify the server + channelCreds = grpc.credentials.createSsl(tlsCaPem); + } else { + // Insecure mode: only allow trusted networks + this.validateTrustedNetwork(address); + channelCreds = grpc.credentials.createInsecure(); } const protoPath = path.join(__dirname, "../../proto/crypto.proto"); @@ -166,82 +183,119 @@ export class RemoteEncryptionService implements IEncryptionService { }; }; }; - this.client = new proto.keyway.crypto.CryptoService(address, grpc.credentials.createInsecure()); + this.client = new proto.keyway.crypto.CryptoService(address, channelCreds); + } + + private loadTlsCa(options?: CryptoServiceOptions): Buffer | null { + if (options?.tlsCa) { + return Buffer.from(options.tlsCa, "base64"); + } + if (options?.tlsCaPath) { + // Path was explicitly configured -- fail loudly if unreadable + return fs.readFileSync(options.tlsCaPath); + } + return null; + } + + private validateTrustedNetwork(address: string): void { + // Security: Only allow insecure gRPC for trusted networks + // Railway private networking doesn't provide TLS, but traffic is isolated + // Docker container names are also trusted (internal Docker network) + const isTrustedNetwork = + address.startsWith("localhost") || + address.startsWith("127.0.0.1") || + address.includes(".railway.internal") || + address.startsWith("crypto:"); // Docker container name for local dev + + if (!isTrustedNetwork) { + throw new Error( + `Crypto service address "${address}" is not on a trusted network. ` + + "Provide TLS CA cert (CRYPTO_TLS_CA or CRYPTO_TLS_CA_PATH) or use a trusted network." + ); + } } async encrypt(content: string): Promise { return withRetry(() => this.encryptOnce(content)); } - private async encryptOnce(content: string): Promise { + private callRpc( + method: CryptoClient[keyof CryptoClient], + request: TReq + ): Promise { return new Promise((resolve, reject) => { - this.client.Encrypt( - { plaintext: Buffer.from(content, "utf-8"), version: 0 }, // 0 = use current version - (err, response) => { - if (err) { - return reject( - formatGrpcError( - err as Error & { code?: number; details?: string }, - this.serviceUrl, - "encrypt" - ) - ); - } - resolve({ - encryptedContent: Buffer.from(response.ciphertext).toString("hex"), - iv: Buffer.from(response.iv).toString("hex"), - authTag: Buffer.from(response.authTag).toString("hex"), - version: response.version, - }); - } - ); + const cb = (err: Error | null, response: TRes) => { + if (err) return reject(err); + resolve(response); + }; + if (this.metadata) { + (method as Function).call(this.client, request, this.metadata, cb); + } else { + (method as Function).call(this.client, request, cb); + } }); } + private async encryptOnce(content: string): Promise { + try { + const response = await this.callRpc<{ plaintext: Buffer; version: number }, EncryptResponse>( + this.client.Encrypt, + { plaintext: Buffer.from(content, "utf-8"), version: 0 } + ); + return { + encryptedContent: Buffer.from(response.ciphertext).toString("hex"), + iv: Buffer.from(response.iv).toString("hex"), + authTag: Buffer.from(response.authTag).toString("hex"), + version: response.version, + }; + } catch (err) { + throw formatGrpcError( + err as Error & { code?: number; details?: string }, + this.serviceUrl, + "encrypt" + ); + } + } + async decrypt(data: EncryptedData): Promise { return withRetry(() => this.decryptOnce(data)); } private async decryptOnce(data: EncryptedData): Promise { - return new Promise((resolve, reject) => { - this.client.Decrypt( - { - ciphertext: Buffer.from(data.encryptedContent, "hex"), - iv: Buffer.from(data.iv, "hex"), - authTag: Buffer.from(data.authTag, "hex"), - version: data.version ?? DEFAULT_ENCRYPTION_VERSION, - }, - (err, response) => { - if (err) { - return reject( - formatGrpcError( - err as Error & { code?: number; details?: string }, - this.serviceUrl, - "decrypt" - ) - ); - } - resolve(Buffer.from(response.plaintext).toString("utf-8")); - } + try { + const response = await this.callRpc< + { ciphertext: Buffer; iv: Buffer; authTag: Buffer; version: number }, + DecryptResponse + >(this.client.Decrypt, { + ciphertext: Buffer.from(data.encryptedContent, "hex"), + iv: Buffer.from(data.iv, "hex"), + authTag: Buffer.from(data.authTag, "hex"), + version: data.version ?? DEFAULT_ENCRYPTION_VERSION, + }); + return Buffer.from(response.plaintext).toString("utf-8"); + } catch (err) { + throw formatGrpcError( + err as Error & { code?: number; details?: string }, + this.serviceUrl, + "decrypt" ); - }); + } } async healthCheck(): Promise<{ healthy: boolean; version: string }> { - return new Promise((resolve, reject) => { - this.client.HealthCheck({}, (err, response) => { - if (err) { - return reject( - formatGrpcError( - err as Error & { code?: number; details?: string }, - this.serviceUrl, - "healthcheck" - ) - ); - } - resolve({ healthy: response.healthy, version: response.version }); - }); - }); + try { + const response = await this.callRpc, HealthResponse>( + this.client.HealthCheck, + {} + ); + return { healthy: response.healthy, version: response.version }; + } catch (err) { + throw formatGrpcError( + err as Error & { code?: number; details?: string }, + this.serviceUrl, + "healthcheck" + ); + } } } @@ -250,8 +304,9 @@ export class RemoteEncryptionService implements IEncryptionService { * Throws CryptoServiceError with detailed message if not accessible */ export async function checkCryptoService( - serviceUrl: string + serviceUrl: string, + options?: CryptoServiceOptions ): Promise<{ healthy: boolean; version: string }> { - const service = new RemoteEncryptionService(serviceUrl); + const service = new RemoteEncryptionService(serviceUrl, options); return service.healthCheck(); } diff --git a/packages/crypto/Dockerfile b/packages/crypto/Dockerfile index b4fe045..497e37d 100644 --- a/packages/crypto/Dockerfile +++ b/packages/crypto/Dockerfile @@ -7,9 +7,9 @@ RUN CGO_ENABLED=0 GOOS=linux go build -o keyway-crypto . FROM alpine:3.23 RUN apk add --no-cache ca-certificates && \ - addgroup -S crypto && adduser -S crypto -G crypto + addgroup -S crypto && adduser -S crypto -G crypto && \ + mkdir -p /data/crypto && chown crypto:crypto /data/crypto COPY --from=builder /app/keyway-crypto /usr/local/bin/ -ENV ENCRYPTION_KEY="" EXPOSE 50051 USER crypto CMD ["keyway-crypto"] diff --git a/packages/crypto/auth_test.go b/packages/crypto/auth_test.go new file mode 100644 index 0000000..7e7cb45 --- /dev/null +++ b/packages/crypto/auth_test.go @@ -0,0 +1,236 @@ +package main + +import ( + "context" + "net" + "testing" + + "keyway-crypto/crypto" + "keyway-crypto/pb" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/health" + "google.golang.org/grpc/health/grpc_health_v1" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" +) + +const testAuthToken = "test-secret-token-for-auth-interceptor" + +func setupAuthTestServer(t *testing.T, token string, keys map[uint32]string) (pb.CryptoServiceClient, func()) { + t.Helper() + + engine, err := crypto.NewMultiEngine(keys) + if err != nil { + t.Fatalf("failed to create engine: %v", err) + } + + lis := bufconn.Listen(bufSize) + s := grpc.NewServer(grpc.UnaryInterceptor(authInterceptor(token))) + pb.RegisterCryptoServiceServer(s, &server{engine: engine}) + + go func() { + if err := s.Serve(lis); err != nil { + // Server stopped, expected during cleanup + } + }() + + dialer := func(context.Context, string) (net.Conn, error) { + return lis.Dial() + } + + conn, err := grpc.NewClient("passthrough://bufnet", + grpc.WithContextDialer(dialer), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + t.Fatalf("failed to dial: %v", err) + } + + client := pb.NewCryptoServiceClient(conn) + cleanup := func() { + conn.Close() + s.Stop() + } + + return client, cleanup +} + +func ctxWithToken(token string) context.Context { + md := metadata.New(map[string]string{"x-crypto-auth-token": token}) + return metadata.NewOutgoingContext(context.Background(), md) +} + +func TestAuthInterceptor_ValidToken(t *testing.T) { + client, cleanup := setupAuthTestServer(t, testAuthToken, map[uint32]string{1: testKey}) + defer cleanup() + + ctx := ctxWithToken(testAuthToken) + + // Encrypt should succeed + resp, err := client.Encrypt(ctx, &pb.EncryptRequest{ + Plaintext: []byte("hello"), + Version: 0, + }) + if err != nil { + t.Fatalf("Encrypt with valid token failed: %v", err) + } + if len(resp.Ciphertext) == 0 { + t.Fatal("expected non-empty ciphertext") + } + + // Decrypt should succeed + decResp, err := client.Decrypt(ctx, &pb.DecryptRequest{ + Ciphertext: resp.Ciphertext, + Iv: resp.Iv, + AuthTag: resp.AuthTag, + Version: resp.Version, + }) + if err != nil { + t.Fatalf("Decrypt with valid token failed: %v", err) + } + if string(decResp.Plaintext) != "hello" { + t.Fatalf("expected 'hello', got '%s'", string(decResp.Plaintext)) + } +} + +func TestAuthInterceptor_InvalidToken(t *testing.T) { + client, cleanup := setupAuthTestServer(t, testAuthToken, map[uint32]string{1: testKey}) + defer cleanup() + + ctx := ctxWithToken("wrong-token") + + _, err := client.Encrypt(ctx, &pb.EncryptRequest{ + Plaintext: []byte("hello"), + Version: 0, + }) + if err == nil { + t.Fatal("expected error with invalid token") + } + + st, ok := status.FromError(err) + if !ok { + t.Fatalf("expected gRPC status error, got: %v", err) + } + if st.Code() != codes.Unauthenticated { + t.Fatalf("expected Unauthenticated, got: %v", st.Code()) + } +} + +func TestAuthInterceptor_MissingMetadata(t *testing.T) { + client, cleanup := setupAuthTestServer(t, testAuthToken, map[uint32]string{1: testKey}) + defer cleanup() + + // No metadata at all + _, err := client.Encrypt(context.Background(), &pb.EncryptRequest{ + Plaintext: []byte("hello"), + Version: 0, + }) + if err == nil { + t.Fatal("expected error without metadata") + } + + st, ok := status.FromError(err) + if !ok { + t.Fatalf("expected gRPC status error, got: %v", err) + } + if st.Code() != codes.Unauthenticated { + t.Fatalf("expected Unauthenticated, got: %v", st.Code()) + } +} + +func TestAuthInterceptor_EmptyToken(t *testing.T) { + client, cleanup := setupAuthTestServer(t, testAuthToken, map[uint32]string{1: testKey}) + defer cleanup() + + ctx := ctxWithToken("") + + _, err := client.Encrypt(ctx, &pb.EncryptRequest{ + Plaintext: []byte("hello"), + Version: 0, + }) + if err == nil { + t.Fatal("expected error with empty token") + } + + st, ok := status.FromError(err) + if !ok { + t.Fatalf("expected gRPC status error, got: %v", err) + } + if st.Code() != codes.Unauthenticated { + t.Fatalf("expected Unauthenticated, got: %v", st.Code()) + } +} + +func TestAuthInterceptor_AllCryptoMethodsProtected(t *testing.T) { + client, cleanup := setupAuthTestServer(t, testAuthToken, map[uint32]string{1: testKey}) + defer cleanup() + + wrongCtx := ctxWithToken("wrong-token") + + // Encrypt + _, err := client.Encrypt(wrongCtx, &pb.EncryptRequest{Plaintext: []byte("test"), Version: 0}) + if st, ok := status.FromError(err); !ok || st.Code() != codes.Unauthenticated { + t.Fatalf("Encrypt: expected Unauthenticated, got: %v", err) + } + + // Decrypt + _, err = client.Decrypt(wrongCtx, &pb.DecryptRequest{Ciphertext: []byte("x"), Iv: []byte("x"), AuthTag: []byte("x"), Version: 1}) + if st, ok := status.FromError(err); !ok || st.Code() != codes.Unauthenticated { + t.Fatalf("Decrypt: expected Unauthenticated, got: %v", err) + } + + // Keyway HealthCheck (custom RPC, should be protected) + _, err = client.HealthCheck(wrongCtx, &pb.Empty{}) + if st, ok := status.FromError(err); !ok || st.Code() != codes.Unauthenticated { + t.Fatalf("HealthCheck: expected Unauthenticated, got: %v", err) + } +} + +func TestAuthInterceptor_GrpcHealthExempt(t *testing.T) { + // The gRPC standard health service should be accessible without auth + // so Docker/k8s health probes work even when auth is enabled. + engine, err := crypto.NewMultiEngine(map[uint32]string{1: testKey}) + if err != nil { + t.Fatalf("failed to create engine: %v", err) + } + + lis := bufconn.Listen(bufSize) + s := grpc.NewServer(grpc.UnaryInterceptor(authInterceptor(testAuthToken))) + pb.RegisterCryptoServiceServer(s, &server{engine: engine}) + grpc_health_v1.RegisterHealthServer(s, health.NewServer()) + + go func() { + if err := s.Serve(lis); err != nil { + // expected during cleanup + } + }() + + dialer := func(context.Context, string) (net.Conn, error) { + return lis.Dial() + } + conn, err := grpc.NewClient("passthrough://bufnet", + grpc.WithContextDialer(dialer), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + t.Fatalf("failed to dial: %v", err) + } + defer func() { + conn.Close() + s.Stop() + }() + + // Call gRPC health check WITHOUT auth token -- should succeed + healthClient := grpc_health_v1.NewHealthClient(conn) + resp, err := healthClient.Check(context.Background(), &grpc_health_v1.HealthCheckRequest{}) + if err != nil { + t.Fatalf("gRPC health check should not require auth, got: %v", err) + } + if resp.Status != grpc_health_v1.HealthCheckResponse_SERVING { + t.Fatalf("expected SERVING, got: %v", resp.Status) + } +} diff --git a/packages/crypto/main.go b/packages/crypto/main.go index 81cc059..5c58f6f 100644 --- a/packages/crypto/main.go +++ b/packages/crypto/main.go @@ -2,19 +2,35 @@ package main import ( "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "encoding/pem" "fmt" "log" + "math/big" "net" "os" "strconv" "strings" + "time" "keyway-crypto/crypto" "keyway-crypto/pb" "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" "google.golang.org/grpc/health" "google.golang.org/grpc/health/grpc_health_v1" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" ) const version = "1.1.0" @@ -64,6 +80,105 @@ func (s *server) HealthCheck(ctx context.Context, req *pb.Empty) (*pb.HealthResp return &pb.HealthResponse{Healthy: true, Version: version}, nil } +// authInterceptor creates a gRPC unary interceptor that validates a shared secret token. +// Uses SHA-256 hashing + constant-time comparison to prevent timing and length-leaking attacks. +// The gRPC health service is exempt so Docker/k8s health probes still work. +func authInterceptor(expectedToken string) grpc.UnaryServerInterceptor { + expectedHash := sha256.Sum256([]byte(expectedToken)) + return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { + // Allow health probes without auth (used by Docker/k8s) + if info.FullMethod == "/grpc.health.v1.Health/Check" { + return handler(ctx, req) + } + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return nil, status.Error(codes.Unauthenticated, "missing metadata") + } + tokens := md.Get("x-crypto-auth-token") + if len(tokens) == 0 { + return nil, status.Error(codes.Unauthenticated, "invalid or missing auth token") + } + providedHash := sha256.Sum256([]byte(tokens[0])) + if subtle.ConstantTimeCompare(expectedHash[:], providedHash[:]) != 1 { + return nil, status.Error(codes.Unauthenticated, "invalid or missing auth token") + } + return handler(ctx, req) + } +} + +// loadOrGenerateTLS loads an existing TLS certificate or generates a self-signed one. +// Returns the certificate, its SHA-256 fingerprint (hex), and any error. +func loadOrGenerateTLS(certPath, keyPath string) (tls.Certificate, string, error) { + // Check for mismatched cert/key files (one exists, other missing) + _, certErr := os.Stat(certPath) + _, keyErr := os.Stat(keyPath) + certExists := certErr == nil + keyExists := keyErr == nil + if certExists != keyExists { + return tls.Certificate{}, "", fmt.Errorf("mismatched TLS files: cert exists=%v, key exists=%v -- delete both to regenerate", certExists, keyExists) + } + + // Try to load existing cert+key pair + if certExists { + cert, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + return tls.Certificate{}, "", fmt.Errorf("failed to load TLS cert: %w", err) + } + hash := sha256.Sum256(cert.Certificate[0]) + return cert, hex.EncodeToString(hash[:]), nil + } + + // Generate self-signed ECDSA P-256 cert + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return tls.Certificate{}, "", fmt.Errorf("failed to generate key: %w", err) + } + + serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return tls.Certificate{}, "", fmt.Errorf("failed to generate serial: %w", err) + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{CommonName: "keyway-crypto"}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + DNSNames: []string{"localhost", "crypto", "*.railway.internal"}, + IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)}, + } + + certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) + if err != nil { + return tls.Certificate{}, "", fmt.Errorf("failed to create certificate: %w", err) + } + + // Write cert PEM + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + if err := os.WriteFile(certPath, certPEM, 0644); err != nil { + return tls.Certificate{}, "", fmt.Errorf("failed to write cert: %w", err) + } + + // Write key PEM + keyDER, err := x509.MarshalECPrivateKey(privateKey) + if err != nil { + return tls.Certificate{}, "", fmt.Errorf("failed to marshal key: %w", err) + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil { + return tls.Certificate{}, "", fmt.Errorf("failed to write key: %w", err) + } + + cert, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + return tls.Certificate{}, "", fmt.Errorf("failed to load generated cert: %w", err) + } + hash := sha256.Sum256(certDER) + return cert, hex.EncodeToString(hash[:]), nil +} + // parseEncryptionKeys parses ENCRYPTION_KEYS format: "1:hex_key_1,2:hex_key_2" // Falls back to ENCRYPTION_KEY (single key as version 1) for backward compatibility func parseEncryptionKeys() (map[uint32]string, error) { @@ -124,7 +239,47 @@ func main() { log.Fatalf("Failed to listen: %v", err) } - s := grpc.NewServer() + // Build gRPC server options (auth + TLS) + var opts []grpc.ServerOption + + // Auth interceptor (shared secret) + authToken := os.Getenv("CRYPTO_AUTH_TOKEN") + if authToken != "" { + opts = append(opts, grpc.UnaryInterceptor(authInterceptor(authToken))) + log.Printf("Auth token interceptor enabled") + } else { + log.Printf("WARNING: No CRYPTO_AUTH_TOKEN set -- running without authentication") + } + + // TLS (self-signed cert, auto-generated if needed) + tlsCertPath := os.Getenv("CRYPTO_TLS_CERT_PATH") + tlsKeyPath := os.Getenv("CRYPTO_TLS_KEY_PATH") + if tlsCertPath == "" { + tlsCertPath = "/data/crypto/tls.crt" + } + if tlsKeyPath == "" { + tlsKeyPath = "/data/crypto/tls.key" + } + + tlsRequired := os.Getenv("CRYPTO_TLS_REQUIRED") == "true" + + if tlsCertPath != "none" { + cert, fingerprint, tlsErr := loadOrGenerateTLS(tlsCertPath, tlsKeyPath) + if tlsErr != nil { + if tlsRequired { + log.Fatalf("TLS setup failed and CRYPTO_TLS_REQUIRED=true: %v", tlsErr) + } + log.Printf("WARNING: TLS setup failed (%v) -- running without TLS", tlsErr) + } else { + creds := credentials.NewServerTLSFromCert(&cert) + opts = append(opts, grpc.Creds(creds)) + log.Printf("TLS enabled, cert SHA-256: %s", fingerprint) + } + } else { + log.Printf("TLS disabled (CRYPTO_TLS_CERT_PATH=none)") + } + + s := grpc.NewServer(opts...) pb.RegisterCryptoServiceServer(s, &server{engine: engine}) // Health check for k8s/docker diff --git a/packages/crypto/tls_test.go b/packages/crypto/tls_test.go new file mode 100644 index 0000000..15b8dbb --- /dev/null +++ b/packages/crypto/tls_test.go @@ -0,0 +1,184 @@ +package main + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/x509" + "encoding/pem" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoadOrGenerateTLS_GeneratesCert(t *testing.T) { + tmpDir := t.TempDir() + certPath := filepath.Join(tmpDir, "tls.crt") + keyPath := filepath.Join(tmpDir, "tls.key") + + cert, hash, err := loadOrGenerateTLS(certPath, keyPath) + if err != nil { + t.Fatalf("loadOrGenerateTLS failed: %v", err) + } + + // Verify files were created + if _, err := os.Stat(certPath); os.IsNotExist(err) { + t.Fatal("cert file was not created") + } + if _, err := os.Stat(keyPath); os.IsNotExist(err) { + t.Fatal("key file was not created") + } + + // Verify key file permissions (0600) + info, err := os.Stat(keyPath) + if err != nil { + t.Fatalf("failed to stat key file: %v", err) + } + if perm := info.Mode().Perm(); perm != 0600 { + t.Fatalf("expected key file permissions 0600, got %o", perm) + } + + // Verify hash is 64 hex chars + if len(hash) != 64 { + t.Fatalf("expected 64-char hash, got %d chars: %s", len(hash), hash) + } + + // Verify cert is valid + if len(cert.Certificate) == 0 { + t.Fatal("expected at least one certificate in chain") + } +} + +func TestLoadOrGenerateTLS_LoadsExisting(t *testing.T) { + tmpDir := t.TempDir() + certPath := filepath.Join(tmpDir, "tls.crt") + keyPath := filepath.Join(tmpDir, "tls.key") + + // Generate first + _, hash1, err := loadOrGenerateTLS(certPath, keyPath) + if err != nil { + t.Fatalf("first call failed: %v", err) + } + + // Load existing -- should return the same hash + _, hash2, err := loadOrGenerateTLS(certPath, keyPath) + if err != nil { + t.Fatalf("second call failed: %v", err) + } + + if hash1 != hash2 { + t.Fatalf("hashes differ: %s vs %s", hash1, hash2) + } +} + +func TestLoadOrGenerateTLS_CertHasCorrectSANs(t *testing.T) { + tmpDir := t.TempDir() + certPath := filepath.Join(tmpDir, "tls.crt") + keyPath := filepath.Join(tmpDir, "tls.key") + + _, _, err := loadOrGenerateTLS(certPath, keyPath) + if err != nil { + t.Fatalf("loadOrGenerateTLS failed: %v", err) + } + + // Parse the cert to check SANs + certPEM, err := os.ReadFile(certPath) + if err != nil { + t.Fatalf("failed to read cert: %v", err) + } + + block, _ := pem.Decode(certPEM) + if block == nil { + t.Fatal("failed to decode PEM") + } + + x509Cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatalf("failed to parse certificate: %v", err) + } + + // Check DNS SANs + expectedDNS := map[string]bool{ + "localhost": false, + "crypto": false, + "*.railway.internal": false, + } + for _, name := range x509Cert.DNSNames { + if _, ok := expectedDNS[name]; ok { + expectedDNS[name] = true + } + } + for name, found := range expectedDNS { + if !found { + t.Errorf("missing DNS SAN: %s (found: %v)", name, x509Cert.DNSNames) + } + } + + // Check IP SANs + foundLoopback := false + for _, ip := range x509Cert.IPAddresses { + if ip.String() == "127.0.0.1" { + foundLoopback = true + } + } + if !foundLoopback { + t.Error("missing IP SAN: 127.0.0.1") + } +} + +func TestLoadOrGenerateTLS_MismatchedFiles(t *testing.T) { + tmpDir := t.TempDir() + certPath := filepath.Join(tmpDir, "tls.crt") + keyPath := filepath.Join(tmpDir, "tls.key") + + // Create cert file but not key file + os.WriteFile(certPath, []byte("dummy"), 0644) + + _, _, err := loadOrGenerateTLS(certPath, keyPath) + if err == nil { + t.Fatal("expected error when cert exists but key doesn't") + } + if !strings.Contains(err.Error(), "mismatched TLS files") { + t.Fatalf("expected mismatched error, got: %v", err) + } + + // Clean up and test the reverse: key exists but cert doesn't + os.Remove(certPath) + os.WriteFile(keyPath, []byte("dummy"), 0600) + + _, _, err = loadOrGenerateTLS(certPath, keyPath) + if err == nil { + t.Fatal("expected error when key exists but cert doesn't") + } + if !strings.Contains(err.Error(), "mismatched TLS files") { + t.Fatalf("expected mismatched error, got: %v", err) + } +} + +func TestLoadOrGenerateTLS_CertIsECDSAP256(t *testing.T) { + tmpDir := t.TempDir() + certPath := filepath.Join(tmpDir, "tls.crt") + keyPath := filepath.Join(tmpDir, "tls.key") + + _, _, err := loadOrGenerateTLS(certPath, keyPath) + if err != nil { + t.Fatalf("loadOrGenerateTLS failed: %v", err) + } + + // Parse cert + certPEM, _ := os.ReadFile(certPath) + block, _ := pem.Decode(certPEM) + x509Cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatalf("failed to parse certificate: %v", err) + } + + // Check algorithm + pubKey, ok := x509Cert.PublicKey.(*ecdsa.PublicKey) + if !ok { + t.Fatalf("expected ECDSA public key, got %T", x509Cert.PublicKey) + } + if pubKey.Curve != elliptic.P256() { + t.Fatalf("expected P-256 curve, got %v", pubKey.Curve.Params().Name) + } +} From e87e408b8cf5b074f6b29760902957698bf4e46f Mon Sep 17 00:00:00 2001 From: Nicolas Ritouet Date: Thu, 9 Apr 2026 21:51:06 +0200 Subject: [PATCH 5/7] fix(backend): resolve lint errors in remoteEncryption callRpc helper - Add curly braces to if-return (curly rule) - Replace `Function` type with explicit `(...args: unknown[]) => void` to satisfy @typescript-eslint/no-unsafe-function-type Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/backend/src/utils/remoteEncryption.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/utils/remoteEncryption.ts b/packages/backend/src/utils/remoteEncryption.ts index 42c201b..021d918 100644 --- a/packages/backend/src/utils/remoteEncryption.ts +++ b/packages/backend/src/utils/remoteEncryption.ts @@ -225,13 +225,17 @@ export class RemoteEncryptionService implements IEncryptionService { ): Promise { return new Promise((resolve, reject) => { const cb = (err: Error | null, response: TRes) => { - if (err) return reject(err); + if (err) { + return reject(err); + } resolve(response); }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -- proto-loaded gRPC clients have dynamic signatures that can't be statically typed + const fn = method as unknown as (...args: unknown[]) => void; if (this.metadata) { - (method as Function).call(this.client, request, this.metadata, cb); + fn.call(this.client, request, this.metadata, cb); } else { - (method as Function).call(this.client, request, cb); + fn.call(this.client, request, cb); } }); } From d577bb7a4643ecc54121b928da07cc3acab63b57 Mon Sep 17 00:00:00 2001 From: Nicolas Ritouet Date: Thu, 9 Apr 2026 21:55:15 +0200 Subject: [PATCH 6/7] fix(crypto): address CodeRabbit review findings - Default CRYPTO_TLS_CERT_PATH=none in docker-compose so plain `docker compose up` stays in insecure mode (no TLS mismatch) - Validate cert expiration on load, auto-regenerate if expired, warn if expiring within 30 days - Atomic cert/key writes via temp files + rename to prevent half-written pairs on crash - Simplify healthcheck back to nc -z (cert check unnecessary since default is now TLS off) Co-Authored-By: Claude Opus 4.6 (1M context) --- docker-compose.yml | 6 +++--- packages/crypto/main.go | 48 +++++++++++++++++++++++++++++++++++------ 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index cd9b858..66927f0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,12 +21,12 @@ services: - ENCRYPTION_KEY=${ENCRYPTION_KEY} - GRPC_PORT=${GRPC_PORT:-50051} - CRYPTO_AUTH_TOKEN=${CRYPTO_AUTH_TOKEN:-} - - CRYPTO_TLS_CERT_PATH=${CRYPTO_TLS_CERT_PATH:-} - - CRYPTO_TLS_KEY_PATH=${CRYPTO_TLS_KEY_PATH:-} + - CRYPTO_TLS_CERT_PATH=${CRYPTO_TLS_CERT_PATH:-none} + - CRYPTO_TLS_KEY_PATH=${CRYPTO_TLS_KEY_PATH:-none} volumes: - crypto_certs:/data/crypto healthcheck: - test: ["CMD-SHELL", "(test -z \"${CRYPTO_TLS_CERT_PATH}\" || test -f /data/crypto/tls.crt) && nc -z localhost ${GRPC_PORT:-50051} || exit 1"] + test: ["CMD-SHELL", "nc -z localhost ${GRPC_PORT:-50051} || exit 1"] interval: 10s timeout: 5s retries: 3 diff --git a/packages/crypto/main.go b/packages/crypto/main.go index 5c58f6f..70c3aea 100644 --- a/packages/crypto/main.go +++ b/packages/crypto/main.go @@ -124,6 +124,22 @@ func loadOrGenerateTLS(certPath, keyPath string) (tls.Certificate, string, error if err != nil { return tls.Certificate{}, "", fmt.Errorf("failed to load TLS cert: %w", err) } + // Validate cert is not expired + x509Cert, err := x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + return tls.Certificate{}, "", fmt.Errorf("failed to parse TLS cert: %w", err) + } + now := time.Now() + if now.Before(x509Cert.NotBefore) || now.After(x509Cert.NotAfter) { + log.Printf("TLS cert expired (valid %s to %s), regenerating...", x509Cert.NotBefore.Format(time.RFC3339), x509Cert.NotAfter.Format(time.RFC3339)) + os.Remove(certPath) + os.Remove(keyPath) + return loadOrGenerateTLS(certPath, keyPath) + } + // Warn if cert expires within 30 days + if time.Until(x509Cert.NotAfter) < 30*24*time.Hour { + log.Printf("WARNING: TLS cert expires in %d days (%s)", int(time.Until(x509Cert.NotAfter).Hours()/24), x509Cert.NotAfter.Format(time.RFC3339)) + } hash := sha256.Sum256(cert.Certificate[0]) return cert, hex.EncodeToString(hash[:]), nil } @@ -155,21 +171,39 @@ func loadOrGenerateTLS(certPath, keyPath string) (tls.Certificate, string, error return tls.Certificate{}, "", fmt.Errorf("failed to create certificate: %w", err) } - // Write cert PEM + // Write cert and key atomically using temp files + rename to avoid partial writes certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) - if err := os.WriteFile(certPath, certPEM, 0644); err != nil { - return tls.Certificate{}, "", fmt.Errorf("failed to write cert: %w", err) - } - - // Write key PEM keyDER, err := x509.MarshalECPrivateKey(privateKey) if err != nil { return tls.Certificate{}, "", fmt.Errorf("failed to marshal key: %w", err) } keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) - if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil { + + tmpCert := certPath + ".tmp" + tmpKey := keyPath + ".tmp" + // Clean up temp files on any error + cleanup := func() { + os.Remove(tmpCert) + os.Remove(tmpKey) + } + + if err := os.WriteFile(tmpCert, certPEM, 0644); err != nil { + cleanup() + return tls.Certificate{}, "", fmt.Errorf("failed to write cert: %w", err) + } + if err := os.WriteFile(tmpKey, keyPEM, 0600); err != nil { + cleanup() return tls.Certificate{}, "", fmt.Errorf("failed to write key: %w", err) } + if err := os.Rename(tmpCert, certPath); err != nil { + cleanup() + return tls.Certificate{}, "", fmt.Errorf("failed to rename cert: %w", err) + } + if err := os.Rename(tmpKey, keyPath); err != nil { + // Cert already renamed, try to clean up + os.Remove(certPath) + return tls.Certificate{}, "", fmt.Errorf("failed to rename key: %w", err) + } cert, err := tls.LoadX509KeyPair(certPath, keyPath) if err != nil { From d4a46c12348d4a6de5af4c8ce36e62dc2bd2318b Mon Sep 17 00:00:00 2001 From: Nicolas Ritouet Date: Thu, 9 Apr 2026 22:17:55 +0200 Subject: [PATCH 7/7] docs(crypto): document TLS/auth deployment recommendations per platform - .env.example: deployment-specific guidance (Docker Compose uses shared volume, Railway only needs auth token, Kubernetes via Secret) - README.md: updated security section with new env vars and per-platform recommendations Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/backend/.env.example | 24 +++++++++++++++++------- packages/crypto/README.md | 7 ++++++- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/packages/backend/.env.example b/packages/backend/.env.example index a66be8d..73cadb8 100644 --- a/packages/backend/.env.example +++ b/packages/backend/.env.example @@ -25,16 +25,26 @@ DATABASE_URL=postgresql://user:password@localhost:5432/keyway CRYPTO_SERVICE_URL=localhost:50051 # Security: shared secret for crypto service authentication (optional but recommended) -# Must match CRYPTO_AUTH_TOKEN on the crypto service +# Must match CRYPTO_AUTH_TOKEN on the crypto service. # Generate with: openssl rand -hex 32 # CRYPTO_AUTH_TOKEN= - +# # TLS for crypto service (optional - for encrypted gRPC transport) -# The crypto service auto-generates a self-signed cert on first start. -# Docker Compose: use shared volume path: -# CRYPTO_TLS_CA_PATH=/data/crypto/tls.crt -# Railway/env-based: base64-encoded PEM cert: -# CRYPTO_TLS_CA=LS0tLS1CRUdJTi... +# The crypto service auto-generates a self-signed ECDSA P-256 cert on first start. +# +# Deployment recommendations: +# - Docker Compose: use the shared volume (cert auto-distributed): +# CRYPTO_TLS_CA_PATH=/data/crypto/tls.crt +# - Railway: TLS is not needed (private networking is already isolated). +# Just use CRYPTO_AUTH_TOKEN for authentication. The .railway.internal +# network is not shared with other tenants. +# - Kubernetes: mount the cert via a Secret or shared volume: +# CRYPTO_TLS_CA_PATH=/etc/keyway/crypto-tls/tls.crt +# - Other (env-based): pass the cert PEM as base64: +# CRYPTO_TLS_CA=LS0tLS1CRUdJTi... +# +# CRYPTO_TLS_CA_PATH= +# CRYPTO_TLS_CA= # ----------------------------------------- # JWT (for device flow tokens) diff --git a/packages/crypto/README.md b/packages/crypto/README.md index edbf298..bd0cf76 100644 --- a/packages/crypto/README.md +++ b/packages/crypto/README.md @@ -75,6 +75,10 @@ ENCRYPTION_KEY=<64-hex-chars> make run | `ENCRYPTION_KEY` | Single AES-256 key in hex (64 chars) | Yes* | | `ENCRYPTION_KEYS` | Versioned keys for rotation (e.g., `1:key1,2:key2`) | Yes* | | `GRPC_PORT` | gRPC server port (default: 50051) | No | +| `CRYPTO_AUTH_TOKEN` | Shared secret for gRPC authentication. Generate with `openssl rand -hex 32`. Must match the backend's `CRYPTO_AUTH_TOKEN`. | No | +| `CRYPTO_TLS_CERT_PATH` | Path to TLS cert PEM. Set to `none` to disable TLS (default in docker-compose). If the file doesn't exist, a self-signed ECDSA P-256 cert is auto-generated. | No | +| `CRYPTO_TLS_KEY_PATH` | Path to TLS key PEM. Auto-generated alongside the cert. | No | +| `CRYPTO_TLS_REQUIRED` | Set to `true` to make TLS failures fatal instead of falling back to plaintext. Recommended for production. | No | \* Either `ENCRYPTION_KEY` or `ENCRYPTION_KEYS` must be set. `ENCRYPTION_KEYS` takes priority if both are set. @@ -179,7 +183,8 @@ Key points: 2. **Key management**: Never commit `ENCRYPTION_KEY` to version control. Use a secrets manager in production. 3. **Key rotation**: Use `ENCRYPTION_KEYS` with versioned keys (e.g., `1:oldkey,2:newkey`) for zero-downtime rotation. 4. **Logging**: The service never logs plaintext, ciphertext, IVs, auth tags, or keys. -5. **mTLS**: Optional but recommended for high-security environments. Network isolation provides the baseline transport security. +5. **Authentication**: Set `CRYPTO_AUTH_TOKEN` on both the crypto service and the backend to require a shared secret on every gRPC call. Uses SHA-256 + constant-time comparison. +6. **TLS**: Set `CRYPTO_TLS_CERT_PATH` to enable auto-generated self-signed TLS. The cert is shared with the backend via Docker volume or env var. Recommended for Docker Compose and Kubernetes. On Railway, the private network isolation makes TLS unnecessary -- use `CRYPTO_AUTH_TOKEN` alone. ## Make Commands