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 @@ -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 an SSH key pair
## SSH Key Comment Field
The SSH public key comment field (the part after the key data) can contain:
- **NIP-05 identifiers** (e.g., `user@domain.com`) - recommended for Nostr users
- Email addresses (e.g., `user@example.com`)
- Any other identifier
The comment field is optional and does not affect the key fingerprint or authentication. It's purely for identification purposes.
## How It Works
1. **Generate SSH Key** (if you don't have one):
```bash
ssh-keygen -t ed25519 -C "your-email@example.com"
# Or use RSA: ssh-keygen -t rsa -b 4096 -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-nip05@example.com"
# Note: The comment field (-C) can contain your NIP-05 identifier or email address
```
2. **Get Your SSH Public Key**:
@ -55,7 +65,7 @@ GitRepublic supports SSH key attestation, allowing you to use standard `git` com @@ -55,7 +65,7 @@ GitRepublic supports SSH key attestation, allowing you to use standard `git` com
"pubkey": "your-nostr-pubkey-hex",
"created_at": 1234567890,
"tags": [],
"content": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... your-email@example.com",
"content": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... your-nip05@example.com",
"id": "event-id-hex",
"sig": "event-signature-hex"
}

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

@ -457,7 +457,13 @@ async function fetchIssues( @@ -457,7 +457,13 @@ async function fetchIssues(
const rawIssues = await fetchFromPlatform<any>(url, headers, platform);
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));
} catch (error) {
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 { @@ -119,6 +119,17 @@ export class RepoManager {
// Check if repo already exists
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)
const otherUrls = cloneUrls.filter(url => !url.includes(this.domain));
if (otherUrls.length > 0 && repoExists) {
@ -127,7 +138,6 @@ export class RepoManager { @@ -127,7 +138,6 @@ export class RepoManager {
}
// Create bare repository if it doesn't exist
const isNewRepo = !repoExists;
if (isNewRepo) {
// Use simple-git to create bare repo (safer than exec)
const git = simpleGit();
@ -455,6 +465,20 @@ export class RepoManager { @@ -455,6 +465,20 @@ export class RepoManager {
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 {
// Extract clone URLs from announcement
const cloneUrls = this.extractCloneUrls(announcementEvent);

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

@ -7,6 +7,7 @@ import { KIND } from '../../types/nostr.js'; @@ -7,6 +7,7 @@ import { KIND } from '../../types/nostr.js';
import type { NostrEvent } from '../../types/nostr.js';
import { RepoManager } from '../git/repo-manager.js';
import { OwnershipTransferService } from './ownership-transfer-service.js';
import { getCachedUserLevel } from '../security/user-level-cache.js';
import logger from '../logger.js';
export class RepoPollingService {
@ -162,6 +163,20 @@ 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
await this.repoManager.provisionRepo(event, selfTransferEvent, isExistingRepo);
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 { @@ -77,14 +77,20 @@ function getLookupKey(fingerprint: string): string {
/**
* Calculate SSH key fingerprint (MD5 or SHA256)
* Format: MD5: aa:bb:cc:dd... or SHA256: base64...
*
* SSH public keys are in format: "key-type base64-key [comment]"
* The comment field (optional) can contain NIP-05 identifiers or email addresses.
* Only the key-type and base64-key are used for fingerprint calculation.
*/
export function calculateSSHKeyFingerprint(publicKey: string, algorithm: 'md5' | 'sha256' = 'sha256'): string {
// SSH public keys are in format: "key-type base64-key [comment]"
// Comment field is optional and can contain NIP-05 identifiers (e.g., "user@domain.com")
const parts = publicKey.trim().split(/\s+/);
if (parts.length < 2) {
throw new Error('Invalid SSH public key format');
}
// Only use the key data (parts[1]) for fingerprint, ignore comment (parts[2+])
const keyData = Buffer.from(parts[1], 'base64');
if (algorithm === 'md5') {
@ -130,6 +136,12 @@ function checkRateLimit(userPubkey: string): { allowed: boolean; remaining: numb @@ -130,6 +136,12 @@ function checkRateLimit(userPubkey: string): { allowed: boolean; remaining: numb
/**
* Parse SSH key attestation from Nostr event
*
* SSH public keys are in the format: "key-type base64-key [comment]"
* The comment field is optional and can contain:
* - Email addresses (e.g., "user@example.com")
* - NIP-05 identifiers (e.g., "user@domain.com" - same format as email)
* - Any other identifier
*/
function parseAttestationEvent(event: NostrEvent): {
sshPublicKey: string;
@ -137,6 +149,8 @@ function parseAttestationEvent(event: NostrEvent): { @@ -137,6 +149,8 @@ function parseAttestationEvent(event: NostrEvent): {
revoked: boolean;
} {
// Content should contain the SSH public key
// Format: "ssh-rsa AAAAB3NzaC1yc2E... [comment]"
// The comment field (after the key data) can contain NIP-05 identifiers or email addresses
const sshPublicKey = event.content.trim();
if (!sshPublicKey) {
throw new Error('SSH public key not found in event content');

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

@ -21,6 +21,7 @@ import { isValidBranchName } from '$lib/utils/security.js'; @@ -21,6 +21,7 @@ import { isValidBranchName } from '$lib/utils/security.js';
import { ResourceLimits } from '$lib/services/security/resource-limits.js';
import { auditLogger } from '$lib/services/security/audit-logger.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 { handleApiError, handleValidationError, handleNotFoundError, handleAuthorizationError } from '$lib/utils/error-handler.js';
@ -108,6 +109,20 @@ export const POST: RequestHandler = async ({ params, request }) => { @@ -108,6 +109,20 @@ export const POST: RequestHandler = async ({ params, request }) => {
// Determine fork name (use original name if not specified)
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
const resourceCheck = await resourceLimits.canCreateRepo(userNpub);
if (!resourceCheck.allowed) {

Loading…
Cancel
Save