6 changed files with 745 additions and 0 deletions
@ -0,0 +1,222 @@ |
|||||||
|
# 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 |
||||||
|
|
||||||
|
## How It Works |
||||||
|
|
||||||
|
1. **Generate SSH Key** (if you don't have one): |
||||||
|
```bash |
||||||
|
ssh-keygen -t ed25519 -C "your-email@example.com" |
||||||
|
# Or use RSA: ssh-keygen -t rsa -b 4096 -C "your-email@example.com" |
||||||
|
``` |
||||||
|
|
||||||
|
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-email@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 |
||||||
|
} |
||||||
|
] |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
### 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 |
||||||
|
|
||||||
|
## Future Improvements |
||||||
|
|
||||||
|
- Persistent storage (Redis/database) for attestations |
||||||
|
- SSH server implementation |
||||||
|
- Support for multiple SSH keys per user |
||||||
|
- Key expiration/rotation policies |
||||||
|
- Audit logging for SSH operations |
||||||
@ -0,0 +1,332 @@ |
|||||||
|
/** |
||||||
|
* 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... |
||||||
|
*/ |
||||||
|
export function calculateSSHKeyFingerprint(publicKey: string, algorithm: 'md5' | 'sha256' = 'sha256'): string { |
||||||
|
// SSH public keys are in format: "key-type base64-key [comment]"
|
||||||
|
const parts = publicKey.trim().split(/\s+/); |
||||||
|
if (parts.length < 2) { |
||||||
|
throw new Error('Invalid SSH public key format'); |
||||||
|
} |
||||||
|
|
||||||
|
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 |
||||||
|
*/ |
||||||
|
function parseAttestationEvent(event: NostrEvent): { |
||||||
|
sshPublicKey: string; |
||||||
|
fingerprint: string; |
||||||
|
revoked: boolean; |
||||||
|
} { |
||||||
|
// Content should contain the SSH public key
|
||||||
|
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,130 @@ |
|||||||
|
/** |
||||||
|
* 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 { 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'; |
||||||
|
|
||||||
|
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; |
||||||
|
|
||||||
|
// 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 (!userLevel || userLevel.level !== 'unlimited') { |
||||||
|
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); |
||||||
|
|
||||||
|
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'; |
||||||
|
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'); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
@ -0,0 +1,52 @@ |
|||||||
|
/** |
||||||
|
* 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 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) { |
||||||
|
return json({ |
||||||
|
valid: false, |
||||||
|
message: 'SSH key not attested or attestation revoked' |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
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