Browse Source

refactor

Nostr-Signature: 190b84b2cff8b8db7b3509e05d5470c073fc88e50ba7ad4fa54fd9a9d8dc0045 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 638b9986b5e534d09752125721a04d8cef7af892c0394515d6deb4116c2fcab378313abc270f47a6605f50457d5bb83fdb8b34af0607725b6d774028dc6a4fb6
main
Silberengel 3 weeks ago
parent
commit
ada014e237
  1. 8
      README.md
  2. 241
      docs/SSH_KEY_ATTESTATION.md
  3. 1
      nostr/commit-signatures.jsonl
  4. 20
      src/lib/config.ts
  5. 16
      src/lib/services/git/file-manager.ts
  6. 63
      src/lib/services/git/repo-manager.ts
  7. 17
      src/lib/services/nostr/maintainer-service.ts
  8. 17
      src/lib/services/nostr/repo-polling.ts
  9. 19
      src/lib/services/security/audit-logger.ts
  10. 346
      src/lib/services/ssh/ssh-key-attestation.ts
  11. 1
      src/lib/types/nostr.ts
  12. 72
      src/lib/utils/nostr-utils.ts
  13. 23
      src/lib/utils/repo-privacy.ts
  14. 21
      src/routes/api/git/[...path]/+server.ts
  15. 104
      src/routes/api/openapi.json/openapi.json
  16. 162
      src/routes/api/user/ssh-keys/+server.ts
  17. 70
      src/routes/api/user/ssh-keys/verify/+server.ts

8
README.md

@ -10,7 +10,6 @@ See [ARCHITECTURE_FAQ.md](./docs/ARCHITECTURE_FAQ.md) for answers to common arch @@ -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: @@ -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 @@ -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.

241
docs/SSH_KEY_ATTESTATION.md

@ -1,241 +0,0 @@ @@ -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

1
nostr/commit-signatures.jsonl

@ -25,3 +25,4 @@ @@ -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"}

20
src/lib/config.ts

@ -13,30 +13,34 @@ export const GIT_DOMAIN = @@ -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',
];
/**

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

@ -947,19 +947,9 @@ export class FileManager { @@ -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');

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

@ -14,6 +14,8 @@ import simpleGit, { type SimpleGit } from 'simple-git'; @@ -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 @@ -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 @@ -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(

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

@ -9,6 +9,7 @@ import type { NostrEvent } from '../../types/nostr.js'; @@ -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 { @@ -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);
}
/**

17
src/lib/services/nostr/repo-polling.ts

@ -9,6 +9,7 @@ import { RepoManager } from '../git/repo-manager.js'; @@ -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 { @@ -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);
}
}

19
src/lib/services/security/audit-logger.ts

@ -338,25 +338,6 @@ export class AuditLogger { @@ -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

346
src/lib/services/ssh/ssh-key-attestation.ts

@ -1,346 +0,0 @@ @@ -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<string, StoredAttestation>();
// Index by user pubkey for quick lookup
// Key: userPubkey, Value: Set of lookup keys
const userAttestations = new Map<string, Set<string>>();
// Rate limiting: track submissions per pubkey
interface SubmissionAttempt {
count: number;
resetAt: number;
}
const submissionAttempts = new Map<string, SubmissionAttempt>();
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;
}

1
src/lib/types/nostr.ts

@ -56,7 +56,6 @@ export const KIND = { @@ -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 {

72
src/lib/utils/nostr-utils.ts

@ -0,0 +1,72 @@ @@ -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;
}

23
src/lib/utils/repo-privacy.ts

@ -5,9 +5,32 @@ @@ -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

21
src/routes/api/git/[...path]/+server.ts

@ -21,6 +21,7 @@ import { BranchProtectionService } from '$lib/services/nostr/branch-protection-s @@ -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<Nost @@ -127,25 +128,7 @@ async function getRepoAnnouncement(npub: string, repoName: string): Promise<Nost
}
}
/**
* Extract clone URLs from repository announcement
* Note: This duplicates logic from RepoManager.extractCloneUrls, but is kept here
* for performance (avoiding instantiation of RepoManager just for this)
*/
function 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;
}
// Note: Using shared extractCloneUrls utility (without normalization for performance)
/**
* Normalize Authorization header from git credential helper format

104
src/routes/api/openapi.json/openapi.json

@ -1796,66 +1796,6 @@ @@ -1796,66 +1796,6 @@
}
}
},
"/api/user/ssh-keys": {
"get": {
"summary": "Get SSH key attestations",
"description": "Get all SSH key attestations for authenticated user",
"tags": ["User"],
"security": [{"NIP98": []}],
"responses": {
"200": {
"description": "SSH key attestations",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"attestations": {
"type": "array",
"items": {
"type": "object",
"properties": {
"eventId": {"type": "string"},
"fingerprint": {"type": "string"},
"keyType": {"type": "string"},
"createdAt": {"type": "integer"},
"revoked": {"type": "boolean"}
}
}
}
}
}
}
}
}
}
},
"post": {
"summary": "Submit SSH key attestation",
"description": "Submit an SSH key attestation event. Requires unlimited access.",
"tags": ["User"],
"security": [{"NIP98": []}],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["event"],
"properties": {
"event": {"$ref": "#/components/schemas/NostrEvent"}
}
}
}
}
},
"responses": {
"200": {
"description": "Attestation stored"
}
}
}
},
"/api/users/{npub}/repos": {
"get": {
"summary": "List user repositories",
@ -2013,50 +1953,6 @@ @@ -2013,50 +1953,6 @@
}
}
}
},
"/api/user/ssh-keys/verify": {
"post": {
"summary": "Verify SSH key fingerprint",
"description": "Verify an SSH key fingerprint against stored attestations",
"tags": ["User"],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["fingerprint"],
"properties": {
"fingerprint": {"type": "string"}
}
}
}
}
},
"responses": {
"200": {
"description": "Verification result",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"valid": {"type": "boolean"},
"attestation": {
"type": "object",
"properties": {
"userPubkey": {"type": "string"},
"fingerprint": {"type": "string"},
"keyType": {"type": "string"}
}
}
}
}
}
}
}
}
}
}
}
}

162
src/routes/api/user/ssh-keys/+server.ts

@ -1,162 +0,0 @@ @@ -1,162 +0,0 @@
/**
* SSH Key Attestation API
*
* Allows users to submit Nostr-signed events that attest to ownership of SSH keys.
* These attestations are stored server-side only (not published to Nostr relays).
*/
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { extractRequestContext } from '$lib/utils/api-context.js';
import { storeAttestation, getUserAttestations, verifyAttestation, calculateSSHKeyFingerprint } from '$lib/services/ssh/ssh-key-attestation.js';
import { getCachedUserLevel } from '$lib/services/security/user-level-cache.js';
import { hasUnlimitedAccess } from '$lib/utils/user-access.js';
import { auditLogger } from '$lib/services/security/audit-logger.js';
import { verifyEvent } from 'nostr-tools';
import type { NostrEvent } from '$lib/types/nostr.js';
import { KIND } from '$lib/types/nostr.js';
import logger from '$lib/services/logger.js';
/**
* POST /api/user/ssh-keys
* Submit an SSH key attestation event
*
* Body: { event: NostrEvent }
* - event.kind must be 30001 (SSH_KEY_ATTESTATION)
* - event.content must contain the SSH public key
* - event must be signed by the user's Nostr key
*/
export const POST: RequestHandler = async (event) => {
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');
}
};

70
src/routes/api/user/ssh-keys/verify/+server.ts

@ -1,70 +0,0 @@ @@ -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');
}
};
Loading…
Cancel
Save