Browse Source

fix git credentials

main
Silberengel 4 weeks ago
parent
commit
b6983321b3
  1. 47
      README.md
  2. 61
      docs/GIT_CREDENTIAL_HELPER.md
  3. 245
      scripts/git-credential-nostr.js
  4. 3
      src/lib/services/nostr/nip98-auth.ts
  5. 309
      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 @@ -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. @@ -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

61
docs/GIT_CREDENTIAL_HELPER.md

@ -36,10 +36,18 @@ export NOSTRGIT_SECRET_KEY="<your-64-char-hex-private-key>" @@ -36,10 +36,18 @@ export NOSTRGIT_SECRET_KEY="<your-64-char-hex-private-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 @@ -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 <base64-event>` 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=<base64-event>`
5. Git converts this to `Authorization: Basic <base64(username:password)>` header
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
@ -189,13 +200,39 @@ export NOSTRGIT_SECRET_KEY="nsec1..." @@ -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

245
scripts/git-credential-nostr.js

@ -19,8 +19,10 @@ @@ -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() { @@ -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) { @@ -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) { @@ -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) { @@ -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() { @@ -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() { @@ -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() { @@ -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

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

@ -40,7 +40,8 @@ export function verifyNIP98Auth( @@ -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);

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

@ -3,7 +3,7 @@ @@ -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'; @@ -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[] { @@ -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=<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 }) => {
const path = params.path || '';
@ -181,7 +233,34 @@ export const GET: RequestHandler = async ({ params, url, request }) => { @@ -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 }) => { @@ -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 }) => { @@ -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 }) => { @@ -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 }) => { @@ -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 }) => { @@ -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 }) => { @@ -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 }) => { @@ -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 }) => { @@ -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 }) => { @@ -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 }) => { @@ -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;

Loading…
Cancel
Save