From 61e08d24d3c697a3b9a6143315d7c44f1b562910 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 18 Feb 2026 11:49:18 +0100 Subject: [PATCH] ssh key attestation --- README.md | 8 + docs/SSH_KEY_ATTESTATION.md | 222 ++++++++++++ src/lib/services/ssh/ssh-key-attestation.ts | 332 ++++++++++++++++++ src/lib/types/nostr.ts | 1 + src/routes/api/user/ssh-keys/+server.ts | 130 +++++++ .../api/user/ssh-keys/verify/+server.ts | 52 +++ 6 files changed, 745 insertions(+) create mode 100644 docs/SSH_KEY_ATTESTATION.md create mode 100644 src/lib/services/ssh/ssh-key-attestation.ts create mode 100644 src/routes/api/user/ssh-keys/+server.ts create mode 100644 src/routes/api/user/ssh-keys/verify/+server.ts diff --git a/README.md b/README.md index cfe2ccb..c5a67f6 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ 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 @@ -95,6 +96,12 @@ 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) @@ -348,6 +355,7 @@ See `docs/SECURITY.md` and `docs/SECURITY_IMPLEMENTATION.md` for detailed inform - `GIT_REPO_ROOT`: Path to store git repositories (default: `/repos`) - `GIT_DOMAIN`: Domain for git repositories (default: `localhost:6543`) - `NOSTR_RELAYS`: Comma-separated list of Nostr relays (default: `wss://theforest.nostr1.com`) +- `SSH_ATTESTATION_LOOKUP_SECRET`: Secret key for HMAC-based SSH key fingerprint lookup (default: `change-me-in-production`). **Important**: Set this to a secure random value in production! - `TOR_SOCKS_PROXY`: Tor SOCKS proxy address (format: `host:port`, default: `127.0.0.1:9050`). Set to empty string to disable Tor support. When configured, the server will automatically route `.onion` addresses through Tor for both Nostr relay connections and git operations. - `TOR_ONION_ADDRESS`: Tor hidden service .onion address (optional). If not set, the server will attempt to read it from Tor's hostname file. When configured, every repository will automatically get a `.onion` clone URL in addition to the regular domain URL, making repositories accessible via Tor even if the server is only running on localhost. diff --git a/docs/SSH_KEY_ATTESTATION.md b/docs/SSH_KEY_ATTESTATION.md new file mode 100644 index 0000000..4a74e6c --- /dev/null +++ b/docs/SSH_KEY_ATTESTATION.md @@ -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 diff --git a/src/lib/services/ssh/ssh-key-attestation.ts b/src/lib/services/ssh/ssh-key-attestation.ts new file mode 100644 index 0000000..da589d1 --- /dev/null +++ b/src/lib/services/ssh/ssh-key-attestation.ts @@ -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(); + +// Index by user pubkey for quick lookup +// Key: userPubkey, Value: Set of lookup keys +const userAttestations = new Map>(); + +// Rate limiting: track submissions per pubkey +interface SubmissionAttempt { + count: number; + resetAt: number; +} + +const submissionAttempts = new Map(); +const MAX_SUBMISSIONS_PER_HOUR = 10; +const SUBMISSION_WINDOW_MS = 60 * 60 * 1000; // 1 hour + +// Cleanup expired rate limit entries +setInterval(() => { + const now = Date.now(); + for (const [key, attempt] of submissionAttempts.entries()) { + if (attempt.resetAt < now) { + submissionAttempts.delete(key); + } + } +}, 5 * 60 * 1000); // Cleanup every 5 minutes + +const LOOKUP_SECRET = process.env.SSH_ATTESTATION_LOOKUP_SECRET || 'change-me-in-production'; + +/** + * Generate HMAC-based lookup key from SSH key fingerprint + * Prevents fingerprint from being directly used as database key + */ +function getLookupKey(fingerprint: string): string { + return createHmac('sha256', LOOKUP_SECRET) + .update(fingerprint) + .digest('hex'); +} + +/** + * Calculate SSH key fingerprint (MD5 or SHA256) + * Format: MD5: aa:bb:cc:dd... or SHA256: base64... + */ +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; +} diff --git a/src/lib/types/nostr.ts b/src/lib/types/nostr.ts index 92506a7..9bebbad 100644 --- a/src/lib/types/nostr.ts +++ b/src/lib/types/nostr.ts @@ -48,6 +48,7 @@ export const KIND = { NIP98_AUTH: 27235, // NIP-98: HTTP authentication event HIGHLIGHT: 9802, // NIP-84: Highlight event PUBLIC_MESSAGE: 24, // NIP-24: Public message (direct chat) + SSH_KEY_ATTESTATION: 30001, // Custom: SSH key attestation (server-side only, not published to relays) } as const; export interface Issue extends NostrEvent { diff --git a/src/routes/api/user/ssh-keys/+server.ts b/src/routes/api/user/ssh-keys/+server.ts new file mode 100644 index 0000000..cdca3de --- /dev/null +++ b/src/routes/api/user/ssh-keys/+server.ts @@ -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'); + } +}; + diff --git a/src/routes/api/user/ssh-keys/verify/+server.ts b/src/routes/api/user/ssh-keys/verify/+server.ts new file mode 100644 index 0000000..070ff29 --- /dev/null +++ b/src/routes/api/user/ssh-keys/verify/+server.ts @@ -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'); + } +};