6 changed files with 745 additions and 0 deletions
@ -0,0 +1,222 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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