Skip to content

feat: add NIP-44 E2E encryption for push notifications#27

Open
alltheseas wants to merge 5 commits into
damus-io:masterfrom
alltheseas:nip44-e2e-encryption
Open

feat: add NIP-44 E2E encryption for push notifications#27
alltheseas wants to merge 5 commits into
damus-io:masterfrom
alltheseas:nip44-e2e-encryption

Conversation

@alltheseas
Copy link
Copy Markdown

Summary

  • Add NIP-44 v2 E2E encryption for push notification payloads
  • Server encrypts notifications to client device pubkeys before sending via APNs
  • Apple only sees ciphertext, protecting notification content privacy

Changes

New Features

  • GET /server-pubkey - Public endpoint for clients to discover server's encryption pubkey
  • PUT /user-info/:pubkey/:deviceToken/encryption-key - Register device pubkey for encrypted notifications
  • Automatic encryption/plaintext fallback based on device registration

Configuration

  • NIP44_ENABLED=true - Enable encryption (default: false)
  • SERVER_PRIVKEY=<hex> - Required server private key for stable identity
  • NIP44_ALLOW_EPHEMERAL=true - Dev mode: allow ephemeral keys (not for production)

Code Quality

  • Typed NotificationManagerError for robust error handling
  • UPSERT pattern preserves device_pubkey on re-registration
  • Sensitive data (device tokens, pubkeys) removed from info-level logs
  • 23 tests total:
    • NIP-44 crypto roundtrip and format tests
    • Env gating logic tests (SERVER_PRIVKEY + allow_generate)
    • Integration tests with in-memory SQLite DB

Test Results

running 23 tests
test notification_manager::nip44_integration_tests::test_encrypted_notification_flow ... ok
test notification_manager::nip44_integration_tests::test_plaintext_fallback_flow ... ok
test notification_manager::nip44_integration_tests::test_upsert_preserves_device_pubkey ... ok
test notification_manager::nip44_integration_tests::test_encryption_key_before_registration_fails ... ok
test server_keys::tests::test_nip44_encrypt_decrypt_roundtrip ... ok
test server_keys::tests::test_nip44_wrong_key_fails_decryption ... ok
test server_keys::tests::test_nip44_payload_format ... ok
test server_keys::tests::test_from_env_value_* ... ok (5 tests)
... and 10 more existing tests

test result: ok. 23 passed; 0 failed

Commits

  1. feat: add NIP-44 E2E encryption for push notifications - Core implementation
  2. fix: address NIP-44 code review feedback - UPSERT fix, env gating, log cleanup
  3. fix: use typed error for device-not-found, add env gating tests - Robust error handling
  4. test: add NIP-44 integration tests with in-memory DB - Full flow tests

🤖 Generated with Claude Code

alltheseas and others added 4 commits December 19, 2025 15:55
Encrypt notification payloads to client device pubkeys using NIP-44 v2,
so Apple only sees ciphertext. Clients decrypt locally with their device
private key.

## Changes

### Infrastructure
- Enable `nip44` feature on nostr crate (uses rust-nostr implementation)
- Add `server_keys.rs` for server keypair management
- Add `NIP44_ENABLED` and `SERVER_PRIVKEY` environment variables
- Add `device_pubkey` column to `user_info` table

### API Endpoints
- `GET /server-pubkey` (public) - Returns server's pubkey for decryption
- `PUT /user-info/:pubkey/:deviceToken/encryption-key` (NIP-98 auth)
  Registers device pubkey for encrypted notifications

### Notification Encryption
- When device has registered pubkey, encrypt event JSON with NIP-44 v2
- Payload format: `{"encrypted": true, "ciphertext": "<base64>"}`
- Fallback to plaintext for devices without registered pubkey
- Structured logging for metrics (nip44_notification, nip44_encryption_error)

## Environment Variables

- `NIP44_ENABLED=true` - Enable E2E encryption
- `SERVER_PRIVKEY=<64-char-hex>` - Server's secp256k1 secret key

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Fix INSERT OR REPLACE dropping device_pubkey on re-registration
  by using UPSERT (ON CONFLICT DO UPDATE) pattern
- Require SERVER_PRIVKEY when NIP44_ENABLED=true (add NIP44_ALLOW_EPHEMERAL
  env var for explicit dev mode opt-in)
- Return 404 instead of 500 when setting encryption-key before device
  registration
- Downgrade sensitive logs (device tokens, full pubkeys) from info to debug
- Add NIP-44 encrypt/decrypt roundtrip tests

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Residual risks addressed:
- Replace string-matching error detection in /encryption-key endpoint
  with typed NotificationManagerError::DeviceNotRegistered for robustness
- Add 5 tests for env gating logic (SERVER_PRIVKEY + allow_generate)
- Refactor from_env_or_generate to extract testable from_env_value helper

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Test 1: Full encrypted notification flow (register → encrypt → decrypt)
- Test 2: Plaintext fallback when device has no pubkey
- Test 3: UPSERT preserves device_pubkey on re-registration
- Test 4: Typed error when setting encryption key before registration

Uses TestHarness with in-memory SQLite to test DB operations
and NIP-44 encryption without requiring APNs client.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@alltheseas
Copy link
Copy Markdown
Author

Mock APNs Integration Tests

Since the APNs client is tightly coupled and requires real credentials, I created a TestHarness that tests the NIP-44 flow with an in-memory SQLite database, bypassing the APNs send path while still exercising all the critical logic.

What's Tested

Test 1: test_encrypted_notification_flow

1. Register device → save_user_device_info()
2. Register device pubkey → save_device_pubkey()
3. Verify pubkey stored → get_device_pubkey()
4. Server encrypts payload → maybe_encrypt_payload() returns ciphertext
5. Client decrypts → nostr::nips::nip44::decrypt() recovers original JSON

Test 2: test_plaintext_fallback_flow

1. Register device (no encryption pubkey)
2. Verify no pubkey stored
3. maybe_encrypt_payload() returns None → plaintext mode

Test 3: test_upsert_preserves_device_pubkey

1. Register + set encryption key
2. Re-register same device (simulates token refresh)
3. Verify device_pubkey preserved (UPSERT fix)

Test 4: test_encryption_key_before_registration_fails

1. Try to set encryption key without registering device
2. Returns NotificationManagerError::DeviceNotRegistered (typed error)

Test Harness Design

struct TestHarness {
    db: Arc<Mutex<r2d2::Pool<SqliteConnectionManager>>>,  // In-memory SQLite
    server_keys: ServerKeys,  // Generated test keys
}

The harness mirrors the NotificationManager methods but doesn't require APNs credentials, making tests fast and CI-friendly.

@alltheseas
Copy link
Copy Markdown
Author

cc @tyiu @danieldaquino @jb55 ready for review sers

Add platform-based routing to send notifications via APNs (iOS) or
ntfy (Android). This enables push notifications for Android devices
without requiring Google Play Services.

Changes:
- Add Platform enum (ios/android) with routing logic
- Add ntfy HTTP client using reqwest
- Add platform column to user_info database table
- Accept optional "platform" field in device registration API
- Add NTFY_SERVER_URL environment variable (default: ntfy.damus.io)

API usage for Android:
  PUT /user-info/:pubkey/:deviceToken
  {"platform": "android"}

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@alltheseas
Copy link
Copy Markdown
Author

Closes #13

@alltheseas
Copy link
Copy Markdown
Author

convert to nip-59 - happens automatically in nostrdb

@alltheseas
Copy link
Copy Markdown
Author

@jb55 - how do we know what's in the giftwrap if iOS damus is receiving from notepush?

How does iOS damus send to nostrdb?

@jb55 maybe nip-59 is not better?

@alltheseas
Copy link
Copy Markdown
Author

@coderabbitai review please

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jan 22, 2026

@alltheseas: I'll review the changes in this PR now.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jan 22, 2026

Warning

Rate limit exceeded

@alltheseas has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 2 minutes and 2 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@alltheseas
Copy link
Copy Markdown
Author

@coderabbitai review please

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jan 22, 2026

@alltheseas: I'll review the changes in this PR now.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@alltheseas
Copy link
Copy Markdown
Author

@coderabbitai review please

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jan 22, 2026

@alltheseas: I'll review the changes in this PR now.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant