diff --git a/nostr/commit-signatures.jsonl b/nostr/commit-signatures.jsonl index 3d0dc4e..75a629f 100644 --- a/nostr/commit-signatures.jsonl +++ b/nostr/commit-signatures.jsonl @@ -78,3 +78,4 @@ {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771970166,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","patch highlights and comments\nupdate prs to match"]],"content":"Signed commit: patch highlights and comments\nupdate prs to match","id":"f85ce49f7314d99b96e3d837d096fa36745f4dd6087123c51a4a9110f23fcbfa","sig":"a323eb2081c46974a2fa09835bde3a65e5242f782ef34a1ec363e9da0784a00ad63c3f9f6cee016612bdab0d4d9a0e54e2a5bdb4424a635c650e317704331825"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771999453,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","load files from HEAD"]],"content":"Signed commit: load files from HEAD","id":"214fc0597e79b465c0c718a2227de942697409002b6cf5c322c9a6d9b36de333","sig":"713a33e751e0582669e9328bca2ac048585534111984bd6ca938270409f7957178d497c92a981719594e927ca7d301e033306c1d1b261395984b91b2d81762e2"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771999938,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","verify button for cloned repos"]],"content":"Signed commit: verify button for cloned repos","id":"4710ea5de6287e00b5da9a6d7cd6568901e3db45a71476b56dc83ec39b8be73d","sig":"7613ca0847af4eb1fd3f52ef0f59c8f6316ba75605085da8eb0a64ced6fe43897d6af26b84d218155ab61ab8e1b42cbc2a686f2eab9572734fb7d911961d3e85"} +{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772000347,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","added status to patches\nrenamed chat-relay to project-relay"]],"content":"Signed commit: added status to patches\nrenamed chat-relay to project-relay","id":"3c717ed3935bf95a70a0e9ffbe655728d325f72e8cbeb3d38da37b1b6e1304a2","sig":"952584bfe718362864fdf117bb4c4b042dbea9fe2307bca2f94a9004394bb6fdb3f4f4acd6714bcfdb32453a9d09d24e2c97f512bc1b06e1ba3cd50556f67b6e"} diff --git a/src/lib/services/git/commit-signer.ts b/src/lib/services/git/commit-signer.ts index d117c2b..bf6bee8 100644 --- a/src/lib/services/git/commit-signer.ts +++ b/src/lib/services/git/commit-signer.ts @@ -6,7 +6,7 @@ * - Direct nsec/hex keys (for server-side signing) */ -import { nip19, getPublicKey, finalizeEvent } from 'nostr-tools'; +import { nip19, getPublicKey, finalizeEvent, verifyEvent, getEventHash } from 'nostr-tools'; import { createHash } from 'crypto'; import type { NostrEvent } from '../../types/nostr.js'; import { KIND } from '../../types/nostr.js'; @@ -106,12 +106,12 @@ export function createCommitSignatureEvent( kind: KIND.COMMIT_SIGNATURE, pubkey, created_at: timestamp, - tags: [ - ['commit', commitHash], - ['author', authorName, authorEmail], - ['message', commitMessage] - ], - content: `Signed commit: ${commitHash}\n\n${commitMessage}` + tags: [ + ['commit', commitHash], + ['author', authorName, authorEmail], + ['message', commitMessage] + ], + content: `Signed commit` }; // Finalize and sign the event @@ -183,7 +183,7 @@ export async function createGitCommitSignature( ['author', authorName, authorEmail], ['message', commitMessage] ], - content: `Signed commit: ${commitMessage}` + content: `Signed commit` }; signedEvent = await signEventWithNIP07(eventTemplate); } @@ -203,7 +203,7 @@ export async function createGitCommitSignature( ['message', commitMessage], ['e', options.nip98Event.id, '', 'nip98-auth'] // Reference the NIP-98 auth event ], - content: `Signed commit: ${commitMessage}\n\nAuthenticated via NIP-98 event: ${options.nip98Event.id}` + content: `Signed commit (NIP-98: ${options.nip98Event.id})` }; // Create event ID without signature (will need client to sign) @@ -239,7 +239,7 @@ export async function createGitCommitSignature( ['author', authorName, authorEmail], ['message', commitMessage] ], - content: `Signed commit: ${commitMessage}` + content: `Signed commit` }; signedEvent = finalizeEvent(eventTemplate, keyBytes); @@ -288,12 +288,21 @@ export function updateCommitSignatureWithHash( } /** - * Verify a commit signature from a Nostr event + * Verify a commit signature from a Nostr event and return original information */ export function verifyCommitSignature( signatureEvent: NostrEvent, commitHash: string -): { valid: boolean; error?: string } { +): { + valid: boolean; + error?: string; + pubkey?: string; + authorName?: string; + authorEmail?: string; + message?: string; + timestamp?: number; + eventId?: string; +} { // Check event kind if (signatureEvent.kind !== KIND.COMMIT_SIGNATURE) { return { valid: false, error: `Invalid event kind for commit signature. Expected ${KIND.COMMIT_SIGNATURE}, got ${signatureEvent.kind}` }; @@ -305,13 +314,30 @@ export function verifyCommitSignature( return { valid: false, error: 'Commit hash mismatch' }; } - // Verify event signature (would need to import verifyEvent from nostr-tools) - // For now, we'll just check the structure - if (!signatureEvent.sig || !signatureEvent.id) { - return { valid: false, error: 'Missing signature or event ID' }; + // Verify event signature cryptographically + if (!verifyEvent(signatureEvent)) { + return { valid: false, error: 'Invalid event signature - event may be forged or corrupted' }; } - return { valid: true }; + // Verify event ID matches computed hash (prevents ID tampering) + const computedId = getEventHash(signatureEvent); + if (computedId !== signatureEvent.id) { + return { valid: false, error: 'Event ID does not match computed hash - event may be tampered with' }; + } + + // Extract original information from tags + const authorTag = signatureEvent.tags.find(t => t[0] === 'author'); + const messageTag = signatureEvent.tags.find(t => t[0] === 'message'); + + return { + valid: true, + pubkey: signatureEvent.pubkey, + authorName: authorTag?.[1] || undefined, + authorEmail: authorTag?.[2] || undefined, + message: messageTag?.[1] || undefined, + timestamp: signatureEvent.created_at, + eventId: signatureEvent.id + }; } /** @@ -341,3 +367,101 @@ export function extractCommitSignature(commitMessage: string): { } }; } + +/** + * Verify a commit by extracting signature from commit message and verifying it + * This function checks the repo's .jsonl file first, then falls back to Nostr relays + */ +export async function verifyCommitFromMessage( + commitMessage: string, + commitHash: string, + nostrClient: { fetchEvents: (filters: any[]) => Promise }, // NostrClient interface + fileManager?: { getFileContent: (npub: string, repoName: string, filePath: string, ref?: string) => Promise<{ content: string }> }, + npub?: string, + repoName?: string +): Promise<{ + valid: boolean; + error?: string; + pubkey?: string; + authorName?: string; + authorEmail?: string; + message?: string; + timestamp?: number; + eventId?: string; + npub?: string; + hasSignature?: boolean; // Indicates if a signature was found at all +}> { + // Extract signature from commit message + const { signature } = extractCommitSignature(commitMessage); + + if (!signature) { + return { valid: false, hasSignature: false, error: 'No Nostr signature found in commit message' }; + } + + let signatureEvent: NostrEvent | null = null; + + // First, try to find the signature event in the repo's .jsonl file + if (fileManager && npub && repoName) { + try { + const jsonlFile = await fileManager.getFileContent(npub, repoName, 'nostr/repo-events.jsonl', commitHash); + if (jsonlFile && jsonlFile.content) { + const lines = jsonlFile.content.trim().split('\n').filter(l => l.trim()); + for (const line of lines) { + try { + const parsed = JSON.parse(line); + // Check if this is a commit signature event with matching event ID + if (parsed.event && + parsed.event.kind === KIND.COMMIT_SIGNATURE && + parsed.event.id === signature.eventId) { + signatureEvent = parsed.event as NostrEvent; + break; // Found it, stop searching + } + } catch { + // Skip invalid JSON lines + } + } + } + } catch (err) { + // .jsonl file not found or error reading it, fall through to relay check + console.debug('Could not read repo-events.jsonl, falling back to relays:', err); + } + } + + // If not found in .jsonl, fetch from Nostr relays + if (!signatureEvent) { + const events = await nostrClient.fetchEvents([ + { + kinds: [KIND.COMMIT_SIGNATURE], + ids: [signature.eventId], + limit: 1 + } + ]); + + if (events.length === 0) { + return { valid: false, hasSignature: true, error: 'Signature event not found in Nostr relays' }; + } + + signatureEvent = events[0] as NostrEvent; + } + + // Verify the signature + const verification = verifyCommitSignature(signatureEvent, commitHash); + + if (!verification.valid) { + return { ...verification, hasSignature: true }; + } + + // Convert pubkey to npub for display + let npubDisplay: string | undefined; + try { + npubDisplay = nip19.npubEncode(signature.pubkey); + } catch { + // Ignore npub encoding errors + } + + return { + ...verification, + npub: npubDisplay, + hasSignature: true + }; +} diff --git a/src/lib/styles/repo.css b/src/lib/styles/repo.css index ecb9aec..ef4c988 100644 --- a/src/lib/styles/repo.css +++ b/src/lib/styles/repo.css @@ -2086,6 +2086,66 @@ span.clone-more { color: var(--text-muted); } +.commit-header-row { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.25rem; + flex-wrap: wrap; +} + +.commit-verification-group { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.commit-verified-badge { + display: inline-block; + padding: 0.125rem 0.5rem; + border-radius: 0.25rem; + font-size: 0.7rem; + font-weight: 500; + background: var(--success); + color: var(--success-text, #ffffff); + cursor: help; + flex-shrink: 0; +} + +.commit-verified-badge.invalid { + background: var(--error); + color: var(--error-text, #ffffff); +} + +.commit-verified-badge.verifying { + background: var(--warning); + color: var(--warning-text, #ffffff); +} + +.commit-verification-tooltip { + position: fixed; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 0.5rem; + padding: 0.75rem; + box-shadow: 0 4px 12px var(--shadow-color); + z-index: 10000; + font-size: 0.875rem; + line-height: 1.6; + max-width: 300px; + pointer-events: none; +} + +.commit-verification-tooltip div { + margin: 0.25rem 0; +} + +.commit-verification-tooltip strong { + color: var(--text-primary); + margin-right: 0.5rem; +} + .issue-status, .pr-status, .patch-status { diff --git a/src/routes/api/repos/[npub]/[repo]/commits/[hash]/verify/+server.ts b/src/routes/api/repos/[npub]/[repo]/commits/[hash]/verify/+server.ts new file mode 100644 index 0000000..e9ea45f --- /dev/null +++ b/src/routes/api/repos/[npub]/[repo]/commits/[hash]/verify/+server.ts @@ -0,0 +1,56 @@ +/** + * API endpoint for verifying commit signatures + */ + +import { json } from '@sveltejs/kit'; +// @ts-ignore - SvelteKit generates this type +import type { RequestHandler } from './$types'; +import { createRepoGetHandler } from '$lib/utils/api-handlers.js'; +import type { RepoRequestContext } from '$lib/utils/api-context.js'; +import { handleApiError } from '$lib/utils/error-handler.js'; +import { nostrClient, fileManager } from '$lib/services/service-registry.js'; +import { verifyCommitFromMessage } from '$lib/services/git/commit-signer.js'; +import simpleGit from 'simple-git'; +import { join } from 'path'; + +const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT + ? process.env.GIT_REPO_ROOT + : '/repos'; + +export const GET: RequestHandler = createRepoGetHandler( + async (context: RepoRequestContext) => { + const { hash } = context.params as { hash: string }; + + if (!hash) { + throw handleApiError(new Error('Missing commit hash'), { operation: 'verifyCommit', npub: context.npub, repo: context.repo }, 'Missing commit hash'); + } + + const repoPath = join(repoRoot, context.npub, `${context.repo}.git`); + + try { + // Get commit message from git + const git = simpleGit(repoPath); + const commit = await git.show([hash, '--format=%B', '--no-patch']); + + if (!commit) { + throw handleApiError(new Error('Commit not found'), { operation: 'verifyCommit', npub: context.npub, repo: context.repo }, 'Commit not found'); + } + + // Verify the commit signature + // Check .jsonl file first, then fall back to relays + const verification = await verifyCommitFromMessage( + commit, + hash, + nostrClient, + fileManager, + context.npub, + context.repo + ); + + return json(verification); + } catch (err) { + throw handleApiError(err, { operation: 'verifyCommit', npub: context.npub, repo: context.repo }, 'Failed to verify commit'); + } + }, + { operation: 'verifyCommit', requireRepoExists: true, requireRepoAccess: false } +); diff --git a/src/routes/repos/[npub]/[repo]/+page.svelte b/src/routes/repos/[npub]/[repo]/+page.svelte index 73162fd..57f0b89 100644 --- a/src/routes/repos/[npub]/[repo]/+page.svelte +++ b/src/routes/repos/[npub]/[repo]/+page.svelte @@ -391,11 +391,29 @@ let defaultBranchName = $state('master'); // Default branch from settings // Commit history - let commits = $state>([]); + let commits = $state>([]); let loadingCommits = $state(false); let selectedCommit = $state(null); let showDiff = $state(false); let diffData = $state>([]); + let verifyingCommits = $state>(new Set()); // Tags let tags = $state>([]); @@ -4452,6 +4470,15 @@ date: commit.date || new Date().toISOString(), files: commit.files || [] })).filter((commit: any) => commit.hash); // Filter out commits without hash + + // Verify commits in background (only for cloned repos) + if (isRepoCloned === true) { + commits.forEach(commit => { + verifyCommit(commit.hash).catch(err => { + console.warn(`Failed to verify commit ${commit.hash}:`, err); + }); + }); + } } } catch (err) { error = err instanceof Error ? err.message : 'Failed to load commit history'; @@ -4460,6 +4487,33 @@ } } + async function verifyCommit(commitHash: string) { + if (verifyingCommits.has(commitHash)) return; // Already verifying + if (!isRepoCloned) return; // Can't verify without local repo + + verifyingCommits.add(commitHash); + try { + const response = await fetch(`/api/repos/${npub}/${repo}/commits/${commitHash}/verify`, { + headers: buildApiHeaders() + }); + if (response.ok) { + const verification = await response.json(); + // Only update verification if there's actually a signature + // If hasSignature is false or undefined, don't set verification at all + if (verification.hasSignature !== false) { + const commitIndex = commits.findIndex(c => c.hash === commitHash); + if (commitIndex >= 0) { + commits[commitIndex].verification = verification; + } + } + } + } catch (err) { + console.warn(`Failed to verify commit ${commitHash}:`, err); + } finally { + verifyingCommits.delete(commitHash); + } + } + async function viewDiff(commitHash: string) { // Set selected commit immediately so it shows in the right panel selectedCommit = commitHash; @@ -5894,10 +5948,65 @@
    {#each commits as commit} {@const commitHash = commit.hash || (commit as any).sha || ''} + {@const verification = commit.verification} + {@const isVerifying = verifyingCommits.has(commitHash)} {#if commitHash}