Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
1 change: 0 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<!-- FOR AI AGENTS - Human readability is a side effect, not a goal -->
<!-- Managed by agent: keep sections and order; edit content, not structure -->
<!-- Last updated: 2026-02-20 | Last verified: 2026-02-20 -->
<!-- Last updated: 2026-02-25 | Last verified: 2026-02-25 -->

# AGENTS.md

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand All @@ -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
```

Expand Down
10 changes: 5 additions & 5 deletions android/AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
<!-- FOR AI AGENTS - Scoped to android/ -->
<!-- Last updated: 2026-02-20 -->
<!-- Last updated: 2026-02-25 -->

# Android AGENTS.md

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

Expand All @@ -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
Expand Down Expand Up @@ -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`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
85 changes: 27 additions & 58 deletions docs/disaster-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -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="<same-secret-as-old-host>" \
-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
Expand All @@ -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

---
Expand All @@ -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:**

Expand All @@ -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)

---

Expand Down Expand Up @@ -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 = '<device-uuid>';
"
```

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 = '<user-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

---

Expand Down
8 changes: 4 additions & 4 deletions docs/privacy-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion docs/protocol/encryption-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
37 changes: 23 additions & 14 deletions server/AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<!-- FOR AI AGENTS - Scoped to server/ -->
<!-- Last updated: 2026-02-20 -->
<!-- Last updated: 2026-02-25 -->

# Server AGENTS.md

Expand All @@ -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

Expand All @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion server/src/main/kotlin/dev/kidsync/server/Application.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down