diff --git a/.env.example b/.env.example index da45234..ca206e8 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,23 @@ KIDSYNC_PORT=8080 # MUST be set in production to the actual server URL. # KIDSYNC_SERVER_ORIGIN=https://api.kidsync.app +# --- Limits --------------------------------------------------- +# Max snapshots stored per bucket (oldest rejected with 409 when exceeded). +# KIDSYNC_MAX_SNAPSHOTS_PER_BUCKET=10 + +# Max devices per bucket. +# KIDSYNC_MAX_DEVICES_PER_BUCKET=10 + +# Snapshot uploads allowed per device per hour. +# KIDSYNC_SNAPSHOT_RATE_LIMIT=1 + +# Comma-separated list of allowed blob MIME types. +# KIDSYNC_ALLOWED_BLOB_CONTENT_TYPES=application/octet-stream,image/jpeg,image/png,image/gif,image/webp,video/mp4,video/quicktime + +# --- Push Notifications --------------------------------------- +# AES key for encrypting push tokens at rest. If unset, tokens stored in plaintext. +# KIDSYNC_PUSH_TOKEN_KEY= + # --- CORS ----------------------------------------------------- # Comma-separated list of allowed origins (hostnames without scheme). # Leave unset for development (allows all origins). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 42c2b77..00f0415 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,6 @@ jobs: id: server-detekt if: always() run: ./gradlew detekt --no-daemon - continue-on-error: true - name: Upload test results if: always() diff --git a/AGENTS.md b/AGENTS.md index 25b0c0f..97a7278 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ - + # AGENTS.md @@ -14,7 +14,7 @@ Local-first, append-only OpLog. Server is a dumb encrypted relay (cannot decrypt | Component | Stack | Entry Point | |-----------|-------|-------------| | Server | Kotlin 2.1.0, Ktor 3.0.3, Exposed ORM, SQLite WAL, JDK 21 | `server/.../Application.kt` | -| Android | Kotlin, Jetpack Compose, Room + SQLCipher, Tink, Hilt | `android/.../ui/MainActivity.kt` | +| Android | Kotlin, Jetpack Compose, Room + SQLCipher, BouncyCastle, Hilt | `android/.../ui/MainActivity.kt` | | Specs | Markdown + YAML + JSON test vectors | `docs/`, `tests/conformance/` | ## Global Rules @@ -44,11 +44,11 @@ Local-first, append-only OpLog. Server is a dumb encrypted relay (cannot decrypt ## Security - E2E encrypted: X25519 key agreement + AES-256-GCM -- Passwords: bcrypt. Tokens: JWT (15 min access, 30 day refresh) +- Auth: Ed25519 challenge-response. Sessions: opaque tokens (1h TTL) - CORS restricted via `KIDSYNC_CORS_ORIGINS` env var - Rate limiting per endpoint. `FLAG_SECURE` on sensitive screens. -## Testing (44 server tests) +## Testing (456 server tests, 881+ Android tests) ```bash docker run --rm -v "$(pwd)/server:/app" -w /app gradle:8.12-jdk21 gradle test --no-daemon diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1b199dd..21c7559 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,7 +44,7 @@ docker run --rm -v "$(pwd)/server:/app" -w /app gradle:8.12-jdk21 gradle buildFa ## Testing Requirements -- Server: All 44+ tests must pass (`gradle test`) +- Server: All 456 tests must pass (`gradle test`) - Android: Unit tests must pass - New features should include appropriate test coverage diff --git a/README.md b/README.md index c5b1794..ccd0fdf 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ KidSync uses a local-first, append-only OpLog architecture: | Component | Technology | |-----------|-----------| -| Android | Kotlin, Jetpack Compose, Room + SQLCipher, Tink, Hilt, WorkManager | +| Android | Kotlin, Jetpack Compose, Room + SQLCipher, BouncyCastle, Hilt, WorkManager | | Server | Kotlin, Ktor, Exposed ORM, SQLite WAL | | Crypto | X25519, AES-256-GCM, HKDF, Ed25519 signatures, BIP39 recovery | @@ -35,7 +35,7 @@ Build and run with Docker: ```bash cp .env.example .env -# Edit .env -- set KIDSYNC_JWT_SECRET to a strong random value +# Edit .env -- adjust settings for your environment docker compose up -d ``` diff --git a/android/AGENTS.md b/android/AGENTS.md index 56fe591..7b3fd81 100644 --- a/android/AGENTS.md +++ b/android/AGENTS.md @@ -1,5 +1,5 @@ - + # Android AGENTS.md @@ -7,7 +7,7 @@ Jetpack Compose Android app. Local-first with E2E encrypted sync to Ktor server. ## Stack -Kotlin, Jetpack Compose, Room + SQLCipher, Tink (crypto), Hilt (DI), WorkManager, Retrofit, min SDK 26, target SDK 35 +Kotlin, Jetpack Compose, Room + SQLCipher, BouncyCastle + JCA (crypto), Hilt (DI), WorkManager, Retrofit, min SDK 26, target SDK 35 ## Package Map @@ -16,11 +16,11 @@ app/src/main/java/com/kidsync/app/ KidSyncApplication.kt # @HiltAndroidApp crypto/ CryptoManager.kt # Interface + buildPayloadAad() helper - TinkCryptoManager.kt # AES-256-GCM encrypt/decrypt, X25519 + TinkCryptoManager.kt # AES-256-GCM encrypt/decrypt, X25519 (uses BouncyCastle + JCA) data/ local/ # Room DB, DAOs, entities, converters remote/api/ # ApiService (Retrofit), DTOs - remote/interceptor/ # AuthInterceptor (JWT token refresh) + remote/interceptor/ # AuthInterceptor (session token auth) repository/ # Repository implementations sync/ # SyncWorker (WorkManager periodic sync) di/ # 4 Hilt modules: App, Database, Network, Crypto @@ -57,7 +57,7 @@ Domain (use cases, models, repository interfaces) ↓ suspend functions Data (Room DAOs, Retrofit API, repository impls, SyncWorker) ↓ -Crypto (Tink: AES-256-GCM, X25519, HKDF, BIP39) +Crypto (BouncyCastle + JCA: AES-256-GCM, X25519, HKDF, BIP39) ``` All ViewModels: `@HiltViewModel`, `viewModelScope`, `MutableStateFlow`/`StateFlow` diff --git a/android/app/src/main/java/com/kidsync/app/di/DatabaseModule.kt b/android/app/src/main/java/com/kidsync/app/di/DatabaseModule.kt index 64fda9c..1ce5429 100644 --- a/android/app/src/main/java/com/kidsync/app/di/DatabaseModule.kt +++ b/android/app/src/main/java/com/kidsync/app/di/DatabaseModule.kt @@ -66,7 +66,7 @@ object DatabaseModule { @Suppress("DEPRECATION") builder.fallbackToDestructiveMigration() } - // DEFERRED: Room migration objects. Add migrations here for each schema version + // DEFERRED(INFRA-02): Room migration objects. Add migrations here for each schema version // bump in release builds: builder.addMigrations(MIGRATION_X_Y, ...). // Currently no pending migrations — destructive fallback handles debug builds // above, and release builds will crash visibly if a migration is missing. diff --git a/docs/disaster-recovery.md b/docs/disaster-recovery.md index d3429ba..dc30763 100644 --- a/docs/disaster-recovery.md +++ b/docs/disaster-recovery.md @@ -158,14 +158,12 @@ find /app/data/blobs -type f | wc -l # 6. Build or pull the server image docker build -t kidsync-server:latest -f server/Dockerfile server/ -# 7. Run with the same environment variables +# 7. Run with the same environment variables (see .env.example for full list) docker run -d \ --name kidsync-server \ -p 8080:8080 \ -v /app/data:/app/data \ - -e KIDSYNC_JWT_SECRET="" \ - -e KIDSYNC_JWT_ISSUER="kidsync-server" \ - -e KIDSYNC_JWT_AUDIENCE="kidsync-client" \ + -e KIDSYNC_SERVER_ORIGIN="https://api.kidsync.app" \ kidsync-server:latest # 8. Verify health @@ -176,13 +174,13 @@ curl -f http://localhost:8080/health curl -s http://localhost:8080/health | jq . ``` -**Critical:** The `KIDSYNC_JWT_SECRET` must be identical on the new host. If it changes, all existing access tokens and refresh tokens become invalid, forcing every user to re-authenticate. +**Note:** Session tokens are stored in the database and migrate with it. Active sessions remain valid on the new host as long as the database file is intact. If the database is lost, all devices must re-authenticate via Ed25519 challenge-response (no passwords involved). ### DNS Cutover 1. Verify the new server returns healthy responses 2. Update DNS or load balancer to point to the new host -3. Monitor error rates for 15 minutes (access token lifetime) +3. Monitor error rates for 60 minutes (session token TTL) 4. Decommission the old host only after confirming zero traffic --- @@ -204,12 +202,13 @@ KidSync uses client-side encryption with a per-family Data Encryption Key (DEK) **Precondition:** The user has their 24-word recovery mnemonic. 1. User installs KidSync on a new device -2. User logs in with email + password (+ TOTP if enabled) -3. User navigates to recovery restore and enters the 24 words -4. The app calls `GET /keys/recovery` to download the wrapped DEK blob -5. The app derives the recovery key from the mnemonic + userId via HKDF -6. The app unwraps the DEK using AES-256-GCM with the recovery key -7. The app stores the DEK locally and resumes sync +2. The app generates new Ed25519/X25519 keypairs and registers with the server +3. User authenticates via Ed25519 challenge-response +4. User navigates to recovery restore and enters the 24 words (+ optional passphrase) +5. The app calls `GET /recovery` to download the encrypted recovery blob +6. The app derives the recovery key from the mnemonic via HKDF +7. The app decrypts the recovery blob, extracts seed, bucket IDs, and DEKs +8. The app re-wraps DEKs for the new device's keys and resumes sync **If the user does NOT have the recovery mnemonic:** @@ -228,7 +227,7 @@ If a device is suspected compromised: 1. Revoke the device via `DELETE /devices/{deviceId}` (sets `revoked_at`) 2. Trigger key rotation from another active device in the family 3. The new epoch's DEK is wrapped for all remaining active devices, excluding the revoked one -4. The revoked device can no longer authenticate (JWT validation checks device status) +4. The revoked device can no longer authenticate (session validation checks device revocation status) --- @@ -328,66 +327,36 @@ done ## 5. Certificate and Secret Rotation -### JWT Secret Rotation +### Session Invalidation -The server uses HMAC256 with `KIDSYNC_JWT_SECRET` to sign all JWTs. Rotating this secret invalidates every outstanding token. +The server uses opaque session tokens (`sess_` prefix) with a configurable TTL (default: 1 hour via `KIDSYNC_SESSION_TTL_SECONDS`). Sessions are stored in the database and validated on each request. -**Impact of rotation:** - -| Token Type | Default Lifetime | Effect | -|---|---|---| -| Access token | 15 minutes (`KIDSYNC_JWT_ACCESS_EXP_MIN`) | Fails validation immediately | -| Refresh token | 30 days (`KIDSYNC_JWT_REFRESH_EXP_DAYS`) | Stored as SHA-256 hash in DB; the hash is secret-independent, BUT the refresh flow issues a new access token signed with the old secret. After rotation the new access token from refresh will use the new secret. | - -**Rotation procedure (immediate, with forced re-auth):** +**To force all devices to re-authenticate** (e.g., after suspected compromise): ```bash -# 1. Generate a new secret (minimum 32 characters) -NEW_SECRET=$(openssl rand -base64 48) - -# 2. Update the environment variable -# For Docker, update docker-compose.yml or the run command: -docker stop kidsync-server - -docker run -d \ - --name kidsync-server \ - -p 8080:8080 \ - -v /app/data:/app/data \ - -e KIDSYNC_JWT_SECRET="$NEW_SECRET" \ - kidsync-server:latest - -# 3. Verify -curl -f http://localhost:8080/health +# Delete all active sessions +sqlite3 /app/data/kidsync.db "DELETE FROM Sessions;" ``` -**What users experience:** Active sessions fail with 401. The Android client's `AuthInterceptor` detects 401 responses and attempts a token refresh. Since the refresh token hash in the database is still valid, the refresh endpoint will issue new tokens signed with the new secret. Users are transparently re-authenticated unless their refresh token has expired. +All devices will receive 401 responses and must re-authenticate via Ed25519 challenge-response. No passwords or secrets are involved -- devices authenticate using their Ed25519 signing keypair. -**To force all sessions to fully re-authenticate** (e.g., after a secret compromise): +**To invalidate sessions for a specific device:** ```bash -# Revoke all refresh tokens in the database sqlite3 /app/data/kidsync.db " - UPDATE refresh_tokens - SET revoked_at = datetime('now') - WHERE revoked_at IS NULL; + DELETE FROM Sessions + WHERE device_id = ''; " ``` -This forces every user to log in again with email + password (+ TOTP). - -### TOTP Secret Compromise - -If the TOTP infrastructure is compromised, per-user TOTP secrets are stored in the `users.totp_secret` column. Disable TOTP for affected users and require re-enrollment: +### Signing Key Compromise -```bash -sqlite3 /app/data/kidsync.db " - UPDATE users - SET totp_enabled = 0, totp_secret = NULL - WHERE id = ''; -" -``` +If a device's Ed25519 signing key is suspected compromised: -The user will need to set up TOTP again via `POST /auth/totp/setup` and `POST /auth/totp/verify`. +1. Revoke the device via `DELETE /devices/{deviceId}` +2. Delete any active sessions for that device +3. Trigger DEK rotation from another active device in the bucket +4. The compromised key can no longer be used to authenticate or sign attestations --- diff --git a/docs/privacy-policy.md b/docs/privacy-policy.md index 8d5a62a..a4d09fc 100644 --- a/docs/privacy-policy.md +++ b/docs/privacy-policy.md @@ -10,11 +10,11 @@ KidSync is an open-source, self-hostable co-parenting coordination app. Privacy ### Account Data -When you create an account, we store: +When you register a device, we store: -- **Email address** -- used for authentication and account recovery -- **Display name** -- shown to your co-parent -- **Password hash** -- your password is hashed with bcrypt; we never store it in plaintext +- **Ed25519 signing public key** -- used for challenge-response authentication (no passwords) +- **X25519 encryption public key** -- used for end-to-end key exchange +- **Device ID** -- a randomly generated UUID identifying your device ### Parenting Data (End-to-End Encrypted) diff --git a/docs/protocol/encryption-spec.md b/docs/protocol/encryption-spec.md index 54612b4..f2bf28f 100644 --- a/docs/protocol/encryption-spec.md +++ b/docs/protocol/encryption-spec.md @@ -100,7 +100,7 @@ No algorithm negotiation. Protocol version changes are required to change any al | Platform | Library | Notes | |----------|---------|-------| -| **Android** | Google Tink + libsodium | Ed25519 via libsodium; X25519 derived via `crypto_sign_ed25519_sk_to_curve25519`; AES-256-GCM via Tink `Aead` or `javax.crypto` | +| **Android** | BouncyCastle + JCA | Ed25519 via BouncyCastle; X25519 derived via BouncyCastle; AES-256-GCM via `javax.crypto` | | **iOS** | Apple CryptoKit | Ed25519 via `Curve25519.Signing`; X25519 via `Curve25519.KeyAgreement`; AES-GCM via `AES.GCM` | | **Server** | None (no crypto on payloads) | Server verifies Ed25519 signatures for challenge-response auth only | diff --git a/server/AGENTS.md b/server/AGENTS.md index 67e25f1..15c0f5c 100644 --- a/server/AGENTS.md +++ b/server/AGENTS.md @@ -1,5 +1,5 @@ - + # Server AGENTS.md @@ -22,27 +22,32 @@ src/main/kotlin/dev/kidsync/server/ plugins/ # Auth, CORS, RateLimit, Serialization, StatusPages, WebSockets routes/ # Auth, Blob, Device, Family, Key, Push, Sync (7 route files) services/ # AuthService, SyncService, BlobService, PushService, WebSocketManager - util/ # HashUtil, JwtUtil, ValidationUtil + util/ # HashUtil, SessionUtil, ValidationUtil ``` ## Commands | Command | Purpose | |---------|---------| -| `docker run --rm -v "$(pwd):/app" -w /app gradle:8.12-jdk21 gradle test --no-daemon` | Run all 44 tests | +| `docker run --rm -v "$(pwd):/app" -w /app gradle:8.12-jdk21 gradle test --no-daemon` | Run all 456 tests | | `docker run --rm -v "$(pwd):/app" -w /app gradle:8.12-jdk21 gradle buildFatJar --no-daemon` | Build fat JAR | | `docker build -t kidsync-server .` | Build Docker image | -## Tests (44 total) - -| Suite | Tests | Coverage | -|-------|-------|----------| -| AuthTest | 8 | Register, login, TOTP, refresh tokens, validation | -| SyncTest | 7 | Upload, pull, hash chain, handshake, pagination | -| HashChainTest | 8 | SHA-256, hex, chain verification | -| IntegrationTest | 8 | Family flow, invites, devices, keys, blobs | -| E2ETest | 8 | Full lifecycle, multi-device, revocation, checkpoints | -| OverrideStateMachineTest | 5 | State transitions, proposer rules | +## Tests (456 across 40 test classes) + +| Area | Key Suites | Focus | +|------|-----------|-------| +| Auth | AuthTest, AuthIntegrationTest, SessionEdgeCaseTest, SessionTokenPrefixTest | Challenge-response, sessions, token prefixes | +| Sync | SyncTest, SyncIntegrationTest, SyncServiceExtendedTest, OpPruningTest | Upload, pull, hash chain, pagination, pruning | +| Hash | HashChainTest, HashUtilUnitTest | SHA-256, hex, chain verification | +| Buckets | BucketTest, BucketIntegrationTest, BucketCreatorTransferTest, BucketServiceCascadeTest | CRUD, invites, creator transfer, cascade delete | +| Devices | DeviceDeregistrationTest, DeviceRevocationTest, DeviceRegistrationRateLimitTest | Registration, revocation, rate limits | +| Keys | KeyTest, KeyServiceExtendedTest | Wrapped DEKs, attestations | +| Blobs/Snapshots | BlobIntegrationTest, BlobServiceTest, SnapshotQuotaTest, SnapshotDownloadTest | Upload, download, quota | +| Security | SecurityHeaderTest, InputValidationEdgeCaseTest, MalformedInputTest, ValidationUtilTest | Headers, input validation, UUID checks | +| E2E | E2ETest, TwoDevicePairingE2ETest | Full lifecycle, multi-device pairing | +| WebSocket | WebSocketManagerTest, WebSocketQueryParamAuthTest | Connection limits, query param auth | +| Infrastructure | ConfigTest, HealthEndpointTest, ConcurrencyTest, RateLimiterTest | Config, health, concurrency, rate limits | ## Critical Patterns @@ -59,9 +64,13 @@ src/main/kotlin/dev/kidsync/server/ | Variable | Default | Purpose | |----------|---------|---------| | `KIDSYNC_DB_PATH` | `data/kidsync.db` | SQLite database path | -| `KIDSYNC_JWT_SECRET` | dev placeholder | JWT signing secret (MUST change in prod) | +| `KIDSYNC_BLOB_PATH` | `data/blobs` | Blob storage directory | +| `KIDSYNC_SNAPSHOT_PATH` | `data/snapshots` | Snapshot storage directory | | `KIDSYNC_CORS_ORIGINS` | (unset = anyHost) | Comma-separated allowed origins | | `KIDSYNC_PORT` | `8080` | Server port | +| `KIDSYNC_SESSION_TTL_SECONDS` | `3600` | Session token lifetime | +| `KIDSYNC_CHALLENGE_TTL_SECONDS` | `60` | Challenge nonce lifetime | +| `KIDSYNC_SERVER_ORIGIN` | `https://api.kidsync.app` | Server origin for challenge-response auth (MUST set in prod) | See `.env.example` at project root for full list. diff --git a/server/src/main/kotlin/dev/kidsync/server/Application.kt b/server/src/main/kotlin/dev/kidsync/server/Application.kt index b28d7d3..95c42d3 100644 --- a/server/src/main/kotlin/dev/kidsync/server/Application.kt +++ b/server/src/main/kotlin/dev/kidsync/server/Application.kt @@ -129,7 +129,7 @@ fun Application.module(config: AppConfig = AppConfig()) { // this server MUST be deployed behind a trusted reverse proxy (nginx, Caddy, etc.) that // strips/overwrites X-Forwarded-* headers from untrusted clients. Without this, an // attacker can spoof their IP address to bypass rate limiting. - // DEFERRED: Ktor framework limitation — XForwardedHeaders trusts all sources and does not + // DEFERRED(INFRA-01): Ktor framework limitation — XForwardedHeaders trusts all sources and does not // support configuring trusted proxy addresses. When Ktor adds this support, restrict to // known reverse proxy IPs. Workaround: deploy behind a reverse proxy that strips/overwrites // X-Forwarded-* headers from untrusted clients.