Browse Source

git credential helper

simplify repo ownership verification
main
Silberengel 4 weeks ago
parent
commit
bea2340bb2
  1. 5
      README.md
  2. 226
      docs/GIT_CREDENTIAL_HELPER.md
  3. 215
      scripts/git-credential-nostr.js
  4. 3
      src/lib/services/git/commit-signer.ts
  5. 65
      src/lib/services/git/file-manager.ts
  6. 92
      src/lib/services/git/repo-manager.ts
  7. 11
      src/lib/services/nostr/maintainer-service.ts
  8. 69
      src/lib/services/nostr/ownership-transfer-service.ts
  9. 172
      src/lib/services/nostr/repo-verification.ts
  10. 2
      src/routes/api/repos/[npub]/[repo]/file/+server.ts
  11. 130
      src/routes/api/repos/[npub]/[repo]/verify/+server.ts
  12. 29
      src/routes/signup/+page.svelte

5
README.md

@ -55,7 +55,6 @@ See [ARCHITECTURE_FAQ.md](./docs/ARCHITECTURE_FAQ.md) for answers to common arch @@ -55,7 +55,6 @@ See [ARCHITECTURE_FAQ.md](./docs/ARCHITECTURE_FAQ.md) for answers to common arch
- Signatures embedded in commit messages as trailers
- **Web UI**: Uses NIP-07 browser extension (secure, keys never leave browser)
- **Git Operations**: Uses NIP-98 HTTP authentication (ephemeral signed events)
- **Server-side**: Optional `NOSTRGIT_SECRET_KEY` environment variable for automated signing
- ⚠ **Security Note**: Never send private keys (nsec) in API requests. Use NIP-07 for web UI or NIP-98 for git operations.
## Nostr Event Kinds Used
@ -124,7 +123,7 @@ These are not part of any NIP but are used by this application: @@ -124,7 +123,7 @@ These are not part of any NIP but are used by this application:
- Creates a bare git repository at `/repos/{npub}/{repo-name}.git`
- Fetches the self-transfer event for ownership verification
- Creates initial commit with `.nostr-ownership-transfer` file containing the self-transfer event
- Creates `.nostr-verification` file with the announcement event (for backward compatibility)
- Creates `.nostr-announcement` file with the full signed announcement event JSON
- If repository has `clone` tags pointing to other remotes, syncs from those remotes
3. **Repository Access**:
@ -352,7 +351,7 @@ See `docs/SECURITY.md` and `docs/SECURITY_IMPLEMENTATION.md` for detailed inform @@ -352,7 +351,7 @@ See `docs/SECURITY.md` and `docs/SECURITY_IMPLEMENTATION.md` for detailed inform
## Environment Variables
- `NOSTRGIT_SECRET_KEY`: Server's nsec (bech32 or hex) for signing repo announcements and initial commits (optional)
- `NOSTRGIT_SECRET_KEY_CLIENT`: User's nsec (bech32 or hex) for client-side git operations via credential helper (optional)
- `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`)

226
docs/GIT_CREDENTIAL_HELPER.md

@ -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)

215
scripts/git-credential-nostr.js

@ -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);
});

3
src/lib/services/git/commit-signer.ts

@ -140,7 +140,8 @@ export function createCommitSignatureEvent( @@ -140,7 +140,8 @@ export function createCommitSignatureEvent(
* - nsec/hex: Direct key signing (server-side ONLY, via environment variables)
*
* SECURITY WARNING: nsecKey should NEVER be sent from client requests.
* It should only be used server-side via environment variables (e.g., NOSTRGIT_SECRET_KEY).
* It should only be used server-side via environment variables for automated operations.
* Note: The server should NOT sign commits on behalf of users - commits should be signed by their authors.
*
* @param commitMessage - The commit message to sign
* @param authorName - Author name

65
src/lib/services/git/file-manager.ts

@ -1371,4 +1371,69 @@ export class FileManager { @@ -1371,4 +1371,69 @@ export class FileManager {
return [];
}
}
/**
* Get the current owner from the most recent announcement file in the repository
* Ownership is determined by the most recent announcement file checked into the git repo
*
* @param npub - Repository owner npub (for path construction)
* @param repoName - The repository name
* @returns The current owner pubkey from the most recent announcement file, or null if not found
*/
async getCurrentOwnerFromRepo(npub: string, repoName: string): Promise<string | null> {
try {
const { VERIFICATION_FILE_PATH } = await import('../nostr/repo-verification.js');
if (!this.repoExists(npub, repoName)) {
return null;
}
const repoPath = this.getRepoPath(npub, repoName);
const git: SimpleGit = simpleGit(repoPath);
// Get git log for the announcement file, most recent first
// Use --all to check all branches, --reverse to get chronological order
const logOutput = await git.raw(['log', '--all', '--format=%H', '--reverse', '--', VERIFICATION_FILE_PATH]);
const commitHashes = logOutput.trim().split('\n').filter(Boolean);
if (commitHashes.length === 0) {
return null; // No announcement file in repo
}
// Get the most recent announcement file content (last commit in the list)
const mostRecentCommit = commitHashes[commitHashes.length - 1];
const announcementFile = await this.getFileContent(npub, repoName, VERIFICATION_FILE_PATH, mostRecentCommit);
// Parse the announcement event from the file
let announcementEvent: any;
try {
announcementEvent = JSON.parse(announcementFile.content);
} catch (parseError) {
logger.warn({ error: parseError, npub, repoName, commit: mostRecentCommit }, 'Failed to parse announcement file JSON');
return null;
}
// Validate the announcement event to prevent fake announcements
const { validateAnnouncementEvent } = await import('../nostr/repo-verification.js');
const validation = validateAnnouncementEvent(announcementEvent, repoName);
if (!validation.valid) {
logger.warn({
error: validation.error,
npub,
repoName,
commit: mostRecentCommit,
eventId: announcementEvent.id,
eventPubkey: announcementEvent.pubkey?.substring(0, 16) + '...'
}, 'Announcement file validation failed - possible fake announcement');
return null;
}
// Return the pubkey from the validated announcement
return announcementEvent.pubkey;
} catch (error) {
logger.error({ error, npub, repoName }, 'Error getting current owner from repo');
return null;
}
}
}

92
src/lib/services/git/repo-manager.ts

@ -144,7 +144,7 @@ export class RepoManager { @@ -144,7 +144,7 @@ export class RepoManager {
const git = simpleGit();
await git.init(['--bare', repoPath.fullPath]);
// Create verification file and self-transfer event in the repository
// Create announcement file and self-transfer event in the repository
await this.createVerificationFile(repoPath.fullPath, event, selfTransferEvent);
// If there are other clone URLs, sync from them after creating the repo
@ -154,7 +154,7 @@ export class RepoManager { @@ -154,7 +154,7 @@ export class RepoManager {
} else if (isExistingRepo && selfTransferEvent) {
// For existing repos, we might want to add the self-transfer event
// But we should be careful not to overwrite existing history
// For now, we'll just ensure the verification file exists
// For now, we'll just ensure the announcement file exists
// The self-transfer event should already be published to relays
logger.info({ repoPath: repoPath.fullPath }, 'Existing repo - self-transfer event should be published to relays');
}
@ -660,12 +660,12 @@ export class RepoManager { @@ -660,12 +660,12 @@ export class RepoManager {
throw new Error('Repository clone completed but repository path does not exist');
}
// Create verification file with the announcement (non-blocking - repo is usable without it)
// Create announcement file with the signed announcement event (non-blocking - repo is usable without it)
try {
await this.createVerificationFile(repoPath, announcementEvent);
} catch (verifyError) {
// Verification file creation is optional - log but don't fail
logger.warn({ error: verifyError, npub, repoName }, 'Failed to create verification file, but repository is usable');
// Announcement file creation is optional - log but don't fail
logger.warn({ error: verifyError, npub, repoName }, 'Failed to create announcement file, but repository is usable');
}
logger.info({ npub, repoName }, 'Successfully fetched repository on-demand');
@ -749,8 +749,8 @@ export class RepoManager { @@ -749,8 +749,8 @@ export class RepoManager {
}
/**
* Create verification file and self-transfer event in a new repository
* This proves the repository is owned by the announcement author
* Create announcement file and self-transfer event in a new repository
* The announcement file contains the full signed announcement event JSON, proving ownership
*/
private async createVerificationFile(repoPath: string, event: NostrEvent, selfTransferEvent?: NostrEvent): Promise<void> {
try {
@ -769,12 +769,43 @@ export class RepoManager { @@ -769,12 +769,43 @@ export class RepoManager {
const git: SimpleGit = simpleGit();
await git.clone(repoPath, workDir);
// Generate verification file content
const verificationContent = generateVerificationFile(event, event.pubkey);
// Extract announcement file content from client-signed event (kind 1642)
// The client creates and signs this event separately, with an 'e' tag pointing to the announcement
// The content is just the full announcement event JSON - simpler than a custom verification format
let announcementFileContent: string | null = null;
// Write verification file
const verificationPath = join(workDir, VERIFICATION_FILE_PATH);
writeFileSync(verificationPath, verificationContent, 'utf-8');
try {
const { NostrClient } = await import('../nostr/nostr-client.js');
const { DEFAULT_NOSTR_RELAYS } = await import('../../config.js');
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
// Look for a kind 1642 event that references this announcement
const announcementFileEvents = await nostrClient.fetchEvents([
{
kinds: [1642], // Announcement file event kind
authors: [event.pubkey],
'#e': [event.id], // References this announcement
limit: 1
}
]);
if (announcementFileEvents.length > 0) {
// Extract announcement file content from the client-signed event
announcementFileContent = announcementFileEvents[0].content;
logger.info({ repoPath, announcementFileEventId: announcementFileEvents[0].id }, 'Using client-signed announcement file');
}
} catch (err) {
logger.warn({ error: err, repoPath }, 'Failed to fetch announcement file event, generating server-side');
}
// If client didn't provide announcement file, generate it from the announcement event
if (!announcementFileContent) {
announcementFileContent = generateVerificationFile(event, event.pubkey);
}
// Write announcement file (contains the full signed announcement event JSON)
const announcementPath = join(workDir, VERIFICATION_FILE_PATH);
writeFileSync(announcementPath, announcementFileContent, 'utf-8');
// If self-transfer event is provided, include it in the commit
const filesToAdd = [VERIFICATION_FILE_PATH];
@ -805,31 +836,12 @@ export class RepoManager { @@ -805,31 +836,12 @@ export class RepoManager {
// Use the event timestamp for commit date
const commitDate = new Date(event.created_at * 1000).toISOString();
let commitMessage = selfTransferEvent
? 'Add Nostr repository verification and initial ownership proof'
: 'Add Nostr repository verification file';
// Sign commit if nsec key is provided (from environment or event)
// Note: For initial commits, we might not have the user's nsec, so this is optional
const nsecKey = process.env.NOSTRGIT_SECRET_KEY;
if (nsecKey) {
try {
const { createGitCommitSignature } = await import('./commit-signer.js');
const { signedMessage } = await createGitCommitSignature(
commitMessage,
'Nostr',
`${event.pubkey}@nostr`,
{
nsecKey,
timestamp: event.created_at
}
);
commitMessage = signedMessage;
} catch (err) {
logger.warn({ error: err, repoPath }, 'Failed to sign initial commit');
// Continue without signature if signing fails
}
}
const commitMessage = selfTransferEvent
? 'Add Nostr repository announcement and initial ownership proof'
: 'Add Nostr repository announcement';
// Note: Initial commits are unsigned. The repository owner can sign their own commits
// when they make changes. The server should never sign commits on behalf of users.
await workGit.commit(commitMessage, filesToAdd, {
'--author': `Nostr <${event.pubkey}@nostr>`,
@ -846,8 +858,8 @@ export class RepoManager { @@ -846,8 +858,8 @@ export class RepoManager {
// Clean up
await rm(workDir, { recursive: true, force: true });
} catch (error) {
logger.error({ error, repoPath }, 'Failed to create verification file');
// Don't throw - verification file creation is important but shouldn't block provisioning
logger.error({ error, repoPath }, 'Failed to create announcement file');
// Don't throw - announcement file creation is important but shouldn't block provisioning
}
}
@ -861,7 +873,7 @@ export class RepoManager { @@ -861,7 +873,7 @@ export class RepoManager {
}
/**
* Check if a repository already has a verification file
* Check if a repository already has an announcement file
* Used to determine if this is a truly new repo or an existing one being added
*/
async hasVerificationFile(repoPath: string): Promise<boolean> {

11
src/lib/services/nostr/maintainer-service.ts

@ -78,11 +78,12 @@ export class MaintainerService { @@ -78,11 +78,12 @@ export class MaintainerService {
// Check if repo is private
const isPrivate = this.isPrivateRepo(announcement);
// Check for ownership transfers - get current owner
const currentOwner = await this.ownershipTransferService.getCurrentOwner(
announcement.pubkey,
repoId
);
// Get current owner from the most recent announcement file in the repo
// Ownership is determined by what's checked into the git repository, not Nostr events
const { nip19 } = await import('nostr-tools');
const npub = nip19.npubEncode(announcement.pubkey);
const { fileManager } = await import('../../services/service-registry.js');
const currentOwner = await fileManager.getCurrentOwnerFromRepo(npub, repoId) || announcement.pubkey;
const maintainers: string[] = [currentOwner]; // Current owner is always a maintainer

69
src/lib/services/nostr/ownership-transfer-service.ts

@ -30,10 +30,79 @@ export class OwnershipTransferService { @@ -30,10 +30,79 @@ export class OwnershipTransferService {
this.nostrClient = new NostrClient(relays);
}
/**
* Get the current owner of a repository from the most recent announcement file in the git repo
* Ownership is determined by the most recent announcement file checked into the repository
*
* @param npub - Repository owner npub (for path construction)
* @param repoId - The repository identifier (d-tag)
* @returns The current owner pubkey from the most recent announcement file in the repo
*/
async getCurrentOwnerFromRepo(npub: string, repoId: string): Promise<string | null> {
try {
const { fileManager } = await import('../services/service-registry.js');
return await fileManager.getCurrentOwnerFromRepo(npub, repoId);
} catch (error) {
logger.error({ error, npub, repoId }, 'Error getting current owner from repo');
return null;
}
}
/**
* Get owners for all clone URLs from the repository announcement
* Each clone can have its own owner determined by the most recent announcement file in that clone
*
* @param announcementEvent - The repository announcement event
* @returns Map of clone URL to owner pubkey (or null if clone doesn't exist or has no announcement file)
*/
async getOwnersForAllClones(announcementEvent: NostrEvent): Promise<Map<string, string | null>> {
const owners = new Map<string, string | null>();
// Extract clone URLs from announcement
const cloneUrls: string[] = [];
for (const tag of announcementEvent.tags) {
if (tag[0] === 'clone') {
for (let i = 1; i < tag.length; i++) {
const url = tag[i];
if (url && typeof url === 'string') {
cloneUrls.push(url);
}
}
}
}
// Get the repo identifier from the announcement
const dTag = announcementEvent.tags.find(t => t[0] === 'd')?.[1];
if (!dTag) {
return owners; // Can't determine repo name
}
// Check the local GitRepublic clone (if it exists)
try {
const { nip19 } = await import('nostr-tools');
const npub = nip19.npubEncode(announcementEvent.pubkey);
const { fileManager } = await import('../services/service-registry.js');
const localOwner = await fileManager.getCurrentOwnerFromRepo(npub, dTag);
const localUrl = cloneUrls.find(url => url.includes(npub) || url.includes(announcementEvent.pubkey));
if (localUrl) {
owners.set(localUrl, localOwner);
}
} catch (error) {
logger.warn({ error, announcementEvent: announcementEvent.id }, 'Failed to get owner from local clone');
}
// For other clones (GitHub, GitLab, etc.), we'd need to fetch them first to check their announcement files
// This is a future enhancement - for now we only check the local GitRepublic clone
return owners;
}
/**
* Get the current owner of a repository, checking for ownership transfers
* The initial ownership is proven by a self-transfer event (from owner to themselves)
*
* @deprecated Use getCurrentOwnerFromRepo instead - ownership is now determined by the most recent announcement file in the repo
* @param originalOwnerPubkey - The original owner from the repo announcement
* @param repoId - The repository identifier (d-tag)
* @returns The current owner pubkey (may be different from original if transferred)

172
src/lib/services/nostr/repo-verification.ts

@ -3,80 +3,178 @@ @@ -3,80 +3,178 @@
* Creates and verifies cryptographic proof linking repo announcements to git repos
*/
import { verifyEvent } from 'nostr-tools';
import { verifyEvent, getEventHash } from 'nostr-tools';
import { KIND } from '../../types/nostr.js';
import type { NostrEvent } from '../../types/nostr.js';
import { nip19 } from 'nostr-tools';
export interface VerificationFile {
eventId: string;
pubkey: string;
npub: string;
signature: string;
timestamp: number;
}
/**
* Generate a verification file content for a repository
* Generate announcement event file content for a repository
* This file should be committed to the repository to prove ownership
* We just save the full announcement event JSON - simpler and more complete than a custom format
*/
export function generateVerificationFile(
announcementEvent: NostrEvent,
ownerPubkey: string
): string {
const npub = nip19.npubEncode(ownerPubkey);
// Just return the full announcement event JSON - it's already signed and contains all needed info
return JSON.stringify(announcementEvent, null, 2) + '\n';
}
/**
* Validate that an event is a legitimate repository announcement
* Checks signature, kind, structure, and d-tag
*/
export function validateAnnouncementEvent(
event: NostrEvent,
expectedRepoName?: string
): { valid: boolean; error?: string } {
// Verify it's a valid Nostr event structure
if (!event.kind || !event.id || !event.sig || !event.pubkey || !event.created_at || !Array.isArray(event.tags)) {
return {
valid: false,
error: 'Invalid event structure: missing required fields'
};
}
// Verify it's actually a repository announcement (kind 30617)
if (event.kind !== KIND.REPO_ANNOUNCEMENT) {
return {
valid: false,
error: `Invalid event kind: expected ${KIND.REPO_ANNOUNCEMENT}, got ${event.kind}`
};
}
// Verify the event signature cryptographically
if (!verifyEvent(event)) {
return {
valid: false,
error: 'Event signature is invalid - event may be forged or corrupted'
};
}
// Verify the event ID matches the computed ID (prevents ID tampering)
const computedId = getEventHash(event);
if (computedId !== event.id) {
return {
valid: false,
error: 'Event ID does not match computed hash - event may be tampered with'
};
}
// Verify d-tag exists (required for repository announcements)
const dTag = event.tags.find(t => t[0] === 'd');
if (!dTag || !dTag[1]) {
return {
valid: false,
error: 'Missing d-tag (repository identifier) in announcement event'
};
}
// If expected repo name is provided, verify it matches
if (expectedRepoName && dTag[1] !== expectedRepoName) {
return {
valid: false,
error: `Repository name mismatch: expected '${expectedRepoName}', got '${dTag[1]}'`
};
}
// Verify pubkey is valid hex format (64 characters)
if (!/^[0-9a-f]{64}$/i.test(event.pubkey)) {
return {
valid: false,
error: 'Invalid pubkey format'
};
}
// Verify signature is valid hex format (128 characters)
if (!/^[0-9a-f]{128}$/i.test(event.sig)) {
return {
valid: false,
error: 'Invalid signature format'
};
}
const verification: VerificationFile = {
eventId: announcementEvent.id,
pubkey: ownerPubkey,
npub: npub,
signature: announcementEvent.sig,
timestamp: announcementEvent.created_at
// Verify created_at is reasonable (not in the future, not too old)
const now = Math.floor(Date.now() / 1000);
const eventTime = event.created_at;
if (eventTime > now + 60) {
return {
valid: false,
error: 'Event timestamp is in the future'
};
}
// Allow events up to 10 years old (reasonable for repository announcements)
if (eventTime < now - (10 * 365 * 24 * 60 * 60)) {
return {
valid: false,
error: 'Event timestamp is too old (more than 10 years)'
};
}
// Create a JSON file with clear formatting
return JSON.stringify(verification, null, 2) + '\n';
return { valid: true };
}
/**
* Verify that a repository announcement matches the verification file in the repo
* Verify that a repository announcement matches the file in the repo
* The file should contain the full announcement event JSON
*/
export function verifyRepositoryOwnership(
announcementEvent: NostrEvent,
verificationFileContent: string
announcementFileContent: string
): { valid: boolean; error?: string } {
try {
// Parse verification file
const verification: VerificationFile = JSON.parse(verificationFileContent);
// Parse the announcement event from the file
const fileEvent: NostrEvent = JSON.parse(announcementFileContent);
// First, validate the file event is a legitimate announcement
const fileValidation = validateAnnouncementEvent(fileEvent);
if (!fileValidation.valid) {
return {
valid: false,
error: `File event validation failed: ${fileValidation.error}`
};
}
// Validate the provided announcement event as well
const announcementValidation = validateAnnouncementEvent(announcementEvent);
if (!announcementValidation.valid) {
return {
valid: false,
error: `Provided announcement validation failed: ${announcementValidation.error}`
};
}
// Check that the event ID matches
if (verification.eventId !== announcementEvent.id) {
if (fileEvent.id !== announcementEvent.id) {
return {
valid: false,
error: 'Verification file event ID does not match announcement'
error: 'Announcement event ID does not match'
};
}
// Check that the pubkey matches
if (verification.pubkey !== announcementEvent.pubkey) {
if (fileEvent.pubkey !== announcementEvent.pubkey) {
return {
valid: false,
error: 'Verification file pubkey does not match announcement author'
error: 'Announcement event pubkey does not match'
};
}
// Verify the announcement event signature
if (!verifyEvent(announcementEvent)) {
// Check that the signature matches
if (fileEvent.sig !== announcementEvent.sig) {
return {
valid: false,
error: 'Announcement event signature is invalid'
error: 'Announcement event signature does not match'
};
}
// Verify the signature in the verification file matches the announcement
if (verification.signature !== announcementEvent.sig) {
// Check that the d-tag (repo name) matches
const fileDTag = fileEvent.tags.find(t => t[0] === 'd')?.[1];
const announcementDTag = announcementEvent.tags.find(t => t[0] === 'd')?.[1];
if (fileDTag !== announcementDTag) {
return {
valid: false,
error: 'Verification file signature does not match announcement'
error: 'Repository name (d-tag) does not match'
};
}
@ -84,12 +182,12 @@ export function verifyRepositoryOwnership( @@ -84,12 +182,12 @@ export function verifyRepositoryOwnership(
} catch (error) {
return {
valid: false,
error: `Failed to parse verification file: ${error instanceof Error ? error.message : String(error)}`
error: `Failed to parse announcement file: ${error instanceof Error ? error.message : String(error)}`
};
}
}
/**
* Get the path where the verification file should be stored
* Get the path where the announcement event file should be stored
*/
export const VERIFICATION_FILE_PATH = '.nostr-verification';
export const VERIFICATION_FILE_PATH = '.nostr-announcement';

2
src/routes/api/repos/[npub]/[repo]/file/+server.ts

@ -378,7 +378,7 @@ export const POST: RequestHandler = async ({ params, url, request }: { params: { @@ -378,7 +378,7 @@ export const POST: RequestHandler = async ({ params, url, request }: { params: {
}
// Note: useNIP07 is no longer used since signing happens client-side
// Explicitly ignore nsecKey from client requests - it's a security risk
// Server-side signing should use NOSTRGIT_SECRET_KEY environment variable instead
// Server-side signing is not recommended - commits should be signed by their authors
if (nsecKey) {
// Security: Log warning but never log the actual key value
const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';

130
src/routes/api/repos/[npub]/[repo]/verify/+server.ts

@ -45,82 +45,100 @@ export const GET: RequestHandler = createRepoGetHandler( @@ -45,82 +45,100 @@ export const GET: RequestHandler = createRepoGetHandler(
const announcement = events[0];
// Check for ownership transfer events (including self-transfer for initial ownership)
const repoTag = `${KIND.REPO_ANNOUNCEMENT}:${context.repoOwnerPubkey}:${context.repo}`;
const transferEvents = await nostrClient.fetchEvents([
{
kinds: [KIND.OWNERSHIP_TRANSFER],
'#a': [repoTag],
limit: 100
// Extract clone URLs from announcement
const cloneUrls: string[] = [];
for (const tag of announcement.tags) {
if (tag[0] === 'clone') {
for (let i = 1; i < tag.length; i++) {
const url = tag[i];
if (url && typeof url === 'string') {
cloneUrls.push(url);
}
}
]);
// Look for self-transfer event (initial ownership proof)
// Self-transfer: from owner to themselves, tagged with 'self-transfer'
const selfTransfer = transferEvents.find(event => {
const pTag = event.tags.find(t => t[0] === 'p');
let toPubkey = pTag?.[1];
// Decode npub if needed
if (toPubkey) {
try {
toPubkey = decodeNpubToHex(toPubkey) || toPubkey;
} catch {
// Assume it's already hex
}
}
return event.pubkey === context.repoOwnerPubkey &&
toPubkey === context.repoOwnerPubkey;
});
// Verify ownership for each clone separately
// Ownership is determined by the most recent announcement file checked into each clone
const cloneVerifications: Array<{ url: string; verified: boolean; ownerPubkey: string | null; error?: string }> = [];
// Verify ownership - prefer self-transfer event, fall back to verification file
let verified = false;
let verificationMethod = '';
let verificationError: string | undefined;
// First, verify the local GitRepublic clone (if it exists)
let localVerified = false;
let localOwner: string | null = null;
let localError: string | undefined;
if (selfTransfer) {
// Verify self-transfer event signature
const { verifyEvent } = await import('nostr-tools');
if (verifyEvent(selfTransfer)) {
verified = true;
verificationMethod = 'self-transfer-event';
} else {
verified = false;
verificationError = 'Self-transfer event signature is invalid';
verificationMethod = 'self-transfer-event';
try {
// Get current owner from the most recent announcement file in the repo
localOwner = await fileManager.getCurrentOwnerFromRepo(context.npub, context.repo);
if (localOwner) {
// Verify the announcement file matches the announcement event
try {
const announcementFile = await fileManager.getFileContent(context.npub, context.repo, VERIFICATION_FILE_PATH, 'HEAD');
const verification = verifyRepositoryOwnership(announcement, announcementFile.content);
localVerified = verification.valid;
if (!verification.valid) {
localError = verification.error;
}
} catch (err) {
localVerified = false;
localError = 'Announcement file not found in repository';
}
} else {
// Fall back to verification file method (for backward compatibility)
try {
const verificationFile = await fileManager.getFileContent(context.npub, context.repo, VERIFICATION_FILE_PATH, 'HEAD');
const verification = verifyRepositoryOwnership(announcement, verificationFile.content);
verified = verification.valid;
verificationError = verification.error;
verificationMethod = 'verification-file';
localVerified = false;
localError = 'No announcement file found in repository';
}
} catch (err) {
verified = false;
verificationError = 'No ownership proof found (neither self-transfer event nor verification file)';
verificationMethod = 'none';
localVerified = false;
localError = err instanceof Error ? err.message : 'Failed to verify local clone';
}
// Add local clone verification
const localUrl = cloneUrls.find(url => url.includes(context.npub) || url.includes(context.repoOwnerPubkey));
if (localUrl) {
cloneVerifications.push({
url: localUrl,
verified: localVerified,
ownerPubkey: localOwner,
error: localError
});
}
if (verified) {
// For other clones (GitHub, GitLab, etc.), we'd need to fetch them first to check their announcement files
// This is a future enhancement - for now we only verify the local GitRepublic clone
// Overall verification: at least one clone must be verified
const overallVerified = cloneVerifications.some(cv => cv.verified);
const verifiedClones = cloneVerifications.filter(cv => cv.verified);
const currentOwner = localOwner || context.repoOwnerPubkey;
if (overallVerified) {
return json({
verified: true,
announcementId: announcement.id,
ownerPubkey: context.repoOwnerPubkey,
verificationMethod,
selfTransferEventId: selfTransfer?.id,
message: 'Repository ownership verified successfully'
ownerPubkey: currentOwner,
verificationMethod: 'announcement-file',
cloneVerifications: cloneVerifications.map(cv => ({
url: cv.url,
verified: cv.verified,
ownerPubkey: cv.ownerPubkey,
error: cv.error
})),
message: `Repository ownership verified successfully for ${verifiedClones.length} clone(s)`
});
} else {
return json({
verified: false,
error: verificationError || 'Repository ownership verification failed',
error: localError || 'Repository ownership verification failed',
announcementId: announcement.id,
verificationMethod,
message: 'Repository ownership verification failed'
verificationMethod: 'announcement-file',
cloneVerifications: cloneVerifications.map(cv => ({
url: cv.url,
verified: cv.verified,
ownerPubkey: cv.ownerPubkey,
error: cv.error
})),
message: 'Repository ownership verification failed for all clones'
});
}
},

29
src/routes/signup/+page.svelte

@ -1365,6 +1365,8 @@ @@ -1365,6 +1365,8 @@
eventTags.push(['client', 'gitrepublic-web']);
}
// We'll generate the announcement file content after signing (it's just the full event JSON)
// Build event
const eventTemplate: Omit<NostrEvent, 'sig' | 'id'> = {
kind: KIND.REPO_ANNOUNCEMENT,
@ -1379,6 +1381,26 @@ @@ -1379,6 +1381,26 @@
const signedEvent = await signEventWithNIP07(eventTemplate);
console.log('Event signed successfully, event ID:', signedEvent.id);
// Generate announcement file content (just the full signed event JSON)
// The server will commit this to prove ownership - no need for a separate verification file event
const { generateVerificationFile } = await import('../../lib/services/nostr/repo-verification.js');
const announcementFileContent = generateVerificationFile(signedEvent, pubkey);
// Create a signed event (kind 1642) with the announcement file content
// This allows the server to fetch and commit the client-signed announcement
const announcementFileEventTemplate: Omit<NostrEvent, 'sig' | 'id'> = {
kind: 1642, // Custom kind for announcement file
pubkey,
created_at: Math.floor(Date.now() / 1000),
content: announcementFileContent,
tags: [
['e', signedEvent.id, '', 'announcement'],
['d', dTag]
]
};
const signedAnnouncementFileEvent = await signEventWithNIP07(announcementFileEventTemplate);
// Get user's inbox/outbox relays (from kind 10002) using comprehensive relay set
console.log('Fetching user relays from comprehensive relay set...');
const { getUserRelays } = await import('../../lib/services/nostr/user-relays.js');
@ -1427,6 +1449,13 @@ @@ -1427,6 +1449,13 @@
console.log('Using relays for publishing:', userRelays);
// Publish announcement file event first (so it's available when server provisions)
console.log('Publishing announcement file event...');
await publishWithRetry(nostrClient, signedAnnouncementFileEvent, userRelays, 2).catch(err => {
console.warn('Failed to publish announcement file event:', err);
// Continue anyway - server can generate it as fallback
});
// Publish repository announcement with retry logic
let publishResult = await publishWithRetry(nostrClient, signedEvent, userRelays, 2);
console.log('Publish result:', publishResult);

Loading…
Cancel
Save