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
2 changes: 1 addition & 1 deletion app/src/main/java/com/flux/data/dao/WorkspaceDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ interface WorkspaceDao {
@Insert(onConflict=OnConflictStrategy.REPLACE)
suspend fun upsertWorkspaces(spaces: List<WorkspaceModel>)

@Query("SELECT workspaceId FROM WorkspaceModel WHERE passKey IS NULL OR passKey = ''")
@Query("SELECT workspaceId FROM WorkspaceModel WHERE passKeyHash IS NULL OR passKeyHash = ''")
fun observePublicWorkspaceIds(): Flow<List<String>>

@Delete
Expand Down
119 changes: 118 additions & 1 deletion app/src/main/java/com/flux/data/database/FluxDatabase.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.flux.data.database

import android.database.Cursor
import android.util.Log
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
Expand Down Expand Up @@ -30,14 +32,15 @@ import com.flux.data.model.SettingsModel
import com.flux.data.model.TodoInstance
import com.flux.data.model.TodoModel
import com.flux.data.model.WorkspaceModel
import com.flux.other.PasswordHasher
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.serialization.json.Json
import java.util.UUID

@Database(
entities = [EventModel::class, LabelModel::class, EventInstanceModel::class, SettingsModel::class, NotesModel::class, HabitModel::class, HabitInstanceModel::class, WorkspaceModel::class, TodoModel::class, JournalModel::class, ProgressBoardModel::class, TodoInstance::class],
version = 11,
version = 12,
exportSchema = false
)
@TypeConverters(Converter::class)
Expand Down Expand Up @@ -405,4 +408,118 @@ val MIGRATION_10_11 = object : Migration(10, 11) {
"ON `HabitInstanceModel` (`instanceDate`)"
)
}
}

val MIGRATION_11_12 = object : Migration(11, 12) {

private val TAG = "Migration_11_12"
private val OLD_TABLE = "WorkspaceModel"
private val TMP_TABLE = "WorkspaceModel_new"

override fun migrate(db: SupportSQLiteDatabase) {
Log.i(TAG, "Starting migration 11 -> 12 (passKey -> hashed passKeyHash)")

recreateTableWithHashedColumn(db)
rehashExistingPasswords(db)

Log.i(TAG, "Migration 11 -> 12 finished")
}

/** Step 1: recreate WorkspaceModel with passKeyHash instead of passKey, copying all rows as-is. */
private fun recreateTableWithHashedColumn(db: SupportSQLiteDatabase) {
db.safeExec(
"""
CREATE TABLE IF NOT EXISTS `$TMP_TABLE` (
`workspaceId` TEXT NOT NULL PRIMARY KEY,
`title` TEXT NOT NULL,
`description` TEXT NOT NULL,
`colorInd` INTEGER NOT NULL,
`cover` TEXT NOT NULL,
`icon` INTEGER NOT NULL,
`passKeyHash` TEXT,
`isPinned` INTEGER NOT NULL,
`selectedSpaces` TEXT NOT NULL
)
""".trimIndent()
)

db.safeExec(
"""
INSERT INTO `$TMP_TABLE`
(`workspaceId`, `title`, `description`, `colorInd`, `cover`, `icon`, `passKeyHash`, `isPinned`, `selectedSpaces`)
SELECT
`workspaceId`, `title`, `description`, `colorInd`, `cover`, `icon`, `passKey`, `isPinned`, `selectedSpaces`
FROM `$OLD_TABLE`
""".trimIndent()
)

db.safeExec("DROP TABLE IF EXISTS `$OLD_TABLE`")
db.safeExec("ALTER TABLE `$TMP_TABLE` RENAME TO `$OLD_TABLE`")

Log.d(TAG, "WorkspaceModel table recreated with passKeyHash column")
}

/**
* Step 2: hash every non-blank plaintext passKeyHash value in place.
* Row-level failures are isolated so one bad row can never crash the migration
* or the app — this is a hard requirement, not an optimization.
*/
private fun rehashExistingPasswords(db: SupportSQLiteDatabase) {
var total = 0
var migrated = 0
var alreadyHashed = 0
var failed = 0

var cursor: Cursor? = null
try {
cursor = db.query(
"SELECT `workspaceId`, `passKeyHash` FROM `$OLD_TABLE` " +
"WHERE `passKeyHash` IS NOT NULL AND TRIM(`passKeyHash`) != ''"
)

val idColumn = cursor.getColumnIndexOrThrow("workspaceId")
val passColumn = cursor.getColumnIndexOrThrow("passKeyHash")

while (cursor.moveToNext()) {
total++
val workspaceId = cursor.getString(idColumn)
val currentValue = cursor.getString(passColumn)

try {
if (PasswordHasher.isHashed(currentValue)) {
Log.d(TAG, "Workspace $workspaceId already hashed, skipping")
alreadyHashed++
continue
}

val hashed = PasswordHasher.hash(currentValue)

// Use safeExec-equivalent guarded update; bind values manually
// since safeExec (per your codebase) likely takes raw SQL only.
db.execSQL(
"UPDATE `$OLD_TABLE` SET `passKeyHash` = ? WHERE `workspaceId` = ?",
arrayOf(hashed, workspaceId)
)
migrated++
Log.d(TAG, "Workspace $workspaceId passkey hashed successfully")
} catch (rowError: Exception) {
failed++
Log.e(
TAG,
"Failed to hash passkey for workspace $workspaceId, leaving value untouched",
rowError
)
}
}
} catch (e: Exception) {
Log.e(TAG, "Critical error while iterating WorkspaceModel rows for hashing", e)
} finally {
cursor?.close()
Log.i(
TAG,
"Passkey hashing summary — total: $total, migrated: $migrated, " +
"alreadyHashed: $alreadyHashed, failed: $failed"
)
}
}
}
23 changes: 21 additions & 2 deletions app/src/main/java/com/flux/data/model/WorkspaceModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import androidx.room.PrimaryKey
import java.util.UUID
import com.flux.R
import kotlinx.serialization.Serializable
import com.flux.other.PasswordHasher

@Serializable
@Entity
Expand All @@ -27,10 +28,14 @@ data class WorkspaceModel(
val colorInd: Int = 0,
val cover: String = "",
val icon: Int = 48,
val passKey: String? = null,
val passKeyHash: String? = null, // renamed from passKey; now stores a PBKDF2 hash, never plaintext
val isPinned: Boolean = false,
val selectedSpaces: List<Int> = emptyList()
)
) {
/** True if this workspace currently requires a passkey to unlock. */
val isLocked: Boolean
get() = !passKeyHash.isNullOrBlank()
}

data class Space(
val id: Int,
Expand All @@ -49,4 +54,18 @@ fun getSpacesList(): List<Space> {
Space(6, stringResource(R.string.Analytics), Icons.Default.Analytics),
Space(7, stringResource(R.string.progress_tracker), Icons.Default.TrackChanges)
)
}

fun WorkspaceModel.lockWith(rawPassword: String): WorkspaceModel {
require(rawPassword.isNotBlank()) { "Passkey cannot be blank" }
return copy(passKeyHash = PasswordHasher.hash(rawPassword))
}

/** Removes the passkey, unlocking the workspace permanently until re-locked. */
fun WorkspaceModel.removePasskey(): WorkspaceModel = copy(passKeyHash = null)

/** Checks [rawPassword] against the stored hash. Returns false if workspace isn't locked. */
fun WorkspaceModel.verifyPasskey(rawPassword: String): Boolean {
val stored = passKeyHash ?: return false
return PasswordHasher.verify(rawPassword, stored)
}
4 changes: 3 additions & 1 deletion app/src/main/java/com/flux/di/DataModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import com.flux.data.dao.TodoInstanceDao
import com.flux.data.dao.WorkspaceDao
import com.flux.data.database.FluxDatabase
import com.flux.data.database.MIGRATION_10_11
import com.flux.data.database.MIGRATION_11_12
import com.flux.data.database.MIGRATION_1_2
import com.flux.data.database.MIGRATION_2_3
import com.flux.data.database.MIGRATION_3_4
Expand Down Expand Up @@ -55,7 +56,8 @@ object DataModule {
MIGRATION_7_8,
MIGRATION_8_9,
MIGRATION_9_10,
MIGRATION_10_11
MIGRATION_10_11,
MIGRATION_11_12
)
.build()

Expand Down
69 changes: 69 additions & 0 deletions app/src/main/java/com/flux/other/Security.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.flux.other

import android.util.Base64
import java.security.SecureRandom
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.PBEKeySpec
import android.util.Log

/**
* Handles one-way password hashing for workspace passkeys.
* Stored format: "<iterations>$<base64Salt>$<base64Hash>"
*/
object PasswordHasher {

private const val TAG = "PasswordHasher"
private const val ALGORITHM = "PBKDF2WithHmacSHA256"
private const val ITERATIONS = 10_000
private const val KEY_LENGTH_BITS = 256
private const val SALT_LENGTH_BYTES = 16
private const val DELIMITER = "$"

/** Hashes [rawPassword] with a freshly generated salt. */
fun hash(rawPassword: String): String {
val salt = ByteArray(SALT_LENGTH_BYTES).apply { SecureRandom().nextBytes(this) }
val hashBytes = pbkdf2(rawPassword.toCharArray(), salt, ITERATIONS, KEY_LENGTH_BITS)

val encodedSalt = Base64.encodeToString(salt, Base64.NO_WRAP)
val encodedHash = Base64.encodeToString(hashBytes, Base64.NO_WRAP)

return "$ITERATIONS$DELIMITER$encodedSalt$DELIMITER$encodedHash"
}

/** Verifies [rawPassword] against a previously [hash]ed value. */
fun verify(rawPassword: String, stored: String): Boolean {
return try {
val (iterations, salt, expectedHash) = parse(stored) ?: return false
val actualHash = pbkdf2(rawPassword.toCharArray(), salt, iterations, expectedHash.size * 8)
actualHash.contentEquals(expectedHash)
} catch (e: Exception) {
Log.e(TAG, "verify: unable to verify password", e)
false
}
}

/** True if [value] is already in our hashed format (used to make migration idempotent). */
fun isHashed(value: String?): Boolean {
if (value.isNullOrBlank()) return false
return parse(value) != null
}

private fun parse(stored: String): Triple<Int, ByteArray, ByteArray>? {
val parts = stored.split(DELIMITER)
if (parts.size != 3) return null
val iterations = parts[0].toIntOrNull() ?: return null
return try {
val salt = Base64.decode(parts[1], Base64.NO_WRAP)
val hash = Base64.decode(parts[2], Base64.NO_WRAP)
Triple(iterations, salt, hash)
} catch (_: IllegalArgumentException) {
null
}
}

private fun pbkdf2(password: CharArray, salt: ByteArray, iterations: Int, keyLengthBits: Int): ByteArray {
val spec = PBEKeySpec(password, salt, iterations, keyLengthBits)
val factory = SecretKeyFactory.getInstance(ALGORITHM)
return factory.generateSecret(spec).encoded
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ fun AnalyticScreen(
description = workspace.description,
cover = workspace.cover,
icon = workspace.icon,
isLocked = workspace.passKey!=null,
isLocked = workspace.isLocked,
onBackPressed = { navController.popBackStack() },
onAddCover = onAddCover,
onRemoveCover = onRemoveCover,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ fun EventScreen(
description = workspace.description,
cover = workspace.cover,
icon = workspace.icon,
isLocked = workspace.passKey!=null,
isLocked = workspace.isLocked,
onBackPressed = { navController.popBackStack() },
onAddCover = onAddCover,
onRemoveCover = onRemoveCover,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ fun HabitScreen(
description = workspace.description,
cover = workspace.cover,
icon = workspace.icon,
isLocked = workspace.passKey!=null,
isLocked = workspace.isLocked,
onBackPressed = { navController.popBackStack() },
onAddCover = onAddCover,
onRemoveCover = onRemoveCover,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ fun JournalScreen(
description = workspace.description,
cover = workspace.cover,
icon = workspace.icon,
isLocked = workspace.passKey!=null,
isLocked = workspace.isLocked,
onBackPressed = { navController.popBackStack() },
onAddCover = onAddCover,
onRemoveCover = onRemoveCover,
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/com/flux/ui/screens/notes/NotesScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ fun NotesScreen(
description = workspace.description,
cover = workspace.cover,
icon = workspace.icon,
isLocked = workspace.passKey!=null,
isLocked = workspace.isLocked,
onBackPressed = { navController.popBackStack() },
onAddCover = onAddCover,
onRemoveCover = onRemoveCover,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ fun ProgressTrackerScreen(
description = workspace.description,
cover = workspace.cover,
icon = workspace.icon,
isLocked = workspace.passKey!=null,
isLocked = workspace.isLocked,
onBackPressed = { navController.popBackStack() },
onAddCover = onAddCover,
onRemoveCover = onRemoveCover,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ fun SearchScreen(navController: NavController, states: States, viewModels: ViewM
val context = LocalContext.current
var query by rememberSaveable { mutableStateOf("") }
val allSpaces = getSpacesList().filter { it.id!=6 }
val lockedWorkspace = states.workspaceState.allWorkspaces.filter { it.passKey?.isNotBlank()==true }.map { it.workspaceId }
val lockedWorkspace = states.workspaceState.allWorkspaces.filter { it.isLocked }.map { it.workspaceId }
var filterState by remember {
mutableStateOf(
FilterState(
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/com/flux/ui/screens/todo/TodoScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ fun TodoScreen(
description = workspace.description,
cover = workspace.cover,
icon = workspace.icon,
isLocked = workspace.passKey!=null,
isLocked = workspace.isLocked,
onBackPressed = { navController.popBackStack() },
onAddCover = onAddCover,
onRemoveCover = onRemoveCover,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ fun EmptyWorkspace(
description = workspace.description,
cover = workspace.cover,
icon = workspace.icon,
isLocked = workspace.passKey!=null,
isLocked = workspace.isLocked,
onBackPressed = { navController.popBackStack() },
onAddCover = onAddCover,
onRemoveCover = onRemoveCover,
Expand Down
Loading
Loading