19 changed files with 2928 additions and 41 deletions
@ -0,0 +1,396 @@ |
|||||||
|
# Messaging Forwarding Feature |
||||||
|
|
||||||
|
This feature allows users with **unlimited access** to forward Nostr events to messaging platforms (Telegram, SimpleX, Email) and Git hosting platforms (GitHub, GitLab, Gitea, Codeberg, Forgejo) when they publish events. |
||||||
|
|
||||||
|
## Security Architecture |
||||||
|
|
||||||
|
### Multi-Layer Security |
||||||
|
|
||||||
|
1. **Encrypted Salt Storage**: Each user's salt is encrypted with a separate key (`MESSAGING_SALT_ENCRYPTION_KEY`) |
||||||
|
2. **HMAC Lookup Keys**: User pubkeys are hashed with HMAC before being used as database keys |
||||||
|
3. **Rate Limiting**: Decryption attempts are rate-limited (10 attempts per 15 minutes) |
||||||
|
4. **Per-User Key Derivation**: Encryption keys derived from master key + pubkey + salt |
||||||
|
5. **AES-256-GCM**: Authenticated encryption with random IV per encryption |
||||||
|
|
||||||
|
### Threat Mitigation |
||||||
|
|
||||||
|
- **Database Compromise**: All data encrypted at rest with per-user keys |
||||||
|
- **Key Leakage**: Per-user key derivation means master key leak doesn't expose all users |
||||||
|
- **Brute Force**: Rate limiting prevents rapid decryption attempts |
||||||
|
- **Lookup Attacks**: HMAC hashing prevents pubkey enumeration |
||||||
|
|
||||||
|
## Environment Variables |
||||||
|
|
||||||
|
### Required for Encryption |
||||||
|
|
||||||
|
```bash |
||||||
|
# Master encryption key (256-bit hex, generate with: openssl rand -hex 32) |
||||||
|
MESSAGING_PREFS_ENCRYPTION_KEY=<256-bit-hex> |
||||||
|
|
||||||
|
# Salt encryption key (256-bit hex, generate with: openssl rand -hex 32) |
||||||
|
MESSAGING_SALT_ENCRYPTION_KEY=<256-bit-hex> |
||||||
|
|
||||||
|
# HMAC secret for lookup key hashing (256-bit hex, generate with: openssl rand -hex 32) |
||||||
|
MESSAGING_LOOKUP_SECRET=<256-bit-hex> |
||||||
|
``` |
||||||
|
|
||||||
|
### Optional for Messaging Platforms |
||||||
|
|
||||||
|
```bash |
||||||
|
# Telegram Bot Configuration |
||||||
|
TELEGRAM_BOT_TOKEN=<bot-token> |
||||||
|
TELEGRAM_ENABLED=true |
||||||
|
|
||||||
|
# Email SMTP Configuration |
||||||
|
SMTP_HOST=<smtp-host> |
||||||
|
SMTP_PORT=587 |
||||||
|
SMTP_USER=<smtp-username> |
||||||
|
SMTP_PASSWORD=<smtp-password> |
||||||
|
SMTP_FROM_ADDRESS=<from-email> |
||||||
|
SMTP_FROM_NAME=GitRepublic |
||||||
|
EMAIL_ENABLED=true |
||||||
|
|
||||||
|
# OR use SMTP API (alternative to direct SMTP) |
||||||
|
SMTP_API_URL=<smtp-api-url> |
||||||
|
SMTP_API_KEY=<api-key> |
||||||
|
|
||||||
|
# SimpleX API Configuration |
||||||
|
SIMPLEX_API_URL=<simplex-api-url> |
||||||
|
SIMPLEX_API_KEY=<api-key> |
||||||
|
SIMPLEX_ENABLED=true |
||||||
|
|
||||||
|
# Git Platforms Forwarding Configuration |
||||||
|
GIT_PLATFORMS_ENABLED=true |
||||||
|
``` |
||||||
|
|
||||||
|
## Setup |
||||||
|
|
||||||
|
### 1. Generate Encryption Keys |
||||||
|
|
||||||
|
```bash |
||||||
|
# Generate all three required keys |
||||||
|
openssl rand -hex 32 # For MESSAGING_PREFS_ENCRYPTION_KEY |
||||||
|
openssl rand -hex 32 # For MESSAGING_SALT_ENCRYPTION_KEY |
||||||
|
openssl rand -hex 32 # For MESSAGING_LOOKUP_SECRET |
||||||
|
``` |
||||||
|
|
||||||
|
### 2. Store Keys Securely |
||||||
|
|
||||||
|
**Development:** |
||||||
|
- Store in `.env` file (never commit to git) |
||||||
|
|
||||||
|
**Production:** |
||||||
|
- Use secret management service (AWS Secrets Manager, HashiCorp Vault, etc.) |
||||||
|
- Or environment variables in deployment platform |
||||||
|
- Consider using Hardware Security Modules (HSM) for maximum security |
||||||
|
|
||||||
|
### 3. Configure Messaging Platforms |
||||||
|
|
||||||
|
#### Telegram |
||||||
|
1. Create a bot with [@BotFather](https://t.me/botfather) |
||||||
|
2. Get bot token |
||||||
|
3. Users will provide their chat ID when configuring preferences |
||||||
|
|
||||||
|
#### Email |
||||||
|
1. Configure SMTP settings (host, port, credentials) |
||||||
|
2. Or use an SMTP API service |
||||||
|
3. Users will provide their email addresses (to/cc) |
||||||
|
|
||||||
|
#### SimpleX |
||||||
|
1. Set up SimpleX Chat API |
||||||
|
2. Configure API URL and key |
||||||
|
3. Users will provide their contact ID |
||||||
|
|
||||||
|
#### Git Platforms (GitHub, GitLab, Gitea, Codeberg, Forgejo) |
||||||
|
1. Users create Personal Access Tokens with appropriate scopes |
||||||
|
2. Users provide platform, username/org, repository name, and token |
||||||
|
3. Events will be forwarded as issues or PRs on the selected platform |
||||||
|
4. Supports self-hosted instances via custom API URL |
||||||
|
|
||||||
|
## User Flow |
||||||
|
|
||||||
|
### 1. User Saves Preferences (Client-Side) |
||||||
|
|
||||||
|
```typescript |
||||||
|
// User encrypts preferences to self on Nostr (kind 30078) |
||||||
|
const encryptedContent = await window.nostr.nip44.encrypt( |
||||||
|
userPubkey, |
||||||
|
JSON.stringify(preferences) |
||||||
|
); |
||||||
|
|
||||||
|
// Publish to Nostr (backup/sync) |
||||||
|
const event = { |
||||||
|
kind: 30078, |
||||||
|
pubkey: userPubkey, |
||||||
|
tags: [['d', 'gitrepublic-messaging'], ['enabled', 'true']], |
||||||
|
content: encryptedContent |
||||||
|
}; |
||||||
|
|
||||||
|
// Sign and publish |
||||||
|
const signedEvent = await signEventWithNIP07(event); |
||||||
|
await nostrClient.publishEvent(signedEvent); |
||||||
|
|
||||||
|
// Send decrypted copy to server (over HTTPS) |
||||||
|
await fetch('/api/user/messaging-preferences', { |
||||||
|
method: 'POST', |
||||||
|
body: JSON.stringify({ |
||||||
|
preferences, |
||||||
|
proofEvent: signedEvent |
||||||
|
}) |
||||||
|
}); |
||||||
|
``` |
||||||
|
|
||||||
|
### 2. Server Stores Securely |
||||||
|
|
||||||
|
- Verifies proof event signature |
||||||
|
- Checks user has unlimited access |
||||||
|
- Generates random salt |
||||||
|
- Encrypts salt with `MESSAGING_SALT_ENCRYPTION_KEY` |
||||||
|
- Derives per-user encryption key |
||||||
|
- Encrypts preferences with AES-256-GCM |
||||||
|
- Stores using HMAC lookup key |
||||||
|
|
||||||
|
### 3. Event Forwarding |
||||||
|
|
||||||
|
When user publishes an event (issue, PR, highlight): |
||||||
|
1. Server checks user has unlimited access |
||||||
|
2. Retrieves encrypted preferences |
||||||
|
3. Decrypts (with rate limiting) |
||||||
|
4. Checks if forwarding enabled and event kind matches |
||||||
|
5. Forwards to configured platforms |
||||||
|
|
||||||
|
## API Endpoints |
||||||
|
|
||||||
|
### POST `/api/user/messaging-preferences` |
||||||
|
|
||||||
|
Save messaging preferences. |
||||||
|
|
||||||
|
**Request:** |
||||||
|
```json |
||||||
|
{ |
||||||
|
"preferences": { |
||||||
|
"telegram": "@username", |
||||||
|
"simplex": "contact-id", |
||||||
|
"email": { |
||||||
|
"to": ["user@example.com"], |
||||||
|
"cc": ["cc@example.com"] |
||||||
|
}, |
||||||
|
"gitPlatforms": [ |
||||||
|
{ |
||||||
|
"platform": "github", |
||||||
|
"owner": "username", |
||||||
|
"repo": "repository-name", |
||||||
|
"token": "ghp_xxxxxxxxxxxx" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"platform": "gitlab", |
||||||
|
"owner": "username", |
||||||
|
"repo": "repository-name", |
||||||
|
"token": "glpat-xxxxxxxxxxxx" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"platform": "codeberg", |
||||||
|
"owner": "username", |
||||||
|
"repo": "repository-name", |
||||||
|
"token": "xxxxxxxxxxxx" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"platform": "onedev", |
||||||
|
"owner": "project-path", |
||||||
|
"repo": "repository-name", |
||||||
|
"token": "xxxxxxxxxxxx", |
||||||
|
"apiUrl": "https://your-onedev-instance.com" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"platform": "custom", |
||||||
|
"owner": "username", |
||||||
|
"repo": "repository-name", |
||||||
|
"token": "xxxxxxxxxxxx", |
||||||
|
"apiUrl": "https://your-git-instance.com/api/v1" |
||||||
|
} |
||||||
|
], |
||||||
|
"enabled": true, |
||||||
|
"notifyOn": ["1621", "1618"] |
||||||
|
}, |
||||||
|
"proofEvent": { /* Signed Nostr event (kind 30078) */ } |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
**Response:** |
||||||
|
```json |
||||||
|
{ |
||||||
|
"success": true |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
### GET `/api/user/messaging-preferences` |
||||||
|
|
||||||
|
Get preferences status (without decrypting). |
||||||
|
|
||||||
|
**Response:** |
||||||
|
```json |
||||||
|
{ |
||||||
|
"configured": true, |
||||||
|
"rateLimit": { |
||||||
|
"remaining": 10, |
||||||
|
"resetAt": null |
||||||
|
} |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
### DELETE `/api/user/messaging-preferences` |
||||||
|
|
||||||
|
Delete messaging preferences. |
||||||
|
|
||||||
|
**Response:** |
||||||
|
```json |
||||||
|
{ |
||||||
|
"success": true |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
## Security Best Practices |
||||||
|
|
||||||
|
1. **Key Management** |
||||||
|
- Never commit keys to git |
||||||
|
- Rotate keys periodically |
||||||
|
- Use secret management services in production |
||||||
|
- Consider HSM for maximum security |
||||||
|
|
||||||
|
2. **Monitoring** |
||||||
|
- Monitor rate limit violations |
||||||
|
- Alert on decryption failures |
||||||
|
- Audit log all preference changes |
||||||
|
|
||||||
|
3. **Access Control** |
||||||
|
- Only users with unlimited access can use this feature |
||||||
|
- Requires valid signed Nostr event proof |
||||||
|
- Server verifies all inputs |
||||||
|
|
||||||
|
4. **Data Protection** |
||||||
|
- All data encrypted at rest |
||||||
|
- Per-user key derivation |
||||||
|
- HMAC lookup keys |
||||||
|
- Rate limiting on decryption |
||||||
|
|
||||||
|
## Troubleshooting |
||||||
|
|
||||||
|
### "Decryption rate limit exceeded" |
||||||
|
|
||||||
|
User has exceeded 10 decryption attempts in 15 minutes. Wait for the window to reset. |
||||||
|
|
||||||
|
### "Messaging forwarding requires unlimited access" |
||||||
|
|
||||||
|
User must have relay write access (unlimited level) to use this feature. |
||||||
|
|
||||||
|
### "Failed to forward event" |
||||||
|
|
||||||
|
Check: |
||||||
|
- Messaging platform API credentials |
||||||
|
- Network connectivity |
||||||
|
- Platform-specific error logs |
||||||
|
|
||||||
|
## Email Setup |
||||||
|
|
||||||
|
### Option 1: Direct SMTP |
||||||
|
|
||||||
|
Install nodemailer: |
||||||
|
```bash |
||||||
|
npm install nodemailer |
||||||
|
``` |
||||||
|
|
||||||
|
Configure environment variables: |
||||||
|
```bash |
||||||
|
SMTP_HOST=smtp.gmail.com |
||||||
|
SMTP_PORT=587 |
||||||
|
SMTP_USER=your-email@gmail.com |
||||||
|
SMTP_PASSWORD=your-app-password |
||||||
|
SMTP_FROM_ADDRESS=noreply@yourdomain.com |
||||||
|
SMTP_FROM_NAME=GitRepublic |
||||||
|
EMAIL_ENABLED=true |
||||||
|
``` |
||||||
|
|
||||||
|
### Option 2: SMTP API |
||||||
|
|
||||||
|
Use an SMTP API service (e.g., SendGrid, Mailgun, AWS SES): |
||||||
|
```bash |
||||||
|
SMTP_API_URL=https://api.sendgrid.com/v3/mail/send |
||||||
|
SMTP_API_KEY=your-api-key |
||||||
|
EMAIL_ENABLED=true |
||||||
|
``` |
||||||
|
|
||||||
|
## Git Platforms Setup |
||||||
|
|
||||||
|
### Supported Platforms |
||||||
|
|
||||||
|
- **GitHub** (`github`) - github.com |
||||||
|
- **GitLab** (`gitlab`) - gitlab.com (also supports self-hosted) |
||||||
|
- **Gitea** (`gitea`) - Self-hosted instances |
||||||
|
- **Codeberg** (`codeberg`) - codeberg.org |
||||||
|
- **Forgejo** (`forgejo`) - Self-hosted instances |
||||||
|
- **OneDev** (`onedev`) - Self-hosted instances (requires apiUrl) |
||||||
|
- **Custom** (`custom`) - Any Gitea-compatible API with custom URL |
||||||
|
|
||||||
|
### Creating Personal Access Tokens |
||||||
|
|
||||||
|
#### GitHub |
||||||
|
1. Go to Settings → Developer settings → Personal access tokens → Tokens (classic) |
||||||
|
2. Click "Generate new token (classic)" |
||||||
|
3. Select `repo` scope |
||||||
|
4. Generate and copy token |
||||||
|
|
||||||
|
#### GitLab |
||||||
|
1. Go to Settings → Access Tokens |
||||||
|
2. Create token with `api` scope |
||||||
|
3. Generate and copy token |
||||||
|
|
||||||
|
#### Gitea/Codeberg/Forgejo |
||||||
|
1. Go to Settings → Applications → Generate New Token |
||||||
|
2. Select `repo` scope |
||||||
|
3. Generate and copy token |
||||||
|
|
||||||
|
#### OneDev |
||||||
|
1. Go to User Settings → Access Tokens |
||||||
|
2. Create a new access token |
||||||
|
3. Select appropriate scopes (typically `write:issue` and `write:pull-request`) |
||||||
|
4. Generate and copy token |
||||||
|
5. **Note**: OneDev is self-hosted, so you must provide the `apiUrl` (e.g., `https://your-onedev-instance.com`) |
||||||
|
|
||||||
|
### User Configuration |
||||||
|
|
||||||
|
Users provide: |
||||||
|
- **Platform**: One of `github`, `gitlab`, `gitea`, `codeberg`, `forgejo`, `onedev`, or `custom` |
||||||
|
- **Owner**: Username or organization name (project path for OneDev) |
||||||
|
- **Repo**: Repository name (project name for OneDev) |
||||||
|
- **Token**: Personal access token (stored encrypted) |
||||||
|
- **API URL** (required for OneDev, optional for custom/self-hosted): Base URL of the instance (e.g., `https://your-onedev-instance.com`) |
||||||
|
|
||||||
|
### Event Mapping |
||||||
|
|
||||||
|
- **Nostr Issues (kind 1621)** → Platform Issues |
||||||
|
- **Nostr PRs (kind 1618)** → Platform Pull Requests/Merge Requests (if branch info available) or Issues with PR label |
||||||
|
- **Other events** → Platform Issues with event kind label |
||||||
|
|
||||||
|
### Platform-Specific Notes |
||||||
|
|
||||||
|
- **GitLab**: Uses `description` field instead of `body`, and `source_branch`/`target_branch` for PRs |
||||||
|
- **OneDev**: Uses `description` field and `source_branch`/`target_branch` for PRs. Requires `apiUrl` (self-hosted). API endpoints: `/api/projects/{owner}/{repo}/issues` and `/api/projects/{owner}/{repo}/pull-requests` |
||||||
|
- **GitHub**: Uses `body` field and `head`/`base` for PRs |
||||||
|
- **Gitea/Codeberg/Forgejo**: Compatible with GitHub API format |
||||||
|
- **Custom**: Must provide `apiUrl` pointing to Gitea-compatible API |
||||||
|
|
||||||
|
### Security Note |
||||||
|
|
||||||
|
All tokens are stored encrypted in the database. Users should: |
||||||
|
- Use tokens with minimal required scopes |
||||||
|
- Rotate tokens periodically |
||||||
|
- Revoke tokens if compromised |
||||||
|
|
||||||
|
## Future Enhancements |
||||||
|
|
||||||
|
- [ ] Support for more messaging platforms |
||||||
|
- [ ] User-configurable message templates |
||||||
|
- [ ] Webhook support |
||||||
|
- [ ] Encrypted preferences sync across devices |
||||||
|
- [ ] Per-repository forwarding rules |
||||||
|
- [ ] HTML email templates |
||||||
@ -0,0 +1,635 @@ |
|||||||
|
/** |
||||||
|
* Event forwarding service |
||||||
|
* Forwards Nostr events to messaging platforms (Telegram, SimpleX, Email, Git platforms) |
||||||
|
* Only for users with unlimited access and configured preferences |
||||||
|
*/ |
||||||
|
|
||||||
|
import logger from '../logger.js'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
import { getPreferences } from './preferences-storage.js'; |
||||||
|
import { getCachedUserLevel } from '../security/user-level-cache.js'; |
||||||
|
import { KIND } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types & Interfaces
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface MessagingConfig { |
||||||
|
telegram?: { |
||||||
|
botToken: string; |
||||||
|
enabled: boolean; |
||||||
|
}; |
||||||
|
simplex?: { |
||||||
|
apiUrl: string; |
||||||
|
apiKey: string; |
||||||
|
enabled: boolean; |
||||||
|
}; |
||||||
|
email?: { |
||||||
|
smtpHost: string; |
||||||
|
smtpPort: number; |
||||||
|
smtpUser: string; |
||||||
|
smtpPassword: string; |
||||||
|
fromAddress: string; |
||||||
|
fromName: string; |
||||||
|
enabled: boolean; |
||||||
|
}; |
||||||
|
gitPlatforms?: { |
||||||
|
enabled: boolean; |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
type GitPlatform = 'github' | 'gitlab' | 'gitea' | 'codeberg' | 'forgejo' | 'onedev' | 'custom'; |
||||||
|
|
||||||
|
interface GitPlatformConfig { |
||||||
|
baseUrl: string; |
||||||
|
issuesPath: string; |
||||||
|
pullsPath: string; |
||||||
|
authHeader: 'Bearer' | 'token'; |
||||||
|
usesDescription: boolean; // true for GitLab/OneDev, false for GitHub/Gitea/etc
|
||||||
|
usesSourceTargetBranch: boolean; // true for GitLab/OneDev, false for GitHub/Gitea/etc
|
||||||
|
customHeaders?: Record<string, string>; |
||||||
|
} |
||||||
|
|
||||||
|
interface EventContent { |
||||||
|
title: string; |
||||||
|
body: string; |
||||||
|
fullBody: string; |
||||||
|
} |
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Constants
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const KIND_NAMES: Record<number, string> = { |
||||||
|
1621: 'Issue', |
||||||
|
1618: 'Pull Request', |
||||||
|
9802: 'Highlight', |
||||||
|
30617: 'Repository Announcement', |
||||||
|
1641: 'Ownership Transfer', |
||||||
|
24: 'Public Message' |
||||||
|
}; |
||||||
|
|
||||||
|
const GIT_PLATFORM_CONFIGS: Record<string, Omit<GitPlatformConfig, 'baseUrl'>> = { |
||||||
|
github: { |
||||||
|
issuesPath: '/repos/{owner}/{repo}/issues', |
||||||
|
pullsPath: '/repos/{owner}/{repo}/pulls', |
||||||
|
authHeader: 'Bearer', |
||||||
|
usesDescription: false, |
||||||
|
usesSourceTargetBranch: false, |
||||||
|
customHeaders: { 'Accept': 'application/vnd.github.v3+json' } |
||||||
|
}, |
||||||
|
gitlab: { |
||||||
|
issuesPath: '/projects/{owner}%2F{repo}/issues', |
||||||
|
pullsPath: '/projects/{owner}%2F{repo}/merge_requests', |
||||||
|
authHeader: 'Bearer', |
||||||
|
usesDescription: true, |
||||||
|
usesSourceTargetBranch: true |
||||||
|
}, |
||||||
|
gitea: { |
||||||
|
issuesPath: '/repos/{owner}/{repo}/issues', |
||||||
|
pullsPath: '/repos/{owner}/{repo}/pulls', |
||||||
|
authHeader: 'token', |
||||||
|
usesDescription: false, |
||||||
|
usesSourceTargetBranch: false |
||||||
|
}, |
||||||
|
codeberg: { |
||||||
|
issuesPath: '/repos/{owner}/{repo}/issues', |
||||||
|
pullsPath: '/repos/{owner}/{repo}/pulls', |
||||||
|
authHeader: 'token', |
||||||
|
usesDescription: false, |
||||||
|
usesSourceTargetBranch: false |
||||||
|
}, |
||||||
|
forgejo: { |
||||||
|
issuesPath: '/repos/{owner}/{repo}/issues', |
||||||
|
pullsPath: '/repos/{owner}/{repo}/pulls', |
||||||
|
authHeader: 'token', |
||||||
|
usesDescription: false, |
||||||
|
usesSourceTargetBranch: false |
||||||
|
}, |
||||||
|
onedev: { |
||||||
|
issuesPath: '/api/projects/{owner}/{repo}/issues', |
||||||
|
pullsPath: '/api/projects/{owner}/{repo}/pull-requests', |
||||||
|
authHeader: 'Bearer', |
||||||
|
usesDescription: true, |
||||||
|
usesSourceTargetBranch: true |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
const MESSAGING_CONFIG: MessagingConfig = { |
||||||
|
telegram: { |
||||||
|
botToken: process.env.TELEGRAM_BOT_TOKEN || '', |
||||||
|
enabled: process.env.TELEGRAM_ENABLED === 'true' |
||||||
|
}, |
||||||
|
simplex: { |
||||||
|
apiUrl: process.env.SIMPLEX_API_URL || '', |
||||||
|
apiKey: process.env.SIMPLEX_API_KEY || '', |
||||||
|
enabled: process.env.SIMPLEX_ENABLED === 'true' |
||||||
|
}, |
||||||
|
email: { |
||||||
|
smtpHost: process.env.SMTP_HOST || '', |
||||||
|
smtpPort: parseInt(process.env.SMTP_PORT || '587', 10), |
||||||
|
smtpUser: process.env.SMTP_USER || '', |
||||||
|
smtpPassword: process.env.SMTP_PASSWORD || '', |
||||||
|
fromAddress: process.env.SMTP_FROM_ADDRESS || '', |
||||||
|
fromName: process.env.SMTP_FROM_NAME || 'GitRepublic', |
||||||
|
enabled: process.env.EMAIL_ENABLED === 'true' |
||||||
|
}, |
||||||
|
gitPlatforms: { |
||||||
|
enabled: process.env.GIT_PLATFORMS_ENABLED === 'true' |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function formatEventMessage(event: NostrEvent, userPubkeyHex: string): string { |
||||||
|
const kindName = KIND_NAMES[event.kind] || `Event ${event.kind}`; |
||||||
|
const userShort = userPubkeyHex.slice(0, 16) + '...'; |
||||||
|
|
||||||
|
// Special formatting for public messages (kind 24)
|
||||||
|
if (event.kind === KIND.PUBLIC_MESSAGE) { |
||||||
|
const recipients = event.tags |
||||||
|
.filter(tag => tag[0] === 'p' && tag[1]) |
||||||
|
.map(tag => tag[1].slice(0, 16) + '...'); |
||||||
|
|
||||||
|
let message = `💬 Public Message from ${userShort}`; |
||||||
|
if (recipients.length > 0) { |
||||||
|
message += ` to ${recipients.join(', ')}`; |
||||||
|
} |
||||||
|
message += '\n\n'; |
||||||
|
message += event.content || 'No content'; |
||||||
|
return message; |
||||||
|
} |
||||||
|
|
||||||
|
let message = `🔔 ${kindName} published by ${userShort}\n\n`; |
||||||
|
|
||||||
|
if (event.content) { |
||||||
|
const content = event.content.length > 500
|
||||||
|
? event.content.slice(0, 500) + '...' |
||||||
|
: event.content; |
||||||
|
message += content; |
||||||
|
} else { |
||||||
|
message += 'No content'; |
||||||
|
} |
||||||
|
|
||||||
|
return message; |
||||||
|
} |
||||||
|
|
||||||
|
function extractEventContent(event: NostrEvent): EventContent { |
||||||
|
const titleTag = event.tags.find(t => t[0] === 'title' || t[0] === 'subject'); |
||||||
|
const title = titleTag?.[1] || (event.content ? event.content.split('\n')[0].slice(0, 100) : 'Untitled'); |
||||||
|
const body = event.content || ''; |
||||||
|
const metadata = `\n\n---\n*Forwarded from GitRepublic (Nostr)*\n*Event ID: ${event.id}*\n*Kind: ${event.kind}*`; |
||||||
|
const fullBody = body + metadata; |
||||||
|
|
||||||
|
return { title, body, fullBody }; |
||||||
|
} |
||||||
|
|
||||||
|
function getGitPlatformConfig( |
||||||
|
platform: GitPlatform, |
||||||
|
customApiUrl?: string |
||||||
|
): GitPlatformConfig { |
||||||
|
if (platform === 'onedev') { |
||||||
|
if (!customApiUrl) { |
||||||
|
throw new Error('OneDev requires apiUrl to be provided (self-hosted instance)'); |
||||||
|
} |
||||||
|
return { |
||||||
|
...GIT_PLATFORM_CONFIGS.onedev, |
||||||
|
baseUrl: customApiUrl |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
if (customApiUrl) { |
||||||
|
// Custom platform - assume Gitea-compatible format
|
||||||
|
return { |
||||||
|
baseUrl: customApiUrl, |
||||||
|
issuesPath: '/repos/{owner}/{repo}/issues', |
||||||
|
pullsPath: '/repos/{owner}/{repo}/pulls', |
||||||
|
authHeader: 'Bearer', |
||||||
|
usesDescription: false, |
||||||
|
usesSourceTargetBranch: false |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
const config = GIT_PLATFORM_CONFIGS[platform]; |
||||||
|
if (!config) { |
||||||
|
throw new Error(`Unsupported Git platform: ${platform}`); |
||||||
|
} |
||||||
|
|
||||||
|
// Get baseUrl from platform config or use default
|
||||||
|
const baseUrls: Record<string, string> = { |
||||||
|
github: 'https://api.github.com', |
||||||
|
gitlab: 'https://gitlab.com/api/v4', |
||||||
|
gitea: 'https://codeberg.org/api/v1', |
||||||
|
codeberg: 'https://codeberg.org/api/v1', |
||||||
|
forgejo: 'https://forgejo.org/api/v1' |
||||||
|
}; |
||||||
|
|
||||||
|
return { |
||||||
|
...config, |
||||||
|
baseUrl: baseUrls[platform] || '' |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
function buildGitPlatformUrl( |
||||||
|
config: GitPlatformConfig, |
||||||
|
owner: string, |
||||||
|
repo: string, |
||||||
|
pathType: 'issues' | 'pulls', |
||||||
|
platform: GitPlatform |
||||||
|
): string { |
||||||
|
const path = pathType === 'issues' ? config.issuesPath : config.pullsPath; |
||||||
|
|
||||||
|
if (platform === 'onedev') { |
||||||
|
const projectPath = repo ? `${owner}/${repo}` : owner; |
||||||
|
const endpoint = pathType === 'issues' ? 'issues' : 'pull-requests'; |
||||||
|
return `${config.baseUrl}/api/projects/${encodeURIComponent(projectPath)}/${endpoint}`; |
||||||
|
} |
||||||
|
|
||||||
|
const urlPath = path |
||||||
|
.replace('{owner}', encodeURIComponent(owner)) |
||||||
|
.replace('{repo}', encodeURIComponent(repo)); |
||||||
|
|
||||||
|
return `${config.baseUrl}${urlPath}`; |
||||||
|
} |
||||||
|
|
||||||
|
function buildAuthHeader(config: GitPlatformConfig, token: string): string { |
||||||
|
return config.authHeader === 'Bearer' ? `Bearer ${token}` : `token ${token}`; |
||||||
|
} |
||||||
|
|
||||||
|
function buildIssuePayload( |
||||||
|
content: EventContent, |
||||||
|
config: GitPlatformConfig, |
||||||
|
prefix?: string |
||||||
|
): Record<string, unknown> { |
||||||
|
const title = prefix ? `${prefix}${content.title}` : content.title; |
||||||
|
const payload: Record<string, unknown> = { title }; |
||||||
|
|
||||||
|
if (config.usesDescription) { |
||||||
|
payload.description = content.fullBody; |
||||||
|
} else { |
||||||
|
payload.body = content.fullBody; |
||||||
|
} |
||||||
|
|
||||||
|
return payload; |
||||||
|
} |
||||||
|
|
||||||
|
function buildPullRequestPayload( |
||||||
|
content: EventContent, |
||||||
|
config: GitPlatformConfig, |
||||||
|
headBranch: string, |
||||||
|
baseBranch: string |
||||||
|
): Record<string, unknown> { |
||||||
|
const payload: Record<string, unknown> = { |
||||||
|
title: content.title |
||||||
|
}; |
||||||
|
|
||||||
|
if (config.usesDescription) { |
||||||
|
payload.description = content.fullBody; |
||||||
|
} else { |
||||||
|
payload.body = content.fullBody; |
||||||
|
} |
||||||
|
|
||||||
|
if (config.usesSourceTargetBranch) { |
||||||
|
payload.source_branch = headBranch; |
||||||
|
payload.target_branch = baseBranch; |
||||||
|
} else { |
||||||
|
payload.head = headBranch; |
||||||
|
payload.base = baseBranch; |
||||||
|
} |
||||||
|
|
||||||
|
return payload; |
||||||
|
} |
||||||
|
|
||||||
|
async function makeGitPlatformRequest( |
||||||
|
url: string, |
||||||
|
method: string, |
||||||
|
headers: Record<string, string>, |
||||||
|
payload: Record<string, unknown>, |
||||||
|
platform: string |
||||||
|
): Promise<void> { |
||||||
|
const response = await fetch(url, { |
||||||
|
method, |
||||||
|
headers, |
||||||
|
body: JSON.stringify(payload) |
||||||
|
}); |
||||||
|
|
||||||
|
if (!response.ok) { |
||||||
|
const error = await response.json().catch(() => ({ message: 'Unknown error' })); |
||||||
|
throw new Error(`${platform} API error: ${error.message || response.statusText}`); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Platform Forwarding Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function sendToTelegram(message: string, chatId: string): Promise<void> { |
||||||
|
if (!MESSAGING_CONFIG.telegram?.enabled || !MESSAGING_CONFIG.telegram?.botToken) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
const url = `https://api.telegram.org/bot${MESSAGING_CONFIG.telegram.botToken}/sendMessage`; |
||||||
|
const response = await fetch(url, { |
||||||
|
method: 'POST', |
||||||
|
headers: { 'Content-Type': 'application/json' }, |
||||||
|
body: JSON.stringify({ |
||||||
|
chat_id: chatId, |
||||||
|
text: message, |
||||||
|
parse_mode: 'Markdown', |
||||||
|
disable_web_page_preview: true |
||||||
|
}) |
||||||
|
}); |
||||||
|
|
||||||
|
if (!response.ok) { |
||||||
|
const error = await response.json().catch(() => ({ error: 'Unknown error' })); |
||||||
|
throw new Error(`Telegram API error: ${error.description || response.statusText}`); |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
logger.error({ error, chatId: chatId.slice(0, 10) + '...' }, 'Failed to send to Telegram'); |
||||||
|
throw error; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function sendToSimpleX(message: string, contactId: string): Promise<void> { |
||||||
|
if (!MESSAGING_CONFIG.simplex?.enabled || !MESSAGING_CONFIG.simplex?.apiUrl) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
const response = await fetch(`${MESSAGING_CONFIG.simplex.apiUrl}/send`, { |
||||||
|
method: 'POST', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json', |
||||||
|
'Authorization': `Bearer ${MESSAGING_CONFIG.simplex.apiKey}` |
||||||
|
}, |
||||||
|
body: JSON.stringify({ |
||||||
|
contact_id: contactId, |
||||||
|
message: message |
||||||
|
}) |
||||||
|
}); |
||||||
|
|
||||||
|
if (!response.ok) { |
||||||
|
const error = await response.text().catch(() => 'Unknown error'); |
||||||
|
throw new Error(`SimpleX API error: ${error}`); |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
logger.error({ error, contactId: contactId.slice(0, 10) + '...' }, 'Failed to send to SimpleX'); |
||||||
|
throw error; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function sendEmail( |
||||||
|
subject: string, |
||||||
|
message: string, |
||||||
|
to: string[], |
||||||
|
cc?: string[] |
||||||
|
): Promise<void> { |
||||||
|
if (!MESSAGING_CONFIG.email?.enabled || !MESSAGING_CONFIG.email?.smtpHost) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
const smtpUrl = process.env.SMTP_API_URL; |
||||||
|
|
||||||
|
if (smtpUrl) { |
||||||
|
// Use SMTP API if provided
|
||||||
|
const response = await fetch(smtpUrl, { |
||||||
|
method: 'POST', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json', |
||||||
|
'Authorization': `Bearer ${process.env.SMTP_API_KEY || ''}` |
||||||
|
}, |
||||||
|
body: JSON.stringify({ |
||||||
|
from: MESSAGING_CONFIG.email.fromAddress, |
||||||
|
to, |
||||||
|
cc: cc || [], |
||||||
|
subject, |
||||||
|
text: message |
||||||
|
}) |
||||||
|
}); |
||||||
|
|
||||||
|
if (!response.ok) { |
||||||
|
const error = await response.text().catch(() => 'Unknown error'); |
||||||
|
throw new Error(`Email API error: ${error}`); |
||||||
|
} |
||||||
|
} else { |
||||||
|
// Direct SMTP using nodemailer
|
||||||
|
try { |
||||||
|
const nodemailer = await import('nodemailer'); |
||||||
|
const { createTransport } = nodemailer; |
||||||
|
|
||||||
|
const transporter = createTransport({ |
||||||
|
host: MESSAGING_CONFIG.email.smtpHost, |
||||||
|
port: MESSAGING_CONFIG.email.smtpPort, |
||||||
|
secure: MESSAGING_CONFIG.email.smtpPort === 465, |
||||||
|
auth: { |
||||||
|
user: MESSAGING_CONFIG.email.smtpUser, |
||||||
|
pass: MESSAGING_CONFIG.email.smtpPassword |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
await transporter.sendMail({ |
||||||
|
from: `"${MESSAGING_CONFIG.email.fromName}" <${MESSAGING_CONFIG.email.fromAddress}>`, |
||||||
|
to: to.join(', '), |
||||||
|
cc: cc && cc.length > 0 ? cc.join(', ') : undefined, |
||||||
|
subject, |
||||||
|
text: message |
||||||
|
}); |
||||||
|
} catch (importError) { |
||||||
|
throw new Error( |
||||||
|
'Email sending requires either SMTP_API_URL or nodemailer package. ' + |
||||||
|
'Install with: npm install nodemailer' |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
logger.error({
|
||||||
|
error,
|
||||||
|
toCount: to.length, |
||||||
|
ccCount: cc?.length || 0 |
||||||
|
}, 'Failed to send email'); |
||||||
|
throw error; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function forwardToGitPlatform( |
||||||
|
event: NostrEvent, |
||||||
|
platform: GitPlatform, |
||||||
|
owner: string, |
||||||
|
repo: string, |
||||||
|
token: string, |
||||||
|
customApiUrl?: string |
||||||
|
): Promise<void> { |
||||||
|
if (!MESSAGING_CONFIG.gitPlatforms?.enabled) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
const config = getGitPlatformConfig(platform, customApiUrl); |
||||||
|
const content = extractEventContent(event); |
||||||
|
|
||||||
|
const authHeader = buildAuthHeader(config, token); |
||||||
|
const headers: Record<string, string> = { |
||||||
|
'Authorization': authHeader, |
||||||
|
'Content-Type': 'application/json', |
||||||
|
'User-Agent': 'GitRepublic', |
||||||
|
...(config.customHeaders || {}) |
||||||
|
}; |
||||||
|
|
||||||
|
// Handle different event kinds
|
||||||
|
if (event.kind === KIND.ISSUE) { |
||||||
|
const issuesUrl = buildGitPlatformUrl(config, owner, repo, 'issues', platform); |
||||||
|
const payload = buildIssuePayload(content, config); |
||||||
|
await makeGitPlatformRequest(issuesUrl, 'POST', headers, payload, platform); |
||||||
|
|
||||||
|
} else if (event.kind === KIND.PULL_REQUEST) { |
||||||
|
const headTag = event.tags.find(t => t[0] === 'head'); |
||||||
|
const baseTag = event.tags.find(t => t[0] === 'base'); |
||||||
|
|
||||||
|
if (headTag?.[1] && baseTag?.[1]) { |
||||||
|
// Create actual PR with branch info
|
||||||
|
const pullsUrl = buildGitPlatformUrl(config, owner, repo, 'pulls', platform); |
||||||
|
const payload = buildPullRequestPayload(content, config, headTag[1], baseTag[1]); |
||||||
|
await makeGitPlatformRequest(pullsUrl, 'POST', headers, payload, platform); |
||||||
|
} else { |
||||||
|
// No branch info, create issue with PR label
|
||||||
|
const issuesUrl = buildGitPlatformUrl(config, owner, repo, 'issues', platform); |
||||||
|
const payload = buildIssuePayload(content, config, '[PR] '); |
||||||
|
if (Array.isArray(payload.labels)) { |
||||||
|
payload.labels.push('pull-request'); |
||||||
|
} else { |
||||||
|
payload.labels = ['pull-request']; |
||||||
|
} |
||||||
|
await makeGitPlatformRequest(issuesUrl, 'POST', headers, payload, platform); |
||||||
|
} |
||||||
|
|
||||||
|
} else { |
||||||
|
// Other event types: create issue with event kind label
|
||||||
|
const issuesUrl = buildGitPlatformUrl(config, owner, repo, 'issues', platform); |
||||||
|
const payload = buildIssuePayload(content, config, `[Event ${event.kind}] `); |
||||||
|
payload.labels = [`nostr-kind-${event.kind}`]; |
||||||
|
await makeGitPlatformRequest(issuesUrl, 'POST', headers, payload, platform); |
||||||
|
} |
||||||
|
|
||||||
|
} catch (error) { |
||||||
|
logger.error({
|
||||||
|
error,
|
||||||
|
platform, |
||||||
|
owner, |
||||||
|
repo: repo.slice(0, 10) + '...', |
||||||
|
eventKind: event.kind |
||||||
|
}, `Failed to forward to ${platform}`); |
||||||
|
throw error; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main Export
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** |
||||||
|
* Forward event to configured messaging platforms |
||||||
|
* Only forwards if: |
||||||
|
* - User has unlimited access |
||||||
|
* - User has preferences configured and enabled |
||||||
|
* - Event kind is in notifyOn list (if specified) |
||||||
|
*/ |
||||||
|
export async function forwardEventIfEnabled( |
||||||
|
event: NostrEvent, |
||||||
|
userPubkeyHex: string |
||||||
|
): Promise<void> { |
||||||
|
try { |
||||||
|
// Early returns for eligibility checks
|
||||||
|
const cached = getCachedUserLevel(userPubkeyHex); |
||||||
|
if (!cached || cached.level !== 'unlimited') { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const preferences = await getPreferences(userPubkeyHex); |
||||||
|
if (!preferences || !preferences.enabled) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (preferences.notifyOn && preferences.notifyOn.length > 0) { |
||||||
|
if (!preferences.notifyOn.includes(event.kind.toString())) { |
||||||
|
return; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Prepare message content
|
||||||
|
const message = formatEventMessage(event, userPubkeyHex); |
||||||
|
const kindName = KIND_NAMES[event.kind] || `Event ${event.kind}`; |
||||||
|
const subject = `GitRepublic: ${kindName} Notification`; |
||||||
|
|
||||||
|
// Collect all forwarding promises
|
||||||
|
const promises: Promise<void>[] = []; |
||||||
|
|
||||||
|
// Messaging platforms
|
||||||
|
if (preferences.telegram) { |
||||||
|
promises.push( |
||||||
|
sendToTelegram(message, preferences.telegram) |
||||||
|
.catch(err => logger.warn({ error: err }, 'Telegram forwarding failed')) |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
if (preferences.simplex) { |
||||||
|
promises.push( |
||||||
|
sendToSimpleX(message, preferences.simplex) |
||||||
|
.catch(err => logger.warn({ error: err }, 'SimpleX forwarding failed')) |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
if (preferences.email?.to && preferences.email.to.length > 0) { |
||||||
|
promises.push( |
||||||
|
sendEmail(subject, message, preferences.email.to, preferences.email.cc) |
||||||
|
.catch(err => logger.warn({ error: err }, 'Email forwarding failed')) |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
// Git platforms
|
||||||
|
if (preferences.gitPlatforms && preferences.gitPlatforms.length > 0) { |
||||||
|
for (const gitPlatform of preferences.gitPlatforms) { |
||||||
|
if (!gitPlatform.owner || !gitPlatform.repo || !gitPlatform.token) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
if (gitPlatform.platform === 'onedev' && !gitPlatform.apiUrl) { |
||||||
|
logger.warn({ platform: 'onedev' }, 'OneDev requires apiUrl to be provided'); |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
promises.push( |
||||||
|
forwardToGitPlatform( |
||||||
|
event, |
||||||
|
gitPlatform.platform, |
||||||
|
gitPlatform.owner, |
||||||
|
gitPlatform.repo, |
||||||
|
gitPlatform.token, |
||||||
|
gitPlatform.apiUrl |
||||||
|
) |
||||||
|
.catch(err => logger.warn({ error: err, platform: gitPlatform.platform }, 'Git platform forwarding failed')) |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Execute all forwarding in parallel
|
||||||
|
await Promise.allSettled(promises); |
||||||
|
|
||||||
|
logger.debug({
|
||||||
|
eventId: event.id.slice(0, 16) + '...', |
||||||
|
userPubkeyHex: userPubkeyHex.slice(0, 16) + '...', |
||||||
|
platforms: Object.keys(preferences).filter(k => k !== 'enabled' && k !== 'notifyOn' && preferences[k as keyof typeof preferences]) |
||||||
|
}, 'Forwarded event to messaging platforms'); |
||||||
|
|
||||||
|
} catch (error) { |
||||||
|
// Log but don't throw - forwarding failures shouldn't break event publishing
|
||||||
|
logger.error({
|
||||||
|
error,
|
||||||
|
eventId: event.id?.slice(0, 16) + '...', |
||||||
|
userPubkeyHex: userPubkeyHex.slice(0, 16) + '...' |
||||||
|
}, 'Failed to forward event to messaging platforms'); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,31 @@ |
|||||||
|
/** |
||||||
|
* Type declarations for nodemailer |
||||||
|
* This file ensures TypeScript recognizes nodemailer types |
||||||
|
*/ |
||||||
|
|
||||||
|
declare module 'nodemailer' { |
||||||
|
export interface TransportOptions { |
||||||
|
host?: string; |
||||||
|
port?: number; |
||||||
|
secure?: boolean; |
||||||
|
auth?: { |
||||||
|
user: string; |
||||||
|
pass: string; |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
export interface MailOptions { |
||||||
|
from?: string; |
||||||
|
to?: string; |
||||||
|
cc?: string; |
||||||
|
subject?: string; |
||||||
|
text?: string; |
||||||
|
html?: string; |
||||||
|
} |
||||||
|
|
||||||
|
export interface Transporter { |
||||||
|
sendMail(options: MailOptions): Promise<{ messageId: string }>; |
||||||
|
} |
||||||
|
|
||||||
|
export function createTransport(options: TransportOptions): Transporter; |
||||||
|
} |
||||||
@ -0,0 +1,383 @@ |
|||||||
|
/** |
||||||
|
* Secure messaging preferences storage |
||||||
|
*
|
||||||
|
* SECURITY FEATURES: |
||||||
|
* - Encrypted salt storage (separate key) |
||||||
|
* - HMAC-based lookup keys (pubkey never directly used as DB key) |
||||||
|
* - Rate limiting on decryption attempts |
||||||
|
* - Per-user key derivation (master key + pubkey + salt) |
||||||
|
* - AES-256-GCM authenticated encryption |
||||||
|
* - Random IV per encryption |
||||||
|
*/ |
||||||
|
|
||||||
|
import {
|
||||||
|
createCipheriv,
|
||||||
|
createDecipheriv,
|
||||||
|
randomBytes,
|
||||||
|
scryptSync, |
||||||
|
createHash, |
||||||
|
createHmac |
||||||
|
} from 'crypto'; |
||||||
|
import logger from '../logger.js'; |
||||||
|
import { getCachedUserLevel } from '../security/user-level-cache.js'; |
||||||
|
|
||||||
|
// Encryption keys from environment (NEVER commit these!)
|
||||||
|
const ENCRYPTION_KEY = process.env.MESSAGING_PREFS_ENCRYPTION_KEY; |
||||||
|
const SALT_ENCRYPTION_KEY = process.env.MESSAGING_SALT_ENCRYPTION_KEY; |
||||||
|
const LOOKUP_SECRET = process.env.MESSAGING_LOOKUP_SECRET; |
||||||
|
|
||||||
|
if (!ENCRYPTION_KEY || !SALT_ENCRYPTION_KEY || !LOOKUP_SECRET) { |
||||||
|
throw new Error( |
||||||
|
'Missing required environment variables: ' + |
||||||
|
'MESSAGING_PREFS_ENCRYPTION_KEY, MESSAGING_SALT_ENCRYPTION_KEY, MESSAGING_LOOKUP_SECRET' |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export interface MessagingPreferences { |
||||||
|
telegram?: string; // Chat ID or username
|
||||||
|
simplex?: string; // Contact ID
|
||||||
|
email?: { |
||||||
|
to: string[]; // To: email addresses
|
||||||
|
cc?: string[]; // CC: email addresses (optional)
|
||||||
|
}; |
||||||
|
gitPlatforms?: Array<{ |
||||||
|
platform: 'github' | 'gitlab' | 'gitea' | 'codeberg' | 'forgejo' | 'onedev' | 'custom'; |
||||||
|
owner: string; // Repository owner (username or org)
|
||||||
|
repo: string; // Repository name
|
||||||
|
token: string; // Personal access token (encrypted)
|
||||||
|
apiUrl?: string; // Custom API URL (required for onedev and self-hosted platforms)
|
||||||
|
}>; |
||||||
|
enabled: boolean; |
||||||
|
notifyOn?: string[]; // Event kinds to forward (e.g., ['1621', '1618'])
|
||||||
|
} |
||||||
|
|
||||||
|
interface StoredPreferences { |
||||||
|
encryptedSalt: string; // Salt encrypted with SALT_ENCRYPTION_KEY
|
||||||
|
encrypted: string; // Preferences encrypted with derived key
|
||||||
|
} |
||||||
|
|
||||||
|
// Rate limiting: track decryption attempts per pubkey
|
||||||
|
interface DecryptionAttempt { |
||||||
|
count: number; |
||||||
|
resetAt: number; |
||||||
|
} |
||||||
|
|
||||||
|
const decryptionAttempts = new Map<string, DecryptionAttempt>(); |
||||||
|
const MAX_DECRYPTION_ATTEMPTS = 10; // Max attempts per window
|
||||||
|
const DECRYPTION_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
|
||||||
|
|
||||||
|
// Cleanup expired rate limit entries
|
||||||
|
setInterval(() => { |
||||||
|
const now = Date.now(); |
||||||
|
for (const [key, attempt] of decryptionAttempts.entries()) { |
||||||
|
if (attempt.resetAt < now) { |
||||||
|
decryptionAttempts.delete(key); |
||||||
|
} |
||||||
|
} |
||||||
|
}, 60 * 1000); // Cleanup every minute
|
||||||
|
|
||||||
|
/** |
||||||
|
* Generate HMAC-based lookup key from pubkey |
||||||
|
* Prevents pubkey from being directly used as database key |
||||||
|
*/ |
||||||
|
function getLookupKey(userPubkeyHex: string): string { |
||||||
|
if (!LOOKUP_SECRET) { |
||||||
|
throw new Error('LOOKUP_SECRET not configured'); |
||||||
|
} |
||||||
|
return createHmac('sha256', LOOKUP_SECRET) |
||||||
|
.update(userPubkeyHex) |
||||||
|
.digest('hex'); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check and enforce rate limiting on decryption attempts |
||||||
|
*/ |
||||||
|
function checkRateLimit(userPubkeyHex: string): { allowed: boolean; remaining: number } { |
||||||
|
const lookupKey = getLookupKey(userPubkeyHex); |
||||||
|
const now = Date.now(); |
||||||
|
|
||||||
|
const attempt = decryptionAttempts.get(lookupKey); |
||||||
|
|
||||||
|
if (!attempt || attempt.resetAt < now) { |
||||||
|
// New window or expired
|
||||||
|
decryptionAttempts.set(lookupKey, { |
||||||
|
count: 1, |
||||||
|
resetAt: now + DECRYPTION_WINDOW_MS |
||||||
|
}); |
||||||
|
return { allowed: true, remaining: MAX_DECRYPTION_ATTEMPTS - 1 }; |
||||||
|
} |
||||||
|
|
||||||
|
if (attempt.count >= MAX_DECRYPTION_ATTEMPTS) { |
||||||
|
logger.warn({ userPubkeyHex: userPubkeyHex.slice(0, 16) + '...' },
|
||||||
|
'Decryption rate limit exceeded'); |
||||||
|
return { allowed: false, remaining: 0 }; |
||||||
|
} |
||||||
|
|
||||||
|
attempt.count++; |
||||||
|
return { allowed: true, remaining: MAX_DECRYPTION_ATTEMPTS - attempt.count }; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Encrypt data with AES-256-GCM |
||||||
|
*/ |
||||||
|
function encryptAES256GCM(key: Buffer, plaintext: string): string { |
||||||
|
const iv = randomBytes(16); |
||||||
|
const cipher = createCipheriv('aes-256-gcm', key, iv); |
||||||
|
|
||||||
|
let encrypted = cipher.update(plaintext, 'utf8', 'hex'); |
||||||
|
encrypted += cipher.final('hex'); |
||||||
|
const authTag = cipher.getAuthTag(); |
||||||
|
|
||||||
|
return JSON.stringify({ |
||||||
|
iv: iv.toString('hex'), |
||||||
|
tag: authTag.toString('hex'), |
||||||
|
data: encrypted |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Decrypt data with AES-256-GCM |
||||||
|
*/ |
||||||
|
function decryptAES256GCM(key: Buffer, encryptedData: string): string { |
||||||
|
const { iv, tag, data } = JSON.parse(encryptedData); |
||||||
|
|
||||||
|
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'hex')); |
||||||
|
decipher.setAuthTag(Buffer.from(tag, 'hex')); |
||||||
|
|
||||||
|
let decrypted = decipher.update(data, 'hex', 'utf8'); |
||||||
|
decrypted += decipher.final('utf8'); |
||||||
|
|
||||||
|
return decrypted; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Encrypt salt with SALT_ENCRYPTION_KEY |
||||||
|
*/ |
||||||
|
function encryptSalt(salt: string): string { |
||||||
|
if (!SALT_ENCRYPTION_KEY) { |
||||||
|
throw new Error('SALT_ENCRYPTION_KEY not configured'); |
||||||
|
} |
||||||
|
const key = createHash('sha256').update(SALT_ENCRYPTION_KEY).digest(); |
||||||
|
return encryptAES256GCM(key, salt); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Decrypt salt with SALT_ENCRYPTION_KEY |
||||||
|
*/ |
||||||
|
function decryptSalt(encryptedSalt: string): string { |
||||||
|
if (!SALT_ENCRYPTION_KEY) { |
||||||
|
throw new Error('SALT_ENCRYPTION_KEY not configured'); |
||||||
|
} |
||||||
|
const key = createHash('sha256').update(SALT_ENCRYPTION_KEY).digest(); |
||||||
|
return decryptAES256GCM(key, encryptedSalt); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Derive per-user encryption key |
||||||
|
* Uses: master key + user pubkey + salt |
||||||
|
*/ |
||||||
|
function deriveUserKey(userPubkeyHex: string, salt: string): Buffer { |
||||||
|
if (!ENCRYPTION_KEY) { |
||||||
|
throw new Error('ENCRYPTION_KEY not configured'); |
||||||
|
} |
||||||
|
// Combine pubkey and salt for key derivation
|
||||||
|
// Attacker needs: master key + pubkey + salt
|
||||||
|
const combinedSalt = `${userPubkeyHex}:${salt}`; |
||||||
|
return scryptSync( |
||||||
|
ENCRYPTION_KEY, |
||||||
|
combinedSalt, |
||||||
|
32, // 256-bit key
|
||||||
|
{ N: 16384, r: 8, p: 1 } // scrypt parameters
|
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* In-memory storage (in production, use Redis or database) |
||||||
|
* Key: HMAC(pubkey), Value: {encryptedSalt, encrypted} |
||||||
|
*/ |
||||||
|
const preferencesStore = new Map<string, string>(); |
||||||
|
|
||||||
|
/** |
||||||
|
* Store user messaging preferences securely |
||||||
|
*
|
||||||
|
* @param userPubkeyHex - User's public key (hex) |
||||||
|
* @param preferences - Messaging preferences to store |
||||||
|
* @throws Error if user doesn't have unlimited access |
||||||
|
*/ |
||||||
|
export async function storePreferences( |
||||||
|
userPubkeyHex: string, |
||||||
|
preferences: MessagingPreferences |
||||||
|
): Promise<void> { |
||||||
|
// Verify user has unlimited access
|
||||||
|
const cached = getCachedUserLevel(userPubkeyHex); |
||||||
|
if (!cached || cached.level !== 'unlimited') { |
||||||
|
throw new Error('Messaging forwarding requires unlimited access'); |
||||||
|
} |
||||||
|
|
||||||
|
// Generate random salt (unique per user, per save)
|
||||||
|
const salt = randomBytes(32).toString('hex'); |
||||||
|
|
||||||
|
// Encrypt salt with separate key
|
||||||
|
const encryptedSalt = encryptSalt(salt); |
||||||
|
|
||||||
|
// Derive user-specific encryption key
|
||||||
|
const userKey = deriveUserKey(userPubkeyHex, salt); |
||||||
|
|
||||||
|
// Encrypt preferences
|
||||||
|
const encrypted = encryptAES256GCM(userKey, JSON.stringify(preferences)); |
||||||
|
|
||||||
|
// Store using HMAC lookup key (not raw pubkey)
|
||||||
|
const lookupKey = getLookupKey(userPubkeyHex); |
||||||
|
const stored: StoredPreferences = { |
||||||
|
encryptedSalt, |
||||||
|
encrypted |
||||||
|
}; |
||||||
|
|
||||||
|
preferencesStore.set(lookupKey, JSON.stringify(stored)); |
||||||
|
|
||||||
|
logger.info({ userPubkeyHex: userPubkeyHex.slice(0, 16) + '...' },
|
||||||
|
'Stored messaging preferences'); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Retrieve and decrypt user messaging preferences |
||||||
|
*
|
||||||
|
* @param userPubkeyHex - User's public key (hex) |
||||||
|
* @returns Decrypted preferences or null if not found |
||||||
|
* @throws Error if rate limit exceeded or decryption fails |
||||||
|
*/ |
||||||
|
export async function getPreferences( |
||||||
|
userPubkeyHex: string |
||||||
|
): Promise<MessagingPreferences | null> { |
||||||
|
// Check rate limit
|
||||||
|
const rateLimit = checkRateLimit(userPubkeyHex); |
||||||
|
if (!rateLimit.allowed) { |
||||||
|
throw new Error( |
||||||
|
`Decryption rate limit exceeded. Try again in ${Math.ceil( |
||||||
|
(decryptionAttempts.get(getLookupKey(userPubkeyHex))!.resetAt - Date.now()) / 1000 / 60 |
||||||
|
)} minutes.` |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
// Get stored data using HMAC lookup key
|
||||||
|
const lookupKey = getLookupKey(userPubkeyHex); |
||||||
|
const storedJson = preferencesStore.get(lookupKey); |
||||||
|
|
||||||
|
if (!storedJson) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
const stored: StoredPreferences = JSON.parse(storedJson); |
||||||
|
|
||||||
|
// Decrypt salt
|
||||||
|
const salt = decryptSalt(stored.encryptedSalt); |
||||||
|
|
||||||
|
// Derive same encryption key
|
||||||
|
const userKey = deriveUserKey(userPubkeyHex, salt); |
||||||
|
|
||||||
|
// Decrypt preferences
|
||||||
|
const decrypted = decryptAES256GCM(userKey, stored.encrypted); |
||||||
|
const preferences: MessagingPreferences = JSON.parse(decrypted); |
||||||
|
|
||||||
|
// Reset rate limit on successful decryption
|
||||||
|
decryptionAttempts.delete(lookupKey); |
||||||
|
|
||||||
|
return preferences; |
||||||
|
} catch (error) { |
||||||
|
logger.error({
|
||||||
|
error,
|
||||||
|
userPubkeyHex: userPubkeyHex.slice(0, 16) + '...'
|
||||||
|
}, 'Failed to decrypt preferences'); |
||||||
|
throw new Error('Failed to decrypt preferences. Data may be corrupted.'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Delete user messaging preferences |
||||||
|
*/ |
||||||
|
export async function deletePreferences(userPubkeyHex: string): Promise<void> { |
||||||
|
const lookupKey = getLookupKey(userPubkeyHex); |
||||||
|
preferencesStore.delete(lookupKey); |
||||||
|
decryptionAttempts.delete(lookupKey); |
||||||
|
|
||||||
|
logger.info({ userPubkeyHex: userPubkeyHex.slice(0, 16) + '...' },
|
||||||
|
'Deleted messaging preferences'); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if user has preferences configured |
||||||
|
*/ |
||||||
|
export async function hasPreferences(userPubkeyHex: string): Promise<boolean> { |
||||||
|
const lookupKey = getLookupKey(userPubkeyHex); |
||||||
|
return preferencesStore.has(lookupKey); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get rate limit status for a user |
||||||
|
*/ |
||||||
|
export function getRateLimitStatus(userPubkeyHex: string): { |
||||||
|
remaining: number; |
||||||
|
resetAt: number | null; |
||||||
|
} { |
||||||
|
const lookupKey = getLookupKey(userPubkeyHex); |
||||||
|
const attempt = decryptionAttempts.get(lookupKey); |
||||||
|
|
||||||
|
if (!attempt) { |
||||||
|
return { remaining: MAX_DECRYPTION_ATTEMPTS, resetAt: null }; |
||||||
|
} |
||||||
|
|
||||||
|
if (attempt.resetAt < Date.now()) { |
||||||
|
return { remaining: MAX_DECRYPTION_ATTEMPTS, resetAt: null }; |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
remaining: Math.max(0, MAX_DECRYPTION_ATTEMPTS - attempt.count), |
||||||
|
resetAt: attempt.resetAt |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get a safe summary of user preferences (without sensitive tokens) |
||||||
|
* This decrypts preferences but only returns safe information |
||||||
|
*/ |
||||||
|
export async function getPreferencesSummary(userPubkeyHex: string): Promise<{ |
||||||
|
configured: boolean; |
||||||
|
enabled: boolean; |
||||||
|
platforms: { |
||||||
|
telegram?: boolean; |
||||||
|
simplex?: boolean; |
||||||
|
email?: boolean; |
||||||
|
gitPlatforms?: Array<{ |
||||||
|
platform: string; |
||||||
|
owner: string; |
||||||
|
repo: string; |
||||||
|
apiUrl?: string; |
||||||
|
}>; |
||||||
|
}; |
||||||
|
notifyOn?: string[]; |
||||||
|
} | null> { |
||||||
|
const preferences = await getPreferences(userPubkeyHex); |
||||||
|
|
||||||
|
if (!preferences) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
configured: true, |
||||||
|
enabled: preferences.enabled, |
||||||
|
platforms: { |
||||||
|
telegram: !!preferences.telegram, |
||||||
|
simplex: !!preferences.simplex, |
||||||
|
email: !!preferences.email, |
||||||
|
gitPlatforms: preferences.gitPlatforms?.map(gp => ({ |
||||||
|
platform: gp.platform, |
||||||
|
owner: gp.owner, |
||||||
|
repo: gp.repo, |
||||||
|
apiUrl: gp.apiUrl |
||||||
|
// token is intentionally omitted
|
||||||
|
})) |
||||||
|
}, |
||||||
|
notifyOn: preferences.notifyOn |
||||||
|
}; |
||||||
|
} |
||||||
@ -0,0 +1,215 @@ |
|||||||
|
/** |
||||||
|
* Service for handling NIP-24 public messages (kind 24) |
||||||
|
* Public messages are direct messages that can be seen by anyone |
||||||
|
*/ |
||||||
|
|
||||||
|
import { NostrClient } from './nostr-client.js'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
import { KIND } from '../../types/nostr.js'; |
||||||
|
import { getUserRelays } from './user-relays.js'; |
||||||
|
import { combineRelays } from '../../config.js'; |
||||||
|
import logger from '../logger.js'; |
||||||
|
import { verifyEvent } from 'nostr-tools'; |
||||||
|
|
||||||
|
export interface PublicMessage extends NostrEvent { |
||||||
|
kind: typeof KIND.PUBLIC_MESSAGE; |
||||||
|
} |
||||||
|
|
||||||
|
export class PublicMessagesService { |
||||||
|
private nostrClient: NostrClient; |
||||||
|
|
||||||
|
constructor(relays: string[]) { |
||||||
|
this.nostrClient = new NostrClient(relays); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetch public messages sent to a user (where user is in p tags) |
||||||
|
*/ |
||||||
|
async getMessagesToUser( |
||||||
|
userPubkey: string, |
||||||
|
limit: number = 50 |
||||||
|
): Promise<PublicMessage[]> { |
||||||
|
try { |
||||||
|
const events = await this.nostrClient.fetchEvents([ |
||||||
|
{ |
||||||
|
kinds: [KIND.PUBLIC_MESSAGE], |
||||||
|
'#p': [userPubkey], // Messages where user is a recipient
|
||||||
|
limit |
||||||
|
} |
||||||
|
]); |
||||||
|
|
||||||
|
// Verify events and filter to only valid kind 24
|
||||||
|
const validMessages = events |
||||||
|
.filter((e): e is PublicMessage => { |
||||||
|
if (e.kind !== KIND.PUBLIC_MESSAGE) return false; |
||||||
|
if (!verifyEvent(e)) { |
||||||
|
logger.warn({ eventId: e.id.slice(0, 16) + '...' }, 'Invalid signature in public message'); |
||||||
|
return false; |
||||||
|
} |
||||||
|
return true; |
||||||
|
}) |
||||||
|
.sort((a, b) => b.created_at - a.created_at); // Newest first
|
||||||
|
|
||||||
|
return validMessages; |
||||||
|
} catch (error) { |
||||||
|
logger.error({ error, userPubkey: userPubkey.slice(0, 16) + '...' }, 'Failed to fetch public messages to user'); |
||||||
|
throw error; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetch public messages sent by a user |
||||||
|
*/ |
||||||
|
async getMessagesFromUser( |
||||||
|
userPubkey: string, |
||||||
|
limit: number = 50 |
||||||
|
): Promise<PublicMessage[]> { |
||||||
|
try { |
||||||
|
const events = await this.nostrClient.fetchEvents([ |
||||||
|
{ |
||||||
|
kinds: [KIND.PUBLIC_MESSAGE], |
||||||
|
authors: [userPubkey], |
||||||
|
limit |
||||||
|
} |
||||||
|
]); |
||||||
|
|
||||||
|
// Verify events
|
||||||
|
const validMessages = events |
||||||
|
.filter((e): e is PublicMessage => { |
||||||
|
if (e.kind !== KIND.PUBLIC_MESSAGE) return false; |
||||||
|
if (!verifyEvent(e)) { |
||||||
|
logger.warn({ eventId: e.id.slice(0, 16) + '...' }, 'Invalid signature in public message'); |
||||||
|
return false; |
||||||
|
} |
||||||
|
return true; |
||||||
|
}) |
||||||
|
.sort((a, b) => b.created_at - a.created_at); // Newest first
|
||||||
|
|
||||||
|
return validMessages; |
||||||
|
} catch (error) { |
||||||
|
logger.error({ error, userPubkey: userPubkey.slice(0, 16) + '...' }, 'Failed to fetch public messages from user'); |
||||||
|
throw error; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetch all public messages involving a user (sent to or from) |
||||||
|
*/ |
||||||
|
async getAllMessagesForUser( |
||||||
|
userPubkey: string, |
||||||
|
limit: number = 50 |
||||||
|
): Promise<PublicMessage[]> { |
||||||
|
try { |
||||||
|
const [toUser, fromUser] = await Promise.all([ |
||||||
|
this.getMessagesToUser(userPubkey, limit), |
||||||
|
this.getMessagesFromUser(userPubkey, limit) |
||||||
|
]); |
||||||
|
|
||||||
|
// Combine and deduplicate by event ID
|
||||||
|
const messageMap = new Map<string, PublicMessage>(); |
||||||
|
[...toUser, ...fromUser].forEach(msg => { |
||||||
|
messageMap.set(msg.id, msg); |
||||||
|
}); |
||||||
|
|
||||||
|
// Sort by created_at descending
|
||||||
|
return Array.from(messageMap.values()) |
||||||
|
.sort((a, b) => b.created_at - a.created_at) |
||||||
|
.slice(0, limit); |
||||||
|
} catch (error) { |
||||||
|
logger.error({ error, userPubkey: userPubkey.slice(0, 16) + '...' }, 'Failed to fetch all public messages for user'); |
||||||
|
throw error; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Create and publish a public message |
||||||
|
* Messages are sent to inbox relays of recipients and outbox relay of sender |
||||||
|
*/ |
||||||
|
async sendPublicMessage( |
||||||
|
senderPubkey: string, |
||||||
|
content: string, |
||||||
|
recipients: Array<{ pubkey: string; relay?: string }>, |
||||||
|
senderRelays?: string[] |
||||||
|
): Promise<PublicMessage> { |
||||||
|
if (!content.trim()) { |
||||||
|
throw new Error('Message content cannot be empty'); |
||||||
|
} |
||||||
|
|
||||||
|
if (recipients.length === 0) { |
||||||
|
throw new Error('At least one recipient is required'); |
||||||
|
} |
||||||
|
|
||||||
|
// Build p tags for recipients
|
||||||
|
const pTags: string[][] = recipients.map(recipient => { |
||||||
|
const tag: string[] = ['p', recipient.pubkey]; |
||||||
|
if (recipient.relay) { |
||||||
|
tag.push(recipient.relay); |
||||||
|
} |
||||||
|
return tag; |
||||||
|
}); |
||||||
|
|
||||||
|
// Create the event (will be signed by client)
|
||||||
|
const messageEvent: Omit<PublicMessage, 'id' | 'sig'> = { |
||||||
|
pubkey: senderPubkey, |
||||||
|
kind: KIND.PUBLIC_MESSAGE, |
||||||
|
created_at: Math.floor(Date.now() / 1000), |
||||||
|
tags: pTags, |
||||||
|
content: content.trim() |
||||||
|
}; |
||||||
|
|
||||||
|
// Get sender's outbox relays if not provided
|
||||||
|
let outboxRelays: string[] = senderRelays || []; |
||||||
|
if (outboxRelays.length === 0) { |
||||||
|
const { outbox } = await getUserRelays(senderPubkey, this.nostrClient); |
||||||
|
outboxRelays = outbox; |
||||||
|
} |
||||||
|
|
||||||
|
// Get inbox relays for each recipient
|
||||||
|
const recipientInboxes = new Set<string>(); |
||||||
|
for (const recipient of recipients) { |
||||||
|
if (recipient.relay) { |
||||||
|
recipientInboxes.add(recipient.relay); |
||||||
|
} else { |
||||||
|
// Fetch recipient's inbox relays
|
||||||
|
try { |
||||||
|
const { inbox } = await getUserRelays(recipient.pubkey, this.nostrClient); |
||||||
|
inbox.forEach(relay => recipientInboxes.add(relay)); |
||||||
|
} catch (error) { |
||||||
|
logger.warn({ error, recipient: recipient.pubkey.slice(0, 16) + '...' }, 'Failed to fetch recipient inbox relays'); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Combine all relays: sender's outbox + all recipient inboxes
|
||||||
|
const allRelays = combineRelays([...outboxRelays, ...Array.from(recipientInboxes)]); |
||||||
|
|
||||||
|
// Return the event (client will sign and publish)
|
||||||
|
return messageEvent as PublicMessage; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get recipients from a public message |
||||||
|
*/ |
||||||
|
getRecipients(message: PublicMessage): Array<{ pubkey: string; relay?: string }> { |
||||||
|
return message.tags |
||||||
|
.filter(tag => tag[0] === 'p' && tag[1]) |
||||||
|
.map(tag => ({ |
||||||
|
pubkey: tag[1], |
||||||
|
relay: tag[2] || undefined |
||||||
|
})); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if a message is sent to a specific user |
||||||
|
*/ |
||||||
|
isMessageToUser(message: PublicMessage, userPubkey: string): boolean { |
||||||
|
return message.tags.some(tag => tag[0] === 'p' && tag[1] === userPubkey); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if a message is from a specific user |
||||||
|
*/ |
||||||
|
isMessageFromUser(message: PublicMessage, userPubkey: string): boolean { |
||||||
|
return message.pubkey === userPubkey; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,112 @@ |
|||||||
|
# Security Audit Report |
||||||
|
|
||||||
|
## Overview |
||||||
|
This document outlines the security measures implemented in the GitRepublic application. |
||||||
|
|
||||||
|
## Security Measures Implemented |
||||||
|
|
||||||
|
### 1. Server-Side Rate Limiting |
||||||
|
- **Location**: `src/lib/services/security/rate-limiter.ts` |
||||||
|
- **Implementation**: Server-side rate limiting in `hooks.server.ts` |
||||||
|
- **Features**: |
||||||
|
- Per-IP and per-user rate limiting |
||||||
|
- Different limits for authenticated vs anonymous users |
||||||
|
- Configurable via environment variables |
||||||
|
- Automatic cleanup of expired entries |
||||||
|
- **Security**: Client-side rate limiting removed (was insecure) |
||||||
|
|
||||||
|
### 2. User Level Verification |
||||||
|
- **Location**: `src/routes/api/user/level/+server.ts` |
||||||
|
- **Implementation**: Server-side verification of relay write access |
||||||
|
- **Features**: |
||||||
|
- Verifies NIP-98 proof events server-side |
||||||
|
- Cannot be bypassed by client manipulation |
||||||
|
- Three-tier access levels: unlimited, rate_limited, strictly_rate_limited |
||||||
|
- **Security**: Client-side checks are UI-only, actual enforcement is server-side |
||||||
|
|
||||||
|
### 3. Input Validation |
||||||
|
- **Location**: `src/lib/utils/input-validation.ts` |
||||||
|
- **Features**: |
||||||
|
- Repository name validation |
||||||
|
- File path validation (prevents path traversal) |
||||||
|
- Pubkey format validation |
||||||
|
- Commit message validation |
||||||
|
- Branch name validation |
||||||
|
- String sanitization (XSS prevention) |
||||||
|
|
||||||
|
### 4. Security Headers |
||||||
|
- **Location**: `src/hooks.server.ts` |
||||||
|
- **Headers Added**: |
||||||
|
- `X-Content-Type-Options: nosniff` - Prevents MIME type sniffing |
||||||
|
- `X-Frame-Options: DENY` - Prevents clickjacking |
||||||
|
- `X-XSS-Protection: 1; mode=block` - XSS protection |
||||||
|
- `Referrer-Policy: strict-origin-when-cross-origin` - Controls referrer information |
||||||
|
- `Permissions-Policy` - Restricts browser features |
||||||
|
- `Content-Security-Policy` - Restricts resource loading |
||||||
|
|
||||||
|
### 5. Audit Logging |
||||||
|
- **Location**: `src/lib/services/security/audit-logger.ts` |
||||||
|
- **Features**: |
||||||
|
- Comprehensive logging of security events |
||||||
|
- Automatic log rotation |
||||||
|
- Configurable retention period |
||||||
|
- Sanitization of sensitive data |
||||||
|
- Pubkey truncation for privacy |
||||||
|
|
||||||
|
### 6. Session Management |
||||||
|
- **Location**: `src/lib/services/activity-tracker.ts` |
||||||
|
- **Features**: |
||||||
|
- 24-hour session timeout |
||||||
|
- Activity tracking (ONLY timestamp, no activity details) |
||||||
|
- Automatic logout on expiry |
||||||
|
- Secure storage in localStorage (with XSS protections) |
||||||
|
- **Privacy**: Only stores timestamp of last activity. No information about what the user did is stored. |
||||||
|
|
||||||
|
### 7. Authentication |
||||||
|
- **NIP-98 Authentication**: Server-side verification of HTTP auth events |
||||||
|
- **NIP-07 Integration**: Client-side key management (keys never leave browser) |
||||||
|
- **Relay Write Proof**: Verifies users can write to Nostr relays |
||||||
|
|
||||||
|
## Security Best Practices |
||||||
|
|
||||||
|
### Client-Side Security |
||||||
|
1. **No Sensitive Logic**: All security-critical operations are server-side |
||||||
|
2. **Input Validation**: All user inputs are validated and sanitized |
||||||
|
3. **XSS Prevention**: Content sanitization and CSP headers |
||||||
|
4. **Session Management**: Secure session tracking with automatic expiry |
||||||
|
|
||||||
|
### Server-Side Security |
||||||
|
1. **Rate Limiting**: Prevents abuse and DoS attacks |
||||||
|
2. **Input Validation**: Server-side validation of all inputs |
||||||
|
3. **Path Traversal Prevention**: Strict path validation |
||||||
|
4. **Audit Logging**: Comprehensive security event logging |
||||||
|
5. **Error Handling**: Secure error messages that don't leak information |
||||||
|
|
||||||
|
## Known Limitations |
||||||
|
|
||||||
|
1. **localStorage Security**: Activity tracking uses localStorage which is vulnerable to XSS |
||||||
|
- **Mitigation**: CSP headers and input sanitization reduce XSS risk |
||||||
|
- **Future**: Consider httpOnly cookies for session management |
||||||
|
|
||||||
|
2. **In-Memory Rate Limiting**: Current implementation uses in-memory storage |
||||||
|
- **Mitigation**: Works for single-instance deployments |
||||||
|
- **Future**: Use Redis for distributed rate limiting |
||||||
|
|
||||||
|
3. **Client-Side User Level**: User level is determined client-side for UI |
||||||
|
- **Mitigation**: Actual enforcement is server-side via rate limiting |
||||||
|
- **Future**: Consider server-side session management |
||||||
|
|
||||||
|
## Recommendations |
||||||
|
|
||||||
|
1. **Implement CSRF Protection**: Add CSRF tokens for state-changing operations |
||||||
|
2. **Add Request Signing**: Sign all API requests to prevent replay attacks |
||||||
|
3. **Implement Rate Limiting Per Endpoint**: More granular rate limiting |
||||||
|
4. **Add IP Reputation Checking**: Block known malicious IPs |
||||||
|
5. **Implement WAF**: Web Application Firewall for additional protection |
||||||
|
6. **Regular Security Audits**: Periodic security reviews and penetration testing |
||||||
|
|
||||||
|
## Compliance |
||||||
|
|
||||||
|
- **GDPR**: User data is minimal (pubkeys only), no personal information stored |
||||||
|
- **Security Logging**: All security events are logged for compliance |
||||||
|
- **Data Retention**: Configurable log retention periods |
||||||
@ -0,0 +1,211 @@ |
|||||||
|
/** |
||||||
|
* API endpoint for managing messaging preferences |
||||||
|
*
|
||||||
|
* SECURITY: |
||||||
|
* - Requires unlimited user access level |
||||||
|
* - Verifies signed Nostr event proof |
||||||
|
* - Stores encrypted preferences with multiple security layers |
||||||
|
*/ |
||||||
|
|
||||||
|
import { json, error } from '@sveltejs/kit'; |
||||||
|
import type { RequestHandler } from './$types'; |
||||||
|
import { verifyEvent } from 'nostr-tools'; |
||||||
|
import { storePreferences, getPreferences, deletePreferences, hasPreferences, getRateLimitStatus } from '$lib/services/messaging/preferences-storage.js'; |
||||||
|
import { getCachedUserLevel } from '$lib/services/security/user-level-cache.js'; |
||||||
|
import { extractRequestContext } from '$lib/utils/api-context.js'; |
||||||
|
import { auditLogger } from '$lib/services/security/audit-logger.js'; |
||||||
|
import logger from '$lib/services/logger.js'; |
||||||
|
import type { MessagingPreferences } from '$lib/services/messaging/preferences-storage.js'; |
||||||
|
|
||||||
|
/** |
||||||
|
* POST - Save messaging preferences |
||||||
|
* Requires: |
||||||
|
* - Signed Nostr event (kind 30078) as proof |
||||||
|
* - User must have unlimited access level |
||||||
|
*/ |
||||||
|
export const POST: RequestHandler = async (event) => { |
||||||
|
const requestContext = extractRequestContext(event); |
||||||
|
const clientIp = requestContext.clientIp || 'unknown'; |
||||||
|
|
||||||
|
try { |
||||||
|
const body = await event.request.json(); |
||||||
|
const { preferences, proofEvent } = body; |
||||||
|
|
||||||
|
// Validate input
|
||||||
|
if (!preferences || !proofEvent) { |
||||||
|
auditLogger.log({ |
||||||
|
user: requestContext.userPubkeyHex || undefined, |
||||||
|
ip: clientIp, |
||||||
|
action: 'api.saveMessagingPreferences', |
||||||
|
resource: 'messaging_preferences', |
||||||
|
result: 'failure', |
||||||
|
error: 'Missing preferences or proof event' |
||||||
|
}); |
||||||
|
return error(400, 'Missing required fields: preferences and proofEvent'); |
||||||
|
} |
||||||
|
|
||||||
|
// Verify proof event signature
|
||||||
|
if (!verifyEvent(proofEvent)) { |
||||||
|
auditLogger.log({ |
||||||
|
user: requestContext.userPubkeyHex || undefined, |
||||||
|
ip: clientIp, |
||||||
|
action: 'api.saveMessagingPreferences', |
||||||
|
resource: 'messaging_preferences', |
||||||
|
result: 'failure', |
||||||
|
error: 'Invalid proof event signature' |
||||||
|
}); |
||||||
|
return error(400, 'Invalid proof event signature'); |
||||||
|
} |
||||||
|
|
||||||
|
const userPubkeyHex = proofEvent.pubkey; |
||||||
|
|
||||||
|
// Verify it's a valid preferences event (kind 30078, d tag = 'gitrepublic-messaging')
|
||||||
|
if (proofEvent.kind !== 30078) { |
||||||
|
auditLogger.log({ |
||||||
|
user: userPubkeyHex, |
||||||
|
ip: clientIp, |
||||||
|
action: 'api.saveMessagingPreferences', |
||||||
|
resource: 'messaging_preferences', |
||||||
|
result: 'failure', |
||||||
|
error: 'Invalid event kind' |
||||||
|
}); |
||||||
|
return error(400, 'Proof event must be kind 30078'); |
||||||
|
} |
||||||
|
|
||||||
|
const dTag = proofEvent.tags.find(t => t[0] === 'd'); |
||||||
|
if (dTag?.[1] !== 'gitrepublic-messaging') { |
||||||
|
auditLogger.log({ |
||||||
|
user: userPubkeyHex, |
||||||
|
ip: clientIp, |
||||||
|
action: 'api.saveMessagingPreferences', |
||||||
|
resource: 'messaging_preferences', |
||||||
|
result: 'failure', |
||||||
|
error: 'Invalid event d tag' |
||||||
|
}); |
||||||
|
return error(400, 'Proof event must have d tag "gitrepublic-messaging"'); |
||||||
|
} |
||||||
|
|
||||||
|
// Verify user has unlimited access
|
||||||
|
const cached = getCachedUserLevel(userPubkeyHex); |
||||||
|
if (!cached || cached.level !== 'unlimited') { |
||||||
|
auditLogger.log({ |
||||||
|
user: userPubkeyHex, |
||||||
|
ip: clientIp, |
||||||
|
action: 'api.saveMessagingPreferences', |
||||||
|
resource: 'messaging_preferences', |
||||||
|
result: 'failure', |
||||||
|
error: 'Requires unlimited access' |
||||||
|
}); |
||||||
|
return error(403, 'Messaging forwarding requires unlimited access level'); |
||||||
|
} |
||||||
|
|
||||||
|
// Validate preferences structure
|
||||||
|
if (typeof preferences.enabled !== 'boolean') { |
||||||
|
return error(400, 'Invalid preferences: enabled must be boolean'); |
||||||
|
} |
||||||
|
|
||||||
|
// Store preferences (will encrypt and store securely)
|
||||||
|
await storePreferences(userPubkeyHex, preferences as MessagingPreferences); |
||||||
|
|
||||||
|
auditLogger.log({ |
||||||
|
user: userPubkeyHex, |
||||||
|
ip: clientIp, |
||||||
|
action: 'api.saveMessagingPreferences', |
||||||
|
resource: 'messaging_preferences', |
||||||
|
result: 'success' |
||||||
|
}); |
||||||
|
|
||||||
|
return json({ success: true }); |
||||||
|
} catch (err) { |
||||||
|
const errorMessage = err instanceof Error ? err.message : String(err); |
||||||
|
logger.error({ error: err, clientIp }, '[API] Error saving messaging preferences'); |
||||||
|
|
||||||
|
auditLogger.log({ |
||||||
|
user: requestContext.userPubkeyHex || undefined, |
||||||
|
ip: clientIp, |
||||||
|
action: 'api.saveMessagingPreferences', |
||||||
|
resource: 'messaging_preferences', |
||||||
|
result: 'failure', |
||||||
|
error: errorMessage |
||||||
|
}); |
||||||
|
|
||||||
|
if (errorMessage.includes('rate limit')) { |
||||||
|
return error(429, errorMessage); |
||||||
|
} |
||||||
|
|
||||||
|
return error(500, 'Failed to save messaging preferences'); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
/** |
||||||
|
* GET - Retrieve messaging preferences |
||||||
|
* Returns encrypted status only (for UI), not decrypted data |
||||||
|
*/ |
||||||
|
export const GET: RequestHandler = async (event) => { |
||||||
|
const requestContext = extractRequestContext(event); |
||||||
|
const clientIp = requestContext.clientIp || 'unknown'; |
||||||
|
|
||||||
|
try { |
||||||
|
if (!requestContext.userPubkeyHex) { |
||||||
|
return error(401, 'Authentication required'); |
||||||
|
} |
||||||
|
|
||||||
|
// Verify user has unlimited access
|
||||||
|
const cached = getCachedUserLevel(requestContext.userPubkeyHex); |
||||||
|
if (!cached || cached.level !== 'unlimited') { |
||||||
|
return error(403, 'Messaging forwarding requires unlimited access level'); |
||||||
|
} |
||||||
|
|
||||||
|
// Check if preferences exist (without decrypting)
|
||||||
|
const exists = await hasPreferences(requestContext.userPubkeyHex); |
||||||
|
|
||||||
|
// Get rate limit status
|
||||||
|
const rateLimit = getRateLimitStatus(requestContext.userPubkeyHex); |
||||||
|
|
||||||
|
return json({ |
||||||
|
configured: exists, |
||||||
|
rateLimit: { |
||||||
|
remaining: rateLimit.remaining, |
||||||
|
resetAt: rateLimit.resetAt |
||||||
|
} |
||||||
|
}); |
||||||
|
} catch (err) { |
||||||
|
logger.error({ error: err, clientIp }, '[API] Error getting messaging preferences status'); |
||||||
|
return error(500, 'Failed to get messaging preferences status'); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
/** |
||||||
|
* DELETE - Remove messaging preferences |
||||||
|
*/ |
||||||
|
export const DELETE: RequestHandler = async (event) => { |
||||||
|
const requestContext = extractRequestContext(event); |
||||||
|
const clientIp = requestContext.clientIp || 'unknown'; |
||||||
|
|
||||||
|
try { |
||||||
|
if (!requestContext.userPubkeyHex) { |
||||||
|
return error(401, 'Authentication required'); |
||||||
|
} |
||||||
|
|
||||||
|
// Verify user has unlimited access
|
||||||
|
const cached = getCachedUserLevel(requestContext.userPubkeyHex); |
||||||
|
if (!cached || cached.level !== 'unlimited') { |
||||||
|
return error(403, 'Messaging forwarding requires unlimited access level'); |
||||||
|
} |
||||||
|
|
||||||
|
await deletePreferences(requestContext.userPubkeyHex); |
||||||
|
|
||||||
|
auditLogger.log({ |
||||||
|
user: requestContext.userPubkeyHex, |
||||||
|
ip: clientIp, |
||||||
|
action: 'api.deleteMessagingPreferences', |
||||||
|
resource: 'messaging_preferences', |
||||||
|
result: 'success' |
||||||
|
}); |
||||||
|
|
||||||
|
return json({ success: true }); |
||||||
|
} catch (err) { |
||||||
|
logger.error({ error: err, clientIp }, '[API] Error deleting messaging preferences'); |
||||||
|
return error(500, 'Failed to delete messaging preferences'); |
||||||
|
} |
||||||
|
}; |
||||||
@ -0,0 +1,58 @@ |
|||||||
|
/** |
||||||
|
* API endpoint for getting messaging preferences summary |
||||||
|
* Returns safe summary without sensitive tokens |
||||||
|
*/ |
||||||
|
|
||||||
|
import { json, error } from '@sveltejs/kit'; |
||||||
|
import type { RequestHandler } from './$types'; |
||||||
|
import { getPreferencesSummary } from '$lib/services/messaging/preferences-storage.js'; |
||||||
|
import { getCachedUserLevel } from '$lib/services/security/user-level-cache.js'; |
||||||
|
import { extractRequestContext } from '$lib/utils/api-context.js'; |
||||||
|
import logger from '$lib/services/logger.js'; |
||||||
|
|
||||||
|
/** |
||||||
|
* GET - Get messaging preferences summary (safe, no tokens) |
||||||
|
*/ |
||||||
|
export const GET: RequestHandler = async (event) => { |
||||||
|
const requestContext = extractRequestContext(event); |
||||||
|
const clientIp = requestContext.clientIp || 'unknown'; |
||||||
|
|
||||||
|
try { |
||||||
|
if (!requestContext.userPubkeyHex) { |
||||||
|
return error(401, 'Authentication required'); |
||||||
|
} |
||||||
|
|
||||||
|
// Verify user has unlimited access
|
||||||
|
const cached = getCachedUserLevel(requestContext.userPubkeyHex); |
||||||
|
if (!cached || cached.level !== 'unlimited') { |
||||||
|
return error(403, 'Messaging forwarding requires unlimited access level'); |
||||||
|
} |
||||||
|
|
||||||
|
// Get safe summary (decrypts but only returns safe info)
|
||||||
|
const summary = await getPreferencesSummary(requestContext.userPubkeyHex); |
||||||
|
|
||||||
|
if (!summary) { |
||||||
|
return json({ |
||||||
|
configured: false, |
||||||
|
enabled: false, |
||||||
|
platforms: {} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
return json(summary); |
||||||
|
} catch (err) { |
||||||
|
logger.error({ error: err, clientIp }, '[API] Error getting messaging preferences summary'); |
||||||
|
|
||||||
|
// If rate limit exceeded, return configured but no details
|
||||||
|
if (err instanceof Error && err.message.includes('rate limit')) { |
||||||
|
return json({ |
||||||
|
configured: true, |
||||||
|
enabled: false, |
||||||
|
platforms: {}, |
||||||
|
error: 'Rate limit exceeded' |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
return error(500, 'Failed to get messaging preferences summary'); |
||||||
|
} |
||||||
|
}; |
||||||
Loading…
Reference in new issue