From ada014e2377991856f294b648d931a48f605990b Mon Sep 17 00:00:00 2001 From: Silberengel Date: Fri, 20 Feb 2026 21:34:07 +0100 Subject: [PATCH] refactor Nostr-Signature: 190b84b2cff8b8db7b3509e05d5470c073fc88e50ba7ad4fa54fd9a9d8dc0045 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 638b9986b5e534d09752125721a04d8cef7af892c0394515d6deb4116c2fcab378313abc270f47a6605f50457d5bb83fdb8b34af0607725b6d774028dc6a4fb6 --- README.md | 8 - docs/SSH_KEY_ATTESTATION.md | 241 ------------ nostr/commit-signatures.jsonl | 1 + src/lib/config.ts | 20 +- src/lib/services/git/file-manager.ts | 16 +- src/lib/services/git/repo-manager.ts | 63 +--- src/lib/services/nostr/maintainer-service.ts | 17 +- src/lib/services/nostr/repo-polling.ts | 17 +- src/lib/services/security/audit-logger.ts | 19 - src/lib/services/ssh/ssh-key-attestation.ts | 346 ------------------ src/lib/types/nostr.ts | 1 - src/lib/utils/nostr-utils.ts | 72 ++++ src/lib/utils/repo-privacy.ts | 23 ++ src/routes/api/git/[...path]/+server.ts | 21 +- src/routes/api/openapi.json/openapi.json | 104 ------ src/routes/api/user/ssh-keys/+server.ts | 162 -------- .../api/user/ssh-keys/verify/+server.ts | 70 ---- 17 files changed, 125 insertions(+), 1076 deletions(-) delete mode 100644 docs/SSH_KEY_ATTESTATION.md delete mode 100644 src/lib/services/ssh/ssh-key-attestation.ts create mode 100644 src/lib/utils/nostr-utils.ts delete mode 100644 src/routes/api/user/ssh-keys/+server.ts delete mode 100644 src/routes/api/user/ssh-keys/verify/+server.ts diff --git a/README.md b/README.md index 242e503..09314bc 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,6 @@ See [ARCHITECTURE_FAQ.md](./docs/ARCHITECTURE_FAQ.md) for answers to common arch - **NIP-34 Repo Announcements**: Create and manage repository announcements on Nostr - **NIP-07 Authentication**: Web UI authentication via browser extensions (e.g., Alby, nos2x) - **NIP-98 HTTP Authentication**: Git operations (clone, push, pull) authenticated using ephemeral Nostr events -- **SSH Key Attestation**: Link SSH keys to Nostr identity for git operations over SSH (see [docs/SSH_KEY_ATTESTATION.md](./docs/SSH_KEY_ATTESTATION.md)) - **Auto-provisioning**: Automatically creates git repositories from NIP-34 announcements - **Multi-remote Sync**: Automatically syncs repositories to multiple remotes listed in announcements - **Repository Size Limits**: Enforces 2 GB maximum repository size @@ -96,12 +95,6 @@ These are not part of any NIP but are used by this application: - Tags: `a` (repo identifier), `p` (new owner), `d` (repo name), `t` (self-transfer marker, optional) - See [docs/NIP_COMPLIANCE.md](./docs/NIP_COMPLIANCE.md#1641---ownership_transfer) for complete example -- **30001** (`SSH_KEY_ATTESTATION`): SSH key attestation (server-side only, not published to relays) - - Links SSH public keys to Nostr identity for git operations over SSH - - Content contains the SSH public key - - Tags: `revoke` (optional, set to 'true' to revoke an attestation) - - See [docs/SSH_KEY_ATTESTATION.md](./docs/SSH_KEY_ATTESTATION.md) for complete documentation - - **30620** (`BRANCH_PROTECTION`): Branch protection rules (replaceable) - Allows requiring pull requests, reviewers, status checks for protected branches - Tags: `d` (repo name), `a` (repo identifier), `branch` (branch name and protection settings) @@ -357,7 +350,6 @@ See `docs/SECURITY.md` and `docs/SECURITY_IMPLEMENTATION.md` for detailed inform - `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`) -- `SSH_ATTESTATION_LOOKUP_SECRET`: Secret key for HMAC-based SSH key fingerprint lookup (default: `change-me-in-production`). **Important**: Set this to a secure random value in production! - `TOR_SOCKS_PROXY`: Tor SOCKS proxy address (format: `host:port`, default: `127.0.0.1:9050`). Set to empty string to disable Tor support. When configured, the server will automatically route `.onion` addresses through Tor for both Nostr relay connections and git operations. - `TOR_ONION_ADDRESS`: Tor hidden service .onion address (optional). If not set, the server will attempt to read it from Tor's hostname file. When configured, every repository will automatically get a `.onion` clone URL in addition to the regular domain URL, making repositories accessible via Tor even if the server is only running on localhost. diff --git a/docs/SSH_KEY_ATTESTATION.md b/docs/SSH_KEY_ATTESTATION.md deleted file mode 100644 index 94f8246..0000000 --- a/docs/SSH_KEY_ATTESTATION.md +++ /dev/null @@ -1,241 +0,0 @@ -# SSH Key Attestation - -This document describes how to link your Nostr npub to SSH public keys for git operations over SSH. - -## Overview - -GitRepublic supports SSH key attestation, allowing you to use standard `git` commands over SSH instead of HTTP with NIP-98 authentication. This is done by signing a Nostr event that proves ownership of an SSH key. - -**Important**: SSH key attestations are stored server-side only and are **not published to Nostr relays**. They are only used for authentication on the GitRepublic server. - -## Prerequisites - -- You must have **unlimited access** (ability to write to at least one default Nostr relay) -- 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-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**: - ```bash - cat ~/.ssh/id_ed25519.pub - # Or: cat ~/.ssh/id_rsa.pub - ``` - -3. **Create Attestation Event**: - - Sign a Nostr event (kind 30001) containing your SSH public key - - The event must be signed with your Nostr private key - - Submit the event to the server via API - -4. **Server Verification**: - - Server verifies the event signature - - Server stores the attestation (SSH key fingerprint → npub mapping) - - Server allows git operations over SSH using that key - -## API Usage - -### Submit SSH Key Attestation - -**Endpoint**: `POST /api/user/ssh-keys` - -**Headers**: -- `X-User-Pubkey`: Your Nostr public key (hex format) - -**Body**: -```json -{ - "event": { - "kind": 30001, - "pubkey": "your-nostr-pubkey-hex", - "created_at": 1234567890, - "tags": [], - "content": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... your-nip05@example.com", - "id": "event-id-hex", - "sig": "event-signature-hex" - } -} -``` - -**Example using curl** (with NIP-07): -```javascript -// In browser console with NIP-07 extension: -const sshPublicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI..."; - -const event = { - kind: 30001, - pubkey: await window.nostr.getPublicKey(), - created_at: Math.floor(Date.now() / 1000), - tags: [], - content: sshPublicKey -}; - -const signedEvent = await window.nostr.signEvent(event); - -// Submit to server -const response = await fetch('/api/user/ssh-keys', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-User-Pubkey': await window.nostr.getPublicKey() - }, - body: JSON.stringify({ event: signedEvent }) -}); -``` - -### Get Your Attestations - -**Endpoint**: `GET /api/user/ssh-keys` - -**Headers**: -- `X-User-Pubkey`: Your Nostr public key (hex format) - -**Response**: -```json -{ - "attestations": [ - { - "eventId": "event-id", - "fingerprint": "SHA256:abc123...", - "keyType": "ssh-ed25519", - "createdAt": 1234567890, - "revoked": false - } - ] -} -``` - -**Note**: You can have multiple SSH keys attested. All active (non-revoked) keys will be returned, sorted by creation date (newest first). - -### Verify SSH Key - -**Endpoint**: `POST /api/user/ssh-keys/verify` - -**Body**: -```json -{ - "fingerprint": "SHA256:abc123..." -} -``` - -**Response**: -```json -{ - "valid": true, - "attestation": { - "userPubkey": "npub-hex", - "fingerprint": "SHA256:abc123...", - "keyType": "ssh-ed25519", - "createdAt": 1234567890 - } -} -``` - -## Revoking Attestations - -To revoke an SSH key attestation, submit a new event with a `revoke` tag: - -```javascript -const event = { - kind: 30001, - pubkey: await window.nostr.getPublicKey(), - created_at: Math.floor(Date.now() / 1000), - tags: [['revoke', 'true']], - content: sshPublicKey // Same public key to revoke -}; - -const signedEvent = await window.nostr.signEvent(event); -// Submit to POST /api/user/ssh-keys -``` - -## SSH Server Integration - -**Note**: The current GitRepublic implementation provides the API for storing and verifying SSH key attestations. To use SSH for git operations, you would need to: - -1. **Set up an SSH server** (e.g., using `node-ssh-server` or a traditional OpenSSH server) -2. **Configure git-shell** or a custom command handler -3. **Verify SSH keys** by: - - Extracting the SSH key fingerprint from the SSH connection - - Calling the verification API or using the `verifyAttestation()` function directly - - Allowing git operations if the key is attested - -### Example SSH Server Integration (Pseudocode) - -```typescript -import { verifyAttestation } from '$lib/services/ssh/ssh-key-attestation.js'; - -// In SSH server authentication handler -async function authenticateSSH(sshKey: string, fingerprint: string) { - const attestation = verifyAttestation(fingerprint); - - if (!attestation) { - return false; // Authentication failed - } - - // User is authenticated as attestation.userPubkey - // Allow git operations - return true; -} -``` - -### Git Configuration - -Once SSH is set up, users can configure git to use SSH: - -```bash -# Add remote using SSH -git remote add origin ssh://git@your-gitrepublic-server.com/repos/{npub}/{repo}.git - -# Or use SSH URL format -git remote add origin git@your-gitrepublic-server.com:repos/{npub}/{repo}.git -``` - -## Security Considerations - -1. **Attestations are server-side only**: They are not published to Nostr relays, reducing privacy concerns -2. **Rate limiting**: Maximum 10 attestations per hour per user -3. **Signature verification**: All attestations must be signed with the user's Nostr private key -4. **Revocation support**: Users can revoke attestations at any time -5. **Fingerprint-based lookup**: SSH key fingerprints are hashed before storage (HMAC) - -## Environment Variables - -- `SSH_ATTESTATION_LOOKUP_SECRET`: Secret key for HMAC-based fingerprint lookup (default: 'change-me-in-production') - - **Important**: Set this to a secure random value in production! - -## Limitations - -- SSH server integration is not yet implemented in the main codebase -- Attestations are stored in-memory (will be lost on server restart) - - In production, use Redis or a database for persistent storage -- Only users with "unlimited access" can create attestations - -## Current Status - -✅ **Implemented:** -- Support for multiple SSH keys per user (users can attest multiple SSH keys) -- Rate limiting (10 attestations per hour per user) -- Revocation support -- HMAC-based fingerprint lookup for security -- Audit logging for SSH key attestation operations (submit, revoke, verify) - -## Future Improvements - -- Persistent storage (Redis/database) for attestations (currently in-memory) -- SSH server implementation (API is ready, server integration needed) -- Key expiration/rotation policies diff --git a/nostr/commit-signatures.jsonl b/nostr/commit-signatures.jsonl index 9f3d1bc..49afd94 100644 --- a/nostr/commit-signatures.jsonl +++ b/nostr/commit-signatures.jsonl @@ -25,3 +25,4 @@ {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771614223,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix websocket problems\nhandle replaceable events correctly\nfix css for docs"]],"content":"Signed commit: fix websocket problems\nhandle replaceable events correctly\nfix css for docs","id":"88c007de2bd48c32c879b9950f0908270b009c6341a97b1c0164982648beb3d9","sig":"c9250a23d38671a5b1c0d3389e003931222385ca9591b9b332585c8c639e2af2a7b2e8cac9c1ca5bd47df19b330622b1a1874e586f112fa84a4a7aa4347c7456"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771615631,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","handle new repo creation"]],"content":"Signed commit: handle new repo creation","id":"59bc1c664590bcbe3e05c4151154590aa1ca4399e2a48d64e94bb960e6056265","sig":"ae666597fc46256915abeec93be97c5d9559eaef90aa65208740f32fe4b00531a51ba432ed9a2089a7ec860ac1dc9a7a4a5d8e84db2a7ae433dd5c668f0b5035"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771618298,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","restrict repos to announced events"]],"content":"Signed commit: restrict repos to announced events","id":"d7ee36680a38fac493b27fba26d6e1c496dee9a3099db68a4352f7709a41e860","sig":"071cc8031940590785e5566a45159e5324e36e8a06023282ab1d50b608902d3b06d95efc03d0a4da861a88f12381f7b64999c09a49dfe5f36fbd8ec6aefd8aeb"} +{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771618514,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"0a4b94a90de38e64e657c3ef5aca2bc61b5a563edf504d10f4cf5ab386b1bd9c","sig":"d7502da3f1f7d7b35b810a09cbcd3a467589afd8b97e0a7a04fb47996bb4959b510580a0f33f21c318c2733004f23840f73929ddc0dfb2572edc83ad967b09d2"} diff --git a/src/lib/config.ts b/src/lib/config.ts index 58fb547..998d717 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -13,30 +13,34 @@ export const GIT_DOMAIN = : 'localhost:6543'; /** - * Default Nostr relays to use + * Default Nostr relays to use for operations (publishing, fetching) * Can be overridden by NOSTR_RELAYS env var (comma-separated list) + * */ export const DEFAULT_NOSTR_RELAYS = typeof process !== 'undefined' && process.env?.NOSTR_RELAYS ? process.env.NOSTR_RELAYS.split(',').map(r => r.trim()).filter(r => r.length > 0) : [ 'wss://theforest.nostr1.com', + 'wss://nostr.land', ]; /** * Nostr relays to use for searching for repositories, profiles, or other events - * Can be overridden by NOSTR_RELAYS env var (comma-separated list) + * Can be overridden by NOSTR_SEARCH_RELAYS env var (comma-separated list) + * */ export const DEFAULT_NOSTR_SEARCH_RELAYS = typeof process !== 'undefined' && process.env?.NOSTR_SEARCH_RELAYS ? process.env.NOSTR_SEARCH_RELAYS.split(',').map(r => r.trim()).filter(r => r.length > 0) : [ - 'wss://nostr.land', - 'wss://relay.damus.io', - 'wss://thecitadel.nostr1.com', - 'wss://nostr21.com', - 'wss://profiles.nostr1.com', - "wss://relay.primal.net", + 'wss://theforest.nostr1.com', + 'wss://nostr.land', + 'wss://relay.damus.io', + 'wss://thecitadel.nostr1.com', + 'wss://nostr21.com', + 'wss://relay.primal.net', + ]; /** diff --git a/src/lib/services/git/file-manager.ts b/src/lib/services/git/file-manager.ts index 5d06a09..6d33478 100644 --- a/src/lib/services/git/file-manager.ts +++ b/src/lib/services/git/file-manager.ts @@ -947,19 +947,9 @@ export class FileManager { const announcement = events[0]; - // Check for ["private", "true"] tag - const privateTag = announcement.tags.find(t => t[0] === 'private' && t[1] === 'true'); - if (privateTag) return true; - - // Check for ["private"] tag (just the tag name, no value) - const privateTagOnly = announcement.tags.find(t => t[0] === 'private' && (!t[1] || t[1] === '')); - if (privateTagOnly) return true; - - // Check for ["t", "private"] tag (topic tag) - const topicTag = announcement.tags.find(t => t[0] === 't' && t[1] === 'private'); - if (topicTag) return true; - - return false; + // Use shared utility to check if repo is private + const { isPrivateRepo: checkIsPrivateRepo } = await import('../../utils/repo-privacy.js'); + return checkIsPrivateRepo(announcement); } catch (err) { // If we can't determine, default to public (safer - allows publishing) logger.debug({ error: err, npub, repoName }, 'Failed to check repo privacy, defaulting to public'); diff --git a/src/lib/services/git/repo-manager.ts b/src/lib/services/git/repo-manager.ts index e92126d..ee255de 100644 --- a/src/lib/services/git/repo-manager.ts +++ b/src/lib/services/git/repo-manager.ts @@ -14,6 +14,8 @@ import simpleGit, { type SimpleGit } from 'simple-git'; import logger from '../logger.js'; import { shouldUseTor, getTorProxy } from '../../utils/tor.js'; import { sanitizeError } from '../../utils/security.js'; +import { isPrivateRepo as checkIsPrivateRepo } from '../../utils/repo-privacy.js'; +import { extractCloneUrls } from '../../utils/nostr-utils.js'; /** * Execute git command with custom environment variables safely @@ -550,53 +552,12 @@ Your commits will all be signed by your Nostr keys and saved to the event files }); } - /** - * Normalize a clone URL to ensure it's in the correct format for git clone - * Handles Gitea URLs that might be missing .git extension - */ - private normalizeCloneUrl(url: string): string { - // Remove trailing slash - url = url.trim().replace(/\/$/, ''); - - // For HTTPS/HTTP URLs that don't end in .git, check if they're Gitea/GitHub/GitLab style - // Pattern: https://domain.com/owner/repo (without .git) - if ((url.startsWith('https://') || url.startsWith('http://')) && !url.endsWith('.git')) { - // Check if it looks like a git hosting service URL (has at least 2 path segments) - const urlObj = new URL(url); - const pathParts = urlObj.pathname.split('/').filter(p => p); - - // If it has 2+ path segments (e.g., /owner/repo), add .git - if (pathParts.length >= 2) { - // Check if it's not already a file or has an extension - const lastPart = pathParts[pathParts.length - 1]; - if (!lastPart.includes('.')) { - return `${url}.git`; - } - } - } - - return url; - } - /** * Extract clone URLs from a NIP-34 repo announcement + * Uses shared utility with normalization enabled */ private extractCloneUrls(event: NostrEvent): string[] { - const urls: string[] = []; - - for (const tag of event.tags) { - if (tag[0] === 'clone') { - for (let i = 1; i < tag.length; i++) { - const url = tag[i]; - if (url && typeof url === 'string') { - // Normalize the URL to ensure it's cloneable - urls.push(this.normalizeCloneUrl(url)); - } - } - } - } - - return urls; + return extractCloneUrls(event, true); } /** @@ -617,22 +578,10 @@ Your commits will all be signed by your Nostr keys and saved to the event files */ /** * Check if a repository is private based on announcement event - * A repo is private if it has a tag ["private"], ["private", "true"], or ["t", "private"] + * Uses shared utility to avoid code duplication */ private isPrivateRepo(announcement: NostrEvent): boolean { - // Check for ["private", "true"] tag - const privateTag = announcement.tags.find(t => t[0] === 'private' && t[1] === 'true'); - if (privateTag) return true; - - // Check for ["private"] tag (just the tag name, no value) - const privateTagOnly = announcement.tags.find(t => t[0] === 'private' && (!t[1] || t[1] === '')); - if (privateTagOnly) return true; - - // Check for ["t", "private"] tag (topic tag) - const topicTag = announcement.tags.find(t => t[0] === 't' && t[1] === 'private'); - if (topicTag) return true; - - return false; + return checkIsPrivateRepo(announcement); } async fetchRepoOnDemand( diff --git a/src/lib/services/nostr/maintainer-service.ts b/src/lib/services/nostr/maintainer-service.ts index 8f44df9..9f851f3 100644 --- a/src/lib/services/nostr/maintainer-service.ts +++ b/src/lib/services/nostr/maintainer-service.ts @@ -9,6 +9,7 @@ import type { NostrEvent } from '../../types/nostr.js'; import { nip19 } from 'nostr-tools'; import { OwnershipTransferService } from './ownership-transfer-service.js'; import logger from '../logger.js'; +import { isPrivateRepo as checkIsPrivateRepo } from '../../utils/repo-privacy.js'; export interface RepoPrivacyInfo { isPrivate: boolean; @@ -29,22 +30,10 @@ export class MaintainerService { /** * Check if a repository is private - * A repo is private if it has a tag ["private"], ["private", "true"], or ["t", "private"] + * Uses shared utility to avoid code duplication */ private isPrivateRepo(announcement: NostrEvent): boolean { - // Check for ["private", "true"] tag - const privateTag = announcement.tags.find(t => t[0] === 'private' && t[1] === 'true'); - if (privateTag) return true; - - // Check for ["private"] tag (just the tag name, no value) - const privateTagOnly = announcement.tags.find(t => t[0] === 'private' && (!t[1] || t[1] === '')); - if (privateTagOnly) return true; - - // Check for ["t", "private"] tag (topic tag) - const topicTag = announcement.tags.find(t => t[0] === 't' && t[1] === 'private'); - if (topicTag) return true; - - return false; + return checkIsPrivateRepo(announcement); } /** diff --git a/src/lib/services/nostr/repo-polling.ts b/src/lib/services/nostr/repo-polling.ts index 02f12c1..759b61c 100644 --- a/src/lib/services/nostr/repo-polling.ts +++ b/src/lib/services/nostr/repo-polling.ts @@ -9,6 +9,7 @@ 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'; +import { extractCloneUrls } from '../../utils/nostr-utils.js'; export class RepoPollingService { private nostrClient: NostrClient; @@ -192,21 +193,9 @@ export class RepoPollingService { /** * Extract clone URLs from a NIP-34 repo announcement + * Uses shared utility (without normalization) */ private extractCloneUrls(event: NostrEvent): string[] { - const urls: string[] = []; - - for (const tag of event.tags) { - if (tag[0] === 'clone') { - for (let i = 1; i < tag.length; i++) { - const url = tag[i]; - if (url && typeof url === 'string') { - urls.push(url); - } - } - } - } - - return urls; + return extractCloneUrls(event, false); } } diff --git a/src/lib/services/security/audit-logger.ts b/src/lib/services/security/audit-logger.ts index 99a4dbd..c34cbdb 100644 --- a/src/lib/services/security/audit-logger.ts +++ b/src/lib/services/security/audit-logger.ts @@ -338,25 +338,6 @@ export class AuditLogger { }); } - /** - * Log SSH key attestation operation - */ - logSSHKeyAttestation( - user: string, - action: 'submit' | 'revoke' | 'verify', - fingerprint: string, - result: 'success' | 'failure', - error?: string - ): void { - this.log({ - user, - action: `ssh.attestation.${action}`, - resource: fingerprint, - result, - error, - metadata: { fingerprint: fingerprint.slice(0, 20) + '...' } - }); - } } // Singleton instance diff --git a/src/lib/services/ssh/ssh-key-attestation.ts b/src/lib/services/ssh/ssh-key-attestation.ts deleted file mode 100644 index 7956304..0000000 --- a/src/lib/services/ssh/ssh-key-attestation.ts +++ /dev/null @@ -1,346 +0,0 @@ -/** - * SSH Key Attestation Service - * - * Allows users to link their Nostr npub to SSH public keys for git operations. - * Users sign a Nostr event (kind 30001) that proves ownership of an SSH key. - * This attestation is stored server-side only (not published to Nostr relays). - * - * SECURITY: - * - Attestations are verified using Nostr event signatures - * - SSH key fingerprints are stored (not full keys) - * - Attestations can be revoked by submitting a new event with 'revoke' tag - * - Rate limiting on attestation submissions - */ - -import { createHash, createHmac } from 'crypto'; -import { verifyEvent } from 'nostr-tools'; -import type { NostrEvent } from '../../types/nostr.js'; -import { KIND } from '../../types/nostr.js'; -import logger from '../logger.js'; - -export interface SSHKeyAttestation { - eventId: string; - userPubkey: string; - sshKeyFingerprint: string; - sshKeyType: string; // e.g., 'ssh-rsa', 'ssh-ed25519', 'ecdsa-sha2-nistp256' - sshPublicKey: string; // Full public key for verification - createdAt: number; - revoked: boolean; - revokedAt?: number; -} - -interface StoredAttestation { - attestation: SSHKeyAttestation; - lookupKey: string; // HMAC of fingerprint for database lookup -} - -// In-memory storage (in production, use Redis or database) -// Key: HMAC(fingerprint), Value: StoredAttestation -const attestations = new Map(); - -// Index by user pubkey for quick lookup -// Key: userPubkey, Value: Set of lookup keys -const userAttestations = new Map>(); - -// Rate limiting: track submissions per pubkey -interface SubmissionAttempt { - count: number; - resetAt: number; -} - -const submissionAttempts = new Map(); -const MAX_SUBMISSIONS_PER_HOUR = 10; -const SUBMISSION_WINDOW_MS = 60 * 60 * 1000; // 1 hour - -// Cleanup expired rate limit entries -setInterval(() => { - const now = Date.now(); - for (const [key, attempt] of submissionAttempts.entries()) { - if (attempt.resetAt < now) { - submissionAttempts.delete(key); - } - } -}, 5 * 60 * 1000); // Cleanup every 5 minutes - -const LOOKUP_SECRET = process.env.SSH_ATTESTATION_LOOKUP_SECRET || 'change-me-in-production'; - -/** - * Generate HMAC-based lookup key from SSH key fingerprint - * Prevents fingerprint from being directly used as database key - */ -function getLookupKey(fingerprint: string): string { - return createHmac('sha256', LOOKUP_SECRET) - .update(fingerprint) - .digest('hex'); -} - -/** - * 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') { - const hash = createHash('md5').update(keyData).digest('hex'); - return `MD5:${hash.match(/.{2}/g)?.join(':') || hash}`; - } else { - const hash = createHash('sha256').update(keyData).digest('base64'); - return `SHA256:${hash}`; - } -} - -/** - * Extract SSH key type from public key - */ -function extractSSHKeyType(publicKey: string): string { - const parts = publicKey.trim().split(/\s+/); - return parts[0] || 'unknown'; -} - -/** - * Check and enforce rate limiting on attestation submissions - */ -function checkRateLimit(userPubkey: string): { allowed: boolean; remaining: number } { - const now = Date.now(); - const attempt = submissionAttempts.get(userPubkey); - - if (!attempt || attempt.resetAt < now) { - // Reset or create new attempt - submissionAttempts.set(userPubkey, { - count: 1, - resetAt: now + SUBMISSION_WINDOW_MS - }); - return { allowed: true, remaining: MAX_SUBMISSIONS_PER_HOUR - 1 }; - } - - if (attempt.count >= MAX_SUBMISSIONS_PER_HOUR) { - return { allowed: false, remaining: 0 }; - } - - attempt.count++; - return { allowed: true, remaining: MAX_SUBMISSIONS_PER_HOUR - attempt.count }; -} - -/** - * 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; - fingerprint: string; - 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'); - } - - // Check for revocation tag - const revoked = event.tags.some(t => t[0] === 'revoke' && t[1] === 'true'); - - // Calculate fingerprint - const fingerprint = calculateSSHKeyFingerprint(sshPublicKey); - - return { sshPublicKey, fingerprint, revoked }; -} - -/** - * Store SSH key attestation - * - * @param event - Signed Nostr event (kind 30001) containing SSH public key - * @returns Attestation record - */ -export function storeAttestation(event: NostrEvent): SSHKeyAttestation { - // Verify event signature - if (!verifyEvent(event)) { - throw new Error('Invalid event signature'); - } - - // Verify event kind - if (event.kind !== KIND.SSH_KEY_ATTESTATION) { - throw new Error(`Invalid event kind: expected ${KIND.SSH_KEY_ATTESTATION}, got ${event.kind}`); - } - - // Check rate limiting - const rateLimit = checkRateLimit(event.pubkey); - if (!rateLimit.allowed) { - throw new Error(`Rate limit exceeded. Maximum ${MAX_SUBMISSIONS_PER_HOUR} attestations per hour.`); - } - - // Parse attestation - const { sshPublicKey, fingerprint, revoked } = parseAttestationEvent(event); - - // Check if this is a revocation - if (revoked) { - // Revoke existing attestation - const lookupKey = getLookupKey(fingerprint); - const stored = attestations.get(lookupKey); - - if (stored && stored.attestation.userPubkey === event.pubkey) { - stored.attestation.revoked = true; - stored.attestation.revokedAt = event.created_at; - - logger.info({ - userPubkey: event.pubkey.slice(0, 16) + '...', - fingerprint: fingerprint.slice(0, 20) + '...', - eventId: event.id - }, 'SSH key attestation revoked'); - - return stored.attestation; - } else { - throw new Error('No attestation found to revoke'); - } - } - - // Create new attestation - const lookupKey = getLookupKey(fingerprint); - const existing = attestations.get(lookupKey); - - // If attestation exists and is not revoked, check if it's from the same user - if (existing && !existing.attestation.revoked) { - if (existing.attestation.userPubkey !== event.pubkey) { - throw new Error('SSH key already attested by different user'); - } - // Update existing attestation - existing.attestation.eventId = event.id; - existing.attestation.createdAt = event.created_at; - existing.attestation.revoked = false; - existing.attestation.revokedAt = undefined; - - logger.info({ - userPubkey: event.pubkey.slice(0, 16) + '...', - fingerprint: fingerprint.slice(0, 20) + '...', - eventId: event.id - }, 'SSH key attestation updated'); - - return existing.attestation; - } - - // Create new attestation - const attestation: SSHKeyAttestation = { - eventId: event.id, - userPubkey: event.pubkey, - sshKeyFingerprint: fingerprint, - sshKeyType: extractSSHKeyType(sshPublicKey), - sshPublicKey: sshPublicKey, - createdAt: event.created_at, - revoked: false - }; - - // Store attestation - attestations.set(lookupKey, { attestation, lookupKey }); - - // Index by user pubkey - if (!userAttestations.has(event.pubkey)) { - userAttestations.set(event.pubkey, new Set()); - } - userAttestations.get(event.pubkey)!.add(lookupKey); - - logger.info({ - userPubkey: event.pubkey.slice(0, 16) + '...', - fingerprint: fingerprint.slice(0, 20) + '...', - keyType: attestation.sshKeyType, - eventId: event.id - }, 'SSH key attestation stored'); - - return attestation; -} - -/** - * Verify SSH key attestation - * - * @param sshKeyFingerprint - SSH key fingerprint (MD5 or SHA256 format) - * @returns Attestation if valid, null otherwise - */ -export function verifyAttestation(sshKeyFingerprint: string): SSHKeyAttestation | null { - const lookupKey = getLookupKey(sshKeyFingerprint); - const stored = attestations.get(lookupKey); - - if (!stored) { - return null; - } - - const { attestation } = stored; - - // Check if revoked - if (attestation.revoked) { - return null; - } - - // Verify fingerprint matches - if (attestation.sshKeyFingerprint !== sshKeyFingerprint) { - return null; - } - - return attestation; -} - -/** - * Get all attestations for a user - * - * @param userPubkey - User's Nostr public key (hex) - * @returns Array of attestations (including revoked ones) - */ -export function getUserAttestations(userPubkey: string): SSHKeyAttestation[] { - const lookupKeys = userAttestations.get(userPubkey); - if (!lookupKeys) { - return []; - } - - const results: SSHKeyAttestation[] = []; - for (const lookupKey of lookupKeys) { - const stored = attestations.get(lookupKey); - if (stored && stored.attestation.userPubkey === userPubkey) { - results.push(stored.attestation); - } - } - - return results.sort((a, b) => b.createdAt - a.createdAt); // Newest first -} - -/** - * Revoke an attestation - * - * @param userPubkey - User's Nostr public key - * @param fingerprint - SSH key fingerprint to revoke - * @returns True if revoked, false if not found - */ -export function revokeAttestation(userPubkey: string, fingerprint: string): boolean { - const lookupKey = getLookupKey(fingerprint); - const stored = attestations.get(lookupKey); - - if (!stored || stored.attestation.userPubkey !== userPubkey) { - return false; - } - - stored.attestation.revoked = true; - stored.attestation.revokedAt = Math.floor(Date.now() / 1000); - - logger.info({ - userPubkey: userPubkey.slice(0, 16) + '...', - fingerprint: fingerprint.slice(0, 20) + '...' - }, 'SSH key attestation revoked'); - - return true; -} diff --git a/src/lib/types/nostr.ts b/src/lib/types/nostr.ts index 5406c7c..bd24427 100644 --- a/src/lib/types/nostr.ts +++ b/src/lib/types/nostr.ts @@ -56,7 +56,6 @@ export const KIND = { NIP98_AUTH: 27235, // NIP-98: HTTP authentication event HIGHLIGHT: 9802, // NIP-84: Highlight event PUBLIC_MESSAGE: 24, // NIP-24: Public message (direct chat) - SSH_KEY_ATTESTATION: 30001, // Custom: SSH key attestation (server-side only, not published to relays) } as const; export interface Issue extends NostrEvent { diff --git a/src/lib/utils/nostr-utils.ts b/src/lib/utils/nostr-utils.ts new file mode 100644 index 0000000..71d2419 --- /dev/null +++ b/src/lib/utils/nostr-utils.ts @@ -0,0 +1,72 @@ +/** + * Shared Nostr utility functions + * Used across web-app, CLI, and API to ensure consistency + */ + +import type { NostrEvent } from '../types/nostr.js'; + +/** + * Extract clone URLs from a NIP-34 repo announcement event + * + * This is a shared utility to avoid code duplication across: + * - RepoManager (with URL normalization) + * - Git API endpoint (for performance, without normalization) + * - RepoPollingService + * + * @param event - The Nostr repository announcement event + * @param normalize - Whether to normalize URLs (add .git suffix if needed). Default: false + * @returns Array of clone URLs + */ +export function extractCloneUrls(event: NostrEvent, normalize: boolean = false): string[] { + const urls: string[] = []; + + for (const tag of event.tags) { + if (tag[0] === 'clone') { + for (let i = 1; i < tag.length; i++) { + const url = tag[i]; + if (url && typeof url === 'string') { + if (normalize) { + urls.push(normalizeCloneUrl(url)); + } else { + urls.push(url); + } + } + } + } + } + + return urls; +} + +/** + * Normalize a clone URL to ensure it's cloneable + * Adds .git suffix to HTTPS/HTTP URLs that don't have it + * Handles Gitea URLs that might be missing .git extension + */ +export function normalizeCloneUrl(url: string): string { + // Remove trailing slash + url = url.trim().replace(/\/$/, ''); + + // For HTTPS/HTTP URLs that don't end in .git, check if they're Gitea/GitHub/GitLab style + // Pattern: https://domain.com/owner/repo (without .git) + if ((url.startsWith('https://') || url.startsWith('http://')) && !url.endsWith('.git')) { + // Check if it looks like a git hosting service URL (has at least 2 path segments) + try { + const urlObj = new URL(url); + const pathParts = urlObj.pathname.split('/').filter(p => p); + + // If it has 2+ path segments (e.g., /owner/repo), add .git + if (pathParts.length >= 2) { + // Check if it's not already a file or has an extension + const lastPart = pathParts[pathParts.length - 1]; + if (!lastPart.includes('.')) { + return `${url}.git`; + } + } + } catch { + // URL parsing failed, return original + } + } + + return url; +} diff --git a/src/lib/utils/repo-privacy.ts b/src/lib/utils/repo-privacy.ts index c7e1a4d..33b1e44 100644 --- a/src/lib/utils/repo-privacy.ts +++ b/src/lib/utils/repo-privacy.ts @@ -5,9 +5,32 @@ import { nip19 } from 'nostr-tools'; import { MaintainerService } from '../services/nostr/maintainer-service.js'; import { DEFAULT_NOSTR_RELAYS } from '../config.js'; +import type { NostrEvent } from '../types/nostr.js'; const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS); +/** + * Check if a repository is private based on announcement event + * A repo is private if it has a tag ["private"], ["private", "true"], or ["t", "private"] + * + * This is a shared utility to avoid code duplication across services. + */ +export function isPrivateRepo(announcement: NostrEvent): boolean { + // Check for ["private", "true"] tag + const privateTag = announcement.tags.find(t => t[0] === 'private' && t[1] === 'true'); + if (privateTag) return true; + + // Check for ["private"] tag (just the tag name, no value) + const privateTagOnly = announcement.tags.find(t => t[0] === 'private' && (!t[1] || t[1] === '')); + if (privateTagOnly) return true; + + // Check for ["t", "private"] tag (topic tag) + const topicTag = announcement.tags.find(t => t[0] === 't' && t[1] === 'private'); + if (topicTag) return true; + + return false; +} + /** * Check if a user can view a repository * Returns the repo owner pubkey and whether access is allowed diff --git a/src/routes/api/git/[...path]/+server.ts b/src/routes/api/git/[...path]/+server.ts index d4a1817..285ce9b 100644 --- a/src/routes/api/git/[...path]/+server.ts +++ b/src/routes/api/git/[...path]/+server.ts @@ -21,6 +21,7 @@ import { BranchProtectionService } from '$lib/services/nostr/branch-protection-s import logger from '$lib/services/logger.js'; import { auditLogger } from '$lib/services/security/audit-logger.js'; import { isValidBranchName, sanitizeError } from '$lib/utils/security.js'; +import { extractCloneUrls } from '$lib/utils/nostr-utils.js'; // Resolve GIT_REPO_ROOT to absolute path (handles both relative and absolute paths) const repoRootEnv = process.env.GIT_REPO_ROOT || '/repos'; @@ -127,25 +128,7 @@ async function getRepoAnnouncement(npub: string, repoName: string): Promise { - const requestContext = extractRequestContext(event); - const clientIp = requestContext.clientIp || 'unknown'; - let attestationFingerprint: string | null = null; - - try { - if (!requestContext.userPubkeyHex) { - return error(401, 'Authentication required'); - } - - const body = await event.request.json(); - if (!body.event) { - return error(400, 'Missing event in request body'); - } - - const attestationEvent: NostrEvent = body.event; - - // Calculate fingerprint for audit logging (before storing) - try { - if (attestationEvent.content) { - attestationFingerprint = calculateSSHKeyFingerprint(attestationEvent.content); - } - } catch { - // Ignore fingerprint calculation errors - } - - // Verify event signature - if (!verifyEvent(attestationEvent)) { - return error(400, 'Invalid event signature'); - } - - // Verify event kind - if (attestationEvent.kind !== KIND.SSH_KEY_ATTESTATION) { - return error(400, `Invalid event kind: expected ${KIND.SSH_KEY_ATTESTATION}, got ${attestationEvent.kind}`); - } - - // Verify event is from the authenticated user - if (attestationEvent.pubkey !== requestContext.userPubkeyHex) { - return error(403, 'Event pubkey does not match authenticated user'); - } - - // Check user has unlimited access (same requirement as messaging forwarding) - const userLevel = getCachedUserLevel(requestContext.userPubkeyHex); - if (!hasUnlimitedAccess(userLevel?.level)) { - return error(403, 'SSH key attestation requires unlimited access. Please verify you can write to at least one default Nostr relay.'); - } - - // Store attestation - const attestation = storeAttestation(attestationEvent); - - // Audit log - auditLogger.logSSHKeyAttestation( - requestContext.userPubkeyHex, - attestation.revoked ? 'revoke' : 'submit', - attestation.sshKeyFingerprint, - 'success' - ); - - logger.info({ - userPubkey: requestContext.userPubkeyHex.slice(0, 16) + '...', - fingerprint: attestation.sshKeyFingerprint.slice(0, 20) + '...', - keyType: attestation.sshKeyType, - revoked: attestation.revoked, - clientIp - }, 'SSH key attestation submitted'); - - return json({ - success: true, - attestation: { - eventId: attestation.eventId, - fingerprint: attestation.sshKeyFingerprint, - keyType: attestation.sshKeyType, - createdAt: attestation.createdAt, - revoked: attestation.revoked - } - }); - } catch (e) { - const errorMessage = e instanceof Error ? e.message : 'Failed to store SSH key attestation'; - - // Audit log failure (if we have user context and fingerprint) - if (requestContext.userPubkeyHex && attestationFingerprint) { - auditLogger.logSSHKeyAttestation( - requestContext.userPubkeyHex, - 'submit', - attestationFingerprint, - 'failure', - errorMessage - ); - } - - logger.error({ error: e, clientIp }, 'Failed to store SSH key attestation'); - - if (errorMessage.includes('Rate limit')) { - return error(429, errorMessage); - } - if (errorMessage.includes('already attested')) { - return error(409, errorMessage); - } - - return error(500, errorMessage); - } -}; - -/** - * GET /api/user/ssh-keys - * Get all SSH key attestations for the authenticated user - */ -export const GET: RequestHandler = async (event) => { - const requestContext = extractRequestContext(event); - const clientIp = requestContext.clientIp || 'unknown'; - - try { - if (!requestContext.userPubkeyHex) { - return error(401, 'Authentication required'); - } - - const attestations = getUserAttestations(requestContext.userPubkeyHex); - - return json({ - attestations: attestations.map(a => ({ - eventId: a.eventId, - fingerprint: a.sshKeyFingerprint, - keyType: a.sshKeyType, - createdAt: a.createdAt, - revoked: a.revoked, - revokedAt: a.revokedAt - })) - }); - } catch (e) { - logger.error({ error: e, clientIp }, 'Failed to get SSH key attestations'); - return error(500, 'Failed to retrieve SSH key attestations'); - } -}; - diff --git a/src/routes/api/user/ssh-keys/verify/+server.ts b/src/routes/api/user/ssh-keys/verify/+server.ts deleted file mode 100644 index be8886c..0000000 --- a/src/routes/api/user/ssh-keys/verify/+server.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * SSH Key Verification API - * - * Verify an SSH key fingerprint against stored attestations - */ - -import { json, error } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { extractRequestContext } from '$lib/utils/api-context.js'; -import { verifyAttestation } from '$lib/services/ssh/ssh-key-attestation.js'; -import { auditLogger } from '$lib/services/security/audit-logger.js'; -import logger from '$lib/services/logger.js'; - -/** - * POST /api/user/ssh-keys/verify - * Verify an SSH key fingerprint - * - * Body: { fingerprint: string } - * Returns the attestation if valid - */ -export const POST: RequestHandler = async (event) => { - const requestContext = extractRequestContext(event); - const clientIp = requestContext.clientIp || 'unknown'; - - try { - const body = await event.request.json(); - if (!body.fingerprint) { - return error(400, 'Missing fingerprint in request body'); - } - - const attestation = verifyAttestation(body.fingerprint); - - if (!attestation) { - // Audit log failed verification - auditLogger.logSSHKeyAttestation( - 'unknown', - 'verify', - body.fingerprint, - 'failure', - 'SSH key not attested or attestation revoked' - ); - - return json({ - valid: false, - message: 'SSH key not attested or attestation revoked' - }); - } - - // Audit log successful verification - auditLogger.logSSHKeyAttestation( - attestation.userPubkey, - 'verify', - body.fingerprint, - 'success' - ); - - return json({ - valid: true, - attestation: { - userPubkey: attestation.userPubkey, - fingerprint: attestation.sshKeyFingerprint, - keyType: attestation.sshKeyType, - createdAt: attestation.createdAt - } - }); - } catch (e) { - logger.error({ error: e, clientIp }, 'Failed to verify SSH key'); - return error(500, 'Failed to verify SSH key'); - } -};