Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
7709e76
feat(security): paranoid mode with LUKS2-style keyslots and hidden vo…
tstapler May 7, 2026
ea2598b
fix(security): address paranoid-mode code review items
tstapler May 7, 2026
373c1dd
fix(ci): address Detekt violations and Wasm/JS platform boundary
tstapler May 7, 2026
477390e
fix(security): harden paranoid-mode before shipping
tstapler May 7, 2026
13be384
fix(security): address all 35 code-review findings in paranoid-mode v…
tstapler May 7, 2026
479ad83
fix(ci): fix Wasm/JS compile errors and KI-01 timeout
tstapler May 7, 2026
b334000
fix(security): catch AEADBadTagException before BadPaddingException i…
tstapler May 7, 2026
7ba7759
fix(security): address three vulnerabilities found in security review
tstapler May 7, 2026
eee9ec1
test(security): add 43 tests covering gaps in paranoid-mode vault cov…
tstapler May 7, 2026
6e121e5
fix(security): fix .md.stek file discovery in FileRegistry and GraphL…
tstapler May 8, 2026
2728178
fix(security): fix 6 correctness/security gaps found in post-ship review
tstapler May 8, 2026
2de9bc3
fix(security): fix 5 correctness gaps from second review pass
tstapler May 8, 2026
00cd9c4
fix(security): fourth review pass — version check, last-slot guard, t…
tstapler May 8, 2026
47cadcb
fix(security): fifth review pass — namespace guard, Argon2 DoS, lock …
tstapler May 8, 2026
a45e141
fix(security): sixth review pass — single cryptoLayer snapshot, DEK c…
tstapler May 8, 2026
c0848bf
fix(security): seventh review pass — consistent cryptoLayer snapshot,…
tstapler May 8, 2026
965dfc1
fix(security): eighth review pass — AAD path pre-set, rename watcher,…
tstapler May 8, 2026
4ed0b85
fix(security): ninth review pass — independent DEK per namespace, loc…
tstapler May 9, 2026
6b070c8
fix(security): tenth review pass — random uninitialized MAC, graphPat…
tstapler May 9, 2026
f2af9e8
fix(security): eleventh review pass — atomic write contract, plaintex…
tstapler May 9, 2026
1228c17
fix(security): twelfth review pass — lock order, registry mutex, crea…
tstapler May 9, 2026
f6cec3e
fix(security): thirteenth review pass — MAC re-randomize, DEK clone, …
tstapler May 9, 2026
4e3abbd
fix(security): fourteenth review pass — createVault DEK cleanup on er…
tstapler May 9, 2026
2757c69
fix(security): fifteenth review pass — v2 format (32-byte salt, names…
tstapler May 9, 2026
4444309
fix(security): sixteenth review pass — Argon2 iteration/parallelism b…
tstapler May 9, 2026
9b46e4e
fix(security): seventeenth review pass — return propagation, decoy ra…
tstapler May 9, 2026
6e167d9
fix(security): eighteenth review pass — validatePassphrase placement,…
tstapler May 9, 2026
c4aad90
fix(security): nineteenth review pass — lock button delegates to flus…
tstapler May 9, 2026
7da5f0a
fix(security): twentieth review pass — scanDirectory mutex, CryptoLay…
tstapler May 9, 2026
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
3 changes: 3 additions & 0 deletions kmp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ kotlin {
implementation("io.opentelemetry:opentelemetry-sdk:1.43.0")
implementation("io.opentelemetry:opentelemetry-exporter-logging:1.43.0")

// BouncyCastle — Argon2id KDF + HKDF-SHA256 for paranoid-mode vault
implementation("org.bouncycastle:bcprov-jdk18on:1.80")

// Graph databases for performance evaluation
// implementation("com.kuzudb:kuzu-jdbc:0.7.0")
// implementation("org.neo4j.driver:neo4j-java-driver:5.21.0")
Expand Down
85 changes: 55 additions & 30 deletions kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/FileRegistry.kt
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,17 @@ class FileRegistry(private val fileSystem: FileSystem) {
// ---- Scan & Register ----

/**
* Scans a directory for .md files, records all mod times, and caches the result.
* Scans a directory for .md and .md.stek files, records all mod times, and caches the result.
* Subsequent calls to [journalFiles], [recentJournals], etc. operate on this cached list.
*
* Acquires [detectMutex] so writes to [modTimes] and [scannedFiles] are serialized with
* [detectChanges], eliminating the concurrent HashMap mutation race on the JVM.
*/
fun scanDirectory(dirPath: String): List<FileEntry> {
if (!fileSystem.directoryExists(dirPath)) return emptyList()
suspend fun scanDirectory(dirPath: String): List<FileEntry> = detectMutex.withLock {
if (!fileSystem.directoryExists(dirPath)) return@withLock emptyList()

val entries = fileSystem.listFilesWithModTimes(dirPath)
.filter { (name, _) -> name.endsWith(".md") }
.filter { (name, _) -> name.endsWith(".md") || name.endsWith(".md.stek") }
.map { (fileName, modTime) ->
val filePath = "$dirPath/$fileName"
modTimes[filePath] = modTime
Expand All @@ -48,17 +51,18 @@ class FileRegistry(private val fileSystem: FileSystem) {
}

scannedFiles[dirPath] = entries
return entries
entries
}

/**
* Returns journal files from the last scan, filtered by [JournalUtils.isJournalName]
* and sorted descending (most recent first).
* Callers must invoke [scanDirectory] before calling this method.
*/
fun journalFiles(dirPath: String): List<FileEntry> {
val entries = scannedFiles[dirPath] ?: scanDirectory(dirPath)
val entries = scannedFiles[dirPath] ?: emptyList()
return entries
.filter { JournalUtils.isJournalName(it.fileName.removeSuffix(".md")) }
.filter { JournalUtils.isJournalName(it.fileName.removeSuffix(".md.stek").removeSuffix(".md")) }
.sortedByDescending { it.fileName }
}

Expand All @@ -70,9 +74,11 @@ class FileRegistry(private val fileSystem: FileSystem) {
fun remainingJournals(dirPath: String, skip: Int, take: Int): List<FileEntry> =
journalFiles(dirPath).drop(skip).take(take)

/** All .md files from the cached scan (no journal filter), sorted alphabetically. */
/** All .md files from the cached scan (no journal filter), sorted alphabetically.
* Callers must invoke [scanDirectory] before calling this method.
*/
fun pageFiles(dirPath: String): List<FileEntry> {
val entries = scannedFiles[dirPath] ?: scanDirectory(dirPath)
val entries = scannedFiles[dirPath] ?: emptyList()
return entries.sortedBy { it.fileName }
}

Expand All @@ -89,7 +95,7 @@ class FileRegistry(private val fileSystem: FileSystem) {
if (!fileSystem.directoryExists(dirPath)) return@withLock ChangeSet.EMPTY

val currentFilesWithTimes = fileSystem.listFilesWithModTimes(dirPath)
.filter { (name, _) -> name.endsWith(".md") }
.filter { (name, _) -> name.endsWith(".md") || name.endsWith(".md.stek") }
val newFiles = mutableListOf<ChangedFile>()
val changedFiles = mutableListOf<ChangedFile>()
val currentPaths = HashSet<String>(currentFilesWithTimes.size * 2)
Expand All @@ -98,25 +104,35 @@ class FileRegistry(private val fileSystem: FileSystem) {
val filePath = "$dirPath/$fileName"
currentPaths.add(filePath)
val lastKnown = modTimes[filePath]
val isEncrypted = fileName.endsWith(".md.stek")

if (lastKnown == null) {
// New file — not in registry
val content = fileSystem.readFile(filePath) ?: continue
// New file — not in registry.
// Encrypted files are binary; content is read via readFileDecrypted at the call site.
val content = if (isEncrypted) "" else fileSystem.readFile(filePath) ?: continue
modTimes[filePath] = modTime
contentHashes[filePath] = content.hashCode()
if (!isEncrypted) contentHashes[filePath] = content.hashCode()
newFiles.add(ChangedFile(FileEntry(fileName, filePath, modTime), content))
} else if (modTime > lastKnown) {
// Mod time changed — check content hash guard
val content = fileSystem.readFile(filePath) ?: continue
val newHash = content.hashCode()
if (contentHashes[filePath] == newHash) {
// Same content (our own write) — update mod time, skip
if (isEncrypted) {
// Encrypted files are binary — skip the text content-hash guard.
// modTime change alone is sufficient signal; markWrittenByUs keeps own-write
// suppression accurate via the modTimes map.
modTimes[filePath] = modTime
changedFiles.add(ChangedFile(FileEntry(fileName, filePath, modTime), ""))
} else {
// Mod time changed — check content hash guard
val content = fileSystem.readFile(filePath) ?: continue
val newHash = content.hashCode()
if (contentHashes[filePath] == newHash) {
// Same content (our own write) — update mod time, skip
modTimes[filePath] = modTime
continue
}
modTimes[filePath] = modTime
continue
contentHashes[filePath] = newHash
changedFiles.add(ChangedFile(FileEntry(fileName, filePath, modTime), content))
}
modTimes[filePath] = modTime
contentHashes[filePath] = newHash
changedFiles.add(ChangedFile(FileEntry(fileName, filePath, modTime), content))
}
}

Expand All @@ -134,29 +150,38 @@ class FileRegistry(private val fileSystem: FileSystem) {
/**
* Marks a file as written by the app. Updates mod time and content hash
* so the watcher's content-hash guard will suppress the next detection.
*
* Acquires [detectMutex] so this update is atomic with respect to [detectChanges],
* eliminating the race where a concurrent [detectChanges] could read a stale modTime
* for a `.md.stek` file (where the content-hash guard is disabled) and emit a spurious
* own-write event.
*/
fun markWrittenByUs(filePath: String) {
val modTime = fileSystem.getLastModifiedTime(filePath) ?: return
suspend fun markWrittenByUs(filePath: String) = detectMutex.withLock {
val modTime = fileSystem.getLastModifiedTime(filePath) ?: return@withLock
modTimes[filePath] = modTime
val content = fileSystem.readFile(filePath)
if (content != null) {
contentHashes[filePath] = content.hashCode()
// Binary encrypted files cannot be read as text — modTime update alone is sufficient
// for own-write suppression (detectChanges skips the content-hash guard for .md.stek).
if (!filePath.endsWith(".md.stek")) {
val content = fileSystem.readFile(filePath)
if (content != null) {
contentHashes[filePath] = content.hashCode()
}
}
}

/** Updates mod time for a file (after parseAndSavePage). */
fun updateModTime(filePath: String, modTime: Long) {
suspend fun updateModTime(filePath: String, modTime: Long) = detectMutex.withLock {
modTimes[filePath] = modTime
}

/** Updates content hash for a file. */
fun updateContentHash(filePath: String, contentHash: Int) {
suspend fun updateContentHash(filePath: String, contentHash: Int) = detectMutex.withLock {
contentHashes[filePath] = contentHash
}

// ---- Cleanup ----

fun clear() {
suspend fun clear() = detectMutex.withLock {
modTimes.clear()
contentHashes.clear()
scannedFiles.clear()
Expand Down
Loading