Browse Source

fix git credentials

main
Silberengel 4 weeks ago
parent
commit
b6983321b3
  1. 47
      README.md
  2. 57
      docs/GIT_CREDENTIAL_HELPER.md
  3. 243
      scripts/git-credential-nostr.js
  4. 3
      src/lib/services/nostr/nip98-auth.ts
  5. 307
      src/routes/api/git/[...path]/+server.ts

47
README.md

@ -351,7 +351,7 @@ See `docs/SECURITY.md` and `docs/SECURITY_IMPLEMENTATION.md` for detailed inform
## Environment Variables ## 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_REPO_ROOT`: Path to store git repositories (default: `/repos`)
- `GIT_DOMAIN`: Domain for git repositories (default: `localhost:6543`) - `GIT_DOMAIN`: Domain for git repositories (default: `localhost:6543`)
- `NOSTR_RELAYS`: Comma-separated list of Nostr relays (default: `wss://theforest.nostr1.com`) - `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. 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 ### Cloning a Repository
```bash ```bash
# Public repository
git clone https://{domain}/{npub}/{repo-name}.git 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 ### Pushing to a Repository
```bash ```bash
# Add remote
git remote add origin https://{domain}/{npub}/{repo-name}.git git remote add origin https://{domain}/{npub}/{repo-name}.git
# Push (requires credential helper setup)
git push origin main 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 ### Viewing Repositories

57
docs/GIT_CREDENTIAL_HELPER.md

@ -36,10 +36,18 @@ export NOSTRGIT_SECRET_KEY="<your-64-char-hex-private-key>"
### 3. Configure git to use the credential helper ### 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): #### Global configuration (for all GitRepublic repositories):
```bash ```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' 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): #### Per-domain configuration (recommended):
@ -160,11 +168,14 @@ git pull gitrepublic-web main
## How It Works ## How It Works
1. When git needs credentials, it calls the credential helper with the repository URL 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) 2. The helper reads your `NOSTRGIT_SECRET_KEY` environment variable
3. It creates a NIP-98 authentication event signed with your private key 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 the "password" 4. The signed event is base64-encoded and returned as `username=nostr` and `password=<base64-event>`
5. Git sends this in the `Authorization: Nostr <base64-event>` header 5. Git converts this to `Authorization: Basic <base64(username:password)>` header
6. The GitRepublic server verifies the NIP-98 auth event and grants access 6. The GitRepublic server detects Basic auth with username "nostr" and converts it to `Authorization: Nostr <base64-event>` 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 ## Troubleshooting
@ -189,13 +200,39 @@ export NOSTRGIT_SECRET_KEY="nsec1..."
- Check that the repository URL is correct - Check that the repository URL is correct
- Ensure your key has maintainer permissions for push operations - Ensure your key has maintainer permissions for push operations
### Push operations fail ### 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..."
```
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: 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.
1. Verify you have maintainer permissions for the repository 5. **Authorization failure (403)**: If authentication succeeds but push fails with 403, check:
2. Check that branch protection rules allow your push - Your pubkey matches the repository owner, OR
3. Ensure your NOSTRGIT_SECRET_KEY is correctly set - You have maintainer permissions for the repository
- Branch protection rules allow your push
## Security Best Practices ## Security Best Practices

243
scripts/git-credential-nostr.js

@ -19,8 +19,10 @@
*/ */
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { getEventHash, signEvent, getPublicKey } from 'nostr-tools'; import { finalizeEvent, getPublicKey } from 'nostr-tools';
import { decode } from 'nostr-tools/nip19'; import { decode } from 'nostr-tools/nip19';
import { readFileSync, existsSync } from 'fs';
import { join, resolve } from 'path';
// NIP-98 auth event kind // NIP-98 auth event kind
const KIND_NIP98_AUTH = 27235; 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) * 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) { function normalizeUrl(url) {
try { try {
const parsed = new URL(url); const parsed = new URL(url);
// Remove trailing slash from pathname // Remove trailing slash from pathname (must match server normalization)
parsed.pathname = parsed.pathname.replace(/\/$/, ''); parsed.pathname = parsed.pathname.replace(/\/$/, '');
return parsed.toString(); return parsed.toString();
} catch { } catch {
@ -83,9 +174,13 @@ function calculateBodyHash(body) {
/** /**
* Create and sign a NIP-98 authentication event * 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) { function createNIP98AuthEvent(privateKeyBytes, url, method, bodyHash = null) {
const pubkey = getPublicKey(privateKey); const pubkey = getPublicKey(privateKeyBytes);
const tags = [ const tags = [
['u', normalizeUrl(url)], ['u', normalizeUrl(url)],
['method', method.toUpperCase()] ['method', method.toUpperCase()]
@ -95,7 +190,7 @@ function createNIP98AuthEvent(privateKey, url, method, bodyHash = null) {
tags.push(['payload', bodyHash]); tags.push(['payload', bodyHash]);
} }
const event = { const eventTemplate = {
kind: KIND_NIP98_AUTH, kind: KIND_NIP98_AUTH,
pubkey, pubkey,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
@ -103,11 +198,10 @@ function createNIP98AuthEvent(privateKey, url, method, bodyHash = null) {
tags tags
}; };
// Sign the event // Sign the event using finalizeEvent (which computes id and sig)
event.id = getEventHash(event); const signedEvent = finalizeEvent(eventTemplate, privateKeyBytes);
event.sig = signEvent(event, privateKey);
return event; return signedEvent;
} }
/** /**
@ -133,12 +227,14 @@ async function main() {
} }
// Parse private key (handle both nsec and hex formats) // Parse private key (handle both nsec and hex formats)
let privateKey; // Convert to Uint8Array for nostr-tools functions
let privateKeyBytes;
if (nsec.startsWith('nsec')) { if (nsec.startsWith('nsec')) {
try { try {
const decoded = decode(nsec); const decoded = decode(nsec);
if (decoded.type === 'nsec') { if (decoded.type === 'nsec') {
privateKey = decoded.data; // decoded.data is already Uint8Array for nsec
privateKeyBytes = decoded.data;
} else { } else {
throw new Error('Invalid nsec format - decoded type is not nsec'); 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)'); console.error('Error: Hex private key must be 64 characters (32 bytes)');
process.exit(1); 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 // Extract URL components from input
const protocol = input.protocol || 'https'; // Git credential helper protocol passes: protocol, host, path (and sometimes username, password)
const host = input.host; // Git may provide either individual attributes (protocol, host, path) or a url attribute
const path = input.path || ''; // 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) { if (!host) {
console.error('Error: No host specified in credential request'); console.error('Error: No host specified in credential request');
process.exit(1); 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 // Parse URL to extract components for method detection
const url = `${protocol}://${host}${path}`; 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 // Determine HTTP method based on git operation
// Git credential helper doesn't know the HTTP method, but we can infer it: // 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) // - If path contains 'git-receive-pack', it's a push (POST)
// - Otherwise, it's likely a fetch/clone (GET) // - If path contains 'git-upload-pack', it's a fetch (GET)
// Note: For initial info/refs requests, git uses GET, so we default to GET // - For info/refs requests, check the service query parameter
// For actual push operations, git will make POST requests to git-receive-pack let method = 'GET';
// The server will validate the method matches, so we need to handle this carefully let authUrl = url; // The URL for which we generate credentials
const method = path.includes('git-receive-pack') ? 'POST' : 'GET';
// 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 // Create and sign NIP-98 auth event
const authEvent = createNIP98AuthEvent(privateKey, url, method); const authEvent = createNIP98AuthEvent(privateKeyBytes, normalizedAuthUrl, method);
// Encode event as base64 // Encode event as base64
const eventJson = JSON.stringify(authEvent); const eventJson = JSON.stringify(authEvent);
@ -191,8 +375,11 @@ async function main() {
console.log(`password=${base64Event}`); console.log(`password=${base64Event}`);
} else if (command === 'store') { } else if (command === 'store') {
// For 'store', we don't need to do anything (credentials are generated on-demand) // For 'store', we don't store credentials because NIP-98 requires per-request credentials
// Just exit successfully // 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); process.exit(0);
} else if (command === 'erase') { } else if (command === 'erase') {
// For 'erase', we don't need to do anything // For 'erase', we don't need to do anything

3
src/lib/services/nostr/nip98-auth.ts

@ -40,7 +40,8 @@ export function verifyNIP98Auth(
try { try {
// Decode base64 event // 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 eventJson = Buffer.from(base64Event, 'base64').toString('utf-8');
const nostrEvent: NostrEvent = JSON.parse(eventJson); const nostrEvent: NostrEvent = JSON.parse(eventJson);

307
src/routes/api/git/[...path]/+server.ts

@ -3,7 +3,7 @@
* Handles git clone, push, pull operations via git-http-backend * 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 type { RequestHandler } from './$types';
import { RepoManager } from '$lib/services/git/repo-manager.js'; import { RepoManager } from '$lib/services/git/repo-manager.js';
import { requireNpubHex } from '$lib/utils/npub-utils.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 { auditLogger } from '$lib/services/security/audit-logger.js';
import { isValidBranchName, sanitizeError } from '$lib/utils/security.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 repoManager = new RepoManager(repoRoot);
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
const ownershipTransferService = new OwnershipTransferService(DEFAULT_NOSTR_RELAYS); const ownershipTransferService = new OwnershipTransferService(DEFAULT_NOSTR_RELAYS);
@ -145,6 +147,56 @@ function extractCloneUrls(event: NostrEvent): string[] {
return urls; return urls;
} }
/**
* Normalize Authorization header from git credential helper format
* Git credential helper outputs username=nostr and password=<base64-event>
* Git HTTP backend converts this to Authorization: Basic <base64(username:password)>
* This function converts it back to Authorization: Nostr <base64-event> 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 }) => { export const GET: RequestHandler = async ({ params, url, request }) => {
const path = params.path || ''; const path = params.path || '';
@ -181,7 +233,34 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
return error(403, 'Invalid repository path'); return error(403, 'Invalid repository path');
} }
if (!repoManager.repoExists(repoPath)) { 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 // 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); const privacyInfo = await maintainerService.getPrivacyInfo(originalOwnerPubkey, repoName);
if (privacyInfo.isPrivate) { if (privacyInfo.isPrivate) {
// Private repos require authentication for clone/fetch // 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 ')) { if (!authHeader || !authHeader.startsWith('Nostr ')) {
return error(401, 'This repository is private. Authentication required.'); 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.'); return error(500, 'git-http-backend not found. Please install git.');
} }
// Build PATH_INFO // Build PATH_INFO using repository-per-directory mode
// Security: Since we're setting GIT_PROJECT_ROOT to the specific repo path, // GIT_PROJECT_ROOT points to the parent directory containing repositories
// PATH_INFO should be relative to that repo (just the git operation path) // PATH_INFO includes the repository name: /repo.git/info/refs
// For info/refs: /info/refs const repoParentDir = resolve(join(repoRoot, npub));
// For other operations: /{git-path} const repoRelativePath = `${repoName}.git`;
const pathInfo = gitPath ? `/${gitPath}` : `/info/refs`; 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 // Set up environment variables for git-http-backend
// Security: Whitelist only necessary environment variables // Security: Whitelist only necessary environment variables
@ -257,7 +352,7 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
USER: process.env.USER || 'git', USER: process.env.USER || 'git',
LANG: process.env.LANG || 'C.UTF-8', LANG: process.env.LANG || 'C.UTF-8',
LC_ALL: process.env.LC_ALL || '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', GIT_HTTP_EXPORT_ALL: '1',
REQUEST_METHOD: request.method, REQUEST_METHOD: request.method,
PATH_INFO: pathInfo, PATH_INFO: pathInfo,
@ -267,6 +362,14 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
HTTP_USER_AGENT: request.headers.get('User-Agent') || '', 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) // Add TZ if set (for consistent timestamps)
if (process.env.TZ) { if (process.env.TZ) {
envVars.TZ = 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) { if (code !== 0 && chunks.length === 0) {
const sanitizedError = sanitizeError(errorOutput || 'Unknown error'); const sanitizedError = sanitizeError(errorOutput || 'Unknown error');
resolve(error(500, `git-http-backend error: ${sanitizedError}`)); resolve(error(500, `git-http-backend error: ${sanitizedError}`));
return; return;
} }
const body = Buffer.concat(chunks); // 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 service // Determine content type based on request type
let contentType = 'application/x-git-upload-pack-result'; // For info/refs requests with service parameter, use the appropriate advertisement content type
if (service === 'git-receive-pack' || gitPath === 'git-receive-pack') { 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'; contentType = 'application/x-git-receive-pack-result';
} else if (service === 'git-upload-pack' || gitPath === 'git-upload-pack') { } else if (service === 'git-upload-pack' || gitPath === 'git-upload-pack') {
// POST requests to git-upload-pack (fetch)
contentType = 'application/x-git-upload-pack-result'; 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, status: code === 0 ? 200 : 500,
contentType,
bodyLength: body.length,
bodyHex: body.slice(0, 100).toString('hex'),
headers: { headers: {
'Content-Type': contentType, 'Content-Type': contentType,
'Content-Length': body.length.toString(), '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'); return error(403, 'Invalid repository path');
} }
if (!repoManager.repoExists(repoPath)) { 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) // Get current owner (may be different if ownership was transferred)
@ -440,18 +638,59 @@ export const POST: RequestHandler = async ({ params, url, request }) => {
// For push operations (git-receive-pack), require NIP-98 authentication // For push operations (git-receive-pack), require NIP-98 authentication
if (gitPath === 'git-receive-pack' || path.includes('git-receive-pack')) { 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 // Verify NIP-98 authentication
const authResult = verifyNIP98Auth( const authResult = verifyNIP98Auth(
request.headers.get('Authorization'), authHeader,
requestUrl, requestUrl,
request.method, request.method,
bodyBuffer.length > 0 ? bodyBuffer : undefined bodyBuffer.length > 0 ? bodyBuffer : undefined
); );
if (!authResult.valid) { 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 // Verify pubkey is current repo owner or maintainer
const isMaintainer = await maintainerService.isMaintainer( const isMaintainer = await maintainerService.isMaintainer(
authResult.pubkey || '', authResult.pubkey || '',
@ -504,10 +743,11 @@ export const POST: RequestHandler = async ({ params, url, request }) => {
return error(500, 'git-http-backend not found. Please install git.'); return error(500, 'git-http-backend not found. Please install git.');
} }
// Build PATH_INFO // Build PATH_INFO using repository-per-directory mode (same as GET handler)
// Security: Since we're setting GIT_PROJECT_ROOT to the specific repo path, const repoParentDir = resolve(join(repoRoot, npub));
// PATH_INFO should be relative to that repo (just the git operation path) const repoRelativePath = `${repoName}.git`;
const pathInfo = gitPath ? `/${gitPath}` : `/`; const gitOperationPath = gitPath ? `/${gitPath}` : `/`;
const pathInfo = `/${repoRelativePath}${gitOperationPath}`;
// Set up environment variables for git-http-backend // Set up environment variables for git-http-backend
// Security: Whitelist only necessary environment variables // Security: Whitelist only necessary environment variables
@ -518,7 +758,7 @@ export const POST: RequestHandler = async ({ params, url, request }) => {
USER: process.env.USER || 'git', USER: process.env.USER || 'git',
LANG: process.env.LANG || 'C.UTF-8', LANG: process.env.LANG || 'C.UTF-8',
LC_ALL: process.env.LC_ALL || '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', GIT_HTTP_EXPORT_ALL: '1',
REQUEST_METHOD: request.method, REQUEST_METHOD: request.method,
PATH_INFO: pathInfo, PATH_INFO: pathInfo,
@ -528,6 +768,21 @@ export const POST: RequestHandler = async ({ params, url, request }) => {
HTTP_USER_AGENT: request.headers.get('User-Agent') || '', 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) // Add TZ if set (for consistent timestamps)
if (process.env.TZ) { if (process.env.TZ) {
envVars.TZ = process.env.TZ; envVars.TZ = process.env.TZ;

Loading…
Cancel
Save