Browse Source

improving commit signing and verification

Nostr-Signature: c149ee64445a63b9a471d1866df86d702fe3fead1049a8e3272ea76a25f11094 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc f0745d02cb1b2ac012feb5e38cd4917eb9af48338eb13626aedae6ce73025758b2debe6874c5af3a4e252241405fdaa91042a031fa56c4fe0257c978d23babb2
main
Silberengel 3 weeks ago
parent
commit
6cc408d7c7
  1. 1
      nostr/commit-signatures.jsonl
  2. 158
      src/lib/services/git/commit-signer.ts
  3. 60
      src/lib/styles/repo.css
  4. 56
      src/routes/api/repos/[npub]/[repo]/commits/[hash]/verify/+server.ts
  5. 113
      src/routes/repos/[npub]/[repo]/+page.svelte

1
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":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":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":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"}

158
src/lib/services/git/commit-signer.ts

@ -6,7 +6,7 @@
* - Direct nsec/hex keys (for server-side signing) * - 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 { createHash } from 'crypto';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/nostr.js'; import { KIND } from '../../types/nostr.js';
@ -106,12 +106,12 @@ export function createCommitSignatureEvent(
kind: KIND.COMMIT_SIGNATURE, kind: KIND.COMMIT_SIGNATURE,
pubkey, pubkey,
created_at: timestamp, created_at: timestamp,
tags: [ tags: [
['commit', commitHash], ['commit', commitHash],
['author', authorName, authorEmail], ['author', authorName, authorEmail],
['message', commitMessage] ['message', commitMessage]
], ],
content: `Signed commit: ${commitHash}\n\n${commitMessage}` content: `Signed commit`
}; };
// Finalize and sign the event // Finalize and sign the event
@ -183,7 +183,7 @@ export async function createGitCommitSignature(
['author', authorName, authorEmail], ['author', authorName, authorEmail],
['message', commitMessage] ['message', commitMessage]
], ],
content: `Signed commit: ${commitMessage}` content: `Signed commit`
}; };
signedEvent = await signEventWithNIP07(eventTemplate); signedEvent = await signEventWithNIP07(eventTemplate);
} }
@ -203,7 +203,7 @@ export async function createGitCommitSignature(
['message', commitMessage], ['message', commitMessage],
['e', options.nip98Event.id, '', 'nip98-auth'] // Reference the NIP-98 auth event ['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) // Create event ID without signature (will need client to sign)
@ -239,7 +239,7 @@ export async function createGitCommitSignature(
['author', authorName, authorEmail], ['author', authorName, authorEmail],
['message', commitMessage] ['message', commitMessage]
], ],
content: `Signed commit: ${commitMessage}` content: `Signed commit`
}; };
signedEvent = finalizeEvent(eventTemplate, keyBytes); 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( export function verifyCommitSignature(
signatureEvent: NostrEvent, signatureEvent: NostrEvent,
commitHash: string 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 // Check event kind
if (signatureEvent.kind !== KIND.COMMIT_SIGNATURE) { if (signatureEvent.kind !== KIND.COMMIT_SIGNATURE) {
return { valid: false, error: `Invalid event kind for commit signature. Expected ${KIND.COMMIT_SIGNATURE}, got ${signatureEvent.kind}` }; 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' }; return { valid: false, error: 'Commit hash mismatch' };
} }
// Verify event signature (would need to import verifyEvent from nostr-tools) // Verify event signature cryptographically
// For now, we'll just check the structure if (!verifyEvent(signatureEvent)) {
if (!signatureEvent.sig || !signatureEvent.id) { return { valid: false, error: 'Invalid event signature - event may be forged or corrupted' };
return { valid: false, error: 'Missing signature or event ID' };
} }
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<any[]> }, // 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
};
}

60
src/lib/styles/repo.css

@ -2086,6 +2086,66 @@ span.clone-more {
color: var(--text-muted); 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, .issue-status,
.pr-status, .pr-status,
.patch-status { .patch-status {

56
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 }
);

113
src/routes/repos/[npub]/[repo]/+page.svelte

@ -391,11 +391,29 @@
let defaultBranchName = $state('master'); // Default branch from settings let defaultBranchName = $state('master'); // Default branch from settings
// Commit history // Commit history
let commits = $state<Array<{ hash: string; message: string; author: string; date: string; files: string[] }>>([]); let commits = $state<Array<{
hash: string;
message: string;
author: string;
date: string;
files: string[];
verification?: {
valid: boolean;
hasSignature?: boolean;
error?: string;
pubkey?: string;
npub?: string;
authorName?: string;
authorEmail?: string;
timestamp?: number;
eventId?: string;
};
}>>([]);
let loadingCommits = $state(false); let loadingCommits = $state(false);
let selectedCommit = $state<string | null>(null); let selectedCommit = $state<string | null>(null);
let showDiff = $state(false); let showDiff = $state(false);
let diffData = $state<Array<{ file: string; additions: number; deletions: number; diff: string }>>([]); let diffData = $state<Array<{ file: string; additions: number; deletions: number; diff: string }>>([]);
let verifyingCommits = $state<Set<string>>(new Set());
// Tags // Tags
let tags = $state<Array<{ name: string; hash: string; message?: string; date?: number }>>([]); let tags = $state<Array<{ name: string; hash: string; message?: string; date?: number }>>([]);
@ -4452,6 +4470,15 @@
date: commit.date || new Date().toISOString(), date: commit.date || new Date().toISOString(),
files: commit.files || [] files: commit.files || []
})).filter((commit: any) => commit.hash); // Filter out commits without hash })).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) { } catch (err) {
error = err instanceof Error ? err.message : 'Failed to load commit history'; 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) { async function viewDiff(commitHash: string) {
// Set selected commit immediately so it shows in the right panel // Set selected commit immediately so it shows in the right panel
selectedCommit = commitHash; selectedCommit = commitHash;
@ -5894,10 +5948,65 @@
<ul class="commit-list"> <ul class="commit-list">
{#each commits as commit} {#each commits as commit}
{@const commitHash = commit.hash || (commit as any).sha || ''} {@const commitHash = commit.hash || (commit as any).sha || ''}
{@const verification = commit.verification}
{@const isVerifying = verifyingCommits.has(commitHash)}
{#if commitHash} {#if commitHash}
<li class="commit-item" class:selected={selectedCommit === commitHash}> <li class="commit-item" class:selected={selectedCommit === commitHash}>
<button onclick={() => viewDiff(commitHash)} class="commit-button"> <button onclick={() => viewDiff(commitHash)} class="commit-button">
<div class="commit-hash">{commitHash.slice(0, 7)}</div> <div class="commit-header-row">
<div class="commit-hash">{commitHash.slice(0, 7)}</div>
{#if verification?.hasSignature}
{#if verification.valid}
<div class="commit-verification-group">
<span
class="commit-verified-badge"
role="button"
tabindex="0"
title="Verified by {verification.npub || verification.pubkey?.slice(0, 16) || 'Nostr'}"
onmouseenter={(e) => {
if (verification) {
const tooltip = document.createElement('div');
tooltip.className = 'commit-verification-tooltip';
tooltip.innerHTML = `
<div><strong>Verified by:</strong> ${verification.npub || verification.pubkey || 'Unknown'}</div>
${verification.authorName ? `<div><strong>Author:</strong> ${verification.authorName}${verification.authorEmail ? ` &lt;${verification.authorEmail}&gt;` : ''}</div>` : ''}
${verification.timestamp ? `<div><strong>Signed:</strong> ${new Date(verification.timestamp * 1000).toLocaleString()}</div>` : ''}
${verification.eventId ? `<div><strong>Event ID:</strong> ${verification.eventId.slice(0, 16)}...</div>` : ''}
`;
document.body.appendChild(tooltip);
const rect = (e.target as HTMLElement).getBoundingClientRect();
tooltip.style.left = `${rect.right + 10}px`;
tooltip.style.top = `${rect.top}px`;
const removeTooltip = () => {
tooltip.remove();
(e.target as HTMLElement).removeEventListener('mouseleave', removeTooltip);
};
(e.target as HTMLElement).addEventListener('mouseleave', removeTooltip);
}
}}
>
✓ Verified
</span>
{#if verification.pubkey}
<UserBadge pubkey={verification.pubkey} />
{:else if verification.npub}
<UserBadge pubkey={verification.npub} />
{/if}
</div>
{:else}
<span
class="commit-verification-badge invalid"
title="Verification failed: {verification.error || 'Unknown error'}"
>
✗ Invalid
</span>
{/if}
{:else if isVerifying}
<span class="commit-verification-badge verifying" title="Verifying signature...">
⏳ Verifying...
</span>
{/if}
</div>
<div class="commit-message">{commit.message || 'No message'}</div> <div class="commit-message">{commit.message || 'No message'}</div>
<div class="commit-meta"> <div class="commit-meta">
<span>{commit.author || 'Unknown'}</span> <span>{commit.author || 'Unknown'}</span>

Loading…
Cancel
Save