12 changed files with 883 additions and 150 deletions
@ -0,0 +1,226 @@
@@ -0,0 +1,226 @@
|
||||
# Git Credential Helper for GitRepublic |
||||
|
||||
This guide explains how to use the GitRepublic credential helper to authenticate git operations (clone, fetch, push) using your Nostr private key. |
||||
|
||||
## Overview |
||||
|
||||
GitRepublic uses NIP-98 HTTP Authentication for git operations. The credential helper automatically generates NIP-98 authentication tokens using your Nostr private key (nsec). |
||||
|
||||
## Setup |
||||
|
||||
### 1. Make the script executable |
||||
|
||||
```bash |
||||
chmod +x scripts/git-credential-nostr.js |
||||
``` |
||||
|
||||
### 2. Set your NOSTRGIT_SECRET_KEY_CLIENT environment variable |
||||
|
||||
**Important:** |
||||
- This is YOUR user private key (for authenticating your git operations) |
||||
- Never commit your private key to version control! |
||||
|
||||
```bash |
||||
# Option 1: Export in your shell session |
||||
export NOSTRGIT_SECRET_KEY_CLIENT="nsec1..." |
||||
|
||||
# Option 2: Add to your ~/.bashrc or ~/.zshrc (for persistent setup) |
||||
echo 'export NOSTRGIT_SECRET_KEY_CLIENT="nsec1..."' >> ~/.bashrc |
||||
source ~/.bashrc |
||||
|
||||
# Option 3: Use a hex private key (64 characters) |
||||
export NOSTRGIT_SECRET_KEY_CLIENT="<your-64-char-hex-private-key>" |
||||
|
||||
# Note: The script also supports NOSTR_PRIVATE_KEY and NSEC for backward compatibility |
||||
``` |
||||
|
||||
### 3. Configure git to use the credential helper |
||||
|
||||
#### Global configuration (for all GitRepublic repositories): |
||||
|
||||
```bash |
||||
git config --global credential.helper '!node /absolute/path/to/gitrepublic-web/scripts/git-credential-nostr.js' |
||||
``` |
||||
|
||||
#### Per-domain configuration (recommended): |
||||
|
||||
```bash |
||||
# Replace your-domain.com with your GitRepublic server domain |
||||
git config --global credential.https://your-domain.com.helper '!node /absolute/path/to/gitrepublic-web/scripts/git-credential-nostr.js' |
||||
``` |
||||
|
||||
#### Localhost configuration (for local development): |
||||
|
||||
If you're running GitRepublic on localhost, configure it like this: |
||||
|
||||
```bash |
||||
# For HTTP (http://localhost:5173) |
||||
git config --global credential.http://localhost.helper '!node /absolute/path/to/gitrepublic-web/scripts/git-credential-nostr.js' |
||||
|
||||
# For HTTPS (https://localhost:5173) - if using SSL locally |
||||
git config --global credential.https://localhost.helper '!node /absolute/path/to/gitrepublic-web/scripts/git-credential-nostr.js' |
||||
|
||||
# For a specific port (e.g., http://localhost:5173) |
||||
git config --global credential.http://localhost:5173.helper '!node /absolute/path/to/gitrepublic-web/scripts/git-credential-nostr.js' |
||||
``` |
||||
|
||||
**Note:** Git's credential helper matching is based on the hostname, so `localhost` will match `localhost:5173` automatically. If you need to match a specific port, include it in the configuration. |
||||
|
||||
#### Per-repository configuration: |
||||
|
||||
```bash |
||||
cd /path/to/your/repo |
||||
git config credential.helper '!node /absolute/path/to/gitrepublic-web/scripts/git-credential-nostr.js' |
||||
``` |
||||
|
||||
## Usage |
||||
|
||||
Once configured, git will automatically use the credential helper for authentication: |
||||
|
||||
### Clone a private repository |
||||
|
||||
```bash |
||||
# Remote server |
||||
git clone https://your-domain.com/npub1abc123.../my-repo.git |
||||
|
||||
# Localhost (local development) |
||||
# The git HTTP backend is at /api/git/ |
||||
git clone http://localhost:5173/api/git/npub1abc123.../my-repo.git |
||||
``` |
||||
|
||||
The credential helper will automatically generate a NIP-98 auth token using your NOSTRGIT_SECRET_KEY_CLIENT. |
||||
|
||||
## Localhost Setup Example |
||||
|
||||
Here's a complete example for setting up the credential helper with a local GitRepublic instance: |
||||
|
||||
### 1. Start your local GitRepublic server |
||||
|
||||
```bash |
||||
cd /path/to/gitrepublic-web |
||||
npm run dev |
||||
# Server runs on http://localhost:5173 |
||||
``` |
||||
|
||||
### 2. Set your NOSTRGIT_SECRET_KEY_CLIENT |
||||
|
||||
```bash |
||||
export NOSTRGIT_SECRET_KEY_CLIENT="nsec1..." |
||||
``` |
||||
|
||||
### 3. Configure git for localhost |
||||
|
||||
```bash |
||||
# Configure for localhost (any port) |
||||
git config --global credential.http://localhost.helper '!node /absolute/path/to/gitrepublic-web/scripts/git-credential-nostr.js' |
||||
|
||||
# Or for a specific port (e.g., 5173) |
||||
git config --global credential.http://localhost:5173.helper '!node /absolute/path/to/gitrepublic-web/scripts/git-credential-nostr.js' |
||||
``` |
||||
|
||||
### 4. Clone a repository |
||||
|
||||
```bash |
||||
# Replace npub1abc123... with the actual npub and my-repo with your repo name |
||||
git clone http://localhost:5173/api/git/npub1abc123.../my-repo.git |
||||
``` |
||||
|
||||
### 5. Add remote and push |
||||
|
||||
```bash |
||||
cd my-repo |
||||
|
||||
# If you need to add the remote manually |
||||
git remote add origin http://localhost:5173/api/git/npub1abc123.../my-repo.git |
||||
|
||||
# Make some changes and push |
||||
git add . |
||||
git commit -m "Initial commit" |
||||
git push -u origin main |
||||
``` |
||||
|
||||
**Note:** The git HTTP backend endpoint is `/api/git/`, so the full URL format is: |
||||
- `http://localhost:5173/api/git/{npub}/{repo-name}.git` |
||||
|
||||
### Push changes |
||||
|
||||
```bash |
||||
git push origin main |
||||
``` |
||||
|
||||
The credential helper will generate the appropriate NIP-98 auth token for push operations. |
||||
|
||||
### Fetch/Pull |
||||
|
||||
```bash |
||||
git fetch origin |
||||
git pull origin main |
||||
``` |
||||
|
||||
## How It Works |
||||
|
||||
1. When git needs credentials, it calls the credential helper with the repository URL |
||||
2. The helper reads your `NOSTRGIT_SECRET_KEY_CLIENT` environment variable (with fallbacks for backward compatibility) |
||||
3. It creates a NIP-98 authentication event signed with your private key |
||||
4. The signed event is base64-encoded and returned as the "password" |
||||
5. Git sends this in the `Authorization: Nostr <base64-event>` header |
||||
6. The GitRepublic server verifies the NIP-98 auth event and grants access |
||||
|
||||
## Troubleshooting |
||||
|
||||
### Error: NOSTRGIT_SECRET_KEY_CLIENT environment variable is not set |
||||
|
||||
Make sure you've exported the NOSTRGIT_SECRET_KEY_CLIENT variable: |
||||
```bash |
||||
export NOSTRGIT_SECRET_KEY_CLIENT="nsec1..." |
||||
``` |
||||
|
||||
**Note:** The script also supports `NOSTR_PRIVATE_KEY` and `NSEC` for backward compatibility, but `NOSTRGIT_SECRET_KEY_CLIENT` is the preferred name. |
||||
|
||||
### Error: Invalid nsec format |
||||
|
||||
- Ensure your nsec starts with `nsec1` (bech32 encoded) |
||||
- Or use a 64-character hex private key |
||||
- Check that the key is not corrupted or truncated |
||||
|
||||
### Authentication fails |
||||
|
||||
- Verify your private key matches the public key that has access to the repository |
||||
- Check that the repository URL is correct |
||||
- Ensure your key has maintainer permissions for push operations |
||||
|
||||
### Push operations fail |
||||
|
||||
Push operations require POST authentication. The credential helper automatically detects push operations (when the path contains `git-receive-pack`) and generates a POST auth event. If you still have issues: |
||||
|
||||
1. Verify you have maintainer permissions for the repository |
||||
2. Check that branch protection rules allow your push |
||||
3. Ensure your NOSTRGIT_SECRET_KEY_CLIENT is correctly set |
||||
|
||||
## Security Best Practices |
||||
|
||||
1. **Never commit your NOSTRGIT_SECRET_KEY_CLIENT to version control** |
||||
- Add `NOSTRGIT_SECRET_KEY_CLIENT` to your `.gitignore` if you store it in a file |
||||
- Use environment variables instead of hardcoding |
||||
- **Important:** This is YOUR user key for client-side operations |
||||
|
||||
2. **Use per-domain configuration** |
||||
- This limits the credential helper to only GitRepublic domains |
||||
- Prevents accidental credential leaks to other services |
||||
|
||||
3. **Protect your private key** |
||||
- Use file permissions: `chmod 600 ~/.nostr-key` (if storing in a file) |
||||
- Consider using a key management service for production |
||||
|
||||
4. **Rotate keys if compromised** |
||||
- If your NOSTR_PRIVATE_KEY is ever exposed, generate a new key pair |
||||
- Update repository maintainer lists with your new public key |
||||
|
||||
## Alternative: Manual Authentication |
||||
|
||||
If you prefer not to use the credential helper, you can manually generate NIP-98 auth tokens, but this is not recommended for regular use as it's cumbersome. |
||||
|
||||
## See Also |
||||
|
||||
- [NIP-98 Specification](https://github.com/nostr-protocol/nips/blob/master/98.md) |
||||
- [Git Credential Helper Documentation](https://git-scm.com/docs/gitcredentials) |
||||
@ -0,0 +1,215 @@
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env node
|
||||
/** |
||||
* Git credential helper for GitRepublic using NIP-98 authentication |
||||
*
|
||||
* This script implements the git credential helper protocol to automatically |
||||
* generate NIP-98 authentication tokens for git operations. |
||||
*
|
||||
* Usage: |
||||
* 1. Make it executable: chmod +x scripts/git-credential-nostr.js |
||||
* 2. Configure git: |
||||
* git config --global credential.helper '!node /path/to/gitrepublic-web/scripts/git-credential-nostr.js' |
||||
* 3. Or for a specific domain: |
||||
* git config --global credential.https://your-domain.com.helper '!node /path/to/gitrepublic-web/scripts/git-credential-nostr.js'
|
||||
*
|
||||
* Environment variables: |
||||
* NOSTRGIT_SECRET_KEY_CLIENT - Your Nostr private key (nsec format or hex) for client-side git operations |
||||
*
|
||||
* Security: Keep your NOSTRGIT_SECRET_KEY_CLIENT secure and never commit it to version control! |
||||
*/ |
||||
|
||||
import { createHash } from 'crypto'; |
||||
import { getEventHash, signEvent, getPublicKey } from 'nostr-tools'; |
||||
import { decode } from 'nostr-tools/nip19'; |
||||
|
||||
// NIP-98 auth event kind
|
||||
const KIND_NIP98_AUTH = 27235; |
||||
|
||||
/** |
||||
* Read input from stdin (git credential helper protocol) |
||||
*/ |
||||
function readInput() { |
||||
const chunks = []; |
||||
process.stdin.setEncoding('utf8'); |
||||
|
||||
return new Promise((resolve) => { |
||||
process.stdin.on('readable', () => { |
||||
let chunk; |
||||
while ((chunk = process.stdin.read()) !== null) { |
||||
chunks.push(chunk); |
||||
} |
||||
}); |
||||
|
||||
process.stdin.on('end', () => { |
||||
const input = chunks.join(''); |
||||
const lines = input.trim().split('\n'); |
||||
const data = {}; |
||||
|
||||
for (const line of lines) { |
||||
if (!line) continue; |
||||
const [key, ...valueParts] = line.split('='); |
||||
if (key && valueParts.length > 0) { |
||||
data[key] = valueParts.join('='); |
||||
} |
||||
} |
||||
|
||||
resolve(data); |
||||
}); |
||||
}); |
||||
} |
||||
|
||||
/** |
||||
* Normalize URL for NIP-98 (remove trailing slashes, ensure consistent format) |
||||
*/ |
||||
function normalizeUrl(url) { |
||||
try { |
||||
const parsed = new URL(url); |
||||
// Remove trailing slash from pathname
|
||||
parsed.pathname = parsed.pathname.replace(/\/$/, ''); |
||||
return parsed.toString(); |
||||
} catch { |
||||
return url; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Calculate SHA256 hash of request body |
||||
*/ |
||||
function calculateBodyHash(body) { |
||||
if (!body) return null; |
||||
const buffer = Buffer.from(body, 'utf-8'); |
||||
return createHash('sha256').update(buffer).digest('hex'); |
||||
} |
||||
|
||||
/** |
||||
* Create and sign a NIP-98 authentication event |
||||
*/ |
||||
function createNIP98AuthEvent(privateKey, url, method, bodyHash = null) { |
||||
const pubkey = getPublicKey(privateKey); |
||||
const tags = [ |
||||
['u', normalizeUrl(url)], |
||||
['method', method.toUpperCase()] |
||||
]; |
||||
|
||||
if (bodyHash) { |
||||
tags.push(['payload', bodyHash]); |
||||
} |
||||
|
||||
const event = { |
||||
kind: KIND_NIP98_AUTH, |
||||
pubkey, |
||||
created_at: Math.floor(Date.now() / 1000), |
||||
content: '', |
||||
tags |
||||
}; |
||||
|
||||
// Sign the event
|
||||
event.id = getEventHash(event); |
||||
event.sig = signEvent(event, privateKey); |
||||
|
||||
return event; |
||||
} |
||||
|
||||
/** |
||||
* Main credential helper logic |
||||
*/ |
||||
async function main() { |
||||
try { |
||||
// Read input from git
|
||||
const input = await readInput(); |
||||
|
||||
// Get command (get, store, erase)
|
||||
const command = process.argv[2] || 'get'; |
||||
|
||||
// For 'get' command, generate credentials
|
||||
if (command === 'get') { |
||||
// Get private key from environment variable
|
||||
// Support NOSTRGIT_SECRET_KEY_CLIENT (preferred), with fallbacks for backward compatibility
|
||||
const nsec = process.env.NOSTRGIT_SECRET_KEY_CLIENT || process.env.NOSTR_PRIVATE_KEY || process.env.NSEC; |
||||
if (!nsec) { |
||||
console.error('Error: NOSTRGIT_SECRET_KEY_CLIENT environment variable is not set'); |
||||
console.error('Set it with: export NOSTRGIT_SECRET_KEY_CLIENT="nsec1..." or NOSTRGIT_SECRET_KEY_CLIENT="<hex-key>"'); |
||||
process.exit(1); |
||||
} |
||||
|
||||
// Parse private key (handle both nsec and hex formats)
|
||||
let privateKey; |
||||
if (nsec.startsWith('nsec')) { |
||||
try { |
||||
const decoded = decode(nsec); |
||||
if (decoded.type === 'nsec') { |
||||
privateKey = decoded.data; |
||||
} else { |
||||
throw new Error('Invalid nsec format - decoded type is not nsec'); |
||||
} |
||||
} catch (err) { |
||||
console.error('Error decoding nsec:', err.message); |
||||
process.exit(1); |
||||
} |
||||
} else { |
||||
// Assume hex format (32 bytes = 64 hex characters)
|
||||
if (nsec.length !== 64) { |
||||
console.error('Error: Hex private key must be 64 characters (32 bytes)'); |
||||
process.exit(1); |
||||
} |
||||
privateKey = nsec; |
||||
} |
||||
|
||||
// Extract URL components from input
|
||||
const protocol = input.protocol || 'https'; |
||||
const host = input.host; |
||||
const path = input.path || ''; |
||||
|
||||
if (!host) { |
||||
console.error('Error: No host specified in credential request'); |
||||
process.exit(1); |
||||
} |
||||
|
||||
// Build full URL
|
||||
const url = `${protocol}://${host}${path}`; |
||||
|
||||
// Determine HTTP method based on git operation
|
||||
// Git credential helper doesn't know the HTTP method, but we can infer it:
|
||||
// - If path contains 'git-receive-pack', it's a push (POST)
|
||||
// - Otherwise, it's likely a fetch/clone (GET)
|
||||
// Note: For initial info/refs requests, git uses GET, so we default to GET
|
||||
// For actual push operations, git will make POST requests to git-receive-pack
|
||||
// The server will validate the method matches, so we need to handle this carefully
|
||||
const method = path.includes('git-receive-pack') ? 'POST' : 'GET'; |
||||
|
||||
// Create and sign NIP-98 auth event
|
||||
const authEvent = createNIP98AuthEvent(privateKey, url, method); |
||||
|
||||
// Encode event as base64
|
||||
const eventJson = JSON.stringify(authEvent); |
||||
const base64Event = Buffer.from(eventJson, 'utf-8').toString('base64'); |
||||
|
||||
// Output credentials in git credential helper format
|
||||
// Username can be anything (git doesn't use it for NIP-98)
|
||||
// Password is the base64-encoded signed event
|
||||
console.log('username=nostr'); |
||||
console.log(`password=${base64Event}`); |
||||
|
||||
} else if (command === 'store') { |
||||
// For 'store', we don't need to do anything (credentials are generated on-demand)
|
||||
// Just exit successfully
|
||||
process.exit(0); |
||||
} else if (command === 'erase') { |
||||
// For 'erase', we don't need to do anything
|
||||
// Just exit successfully
|
||||
process.exit(0); |
||||
} else { |
||||
console.error(`Error: Unknown command: ${command}`); |
||||
process.exit(1); |
||||
} |
||||
} catch (error) { |
||||
console.error('Error:', error.message); |
||||
process.exit(1); |
||||
} |
||||
} |
||||
|
||||
// Run main function
|
||||
main().catch((error) => { |
||||
console.error('Fatal error:', error); |
||||
process.exit(1); |
||||
}); |
||||
Loading…
Reference in new issue