From b6983321b3b2028da9b0479b42f44b43774d2a36 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 19 Feb 2026 00:28:13 +0100 Subject: [PATCH] fix git credentials --- README.md | 47 +++- docs/GIT_CREDENTIAL_HELPER.md | 61 ++++- scripts/git-credential-nostr.js | 245 ++++++++++++++++--- src/lib/services/nostr/nip98-auth.ts | 3 +- src/routes/api/git/[...path]/+server.ts | 309 +++++++++++++++++++++--- 5 files changed, 592 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index 29576e8..9737312 100644 --- a/README.md +++ b/README.md @@ -351,7 +351,7 @@ See `docs/SECURITY.md` and `docs/SECURITY_IMPLEMENTATION.md` for detailed inform ## Environment Variables -- `NOSTRGIT_SECRET_KEY`: User's nsec (bech32 or hex) for client-side git operations via credential helper (optional) +- `NOSTRGIT_SECRET_KEY`: User's Nostr private key (nsec bech32 or hex) for git command-line operations via credential helper. Required for `git clone`, `git push`, and `git pull` operations from the command line. See [Git Command Line Setup](#git-command-line-setup) above. - `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`) @@ -446,22 +446,61 @@ The server will automatically locate `git-http-backend` in common locations. The server will automatically provision the repository. +### Git Command Line Setup + +To use git from the command line with GitRepublic, you need to configure the credential helper. This enables automatic NIP-98 authentication for all git operations (clone, push, pull). + +**Quick Setup:** + +1. **Set your Nostr private key**: + ```bash + export NOSTRGIT_SECRET_KEY="nsec1..." + # Or add to ~/.bashrc or ~/.zshrc for persistence + echo 'export NOSTRGIT_SECRET_KEY="nsec1..."' >> ~/.bashrc + ``` + +2. **Configure git credential helper**: + ```bash + # For a specific domain (recommended) + git config --global credential.https://your-domain.com.helper '!node /absolute/path/to/gitrepublic-web/scripts/git-credential-nostr.js' + + # For localhost development + git config --global credential.http://localhost:5173.helper '!node /absolute/path/to/gitrepublic-web/scripts/git-credential-nostr.js' + ``` + +3. **Make the script executable**: + ```bash + chmod +x /absolute/path/to/gitrepublic-web/scripts/git-credential-nostr.js + ``` + +**Important Notes:** +- The `NOSTRGIT_SECRET_KEY` must match the repository owner or you must have maintainer permissions +- The credential helper generates fresh NIP-98 tokens for each request (per-request authentication) +- Never commit your private key to version control + +For complete setup instructions and troubleshooting, see [docs/GIT_CREDENTIAL_HELPER.md](./docs/GIT_CREDENTIAL_HELPER.md). + ### Cloning a Repository ```bash +# Public repository git clone https://{domain}/{npub}/{repo-name}.git -``` -For private repositories, configure git with NIP-98 authentication. +# Private repository (requires credential helper setup) +git clone https://{domain}/{npub}/{repo-name}.git +``` ### Pushing to a Repository ```bash +# Add remote git remote add origin https://{domain}/{npub}/{repo-name}.git + +# Push (requires credential helper setup) git push origin main ``` -Requires NIP-98 authentication. Your git client needs to support NIP-98 or you can use a custom credential helper. +The credential helper will automatically generate NIP-98 authentication tokens for push operations. ### Viewing Repositories diff --git a/docs/GIT_CREDENTIAL_HELPER.md b/docs/GIT_CREDENTIAL_HELPER.md index 007ef29..846f6be 100644 --- a/docs/GIT_CREDENTIAL_HELPER.md +++ b/docs/GIT_CREDENTIAL_HELPER.md @@ -36,10 +36,18 @@ export NOSTRGIT_SECRET_KEY="" ### 3. Configure git to use the credential helper +**Important:** The credential helper must be called for EACH request (not just the first one), because NIP-98 requires per-request authentication tokens. Make sure it's configured BEFORE any caching credential helpers. + #### Global configuration (for all GitRepublic repositories): ```bash +# Add our helper FIRST (before any cache/store helpers) git config --global credential.helper '!node /absolute/path/to/gitrepublic-web/scripts/git-credential-nostr.js' + +# Optional: Disable credential caching to ensure our helper is always called +git config --global credential.helper cache +# Or remove cache helper if you want to ensure fresh credentials each time: +# git config --global --unset credential.helper cache ``` #### Per-domain configuration (recommended): @@ -160,11 +168,14 @@ git pull gitrepublic-web 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` 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 +2. The helper reads your `NOSTRGIT_SECRET_KEY` environment variable +3. It creates a NIP-98 authentication event signed with your private key for the specific URL and HTTP method +4. The signed event is base64-encoded and returned as `username=nostr` and `password=` +5. Git converts this to `Authorization: Basic ` header +6. The GitRepublic server detects Basic auth with username "nostr" and converts it to `Authorization: Nostr ` format +7. The server verifies the NIP-98 auth event (signature, URL, method, timestamp) and grants access if valid + +**Important:** The credential helper generates fresh credentials for each request because NIP-98 requires per-request authentication tokens. The URL and HTTP method are part of the signed event, so credentials cannot be reused. ## Troubleshooting @@ -189,13 +200,39 @@ export NOSTRGIT_SECRET_KEY="nsec1..." - 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 is correctly set +### Push operations fail or show login dialog + +If you see a login dialog when pushing, git isn't calling the credential helper for the POST request. This usually happens because: + +1. **Credential helper not configured correctly**: + ```bash + # Check your credential helper configuration + git config --global --get-regexp credential.helper + + # Make sure the GitRepublic helper is configured for your domain + git config --global credential.http://localhost:5173.helper '!node /path/to/gitrepublic-web/scripts/git-credential-nostr.js' + ``` + +2. **Other credential helpers interfering**: Git might be using cached credentials from another helper. Make sure the GitRepublic helper is listed FIRST: + ```bash + # Remove all credential helpers + git config --global --unset-all credential.helper + + # Add only the GitRepublic helper + git config --global credential.http://localhost:5173.helper '!node /path/to/gitrepublic-web/scripts/git-credential-nostr.js' + ``` + +3. **NOSTRGIT_SECRET_KEY not set**: Make sure the environment variable is set in the shell where git runs: + ```bash + export NOSTRGIT_SECRET_KEY="nsec1..." + ``` + +4. **Wrong private key**: Ensure your `NOSTRGIT_SECRET_KEY` matches the repository owner or you have maintainer permissions for the repository you're pushing to. + +5. **Authorization failure (403)**: If authentication succeeds but push fails with 403, check: + - Your pubkey matches the repository owner, OR + - You have maintainer permissions for the repository + - Branch protection rules allow your push ## Security Best Practices diff --git a/scripts/git-credential-nostr.js b/scripts/git-credential-nostr.js index b9e9035..b5bc3a3 100755 --- a/scripts/git-credential-nostr.js +++ b/scripts/git-credential-nostr.js @@ -19,8 +19,10 @@ */ import { createHash } from 'crypto'; -import { getEventHash, signEvent, getPublicKey } from 'nostr-tools'; +import { finalizeEvent, getPublicKey } from 'nostr-tools'; import { decode } from 'nostr-tools/nip19'; +import { readFileSync, existsSync } from 'fs'; +import { join, resolve } from 'path'; // NIP-98 auth event kind const KIND_NIP98_AUTH = 27235; @@ -58,13 +60,102 @@ function readInput() { }); } +/** + * Try to extract the git remote URL path from .git/config + * This is used as a fallback when git calls us with wwwauth[] but no path + */ +function tryGetPathFromGitRemote(host, protocol) { + try { + // Git sets GIT_DIR environment variable when calling credential helpers + // Use it if available, otherwise try to find .git directory + let gitDir = process.env.GIT_DIR; + let configPath = null; + + if (gitDir) { + // GIT_DIR might point directly to .git directory or to the config file + if (existsSync(gitDir) && existsSync(join(gitDir, 'config'))) { + configPath = join(gitDir, 'config'); + } else if (existsSync(gitDir) && gitDir.endsWith('config')) { + configPath = gitDir; + } + } + + // If GIT_DIR didn't work, try to find .git directory starting from current working directory + if (!configPath) { + let currentDir = process.cwd(); + const maxDepth = 10; // Limit search depth + let depth = 0; + + while (depth < maxDepth) { + const potentialGitDir = join(currentDir, '.git'); + if (existsSync(potentialGitDir) && existsSync(join(potentialGitDir, 'config'))) { + configPath = join(potentialGitDir, 'config'); + break; + } + + // Move up one directory + const parentDir = resolve(currentDir, '..'); + if (parentDir === currentDir) { + // Reached filesystem root + break; + } + currentDir = parentDir; + depth++; + } + } + + if (!configPath || !existsSync(configPath)) { + return null; + } + + // Read git config + const config = readFileSync(configPath, 'utf-8'); + + // Find remotes that match our host + // Match: [remote "name"] ... url = http://host/path + const remoteRegex = /\[remote\s+"([^"]+)"\][\s\S]*?url\s*=\s*([^\n]+)/g; + let match; + while ((match = remoteRegex.exec(config)) !== null) { + const remoteUrl = match[2].trim(); + + // Check if this remote URL matches our host + try { + const url = new URL(remoteUrl); + const remoteHost = url.hostname + (url.port ? ':' + url.port : ''); + if (url.host === host || remoteHost === host) { + // Extract path from remote URL + let path = url.pathname; + if (path && path.includes('git-receive-pack')) { + // Already has git-receive-pack in path + return path; + } else if (path && path.endsWith('.git')) { + // Add git-receive-pack to path + return path + '/git-receive-pack'; + } else if (path) { + // Path exists but doesn't end with .git, try adding /git-receive-pack + return path + '/git-receive-pack'; + } + } + } catch (e) { + // Not a valid URL, skip + continue; + } + } + } catch (err) { + // If anything fails, return null silently + } + + return null; +} + /** * Normalize URL for NIP-98 (remove trailing slashes, ensure consistent format) + * This must match the normalization used by the server in nip98-auth.ts */ function normalizeUrl(url) { try { const parsed = new URL(url); - // Remove trailing slash from pathname + // Remove trailing slash from pathname (must match server normalization) parsed.pathname = parsed.pathname.replace(/\/$/, ''); return parsed.toString(); } catch { @@ -83,9 +174,13 @@ function calculateBodyHash(body) { /** * Create and sign a NIP-98 authentication event + * @param privateKeyBytes - Private key as Uint8Array (32 bytes) + * @param url - Request URL + * @param method - HTTP method (GET, POST, etc.) + * @param bodyHash - Optional SHA256 hash of request body (for POST requests) */ -function createNIP98AuthEvent(privateKey, url, method, bodyHash = null) { - const pubkey = getPublicKey(privateKey); +function createNIP98AuthEvent(privateKeyBytes, url, method, bodyHash = null) { + const pubkey = getPublicKey(privateKeyBytes); const tags = [ ['u', normalizeUrl(url)], ['method', method.toUpperCase()] @@ -95,7 +190,7 @@ function createNIP98AuthEvent(privateKey, url, method, bodyHash = null) { tags.push(['payload', bodyHash]); } - const event = { + const eventTemplate = { kind: KIND_NIP98_AUTH, pubkey, created_at: Math.floor(Date.now() / 1000), @@ -103,11 +198,10 @@ function createNIP98AuthEvent(privateKey, url, method, bodyHash = null) { tags }; - // Sign the event - event.id = getEventHash(event); - event.sig = signEvent(event, privateKey); + // Sign the event using finalizeEvent (which computes id and sig) + const signedEvent = finalizeEvent(eventTemplate, privateKeyBytes); - return event; + return signedEvent; } /** @@ -133,12 +227,14 @@ async function main() { } // Parse private key (handle both nsec and hex formats) - let privateKey; + // Convert to Uint8Array for nostr-tools functions + let privateKeyBytes; if (nsec.startsWith('nsec')) { try { const decoded = decode(nsec); if (decoded.type === 'nsec') { - privateKey = decoded.data; + // decoded.data is already Uint8Array for nsec + privateKeyBytes = decoded.data; } else { throw new Error('Invalid nsec format - decoded type is not nsec'); } @@ -152,33 +248,121 @@ async function main() { console.error('Error: Hex private key must be 64 characters (32 bytes)'); process.exit(1); } - privateKey = nsec; + // Convert hex string to Uint8Array + privateKeyBytes = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + privateKeyBytes[i] = parseInt(nsec.slice(i * 2, i * 2 + 2), 16); + } } // 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); + // Git credential helper protocol passes: protocol, host, path (and sometimes username, password) + // Git may provide either individual attributes (protocol, host, path) or a url attribute + // If we have a url, use it; otherwise construct from individual attributes + let url; + if (input.url) { + // Git provided a url attribute - use it directly + url = input.url; + } else { + // Construct URL from individual attributes + const protocol = input.protocol || 'https'; + const host = input.host; + let path = input.path || ''; + const wwwauth = input['wwwauth[]'] || input.wwwauth; + + if (!host) { + console.error('Error: No host specified in credential request'); + process.exit(1); + } + + // If path is missing, try to extract it from git remote URL + // This happens when git calls us reactively after a 401 with wwwauth[] but no path + if (!path) { + if (wwwauth) { + // Try to get path from git remote URL + const extractedPath = tryGetPathFromGitRemote(host, protocol); + if (extractedPath) { + path = extractedPath; + } else { + // Exit without output - git should call us again with the full path when it retries + process.exit(0); + } + } else { + // Exit without output - git will call us again with the full path + process.exit(0); + } + } + + // Build full URL (include query string if present) + const query = input.query || ''; + const fullPath = query ? `${path}?${query}` : path; + url = `${protocol}://${host}${fullPath}`; } - // Build full URL - const url = `${protocol}://${host}${path}`; + // Parse URL to extract components for method detection + let urlPath = ''; + try { + const urlObj = new URL(url); + urlPath = urlObj.pathname; + } catch (err) { + // If URL parsing fails, try to extract path from the URL string + const match = url.match(/https?:\/\/[^\/]+(\/.*)/); + urlPath = match ? match[1] : ''; + } // 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'; + // - If path contains 'git-upload-pack', it's a fetch (GET) + // - For info/refs requests, check the service query parameter + let method = 'GET'; + let authUrl = url; // The URL for which we generate credentials + + // Parse query string from URL if present + let query = ''; + try { + const urlObj = new URL(url); + query = urlObj.search.slice(1); // Remove leading '?' + } catch (err) { + // If URL parsing fails, try to extract query from the URL string + const match = url.match(/\?(.+)$/); + query = match ? match[1] : ''; + } + + if (urlPath.includes('git-receive-pack')) { + // Direct POST request to git-receive-pack + method = 'POST'; + authUrl = url; + } else if (urlPath.includes('git-upload-pack')) { + // Direct GET request to git-upload-pack + method = 'GET'; + authUrl = url; + } else if (query.includes('service=git-receive-pack')) { + // info/refs?service=git-receive-pack - this is a GET request + // However, git might not call us again for the POST request + // So we need to generate credentials for the POST request that will happen next + // Replace info/refs with git-receive-pack in the path + try { + const urlObj = new URL(url); + urlObj.pathname = urlObj.pathname.replace(/\/info\/refs$/, '/git-receive-pack'); + urlObj.search = ''; // Remove query string for POST request + authUrl = urlObj.toString(); + } catch (err) { + // Fallback: string replacement + authUrl = url.replace(/\/info\/refs(\?.*)?$/, '/git-receive-pack'); + } + method = 'POST'; + } else { + // Default: GET request (info/refs, etc.) + method = 'GET'; + authUrl = url; + } + + // Normalize the URL before creating the event (must match server normalization) + const normalizedAuthUrl = normalizeUrl(authUrl); // Create and sign NIP-98 auth event - const authEvent = createNIP98AuthEvent(privateKey, url, method); + const authEvent = createNIP98AuthEvent(privateKeyBytes, normalizedAuthUrl, method); // Encode event as base64 const eventJson = JSON.stringify(authEvent); @@ -191,8 +375,11 @@ async function main() { 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 + // For 'store', we don't store credentials because NIP-98 requires per-request credentials + // The URL and method are part of the signed event, so we can't reuse credentials + // However, we should NOT prevent git from storing - let other credential helpers handle it + // We just exit successfully without storing anything ourselves + // This allows git to call us again for each request process.exit(0); } else if (command === 'erase') { // For 'erase', we don't need to do anything diff --git a/src/lib/services/nostr/nip98-auth.ts b/src/lib/services/nostr/nip98-auth.ts index 47b964c..3189a15 100644 --- a/src/lib/services/nostr/nip98-auth.ts +++ b/src/lib/services/nostr/nip98-auth.ts @@ -40,7 +40,8 @@ export function verifyNIP98Auth( try { // Decode base64 event - const base64Event = authHeader.slice(7); // Remove "Nostr " prefix + // "Nostr " is 6 characters (N-o-s-t-r-space), so we slice from index 6 + const base64Event = authHeader.slice(6).trim(); // Remove "Nostr " prefix and trim whitespace const eventJson = Buffer.from(base64Event, 'base64').toString('utf-8'); const nostrEvent: NostrEvent = JSON.parse(eventJson); diff --git a/src/routes/api/git/[...path]/+server.ts b/src/routes/api/git/[...path]/+server.ts index 364832b..c914807 100644 --- a/src/routes/api/git/[...path]/+server.ts +++ b/src/routes/api/git/[...path]/+server.ts @@ -3,7 +3,7 @@ * Handles git clone, push, pull operations via git-http-backend */ -import { error } from '@sveltejs/kit'; +import { error, json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { RepoManager } from '$lib/services/git/repo-manager.js'; import { requireNpubHex } from '$lib/utils/npub-utils.js'; @@ -22,7 +22,9 @@ import logger from '$lib/services/logger.js'; import { auditLogger } from '$lib/services/security/audit-logger.js'; import { isValidBranchName, sanitizeError } from '$lib/utils/security.js'; -const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; +// Resolve GIT_REPO_ROOT to absolute path (handles both relative and absolute paths) +const repoRootEnv = process.env.GIT_REPO_ROOT || '/repos'; +const repoRoot = resolve(repoRootEnv); const repoManager = new RepoManager(repoRoot); const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); const ownershipTransferService = new OwnershipTransferService(DEFAULT_NOSTR_RELAYS); @@ -145,6 +147,56 @@ function extractCloneUrls(event: NostrEvent): string[] { return urls; } +/** + * Normalize Authorization header from git credential helper format + * Git credential helper outputs username=nostr and password= + * Git HTTP backend converts this to Authorization: Basic + * This function converts it back to Authorization: Nostr format + */ +function normalizeAuthHeader(authHeader: string | null): string | null { + if (!authHeader) { + return null; + } + + // If already in Nostr format, return as-is + if (authHeader.startsWith('Nostr ')) { + return authHeader; + } + + // If it's Basic auth, try to extract the NIP-98 event + if (authHeader.startsWith('Basic ')) { + try { + const base64Credentials = authHeader.slice(6); // Remove "Basic " prefix + const credentials = Buffer.from(base64Credentials, 'base64').toString('utf-8'); + const [username, ...passwordParts] = credentials.split(':'); + const password = passwordParts.join(':'); // Rejoin in case password contains colons + + // If username is "nostr", the password is the base64-encoded NIP-98 event + if (username === 'nostr' && password) { + // Trim whitespace and control characters that might be added during encoding + const trimmedPassword = password.trim().replace(/[\r\n\t\0]/g, ''); + + // Validate the password is valid base64-encoded JSON before using it + try { + const testDecode = Buffer.from(trimmedPassword, 'base64').toString('utf-8'); + JSON.parse(testDecode); // Verify it's valid JSON + return `Nostr ${trimmedPassword}`; + } catch (err) { + logger.warn({ error: err instanceof Error ? err.message : String(err) }, + 'Invalid base64-encoded NIP-98 event in Basic auth password'); + return authHeader; // Return original header if invalid + } + } + } catch (err) { + // If decoding fails, return original header + logger.debug({ error: err }, 'Failed to decode Basic auth header'); + } + } + + // Return original header if we can't convert it + return authHeader; +} + export const GET: RequestHandler = async ({ params, url, request }) => { const path = params.path || ''; @@ -181,7 +233,34 @@ export const GET: RequestHandler = async ({ params, url, request }) => { return error(403, 'Invalid repository path'); } if (!repoManager.repoExists(repoPath)) { - return error(404, 'Repository not found'); + logger.warn({ repoPath, resolvedPath, repoRoot, resolvedRoot }, 'Repository not found at expected path'); + return error(404, `Repository not found at ${resolvedPath}. Please check GIT_REPO_ROOT environment variable (currently: ${repoRoot})`); + } + + // Verify it's a valid git repository + const gitDir = join(resolvedPath, 'objects'); + if (!existsSync(gitDir)) { + logger.warn({ repoPath: resolvedPath }, 'Repository path exists but is not a valid git repository'); + return error(500, `Repository at ${resolvedPath} is not a valid git repository`); + } + + // Ensure http.receivepack is enabled for push operations + // This is required for git-http-backend to allow receive-pack service + // Even with GIT_HTTP_EXPORT_ALL=1, the repository config must allow it + if (service === 'git-receive-pack') { + try { + const { execSync } = await import('child_process'); + // Set http.receivepack to true if not already set + execSync('git config http.receivepack true', { + cwd: resolvedPath, + stdio: 'ignore', + timeout: 5000 + }); + logger.debug({ repoPath: resolvedPath }, 'Enabled http.receivepack for repository'); + } catch (err) { + // Log but don't fail - git-http-backend might still work + logger.debug({ error: err, repoPath: resolvedPath }, 'Failed to set http.receivepack (may already be set)'); + } } // Check repository privacy for clone/fetch operations @@ -197,7 +276,9 @@ export const GET: RequestHandler = async ({ params, url, request }) => { const privacyInfo = await maintainerService.getPrivacyInfo(originalOwnerPubkey, repoName); if (privacyInfo.isPrivate) { // Private repos require authentication for clone/fetch - const authHeader = request.headers.get('Authorization'); + const rawAuthHeader = request.headers.get('Authorization'); + // Normalize auth header (convert Basic auth from git credential helper to Nostr format) + const authHeader = normalizeAuthHeader(rawAuthHeader); if (!authHeader || !authHeader.startsWith('Nostr ')) { return error(401, 'This repository is private. Authentication required.'); } @@ -241,12 +322,26 @@ export const GET: RequestHandler = async ({ params, url, request }) => { return error(500, 'git-http-backend not found. Please install git.'); } - // Build PATH_INFO - // Security: Since we're setting GIT_PROJECT_ROOT to the specific repo path, - // PATH_INFO should be relative to that repo (just the git operation path) - // For info/refs: /info/refs - // For other operations: /{git-path} - const pathInfo = gitPath ? `/${gitPath}` : `/info/refs`; + // Build PATH_INFO using repository-per-directory mode + // GIT_PROJECT_ROOT points to the parent directory containing repositories + // PATH_INFO includes the repository name: /repo.git/info/refs + const repoParentDir = resolve(join(repoRoot, npub)); + const repoRelativePath = `${repoName}.git`; + const gitOperationPath = gitPath ? `/${gitPath}` : `/info/refs`; + const pathInfo = `/${repoRelativePath}${gitOperationPath}`; + + // Debug logging for git operations + logger.debug({ + npub, + repoName, + resolvedPath, + repoParentDir, + repoRelativePath, + pathInfo, + service, + gitHttpBackend, + method: request.method + }, 'Processing git HTTP request'); // Set up environment variables for git-http-backend // Security: Whitelist only necessary environment variables @@ -257,7 +352,7 @@ export const GET: RequestHandler = async ({ params, url, request }) => { USER: process.env.USER || 'git', LANG: process.env.LANG || 'C.UTF-8', LC_ALL: process.env.LC_ALL || 'C.UTF-8', - GIT_PROJECT_ROOT: resolve(repoPath), // Use specific repo path, not repoRoot + GIT_PROJECT_ROOT: repoParentDir, // Parent directory containing repositories GIT_HTTP_EXPORT_ALL: '1', REQUEST_METHOD: request.method, PATH_INFO: pathInfo, @@ -267,6 +362,14 @@ export const GET: RequestHandler = async ({ params, url, request }) => { HTTP_USER_AGENT: request.headers.get('User-Agent') || '', }; + // Debug: Log environment variables (sanitized) + logger.debug({ + GIT_PROJECT_ROOT: repoParentDir, + PATH_INFO: pathInfo, + QUERY_STRING: url.searchParams.toString(), + REQUEST_METHOD: request.method + }, 'git-http-backend environment'); + // Add TZ if set (for consistent timestamps) if (process.env.TZ) { envVars.TZ = process.env.TZ; @@ -349,30 +452,98 @@ export const GET: RequestHandler = async ({ params, url, request }) => { ); } + // Debug: Log git-http-backend output + let body = Buffer.concat(chunks); + logger.debug({ + code, + bodyLength: body.length, + bodyPreview: body.slice(0, 200).toString('utf-8'), + errorOutput: errorOutput.slice(0, 500), + pathInfo, + service + }, 'git-http-backend response'); + if (code !== 0 && chunks.length === 0) { const sanitizedError = sanitizeError(errorOutput || 'Unknown error'); resolve(error(500, `git-http-backend error: ${sanitizedError}`)); return; } - - const body = Buffer.concat(chunks); - // Determine content type based on service - let contentType = 'application/x-git-upload-pack-result'; - if (service === 'git-receive-pack' || gitPath === 'git-receive-pack') { + // For info/refs requests, git-http-backend includes HTTP headers in the body + // We need to strip them and only send the git protocol data + // The format is: HTTP headers + blank line (\r\n\r\n) + git protocol data + if (pathInfo.includes('info/refs')) { + const bodyStr = body.toString('binary'); + const headerEnd = bodyStr.indexOf('\r\n\r\n'); + if (headerEnd !== -1) { + // Extract only the git protocol data (after the blank line) + body = Buffer.from(bodyStr.slice(headerEnd + 4), 'binary'); + logger.debug({ + originalLength: Buffer.concat(chunks).length, + protocolDataLength: body.length, + headerEnd + }, 'Stripped HTTP headers from info/refs response'); + } + } + + // Determine content type based on request type + // For info/refs requests with service parameter, use the appropriate advertisement content type + let contentType = 'text/plain; charset=utf-8'; + if (pathInfo.includes('info/refs')) { + if (service === 'git-receive-pack') { + // info/refs?service=git-receive-pack returns application/x-git-receive-pack-advertisement + contentType = 'application/x-git-receive-pack-advertisement'; + } else if (service === 'git-upload-pack') { + // info/refs?service=git-upload-pack returns application/x-git-upload-pack-advertisement + contentType = 'application/x-git-upload-pack-advertisement'; + } else { + // info/refs without service parameter is text/plain + contentType = 'text/plain; charset=utf-8'; + } + } else if (service === 'git-receive-pack' || gitPath === 'git-receive-pack') { + // POST requests to git-receive-pack (push) contentType = 'application/x-git-receive-pack-result'; } else if (service === 'git-upload-pack' || gitPath === 'git-upload-pack') { + // POST requests to git-upload-pack (fetch) contentType = 'application/x-git-upload-pack-result'; - } else if (pathInfo.includes('info/refs')) { - contentType = 'text/plain; charset=utf-8'; } - resolve(new Response(body, { + // Debug: Log response details + logger.debug({ status: code === 0 ? 200 : 500, + contentType, + bodyLength: body.length, + bodyHex: body.slice(0, 100).toString('hex'), headers: { 'Content-Type': contentType, 'Content-Length': body.length.toString(), } + }, 'Sending git HTTP response'); + + // Build response headers + // Git expects specific headers for info/refs responses + const headers: HeadersInit = { + 'Content-Type': contentType, + 'Content-Length': body.length.toString(), + }; + + // For info/refs with service parameter, add Cache-Control header + if (pathInfo.includes('info/refs') && service) { + headers['Cache-Control'] = 'no-cache'; + } + + // Debug: Log response details + logger.debug({ + status: code === 0 ? 200 : 500, + contentType, + bodyLength: body.length, + bodyPreview: body.slice(0, 200).toString('utf-8'), + headers + }, 'Sending git HTTP response'); + + resolve(new Response(body, { + status: code === 0 ? 200 : 500, + headers })); }); @@ -423,7 +594,34 @@ export const POST: RequestHandler = async ({ params, url, request }) => { return error(403, 'Invalid repository path'); } if (!repoManager.repoExists(repoPath)) { - return error(404, 'Repository not found'); + logger.warn({ repoPath, resolvedPath, repoRoot, resolvedRoot }, 'Repository not found at expected path'); + return error(404, `Repository not found at ${resolvedPath}. Please check GIT_REPO_ROOT environment variable (currently: ${repoRoot})`); + } + + // Verify it's a valid git repository + const gitDir = join(resolvedPath, 'objects'); + if (!existsSync(gitDir)) { + logger.warn({ repoPath: resolvedPath }, 'Repository path exists but is not a valid git repository'); + return error(500, `Repository at ${resolvedPath} is not a valid git repository`); + } + + // Ensure http.receivepack is enabled for push operations + // This is required for git-http-backend to allow receive-pack service + // Even with GIT_HTTP_EXPORT_ALL=1, the repository config must allow it + if (gitPath === 'git-receive-pack' || path.includes('git-receive-pack')) { + try { + const { execSync } = await import('child_process'); + // Set http.receivepack to true if not already set + execSync('git config http.receivepack true', { + cwd: resolvedPath, + stdio: 'ignore', + timeout: 5000 + }); + logger.debug({ repoPath: resolvedPath }, 'Enabled http.receivepack for repository'); + } catch (err) { + // Log but don't fail - git-http-backend might still work + logger.debug({ error: err, repoPath: resolvedPath }, 'Failed to set http.receivepack (may already be set)'); + } } // Get current owner (may be different if ownership was transferred) @@ -440,17 +638,58 @@ export const POST: RequestHandler = async ({ params, url, request }) => { // For push operations (git-receive-pack), require NIP-98 authentication if (gitPath === 'git-receive-pack' || path.includes('git-receive-pack')) { + const rawAuthHeader = request.headers.get('Authorization'); + + // Always return 401 with WWW-Authenticate if no Authorization header + // This ensures git calls the credential helper proactively + // Git requires WWW-Authenticate header on ALL 401 responses, otherwise it won't retry + if (!rawAuthHeader) { + return new Response('Authentication required. Please configure the git credential helper. See docs/GIT_CREDENTIAL_HELPER.md for setup instructions.', { + status: 401, + headers: { + 'WWW-Authenticate': 'Basic realm="GitRepublic"', + 'Content-Type': 'text/plain' + } + }); + } + + // Normalize auth header (convert Basic auth from git credential helper to Nostr format) + const authHeader = normalizeAuthHeader(rawAuthHeader); + // Verify NIP-98 authentication const authResult = verifyNIP98Auth( - request.headers.get('Authorization'), + authHeader, requestUrl, request.method, bodyBuffer.length > 0 ? bodyBuffer : undefined ); if (!authResult.valid) { - return error(401, authResult.error || 'Authentication required'); + logger.warn({ + error: authResult.error, + requestUrl, + requestMethod: request.method + }, 'NIP-98 authentication failed for push'); + + // Always return 401 with WWW-Authenticate header, even if Authorization was present + // This ensures git retries with the credential helper + // Git requires WWW-Authenticate on ALL 401 responses, otherwise it won't retry + const errorMessage = authResult.error || 'Authentication required'; + + return new Response(errorMessage, { + status: 401, + headers: { + 'WWW-Authenticate': 'Basic realm="GitRepublic"', + 'Content-Type': 'text/plain' + } + }); } + + logger.debug({ + pubkey: authResult.pubkey, + requestUrl, + requestMethod: request.method + }, 'NIP-98 authentication successful for push'); // Verify pubkey is current repo owner or maintainer const isMaintainer = await maintainerService.isMaintainer( @@ -504,10 +743,11 @@ export const POST: RequestHandler = async ({ params, url, request }) => { return error(500, 'git-http-backend not found. Please install git.'); } - // Build PATH_INFO - // Security: Since we're setting GIT_PROJECT_ROOT to the specific repo path, - // PATH_INFO should be relative to that repo (just the git operation path) - const pathInfo = gitPath ? `/${gitPath}` : `/`; + // Build PATH_INFO using repository-per-directory mode (same as GET handler) + const repoParentDir = resolve(join(repoRoot, npub)); + const repoRelativePath = `${repoName}.git`; + const gitOperationPath = gitPath ? `/${gitPath}` : `/`; + const pathInfo = `/${repoRelativePath}${gitOperationPath}`; // Set up environment variables for git-http-backend // Security: Whitelist only necessary environment variables @@ -518,7 +758,7 @@ export const POST: RequestHandler = async ({ params, url, request }) => { USER: process.env.USER || 'git', LANG: process.env.LANG || 'C.UTF-8', LC_ALL: process.env.LC_ALL || 'C.UTF-8', - GIT_PROJECT_ROOT: resolve(repoPath), // Use specific repo path, not repoRoot + GIT_PROJECT_ROOT: repoParentDir, // Parent directory containing repositories GIT_HTTP_EXPORT_ALL: '1', REQUEST_METHOD: request.method, PATH_INFO: pathInfo, @@ -528,6 +768,21 @@ export const POST: RequestHandler = async ({ params, url, request }) => { HTTP_USER_AGENT: request.headers.get('User-Agent') || '', }; + // Pass Authorization header to git-http-backend (if present) + // Git-http-backend uses HTTP_AUTHORIZATION environment variable + const authHeader = request.headers.get('Authorization'); + if (authHeader) { + envVars.HTTP_AUTHORIZATION = authHeader; + } + + // Debug: Log environment variables (sanitized) + logger.debug({ + GIT_PROJECT_ROOT: repoParentDir, + PATH_INFO: pathInfo, + QUERY_STRING: url.searchParams.toString(), + REQUEST_METHOD: request.method + }, 'git-http-backend environment (POST)'); + // Add TZ if set (for consistent timestamps) if (process.env.TZ) { envVars.TZ = process.env.TZ;