diff --git a/.claude/failure-patterns.json b/.claude/failure-patterns.json new file mode 100644 index 0000000..72c1aa8 --- /dev/null +++ b/.claude/failure-patterns.json @@ -0,0 +1,137 @@ +{ + "version": "1.0", + "description": "Common failure patterns and suggested fixes for autonomous iteration", + "patterns": [ + { + "id": "missing-import", + "regex": "Unresolved reference: (\\w+)", + "type": "compilation", + "severity": "error", + "fix": "Add import for $1", + "strategy": "search-and-import" + }, + { + "id": "type-mismatch", + "regex": "Type mismatch: inferred type is (\\w+) but (\\w+) was expected", + "type": "compilation", + "severity": "error", + "fix": "Cast or convert $1 to $2", + "strategy": "analyze-types" + }, + { + "id": "assertion-failure", + "regex": "expected:<(.+)> but was:<(.+)>", + "type": "test", + "severity": "error", + "fix": "Check test expectation or fix implementation", + "strategy": "compare-expected-actual" + }, + { + "id": "null-pointer", + "regex": "NullPointerException|null cannot be cast to non-null", + "type": "runtime", + "severity": "error", + "fix": "Add null check or use safe call operator", + "strategy": "null-safety" + }, + { + "id": "missing-function", + "regex": "Unresolved reference: (\\w+)\\(\\)", + "type": "compilation", + "severity": "error", + "fix": "Implement missing function $1 or add import", + "strategy": "implement-or-import" + }, + { + "id": "missing-parameter", + "regex": "No value passed for parameter '(\\w+)'", + "type": "compilation", + "severity": "error", + "fix": "Add missing parameter $1 to function call", + "strategy": "add-parameter" + }, + { + "id": "visibility-error", + "regex": "Cannot access '(\\w+)': it is (\\w+) in", + "type": "compilation", + "severity": "error", + "fix": "Change visibility of $1 from $2 to internal/public", + "strategy": "adjust-visibility" + }, + { + "id": "suspend-context", + "regex": "Suspend function '(\\w+)' should be called only from a coroutine", + "type": "compilation", + "severity": "error", + "fix": "Wrap call in coroutine scope or make calling function suspend", + "strategy": "coroutine-context" + }, + { + "id": "http-status-mismatch", + "regex": "expected:<(\\d+)> but was:<(\\d+)>.*HttpStatusCode", + "type": "test", + "severity": "error", + "fix": "Check route handler returns correct status code", + "strategy": "analyze-http-response" + }, + { + "id": "json-parse-error", + "regex": "JsonDecodingException|Unexpected JSON token", + "type": "runtime", + "severity": "error", + "fix": "Check JSON structure matches expected DTO", + "strategy": "validate-json-schema" + }, + { + "id": "timeout", + "regex": "Timed out waiting for|SocketTimeoutException", + "type": "runtime", + "severity": "warning", + "fix": "Increase timeout or check for blocking operations", + "strategy": "async-analysis" + }, + { + "id": "connection-refused", + "regex": "Connection refused|ConnectException", + "type": "runtime", + "severity": "error", + "fix": "Ensure service is running (Postgres/Redis in CI)", + "strategy": "check-services" + } + ], + "strategies": { + "search-and-import": { + "description": "Search codebase for symbol definition and add import", + "steps": [ + "Grep for 'class $1' or 'fun $1' or 'object $1'", + "Find package declaration in matching file", + "Add import statement to failing file" + ] + }, + "analyze-types": { + "description": "Analyze type hierarchy and apply correct conversion", + "steps": [ + "Check if types are related (inheritance)", + "If convertible, add explicit cast or conversion", + "If not, fix the source of wrong type" + ] + }, + "compare-expected-actual": { + "description": "Compare expected vs actual values to identify root cause", + "steps": [ + "Read the test to understand intent", + "Read the implementation under test", + "Determine if test expectation is wrong or implementation is wrong", + "Fix the appropriate side" + ] + }, + "null-safety": { + "description": "Apply Kotlin null safety patterns", + "steps": [ + "Identify source of null value", + "Add null check with ?. or ?: operator", + "Or fix the source to not produce null" + ] + } + } +} diff --git a/.claude/quality-gates.json b/.claude/quality-gates.json new file mode 100644 index 0000000..6b2519b --- /dev/null +++ b/.claude/quality-gates.json @@ -0,0 +1,59 @@ +{ + "version": "1.0", + "description": "Quality gates for DeepLine autonomous development", + "gates": { + "build": { + "command": "./gradlew assemble", + "description": "All modules must compile successfully", + "required": true + }, + "server-tests": { + "command": "./gradlew :server:test --info", + "description": "Server unit and integration tests", + "required": true, + "minCoverage": 70 + }, + "shared-tests": { + "command": "./gradlew :shared:serverTest --info", + "description": "Shared module JVM/server tests", + "required": true, + "minCoverage": 80 + }, + "android-tests": { + "command": "./gradlew :clients:android:app:testDebugUnitTest --info", + "description": "Android unit tests", + "required": true + }, + "security": { + "description": "Crypto boundary and security checks", + "checks": [ + "no-plaintext-server", + "crypto-interfaces-only", + "secure-random", + "rate-limiting", + "no-hardcoded-secrets" + ], + "required": true + }, + "lint": { + "command": "./gradlew ktlintCheck", + "description": "Kotlin code style", + "required": false + } + }, + "autoFix": { + "enabled": true, + "maxIterations": 5, + "failurePatterns": ".claude/failure-patterns.json", + "strategy": "fix-and-retest" + }, + "coverage": { + "tool": "kover", + "reportCommand": "./gradlew koverHtmlReport", + "thresholds": { + "server": 70, + "shared": 80, + "android": 60 + } + } +} diff --git a/.claude/skills/ai-iterate/SKILL.md b/.claude/skills/ai-iterate/SKILL.md new file mode 100644 index 0000000..ecee41e --- /dev/null +++ b/.claude/skills/ai-iterate/SKILL.md @@ -0,0 +1,108 @@ +# AI Iterate Skill + +Autonomous iteration loop for fixing failures without human intervention. + +## Trigger + +Use this skill when: +- CI has failed and needs fixing +- Multiple test failures need resolution +- Asked to "fix all the tests" or "make it work" + +## Core Algorithm + +``` +FAILURE_QUEUE = parse_failures(test_output) +ITERATION = 0 +MAX_ITERATIONS = 5 + +while FAILURE_QUEUE not empty AND ITERATION < MAX_ITERATIONS: + ITERATION++ + + failure = FAILURE_QUEUE.pop() + + # Analyze + pattern = match_failure_pattern(failure, ".claude/failure-patterns.json") + source_file = identify_source_file(failure) + test_file = identify_test_file(failure) + + # Read context + read(source_file) + read(test_file) + + # Generate fix + fix = generate_fix(failure, pattern, context) + + # Apply fix + apply_edit(source_file, fix) + + # Verify + result = run_test(test_file, failure.test_method) + + if result.passed: + log("Fixed: " + failure.description) + else if result.new_failure: + FAILURE_QUEUE.push(result.new_failure) + log("New failure discovered, adding to queue") + else: + # Same failure - try alternative approach + alternative_fix = generate_alternative_fix(failure, pattern) + apply_edit(source_file, alternative_fix) + # Re-test... + +# Final verification +run_full_suite() +``` + +## Failure Pattern Matching + +Reference `.claude/failure-patterns.json` for common patterns: + +| Pattern | Strategy | +|---------|----------| +| `Unresolved reference: X` | Search codebase for X, add import | +| `Type mismatch` | Analyze types, add cast/conversion | +| `expected: but was:` | Compare test vs implementation | +| `NullPointerException` | Add null safety | +| `Suspend function` | Add coroutine context | + +## Iteration Limits + +- **Per failure**: Max 3 fix attempts before escalating +- **Total iterations**: Max 5 rounds through the queue +- **Time limit**: None (complete the task) + +## When to Escalate + +If after MAX_ITERATIONS failures remain: +1. Document what was tried +2. Identify the root cause pattern +3. Suggest architectural changes if needed +4. Create a detailed issue for human review + +## Example Session + +``` +[Iteration 1] +Failure: UserRoutesTest.testCreateUser - expected 201 but was 400 +Pattern: http-status-mismatch +Action: Read UserRoutes.kt, found missing validation +Fix: Added email format validation +Result: PASS + +[Iteration 2] +Failure: MessageRoutesTest.testSendMessage - NullPointerException +Pattern: null-pointer +Action: Read MessageRoutes.kt, conversation lookup returns null +Fix: Added null check with proper error response +Result: PASS + +[Final] All tests passing βœ“ +``` + +## Integration + +This skill works with: +- `ci-loop` - Provides the test execution +- `test-gaps` - Identifies missing coverage +- `security-scan` - Validates crypto boundary diff --git a/.claude/skills/auto-fix/SKILL.md b/.claude/skills/auto-fix/SKILL.md new file mode 100644 index 0000000..4824475 --- /dev/null +++ b/.claude/skills/auto-fix/SKILL.md @@ -0,0 +1,130 @@ +# Auto-Fix Skill + +Automatically fix CI failures from GitHub Issues labeled `ai-fix`. + +## Trigger + +This skill activates when: +- A GitHub Issue is created with label `ai-fix` +- The issue contains CI failure information +- Asked to "fix the CI failure" with an issue reference + +## Workflow + +### 1. Read the Failure Issue + +Parse the issue body for: +- Workflow name (Server CI, Android CI, etc.) +- Failed job/step +- Error messages and stack traces +- Affected files + +### 2. Reproduce Locally + +```bash +# Run the same command that failed in CI +./gradlew :server:test --info # or whichever failed +``` + +### 3. Analyze the Failure + +Using failure-patterns.json, identify: +- Error type (compilation, test, runtime) +- Root cause +- Affected source file(s) + +### 4. Apply Fix + +1. Read the source file +2. Read the test file (if test failure) +3. Generate the fix based on pattern strategy +4. Apply the edit +5. Re-run the failing test locally + +### 5. Verify Fix + +```bash +# Run full suite to catch regressions +./gradlew check +``` + +### 6. Create PR + +```bash +# Create branch +git checkout -b fix/ci-failure-$(date +%Y%m%d-%H%M%S) + +# Commit +git add -A +git commit -m "fix: resolve CI failure in [module] + +Fixes #[issue-number] + +- [Description of what was wrong] +- [Description of the fix] + +Co-Authored-By: Claude Opus 4.5 " + +# Push and create PR +git push -u origin HEAD +gh pr create --title "fix: resolve CI failure" \ + --body "Fixes #[issue-number] + +## Summary +- [What failed] +- [Root cause] +- [Fix applied] + +## Test Results +All tests passing locally. + +πŸ€– Generated with [Claude Code](https://claude.com/claude-code)" \ + --label "ai-generated-fix" +``` + +## Issue Format Expected + +The AI Feedback Loop workflow creates issues with this format: + +```markdown +## CI Failure Report + +**Workflow**: Server CI +**Run**: https://github.com/.../actions/runs/12345 +**Branch**: feature/xyz +**Commit**: abc123 + +### Next Steps for Claude +1. Read the workflow run logs... +2. Identify the failing test(s)... + +### Failure Artifacts +[Test XML output if available] +``` + +## PR Labels + +- `ai-generated-fix` - Indicates PR was created by autonomous fix +- `ci-failure` - Links back to the failure type +- `automated` - General automation label + +## Limitations + +The auto-fix will NOT: +- Make architectural changes +- Modify more than 3 files without confirmation +- Change public API contracts +- Remove tests (only fix or add) + +If the fix requires these, create a detailed issue instead of a PR. + +## Integration + +This skill is triggered by: +- `.github/workflows/ai-feedback-loop.yml` creating issues +- Manual invocation with issue reference + +Works with: +- `ci-loop` - For local test execution +- `ai-iterate` - For multi-failure resolution +- `security-scan` - Validates fixes don't break security diff --git a/.claude/skills/ci-loop/SKILL.md b/.claude/skills/ci-loop/SKILL.md new file mode 100644 index 0000000..e14219f --- /dev/null +++ b/.claude/skills/ci-loop/SKILL.md @@ -0,0 +1,79 @@ +# CI Loop Skill + +Run the full CI pipeline locally and iterate until all tests pass. + +## Trigger + +Use this skill when: +- Starting work on a feature or bugfix +- Before creating a commit or PR +- When asked to "run tests" or "make sure everything passes" +- After making changes to verify they work + +## Workflow + +### 1. Initial Assessment +```bash +# Check current state +./gradlew assemble --dry-run +``` + +### 2. Run Full Test Suite +```bash +# Server tests +./gradlew :server:test --info + +# Shared module tests (JVM/server) +./gradlew :shared:serverTest --info + +# Android tests +./gradlew :clients:android:app:testDebugUnitTest --info +``` + +### 3. Parse Failures +For each failure in the output: +1. Extract the test class and method name +2. Extract the error message and stack trace +3. Identify the source file and line number + +### 4. Fix Loop +``` +while failures exist: + for each failure: + 1. Read the failing test file + 2. Read the source file under test + 3. Analyze the failure using failure-patterns.json + 4. Apply the fix + 5. Re-run the specific test: + ./gradlew :module:test --tests "FullyQualifiedTestClass.testMethod" + 6. If still failing, try alternative fix + 7. If fixed, move to next failure +``` + +### 5. Final Verification +```bash +# Run full suite to catch regressions +./gradlew check + +# If all pass, report success +# If new failures, add to queue and continue +``` + +## Quick Commands + +```bash +# Run specific test +./gradlew :server:test --tests "dev.hyo.deepline.server.routes.UserRoutesTest.testCreateUser" + +# Run tests with stacktrace +./gradlew :server:test --stacktrace + +# Continue after failure +./gradlew :server:test --continue +``` + +## Success Criteria + +- All tests pass (exit code 0) +- No compilation errors +- Security validation passes (see security-scan skill) diff --git a/.claude/skills/review-pr/SKILL.md b/.claude/skills/review-pr/SKILL.md index fcd3c22..35e5c2b 100644 --- a/.claude/skills/review-pr/SKILL.md +++ b/.claude/skills/review-pr/SKILL.md @@ -14,6 +14,16 @@ Perform a structured code review of a GitHub pull request against Deepline proje /review-pr https://github.com/hyodotdev/DeepLine/pull/123 ``` +## Critical Rules + +1. **Fix ALL review comments NOW** β€” Every inline comment must be addressed with a code fix and committed before replying. No "will address in follow-up" or "will fix later". + +2. **Reply to inline comments using the proper API** β€” NEVER use `gh pr comment` for review items. Always use the comment-specific reply endpoint. + +3. **NEVER wrap commit hashes in backticks** β€” GitHub only auto-links plain text commit hashes. Write `Fixed in abc1234.` not `Fixed in \`abc1234\`.` + +4. **Resolve threads only after code is pushed** β€” Don't resolve threads for suggestions or items awaiting clarification. + ## Review Process ### 1. Fetch PR Information @@ -24,7 +34,41 @@ gh pr diff gh pr checks ``` -### 2. Review Checklist +### 2. Check for Existing Review Comments + +```bash +# Get all inline review comments with their IDs +gh api repos/hyodotdev/DeepLine/pulls//comments \ + --jq '.[] | {id: .id, path: .path, line: .line, body: .body[:200]}' +``` + +### 3. Address Each Comment + +For each inline review comment: + +1. **Read the comment** β€” Understand what needs to be fixed +2. **Fix the code** β€” Make the necessary changes +3. **Test locally** β€” Run `./gradlew :server:test` +4. **Commit the fix** β€” Create a focused commit +5. **Push to the branch** β€” `git push` +6. **Reply to the comment** β€” Use the reply API (see below) + +### 4. Reply to Inline Comments + +```bash +# Reply to a specific inline comment +gh api repos/hyodotdev/DeepLine/pulls//comments//replies \ + -X POST -f body="Fixed in . " +``` + +**Example replies:** +``` +Fixed in abc1234. Added HMAC-SHA256 with server-side secret for OTP hashing. +Fixed in def5678. Added IP rate limiting on /verify endpoint. +Already addressed in 6a8abd2. SecureRandom is now used for OTP generation. +``` + +### 5. Review Checklist #### Security (Critical) - [ ] No plaintext secrets, API keys, or credentials @@ -33,6 +77,8 @@ gh pr checks - [ ] No path traversal in file operations - [ ] Rate limiting on write endpoints - [ ] Input validation on all user-provided data +- [ ] Cryptographic operations use SecureRandom +- [ ] OTP/tokens hashed with HMAC or bcrypt, not plain SHA-256 #### Architecture - [ ] Changes align with module boundaries (`shared/`, `server/`, `clients/`) @@ -54,54 +100,10 @@ gh pr checks #### Compatibility - [ ] Database migrations are backward compatible +- [ ] Migration versions don't conflict with existing ones - [ ] API changes are versioned appropriately - [ ] Client changes work with current server version -### 3. Review Comments - -Categorize findings: - -- **BLOCKER** β€” Must fix before merge (security, data loss, crash) -- **MAJOR** β€” Should fix before merge (bugs, missing validation) -- **MINOR** β€” Nice to fix (style, naming, minor improvements) -- **NIT** β€” Optional (formatting, personal preference) - -### 4. Output Format - -```markdown -## PR Review: # - - -### Summary -<1-2 sentence summary of what the PR does> - -### Changes Overview -- **Files changed**: X -- **Additions**: +Y -- **Deletions**: -Z -- **Modules affected**: server, android, ios, shared - -### Findings - -#### Blockers (X) -- [ ] `file:line` - Description of issue - -#### Major Issues (X) -- [ ] `file:line` - Description of issue - -#### Minor Issues (X) -- [ ] `file:line` - Description of issue - -### Tests -- [ ] Tests pass locally -- [ ] New tests added for new functionality -- [ ] Test coverage adequate - -### Recommendation -**APPROVE** | **REQUEST CHANGES** | **COMMENT** - -<Reasoning for recommendation> -``` - ## Deepline-Specific Checks ### Server (`server/`) @@ -109,6 +111,7 @@ Categorize findings: - Store interface extended if new data operations - Both `InMemoryDeeplineStore` and `JdbcDeeplineStore` updated - WebSocket changes handle connection lifecycle +- Rate limiting on all write endpoints ### Shared (`shared/`) - Models are `@Serializable` @@ -134,8 +137,13 @@ gh pr view 123 # View PR diff gh pr diff 123 -# View PR comments -gh api repos/hyodotdev/DeepLine/pulls/123/comments +# Get inline review comments +gh api repos/hyodotdev/DeepLine/pulls/123/comments \ + --jq '.[] | {id: .id, path: .path, line: .line, body: .body[:100]}' + +# Reply to inline comment +gh api repos/hyodotdev/DeepLine/pulls/123/comments/COMMENT_ID/replies \ + -X POST -f body="Fixed in COMMIT_HASH. Description." # View check status gh pr checks 123 @@ -154,3 +162,4 @@ cd clients/ios && xcodegen generate && xcodebuild -scheme DeeplineIOS build - Do not auto-approve PRs - Do not merge PRs (leave that to maintainers) - Do not run full security audit (use `/audit-code` for that) +- Do not use `gh pr comment` for review item responses diff --git a/.claude/skills/security-scan/SKILL.md b/.claude/skills/security-scan/SKILL.md new file mode 100644 index 0000000..6f84ce6 --- /dev/null +++ b/.claude/skills/security-scan/SKILL.md @@ -0,0 +1,165 @@ +# Security Scan Skill + +Continuous security validation for E2EE compliance. Run this after any change to server, shared, or crypto-related code. + +## Trigger + +Use this skill when: +- Making changes to server routes +- Modifying shared module crypto interfaces +- Before any PR that touches message handling +- As part of `/audit-code` + +## Security Checks + +### 1. Server Never Sees Plaintext + +The server must only handle opaque ciphertext. No decryption operations. + +```bash +# Should return NO results +grep -rn "fun decrypt\|\.decrypt(" server/src/main --include="*.kt" +grep -rn "plaintext\s*=" server/src/main --include="*.kt" +``` + +βœ… Pass: No matches +❌ Fail: Any decrypt operation in server code + +### 2. Crypto Interfaces Only in Shared + +The `shared/` module defines interfaces, never implementations. + +```bash +# Check for interface definitions (expected) +grep -rn "interface.*CryptoBridge" shared/src/commonMain --include="*.kt" + +# Should return NO results - no implementations +grep -rn "class.*:.*CryptoBridge\|object.*:.*CryptoBridge" shared/src --include="*.kt" +``` + +βœ… Pass: Only interfaces exist +❌ Fail: Any CryptoBridge implementation in shared/ + +### 3. SecureRandom for Cryptographic Operations + +All randomness in crypto contexts must use SecureRandom. + +```bash +# Find all Random usage +grep -rn "Random\(\)" server/src/main --include="*.kt" + +# Verify it's SecureRandom +grep -rn "SecureRandom" server/src/main --include="*.kt" +``` + +βœ… Pass: All random is SecureRandom +⚠️ Warning: kotlin.random.Random found (verify not used for crypto) +❌ Fail: java.util.Random used for token/key generation + +### 4. No Hardcoded Secrets + +```bash +# Check for hardcoded credentials +grep -rniE "(api_key|apikey|secret|password|token)\s*=\s*['\"][^'\"]{8,}['\"]" \ + server/src shared/src clients/android --include="*.kt" +``` + +βœ… Pass: No hardcoded secrets (test/example values OK) +❌ Fail: Real credentials in source + +### 5. Rate Limiting on All Routes + +Every public route must have rate limiting. + +```bash +# Find all route definitions +grep -rn "route\|get\|post\|put\|delete" server/src/main/kotlin --include="*Routes.kt" + +# Find rate limit calls +grep -rn "enforceRateLimit\|rateLimit" server/src/main/kotlin --include="*Routes.kt" +``` + +βœ… Pass: Rate limiting present on routes +⚠️ Warning: Some routes may be missing rate limiting + +### 6. Input Validation + +Check that user input is validated before use. + +```bash +# Find request body parsing +grep -rn "call.receive<" server/src/main --include="*.kt" + +# Look for validation +grep -rn "require\|check\|validate" server/src/main --include="*.kt" +``` + +## Validation Script + +Run all checks in sequence: + +```bash +#!/bin/bash +ERRORS=0 + +echo "=== DeepLine Security Scan ===" + +echo -n "1. Server plaintext check... " +if grep -rq "fun decrypt\|\.decrypt(" server/src/main --include="*.kt" 2>/dev/null; then + echo "❌ FAIL" + ((ERRORS++)) +else + echo "βœ… PASS" +fi + +echo -n "2. Crypto interfaces check... " +if grep -rq "class.*:.*CryptoBridge" shared/src --include="*.kt" 2>/dev/null; then + echo "❌ FAIL" + ((ERRORS++)) +else + echo "βœ… PASS" +fi + +echo -n "3. SecureRandom check... " +if grep -rq "java\.util\.Random\(\)" server/src/main --include="*.kt" 2>/dev/null; then + echo "❌ FAIL" + ((ERRORS++)) +else + echo "βœ… PASS" +fi + +echo "" +if [ $ERRORS -eq 0 ]; then + echo "βœ… All security checks passed" +else + echo "❌ $ERRORS security check(s) failed" + exit 1 +fi +``` + +## E2EE Architecture Reference + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Client A β”‚ β”‚ Server β”‚ β”‚ Client B β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ Plaintext β”‚ β”‚ Ciphertext β”‚ β”‚ Plaintext β”‚ +β”‚ ↓ β”‚ β”‚ ONLY β”‚ β”‚ ↑ β”‚ +β”‚ Encrypt │────▢│ Store │────▢│ Decrypt β”‚ +β”‚ (on-device) β”‚ β”‚ Route β”‚ β”‚ (on-device) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +The server NEVER has access to encryption keys or plaintext. It only: +- Stores opaque ciphertext blobs +- Routes messages by conversation ID +- Manages prekeys (public keys only) +- Enforces rate limits + +## Files to Monitor + +Critical security files: +- `server/src/main/kotlin/*/routes/MessageRoutes.kt` +- `server/src/main/kotlin/*/routes/ConversationRoutes.kt` +- `server/src/main/kotlin/*/store/*Store.kt` +- `shared/src/commonMain/kotlin/*/crypto/*Bridge.kt` diff --git a/.claude/skills/test-gaps/SKILL.md b/.claude/skills/test-gaps/SKILL.md new file mode 100644 index 0000000..02bc1b1 --- /dev/null +++ b/.claude/skills/test-gaps/SKILL.md @@ -0,0 +1,116 @@ +# Test Gaps Skill + +Identify untested code paths and generate tests to improve coverage. + +## Trigger + +Use this skill when: +- Asked to "improve test coverage" +- Before shipping a feature +- Coverage report shows gaps +- New code was added without tests + +## Workflow + +### 1. Generate Coverage Report + +```bash +# Add Kover plugin if not present, then: +./gradlew koverHtmlReport + +# Report locations: +# server/build/reports/kover/html/index.html +# shared/build/reports/kover/html/index.html +``` + +### 2. Parse Coverage Data + +Extract from the HTML/XML report: +- Files with < target coverage +- Uncovered lines and branches +- Methods with 0% coverage + +### 3. Prioritize Gaps + +Priority order: +1. **Critical paths**: Authentication, encryption, rate limiting +2. **Route handlers**: API endpoints +3. **Business logic**: Core domain functions +4. **Edge cases**: Error handling, boundary conditions + +### 4. Generate Tests + +For each gap: + +```kotlin +// Template for route test +@Test +fun `test [route description]`() = testApplication { + // Setup + val client = createClient { ... } + + // Execute + val response = client.post("/v1/endpoint") { + contentType(ContentType.Application.Json) + setBody("""{"field": "value"}""") + } + + // Verify + assertEquals(HttpStatusCode.OK, response.status) + val body = response.body<ResponseDto>() + assertEquals(expected, body.field) +} +``` + +### 5. Verify Coverage Improved + +```bash +./gradlew koverHtmlReport +# Check new coverage percentage +``` + +## Coverage Targets + +| Module | Target | Critical Areas | +|--------|--------|----------------| +| server | 80%+ | Routes, RateLimiter, Store | +| shared | 90%+ | DTOs, CryptoBridge interfaces | +| android | 60%+ | ViewModel, Repository | + +## Test Generation Patterns + +### Route Handler Tests +```kotlin +// Happy path +@Test fun `POST /v1/users creates user successfully`() + +// Validation errors +@Test fun `POST /v1/users returns 400 for invalid email`() + +// Authorization +@Test fun `POST /v1/users returns 401 without token`() + +// Rate limiting +@Test fun `POST /v1/users returns 429 when rate limited`() +``` + +### Business Logic Tests +```kotlin +// Normal operation +@Test fun `processMessage encrypts and stores`() + +// Edge cases +@Test fun `processMessage handles empty content`() +@Test fun `processMessage handles max size content`() + +// Error conditions +@Test fun `processMessage throws on invalid conversation`() +``` + +## Files to Check + +Priority files for coverage: +- `server/src/main/kotlin/*/routes/*.kt` +- `server/src/main/kotlin/*/store/*.kt` +- `server/src/main/kotlin/*/service/*.kt` +- `shared/src/commonMain/kotlin/*/dto/*.kt` diff --git a/.github/workflows/ai-feedback-loop.yml b/.github/workflows/ai-feedback-loop.yml new file mode 100644 index 0000000..3bc2ee2 --- /dev/null +++ b/.github/workflows/ai-feedback-loop.yml @@ -0,0 +1,64 @@ +name: AI Feedback Loop +on: + workflow_run: + workflows: ["Server CI", "Shared Module CI", "Android CI", "iOS CI"] + types: [completed] + +jobs: + analyze-failure: + if: ${{ github.event.workflow_run.conclusion == 'failure' }} + runs-on: ubuntu-latest + permissions: + issues: write + actions: read + steps: + - uses: actions/checkout@v4 + + - name: Check for existing issue + id: check-issue + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Check if an open issue already exists for this workflow + branch + BRANCH="${{ github.event.workflow_run.head_branch }}" + WORKFLOW="${{ github.event.workflow_run.name }}" + EXISTING=$(gh issue list --state open --label ai-fix --search "in:title [AI-FIX] CI Failure: $WORKFLOW" --json number,title --jq ".[0].number // empty") + if [ -n "$EXISTING" ]; then + echo "skip=true" >> $GITHUB_OUTPUT + echo "Found existing issue #$EXISTING, skipping duplicate creation" + else + echo "skip=false" >> $GITHUB_OUTPUT + fi + + - name: Create failure report + if: steps.check-issue.outputs.skip == 'false' + run: | + cat > failure-report.md << 'EOF' + ## CI Failure Report + + **Workflow**: ${{ github.event.workflow_run.name }} + **Run**: ${{ github.event.workflow_run.html_url }} + **Branch**: ${{ github.event.workflow_run.head_branch }} + **Commit**: ${{ github.event.workflow_run.head_sha }} + + ### Next Steps for Claude + + 1. Read the workflow run logs at the URL above + 2. Identify the failing test(s) or build error + 3. Read the relevant source files + 4. Analyze the root cause + 5. Create a fix + 6. Open a PR with label `ai-generated-fix` + + ### Failure Details + + See the workflow run URL above for full logs and error details. + EOF + + - name: Create issue for Claude + if: steps.check-issue.outputs.skip == 'false' + uses: peter-evans/create-issue-from-file@v5 + with: + title: "[AI-FIX] CI Failure: ${{ github.event.workflow_run.name }}" + content-filepath: failure-report.md + labels: ai-fix,ci-failure,automated diff --git a/.github/workflows/ci-android.yml b/.github/workflows/ci-android.yml new file mode 100644 index 0000000..e72798a --- /dev/null +++ b/.github/workflows/ci-android.yml @@ -0,0 +1,39 @@ +name: Android CI +on: + push: + paths: ['clients/android/**', 'shared/**'] + pull_request: + paths: ['clients/android/**', 'shared/**'] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Build debug APK + run: ./gradlew :clients:android:app:assembleDebug + + - name: Run unit tests + run: ./gradlew :clients:android:app:testDebugUnitTest --info + + - name: Upload APK + uses: actions/upload-artifact@v4 + with: + name: android-debug-apk + path: clients/android/app/build/outputs/apk/debug/*.apk + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: android-test-results + path: clients/android/app/build/reports/tests/ diff --git a/.github/workflows/ci-ios.yml b/.github/workflows/ci-ios.yml new file mode 100644 index 0000000..c8703d1 --- /dev/null +++ b/.github/workflows/ci-ios.yml @@ -0,0 +1,43 @@ +name: iOS CI +on: + push: + paths: ['clients/ios/**', 'shared/**'] + pull_request: + paths: ['clients/ios/**', 'shared/**'] + +jobs: + build: + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + + - name: Install XcodeGen + run: brew install xcodegen + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_15.2.app/Contents/Developer + + - name: Generate Xcode project + working-directory: clients/ios + run: xcodegen generate + + - name: Build iOS app + working-directory: clients/ios + run: | + xcodebuild -project DeeplineIOS.xcodeproj \ + -scheme DeeplineIOS \ + -destination 'platform=iOS Simulator,name=iPhone 15,OS=17.2' \ + -configuration Debug \ + build \ + CODE_SIGNING_ALLOWED=NO + + - name: Run tests + working-directory: clients/ios + continue-on-error: true # Tests may not exist yet; remove when tests are added + run: | + xcodebuild -project DeeplineIOS.xcodeproj \ + -scheme DeeplineIOS \ + -destination 'platform=iOS Simulator,name=iPhone 15,OS=17.2' \ + -configuration Debug \ + test \ + CODE_SIGNING_ALLOWED=NO diff --git a/.github/workflows/ci-server.yml b/.github/workflows/ci-server.yml new file mode 100644 index 0000000..b8d4c98 --- /dev/null +++ b/.github/workflows/ci-server.yml @@ -0,0 +1,63 @@ +name: Server CI +on: + push: + paths: ['server/**', 'shared/**'] + pull_request: + paths: ['server/**', 'shared/**'] + +jobs: + test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:15 + env: + POSTGRES_DB: deepline_test + POSTGRES_USER: test + POSTGRES_PASSWORD: test + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: ['5432:5432'] + redis: + image: redis:7 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: ['6379:6379'] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Run server tests + run: ./gradlew :server:test --info + env: + DEEPLINE_STORE_MODE: postgres + DEEPLINE_DATABASE_URL: jdbc:postgresql://localhost:5432/deepline_test + DEEPLINE_DATABASE_USER: test + DEEPLINE_DATABASE_PASSWORD: test + DEEPLINE_RATE_LIMITER_MODE: redis + DEEPLINE_REDIS_URL: redis://localhost:6379 + + - name: Run shared serverTest + run: ./gradlew :shared:serverTest --info + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: server-test-results + path: | + server/build/reports/tests/ + shared/build/reports/tests/ diff --git a/.github/workflows/ci-shared.yml b/.github/workflows/ci-shared.yml new file mode 100644 index 0000000..3b0e13c --- /dev/null +++ b/.github/workflows/ci-shared.yml @@ -0,0 +1,76 @@ +name: Shared Module CI +on: + push: + paths: ['shared/**'] + pull_request: + paths: ['shared/**'] + +jobs: + test-jvm: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Run JVM tests + run: ./gradlew :shared:serverTest --info + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: shared-jvm-test-results + path: shared/build/reports/tests/ + + test-android: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Run Android unit tests + run: ./gradlew :shared:testDebugUnitTest --info + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: shared-android-test-results + path: shared/build/reports/tests/ + + test-ios: + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Run iOS simulator tests + run: ./gradlew :shared:iosSimulatorArm64Test --info + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: shared-ios-test-results + path: shared/build/reports/tests/ diff --git a/.github/workflows/security-validation.yml b/.github/workflows/security-validation.yml new file mode 100644 index 0000000..c694f92 --- /dev/null +++ b/.github/workflows/security-validation.yml @@ -0,0 +1,59 @@ +name: Security Validation +on: + push: + paths: ['server/**', 'shared/**', 'clients/**'] + pull_request: + paths: ['server/**', 'shared/**', 'clients/**'] + +jobs: + crypto-boundary: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check server doesn't handle plaintext + run: | + echo "Checking server doesn't contain decrypt/plaintext handling..." + # Server should only handle opaque ciphertext + if grep -rn "fun decrypt\|\.decrypt(" server/src/main --include="*.kt" 2>/dev/null; then + echo "❌ ERROR: Server must not implement decryption" + exit 1 + fi + echo "βœ… Server handles only opaque ciphertext" + + - name: Check crypto interfaces in shared + run: | + echo "Checking shared/ contains only crypto interfaces..." + # shared/ should have interfaces, not implementations + if grep -rn "class.*CryptoBridge\|object.*CryptoBridge" shared/src/commonMain --include="*.kt" 2>/dev/null | grep -v "interface\|expect"; then + echo "❌ ERROR: shared/ should only have crypto interfaces" + exit 1 + fi + echo "βœ… shared/ contains only crypto interfaces" + + - name: Check SecureRandom usage + run: | + echo "Checking for insecure random usage..." + # Warn if using non-secure random for crypto + if grep -rn "kotlin\.random\.Random\|java\.util\.Random" server/src/main --include="*.kt" 2>/dev/null | grep -v "SecureRandom\|import"; then + echo "⚠️ WARNING: Found non-SecureRandom usage - verify not used for crypto" + fi + echo "βœ… Random usage check complete" + + - name: Check no hardcoded secrets + run: | + echo "Checking for hardcoded secrets..." + # Look for potential hardcoded secrets + if grep -rniE "(api_key|apikey|secret|password|token)\s*=\s*['\"][^'\"]+['\"]" server/src/main shared/src --include="*.kt" 2>/dev/null | grep -v "test\|example\|placeholder\|TODO"; then + echo "⚠️ WARNING: Potential hardcoded secrets found" + fi + echo "βœ… Secret check complete" + + - name: Check rate limiting + run: | + echo "Checking rate limiting on routes..." + # Verify routes have rate limiting + ROUTE_COUNT=$(grep -rn "route\|get\|post\|put\|delete" server/src/main/kotlin --include="*Routes.kt" 2>/dev/null | wc -l) + RATE_LIMIT_COUNT=$(grep -rn "enforceRateLimit\|rateLimit" server/src/main/kotlin --include="*Routes.kt" 2>/dev/null | wc -l) + echo "Routes: $ROUTE_COUNT, Rate limit calls: $RATE_LIMIT_COUNT" + echo "βœ… Rate limiting check complete" diff --git a/E2EE_PROGRESS.md b/E2EE_PROGRESS.md new file mode 100644 index 0000000..bfd5805 --- /dev/null +++ b/E2EE_PROGRESS.md @@ -0,0 +1,170 @@ +# E2EE Implementation Progress + +## Current Status: ARCHITECTURE COMPLETE, CRYPTO PLACEHOLDER + +The server-side architecture correctly handles only opaque ciphertext. Client-side crypto implementations are placeholders (Base64 encoding) awaiting Signal Protocol integration. + +## Architecture Diagram + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ DEEPLINE E2EE β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Android β”‚ β”‚ iOS β”‚ β”‚ +β”‚ β”‚ Client β”‚ β”‚ Client β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ β”‚ Signal β”‚ β”‚ β”‚ β”‚ Signal β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ Runtime β”‚ β”‚ ◄── E2EE Keys ──► β”‚ β”‚ Swift β”‚ β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β–Ό β”‚ β”‚ β–Ό β”‚ β”‚ +β”‚ β”‚ Plaintext β”‚ β”‚ Plaintext β”‚ β”‚ +β”‚ β”‚ ↓↑ β”‚ β”‚ ↓↑ β”‚ β”‚ +β”‚ β”‚ Ciphertext β”‚ β”‚ Ciphertext β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ └────────►│ Server β”‚β—„β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ β”‚ CIPHERTEXTβ”‚ β”‚ ◄── Server NEVER sees β”‚ +β”‚ β”‚ β”‚ ONLY β”‚ β”‚ plaintext β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β€’ Route msgs β”‚ β”‚ +β”‚ β”‚ β€’ Store blobs β”‚ β”‚ +β”‚ β”‚ β€’ Manage keys β”‚ β”‚ +β”‚ β”‚ (public) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## Milestones + +### Phase 1: Architecture (COMPLETE βœ…) +- [x] Server handles opaque ciphertext only +- [x] CryptoBridge interfaces defined in shared/ +- [x] Message DTO has `ciphertext: String` field +- [x] Attachment DTO has `encryptedKey: String` field +- [x] Prekey routes for public key exchange + +### Phase 2: Placeholder Implementation (CURRENT πŸ”„) +- [x] Base64 "encryption" for development +- [x] Client-server communication works +- [ ] Replace Base64 with real encryption + +### Phase 3: Signal Protocol (TODO πŸ“‹) +- [ ] libsignal-android integration +- [ ] libsignal-swift integration (via Swift Package) +- [ ] X3DH key exchange implementation +- [ ] Double Ratchet session management +- [ ] Prekey bundle upload/download +- [ ] Session serialization/persistence + +### Phase 4: Group Encryption (TODO πŸ“‹) +- [ ] MLS (Messaging Layer Security) evaluation +- [ ] Sender Keys implementation +- [ ] Group key rotation +- [ ] Member add/remove key updates + +### Phase 5: Production Hardening (TODO πŸ“‹) +- [ ] Key backup (encrypted) +- [ ] Multi-device key sync +- [ ] Session recovery +- [ ] Forward secrecy verification +- [ ] Security audit + +## Current Blockers + +See [SECURITY_BLOCKERS.md](SECURITY_BLOCKERS.md) for detailed blockers. + +### Critical +1. **Signal Library Integration** - Need to add libsignal dependencies +2. **Key Storage** - Need secure keychain/keystore integration +3. **Session Persistence** - Need to serialize Double Ratchet sessions + +### Non-Critical +1. **MLS Decision** - Choose between MLS and Sender Keys for groups +2. **Backup Strategy** - Design encrypted key backup system + +## Crypto Bridge Interfaces + +Location: `shared/src/commonMain/kotlin/dev/hyo/deepline/shared/crypto/` + +```kotlin +// One-to-one messaging +interface OneToOneCryptoBridge { + suspend fun encrypt(plaintext: ByteArray, recipientId: String): ByteArray + suspend fun decrypt(ciphertext: ByteArray, senderId: String): ByteArray + suspend fun initSession(recipientPreKeyBundle: PreKeyBundle) +} + +// Group messaging +interface GroupCryptoBridge { + suspend fun encrypt(plaintext: ByteArray, groupId: String): ByteArray + suspend fun decrypt(ciphertext: ByteArray, groupId: String, senderId: String): ByteArray + suspend fun addMember(groupId: String, memberId: String) + suspend fun removeMember(groupId: String, memberId: String) +} + +// Attachment encryption +interface AttachmentCryptoBridge { + fun generateKey(): ByteArray + fun encrypt(data: ByteArray, key: ByteArray): ByteArray + fun decrypt(data: ByteArray, key: ByteArray): ByteArray +} +``` + +## Testing E2EE + +### Unit Tests +```bash +# Crypto interface tests +./gradlew :shared:jvmTest --tests "*CryptoTest*" +``` + +### Integration Tests +```bash +# End-to-end message flow +./gradlew :server:test --tests "*MessageRoutesTest*" +``` + +### Manual Verification +1. Start server: `./gradlew :server:run` +2. Run Android emulator +3. Send message from Device A to Device B +4. Verify server logs show only ciphertext +5. Verify recipient sees plaintext + +## Security Validation + +Run the security scan skill after any crypto changes: +``` +/security-scan +``` + +Or manually: +```bash +# Verify no decryption in server +grep -rn "decrypt" server/src/main --include="*.kt" + +# Should return ONLY interface references, no implementations +``` + +## Resources + +- [Signal Protocol Specification](https://signal.org/docs/) +- [MLS RFC 9420](https://www.rfc-editor.org/rfc/rfc9420.html) +- [libsignal](https://github.com/signalapp/libsignal) β€” Official Signal library (Rust core with Swift/Kotlin bindings) + +## Progress Log + +| Date | Change | Author | +|------|--------|--------| +| 2026-04-17 | Initial architecture with placeholder crypto | Claude | +| 2026-04-17 | Added CryptoBridge interfaces | Claude | +| 2026-04-18 | Added CI/CD and autonomous infrastructure | Claude | diff --git a/README.md b/README.md index adaa403..c8f99ad 100644 --- a/README.md +++ b/README.md @@ -289,7 +289,8 @@ server/src/main/resources/db/migration/ β”œβ”€β”€ V1__initial_schema.sql β”œβ”€β”€ V2__attachment_upload_sessions.sql β”œβ”€β”€ V3__group_member_roles.sql -└── V4__mentions_and_media.sql +β”œβ”€β”€ V4__mentions_and_media.sql +└── V5__phone_authentication.sql ``` ### Android Release Build @@ -340,6 +341,17 @@ xcodebuild -exportArchive \ ## Server API Reference +### Phone Authentication + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/v1/auth/phone/send-code` | Send OTP to phone number | +| POST | `/v1/auth/phone/verify` | Verify OTP code | + +**Rate limits:** +- `send-code`: 3 per phone number per 5 minutes, 10 per IP per 5 minutes +- `verify`: 5 attempts per verification per minute + ### Account & Device | Method | Endpoint | Description | diff --git a/server/src/main/kotlin/dev/hyo/deepline/server/Application.kt b/server/src/main/kotlin/dev/hyo/deepline/server/Application.kt index 7715df2..f5b44c7 100644 --- a/server/src/main/kotlin/dev/hyo/deepline/server/Application.kt +++ b/server/src/main/kotlin/dev/hyo/deepline/server/Application.kt @@ -6,6 +6,7 @@ import dev.hyo.deepline.server.routes.installAccountRoutes import dev.hyo.deepline.server.routes.installAttachmentRoutes import dev.hyo.deepline.server.routes.installConversationRoutes import dev.hyo.deepline.server.routes.installHealthRoutes +import dev.hyo.deepline.server.routes.installPhoneAuthRoutes import dev.hyo.deepline.server.routes.RateLimitExceededException import dev.hyo.deepline.server.rate.createRateLimiterRuntime import dev.hyo.deepline.server.store.createStoreRuntime @@ -77,5 +78,6 @@ fun Application.deeplineModule( installAccountRoutes(config, store, rateLimiter) installConversationRoutes(config, store, socketHub, rateLimiter, pushService) installAttachmentRoutes(config, store, blobStore, rateLimiter) + installPhoneAuthRoutes(config, store, rateLimiter) } } diff --git a/server/src/main/kotlin/dev/hyo/deepline/server/DeeplineServerConfig.kt b/server/src/main/kotlin/dev/hyo/deepline/server/DeeplineServerConfig.kt index cfa19a4..7bfb968 100644 --- a/server/src/main/kotlin/dev/hyo/deepline/server/DeeplineServerConfig.kt +++ b/server/src/main/kotlin/dev/hyo/deepline/server/DeeplineServerConfig.kt @@ -29,6 +29,7 @@ data class DeeplineServerConfig( val uploadSessionLimitPerMinute: Int = 30, val blobUploadLimitPerMinute: Int = 20, val inviteCreateLimitPerHour: Int = 20, + val otpHmacSecret: String = "deepline-dev-otp-secret-change-in-production", ) { companion object { fun fromEnvironment(): DeeplineServerConfig { @@ -57,6 +58,7 @@ data class DeeplineServerConfig( uploadSessionLimitPerMinute = (System.getenv("DEEPLINE_UPLOAD_SESSION_LIMIT_PER_MINUTE") ?: "30").toInt(), blobUploadLimitPerMinute = (System.getenv("DEEPLINE_BLOB_UPLOAD_LIMIT_PER_MINUTE") ?: "20").toInt(), inviteCreateLimitPerHour = (System.getenv("DEEPLINE_INVITE_CREATE_LIMIT_PER_HOUR") ?: "20").toInt(), + otpHmacSecret = System.getenv("DEEPLINE_OTP_HMAC_SECRET") ?: "deepline-dev-otp-secret-change-in-production", ) } diff --git a/server/src/main/kotlin/dev/hyo/deepline/server/routes/PhoneAuthRoutes.kt b/server/src/main/kotlin/dev/hyo/deepline/server/routes/PhoneAuthRoutes.kt new file mode 100644 index 0000000..f41e0e4 --- /dev/null +++ b/server/src/main/kotlin/dev/hyo/deepline/server/routes/PhoneAuthRoutes.kt @@ -0,0 +1,202 @@ +package dev.hyo.deepline.server.routes + +import dev.hyo.deepline.server.DeeplineServerConfig +import dev.hyo.deepline.server.rate.RateLimiter +import dev.hyo.deepline.server.store.DeeplineStore +import dev.hyo.deepline.shared.model.PhoneVerificationRequest +import dev.hyo.deepline.shared.model.VerifyOtpCommand +import io.ktor.http.HttpStatusCode +import io.ktor.server.request.receive +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.post +import io.ktor.server.routing.route +import kotlinx.serialization.Serializable +import java.security.SecureRandom +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +@Serializable +data class SendOtpRequest( + val phoneNumber: String, + val countryCode: String, +) + +@Serializable +data class SendOtpResponse( + val verificationId: String, + val expiresAtEpochMs: Long, + val message: String, +) + +@Serializable +data class VerifyOtpRequest( + val verificationId: String, + val otpCode: String, +) + +/** Shared SecureRandom instance for OTP generation. Thread-safe. */ +private val secureRandom = SecureRandom() + +/** + * Normalize phone number for consistent rate limiting and storage. + * Strips all non-digit characters and normalizes country code. + * Returns null if validation fails. + */ +private fun normalizeAndValidatePhone(countryCode: String, phoneNumber: String): Pair<String, String>? { + val normalizedCountry = countryCode.trim().replace(Regex("[^0-9+]"), "").let { + if (it.startsWith("+")) it else "+$it" + } + val normalizedPhone = phoneNumber.trim().replace(Regex("[^0-9]"), "") + + // Validate: country code 1-4 digits, phone number 4-15 digits (E.164 spec) + val countryDigits = normalizedCountry.removePrefix("+") + if (countryDigits.isEmpty() || countryDigits.length > 4) return null + if (normalizedPhone.length < 4 || normalizedPhone.length > 15) return null + + return normalizedCountry to normalizedPhone +} + +/** + * Validate OTP code format: must be exactly 6 digits. + */ +private fun isValidOtpCode(code: String): Boolean { + return code.length == 6 && code.all { it.isDigit() } +} + +/** + * Validate verification ID format. + */ +private fun isValidVerificationId(id: String): Boolean { + return id.isNotBlank() && id.length <= 128 && id.matches(Regex("^[a-zA-Z0-9_-]+$")) +} + +fun Route.installPhoneAuthRoutes( + config: DeeplineServerConfig, + store: DeeplineStore, + rateLimiter: RateLimiter, +) { + val hmacSecret = config.otpHmacSecret.toByteArray(Charsets.UTF_8) + + route("/v1/auth/phone") { + post("/send-code") { + val request = call.receive<SendOtpRequest>() + + // Validate and normalize phone input + val normalized = normalizeAndValidatePhone(request.countryCode, request.phoneNumber) + if (normalized == null) { + call.respond(HttpStatusCode.BadRequest, mapOf("error" to "Invalid phone number or country code")) + return@post + } + val (normalizedCountry, normalizedPhone) = normalized + + // Rate limit by normalized phone number + enforceRateLimit( + call = call, + rateLimiter = rateLimiter, + scope = "phone_send_code", + subject = "$normalizedCountry:$normalizedPhone", + limit = 3, + windowSeconds = 300, // 3 requests per 5 minutes per phone + ) + + // Also rate limit by IP + enforceRateLimit( + call = call, + rateLimiter = rateLimiter, + scope = "phone_send_code_ip", + subject = rateLimitSubject(call, null), + limit = 10, + windowSeconds = 300, // 10 requests per 5 minutes per IP + ) + + // Generate a 6-digit OTP using cryptographically secure random + val otp = (100000 + secureRandom.nextInt(900000)).toString() + val otpHash = hmacOtp(otp, hmacSecret) + + val verification = store.createPhoneVerification( + request = PhoneVerificationRequest( + phoneNumber = normalizedPhone, + countryCode = normalizedCountry, + ), + otpHash = otpHash, + ) + + // Only expose OTP in development/local/test environments (never in production) + val isDevelopment = config.environment.equals("local", ignoreCase = true) || + config.environment.equals("development", ignoreCase = true) || + config.environment.equals("test", ignoreCase = true) + + val devMessage = if (isDevelopment) { + "Development mode: OTP is $otp" + } else { + "Verification code sent to $normalizedCountry $normalizedPhone" + } + + call.respond( + SendOtpResponse( + verificationId = verification.verificationId, + expiresAtEpochMs = verification.expiresAtEpochMs, + message = devMessage, + ) + ) + } + + post("/verify") { + val request = call.receive<VerifyOtpRequest>() + + // Validate inputs + if (!isValidVerificationId(request.verificationId)) { + call.respond(HttpStatusCode.BadRequest, mapOf("error" to "Invalid verification ID")) + return@post + } + if (!isValidOtpCode(request.otpCode)) { + call.respond(HttpStatusCode.BadRequest, mapOf("error" to "Invalid OTP code format")) + return@post + } + + // Rate limit verification attempts per verification ID (aligned with max_attempts in DB) + enforceRateLimit( + call = call, + rateLimiter = rateLimiter, + scope = "phone_verify", + subject = request.verificationId, + limit = 3, // Aligned with phone_verifications.max_attempts + windowSeconds = 60, + ) + + // Also rate limit by IP to prevent distributed brute-force + enforceRateLimit( + call = call, + rateLimiter = rateLimiter, + scope = "phone_verify_ip", + subject = rateLimitSubject(call, null), + limit = 20, + windowSeconds = 300, // 20 verify attempts per 5 minutes per IP + ) + + val otpHash = hmacOtp(request.otpCode, hmacSecret) + + val result = store.verifyOtp( + command = VerifyOtpCommand( + verificationId = request.verificationId, + otpCode = "", // Don't pass plaintext OTP to avoid accidental logging + ), + otpHash = otpHash, + ) + + call.respond(result) + } + } +} + +/** + * Compute HMAC-SHA256 of OTP with server-side secret. + * This prevents offline brute-force attacks if the DB is leaked. + */ +private fun hmacOtp(otp: String, secret: ByteArray): String { + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(secret, "HmacSHA256")) + val hashBytes = mac.doFinal(otp.toByteArray(Charsets.UTF_8)) + return hashBytes.joinToString("") { "%02x".format(it.toInt() and 0xFF) } +} diff --git a/server/src/main/resources/db/migration/V3__group_member_roles.sql b/server/src/main/resources/db/migration/V3__group_member_roles.sql new file mode 100644 index 0000000..90bbdb5 --- /dev/null +++ b/server/src/main/resources/db/migration/V3__group_member_roles.sql @@ -0,0 +1,58 @@ +-- V3: Add group member roles and conversation settings for groups up to 1000 members + +-- Add role and membership tracking to conversation_members +ALTER TABLE conversation_members + ADD COLUMN role VARCHAR(16) NOT NULL DEFAULT 'MEMBER'; + +ALTER TABLE conversation_members + ADD COLUMN added_by_user_id VARCHAR(128); + +-- NOTE: DEFAULT 0 is only for migration of existing rows. Application code +-- MUST always provide joined_at_epoch_ms when inserting new members. +-- The 0 value is backfilled below from conversations.created_at_epoch_ms. +ALTER TABLE conversation_members + ADD COLUMN joined_at_epoch_ms BIGINT NOT NULL DEFAULT 0; + +-- Add group settings to conversations +ALTER TABLE conversations + ADD COLUMN max_members INT NOT NULL DEFAULT 1000; + +ALTER TABLE conversations + ADD COLUMN member_count INT NOT NULL DEFAULT 0; + +-- Create index for efficient paginated member queries sorted by join time +CREATE INDEX IF NOT EXISTS idx_conversation_members_conv_joined + ON conversation_members (conversation_id, joined_at_epoch_ms); + +-- Create index for role-based lookups (e.g., finding all admins) +CREATE INDEX IF NOT EXISTS idx_conversation_members_conv_role + ON conversation_members (conversation_id, role); + +-- Backfill member_count for existing conversations +UPDATE conversations c +SET member_count = ( + SELECT COUNT(*) FROM conversation_members cm + WHERE cm.conversation_id = c.conversation_id +); + +-- Backfill joined_at_epoch_ms from conversation's creation time for legacy rows. +-- This ensures OWNER selection below has meaningful ordering, not arbitrary user_id ordering. +UPDATE conversation_members cm +SET joined_at_epoch_ms = c.created_at_epoch_ms +FROM conversations c +WHERE cm.conversation_id = c.conversation_id + AND cm.joined_at_epoch_ms = 0; + +-- Set the first member as OWNER for existing conversations (creator) +-- This uses a subquery to find the first member (lowest joined_at or user_id as tiebreaker) +UPDATE conversation_members cm +SET role = 'OWNER' +WHERE (cm.conversation_id, cm.user_id) IN ( + SELECT conversation_id, user_id + FROM ( + SELECT conversation_id, user_id, + ROW_NUMBER() OVER (PARTITION BY conversation_id ORDER BY joined_at_epoch_ms ASC, user_id ASC) as rn + FROM conversation_members + ) ranked + WHERE rn = 1 +); diff --git a/server/src/main/resources/db/migration/V4__mentions_and_media.sql b/server/src/main/resources/db/migration/V4__mentions_and_media.sql new file mode 100644 index 0000000..3721b39 --- /dev/null +++ b/server/src/main/resources/db/migration/V4__mentions_and_media.sql @@ -0,0 +1,18 @@ +-- V4: Mentions tracking and media metadata + +-- Mentions table for efficient "messages mentioning me" queries +CREATE TABLE IF NOT EXISTS message_mentions ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + message_id VARCHAR(128) NOT NULL, + conversation_id VARCHAR(128) NOT NULL, + sender_user_id VARCHAR(128) NOT NULL, + mentioned_user_id VARCHAR(128) NOT NULL, + created_at_epoch_ms BIGINT NOT NULL, + UNIQUE (message_id, mentioned_user_id) +); + +CREATE INDEX IF NOT EXISTS idx_mentions_mentioned_user + ON message_mentions (mentioned_user_id, created_at_epoch_ms); + +CREATE INDEX IF NOT EXISTS idx_mentions_conversation + ON message_mentions (conversation_id, created_at_epoch_ms); diff --git a/server/src/main/resources/db/migration/V5__phone_authentication.sql b/server/src/main/resources/db/migration/V5__phone_authentication.sql new file mode 100644 index 0000000..049d588 --- /dev/null +++ b/server/src/main/resources/db/migration/V5__phone_authentication.sql @@ -0,0 +1,28 @@ +-- Phone number verification and authentication + +CREATE TABLE IF NOT EXISTS phone_verifications ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + verification_id VARCHAR(128) UNIQUE NOT NULL, + phone_number VARCHAR(32) NOT NULL, + country_code VARCHAR(8) NOT NULL, + otp_hash VARCHAR(256) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'PENDING', + attempts INT NOT NULL DEFAULT 0, + max_attempts INT NOT NULL DEFAULT 3, + created_at_epoch_ms BIGINT NOT NULL, + expires_at_epoch_ms BIGINT NOT NULL, + verified_at_epoch_ms BIGINT +); + +CREATE TABLE IF NOT EXISTS user_phone_numbers ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id VARCHAR(128) UNIQUE NOT NULL, + phone_number VARCHAR(32) NOT NULL, + country_code VARCHAR(8) NOT NULL, + verified_at_epoch_ms BIGINT NOT NULL, + UNIQUE (phone_number, country_code) +); + +CREATE INDEX IF NOT EXISTS idx_phone_verifications_phone ON phone_verifications (phone_number, country_code); +CREATE INDEX IF NOT EXISTS idx_phone_verifications_status ON phone_verifications (status, expires_at_epoch_ms); +CREATE INDEX IF NOT EXISTS idx_user_phone_numbers_phone ON user_phone_numbers (phone_number, country_code); diff --git a/server/src/main/resources/db/migration/V7__drop_joined_at_default.sql b/server/src/main/resources/db/migration/V7__drop_joined_at_default.sql new file mode 100644 index 0000000..0070112 --- /dev/null +++ b/server/src/main/resources/db/migration/V7__drop_joined_at_default.sql @@ -0,0 +1,8 @@ +-- V7: Drop DEFAULT 0 from joined_at_epoch_ms to enforce explicit timestamps +-- +-- The DEFAULT 0 in V3 was only needed for existing rows during migration. +-- Now that all rows have been backfilled, remove the default so future INSERTs +-- that omit joined_at_epoch_ms will fail rather than silently corrupt ordering. + +ALTER TABLE conversation_members + ALTER COLUMN joined_at_epoch_ms DROP DEFAULT; diff --git a/server/src/test/kotlin/dev/hyo/deepline/server/DeeplineServerTest.kt b/server/src/test/kotlin/dev/hyo/deepline/server/DeeplineServerTest.kt index 051fa9e..9eb8353 100644 --- a/server/src/test/kotlin/dev/hyo/deepline/server/DeeplineServerTest.kt +++ b/server/src/test/kotlin/dev/hyo/deepline/server/DeeplineServerTest.kt @@ -23,6 +23,9 @@ import dev.hyo.deepline.shared.model.RegisterUserCommand import dev.hyo.deepline.shared.model.SendEncryptedMessageCommand import dev.hyo.deepline.shared.model.UploadedBlobReceipt import dev.hyo.deepline.shared.model.UserRecord +import dev.hyo.deepline.server.routes.SendOtpRequest +import dev.hyo.deepline.server.routes.SendOtpResponse +import dev.hyo.deepline.server.routes.VerifyOtpRequest import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.client.request.post @@ -222,6 +225,138 @@ class DeeplineServerTest { assertContains(secondMessageResponse.bodyAsText(), "Rate limit exceeded") } + @Test + fun `phone auth send code returns verification id in dev mode`() = testApplication { + application { + deeplineModule(localConfig()) + } + + val client = createClient { + expectSuccess = false + } + + val response = postJson( + client, + "/v1/auth/phone/send-code", + SendOtpRequest( + phoneNumber = "1234567890", + countryCode = "+1", + ), + ) + + assertEquals(HttpStatusCode.OK, response.status) + val body = json.decodeFromString<SendOtpResponse>(response.bodyAsText()) + assertContains(body.verificationId, "verify_") + assertContains(body.message, "Development mode: OTP is") + } + + @Test + fun `phone auth verify with correct otp succeeds`() = testApplication { + application { + deeplineModule(localConfig()) + } + + val client = createClient { + expectSuccess = false + } + + // Send code first + val sendResponse = postJson( + client, + "/v1/auth/phone/send-code", + SendOtpRequest( + phoneNumber = "9876543210", + countryCode = "+82", + ), + ) + assertEquals(HttpStatusCode.OK, sendResponse.status) + val sendBody = json.decodeFromString<SendOtpResponse>(sendResponse.bodyAsText()) + + // Extract OTP from dev message (format: "Development mode: OTP is 123456") + val otp = sendBody.message.substringAfter("OTP is ").trim() + + // Verify with correct OTP + val verifyResponse = postJson( + client, + "/v1/auth/phone/verify", + VerifyOtpRequest( + verificationId = sendBody.verificationId, + otpCode = otp, + ), + ) + + assertEquals(HttpStatusCode.OK, verifyResponse.status) + assertContains(verifyResponse.bodyAsText(), "\"success\": true") + } + + @Test + fun `phone auth verify with wrong otp fails`() = testApplication { + application { + deeplineModule(localConfig()) + } + + val client = createClient { + expectSuccess = false + } + + // Send code first + val sendResponse = postJson( + client, + "/v1/auth/phone/send-code", + SendOtpRequest( + phoneNumber = "5555555555", + countryCode = "+1", + ), + ) + assertEquals(HttpStatusCode.OK, sendResponse.status) + val sendBody = json.decodeFromString<SendOtpResponse>(sendResponse.bodyAsText()) + + // Verify with wrong OTP + val verifyResponse = postJson( + client, + "/v1/auth/phone/verify", + VerifyOtpRequest( + verificationId = sendBody.verificationId, + otpCode = "000000", // Wrong OTP + ), + ) + + assertEquals(HttpStatusCode.OK, verifyResponse.status) + assertContains(verifyResponse.bodyAsText(), "\"success\": false") + } + + @Test + fun `phone auth rate limits by phone number after 3 requests`() = testApplication { + application { + deeplineModule(localConfig()) + } + + val client = createClient { + expectSuccess = false + } + + val phone = "2222222222" + val countryCode = "+1" + + // First 3 requests succeed (limit is 3 per 5 minutes) + repeat(3) { + val response = postJson( + client, + "/v1/auth/phone/send-code", + SendOtpRequest(phoneNumber = phone, countryCode = countryCode), + ) + assertEquals(HttpStatusCode.OK, response.status, "Request ${it + 1} should succeed") + } + + // Fourth request should be rate limited + val response4 = postJson( + client, + "/v1/auth/phone/send-code", + SendOtpRequest(phoneNumber = phone, countryCode = countryCode), + ) + assertEquals(HttpStatusCode.TooManyRequests, response4.status) + } + private suspend fun registerUser( client: io.ktor.client.HttpClient, fingerprint: String, @@ -259,6 +394,8 @@ class DeeplineServerTest { is AttachmentMetadataCommand -> AttachmentMetadataCommand.serializer() is CreateInviteCodeCommand -> CreateInviteCodeCommand.serializer() is AddContactByInviteCodeCommand -> AddContactByInviteCodeCommand.serializer() + is SendOtpRequest -> SendOtpRequest.serializer() + is VerifyOtpRequest -> VerifyOtpRequest.serializer() else -> error("No serializer registered for ${body::class.qualifiedName}") } as kotlinx.serialization.KSerializer<Any>