Browse Source

suggest using nip-05 in ssh keys, rather than email

main
Silberengel 4 weeks ago
parent
commit
47ac25ba7f
  1. 16
      docs/SSH_KEY_ATTESTATION.md
  2. 8
      src/lib/services/git-platforms/git-platform-fetcher.ts
  3. 26
      src/lib/services/git/repo-manager.ts
  4. 15
      src/lib/services/nostr/repo-polling.ts
  5. 14
      src/lib/services/ssh/ssh-key-attestation.ts
  6. 15
      src/routes/api/repos/[npub]/[repo]/fork/+server.ts

16
docs/SSH_KEY_ATTESTATION.md

@ -14,12 +14,22 @@ GitRepublic supports SSH key attestation, allowing you to use standard `git` com
- You must have a Nostr key pair (via NIP-07 browser extension) - You must have a Nostr key pair (via NIP-07 browser extension)
- You must have an SSH key pair - 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 ## How It Works
1. **Generate SSH Key** (if you don't have one): 1. **Generate SSH Key** (if you don't have one):
```bash ```bash
ssh-keygen -t ed25519 -C "your-email@example.com" ssh-keygen -t ed25519 -C "your-nip05@example.com"
# Or use RSA: ssh-keygen -t rsa -b 4096 -C "your-email@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**: 2. **Get Your SSH Public Key**:
@ -55,7 +65,7 @@ GitRepublic supports SSH key attestation, allowing you to use standard `git` com
"pubkey": "your-nostr-pubkey-hex", "pubkey": "your-nostr-pubkey-hex",
"created_at": 1234567890, "created_at": 1234567890,
"tags": [], "tags": [],
"content": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... your-email@example.com", "content": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... your-nip05@example.com",
"id": "event-id-hex", "id": "event-id-hex",
"sig": "event-signature-hex" "sig": "event-signature-hex"
} }

8
src/lib/services/git-platforms/git-platform-fetcher.ts

@ -457,7 +457,13 @@ async function fetchIssues(
const rawIssues = await fetchFromPlatform<any>(url, headers, platform); const rawIssues = await fetchFromPlatform<any>(url, headers, platform);
return rawIssues return rawIssues
.filter((issue: any) => !issue.pull_request) // Exclude PRs (GitHub returns PRs in issues endpoint) .filter((issue: any) => {
// Exclude PRs (GitHub returns PRs in issues endpoint)
if (issue.pull_request) return false;
// GitLab merge requests have iid but are not issues
if (platform === 'gitlab' && issue.merge_request) return false;
return true;
})
.map((issue: any) => normalizeIssue(issue, platform, owner, repo, apiUrl)); .map((issue: any) => normalizeIssue(issue, platform, owner, repo, apiUrl));
} catch (error) { } catch (error) {
logger.error({ error, platform, owner, repo }, 'Failed to fetch issues'); logger.error({ error, platform, owner, repo }, 'Failed to fetch issues');

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

@ -119,6 +119,17 @@ export class RepoManager {
// Check if repo already exists // Check if repo already exists
const repoExists = existsSync(repoPath.fullPath); const repoExists = existsSync(repoPath.fullPath);
// Security: Only allow new repo creation if user has unlimited access
// This prevents spam and abuse
const isNewRepo = !repoExists;
if (isNewRepo && !isExistingRepo) {
const { getCachedUserLevel } = await import('../security/user-level-cache.js');
const userLevel = getCachedUserLevel(event.pubkey);
if (!userLevel || userLevel.level !== 'unlimited') {
throw new Error(`Repository creation requires unlimited access. User has level: ${userLevel?.level || 'none'}`);
}
}
// If there are other clone URLs, sync from them first (for existing repos) // If there are other clone URLs, sync from them first (for existing repos)
const otherUrls = cloneUrls.filter(url => !url.includes(this.domain)); const otherUrls = cloneUrls.filter(url => !url.includes(this.domain));
if (otherUrls.length > 0 && repoExists) { if (otherUrls.length > 0 && repoExists) {
@ -127,7 +138,6 @@ export class RepoManager {
} }
// Create bare repository if it doesn't exist // Create bare repository if it doesn't exist
const isNewRepo = !repoExists;
if (isNewRepo) { if (isNewRepo) {
// Use simple-git to create bare repo (safer than exec) // Use simple-git to create bare repo (safer than exec)
const git = simpleGit(); const git = simpleGit();
@ -455,6 +465,20 @@ export class RepoManager {
return false; return false;
} }
// Security: Only allow fetching if user has unlimited access
// This prevents unauthorized repository creation
const { getCachedUserLevel } = await import('../security/user-level-cache.js');
const userLevel = getCachedUserLevel(announcementEvent.pubkey);
if (!userLevel || userLevel.level !== 'unlimited') {
logger.warn({
npub,
repoName,
pubkey: announcementEvent.pubkey.slice(0, 16) + '...',
level: userLevel?.level || 'none'
}, 'Skipping on-demand repo fetch: user does not have unlimited access');
return false;
}
try { try {
// Extract clone URLs from announcement // Extract clone URLs from announcement
const cloneUrls = this.extractCloneUrls(announcementEvent); const cloneUrls = this.extractCloneUrls(announcementEvent);

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

@ -7,6 +7,7 @@ import { KIND } from '../../types/nostr.js';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { RepoManager } from '../git/repo-manager.js'; import { RepoManager } from '../git/repo-manager.js';
import { OwnershipTransferService } from './ownership-transfer-service.js'; import { OwnershipTransferService } from './ownership-transfer-service.js';
import { getCachedUserLevel } from '../security/user-level-cache.js';
import logger from '../logger.js'; import logger from '../logger.js';
export class RepoPollingService { export class RepoPollingService {
@ -162,6 +163,20 @@ export class RepoPollingService {
} }
} }
// Check if user has unlimited access before provisioning new repos
// This prevents spam and abuse
if (!isExistingRepo) {
const userLevel = getCachedUserLevel(event.pubkey);
if (!userLevel || userLevel.level !== 'unlimited') {
logger.warn({
eventId: event.id,
pubkey: event.pubkey.slice(0, 16) + '...',
level: userLevel?.level || 'none'
}, 'Skipping repo provisioning: user does not have unlimited access');
continue;
}
}
// Provision the repo with self-transfer event if available // Provision the repo with self-transfer event if available
await this.repoManager.provisionRepo(event, selfTransferEvent, isExistingRepo); await this.repoManager.provisionRepo(event, selfTransferEvent, isExistingRepo);
logger.info({ eventId: event.id, isExistingRepo }, 'Provisioned repo from announcement'); logger.info({ eventId: event.id, isExistingRepo }, 'Provisioned repo from announcement');

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

@ -77,14 +77,20 @@ function getLookupKey(fingerprint: string): string {
/** /**
* Calculate SSH key fingerprint (MD5 or SHA256) * Calculate SSH key fingerprint (MD5 or SHA256)
* Format: MD5: aa:bb:cc:dd... or SHA256: base64... * 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 { export function calculateSSHKeyFingerprint(publicKey: string, algorithm: 'md5' | 'sha256' = 'sha256'): string {
// SSH public keys are in format: "key-type base64-key [comment]" // 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+/); const parts = publicKey.trim().split(/\s+/);
if (parts.length < 2) { if (parts.length < 2) {
throw new Error('Invalid SSH public key format'); 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'); const keyData = Buffer.from(parts[1], 'base64');
if (algorithm === 'md5') { if (algorithm === 'md5') {
@ -130,6 +136,12 @@ function checkRateLimit(userPubkey: string): { allowed: boolean; remaining: numb
/** /**
* Parse SSH key attestation from Nostr event * 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): { function parseAttestationEvent(event: NostrEvent): {
sshPublicKey: string; sshPublicKey: string;
@ -137,6 +149,8 @@ function parseAttestationEvent(event: NostrEvent): {
revoked: boolean; revoked: boolean;
} { } {
// Content should contain the SSH public key // 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(); const sshPublicKey = event.content.trim();
if (!sshPublicKey) { if (!sshPublicKey) {
throw new Error('SSH public key not found in event content'); throw new Error('SSH public key not found in event content');

15
src/routes/api/repos/[npub]/[repo]/fork/+server.ts

@ -21,6 +21,7 @@ import { isValidBranchName } from '$lib/utils/security.js';
import { ResourceLimits } from '$lib/services/security/resource-limits.js'; import { ResourceLimits } from '$lib/services/security/resource-limits.js';
import { auditLogger } from '$lib/services/security/audit-logger.js'; import { auditLogger } from '$lib/services/security/audit-logger.js';
import { ForkCountService } from '$lib/services/nostr/fork-count-service.js'; import { ForkCountService } from '$lib/services/nostr/fork-count-service.js';
import { getCachedUserLevel } from '$lib/services/security/user-level-cache.js';
import logger from '$lib/services/logger.js'; import logger from '$lib/services/logger.js';
import { handleApiError, handleValidationError, handleNotFoundError, handleAuthorizationError } from '$lib/utils/error-handler.js'; import { handleApiError, handleValidationError, handleNotFoundError, handleAuthorizationError } from '$lib/utils/error-handler.js';
@ -108,6 +109,20 @@ export const POST: RequestHandler = async ({ params, request }) => {
// Determine fork name (use original name if not specified) // Determine fork name (use original name if not specified)
const forkRepoName = forkName || repo; const forkRepoName = forkName || repo;
// Check if user has unlimited access (required for storing repos locally)
const userLevel = getCachedUserLevel(userPubkeyHex);
if (!userLevel || userLevel.level !== 'unlimited') {
const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
auditLogger.logRepoFork(
userPubkeyHex,
`${npub}/${repo}`,
`${userNpub}/${forkRepoName}`,
'failure',
'User does not have unlimited access'
);
return error(403, 'Repository creation requires unlimited access. Please verify you can write to at least one default Nostr relay.');
}
// Check resource limits before forking // Check resource limits before forking
const resourceCheck = await resourceLimits.canCreateRepo(userNpub); const resourceCheck = await resourceLimits.canCreateRepo(userNpub);
if (!resourceCheck.allowed) { if (!resourceCheck.allowed) {

Loading…
Cancel
Save