You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

402 lines
14 KiB

#!/usr/bin/env node
/**
* Git credential helper for GitRepublic using NIP-98 authentication
*
* This script implements the git credential helper protocol to automatically
* generate NIP-98 authentication tokens for git operations.
*
* Usage:
* 1. Make it executable: chmod +x scripts/git-credential-nostr.js
* 2. Configure git:
* git config --global credential.helper '!node /path/to/gitrepublic-web/scripts/git-credential-nostr.js'
* 3. Or for a specific domain:
* git config --global credential.https://your-domain.com.helper '!node /path/to/gitrepublic-web/scripts/git-credential-nostr.js'
*
* Environment variables:
* NOSTRGIT_SECRET_KEY - Your Nostr private key (nsec format or hex) for client-side git operations
*
* Security: Keep your NOSTRGIT_SECRET_KEY secure and never commit it to version control!
*/
import { createHash } from 'crypto';
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;
/**
* Read input from stdin (git credential helper protocol)
*/
function readInput() {
const chunks = [];
process.stdin.setEncoding('utf8');
return new Promise((resolve) => {
process.stdin.on('readable', () => {
let chunk;
while ((chunk = process.stdin.read()) !== null) {
chunks.push(chunk);
}
});
process.stdin.on('end', () => {
const input = chunks.join('');
const lines = input.trim().split('\n');
const data = {};
for (const line of lines) {
if (!line) continue;
const [key, ...valueParts] = line.split('=');
if (key && valueParts.length > 0) {
data[key] = valueParts.join('=');
}
}
resolve(data);
});
});
}
/**
* 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 (must match server normalization)
parsed.pathname = parsed.pathname.replace(/\/$/, '');
return parsed.toString();
} catch {
return url;
}
}
/**
* Calculate SHA256 hash of request body
*/
function calculateBodyHash(body) {
if (!body) return null;
const buffer = Buffer.from(body, 'utf-8');
return createHash('sha256').update(buffer).digest('hex');
}
/**
* Create and sign a NIP-98 authentication event
* @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(privateKeyBytes, url, method, bodyHash = null) {
const pubkey = getPublicKey(privateKeyBytes);
const tags = [
['u', normalizeUrl(url)],
['method', method.toUpperCase()]
];
if (bodyHash) {
tags.push(['payload', bodyHash]);
}
const eventTemplate = {
kind: KIND_NIP98_AUTH,
pubkey,
created_at: Math.floor(Date.now() / 1000),
content: '',
tags
};
// Sign the event using finalizeEvent (which computes id and sig)
const signedEvent = finalizeEvent(eventTemplate, privateKeyBytes);
return signedEvent;
}
/**
* Main credential helper logic
*/
async function main() {
try {
// Read input from git
const input = await readInput();
// Get command (get, store, erase)
const command = process.argv[2] || 'get';
// For 'get' command, generate credentials
if (command === 'get') {
// Get private key from environment variable
// Support NOSTRGIT_SECRET_KEY (preferred), with fallbacks for backward compatibility
const nsec = process.env.NOSTRGIT_SECRET_KEY || process.env.NOSTR_PRIVATE_KEY || process.env.NSEC;
if (!nsec) {
console.error('Error: NOSTRGIT_SECRET_KEY environment variable is not set');
console.error('Set it with: export NOSTRGIT_SECRET_KEY="nsec1..." or NOSTRGIT_SECRET_KEY="<hex-key>"');
process.exit(1);
}
// Parse private key (handle both nsec and hex formats)
// Convert to Uint8Array for nostr-tools functions
let privateKeyBytes;
if (nsec.startsWith('nsec')) {
try {
const decoded = decode(nsec);
if (decoded.type === 'nsec') {
// decoded.data is already Uint8Array for nsec
privateKeyBytes = decoded.data;
} else {
throw new Error('Invalid nsec format - decoded type is not nsec');
}
} catch (err) {
console.error('Error decoding nsec:', err.message);
process.exit(1);
}
} else {
// Assume hex format (32 bytes = 64 hex characters)
if (nsec.length !== 64) {
console.error('Error: Hex private key must be 64 characters (32 bytes)');
process.exit(1);
}
// 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
// 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}`;
}
// 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)
// - 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(privateKeyBytes, normalizedAuthUrl, method);
// Encode event as base64
const eventJson = JSON.stringify(authEvent);
const base64Event = Buffer.from(eventJson, 'utf-8').toString('base64');
// Output credentials in git credential helper format
// Username can be anything (git doesn't use it for NIP-98)
// Password is the base64-encoded signed event
console.log('username=nostr');
console.log(`password=${base64Event}`);
} else if (command === 'store') {
// For 'store', we don't 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
// Just exit successfully
process.exit(0);
} else {
console.error(`Error: Unknown command: ${command}`);
process.exit(1);
}
} catch (error) {
console.error('Error:', error.message);
process.exit(1);
}
}
// Run main function
main().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});