diff --git a/README.md b/README.md index e76310c..b7c36a3 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,6 @@ See [ARCHITECTURE_FAQ.md](./docs/ARCHITECTURE_FAQ.md) for answers to common arch - Signatures embedded in commit messages as trailers - **Web UI**: Uses NIP-07 browser extension (secure, keys never leave browser) - **Git Operations**: Uses NIP-98 HTTP authentication (ephemeral signed events) - - **Server-side**: Optional `NOSTRGIT_SECRET_KEY` environment variable for automated signing - ⚠️ **Security Note**: Never send private keys (nsec) in API requests. Use NIP-07 for web UI or NIP-98 for git operations. ## Nostr Event Kinds Used @@ -124,7 +123,7 @@ These are not part of any NIP but are used by this application: - Creates a bare git repository at `/repos/{npub}/{repo-name}.git` - Fetches the self-transfer event for ownership verification - Creates initial commit with `.nostr-ownership-transfer` file containing the self-transfer event - - Creates `.nostr-verification` file with the announcement event (for backward compatibility) + - Creates `.nostr-announcement` file with the full signed announcement event JSON - If repository has `clone` tags pointing to other remotes, syncs from those remotes 3. **Repository Access**: @@ -352,7 +351,7 @@ See `docs/SECURITY.md` and `docs/SECURITY_IMPLEMENTATION.md` for detailed inform ## Environment Variables -- `NOSTRGIT_SECRET_KEY`: Server's nsec (bech32 or hex) for signing repo announcements and initial commits (optional) +- `NOSTRGIT_SECRET_KEY_CLIENT`: User's nsec (bech32 or hex) for client-side git operations via credential helper (optional) - `GIT_REPO_ROOT`: Path to store git repositories (default: `/repos`) - `GIT_DOMAIN`: Domain for git repositories (default: `localhost:6543`) - `NOSTR_RELAYS`: Comma-separated list of Nostr relays (default: `wss://theforest.nostr1.com`) diff --git a/docs/GIT_CREDENTIAL_HELPER.md b/docs/GIT_CREDENTIAL_HELPER.md new file mode 100644 index 0000000..0faaa8a --- /dev/null +++ b/docs/GIT_CREDENTIAL_HELPER.md @@ -0,0 +1,226 @@ +# Git Credential Helper for GitRepublic + +This guide explains how to use the GitRepublic credential helper to authenticate git operations (clone, fetch, push) using your Nostr private key. + +## Overview + +GitRepublic uses NIP-98 HTTP Authentication for git operations. The credential helper automatically generates NIP-98 authentication tokens using your Nostr private key (nsec). + +## Setup + +### 1. Make the script executable + +```bash +chmod +x scripts/git-credential-nostr.js +``` + +### 2. Set your NOSTRGIT_SECRET_KEY_CLIENT environment variable + +**Important:** +- This is YOUR user private key (for authenticating your git operations) +- Never commit your private key to version control! + +```bash +# Option 1: Export in your shell session +export NOSTRGIT_SECRET_KEY_CLIENT="nsec1..." + +# Option 2: Add to your ~/.bashrc or ~/.zshrc (for persistent setup) +echo 'export NOSTRGIT_SECRET_KEY_CLIENT="nsec1..."' >> ~/.bashrc +source ~/.bashrc + +# Option 3: Use a hex private key (64 characters) +export NOSTRGIT_SECRET_KEY_CLIENT="" + +# Note: The script also supports NOSTR_PRIVATE_KEY and NSEC for backward compatibility +``` + +### 3. Configure git to use the credential helper + +#### Global configuration (for all GitRepublic repositories): + +```bash +git config --global credential.helper '!node /absolute/path/to/gitrepublic-web/scripts/git-credential-nostr.js' +``` + +#### Per-domain configuration (recommended): + +```bash +# Replace your-domain.com with your GitRepublic server domain +git config --global credential.https://your-domain.com.helper '!node /absolute/path/to/gitrepublic-web/scripts/git-credential-nostr.js' +``` + +#### Localhost configuration (for local development): + +If you're running GitRepublic on localhost, configure it like this: + +```bash +# For HTTP (http://localhost:5173) +git config --global credential.http://localhost.helper '!node /absolute/path/to/gitrepublic-web/scripts/git-credential-nostr.js' + +# For HTTPS (https://localhost:5173) - if using SSL locally +git config --global credential.https://localhost.helper '!node /absolute/path/to/gitrepublic-web/scripts/git-credential-nostr.js' + +# For a specific port (e.g., http://localhost:5173) +git config --global credential.http://localhost:5173.helper '!node /absolute/path/to/gitrepublic-web/scripts/git-credential-nostr.js' +``` + +**Note:** Git's credential helper matching is based on the hostname, so `localhost` will match `localhost:5173` automatically. If you need to match a specific port, include it in the configuration. + +#### Per-repository configuration: + +```bash +cd /path/to/your/repo +git config credential.helper '!node /absolute/path/to/gitrepublic-web/scripts/git-credential-nostr.js' +``` + +## Usage + +Once configured, git will automatically use the credential helper for authentication: + +### Clone a private repository + +```bash +# Remote server +git clone https://your-domain.com/npub1abc123.../my-repo.git + +# Localhost (local development) +# The git HTTP backend is at /api/git/ +git clone http://localhost:5173/api/git/npub1abc123.../my-repo.git +``` + +The credential helper will automatically generate a NIP-98 auth token using your NOSTRGIT_SECRET_KEY_CLIENT. + +## Localhost Setup Example + +Here's a complete example for setting up the credential helper with a local GitRepublic instance: + +### 1. Start your local GitRepublic server + +```bash +cd /path/to/gitrepublic-web +npm run dev +# Server runs on http://localhost:5173 +``` + +### 2. Set your NOSTRGIT_SECRET_KEY_CLIENT + +```bash +export NOSTRGIT_SECRET_KEY_CLIENT="nsec1..." +``` + +### 3. Configure git for localhost + +```bash +# Configure for localhost (any port) +git config --global credential.http://localhost.helper '!node /absolute/path/to/gitrepublic-web/scripts/git-credential-nostr.js' + +# Or for a specific port (e.g., 5173) +git config --global credential.http://localhost:5173.helper '!node /absolute/path/to/gitrepublic-web/scripts/git-credential-nostr.js' +``` + +### 4. Clone a repository + +```bash +# Replace npub1abc123... with the actual npub and my-repo with your repo name +git clone http://localhost:5173/api/git/npub1abc123.../my-repo.git +``` + +### 5. Add remote and push + +```bash +cd my-repo + +# If you need to add the remote manually +git remote add origin http://localhost:5173/api/git/npub1abc123.../my-repo.git + +# Make some changes and push +git add . +git commit -m "Initial commit" +git push -u origin main +``` + +**Note:** The git HTTP backend endpoint is `/api/git/`, so the full URL format is: +- `http://localhost:5173/api/git/{npub}/{repo-name}.git` + +### Push changes + +```bash +git push origin main +``` + +The credential helper will generate the appropriate NIP-98 auth token for push operations. + +### Fetch/Pull + +```bash +git fetch origin +git pull origin main +``` + +## How It Works + +1. When git needs credentials, it calls the credential helper with the repository URL +2. The helper reads your `NOSTRGIT_SECRET_KEY_CLIENT` environment variable (with fallbacks for backward compatibility) +3. It creates a NIP-98 authentication event signed with your private key +4. The signed event is base64-encoded and returned as the "password" +5. Git sends this in the `Authorization: Nostr ` header +6. The GitRepublic server verifies the NIP-98 auth event and grants access + +## Troubleshooting + +### Error: NOSTRGIT_SECRET_KEY_CLIENT environment variable is not set + +Make sure you've exported the NOSTRGIT_SECRET_KEY_CLIENT variable: +```bash +export NOSTRGIT_SECRET_KEY_CLIENT="nsec1..." +``` + +**Note:** The script also supports `NOSTR_PRIVATE_KEY` and `NSEC` for backward compatibility, but `NOSTRGIT_SECRET_KEY_CLIENT` is the preferred name. + +### Error: Invalid nsec format + +- Ensure your nsec starts with `nsec1` (bech32 encoded) +- Or use a 64-character hex private key +- Check that the key is not corrupted or truncated + +### Authentication fails + +- Verify your private key matches the public key that has access to the repository +- Check that the repository URL is correct +- Ensure your key has maintainer permissions for push operations + +### Push operations fail + +Push operations require POST authentication. The credential helper automatically detects push operations (when the path contains `git-receive-pack`) and generates a POST auth event. If you still have issues: + +1. Verify you have maintainer permissions for the repository +2. Check that branch protection rules allow your push +3. Ensure your NOSTRGIT_SECRET_KEY_CLIENT is correctly set + +## Security Best Practices + +1. **Never commit your NOSTRGIT_SECRET_KEY_CLIENT to version control** + - Add `NOSTRGIT_SECRET_KEY_CLIENT` to your `.gitignore` if you store it in a file + - Use environment variables instead of hardcoding + - **Important:** This is YOUR user key for client-side operations + +2. **Use per-domain configuration** + - This limits the credential helper to only GitRepublic domains + - Prevents accidental credential leaks to other services + +3. **Protect your private key** + - Use file permissions: `chmod 600 ~/.nostr-key` (if storing in a file) + - Consider using a key management service for production + +4. **Rotate keys if compromised** + - If your NOSTR_PRIVATE_KEY is ever exposed, generate a new key pair + - Update repository maintainer lists with your new public key + +## Alternative: Manual Authentication + +If you prefer not to use the credential helper, you can manually generate NIP-98 auth tokens, but this is not recommended for regular use as it's cumbersome. + +## See Also + +- [NIP-98 Specification](https://github.com/nostr-protocol/nips/blob/master/98.md) +- [Git Credential Helper Documentation](https://git-scm.com/docs/gitcredentials) diff --git a/scripts/git-credential-nostr.js b/scripts/git-credential-nostr.js new file mode 100755 index 0000000..ec9f9c4 --- /dev/null +++ b/scripts/git-credential-nostr.js @@ -0,0 +1,215 @@ +#!/usr/bin/env node +/** + * Git credential helper for GitRepublic using NIP-98 authentication + * + * This script implements the git credential helper protocol to automatically + * generate NIP-98 authentication tokens for git operations. + * + * Usage: + * 1. Make it executable: chmod +x scripts/git-credential-nostr.js + * 2. Configure git: + * git config --global credential.helper '!node /path/to/gitrepublic-web/scripts/git-credential-nostr.js' + * 3. Or for a specific domain: + * git config --global credential.https://your-domain.com.helper '!node /path/to/gitrepublic-web/scripts/git-credential-nostr.js' + * + * Environment variables: + * NOSTRGIT_SECRET_KEY_CLIENT - Your Nostr private key (nsec format or hex) for client-side git operations + * + * Security: Keep your NOSTRGIT_SECRET_KEY_CLIENT secure and never commit it to version control! + */ + +import { createHash } from 'crypto'; +import { getEventHash, signEvent, getPublicKey } from 'nostr-tools'; +import { decode } from 'nostr-tools/nip19'; + +// NIP-98 auth event kind +const KIND_NIP98_AUTH = 27235; + +/** + * Read input from stdin (git credential helper protocol) + */ +function readInput() { + const chunks = []; + process.stdin.setEncoding('utf8'); + + return new Promise((resolve) => { + process.stdin.on('readable', () => { + let chunk; + while ((chunk = process.stdin.read()) !== null) { + chunks.push(chunk); + } + }); + + process.stdin.on('end', () => { + const input = chunks.join(''); + const lines = input.trim().split('\n'); + const data = {}; + + for (const line of lines) { + if (!line) continue; + const [key, ...valueParts] = line.split('='); + if (key && valueParts.length > 0) { + data[key] = valueParts.join('='); + } + } + + resolve(data); + }); + }); +} + +/** + * Normalize URL for NIP-98 (remove trailing slashes, ensure consistent format) + */ +function normalizeUrl(url) { + try { + const parsed = new URL(url); + // Remove trailing slash from pathname + parsed.pathname = parsed.pathname.replace(/\/$/, ''); + return parsed.toString(); + } catch { + return url; + } +} + +/** + * Calculate SHA256 hash of request body + */ +function calculateBodyHash(body) { + if (!body) return null; + const buffer = Buffer.from(body, 'utf-8'); + return createHash('sha256').update(buffer).digest('hex'); +} + +/** + * Create and sign a NIP-98 authentication event + */ +function createNIP98AuthEvent(privateKey, url, method, bodyHash = null) { + const pubkey = getPublicKey(privateKey); + const tags = [ + ['u', normalizeUrl(url)], + ['method', method.toUpperCase()] + ]; + + if (bodyHash) { + tags.push(['payload', bodyHash]); + } + + const event = { + kind: KIND_NIP98_AUTH, + pubkey, + created_at: Math.floor(Date.now() / 1000), + content: '', + tags + }; + + // Sign the event + event.id = getEventHash(event); + event.sig = signEvent(event, privateKey); + + return event; +} + +/** + * Main credential helper logic + */ +async function main() { + try { + // Read input from git + const input = await readInput(); + + // Get command (get, store, erase) + const command = process.argv[2] || 'get'; + + // For 'get' command, generate credentials + if (command === 'get') { + // Get private key from environment variable + // Support NOSTRGIT_SECRET_KEY_CLIENT (preferred), with fallbacks for backward compatibility + const nsec = process.env.NOSTRGIT_SECRET_KEY_CLIENT || process.env.NOSTR_PRIVATE_KEY || process.env.NSEC; + if (!nsec) { + console.error('Error: NOSTRGIT_SECRET_KEY_CLIENT environment variable is not set'); + console.error('Set it with: export NOSTRGIT_SECRET_KEY_CLIENT="nsec1..." or NOSTRGIT_SECRET_KEY_CLIENT=""'); + process.exit(1); + } + + // Parse private key (handle both nsec and hex formats) + let privateKey; + if (nsec.startsWith('nsec')) { + try { + const decoded = decode(nsec); + if (decoded.type === 'nsec') { + privateKey = decoded.data; + } else { + throw new Error('Invalid nsec format - decoded type is not nsec'); + } + } catch (err) { + console.error('Error decoding nsec:', err.message); + process.exit(1); + } + } else { + // Assume hex format (32 bytes = 64 hex characters) + if (nsec.length !== 64) { + console.error('Error: Hex private key must be 64 characters (32 bytes)'); + process.exit(1); + } + privateKey = nsec; + } + + // Extract URL components from input + const protocol = input.protocol || 'https'; + const host = input.host; + const path = input.path || ''; + + if (!host) { + console.error('Error: No host specified in credential request'); + process.exit(1); + } + + // Build full URL + const url = `${protocol}://${host}${path}`; + + // Determine HTTP method based on git operation + // Git credential helper doesn't know the HTTP method, but we can infer it: + // - If path contains 'git-receive-pack', it's a push (POST) + // - Otherwise, it's likely a fetch/clone (GET) + // Note: For initial info/refs requests, git uses GET, so we default to GET + // For actual push operations, git will make POST requests to git-receive-pack + // The server will validate the method matches, so we need to handle this carefully + const method = path.includes('git-receive-pack') ? 'POST' : 'GET'; + + // Create and sign NIP-98 auth event + const authEvent = createNIP98AuthEvent(privateKey, url, method); + + // Encode event as base64 + const eventJson = JSON.stringify(authEvent); + const base64Event = Buffer.from(eventJson, 'utf-8').toString('base64'); + + // Output credentials in git credential helper format + // Username can be anything (git doesn't use it for NIP-98) + // Password is the base64-encoded signed event + console.log('username=nostr'); + console.log(`password=${base64Event}`); + + } else if (command === 'store') { + // For 'store', we don't need to do anything (credentials are generated on-demand) + // Just exit successfully + process.exit(0); + } else if (command === 'erase') { + // For 'erase', we don't need to do anything + // Just exit successfully + process.exit(0); + } else { + console.error(`Error: Unknown command: ${command}`); + process.exit(1); + } + } catch (error) { + console.error('Error:', error.message); + process.exit(1); + } +} + +// Run main function +main().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/src/lib/services/git/commit-signer.ts b/src/lib/services/git/commit-signer.ts index ae642cd..d117c2b 100644 --- a/src/lib/services/git/commit-signer.ts +++ b/src/lib/services/git/commit-signer.ts @@ -140,7 +140,8 @@ export function createCommitSignatureEvent( * - nsec/hex: Direct key signing (server-side ONLY, via environment variables) * * ⚠️ SECURITY WARNING: nsecKey should NEVER be sent from client requests. - * It should only be used server-side via environment variables (e.g., NOSTRGIT_SECRET_KEY). + * It should only be used server-side via environment variables for automated operations. + * Note: The server should NOT sign commits on behalf of users - commits should be signed by their authors. * * @param commitMessage - The commit message to sign * @param authorName - Author name diff --git a/src/lib/services/git/file-manager.ts b/src/lib/services/git/file-manager.ts index 660dd64..5d31f2f 100644 --- a/src/lib/services/git/file-manager.ts +++ b/src/lib/services/git/file-manager.ts @@ -1371,4 +1371,69 @@ export class FileManager { return []; } } + + /** + * Get the current owner from the most recent announcement file in the repository + * Ownership is determined by the most recent announcement file checked into the git repo + * + * @param npub - Repository owner npub (for path construction) + * @param repoName - The repository name + * @returns The current owner pubkey from the most recent announcement file, or null if not found + */ + async getCurrentOwnerFromRepo(npub: string, repoName: string): Promise { + try { + const { VERIFICATION_FILE_PATH } = await import('../nostr/repo-verification.js'); + + if (!this.repoExists(npub, repoName)) { + return null; + } + + const repoPath = this.getRepoPath(npub, repoName); + const git: SimpleGit = simpleGit(repoPath); + + // Get git log for the announcement file, most recent first + // Use --all to check all branches, --reverse to get chronological order + const logOutput = await git.raw(['log', '--all', '--format=%H', '--reverse', '--', VERIFICATION_FILE_PATH]); + const commitHashes = logOutput.trim().split('\n').filter(Boolean); + + if (commitHashes.length === 0) { + return null; // No announcement file in repo + } + + // Get the most recent announcement file content (last commit in the list) + const mostRecentCommit = commitHashes[commitHashes.length - 1]; + const announcementFile = await this.getFileContent(npub, repoName, VERIFICATION_FILE_PATH, mostRecentCommit); + + // Parse the announcement event from the file + let announcementEvent: any; + try { + announcementEvent = JSON.parse(announcementFile.content); + } catch (parseError) { + logger.warn({ error: parseError, npub, repoName, commit: mostRecentCommit }, 'Failed to parse announcement file JSON'); + return null; + } + + // Validate the announcement event to prevent fake announcements + const { validateAnnouncementEvent } = await import('../nostr/repo-verification.js'); + const validation = validateAnnouncementEvent(announcementEvent, repoName); + + if (!validation.valid) { + logger.warn({ + error: validation.error, + npub, + repoName, + commit: mostRecentCommit, + eventId: announcementEvent.id, + eventPubkey: announcementEvent.pubkey?.substring(0, 16) + '...' + }, 'Announcement file validation failed - possible fake announcement'); + return null; + } + + // Return the pubkey from the validated announcement + return announcementEvent.pubkey; + } catch (error) { + logger.error({ error, npub, repoName }, 'Error getting current owner from repo'); + return null; + } + } } diff --git a/src/lib/services/git/repo-manager.ts b/src/lib/services/git/repo-manager.ts index 4d602c7..003a28b 100644 --- a/src/lib/services/git/repo-manager.ts +++ b/src/lib/services/git/repo-manager.ts @@ -144,7 +144,7 @@ export class RepoManager { const git = simpleGit(); await git.init(['--bare', repoPath.fullPath]); - // Create verification file and self-transfer event in the repository + // Create announcement file and self-transfer event in the repository await this.createVerificationFile(repoPath.fullPath, event, selfTransferEvent); // If there are other clone URLs, sync from them after creating the repo @@ -154,7 +154,7 @@ export class RepoManager { } else if (isExistingRepo && selfTransferEvent) { // For existing repos, we might want to add the self-transfer event // But we should be careful not to overwrite existing history - // For now, we'll just ensure the verification file exists + // For now, we'll just ensure the announcement file exists // The self-transfer event should already be published to relays logger.info({ repoPath: repoPath.fullPath }, 'Existing repo - self-transfer event should be published to relays'); } @@ -660,12 +660,12 @@ export class RepoManager { throw new Error('Repository clone completed but repository path does not exist'); } - // Create verification file with the announcement (non-blocking - repo is usable without it) + // Create announcement file with the signed announcement event (non-blocking - repo is usable without it) try { await this.createVerificationFile(repoPath, announcementEvent); } catch (verifyError) { - // Verification file creation is optional - log but don't fail - logger.warn({ error: verifyError, npub, repoName }, 'Failed to create verification file, but repository is usable'); + // Announcement file creation is optional - log but don't fail + logger.warn({ error: verifyError, npub, repoName }, 'Failed to create announcement file, but repository is usable'); } logger.info({ npub, repoName }, 'Successfully fetched repository on-demand'); @@ -749,8 +749,8 @@ export class RepoManager { } /** - * Create verification file and self-transfer event in a new repository - * This proves the repository is owned by the announcement author + * Create announcement file and self-transfer event in a new repository + * The announcement file contains the full signed announcement event JSON, proving ownership */ private async createVerificationFile(repoPath: string, event: NostrEvent, selfTransferEvent?: NostrEvent): Promise { try { @@ -769,12 +769,43 @@ export class RepoManager { const git: SimpleGit = simpleGit(); await git.clone(repoPath, workDir); - // Generate verification file content - const verificationContent = generateVerificationFile(event, event.pubkey); + // Extract announcement file content from client-signed event (kind 1642) + // The client creates and signs this event separately, with an 'e' tag pointing to the announcement + // The content is just the full announcement event JSON - simpler than a custom verification format + let announcementFileContent: string | null = null; + + try { + const { NostrClient } = await import('../nostr/nostr-client.js'); + const { DEFAULT_NOSTR_RELAYS } = await import('../../config.js'); + const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); + + // Look for a kind 1642 event that references this announcement + const announcementFileEvents = await nostrClient.fetchEvents([ + { + kinds: [1642], // Announcement file event kind + authors: [event.pubkey], + '#e': [event.id], // References this announcement + limit: 1 + } + ]); + + if (announcementFileEvents.length > 0) { + // Extract announcement file content from the client-signed event + announcementFileContent = announcementFileEvents[0].content; + logger.info({ repoPath, announcementFileEventId: announcementFileEvents[0].id }, 'Using client-signed announcement file'); + } + } catch (err) { + logger.warn({ error: err, repoPath }, 'Failed to fetch announcement file event, generating server-side'); + } + + // If client didn't provide announcement file, generate it from the announcement event + if (!announcementFileContent) { + announcementFileContent = generateVerificationFile(event, event.pubkey); + } - // Write verification file - const verificationPath = join(workDir, VERIFICATION_FILE_PATH); - writeFileSync(verificationPath, verificationContent, 'utf-8'); + // Write announcement file (contains the full signed announcement event JSON) + const announcementPath = join(workDir, VERIFICATION_FILE_PATH); + writeFileSync(announcementPath, announcementFileContent, 'utf-8'); // If self-transfer event is provided, include it in the commit const filesToAdd = [VERIFICATION_FILE_PATH]; @@ -805,31 +836,12 @@ export class RepoManager { // Use the event timestamp for commit date const commitDate = new Date(event.created_at * 1000).toISOString(); - let commitMessage = selfTransferEvent - ? 'Add Nostr repository verification and initial ownership proof' - : 'Add Nostr repository verification file'; + const commitMessage = selfTransferEvent + ? 'Add Nostr repository announcement and initial ownership proof' + : 'Add Nostr repository announcement'; - // Sign commit if nsec key is provided (from environment or event) - // Note: For initial commits, we might not have the user's nsec, so this is optional - const nsecKey = process.env.NOSTRGIT_SECRET_KEY; - if (nsecKey) { - try { - const { createGitCommitSignature } = await import('./commit-signer.js'); - const { signedMessage } = await createGitCommitSignature( - commitMessage, - 'Nostr', - `${event.pubkey}@nostr`, - { - nsecKey, - timestamp: event.created_at - } - ); - commitMessage = signedMessage; - } catch (err) { - logger.warn({ error: err, repoPath }, 'Failed to sign initial commit'); - // Continue without signature if signing fails - } - } + // Note: Initial commits are unsigned. The repository owner can sign their own commits + // when they make changes. The server should never sign commits on behalf of users. await workGit.commit(commitMessage, filesToAdd, { '--author': `Nostr <${event.pubkey}@nostr>`, @@ -846,8 +858,8 @@ export class RepoManager { // Clean up await rm(workDir, { recursive: true, force: true }); } catch (error) { - logger.error({ error, repoPath }, 'Failed to create verification file'); - // Don't throw - verification file creation is important but shouldn't block provisioning + logger.error({ error, repoPath }, 'Failed to create announcement file'); + // Don't throw - announcement file creation is important but shouldn't block provisioning } } @@ -861,7 +873,7 @@ export class RepoManager { } /** - * Check if a repository already has a verification file + * Check if a repository already has an announcement file * Used to determine if this is a truly new repo or an existing one being added */ async hasVerificationFile(repoPath: string): Promise { diff --git a/src/lib/services/nostr/maintainer-service.ts b/src/lib/services/nostr/maintainer-service.ts index 3c4692e..4b60081 100644 --- a/src/lib/services/nostr/maintainer-service.ts +++ b/src/lib/services/nostr/maintainer-service.ts @@ -78,11 +78,12 @@ export class MaintainerService { // Check if repo is private const isPrivate = this.isPrivateRepo(announcement); - // Check for ownership transfers - get current owner - const currentOwner = await this.ownershipTransferService.getCurrentOwner( - announcement.pubkey, - repoId - ); + // Get current owner from the most recent announcement file in the repo + // Ownership is determined by what's checked into the git repository, not Nostr events + const { nip19 } = await import('nostr-tools'); + const npub = nip19.npubEncode(announcement.pubkey); + const { fileManager } = await import('../../services/service-registry.js'); + const currentOwner = await fileManager.getCurrentOwnerFromRepo(npub, repoId) || announcement.pubkey; const maintainers: string[] = [currentOwner]; // Current owner is always a maintainer diff --git a/src/lib/services/nostr/ownership-transfer-service.ts b/src/lib/services/nostr/ownership-transfer-service.ts index 4167c16..8efcf81 100644 --- a/src/lib/services/nostr/ownership-transfer-service.ts +++ b/src/lib/services/nostr/ownership-transfer-service.ts @@ -30,10 +30,79 @@ export class OwnershipTransferService { this.nostrClient = new NostrClient(relays); } + /** + * Get the current owner of a repository from the most recent announcement file in the git repo + * Ownership is determined by the most recent announcement file checked into the repository + * + * @param npub - Repository owner npub (for path construction) + * @param repoId - The repository identifier (d-tag) + * @returns The current owner pubkey from the most recent announcement file in the repo + */ + async getCurrentOwnerFromRepo(npub: string, repoId: string): Promise { + try { + const { fileManager } = await import('../services/service-registry.js'); + return await fileManager.getCurrentOwnerFromRepo(npub, repoId); + } catch (error) { + logger.error({ error, npub, repoId }, 'Error getting current owner from repo'); + return null; + } + } + + /** + * Get owners for all clone URLs from the repository announcement + * Each clone can have its own owner determined by the most recent announcement file in that clone + * + * @param announcementEvent - The repository announcement event + * @returns Map of clone URL to owner pubkey (or null if clone doesn't exist or has no announcement file) + */ + async getOwnersForAllClones(announcementEvent: NostrEvent): Promise> { + const owners = new Map(); + + // Extract clone URLs from announcement + const cloneUrls: string[] = []; + for (const tag of announcementEvent.tags) { + if (tag[0] === 'clone') { + for (let i = 1; i < tag.length; i++) { + const url = tag[i]; + if (url && typeof url === 'string') { + cloneUrls.push(url); + } + } + } + } + + // Get the repo identifier from the announcement + const dTag = announcementEvent.tags.find(t => t[0] === 'd')?.[1]; + if (!dTag) { + return owners; // Can't determine repo name + } + + // Check the local GitRepublic clone (if it exists) + try { + const { nip19 } = await import('nostr-tools'); + const npub = nip19.npubEncode(announcementEvent.pubkey); + const { fileManager } = await import('../services/service-registry.js'); + + const localOwner = await fileManager.getCurrentOwnerFromRepo(npub, dTag); + const localUrl = cloneUrls.find(url => url.includes(npub) || url.includes(announcementEvent.pubkey)); + if (localUrl) { + owners.set(localUrl, localOwner); + } + } catch (error) { + logger.warn({ error, announcementEvent: announcementEvent.id }, 'Failed to get owner from local clone'); + } + + // For other clones (GitHub, GitLab, etc.), we'd need to fetch them first to check their announcement files + // This is a future enhancement - for now we only check the local GitRepublic clone + + return owners; + } + /** * Get the current owner of a repository, checking for ownership transfers * The initial ownership is proven by a self-transfer event (from owner to themselves) * + * @deprecated Use getCurrentOwnerFromRepo instead - ownership is now determined by the most recent announcement file in the repo * @param originalOwnerPubkey - The original owner from the repo announcement * @param repoId - The repository identifier (d-tag) * @returns The current owner pubkey (may be different from original if transferred) diff --git a/src/lib/services/nostr/repo-verification.ts b/src/lib/services/nostr/repo-verification.ts index 15b21a1..5ad12ee 100644 --- a/src/lib/services/nostr/repo-verification.ts +++ b/src/lib/services/nostr/repo-verification.ts @@ -3,80 +3,178 @@ * Creates and verifies cryptographic proof linking repo announcements to git repos */ -import { verifyEvent } from 'nostr-tools'; +import { verifyEvent, getEventHash } from 'nostr-tools'; +import { KIND } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js'; -import { nip19 } from 'nostr-tools'; - -export interface VerificationFile { - eventId: string; - pubkey: string; - npub: string; - signature: string; - timestamp: number; -} /** - * Generate a verification file content for a repository + * Generate announcement event file content for a repository * This file should be committed to the repository to prove ownership + * We just save the full announcement event JSON - simpler and more complete than a custom format */ export function generateVerificationFile( announcementEvent: NostrEvent, ownerPubkey: string ): string { - const npub = nip19.npubEncode(ownerPubkey); - - const verification: VerificationFile = { - eventId: announcementEvent.id, - pubkey: ownerPubkey, - npub: npub, - signature: announcementEvent.sig, - timestamp: announcementEvent.created_at - }; - - // Create a JSON file with clear formatting - return JSON.stringify(verification, null, 2) + '\n'; + // Just return the full announcement event JSON - it's already signed and contains all needed info + return JSON.stringify(announcementEvent, null, 2) + '\n'; } /** - * Verify that a repository announcement matches the verification file in the repo + * Validate that an event is a legitimate repository announcement + * Checks signature, kind, structure, and d-tag + */ +export function validateAnnouncementEvent( + event: NostrEvent, + expectedRepoName?: string +): { valid: boolean; error?: string } { + // Verify it's a valid Nostr event structure + if (!event.kind || !event.id || !event.sig || !event.pubkey || !event.created_at || !Array.isArray(event.tags)) { + return { + valid: false, + error: 'Invalid event structure: missing required fields' + }; + } + + // Verify it's actually a repository announcement (kind 30617) + if (event.kind !== KIND.REPO_ANNOUNCEMENT) { + return { + valid: false, + error: `Invalid event kind: expected ${KIND.REPO_ANNOUNCEMENT}, got ${event.kind}` + }; + } + + // Verify the event signature cryptographically + if (!verifyEvent(event)) { + return { + valid: false, + error: 'Event signature is invalid - event may be forged or corrupted' + }; + } + + // Verify the event ID matches the computed ID (prevents ID tampering) + const computedId = getEventHash(event); + if (computedId !== event.id) { + return { + valid: false, + error: 'Event ID does not match computed hash - event may be tampered with' + }; + } + + // Verify d-tag exists (required for repository announcements) + const dTag = event.tags.find(t => t[0] === 'd'); + if (!dTag || !dTag[1]) { + return { + valid: false, + error: 'Missing d-tag (repository identifier) in announcement event' + }; + } + + // If expected repo name is provided, verify it matches + if (expectedRepoName && dTag[1] !== expectedRepoName) { + return { + valid: false, + error: `Repository name mismatch: expected '${expectedRepoName}', got '${dTag[1]}'` + }; + } + + // Verify pubkey is valid hex format (64 characters) + if (!/^[0-9a-f]{64}$/i.test(event.pubkey)) { + return { + valid: false, + error: 'Invalid pubkey format' + }; + } + + // Verify signature is valid hex format (128 characters) + if (!/^[0-9a-f]{128}$/i.test(event.sig)) { + return { + valid: false, + error: 'Invalid signature format' + }; + } + + // Verify created_at is reasonable (not in the future, not too old) + const now = Math.floor(Date.now() / 1000); + const eventTime = event.created_at; + if (eventTime > now + 60) { + return { + valid: false, + error: 'Event timestamp is in the future' + }; + } + // Allow events up to 10 years old (reasonable for repository announcements) + if (eventTime < now - (10 * 365 * 24 * 60 * 60)) { + return { + valid: false, + error: 'Event timestamp is too old (more than 10 years)' + }; + } + + return { valid: true }; +} + +/** + * Verify that a repository announcement matches the file in the repo + * The file should contain the full announcement event JSON */ export function verifyRepositoryOwnership( announcementEvent: NostrEvent, - verificationFileContent: string + announcementFileContent: string ): { valid: boolean; error?: string } { try { - // Parse verification file - const verification: VerificationFile = JSON.parse(verificationFileContent); + // Parse the announcement event from the file + const fileEvent: NostrEvent = JSON.parse(announcementFileContent); + + // First, validate the file event is a legitimate announcement + const fileValidation = validateAnnouncementEvent(fileEvent); + if (!fileValidation.valid) { + return { + valid: false, + error: `File event validation failed: ${fileValidation.error}` + }; + } + // Validate the provided announcement event as well + const announcementValidation = validateAnnouncementEvent(announcementEvent); + if (!announcementValidation.valid) { + return { + valid: false, + error: `Provided announcement validation failed: ${announcementValidation.error}` + }; + } + // Check that the event ID matches - if (verification.eventId !== announcementEvent.id) { + if (fileEvent.id !== announcementEvent.id) { return { valid: false, - error: 'Verification file event ID does not match announcement' + error: 'Announcement event ID does not match' }; } - + // Check that the pubkey matches - if (verification.pubkey !== announcementEvent.pubkey) { + if (fileEvent.pubkey !== announcementEvent.pubkey) { return { valid: false, - error: 'Verification file pubkey does not match announcement author' + error: 'Announcement event pubkey does not match' }; } - - // Verify the announcement event signature - if (!verifyEvent(announcementEvent)) { + + // Check that the signature matches + if (fileEvent.sig !== announcementEvent.sig) { return { valid: false, - error: 'Announcement event signature is invalid' + error: 'Announcement event signature does not match' }; } - // Verify the signature in the verification file matches the announcement - if (verification.signature !== announcementEvent.sig) { + // Check that the d-tag (repo name) matches + const fileDTag = fileEvent.tags.find(t => t[0] === 'd')?.[1]; + const announcementDTag = announcementEvent.tags.find(t => t[0] === 'd')?.[1]; + if (fileDTag !== announcementDTag) { return { valid: false, - error: 'Verification file signature does not match announcement' + error: 'Repository name (d-tag) does not match' }; } @@ -84,12 +182,12 @@ export function verifyRepositoryOwnership( } catch (error) { return { valid: false, - error: `Failed to parse verification file: ${error instanceof Error ? error.message : String(error)}` + error: `Failed to parse announcement file: ${error instanceof Error ? error.message : String(error)}` }; } } /** - * Get the path where the verification file should be stored + * Get the path where the announcement event file should be stored */ -export const VERIFICATION_FILE_PATH = '.nostr-verification'; +export const VERIFICATION_FILE_PATH = '.nostr-announcement'; diff --git a/src/routes/api/repos/[npub]/[repo]/file/+server.ts b/src/routes/api/repos/[npub]/[repo]/file/+server.ts index 415347a..23efb76 100644 --- a/src/routes/api/repos/[npub]/[repo]/file/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/file/+server.ts @@ -378,7 +378,7 @@ export const POST: RequestHandler = async ({ params, url, request }: { params: { } // Note: useNIP07 is no longer used since signing happens client-side // Explicitly ignore nsecKey from client requests - it's a security risk - // Server-side signing should use NOSTRGIT_SECRET_KEY environment variable instead + // Server-side signing is not recommended - commits should be signed by their authors if (nsecKey) { // Security: Log warning but never log the actual key value const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'; diff --git a/src/routes/api/repos/[npub]/[repo]/verify/+server.ts b/src/routes/api/repos/[npub]/[repo]/verify/+server.ts index 1aa12ad..8da348d 100644 --- a/src/routes/api/repos/[npub]/[repo]/verify/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/verify/+server.ts @@ -45,82 +45,100 @@ export const GET: RequestHandler = createRepoGetHandler( const announcement = events[0]; - // Check for ownership transfer events (including self-transfer for initial ownership) - const repoTag = `${KIND.REPO_ANNOUNCEMENT}:${context.repoOwnerPubkey}:${context.repo}`; - const transferEvents = await nostrClient.fetchEvents([ - { - kinds: [KIND.OWNERSHIP_TRANSFER], - '#a': [repoTag], - limit: 100 + // Extract clone URLs from announcement + const cloneUrls: string[] = []; + for (const tag of announcement.tags) { + if (tag[0] === 'clone') { + for (let i = 1; i < tag.length; i++) { + const url = tag[i]; + if (url && typeof url === 'string') { + cloneUrls.push(url); + } + } } - ]); + } - // Look for self-transfer event (initial ownership proof) - // Self-transfer: from owner to themselves, tagged with 'self-transfer' - const selfTransfer = transferEvents.find(event => { - const pTag = event.tags.find(t => t[0] === 'p'); - let toPubkey = pTag?.[1]; + // Verify ownership for each clone separately + // Ownership is determined by the most recent announcement file checked into each clone + const cloneVerifications: Array<{ url: string; verified: boolean; ownerPubkey: string | null; error?: string }> = []; + + // First, verify the local GitRepublic clone (if it exists) + let localVerified = false; + let localOwner: string | null = null; + let localError: string | undefined; + + try { + // Get current owner from the most recent announcement file in the repo + localOwner = await fileManager.getCurrentOwnerFromRepo(context.npub, context.repo); - // Decode npub if needed - if (toPubkey) { + if (localOwner) { + // Verify the announcement file matches the announcement event try { - toPubkey = decodeNpubToHex(toPubkey) || toPubkey; - } catch { - // Assume it's already hex + const announcementFile = await fileManager.getFileContent(context.npub, context.repo, VERIFICATION_FILE_PATH, 'HEAD'); + const verification = verifyRepositoryOwnership(announcement, announcementFile.content); + localVerified = verification.valid; + if (!verification.valid) { + localError = verification.error; + } + } catch (err) { + localVerified = false; + localError = 'Announcement file not found in repository'; } - } - - return event.pubkey === context.repoOwnerPubkey && - toPubkey === context.repoOwnerPubkey; - }); - - // Verify ownership - prefer self-transfer event, fall back to verification file - let verified = false; - let verificationMethod = ''; - let verificationError: string | undefined; - - if (selfTransfer) { - // Verify self-transfer event signature - const { verifyEvent } = await import('nostr-tools'); - if (verifyEvent(selfTransfer)) { - verified = true; - verificationMethod = 'self-transfer-event'; } else { - verified = false; - verificationError = 'Self-transfer event signature is invalid'; - verificationMethod = 'self-transfer-event'; - } - } else { - // Fall back to verification file method (for backward compatibility) - try { - const verificationFile = await fileManager.getFileContent(context.npub, context.repo, VERIFICATION_FILE_PATH, 'HEAD'); - const verification = verifyRepositoryOwnership(announcement, verificationFile.content); - verified = verification.valid; - verificationError = verification.error; - verificationMethod = 'verification-file'; - } catch (err) { - verified = false; - verificationError = 'No ownership proof found (neither self-transfer event nor verification file)'; - verificationMethod = 'none'; + localVerified = false; + localError = 'No announcement file found in repository'; } + } catch (err) { + localVerified = false; + localError = err instanceof Error ? err.message : 'Failed to verify local clone'; + } + + // Add local clone verification + const localUrl = cloneUrls.find(url => url.includes(context.npub) || url.includes(context.repoOwnerPubkey)); + if (localUrl) { + cloneVerifications.push({ + url: localUrl, + verified: localVerified, + ownerPubkey: localOwner, + error: localError + }); } + + // For other clones (GitHub, GitLab, etc.), we'd need to fetch them first to check their announcement files + // This is a future enhancement - for now we only verify the local GitRepublic clone + + // Overall verification: at least one clone must be verified + const overallVerified = cloneVerifications.some(cv => cv.verified); + const verifiedClones = cloneVerifications.filter(cv => cv.verified); + const currentOwner = localOwner || context.repoOwnerPubkey; - if (verified) { + if (overallVerified) { return json({ verified: true, announcementId: announcement.id, - ownerPubkey: context.repoOwnerPubkey, - verificationMethod, - selfTransferEventId: selfTransfer?.id, - message: 'Repository ownership verified successfully' + ownerPubkey: currentOwner, + verificationMethod: 'announcement-file', + cloneVerifications: cloneVerifications.map(cv => ({ + url: cv.url, + verified: cv.verified, + ownerPubkey: cv.ownerPubkey, + error: cv.error + })), + message: `Repository ownership verified successfully for ${verifiedClones.length} clone(s)` }); } else { return json({ verified: false, - error: verificationError || 'Repository ownership verification failed', + error: localError || 'Repository ownership verification failed', announcementId: announcement.id, - verificationMethod, - message: 'Repository ownership verification failed' + verificationMethod: 'announcement-file', + cloneVerifications: cloneVerifications.map(cv => ({ + url: cv.url, + verified: cv.verified, + ownerPubkey: cv.ownerPubkey, + error: cv.error + })), + message: 'Repository ownership verification failed for all clones' }); } }, diff --git a/src/routes/signup/+page.svelte b/src/routes/signup/+page.svelte index dda4965..811e4e9 100644 --- a/src/routes/signup/+page.svelte +++ b/src/routes/signup/+page.svelte @@ -1365,6 +1365,8 @@ eventTags.push(['client', 'gitrepublic-web']); } + // We'll generate the announcement file content after signing (it's just the full event JSON) + // Build event const eventTemplate: Omit = { kind: KIND.REPO_ANNOUNCEMENT, @@ -1379,6 +1381,26 @@ const signedEvent = await signEventWithNIP07(eventTemplate); console.log('Event signed successfully, event ID:', signedEvent.id); + // Generate announcement file content (just the full signed event JSON) + // The server will commit this to prove ownership - no need for a separate verification file event + const { generateVerificationFile } = await import('../../lib/services/nostr/repo-verification.js'); + const announcementFileContent = generateVerificationFile(signedEvent, pubkey); + + // Create a signed event (kind 1642) with the announcement file content + // This allows the server to fetch and commit the client-signed announcement + const announcementFileEventTemplate: Omit = { + kind: 1642, // Custom kind for announcement file + pubkey, + created_at: Math.floor(Date.now() / 1000), + content: announcementFileContent, + tags: [ + ['e', signedEvent.id, '', 'announcement'], + ['d', dTag] + ] + }; + + const signedAnnouncementFileEvent = await signEventWithNIP07(announcementFileEventTemplate); + // Get user's inbox/outbox relays (from kind 10002) using comprehensive relay set console.log('Fetching user relays from comprehensive relay set...'); const { getUserRelays } = await import('../../lib/services/nostr/user-relays.js'); @@ -1427,6 +1449,13 @@ console.log('Using relays for publishing:', userRelays); + // Publish announcement file event first (so it's available when server provisions) + console.log('Publishing announcement file event...'); + await publishWithRetry(nostrClient, signedAnnouncementFileEvent, userRelays, 2).catch(err => { + console.warn('Failed to publish announcement file event:', err); + // Continue anyway - server can generate it as fallback + }); + // Publish repository announcement with retry logic let publishResult = await publishWithRetry(nostrClient, signedEvent, userRelays, 2); console.log('Publish result:', publishResult);