Browse Source
Nostr-Signature: 5a14564a2b82b3b4ee4e21d28e7b362cc82e3c27eac38691c85f46480b100cf1 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc d1369aff4db39f61aba5f0954c0c8ba92df4aec96f1fab7cc5af51d1b0667734f35dec99363290de2c248b7074369f592b238b1b66987e09f267062073167131main
10 changed files with 388 additions and 700 deletions
@ -0,0 +1,21 @@ |
|||||||
|
MIT License for GitRepublic Web |
||||||
|
|
||||||
|
Copyright (c) 2026 GitCitadel LLC |
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy |
||||||
|
of this software and associated documentation files (the "Software"), to deal |
||||||
|
in the Software without restriction, including without limitation the rights |
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
||||||
|
copies of the Software, and to permit persons to whom the Software is |
||||||
|
furnished to do so, subject to the following conditions: |
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all |
||||||
|
copies or substantial portions of the Software. |
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
||||||
|
SOFTWARE. |
||||||
@ -1,263 +0,0 @@ |
|||||||
# 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 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="nsec1..." |
|
||||||
|
|
||||||
# Option 2: Add to your ~/.bashrc or ~/.zshrc (for persistent setup) |
|
||||||
echo 'export NOSTRGIT_SECRET_KEY="nsec1..."' >> ~/.bashrc |
|
||||||
source ~/.bashrc |
|
||||||
|
|
||||||
# Option 3: Use a hex private key (64 characters) |
|
||||||
export NOSTRGIT_SECRET_KEY="<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 |
|
||||||
|
|
||||||
**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): |
|
||||||
|
|
||||||
```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. |
|
||||||
|
|
||||||
## 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 |
|
||||||
|
|
||||||
```bash |
|
||||||
export NOSTRGIT_SECRET_KEY="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 gitrepublic-web http://localhost:5173/api/git/npub1abc123.../my-repo.git |
|
||||||
|
|
||||||
# Make some changes and push |
|
||||||
git add . |
|
||||||
git commit -m "Initial commit" |
|
||||||
git push -u gitrepublic-web 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 gitrepublic-web main |
|
||||||
``` |
|
||||||
|
|
||||||
The credential helper will generate the appropriate NIP-98 auth token for push operations. |
|
||||||
|
|
||||||
### Fetch/Pull |
|
||||||
|
|
||||||
```bash |
|
||||||
git fetch gitrepublic-web |
|
||||||
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 |
|
||||||
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 |
|
||||||
|
|
||||||
### Error: NOSTRGIT_SECRET_KEY environment variable is not set |
|
||||||
|
|
||||||
Make sure you've exported the NOSTRGIT_SECRET_KEY variable: |
|
||||||
```bash |
|
||||||
export NOSTRGIT_SECRET_KEY="nsec1..." |
|
||||||
``` |
|
||||||
|
|
||||||
**Note:** The script also supports `NOSTR_PRIVATE_KEY` and `NSEC` for backward compatibility, but `NOSTRGIT_SECRET_KEY` 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 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 |
|
||||||
|
|
||||||
1. **Never commit your NOSTRGIT_SECRET_KEY to version control** |
|
||||||
- Add `NOSTRGIT_SECRET_KEY` 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,123 @@ |
|||||||
|
# Publishing GitRepublic CLI to npm |
||||||
|
|
||||||
|
## Prerequisites |
||||||
|
|
||||||
|
1. **Create npm account** (if you don't have one): |
||||||
|
- Visit https://www.npmjs.com/signup |
||||||
|
- Or run: `npm adduser` |
||||||
|
|
||||||
|
2. **Login to npm**: |
||||||
|
```bash |
||||||
|
npm login |
||||||
|
``` |
||||||
|
Enter your username, password, and email. |
||||||
|
|
||||||
|
3. **Check if package name is available**: |
||||||
|
```bash |
||||||
|
npm view gitrepublic-cli |
||||||
|
``` |
||||||
|
If it returns 404, the name is available. If it shows package info, the name is taken. |
||||||
|
|
||||||
|
## Publishing Steps |
||||||
|
|
||||||
|
### 1. Update version (if needed) |
||||||
|
|
||||||
|
```bash |
||||||
|
# Patch version (1.0.0 -> 1.0.1) |
||||||
|
npm version patch |
||||||
|
|
||||||
|
# Minor version (1.0.0 -> 1.1.0) |
||||||
|
npm version minor |
||||||
|
|
||||||
|
# Major version (1.0.0 -> 2.0.0) |
||||||
|
npm version major |
||||||
|
``` |
||||||
|
|
||||||
|
Or manually edit `package.json` and update the version field. |
||||||
|
|
||||||
|
### 2. Verify package contents |
||||||
|
|
||||||
|
```bash |
||||||
|
# See what will be published |
||||||
|
npm pack --dry-run |
||||||
|
``` |
||||||
|
|
||||||
|
This shows the files that will be included (based on `files` field in package.json). |
||||||
|
|
||||||
|
### 3. Test the package locally |
||||||
|
|
||||||
|
```bash |
||||||
|
# Pack the package |
||||||
|
npm pack |
||||||
|
|
||||||
|
# Install it locally to test |
||||||
|
npm install -g ./gitrepublic-cli-1.0.0.tgz |
||||||
|
|
||||||
|
# Test the commands |
||||||
|
gitrepublic-path --credential |
||||||
|
gitrepublic-path --hook |
||||||
|
``` |
||||||
|
|
||||||
|
### 4. Publish to npm |
||||||
|
|
||||||
|
```bash |
||||||
|
cd gitrepublic-cli |
||||||
|
npm publish |
||||||
|
``` |
||||||
|
|
||||||
|
For scoped packages (if you want `@your-org/gitrepublic-cli`): |
||||||
|
```bash |
||||||
|
npm publish --access public |
||||||
|
``` |
||||||
|
|
||||||
|
### 5. Verify publication |
||||||
|
|
||||||
|
```bash |
||||||
|
# Check on npm website |
||||||
|
# Visit: https://www.npmjs.com/package/gitrepublic-cli |
||||||
|
|
||||||
|
# Or via command line |
||||||
|
npm view gitrepublic-cli |
||||||
|
``` |
||||||
|
|
||||||
|
## After Publishing |
||||||
|
|
||||||
|
Users can now install via: |
||||||
|
```bash |
||||||
|
npm install -g gitrepublic-cli |
||||||
|
``` |
||||||
|
|
||||||
|
## Updating the Package |
||||||
|
|
||||||
|
1. Make your changes |
||||||
|
2. Update version: `npm version patch` (or minor/major) |
||||||
|
3. Publish: `npm publish` |
||||||
|
|
||||||
|
## Important Notes |
||||||
|
|
||||||
|
- **Package name**: `gitrepublic-cli` must be unique on npm. If taken, use a scoped name like `@your-org/gitrepublic-cli` |
||||||
|
- **Version**: Follow semantic versioning (semver) |
||||||
|
- **Files**: Only files listed in `files` array (or not in `.npmignore`) will be published |
||||||
|
- **Unpublishing**: You can unpublish within 72 hours, but it's discouraged. Use deprecation instead: |
||||||
|
```bash |
||||||
|
npm deprecate gitrepublic-cli@1.0.0 "Use version 1.0.1 instead" |
||||||
|
``` |
||||||
|
|
||||||
|
## Troubleshooting |
||||||
|
|
||||||
|
### "Package name already exists" |
||||||
|
- The name `gitrepublic-cli` is taken |
||||||
|
- Options: |
||||||
|
1. Use a scoped package: Change name to `@your-org/gitrepublic-cli` in package.json |
||||||
|
2. Choose a different name |
||||||
|
3. Contact the owner of the existing package |
||||||
|
|
||||||
|
### "You do not have permission" |
||||||
|
- Make sure you're logged in: `npm whoami` |
||||||
|
- If using scoped package, add `--access public` flag |
||||||
|
|
||||||
|
### "Invalid package name" |
||||||
|
- Package names must be lowercase |
||||||
|
- Can contain hyphens and underscores |
||||||
|
- Cannot start with dot or underscore |
||||||
|
- Max 214 characters |
||||||
@ -1,402 +0,0 @@ |
|||||||
#!/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); |
|
||||||
}); |
|
||||||
Loading…
Reference in new issue