Browse Source
Nostr-Signature: 190b84b2cff8b8db7b3509e05d5470c073fc88e50ba7ad4fa54fd9a9d8dc0045 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 638b9986b5e534d09752125721a04d8cef7af892c0394515d6deb4116c2fcab378313abc270f47a6605f50457d5bb83fdb8b34af0607725b6d774028dc6a4fb6main
17 changed files with 125 additions and 1076 deletions
@ -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,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; |
|
||||||
} |
|
||||||
@ -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; |
||||||
|
} |
||||||
@ -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'); |
|
||||||
} |
|
||||||
}; |
|
||||||
|
|
||||||
@ -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…
Reference in new issue