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
Original file line number Diff line number Diff line change
Expand Up @@ -234,8 +234,8 @@ class BucketViewModel @Inject constructor(
try {
val parsedUrl = java.net.URL(payload.s)
// SEC3-A-18: Warn when connecting to non-kidsync.app domains.
// TODO: For production, enforce a domain whitelist (e.g., only *.kidsync.app)
// and reject connections to non-whitelisted domains entirely.
// DEFERRED(SEC3-A-18): For production, enforce a domain whitelist
// (e.g., only *.kidsync.app) and reject non-whitelisted domains.
if (!parsedUrl.host.endsWith("kidsync.app")) {
android.util.Log.w(
"BucketViewModel",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,13 @@ class RecoveryKeyGeneratorTest : FunSpec({

test("mnemonicToEntropy throws for bad checksum") {
val (words, _) = generator.generateMnemonic()
// Replace last word to break the checksum
val lastWord = words.last()
val differentWord = Bip39WordList.WORDS.first { it != lastWord }
val modifiedWords = words.dropLast(1) + listOf(differentWord)
// Flip the least significant bit of the last word's index. For a 24-word
// mnemonic, the last 8 bits of the 264-bit sequence are checksum bits, so
// flipping bit 0 of the last word deterministically breaks the checksum
// (the entropy stays the same but the stored checksum differs by 1 bit).
val lastWordIndex = Bip39WordList.WORDS.indexOf(words.last())
val badIndex = lastWordIndex xor 1
val modifiedWords = words.dropLast(1) + listOf(Bip39WordList.WORDS[badIndex])

val result = runCatching {
generator.mnemonicToEntropy(modifiedWords)
Expand Down
5 changes: 3 additions & 2 deletions android/gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ serialization = "1.9.0"
sqlcipher = "4.9.0"
sqlite = "2.4.0"
workmanager = "2.10.0"
# TODO(SEC-A-14): EncryptedSharedPreferences is on an alpha version (1.1.0-alpha06).
# Upgrade to a stable release when available to reduce risk of breaking API changes.
# DEFERRED(SEC-A-14): EncryptedSharedPreferences was deprecated (Apr 2025) without ever
# reaching a stable release. Migrate to DataStore + Tink when feasible.
# See: https://developer.android.com/jetpack/androidx/releases/security
security-crypto = "1.1.0-alpha06"
kotest = "5.9.1"
mockk = "1.14.3"
Expand Down
72 changes: 71 additions & 1 deletion server/detekt.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
build:
maxIssues: -1 # Report all issues without failing (baseline phase)
maxIssues: 0

complexity:
LongMethod:
threshold: 60
# Ktor route/plugin/service functions and E2E tests are monolithic by convention
excludes: ['**/routes/**', '**/plugins/**', '**/services/**', '**/test/**']
LongParameterList:
functionThreshold: 8
constructorThreshold: 10
Expand All @@ -13,6 +15,8 @@ complexity:
thresholdInInterfaces: 20
CyclomaticComplexMethod:
threshold: 20
# Ktor route extension functions have many branches by design
excludes: ['**/routes/**']
NestedBlockDepth:
threshold: 5

Expand All @@ -33,8 +37,39 @@ style:
- '0'
- '1'
- '2'
- '3'
- '5'
- '8'
- '10'
- '16'
- '24'
- '32'
- '44'
- '50'
- '60'
- '64'
- '100'
- '128'
- '256'
- '1000'
- '1024'
- '3600'
- '4096'
- '8080'
- '8192'
- '10_240'
- '1_048_576'
# Bitmasks
- '0xFF'
# HTTP status codes
- '400'
- '401'
- '403'
- '404'
- '409'
- '413'
- '415'
- '429'
ignoreHashCodeFunction: true
ignorePropertyDeclaration: true
ignoreLocalVariableDeclaration: true
Expand All @@ -49,19 +84,54 @@ style:
WildcardImport:
active: true
excludeImports:
# Ktor server framework
- 'io.ktor.server.routing.*'
- 'io.ktor.server.application.*'
- 'io.ktor.server.response.*'
- 'io.ktor.server.request.*'
- 'io.ktor.server.auth.*'
- 'io.ktor.server.plugins.*'
- 'io.ktor.server.websocket.*'
- 'io.ktor.server.engine.*'
- 'io.ktor.server.netty.*'
- 'io.ktor.server.testing.*'
- 'io.ktor.http.*'
- 'io.ktor.websocket.*'
- 'io.ktor.utils.io.*'
- 'io.ktor.serialization.*'
# Ktor client (test code)
- 'io.ktor.client.call.*'
- 'io.ktor.client.request.*'
- 'io.ktor.client.request.forms.*'
- 'io.ktor.client.statement.*'
- 'io.ktor.client.plugins.*'
- 'io.ktor.client.plugins.contentnegotiation.*'
# Exposed ORM
- 'org.jetbrains.exposed.sql.*'
# Kotlin/Kotlinx
- 'kotlinx.coroutines.*'
- 'kotlinx.serialization.*'
- 'kotlinx.serialization.json.*'
# Project internal packages
- 'dev.kidsync.server.db.*'
- 'dev.kidsync.server.models.*'
- 'dev.kidsync.server.services.*'
- 'dev.kidsync.server.plugins.*'
- 'dev.kidsync.server.routes.*'
# Java stdlib
- 'java.util.*'
ReturnCount:
max: 5
ForbiddenComment:
active: false # Allow TODO/FIXME comments during development
UnusedPrivateMember:
active: true
allowedNames: 'serialVersionUID'
ThrowsCount:
active: true
max: 4
# Ktor route and service functions use throw for HTTP error responses
excludes: ['**/routes/**', '**/services/**']

exceptions:
TooGenericExceptionCaught:
Expand Down
1 change: 1 addition & 0 deletions server/src/main/kotlin/dev/kidsync/server/Application.kt
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ fun main() {
}.start(wait = true)
}

@Suppress("LongMethod")
fun Application.module(config: AppConfig = AppConfig()) {
// SEC3-S-16: Validate storage paths on startup (fail fast if invalid)
AppConfig.validateStoragePaths(config)
Expand Down
28 changes: 9 additions & 19 deletions server/src/main/kotlin/dev/kidsync/server/Config.kt
Original file line number Diff line number Diff line change
Expand Up @@ -51,19 +51,15 @@ data class AppConfig(
)

for ((name, path) in pathsToValidate) {
if (path.isBlank()) {
throw IllegalStateException("SEC3-S-16: $name is empty. Configure a valid storage path.")
}
check(path.isNotBlank()) { "SEC3-S-16: $name is empty. Configure a valid storage path." }

val dir = File(path)

// Create directory if it doesn't exist
if (!dir.exists()) {
logger.info("Creating storage directory for {}: {}", name, dir.absolutePath)
if (!dir.mkdirs()) {
throw IllegalStateException(
"SEC3-S-16: Failed to create directory for $name: ${dir.absolutePath}"
)
check(dir.mkdirs()) {
"SEC3-S-16: Failed to create directory for $name: ${dir.absolutePath}"
}

// Set directory permissions to 700 (owner rwx only)
Expand All @@ -78,17 +74,13 @@ data class AppConfig(
}

// Verify it's a directory
if (!dir.isDirectory) {
throw IllegalStateException(
"SEC3-S-16: $name path is not a directory: ${dir.absolutePath}"
)
check(dir.isDirectory) {
"SEC3-S-16: $name path is not a directory: ${dir.absolutePath}"
}

// Verify it's writable
if (!dir.canWrite()) {
throw IllegalStateException(
"SEC3-S-16: $name path is not writable: ${dir.absolutePath}"
)
check(dir.canWrite()) {
"SEC3-S-16: $name path is not writable: ${dir.absolutePath}"
}

logger.info("Validated storage path {}: {}", name, dir.canonicalPath)
Expand All @@ -99,10 +91,8 @@ data class AppConfig(
val dbDir = File(config.dbPath).parentFile
if (dbDir != null && !dbDir.exists()) {
logger.info("Creating database directory: {}", dbDir.absolutePath)
if (!dbDir.mkdirs()) {
throw IllegalStateException(
"SEC3-S-16: Failed to create database directory: ${dbDir.absolutePath}"
)
check(dbDir.mkdirs()) {
"SEC3-S-16: Failed to create database directory: ${dbDir.absolutePath}"
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,9 @@ fun Route.deviceRoutes(sessionUtil: SessionUtil) {
* Register a new device with its public keys. No auth required.
*
* SEC-S-10: Device registration is rate-limited but has no absolute count limit.
* TODO: For production, consider adding proof-of-work, CAPTCHA, or invitation-gated
* registration to prevent mass device creation attacks. The rate limiter ("auth")
* provides basic protection for now.
* DEFERRED(SEC-S-10): For production, consider adding proof-of-work, CAPTCHA, or
* invitation-gated registration to prevent mass device creation attacks. The rate
* limiter ("auth") + IP-based rate limiter provides basic protection for now.
*/
post("/register") {
// SEC-S-10: IP-based rate limiting for device registration
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import dev.kidsync.server.db.Blobs
import dev.kidsync.server.db.DatabaseFactory.dbQuery
import dev.kidsync.server.models.BlobResponse
import org.jetbrains.exposed.sql.*
import org.slf4j.LoggerFactory
import java.io.File
import java.nio.file.Files
import java.nio.file.attribute.PosixFilePermissions
Expand All @@ -17,7 +16,6 @@ import java.util.*

class BlobService(private val config: AppConfig) {

private val logger = LoggerFactory.getLogger(BlobService::class.java)
private val isoFormatter = DateTimeFormatter.ISO_INSTANT

companion object {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ class PushService(private val encryptionKeyBase64: String? = null) {
val platform = tokenRow[PushTokens.platform]
// SEC6-S-13: Decrypt token for use
// SEC7-S-05: Skip devices whose token cannot be decrypted
val pushToken = decryptToken(tokenRow[PushTokens.token]) ?: continue
decryptToken(tokenRow[PushTokens.token]) ?: continue

// In production, this would call the actual push API
// Payload is opaque: { "type": "sync", "bucket": bucketId }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -484,7 +484,7 @@ class InputValidationEdgeCaseTest {
val client = createJsonClient()

val device = TestHelper.registerDevice(client)
val authedDevice = TestHelper.authenticateDevice(client, device)
TestHelper.authenticateDevice(client, device)

// Get a fresh challenge
val challengeResp = client.post("/auth/challenge") {
Expand Down
15 changes: 13 additions & 2 deletions server/src/test/kotlin/dev/kidsync/server/MalformedInputTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,13 @@ class MalformedInputTest {
val response = client.post("/buckets/$bucketId/ops") {
contentType(ContentType.Application.Json)
header(HttpHeaders.Authorization, "Bearer ${device.sessionToken}")
setBody("""{"ops": [{"deviceId": "test", "keyEpoch": "not-a-number", "encryptedPayload": "dGVzdA==", "prevHash": "${"0".repeat(64)}", "currentHash": "${"a".repeat(64)}"}]}""")
val prevHash = "0".repeat(64)
val currentHash = "a".repeat(64)
setBody(
"""{"ops": [{"deviceId": "test", "keyEpoch": "not-a-number", """ +
""""encryptedPayload": "dGVzdA==", "prevHash": "$prevHash", """ +
""""currentHash": "$currentHash"}]}"""
)
}

assertEquals(HttpStatusCode.BadRequest, response.status)
Expand Down Expand Up @@ -187,7 +193,12 @@ class MalformedInputTest {

val response = client.post("/register") {
contentType(ContentType.Application.Json)
setBody("""{"signingKey": "${TestHelper.encodePublicKey(signingKP.public)}", "encryptionKey": "${TestHelper.encodePublicKey(encryptionKP.public)}", "extraField": "should-be-ignored"}""")
val signingKey = TestHelper.encodePublicKey(signingKP.public)
val encryptionKey = TestHelper.encodePublicKey(encryptionKP.public)
setBody(
"""{"signingKey": "$signingKey", "encryptionKey": "$encryptionKey", """ +
""""extraField": "should-be-ignored"}"""
)
}

assertEquals(HttpStatusCode.Created, response.status)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,9 @@ class SecurityHeaderTest {

val response = client.post("/register") {
contentType(ContentType.Application.Json)
setBody("""{"signingKey": "${TestHelper.encodePublicKey(signingKP.public)}", "encryptionKey": "${TestHelper.encodePublicKey(encryptionKP.public)}"}""")
val signingKey = TestHelper.encodePublicKey(signingKP.public)
val encryptionKey = TestHelper.encodePublicKey(encryptionKP.public)
setBody("""{"signingKey": "$signingKey", "encryptionKey": "$encryptionKey"}""")
}
assertEquals(HttpStatusCode.Created, response.status)
assertEquals("nosniff", response.headers["X-Content-Type-Options"])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,6 @@ class SnapshotQuotaTest {

// Create device with bucket 1
val device = TestHelper.setupDeviceWithBucket(client)
val bucket1Id = device.bucketId!!
uploadOpsChain(client, device, 1)
val resp1 = uploadSnapshot(client, device, 1)
assertEquals(HttpStatusCode.Created, resp1.status)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ class WebSocketManagerTest {
override var masking: Boolean = false
override var maxFrameSize: Long = Long.MAX_VALUE
override val outgoing: SendChannel<Frame> = Channel(Channel.UNLIMITED)
override suspend fun flush() {}
override suspend fun flush() { /* no-op stub */ }
@Deprecated("Use cancel instead", ReplaceWith("cancel()"))
override fun terminate() {}
override fun terminate() { /* no-op stub */ }
}

private fun newManager() = WebSocketManager()
Expand Down