diff --git a/docs/SSH_KEY_ATTESTATION.md b/docs/SSH_KEY_ATTESTATION.md index 4a74e6c..699ee10 100644 --- a/docs/SSH_KEY_ATTESTATION.md +++ b/docs/SSH_KEY_ATTESTATION.md @@ -14,12 +14,22 @@ GitRepublic supports SSH key attestation, allowing you to use standard `git` com - You must have a Nostr key pair (via NIP-07 browser extension) - You must have an SSH key pair +## SSH Key Comment Field + +The SSH public key comment field (the part after the key data) can contain: +- **NIP-05 identifiers** (e.g., `user@domain.com`) - recommended for Nostr users +- Email addresses (e.g., `user@example.com`) +- Any other identifier + +The comment field is optional and does not affect the key fingerprint or authentication. It's purely for identification purposes. + ## How It Works 1. **Generate SSH Key** (if you don't have one): ```bash - ssh-keygen -t ed25519 -C "your-email@example.com" - # Or use RSA: ssh-keygen -t rsa -b 4096 -C "your-email@example.com" + ssh-keygen -t ed25519 -C "your-nip05@example.com" + # Or use RSA: ssh-keygen -t rsa -b 4096 -C "your-nip05@example.com" + # Note: The comment field (-C) can contain your NIP-05 identifier or email address ``` 2. **Get Your SSH Public Key**: @@ -55,7 +65,7 @@ GitRepublic supports SSH key attestation, allowing you to use standard `git` com "pubkey": "your-nostr-pubkey-hex", "created_at": 1234567890, "tags": [], - "content": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... your-email@example.com", + "content": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... your-nip05@example.com", "id": "event-id-hex", "sig": "event-signature-hex" } diff --git a/src/lib/services/git-platforms/git-platform-fetcher.ts b/src/lib/services/git-platforms/git-platform-fetcher.ts index 24dfdce..cb8d98f 100644 --- a/src/lib/services/git-platforms/git-platform-fetcher.ts +++ b/src/lib/services/git-platforms/git-platform-fetcher.ts @@ -457,7 +457,13 @@ async function fetchIssues( const rawIssues = await fetchFromPlatform(url, headers, platform); return rawIssues - .filter((issue: any) => !issue.pull_request) // Exclude PRs (GitHub returns PRs in issues endpoint) + .filter((issue: any) => { + // Exclude PRs (GitHub returns PRs in issues endpoint) + if (issue.pull_request) return false; + // GitLab merge requests have iid but are not issues + if (platform === 'gitlab' && issue.merge_request) return false; + return true; + }) .map((issue: any) => normalizeIssue(issue, platform, owner, repo, apiUrl)); } catch (error) { logger.error({ error, platform, owner, repo }, 'Failed to fetch issues'); diff --git a/src/lib/services/git/repo-manager.ts b/src/lib/services/git/repo-manager.ts index 3c0a459..a558049 100644 --- a/src/lib/services/git/repo-manager.ts +++ b/src/lib/services/git/repo-manager.ts @@ -119,6 +119,17 @@ export class RepoManager { // Check if repo already exists const repoExists = existsSync(repoPath.fullPath); + // Security: Only allow new repo creation if user has unlimited access + // This prevents spam and abuse + const isNewRepo = !repoExists; + if (isNewRepo && !isExistingRepo) { + const { getCachedUserLevel } = await import('../security/user-level-cache.js'); + const userLevel = getCachedUserLevel(event.pubkey); + if (!userLevel || userLevel.level !== 'unlimited') { + throw new Error(`Repository creation requires unlimited access. User has level: ${userLevel?.level || 'none'}`); + } + } + // If there are other clone URLs, sync from them first (for existing repos) const otherUrls = cloneUrls.filter(url => !url.includes(this.domain)); if (otherUrls.length > 0 && repoExists) { @@ -127,7 +138,6 @@ export class RepoManager { } // Create bare repository if it doesn't exist - const isNewRepo = !repoExists; if (isNewRepo) { // Use simple-git to create bare repo (safer than exec) const git = simpleGit(); @@ -455,6 +465,20 @@ export class RepoManager { return false; } + // Security: Only allow fetching if user has unlimited access + // This prevents unauthorized repository creation + const { getCachedUserLevel } = await import('../security/user-level-cache.js'); + const userLevel = getCachedUserLevel(announcementEvent.pubkey); + if (!userLevel || userLevel.level !== 'unlimited') { + logger.warn({ + npub, + repoName, + pubkey: announcementEvent.pubkey.slice(0, 16) + '...', + level: userLevel?.level || 'none' + }, 'Skipping on-demand repo fetch: user does not have unlimited access'); + return false; + } + try { // Extract clone URLs from announcement const cloneUrls = this.extractCloneUrls(announcementEvent); diff --git a/src/lib/services/nostr/repo-polling.ts b/src/lib/services/nostr/repo-polling.ts index 96570e8..a3d9124 100644 --- a/src/lib/services/nostr/repo-polling.ts +++ b/src/lib/services/nostr/repo-polling.ts @@ -7,6 +7,7 @@ import { KIND } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js'; import { RepoManager } from '../git/repo-manager.js'; import { OwnershipTransferService } from './ownership-transfer-service.js'; +import { getCachedUserLevel } from '../security/user-level-cache.js'; import logger from '../logger.js'; export class RepoPollingService { @@ -162,6 +163,20 @@ export class RepoPollingService { } } + // Check if user has unlimited access before provisioning new repos + // This prevents spam and abuse + if (!isExistingRepo) { + const userLevel = getCachedUserLevel(event.pubkey); + if (!userLevel || userLevel.level !== 'unlimited') { + logger.warn({ + eventId: event.id, + pubkey: event.pubkey.slice(0, 16) + '...', + level: userLevel?.level || 'none' + }, 'Skipping repo provisioning: user does not have unlimited access'); + continue; + } + } + // Provision the repo with self-transfer event if available await this.repoManager.provisionRepo(event, selfTransferEvent, isExistingRepo); logger.info({ eventId: event.id, isExistingRepo }, 'Provisioned repo from announcement'); diff --git a/src/lib/services/ssh/ssh-key-attestation.ts b/src/lib/services/ssh/ssh-key-attestation.ts index da589d1..7956304 100644 --- a/src/lib/services/ssh/ssh-key-attestation.ts +++ b/src/lib/services/ssh/ssh-key-attestation.ts @@ -77,14 +77,20 @@ function getLookupKey(fingerprint: string): string { /** * Calculate SSH key fingerprint (MD5 or SHA256) * Format: MD5: aa:bb:cc:dd... or SHA256: base64... + * + * SSH public keys are in format: "key-type base64-key [comment]" + * The comment field (optional) can contain NIP-05 identifiers or email addresses. + * Only the key-type and base64-key are used for fingerprint calculation. */ export function calculateSSHKeyFingerprint(publicKey: string, algorithm: 'md5' | 'sha256' = 'sha256'): string { // SSH public keys are in format: "key-type base64-key [comment]" + // Comment field is optional and can contain NIP-05 identifiers (e.g., "user@domain.com") const parts = publicKey.trim().split(/\s+/); if (parts.length < 2) { throw new Error('Invalid SSH public key format'); } + // Only use the key data (parts[1]) for fingerprint, ignore comment (parts[2+]) const keyData = Buffer.from(parts[1], 'base64'); if (algorithm === 'md5') { @@ -130,6 +136,12 @@ function checkRateLimit(userPubkey: string): { allowed: boolean; remaining: numb /** * Parse SSH key attestation from Nostr event + * + * SSH public keys are in the format: "key-type base64-key [comment]" + * The comment field is optional and can contain: + * - Email addresses (e.g., "user@example.com") + * - NIP-05 identifiers (e.g., "user@domain.com" - same format as email) + * - Any other identifier */ function parseAttestationEvent(event: NostrEvent): { sshPublicKey: string; @@ -137,6 +149,8 @@ function parseAttestationEvent(event: NostrEvent): { revoked: boolean; } { // Content should contain the SSH public key + // Format: "ssh-rsa AAAAB3NzaC1yc2E... [comment]" + // The comment field (after the key data) can contain NIP-05 identifiers or email addresses const sshPublicKey = event.content.trim(); if (!sshPublicKey) { throw new Error('SSH public key not found in event content'); diff --git a/src/routes/api/repos/[npub]/[repo]/fork/+server.ts b/src/routes/api/repos/[npub]/[repo]/fork/+server.ts index 784f331..ac78539 100644 --- a/src/routes/api/repos/[npub]/[repo]/fork/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/fork/+server.ts @@ -21,6 +21,7 @@ import { isValidBranchName } from '$lib/utils/security.js'; import { ResourceLimits } from '$lib/services/security/resource-limits.js'; import { auditLogger } from '$lib/services/security/audit-logger.js'; import { ForkCountService } from '$lib/services/nostr/fork-count-service.js'; +import { getCachedUserLevel } from '$lib/services/security/user-level-cache.js'; import logger from '$lib/services/logger.js'; import { handleApiError, handleValidationError, handleNotFoundError, handleAuthorizationError } from '$lib/utils/error-handler.js'; @@ -108,6 +109,20 @@ export const POST: RequestHandler = async ({ params, request }) => { // Determine fork name (use original name if not specified) const forkRepoName = forkName || repo; + // Check if user has unlimited access (required for storing repos locally) + const userLevel = getCachedUserLevel(userPubkeyHex); + if (!userLevel || userLevel.level !== 'unlimited') { + const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'; + auditLogger.logRepoFork( + userPubkeyHex, + `${npub}/${repo}`, + `${userNpub}/${forkRepoName}`, + 'failure', + 'User does not have unlimited access' + ); + return error(403, 'Repository creation requires unlimited access. Please verify you can write to at least one default Nostr relay.'); + } + // Check resource limits before forking const resourceCheck = await resourceLimits.canCreateRepo(userNpub); if (!resourceCheck.allowed) {