diff --git a/kmp/build.gradle.kts b/kmp/build.gradle.kts index 616e9e20..0ce6e634 100644 --- a/kmp/build.gradle.kts +++ b/kmp/build.gradle.kts @@ -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") diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/FileRegistry.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/FileRegistry.kt index d05dbb48..c17a2d1b 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/FileRegistry.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/FileRegistry.kt @@ -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 { - if (!fileSystem.directoryExists(dirPath)) return emptyList() + suspend fun scanDirectory(dirPath: String): List = 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 @@ -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 { - 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 } } @@ -70,9 +74,11 @@ class FileRegistry(private val fileSystem: FileSystem) { fun remainingJournals(dirPath: String, skip: Int, take: Int): List = 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 { - val entries = scannedFiles[dirPath] ?: scanDirectory(dirPath) + val entries = scannedFiles[dirPath] ?: emptyList() return entries.sortedBy { it.fileName } } @@ -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() val changedFiles = mutableListOf() val currentPaths = HashSet(currentFilesWithTimes.size * 2) @@ -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)) } } @@ -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() diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/GraphLoader.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/GraphLoader.kt index 899aa4b1..22778428 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/GraphLoader.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/GraphLoader.kt @@ -4,6 +4,7 @@ import arrow.core.Either import arrow.core.left import arrow.core.right import dev.stapler.stelekit.error.DomainError +import kotlin.concurrent.Volatile import dev.stapler.stelekit.model.Block import dev.stapler.stelekit.model.Page @@ -27,6 +28,8 @@ import dev.stapler.stelekit.performance.SpanRepository import dev.stapler.stelekit.util.ContentHasher import dev.stapler.stelekit.util.FileUtils import dev.stapler.stelekit.util.UuidGenerator +import dev.stapler.stelekit.vault.CryptoLayer +import dev.stapler.stelekit.vault.VaultError import kotlin.time.Clock import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel @@ -40,9 +43,15 @@ import kotlinx.coroutines.sync.withLock /** * GraphLoader handles loading markdown files from disk into the database. - * + * * Updated to use UUID-native storage for all references. * Includes file system watching for auto-reload. + * + * Paranoid-mode invariant: [cryptoLayer] must be set to a [CryptoLayer] initialized with the + * current DEK before reading encrypted files. Setting it to null switches to plaintext mode. + * The caller is responsible for keeping [cryptoLayer] in sync with the vault's lock/unlock + * lifecycle — reading with a stale [CryptoLayer] after a DEK rotation will produce an + * [AuthenticationFailed] error from AEAD tag verification. */ class GraphLoader( private val fileSystem: FileSystem, @@ -58,10 +67,67 @@ class GraphLoader( private val sidecarManager: SidecarManager? = null, private val histogramWriter: dev.stapler.stelekit.performance.HistogramWriter? = null, private val spanRepository: SpanRepository? = null, + /** When non-null, all file reads are passed through paranoid-mode decryption. */ + @Volatile var cryptoLayer: CryptoLayer? = null, ) { private val logger = Logger("GraphLoader") private val markdownParser = MarkdownParser() + private fun String.stripPageExtension() = removeSuffix(".md.stek").removeSuffix(".md") + + /** + * Resolve relative file path for CryptoLayer AAD. + * Uses graph-root-relative path (e.g. "pages/Note.md.stek") per plan OPEN-1 decision. + */ + private fun relativePathFor(absoluteFilePath: String): String { + val graphPath = currentGraphPath + if (graphPath.isEmpty()) return absoluteFilePath + val base = if (graphPath.endsWith("/")) graphPath else "$graphPath/" + return if (absoluteFilePath.startsWith(base)) { + absoluteFilePath.removePrefix(base) + } else { + logger.error("relativePathFor: '$absoluteFilePath' is outside graph root '$graphPath' — AAD may be non-portable") + absoluteFilePath + } + } + + /** + * Read a file from disk, decrypting via [cryptoLayer] if paranoid mode is active. + * Returns null if the file does not exist or decryption fails with a non-recoverable error. + * Returns the plaintext string on success. + */ + private fun readFileDecrypted(filePath: String): String? { + val layer = cryptoLayer + if (layer == null) { + return fileSystem.readFile(filePath) + } + if (currentGraphPath.isEmpty()) { + logger.error("readFileDecrypted: cryptoLayer is set but graphPath is empty — refusing to decrypt (wrong AAD)") + return null + } + val rawBytes = fileSystem.readFileBytes(filePath) ?: return null + val relPath = relativePathFor(filePath) + return when (val result = layer.decrypt(relPath, rawBytes)) { + is Either.Right -> result.value.decodeToString() + is Either.Left -> when (val err = result.value) { + is VaultError.NotEncrypted -> { + // .md.stek files MUST be encrypted — reject plaintext to prevent downgrade injection. + if (filePath.endsWith(".md.stek")) { + logger.error("Decryption failed for $filePath: file lacks STEK magic but has .md.stek extension — possible tampering or corruption. Refusing plaintext fallback.") + null + } else { + logger.warn("Paranoid mode active but $filePath has no STEK magic — reading as plaintext. Re-encrypt to clear this warning.") + fileSystem.readFile(filePath) + } + } + else -> { + logger.warn("Decryption failed for $filePath: ${err.message}") + null + } + } + } + } + /** Called after a full bulk import completes. Used to trigger WAL checkpoint. */ var onBulkImportComplete: (suspend () -> Unit)? = null @@ -125,6 +191,15 @@ class GraphLoader( // Tracks the in-flight background indexing job so it can be cancelled under memory pressure. private var backgroundIndexJob: Job? = null + /** + * Pre-sets the graph path used by [relativePathFor] for AEAD-AAD computation. + * Must be called before [cryptoLayer] is assigned so the first decryption uses + * the correct relative path rather than falling back to the absolute path. + */ + fun setGraphPath(path: String) { + currentGraphPath = path + } + /** * Cancels any in-flight background indexing (Phase 2) immediately. * Safe to call from any thread. No-op if no indexing is running. @@ -138,7 +213,7 @@ class GraphLoader( * Called by GraphWriter after it writes a file, so the watcher doesn't treat * our own write as an external change. */ - fun markFileWrittenByUs(filePath: String) { + suspend fun markFileWrittenByUs(filePath: String) { fileRegistry.markWrittenByUs(filePath) } @@ -158,16 +233,18 @@ class GraphLoader( private val _writeErrors = MutableSharedFlow(extraBufferCapacity = 16) val writeErrors: SharedFlow = _writeErrors.asSharedFlow() - // Files suppressed from external-change processing. - // Two modes: - // 1. Single-shot: subscriber calls suppress() in ExternalFileChange handler; the path is - // added here and then removed by checkDirectoryForChanges via .remove(). - // 2. Sticky (git merge): beginGitMerge() pre-adds paths; they are checked with .contains() - // and not removed until endGitMerge() calls .clear(). - // Both modes share the same set; sticky paths survive across multiple watcher ticks. - private val suppressedFiles = mutableSetOf() + // Mutex protecting gitMergeSuppressedFiles. The set is accessed concurrently by the + // 5-second polling loop and native-change callbacks — on JVM, unsynchronized HashSet + // mutation can corrupt the structure. suppressMutex is a kotlinx.coroutines.sync.Mutex + // (KMP-safe; already imported above). + // + // Single-shot suppression (subscriber calling suppress() on an ExternalFileChange event) + // no longer uses this mutex — it uses a local var per emission instead, removing the + // need for runBlocking in the suppress lambda. + private val suppressMutex = Mutex() // Paths added by beginGitMerge() — kept for sticky suppression across watcher ticks. + // ALL accesses must be inside suppressMutex.withLock { }. private val gitMergeSuppressedFiles = mutableSetOf() /** @@ -208,11 +285,15 @@ class GraphLoader( } /** - * Tries to find the .md file for a page by searching pages/ and journals/ directories. + * Tries to find the page file by searching pages/ and journals/ directories. + * Encrypted files (.md.stek) are checked before plaintext (.md) so paranoid-mode + * graphs resolve correctly. */ fun resolvePageFilePath(pageName: String): String? { if (currentGraphPath.isEmpty()) return null val candidates = listOf( + "$currentGraphPath/pages/$pageName.md.stek", + "$currentGraphPath/journals/$pageName.md.stek", "$currentGraphPath/pages/$pageName.md", "$currentGraphPath/journals/$pageName.md" ) @@ -234,7 +315,7 @@ class GraphLoader( return pageRepository.getPageByName(pageName).first().getOrNull() } try { - val content = fileSystem.readFile(filePath) ?: return null + val content = readFileDecrypted(filePath) ?: return null parseAndSavePage(filePath, content, ParseMode.FULL) return pageRepository.getPageByName(pageName).first().getOrNull() } finally { @@ -505,7 +586,7 @@ class GraphLoader( } val path = page.filePath ?: resolvePageFilePath(page.name) if (path == null) return@async null - val content = fileSystem.readFile(path) ?: return@async null + val content = readFileDecrypted(path) ?: return@async null try { parsePageWithoutSaving(path, content, ParseMode.FULL) } catch (e: CancellationException) { @@ -546,7 +627,12 @@ class GraphLoader( for (changed in changeSet.newFiles) { logger.info("New file detected: ${changed.entry.filePath}") fileSystem.invalidateShadow(changed.entry.filePath) - parseAndSavePage(changed.entry.filePath, changed.content, ParseMode.FULL) + val content = if (changed.entry.filePath.endsWith(".md.stek")) { + readFileDecrypted(changed.entry.filePath) ?: continue + } else { + changed.content + } + parseAndSavePage(changed.entry.filePath, content, ParseMode.FULL) } for (changed in changeSet.changedFiles) { @@ -555,21 +641,36 @@ class GraphLoader( // Sticky git-merge suppression: if the path was added by beginGitMerge, // skip it without consuming the entry (it remains suppressed until endGitMerge). - if (gitMergeSuppressedFiles.contains(changed.entry.filePath)) { + val isMergeSuppressed = suppressMutex.withLock { gitMergeSuppressedFiles.contains(changed.entry.filePath) } + if (isMergeSuppressed) { logger.debug("Skipping watcher reload for git-merge-suppressed file: ${changed.entry.filePath}") continue } - // Emit event so subscribers can suppress the re-import - _externalFileChanges.tryEmit(ExternalFileChange(changed.entry.filePath, changed.content) { - suppressedFiles.add(changed.entry.filePath) + // For encrypted files, do NOT buffer the decrypted content in the SharedFlow. + // Emit with empty content; decrypt on-demand only if the change is not suppressed. + // This prevents up to 8 decrypted pages from sitting in the SharedFlow heap buffer. + val emitContent = if (changed.entry.filePath.endsWith(".md.stek")) "" else changed.content + + // Emit event so subscribers can suppress the re-import. + // The suppress lambda is called synchronously within the same coroutine execution + // between tryEmit and yield(), so a plain local var is safe — no thread switch + // occurs in that window and no mutex is needed. + var suppressed = false + _externalFileChanges.tryEmit(ExternalFileChange(changed.entry.filePath, emitContent) { + suppressed = true }) yield() - if (suppressedFiles.remove(changed.entry.filePath)) { + if (suppressed) { continue } - parseAndSavePage(changed.entry.filePath, changed.content, ParseMode.FULL) + val content = if (changed.entry.filePath.endsWith(".md.stek")) { + readFileDecrypted(changed.entry.filePath) ?: continue + } else { + changed.content + } + parseAndSavePage(changed.entry.filePath, content, ParseMode.FULL) } for (filePath in changeSet.deletedPaths) { @@ -644,7 +745,7 @@ class GraphLoader( * Always paired with [endGitMerge]. */ fun beginGitMerge(pathsBeingMerged: List) { - gitMergeSuppressedFiles.addAll(pathsBeingMerged) + kotlinx.coroutines.runBlocking { suppressMutex.withLock { gitMergeSuppressedFiles.addAll(pathsBeingMerged) } } } /** @@ -652,7 +753,7 @@ class GraphLoader( * Must be called after [reloadFiles] completes (or if merge is aborted). */ fun endGitMerge() { - gitMergeSuppressedFiles.clear() + kotlinx.coroutines.runBlocking { suppressMutex.withLock { gitMergeSuppressedFiles.clear() } } } /** @@ -665,7 +766,7 @@ class GraphLoader( */ suspend fun reloadFiles(filePaths: List) { for (path in filePaths) { - val content = fileSystem.readFile(path) ?: continue + val content = readFileDecrypted(path) ?: continue parseAndSavePage(path, content, ParseMode.FULL, DatabaseWriteActor.Priority.HIGH) } } @@ -711,7 +812,7 @@ class GraphLoader( return } - val content = fileSystem.readFile(filePath) + val content = readFileDecrypted(filePath) if (content == null) { logger.warn("Failed to read file: $filePath") return @@ -739,7 +840,7 @@ class GraphLoader( var loadedCount = 0 for (entry in immediateFiles) { - val content = fileSystem.readFile(entry.filePath) ?: continue + val content = readFileDecrypted(entry.filePath) ?: continue try { parseAndSavePage(entry.filePath, content, ParseMode.FULL) loadedCount++ @@ -773,7 +874,7 @@ class GraphLoader( remainingFiles.chunked(50).map { chunk -> async(Dispatchers.Default) { val count = chunk.count { entry -> - val content = fileSystem.readFile(entry.filePath) ?: return@count false + val content = readFileDecrypted(entry.filePath) ?: return@count false try { parseAndSavePage(entry.filePath, content, ParseMode.METADATA_ONLY) true @@ -798,8 +899,14 @@ class GraphLoader( private suspend fun sanitizeDirectory(path: String) { if (!fileSystem.directoryExists(path)) return - - val files = fileSystem.listFiles(path).filter { it.endsWith(".md") } + // Never traverse the hidden-volume reserve directory + if (path.endsWith("/_hidden_reserve") || path.contains("/_hidden_reserve/")) return + + val files = fileSystem.listFiles(path).filter { fileName -> + fileName.endsWith(".md") && + fileName != ".stele-vault" && + !fileName.endsWith(".md.stek") // Never rename encrypted files + } for (fileName in files) { val nameWithoutExt = fileName.removeSuffix(".md") val decodedName = FileUtils.decodeFileName(nameWithoutExt) @@ -809,7 +916,7 @@ class GraphLoader( val oldPath = "$path/$fileName" val newPath = "$path/$expectedName.md" - if (!fileSystem.fileExists(newPath)) { + if (!fileSystem.fileExists(newPath) && !fileSystem.fileExists("$path/$expectedName.md.stek")) { try { val content = fileSystem.readFile(oldPath) if (content != null) { @@ -837,12 +944,29 @@ class GraphLoader( // Single scan registers ALL files and provides filtered views fileRegistry.scanDirectory(path) - val fileEntries = if (path.endsWith("/journals")) { + val rawEntries = if (path.endsWith("/journals")) { fileRegistry.recentJournals(path, 30) } else { fileRegistry.pageFiles(path) } + // When both a plaintext .md and its encrypted .md.stek counterpart exist + // (e.g. after a partial migration), prefer the encrypted file and skip the + // plaintext to avoid duplicate page entries in the database. + val encryptedStems = rawEntries + .filter { it.fileName.endsWith(".md.stek") } + .mapTo(HashSet()) { it.fileName.stripPageExtension() } + val fileEntries = if (encryptedStems.isEmpty()) rawEntries else { + rawEntries.filter { entry -> + val keep = !entry.fileName.endsWith(".md") || + entry.fileName.stripPageExtension() !in encryptedStems + if (!keep) logger.warn( + "Skipping plaintext ${entry.filePath} — encrypted .md.stek counterpart exists" + ) + keep + } + } + // Pre-load all existing pages in one query. Replaces one getPageByName DB call per // file (up to 4 000 round-trips on a warm restart) with a single bulk read whose // result is shared across all parallel chunks read-only. @@ -869,7 +993,7 @@ class GraphLoader( val filePath = entry.filePath val fileModTime = entry.modTime - val title = FileUtils.decodeFileName(fileName.removeSuffix(".md")) + val title = FileUtils.decodeFileName(fileName.stripPageExtension()) // Skip Logseq-internal file: protocol artifacts (e.g. file%3A..%2F%2F...) if (title.startsWith("file:")) return@count false val name = title @@ -893,8 +1017,8 @@ class GraphLoader( } if (isPriorityFile(filePath)) return@count true - - val content = fileSystem.readFile(filePath) ?: return@count false + + val content = readFileDecrypted(filePath) ?: return@count false try { val parseResult = parsePageWithoutSaving(filePath, content, mode) val updatedPage = parseResult.page @@ -965,7 +1089,7 @@ class GraphLoader( private suspend fun parsePageWithoutSaving(filePath: String, content: String, mode: ParseMode = ParseMode.FULL): ParseResult { val fileName = filePath.replace("\\", "/").substringAfterLast("/") - val title = FileUtils.decodeFileName(fileName.removeSuffix(".md")) + val title = FileUtils.decodeFileName(fileName.stripPageExtension()) val name = title val journalDate = if (filePath.contains("/journals/")) JournalUtils.parseJournalDate(title) else null val isJournal = journalDate != null @@ -1267,7 +1391,7 @@ class GraphLoader( CurrentSpanContext.set(ActiveSpanContext(traceId = traceId, parentSpanId = rootSpan.spanId)) try { val fileName = filePath.replace("\\", "/").substringAfterLast("/") - val title = FileUtils.decodeFileName(fileName.removeSuffix(".md")) + val title = FileUtils.decodeFileName(fileName.stripPageExtension()) // Skip Logseq-internal file: protocol artifacts silently if (title.startsWith("file:")) return // Reject files with git conflict markers — importing them would corrupt the graph. diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/GraphManager.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/GraphManager.kt index b8491bf1..be1ed221 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/GraphManager.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/GraphManager.kt @@ -14,6 +14,7 @@ import dev.stapler.stelekit.migration.MigrationRunner import dev.stapler.stelekit.migration.MigrationTamperedError import dev.stapler.stelekit.model.GraphInfo import dev.stapler.stelekit.model.GraphRegistry +import dev.stapler.stelekit.vault.VaultManager import dev.stapler.stelekit.platform.FileSystem import dev.stapler.stelekit.platform.Settings import dev.stapler.stelekit.repository.GraphBackend @@ -209,11 +210,13 @@ class GraphManager( // as they are binary and will cause unresolvable merge conflicts. checkGitignoreForDatabase(expandedPath) + val isParanoidMode = fileSystem.fileExists(VaultManager.vaultFilePath(expandedPath)) val info = GraphInfo( id = graphId, path = expandedPath, displayName = displayName, - addedAt = Clock.System.now().toEpochMilliseconds() + addedAt = Clock.System.now().toEpochMilliseconds(), + isParanoidMode = isParanoidMode, ) val registry = _graphRegistry.value @@ -380,6 +383,12 @@ class GraphManager( if (!content.contains("*.db") && !content.contains(".db")) { println("WARNING: $gitignorePath does not contain '*.db' — SQLite database files may be accidentally committed to git.") } + if (!content.contains(".stele-vault")) { + println("WARNING: $gitignorePath does not contain '.stele-vault' — vault header file may be accidentally committed to git.") + } + if (!content.contains("_hidden_reserve")) { + println("WARNING: $gitignorePath does not contain '_hidden_reserve/' — hidden volume directory may be accidentally committed to git, exposing its existence.") + } } /** diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/GraphWriter.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/GraphWriter.kt index 7f30943d..c4e79c75 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/GraphWriter.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/GraphWriter.kt @@ -4,6 +4,7 @@ import arrow.core.Either import arrow.core.left import arrow.core.right import arrow.fx.coroutines.Resource +import kotlin.concurrent.Volatile import arrow.fx.coroutines.resource import arrow.resilience.saga import arrow.resilience.transact @@ -15,6 +16,8 @@ import dev.stapler.stelekit.platform.FileSystem import dev.stapler.stelekit.repository.DirectRepositoryWrite import dev.stapler.stelekit.repository.PageRepository import dev.stapler.stelekit.util.FileUtils +import dev.stapler.stelekit.vault.CryptoLayer +import dev.stapler.stelekit.vault.VaultError import kotlinx.coroutines.* import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -27,14 +30,25 @@ import kotlinx.coroutines.sync.withLock * * The multi-step save pipeline is modelled as an Arrow Saga for transactional rollback: * if the DB update fails after the file has been written, the file content is restored. + * + * Paranoid-mode invariant: [cryptoLayer] must be set to a [CryptoLayer] initialized with the + * current DEK before any write that should be encrypted. Setting it to null switches the writer + * back to plaintext mode (used when the vault is locked or the graph is not paranoid-mode). + * The caller (typically [GraphManager]) is responsible for keeping [cryptoLayer] in sync with + * the vault's lock/unlock lifecycle — stale [CryptoLayer] references after a DEK rotation will + * silently produce ciphertext the new key cannot decrypt. */ class GraphWriter( private val fileSystem: FileSystem, private val writeActor: DatabaseWriteActor? = null, - private val onFileWritten: ((filePath: String) -> Unit)? = null, + private val onFileWritten: (suspend (filePath: String) -> Unit)? = null, @Deprecated("Use writeActor instead", level = DeprecationLevel.WARNING) private val pageRepository: PageRepository? = null, private val sidecarManager: SidecarManager? = null, + /** When non-null, all file writes are encrypted via paranoid-mode before hitting disk. */ + @Volatile var cryptoLayer: CryptoLayer? = null, + /** Graph root path — required to compute graph-root-relative AAD paths for encryption. */ + @Volatile var graphPath: String = "", ) { private val logger = Logger("GraphWriter") private val saveMutex = Mutex() @@ -131,20 +145,67 @@ class GraphWriter( return@withLock false } - // Calculate new path - val newPath = getPageFilePath(page.copy(name = newName), graphPath) + // Guard: cannot rename pages in the hidden-volume reserve area + val renameLayer = cryptoLayer + val renameGraphPath = this.graphPath + if (renameLayer != null && renameGraphPath.isEmpty()) { + logger.error("renamePage aborted — cryptoLayer is set but graphPath is empty (AAD would be wrong)") + return@withLock false + } + if (renameLayer != null) { + if (renameLayer.checkNotHiddenReserve(relativeFilePath(oldPath, renameGraphPath)).isLeft()) { + logger.error("Rename blocked — restricted path: $oldPath") + return@withLock false + } + } + + // Calculate new path using the already-captured renameLayer snapshot + val newPath = getPageFilePath(page.copy(name = newName), graphPath, renameLayer) // If paths are same, nothing to do (except maybe case change on some FS) if (oldPath == newPath) return true - val content = fileSystem.readFile(oldPath) - if (content == null) { - logger.error("Failed to read file for rename: $oldPath") - return false + // When encryption is active, re-encrypt with the new path as AAD rather than copying + // raw ciphertext — the AEAD tag binds the old path, so a verbatim copy would be + // permanently unreadable at the new location. + // Use the already-captured renameLayer snapshot — re-reading cryptoLayer here could + // observe null if the vault is locked between the guard check and this point. + val cryptoLayerNow = renameLayer + val writeOk = if (cryptoLayerNow != null) { + val bytes = fileSystem.readFileBytes(oldPath) + if (bytes == null) { + logger.error("Failed to read file bytes for rename: $oldPath") + return false + } + val oldRelPath = relativeFilePath(oldPath, renameGraphPath) + val newRelPath = relativeFilePath(newPath, renameGraphPath) + val plaintext = when (val result = cryptoLayerNow.decrypt(oldRelPath, bytes)) { + is Either.Right -> result.value + is Either.Left -> { + logger.error("Failed to decrypt file for rename: $oldPath (${result.value})") + return false + } + } + try { + fileSystem.writeFileBytes(newPath, cryptoLayerNow.encrypt(newRelPath, plaintext)) + } finally { + plaintext.fill(0) + } + } else { + val content = fileSystem.readFile(oldPath) + if (content == null) { + logger.error("Failed to read file for rename: $oldPath") + return false + } + fileSystem.writeFile(newPath, content) } - if (fileSystem.writeFile(newPath, content)) { + if (writeOk) { + onFileWritten?.invoke(oldPath) // mark old path as our own deletion if (fileSystem.deleteFile(oldPath)) { + // Notify the file watcher so it registers the new path and does not treat + // the newly-created file as an external change on the next poll tick. + onFileWritten?.invoke(newPath) logger.debug("Renamed page from $oldPath to $newPath") return true } else { @@ -167,6 +228,15 @@ class GraphWriter( return false } + // Guard: cannot delete pages in the hidden-volume reserve area + val deleteLayer = cryptoLayer + val deleteGraphPath = this.graphPath + if (deleteLayer != null && deleteLayer.checkNotHiddenReserve(relativeFilePath(path, deleteGraphPath)).isLeft()) { + logger.error("Delete blocked — restricted path: $path") + return false + } + + onFileWritten?.invoke(path) // pre-register deletion so file watcher ignores own-delete event val success = fileSystem.deleteFile(path) if (success) { logger.debug("Deleted page file: $path") @@ -187,27 +257,64 @@ class GraphWriter( * * On any step failure the saga runs compensations in reverse order, ensuring the * on-disk state is rolled back before the error propagates. + * + * **Atomicity**: The platform [FileSystem.writeFileBytes] implementation MUST be atomic + * (temp-file + rename). A partial write of an .md.stek ciphertext cannot be recovered — + * the AEAD tag will fail to verify and the page will be permanently unreadable. */ private suspend fun savePageInternal(page: Page, blocks: List, graphPath: String): Boolean = saveMutex.withLock { + // Capture cryptoLayer and graphPath once at lock entry — also used by getPageFilePath so + // the file extension (.md.stek vs .md) is consistent with all subsequent encrypt/decrypt calls. + val capturedCryptoLayer = cryptoLayer + val capturedGraphPath = this.graphPath + // GAP-3: fail fast if encryption is active but the graph path hasn't been set yet. + // relativeFilePath() with empty graphPath strips only the leading "/" from absolute paths, + // producing the wrong AAD string and making the file permanently unreadable. + if (capturedCryptoLayer != null && capturedGraphPath.isEmpty()) { + logger.error("savePageInternal aborted — cryptoLayer is set but graphPath is empty (AAD would be wrong)") + return@withLock false + } + val filePath = if (!page.filePath.isNullOrBlank()) { page.filePath } else { - getPageFilePath(page, graphPath) + getPageFilePath(page, graphPath, capturedCryptoLayer) + } + + // Guard: outer graph cannot write to the hidden volume reserve area + if (capturedCryptoLayer != null) { + val relPath = relativeFilePath(filePath, capturedGraphPath) + val guard = capturedCryptoLayer.checkNotHiddenReserve(relPath) + if (guard.isLeft()) { + logger.error("Write blocked — restricted path: $filePath") + return@withLock false + } } // 0. Safety Check for Large Deletions if (fileSystem.fileExists(filePath)) { - val oldContent = fileSystem.readFile(filePath) ?: "" - val oldBlockCount = oldContent.lines().count { it.trim().startsWith("- ") } - val newBlockCount = blocks.size - - if (oldBlockCount > largeDeletionThreshold && newBlockCount < oldBlockCount / 2) { - logger.error( - "Safety check triggered: Attempting to delete more than 50% of blocks on page " + - "'${page.name}' ($oldBlockCount -> $newBlockCount). Save aborted." - ) - return@withLock false + val oldContent = if (capturedCryptoLayer != null) { + val rawBytes = fileSystem.readFileBytes(filePath) + if (rawBytes != null) { + when (val r = capturedCryptoLayer.decrypt(relativeFilePath(filePath, capturedGraphPath), rawBytes)) { + is Either.Right -> r.value.decodeToString() + is Either.Left -> null // decrypt failed — skip guard conservatively + } + } else null + } else { + fileSystem.readFile(filePath) ?: "" + } + if (oldContent != null) { + val oldBlockCount = oldContent.lines().count { it.trim().startsWith("- ") } + val newBlockCount = blocks.size + if (oldBlockCount > largeDeletionThreshold && newBlockCount < oldBlockCount / 2) { + logger.error( + "Safety check triggered: Attempting to delete more than 50% of blocks on page " + + "'${page.name}' ($oldBlockCount -> $newBlockCount). Save aborted." + ) + return@withLock false + } } } @@ -220,18 +327,33 @@ class GraphWriter( runCatching { saga { // Step 1: write markdown file — rollback restores previous content - val oldContent = if (fileSystem.fileExists(filePath)) fileSystem.readFile(filePath) else null + val cryptoLayerNow = capturedCryptoLayer + val oldRawBytes = if (cryptoLayerNow != null && fileSystem.fileExists(filePath)) fileSystem.readFileBytes(filePath) else null + val oldContent = if (cryptoLayerNow == null && fileSystem.fileExists(filePath)) fileSystem.readFile(filePath) else null saga( action = { - if (!fileSystem.writeFile(filePath, content)) { - error("writeFile returned false for: $filePath") + if (cryptoLayerNow != null) { + val relPath = relativeFilePath(filePath, capturedGraphPath) + val encryptedBytes = cryptoLayerNow.encrypt(relPath, content.encodeToByteArray()) + if (!fileSystem.writeFileBytes(filePath, encryptedBytes)) { + error("writeFileBytes returned false for: $filePath") + } + } else { + if (!fileSystem.writeFile(filePath, content)) { + error("writeFile returned false for: $filePath") + } } fileSystem.updateShadow(filePath, content) }, compensation = { _ -> try { - if (oldContent != null) fileSystem.writeFile(filePath, oldContent) - else fileSystem.deleteFile(filePath) + if (cryptoLayerNow != null) { + if (oldRawBytes != null) fileSystem.writeFileBytes(filePath, oldRawBytes) + else fileSystem.deleteFile(filePath) + } else { + if (oldContent != null) fileSystem.writeFile(filePath, oldContent) + else fileSystem.deleteFile(filePath) + } fileSystem.invalidateShadow(filePath) } catch (e: CancellationException) { throw e @@ -327,11 +449,25 @@ class GraphWriter( writeBlocks(null) } - private fun getPageFilePath(page: Page, graphPath: String): String { + private fun getPageFilePath(page: Page, graphPath: String, layer: CryptoLayer? = cryptoLayer): String { val safeName = FileUtils.sanitizeFileName(page.name) val basePath = if (graphPath.endsWith("/")) graphPath else "$graphPath/" val folder = if (page.isJournal) "journals" else "pages" - return "${basePath}$folder/$safeName.md" + val extension = if (layer != null) ".md.stek" else ".md" + return "${basePath}$folder/$safeName$extension" + } + + /** Compute the graph-root-relative path used as AAD for file encryption. */ + private fun relativeFilePath(absoluteFilePath: String, base: String = graphPath): String { + val baseWithSlash = if (base.endsWith("/")) base else "$base/" + return if (absoluteFilePath.startsWith(baseWithSlash)) { + absoluteFilePath.removePrefix(baseWithSlash) + } else { + if (base.isNotEmpty()) { + logger.error("relativeFilePath: '$absoluteFilePath' is outside graph root '$base' — AAD may be non-portable") + } + absoluteFilePath + } } companion object { @@ -343,9 +479,11 @@ class GraphWriter( fun resource( fileSystem: FileSystem, writeActor: DatabaseWriteActor? = null, - onFileWritten: ((String) -> Unit)? = null, + onFileWritten: (suspend (String) -> Unit)? = null, pageRepository: PageRepository? = null, sidecarManager: SidecarManager? = null, + cryptoLayer: CryptoLayer? = null, + graphPath: String = "", ): Resource = resource { val writer = GraphWriter( fileSystem = fileSystem, @@ -353,6 +491,8 @@ class GraphWriter( onFileWritten = onFileWritten, pageRepository = pageRepository, sidecarManager = sidecarManager, + cryptoLayer = cryptoLayer, + graphPath = graphPath, ) onRelease { try { writer.flush() } catch (_: Exception) { /* best-effort flush */ } diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/model/GraphInfo.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/model/GraphInfo.kt index 9c0506e7..782e4fd3 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/model/GraphInfo.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/model/GraphInfo.kt @@ -10,7 +10,8 @@ data class GraphInfo( val id: String, // sha256(canonicalPath).take(16) val path: String, // Canonical absolute path val displayName: String, // User-facing name (defaults to directory name) - val addedAt: Long // Epoch millis + val addedAt: Long, // Epoch millis + val isParanoidMode: Boolean = false, // True when .stele-vault is present ) /** diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/platform/FileSystem.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/platform/FileSystem.kt index ccf328fc..5d27c513 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/platform/FileSystem.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/platform/FileSystem.kt @@ -47,6 +47,24 @@ interface FileSystem { */ suspend fun pickSaveFileAsync(suggestedName: String, mimeType: String = "application/json"): String? = null + /** + * Read raw bytes from a file. Used by paranoid-mode decryption to read STEK-format files. + * Platforms that support paranoid mode must override this with true byte-level IO. + * The default throws [UnsupportedOperationException] to prevent silent data corruption + * from a round-trip through String (which mangles non-UTF-8 byte sequences). + */ + fun readFileBytes(path: String): ByteArray? = + throw UnsupportedOperationException("readFileBytes is not implemented for this platform. Override in a platform-specific FileSystem implementation.") + + /** + * Write raw bytes to a file. Used by paranoid-mode encryption. + * Platforms that support paranoid mode must override this with true byte-level IO. + * The default throws [UnsupportedOperationException] — decoding arbitrary ciphertext as + * UTF-8 and re-encoding it is lossy and would corrupt encrypted file content. + */ + fun writeFileBytes(path: String, data: ByteArray): Boolean = + throw UnsupportedOperationException("writeFileBytes is not implemented for this platform. Override in a platform-specific FileSystem implementation.") + /** Updates the shadow copy after a SAF write. No-op on non-SAF file systems. */ fun updateShadow(path: String, content: String) { /* no-op */ } diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/App.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/App.kt index 49a1bbe9..35a6f522 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/App.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/App.kt @@ -82,6 +82,8 @@ import dev.stapler.stelekit.ui.screens.LibrarySetupScreen import dev.stapler.stelekit.ui.screens.PageView import dev.stapler.stelekit.ui.screens.PermissionRecoveryScreen import dev.stapler.stelekit.ui.screens.SearchViewModel +import dev.stapler.stelekit.ui.screens.VaultUnlockScreen +import dev.stapler.stelekit.vault.VaultManager.VaultEvent import dev.stapler.stelekit.domain.NoOpUrlFetcher import dev.stapler.stelekit.domain.UrlFetcher import dev.stapler.stelekit.voice.VoiceCaptureState @@ -131,6 +133,12 @@ fun StelekitApp( * [AndroidGitRepository] on Android. When null, git sync is disabled. */ gitRepository: dev.stapler.stelekit.git.GitRepository? = null, + /** + * Platform-specific crypto engine for paranoid-mode vault operations. + * Pass [JvmCryptoEngine] on Desktop. Android support is pending an AndroidCryptoEngine. + * When null, paranoid mode is unavailable. + */ + cryptoEngine: dev.stapler.stelekit.vault.CryptoEngine? = null, ) { val platformSettings = remember { PlatformSettings() } val scope = rememberCoroutineScope() @@ -285,6 +293,7 @@ fun StelekitApp( spanRecorder = spanRecorder, onMemoryPressure = onMemoryPressure, gitRepository = gitRepository, + cryptoEngine = cryptoEngine, ) } } @@ -315,13 +324,40 @@ private fun GraphContent( spanRecorder: SpanRecorder = NoOpSpanRecorder, onMemoryPressure: (((() -> Unit) -> Unit))? = null, gitRepository: dev.stapler.stelekit.git.GitRepository? = null, + cryptoEngine: dev.stapler.stelekit.vault.CryptoEngine? = null, ) { CompositionLocalProvider(LocalSpanRecorder provides spanRecorder) { val scope = rememberCoroutineScope() val composeClipboard = LocalClipboardManager.current val clipboardProvider = rememberClipboardProvider(composeClipboard) + + val activeGraphInfo = remember { graphManager.getActiveGraphInfo() } + val activeGraphPath = activeGraphInfo?.path ?: "" + + // Paranoid mode: true when a .stele-vault file exists for this graph and a crypto engine is available. + val isParanoidMode = remember { + cryptoEngine != null && activeGraphPath.isNotEmpty() && + fileSystem.fileExists(dev.stapler.stelekit.vault.VaultManager.vaultFilePath(activeGraphPath)) + } + + // Vault state drives the unlock screen and gates graph loading. + var vaultState by remember { + androidx.compose.runtime.mutableStateOf( + if (isParanoidMode) VaultState.Locked else VaultState.Unlocked(dev.stapler.stelekit.vault.VaultNamespace.OUTER) + ) + } + + val vaultManager = remember { + if (!isParanoidMode || cryptoEngine == null) null + else dev.stapler.stelekit.vault.VaultManager( + crypto = cryptoEngine, + fileReadBytes = { path -> fileSystem.readFileBytes(path) }, + fileWriteBytes = { path, data -> fileSystem.writeFileBytes(path, data) }, + ) + } + val sidecarManager = remember { - val graphPath = graphManager.getActiveGraphInfo()?.path + val graphPath = activeGraphPath.ifEmpty { null } if (graphPath != null) SidecarManager(fileSystem, graphPath) else null } val graphLoader = remember { @@ -435,10 +471,10 @@ private fun GraphContent( } // Bootstrap loadGraph when the ViewModel has no persisted path but GraphManager has an - // active graph. This happens when Onboarding completes via onComplete (not onGraphSelected), - // or when lastGraphPath was never written to SharedPreferences. + // active graph. For paranoid-mode graphs, loading is deferred until after unlock so the + // CryptoLayer is in place before any file reads. LaunchedEffect(Unit) { - if (viewModel.uiState.value.currentGraphPath.isEmpty()) { + if (!isParanoidMode && viewModel.uiState.value.currentGraphPath.isEmpty()) { val path = graphManager.getActiveGraphInfo()?.path if (!path.isNullOrEmpty()) { viewModel.setGraphPath(path) @@ -446,6 +482,59 @@ private fun GraphContent( } } + // When the vault locks, null out CryptoLayer references so loader/writer do not use + // the zeroed DEK left behind by VaultManager.lock(). Subscribes to vaultEvents so + // the cleanup runs even when lock() is triggered programmatically (not via vaultState). + LaunchedEffect(vaultManager) { + vaultManager?.vaultEvents?.collect { event -> + if (event is VaultEvent.Locked) { + // DEK is already zeroed at this point — do not flush (would write with zero-key). + // The primary lock path (user-initiated) already flushed before calling lock(). + // close() zeroes the CryptoLayer's owned DEK copy before nulling the reference. + graphLoader.cryptoLayer?.close() + graphLoader.cryptoLayer = null + graphWriter.cryptoLayer?.close() + graphWriter.cryptoLayer = null + vaultState = VaultState.Locked // show lock/unlock screen; gates graph content + } + } + } + + // After successful vault unlock, inject CryptoLayer into loader/writer then load graph. + LaunchedEffect(vaultState) { + val state = vaultState + if (state is VaultState.Unlocked && isParanoidMode && viewModel.uiState.value.currentGraphPath.isEmpty()) { + val path = graphManager.getActiveGraphInfo()?.path ?: return@LaunchedEffect + viewModel.setGraphPath(path) + } + } + + // Unlock handler — called from VaultUnlockScreen. The namespace arg is UI-only (OUTER vs HIDDEN + // button); VaultManager determines the actual namespace from the keyslot that decrypts. + val onVaultUnlock: (passphrase: CharArray, dev.stapler.stelekit.vault.VaultNamespace) -> Unit = handler@{ passphrase, _ -> + val vm = vaultManager ?: run { passphrase.fill(' '); return@handler } + val engine = cryptoEngine ?: run { passphrase.fill(' '); return@handler } + vaultState = VaultState.Unlocking + scope.launch { + when (val result = vm.unlock(activeGraphPath, passphrase)) { + is arrow.core.Either.Right -> { + val unlockResult = result.value + val layer = dev.stapler.stelekit.vault.CryptoLayer(engine, unlockResult.dek) + // Set graph paths before cryptoLayer so any concurrent reader that observes + // cryptoLayer != null will also see the correct graphPath (used as AAD base). + graphWriter.graphPath = activeGraphPath + graphLoader.setGraphPath(activeGraphPath) + graphLoader.cryptoLayer = layer + graphWriter.cryptoLayer = layer + vaultState = VaultState.Unlocked(unlockResult.namespace) + } + is arrow.core.Either.Left -> { + vaultState = VaultState.Error(result.value) + } + } + } + } + val frameMetricState = remember { kotlinx.coroutines.flow.MutableStateFlow(dev.stapler.stelekit.performance.FrameMetric()) } var debugMenuState by remember { @@ -506,13 +595,25 @@ private fun GraphContent( // Force-flush pending writes on Android lifecycle pause/stop. // Keyed on voiceCaptureViewModel so the observer is re-registered whenever the VM is // recreated (e.g. after voicePipeline changes), preventing calls on a stale closed instance. + // Uses each object's own internal scope rather than rememberCoroutineScope to avoid + // ForgottenCoroutineScopeException when ON_PAUSE fires after composition teardown. val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner, voiceCaptureViewModel) { val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) { - viewModel.savePendingChanges() - scope.launch { blockStateManager.flush() } - voiceCaptureViewModel.cancel() + when (event) { + Lifecycle.Event.ON_PAUSE -> { + viewModel.savePendingChanges() // launches flush on viewModel's own scope + voiceCaptureViewModel.cancel() + } + Lifecycle.Event.ON_STOP -> { + val vm = vaultManager + if (vm != null) { + // Use viewModel's own scope — avoids ForgottenCoroutineScopeException + // if ON_STOP fires after composition teardown. + viewModel.flushAndLockVault(graphLoader, graphWriter, vm) + } + } + else -> Unit } } lifecycleOwner.lifecycle.addObserver(observer) @@ -545,7 +646,6 @@ private fun GraphContent( val appState by viewModel.uiState.collectAsState() val voiceCaptureState by voiceCaptureViewModel.state.collectAsState() val graphRegistry by graphManager.graphRegistry.collectAsState() - val activeGraphInfo = graphManager.getActiveGraphInfo() val activeGraphId = graphRegistry.activeGraphId StelekitTheme(themeMode = appState.themeMode) { @@ -571,6 +671,13 @@ private fun GraphContent( ) } else { val focusManager = LocalFocusManager.current + if (isParanoidMode && vaultState !is VaultState.Unlocked) { + VaultUnlockScreen( + graphName = activeGraphInfo?.displayName ?: activeGraphPath, + vaultState = vaultState, + onUnlock = onVaultUnlock, + ) + } else { BoxWithConstraints( modifier = Modifier .fillMaxSize() @@ -732,12 +839,28 @@ private fun GraphContent( }, statusBar = { if (!isMobile) { - StatusBarContent( - isEncrypted = encryptionManager.isEncryptionEnabled(appState.currentGraphPath), - statusMessage = appState.statusMessage, - activeGraphName = activeGraphInfo?.displayName ?: "", - pluginCount = pluginHost.getAllPlugins().size - ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + StatusBarContent( + isEncrypted = encryptionManager.isEncryptionEnabled(appState.currentGraphPath), + statusMessage = appState.statusMessage, + activeGraphName = activeGraphInfo?.displayName ?: "", + pluginCount = pluginHost.getAllPlugins().size, + modifier = Modifier.weight(1f), + ) + if (isParanoidMode && vaultManager != null) { + IconButton(onClick = { + viewModel.flushAndLockVault(graphLoader, graphWriter, vaultManager) + }) { + Icon( + imageVector = Icons.Filled.Lock, + contentDescription = "Lock vault", + ) + } + } + } } }, bottomBar = { @@ -783,6 +906,7 @@ private fun GraphContent( } // CompositionLocalProvider(LocalWindowSizeClass) } + } // vault unlocked else } } } @@ -967,6 +1091,9 @@ private fun ScreenRouter( }, ) } + is Screen.VaultUnlock -> { + // Vault unlock is handled by the outer StelekitApp scaffold — no-op here + } } } } @@ -1096,10 +1223,11 @@ private fun StatusBarContent( isEncrypted: Boolean, statusMessage: String, activeGraphName: String, - pluginCount: Int + pluginCount: Int, + modifier: Modifier = Modifier, ) { Row( - modifier = Modifier + modifier = modifier .fillMaxWidth() .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) .padding(horizontal = 16.dp, vertical = 4.dp), diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/AppState.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/AppState.kt index 4925c85c..d6c9877c 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/AppState.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/AppState.kt @@ -9,10 +9,20 @@ import dev.stapler.stelekit.docs.PageViewDocs import dev.stapler.stelekit.git.model.GitConfig import dev.stapler.stelekit.git.model.SyncState import dev.stapler.stelekit.model.GraphInfo +import dev.stapler.stelekit.vault.VaultError +import dev.stapler.stelekit.vault.VaultNamespace import dev.stapler.stelekit.model.Page import dev.stapler.stelekit.ui.theme.StelekitThemeMode import dev.stapler.stelekit.ui.i18n.Language +/** Vault unlock state for paranoid-mode graphs. */ +sealed interface VaultState { + data object Locked : VaultState + data object Unlocking : VaultState + data class Unlocked(val namespace: VaultNamespace) : VaultState + data class Error(val error: VaultError) : VaultState +} + sealed class Screen { @HelpPage(docs = JournalsDocs::class) data object Journals : Screen() @@ -30,6 +40,8 @@ sealed class Screen { data object GlobalUnlinkedReferences : Screen() data object Import : Screen() + data object VaultUnlock : Screen() + @HelpPage(docs = PageViewDocs::class) data class PageView(val page: Page) : Screen() } diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/StelekitViewModel.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/StelekitViewModel.kt index 208a02b9..197d404d 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/StelekitViewModel.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/StelekitViewModel.kt @@ -4,6 +4,7 @@ import dev.stapler.stelekit.db.BacklinkRenamer import dev.stapler.stelekit.db.DatabaseWriteActor import dev.stapler.stelekit.db.GraphLoader import dev.stapler.stelekit.db.GraphWriter +import dev.stapler.stelekit.vault.VaultManager import dev.stapler.stelekit.db.RenameResult import dev.stapler.stelekit.db.UndoManager import dev.stapler.stelekit.export.ClipboardProvider @@ -750,6 +751,7 @@ class StelekitViewModel( is Screen.GlobalUnlinkedReferences -> "Opened Unlinked References" is Screen.Import -> "Import text as new page" is Screen.LibraryStats -> "Opened Library Stats" + is Screen.VaultUnlock -> "Vault locked" } ) } @@ -1434,6 +1436,25 @@ class StelekitViewModel( } } + /** + * Flush pending writes, null out CryptoLayer references, and lock the vault. + * Launched on the ViewModel's own scope to avoid [ForgottenCoroutineScopeException] + * when called from a lifecycle observer that may fire after composition teardown. + */ + fun flushAndLockVault(graphLoader: GraphLoader, graphWriter: GraphWriter, vaultManager: VaultManager) { + scope.launch { + blockStateManager?.flush() // drain in-memory block edits before DEK is zeroed + graphWriter.flush() + // close() zeroes the CryptoLayer's owned DEK copy before nulling the reference, + // so the copy is wiped independently of session.dek that vaultManager.lock() zeroes. + graphLoader.cryptoLayer?.close() + graphLoader.cryptoLayer = null + graphWriter.cryptoLayer?.close() + graphWriter.cryptoLayer = null + vaultManager.lock() + } + } + // ===== Export ===== /** diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/SearchDialog.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/SearchDialog.kt index 6276b51f..0b52a666 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/SearchDialog.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/SearchDialog.kt @@ -40,6 +40,10 @@ import dev.stapler.stelekit.ui.screens.SearchViewModel import dev.stapler.stelekit.util.toTitleCase import kotlinx.coroutines.flow.Flow +private val REGEX_TAG_FILTER = Regex("""#\S+""") +private val REGEX_SCOPE_FILTER = Regex("""/(pages?|blocks?|journal|current)\b""", RegexOption.IGNORE_CASE) +private val REGEX_DATE_FILTER = Regex("""modified:(today|day|week|month|year)""", RegexOption.IGNORE_CASE) + @OptIn(ExperimentalMaterial3Api::class) @Composable fun SearchDialog( @@ -330,7 +334,9 @@ fun SearchDialog( } } } - if (uiState.results.isEmpty() && uiState.query.isNotEmpty() && !uiState.isLoading && !uiState.isSkeletonVisible) { + val showNoResults = uiState.results.isEmpty() && uiState.query.isNotEmpty() + && !uiState.isLoading && !uiState.isSkeletonVisible + if (showNoResults) { Box( modifier = Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center @@ -424,17 +430,17 @@ fun SearchDialog( parsedQuery = uiState.parsedQuery, onRemoveTag = { viewModel.onQueryChange( - uiState.query.replace(Regex("""#\S+"""), "").trim() + uiState.query.replace(REGEX_TAG_FILTER, "").trim() ) }, onRemoveScope = { viewModel.onQueryChange( - uiState.query.replace(Regex("""/(pages?|blocks?|journal|current)\b""", RegexOption.IGNORE_CASE), "").trim() + uiState.query.replace(REGEX_SCOPE_FILTER, "").trim() ) }, onRemoveDate = { viewModel.onQueryChange( - uiState.query.replace(Regex("""modified:(today|day|week|month|year)""", RegexOption.IGNORE_CASE), "").trim() + uiState.query.replace(REGEX_DATE_FILTER, "").trim() ) }, onRemoveProperty = { key -> @@ -479,12 +485,12 @@ fun SearchDialog( @Composable fun SearchResultRow( title: String, + isSelected: Boolean, + onClick: () -> Unit, subtitle: String? = null, relativeDate: String? = null, inlineTags: List = emptyList(), snippet: String? = null, - isSelected: Boolean, - onClick: () -> Unit ) { Row( modifier = Modifier @@ -574,7 +580,8 @@ fun ActivePrefixChipRow( onRemoveTag: () -> Unit, onRemoveScope: () -> Unit, onRemoveDate: () -> Unit, - onRemoveProperty: (String) -> Unit + onRemoveProperty: (String) -> Unit, + modifier: Modifier = Modifier, ) { if (parsedQuery == null) return val hasAnyFilter = parsedQuery.tagFilter != null @@ -584,7 +591,7 @@ fun ActivePrefixChipRow( if (!hasAnyFilter) return Row( - modifier = Modifier + modifier = modifier .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(6.dp) diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/SearchSkeleton.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/SearchSkeleton.kt index c2b21841..1fcd60cf 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/SearchSkeleton.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/SearchSkeleton.kt @@ -30,7 +30,7 @@ import androidx.compose.ui.unit.dp private val widthFractions = listOf(0.55f, 0.75f, 0.40f, 0.65f, 0.80f, 0.45f) @Composable -fun SearchSkeletonList(rowCount: Int = 6) { +fun SearchSkeletonList(modifier: Modifier = Modifier, rowCount: Int = 6) { val infiniteTransition = rememberInfiniteTransition(label = "skeleton") val animatedAlpha by infiniteTransition.animateFloat( initialValue = 0.3f, diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/SearchViewModel.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/SearchViewModel.kt index e6f1046c..af7c1a09 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/SearchViewModel.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/SearchViewModel.kt @@ -150,6 +150,8 @@ class SearchViewModel( } _uiState.update { it.copy(recentPages = recentPageItems) } } + } catch (e: CancellationException) { + throw e } catch (_: Exception) { // Ignore errors loading recent pages — non-critical } diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/VaultUnlockScreen.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/VaultUnlockScreen.kt new file mode 100644 index 00000000..8b55a9f5 --- /dev/null +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/VaultUnlockScreen.kt @@ -0,0 +1,187 @@ +package dev.stapler.stelekit.ui.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import dev.stapler.stelekit.ui.VaultState +import dev.stapler.stelekit.vault.VaultError +import dev.stapler.stelekit.vault.VaultNamespace + +/** + * Full-screen vault unlock dialog shown when a paranoid-mode graph requires a passphrase. + * + * "Open alternate graph" is a subtle secondary action below the main form; it does not + * visually suggest that a hidden volume exists (plausible deniability per NFR-5). + * + * Security note (JVM): Compose state stores [passphraseText] as a JVM [String], which is + * heap-interned and cannot be zeroed. The string is cleared from the state immediately after + * [toCharArray] extracts it, but residual copies may linger in the heap until GC. On JVM, + * full in-memory passphrase zeroing requires a SecurePassword widget (not yet available in + * Compose). iOS/Android have analogous limitations with SecureField/EditText content. + */ +@Composable +fun VaultUnlockScreen( + graphName: String, + vaultState: VaultState, + onUnlock: (passphrase: CharArray, namespace: VaultNamespace) -> Unit, + modifier: Modifier = Modifier, +) { + var passphraseText by remember { mutableStateOf("") } + var showPassphrase by remember { mutableStateOf(false) } + var showHiddenOption by remember { mutableStateOf(false) } + val focusRequester = remember { FocusRequester() } + + val isUnlocking = vaultState == VaultState.Unlocking + val errorMessage = when (vaultState) { + is VaultState.Error -> when (vaultState.error) { + // HeaderTampered is intentionally mapped to the same message as InvalidCredential. + // Distinguishing them would act as a passphrase oracle: an attacker could confirm + // a correct passphrase by observing the tamper-specific error. + is VaultError.InvalidCredential, + is VaultError.HeaderTampered -> "Incorrect passphrase." + is VaultError.CorruptedFile -> "Vault file is corrupted or truncated." + is VaultError.UnsupportedVersion -> "Unsupported vault format version." + is VaultError.NotAVault -> "Vault file is missing or has been moved. Locate the .stele-vault file and try again." + else -> "Vault error. Please try again or contact support." + } + else -> null + } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + fun attemptUnlock(namespace: VaultNamespace) { + if (passphraseText.isBlank()) return + val chars = passphraseText.toCharArray() + passphraseText = "" + onUnlock(chars, namespace) + } + + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Card( + modifier = Modifier.widthIn(max = 400.dp).padding(24.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + ) { + Column( + modifier = Modifier.padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + imageVector = Icons.Default.Lock, + contentDescription = "Vault locked", + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.primary, + ) + + Text( + text = graphName, + style = MaterialTheme.typography.headlineSmall, + ) + + Text( + text = "This graph is protected. Enter your passphrase to unlock.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + OutlinedTextField( + value = passphraseText, + onValueChange = { passphraseText = it }, + label = { Text("Passphrase") }, + singleLine = true, + visualTransformation = if (showPassphrase) VisualTransformation.None else PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { attemptUnlock(VaultNamespace.OUTER) } + ), + trailingIcon = { + IconButton(onClick = { showPassphrase = !showPassphrase }) { + Icon( + imageVector = if (showPassphrase) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = if (showPassphrase) "Hide passphrase" else "Show passphrase", + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + enabled = !isUnlocking, + isError = errorMessage != null, + ) + + if (errorMessage != null) { + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + + Button( + onClick = { attemptUnlock(VaultNamespace.OUTER) }, + modifier = Modifier.fillMaxWidth(), + enabled = !isUnlocking && passphraseText.isNotBlank(), + ) { + if (isUnlocking) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + ) + Spacer(Modifier.width(8.dp)) + Text("Unlocking...") + } else { + Text("Unlock") + } + } + + // Subtle secondary action — intentionally low-contrast to preserve deniability + TextButton( + onClick = { showHiddenOption = !showHiddenOption }, + modifier = Modifier.alpha(0.5f), // intentionally dim + ) { + Text( + text = if (showHiddenOption) "Cancel" else "Advanced", + style = MaterialTheme.typography.bodySmall, + ) + } + + if (showHiddenOption) { + TextButton( + onClick = { attemptUnlock(VaultNamespace.HIDDEN) }, + enabled = !isUnlocking && passphraseText.isNotBlank(), + ) { + Text( + text = "Open alternate graph", + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + } + } +} + diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/vault/CryptoEngine.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/vault/CryptoEngine.kt new file mode 100644 index 00000000..0d222f71 --- /dev/null +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/vault/CryptoEngine.kt @@ -0,0 +1,96 @@ +package dev.stapler.stelekit.vault + +/** + * Platform-agnostic cryptographic primitives for paranoid-mode vault operations. + * + * All implementations must use cryptographically secure random sources (never counters or + * timestamps) and must not share mutable cipher/key state across concurrent calls. + * + * JVM (Desktop): JvmCryptoEngine (javax.crypto ChaCha20-Poly1305 + BouncyCastle Argon2id/HKDF) + * Android: TODO — AndroidCryptoEngine using javax.crypto (same APIs, different BouncyCastle setup) + * WASM: TODO — WasmCryptoEngine via libsodium.js interop (out of scope for v1) + */ +interface CryptoEngine { + /** + * Encrypt [plaintext] using ChaCha20-Poly1305 AEAD. + * Returns ciphertext with the 16-byte Poly1305 tag appended. + * [aad] is authenticated but not encrypted. + */ + fun encryptAEAD(key: ByteArray, nonce: ByteArray, plaintext: ByteArray, aad: ByteArray): ByteArray + + /** + * Decrypt [ciphertext] (which includes the trailing Poly1305 tag) using ChaCha20-Poly1305. + * Throws [VaultError.AuthenticationFailed] if the tag does not verify. + */ + fun decryptAEAD(key: ByteArray, nonce: ByteArray, ciphertext: ByteArray, aad: ByteArray): ByteArray + + /** + * HKDF-SHA256 key derivation. + * Returns [length] bytes derived from [ikm] using [salt] and [info]. + */ + fun hkdfSha256(ikm: ByteArray, salt: ByteArray, info: ByteArray, length: Int): ByteArray + + /** + * Argon2id password-based key derivation. + * Returns [outputLength] bytes. + */ + fun argon2id( + password: ByteArray, + salt: ByteArray, + memory: Int, + iterations: Int, + parallelism: Int, + outputLength: Int, + ): ByteArray + + /** + * Generate [length] cryptographically random bytes. + * Must use SecureRandom / crypto.getRandomValues() — never counters or timestamps. + */ + fun secureRandom(length: Int): ByteArray + + /** HMAC-SHA256: returns 32-byte MAC of [data] under [key]. */ + fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray + + /** + * Constant-time byte array comparison. Default is a pure-Kotlin XOR-fold; + * platform implementations may delegate to a native constant-time primitive. + */ + fun constantTimeEquals(a: ByteArray, b: ByteArray): Boolean { + if (a.size != b.size) return false + var diff = 0 + for (i in a.indices) diff = diff or (a[i].toInt() xor b[i].toInt()) + return diff == 0 + } + + /** + * Zero-fill [bytes] in place to reduce window for memory dumps. + * Best-effort on JVM (GC may have copied the array). + */ + fun clearBytes(bytes: ByteArray) { + bytes.fill(0) + } +} + +/** Argon2id tuning parameters stored per-keyslot in the vault header. */ +data class Argon2Params( + val memory: Int, + val iterations: Int, + val parallelism: Int, +) + +/** + * Thrown by [CryptoEngine.decryptAEAD] when the Poly1305 authentication tag fails. + * Caught at CryptoLayer and VaultManager boundaries and mapped to [VaultError.AuthenticationFailed]. + */ +class VaultAuthException(message: String) : Exception(message) + +/** Fast parameters for tests — do NOT use in production. */ +@Deprecated( + message = "TEST_ARGON2_PARAMS is intentionally weak — use only in test source sets, never in production code.", + level = DeprecationLevel.WARNING, +) +val TEST_ARGON2_PARAMS = Argon2Params(memory = 4096, iterations = 1, parallelism = 1) + +/** Production defaults: 64 MiB / 3 iterations / 1 thread. */ +val DEFAULT_ARGON2_PARAMS = Argon2Params(memory = 64 * 1024, iterations = 3, parallelism = 1) diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/vault/CryptoLayer.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/vault/CryptoLayer.kt new file mode 100644 index 00000000..9122a190 --- /dev/null +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/vault/CryptoLayer.kt @@ -0,0 +1,120 @@ +package dev.stapler.stelekit.vault + +import arrow.core.Either +import arrow.core.left +import arrow.core.right + +/** + * Per-file encrypt/decrypt using the STEK binary format. + * + * STEK file layout: + * [0] 4 bytes — magic "STEK" (0x53 0x54 0x45 0x4B) + * [4] 1 byte — version 0x01 + * [5] 12 bytes — random nonce (SecureRandom, per-write) + * [17] N bytes — ciphertext + 16-byte Poly1305 tag (ChaCha20-Poly1305) + * AAD = relative file path UTF-8 bytes + * subkey = HKDF-SHA256(DEK, salt=filePathBytes, info="stelekit-file-v1") + * + * The file path used as AAD and HKDF salt is always graph-root-relative (e.g. "pages/Note.md.stek"). + * This binds the ciphertext to its location — moving a file breaks decryption (relocation attack + * prevention per plan OPEN-1 decision). + * + * [cryptoEngine] — platform crypto implementation + * [dek] — 32-byte Data Encryption Key; CryptoLayer takes its own copy so the caller's zeroing + * (e.g. VaultManager.lock()) cannot race with an in-flight encrypt/decrypt. + * Call [close] to zero the owned copy when the CryptoLayer is no longer needed. + */ +class CryptoLayer( + private val cryptoEngine: CryptoEngine, + dek: ByteArray, +) { + // Owned copy — decouples this object's DEK lifetime from the session.dek reference that + // VaultManager.lock() zeroes. Zeroed by close() when the caller nulls this CryptoLayer. + private val dek: ByteArray = dek.copyOf() + + /** + * Zeroes the owned DEK copy. Must be called before nulling the CryptoLayer reference + * to ensure the key material does not linger in heap. + */ + fun close() { + cryptoEngine.clearBytes(dek) + } + companion object { + val STEK_MAGIC = byteArrayOf(0x53, 0x54, 0x45, 0x4B) + const val STEK_VERSION: Byte = 0x01 + const val HEADER_SIZE = 17 // 4 magic + 1 version + 12 nonce + private const val NONCE_SIZE = 12 + private val HKDF_INFO = "stelekit-file-v1".encodeToByteArray() + } + + /** + * Encrypt [plaintext] and return STEK-format bytes. + * [relativeFilePath] is used as both HKDF salt and AEAD additional data. + */ + fun encrypt(relativeFilePath: String, plaintext: ByteArray): ByteArray { + val pathBytes = relativeFilePath.encodeToByteArray() + val subkey = cryptoEngine.hkdfSha256(dek, pathBytes, HKDF_INFO, 32) + try { + val nonce = cryptoEngine.secureRandom(NONCE_SIZE) + val ciphertext = cryptoEngine.encryptAEAD(subkey, nonce, plaintext, pathBytes) + + val result = ByteArray(HEADER_SIZE + ciphertext.size) + STEK_MAGIC.copyInto(result, 0) + result[4] = STEK_VERSION + nonce.copyInto(result, 5) + ciphertext.copyInto(result, HEADER_SIZE) + return result + } finally { + cryptoEngine.clearBytes(subkey) + } + } + + /** + * Decrypt STEK-format [raw] bytes. + * Returns [VaultError.NotEncrypted] if magic bytes do not match (plaintext file). + * Returns [VaultError.CorruptedFile] if the file is too short. + * Returns [VaultError.AuthenticationFailed] if AEAD tag fails. + */ + fun decrypt(relativeFilePath: String, raw: ByteArray): Either { + if (raw.size < 4) return VaultError.CorruptedFile("File too short (${raw.size} bytes)").left() + + val magic = raw.sliceArray(0 until 4) + if (!magic.contentEquals(STEK_MAGIC)) { + return VaultError.NotEncrypted().left() + } + + if (raw.size < HEADER_SIZE) { + return VaultError.CorruptedFile("STEK header truncated (${raw.size} < $HEADER_SIZE bytes)").left() + } + if (raw.size == HEADER_SIZE) { + return VaultError.CorruptedFile("STEK ciphertext is empty — no Poly1305 tag").left() + } + val version = raw[4].toInt() and 0xFF + if (version != STEK_VERSION.toInt() and 0xFF) { + return VaultError.UnsupportedVersion(version).left() + } + + val nonce = raw.sliceArray(5 until 17) + val ciphertext = raw.sliceArray(HEADER_SIZE until raw.size) + val pathBytes = relativeFilePath.encodeToByteArray() + val subkey = cryptoEngine.hkdfSha256(dek, pathBytes, HKDF_INFO, 32) + try { + return try { + cryptoEngine.decryptAEAD(subkey, nonce, ciphertext, pathBytes).right() + } catch (_: VaultAuthException) { + VaultError.AuthenticationFailed().left() + } + } finally { + cryptoEngine.clearBytes(subkey) + } + } + + /** Guard against outer-graph writes into the hidden volume reserve directory. */ + fun checkNotHiddenReserve(relativeFilePath: String): Either { + return if (relativeFilePath == "_hidden_reserve" || relativeFilePath.startsWith("_hidden_reserve/")) { + VaultError.HiddenAreaWriteDenied().left() + } else { + Unit.right() + } + } +} diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/vault/VaultError.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/vault/VaultError.kt new file mode 100644 index 00000000..e02ffb0d --- /dev/null +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/vault/VaultError.kt @@ -0,0 +1,38 @@ +package dev.stapler.stelekit.vault + +/** + * Vault-specific errors. Used with Arrow Either for all vault operations. + * Not extending DomainError (sealed interface cross-package constraint); callers that need + * a DomainError can wrap the message in their own error type at the use-site. + */ +sealed class VaultError(open val message: String) { + /** Passphrase or key-file did not match any keyslot. */ + class InvalidCredential(message: String = "Invalid passphrase or key") : VaultError(message) + + /** AEAD authentication tag did not verify — ciphertext was modified. */ + class AuthenticationFailed(message: String = "Authentication tag verification failed") : VaultError(message) + + /** Header HMAC-SHA256 did not verify after DEK recovery. */ + class HeaderTampered(message: String = "Vault header MAC verification failed") : VaultError(message) + + /** Magic bytes are not "SKVT" — not a vault file. */ + class NotAVault(message: String = "File is not a SteleKit vault") : VaultError(message) + + /** Format version byte is not recognised. */ + class UnsupportedVersion(val version: Int, message: String = "Unsupported vault version: $version") : VaultError(message) + + /** Magic bytes are not "STEK" — not an encrypted file (migration compatibility). */ + class NotEncrypted(message: String = "File is not STEK-encrypted (plaintext)") : VaultError(message) + + /** STEK file is too short to contain a valid header. */ + class CorruptedFile(message: String = "Encrypted file is truncated or corrupted") : VaultError(message) + + /** All 8 keyslots were tried and none verified — header may be corrupt. */ + class NoValidKeyslot(message: String = "No valid keyslot found in vault header") : VaultError(message) + + /** All keyslots for the requested namespace are occupied. */ + class SlotsFull(message: String = "All keyslots for this namespace are occupied") : VaultError(message) + + /** Attempt to write into the hidden-reserve area from the outer graph. */ + class HiddenAreaWriteDenied(message: String = "Outer graph cannot write to _hidden_reserve/") : VaultError(message) +} diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/vault/VaultHeader.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/vault/VaultHeader.kt new file mode 100644 index 00000000..af750106 --- /dev/null +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/vault/VaultHeader.kt @@ -0,0 +1,145 @@ +package dev.stapler.stelekit.vault + +/** + * Binary layout of a .stele-vault file (total: 2605 bytes). + * + * Offset Size Field + * 0 4 Magic: 0x53 0x4B 0x56 0x54 ("SKVT") + * 4 1 Format version: 0x02 + * 5 8 Random padding (prevents zero-length fingerprinting) + * 13 2048 Keyslot array: 8 × 256 bytes each + * slots 0–3: OUTER namespace + * slots 4–7: HIDDEN namespace + * 2061 480 Reserved: random bytes. NOT authenticated by either MAC (intentional — + * authentication requires a DEK to produce a MAC, and this region must be + * freely mutable before either namespace is initialized). Any future semantic + * use of bytes in this region MUST include a format version bump and MAC + * coverage update. + * (was 512; 32 bytes repurposed for hiddenHeaderMac) + * 2541 32 HIDDEN namespace MAC: HMAC-SHA256(hidden_mac_key, prefix13 ++ slots4-7) + * authenticates bytes[0..12] ++ bytes[1037..2060] + * 2573 32 OUTER namespace MAC: HMAC-SHA256(outer_mac_key, bytes[0..1036]) + * authenticates bytes[0..1036] (magic+version+padding+slots0-3) + * mac_key = HKDF-SHA256(DEK, salt="vault-header-mac", info="v1") + * + * Key property: neither MAC covers the other namespace's keyslot range, so HIDDEN slots + * can be updated without invalidating the OUTER MAC (and vice versa). + * + * Total: 4 + 1 + 8 + 2048 + 480 + 32 + 32 = 2605 + */ +data class VaultHeader( + val version: Byte = 0x02, + val randomPadding: ByteArray, // 8 bytes + val keyslots: List, // exactly 8 elements + val reserved: ByteArray, // 480 bytes + val hiddenHeaderMac: ByteArray, // 32 bytes (HIDDEN namespace MAC, at [2541]) + val headerMac: ByteArray, // 32 bytes (OUTER namespace MAC, at [2573]) +) { + companion object { + val MAGIC = byteArrayOf(0x53, 0x4B, 0x56, 0x54) // "SKVT" + const val SUPPORTED_VERSION: Byte = 0x02 + const val TOTAL_SIZE = 2605 + const val KEYSLOT_COUNT = 8 + const val KEYSLOT_SIZE = 256 + const val RESERVED_SIZE = 480 // was 512; 32 bytes repurposed for hiddenHeaderMac + const val MAC_SIZE = 32 + const val PADDING_SIZE = 8 + const val OUTER_MAC_AUTH_SIZE = 1037 // bytes[0..1036]: magic+version+padding+slots0-3 + const val HEADER_PREFIX_SIZE = 13 // bytes[0..12]: magic+version+padding + const val HIDDEN_SLOT_START = 1037 // first byte of hidden keyslots (slots 4-7) + const val HIDDEN_SLOT_END = 2061 // one-past-end of hidden keyslots + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is VaultHeader) return false + if (version != other.version) return false + if (!randomPadding.contentEquals(other.randomPadding)) return false + if (keyslots != other.keyslots) return false + if (!reserved.contentEquals(other.reserved)) return false + if (!hiddenHeaderMac.contentEquals(other.hiddenHeaderMac)) return false + if (!headerMac.contentEquals(other.headerMac)) return false + return true + } + + override fun hashCode(): Int { + var result = version.toInt() + result = 31 * result + randomPadding.contentHashCode() + result = 31 * result + keyslots.hashCode() + result = 31 * result + reserved.contentHashCode() + result = 31 * result + hiddenHeaderMac.contentHashCode() + result = 31 * result + headerMac.contentHashCode() + return result + } +} + +/** + * Per-keyslot layout (256 bytes each): + * + * Offset Size Field + * 0 32 Argon2id salt (RFC 9106 recommended 32 bytes for high-security applications) + * 32 4 Argon2id memory (KiB, LE uint32) + * 36 2 Argon2id iterations (LE uint16) + * 38 2 Argon2id parallelism (LE uint16) + * 40 50 Encrypted DEK blob: ChaCha20-Poly1305(keyslot_key, slot_nonce, DEK||namespace_tag||provider_type) + * DEK = 32 bytes, namespace_tag = 1 byte, provider_type = 1 byte, AEAD_tag = 16 bytes → 50 bytes + * 90 12 slot_nonce (nonce for the DEK-wrapping AEAD) + * 102 154 Reserved: reserved[0..3] is a 4-byte DEK-derived slot-activity marker + * (HKDF-SHA256(dek, "slot-marker-v1", [slotIndex, namespace.tag]), length=4; + * false-positive rate 1/2^32); remaining bytes are random. Active and decoy + * slots are indistinguishable on disk — only Argon2id + AEAD decryption can + * identify a valid slot. + * + * All 8 slots are always tried on unlock in constant order (no plaintext hint as to which are active), + * preserving deniability for hidden-volume passphrases. + */ +data class Keyslot( + val salt: ByteArray, // 32 bytes + val argon2Params: Argon2Params, + val encryptedDekBlob: ByteArray, // 50 bytes (34 plaintext + 16 AEAD tag) + val slotNonce: ByteArray, // 12 bytes + val reserved: ByteArray, // 154 bytes; reserved[0..3] is 4-byte slot-activity marker +) { + companion object { + const val SALT_SIZE = 32 + const val ENCRYPTED_BLOB_SIZE = 50 // 32 DEK + 1 namespace_tag + 1 provider_type + 16 AEAD tag + const val NONCE_SIZE = 12 + const val RESERVED_SIZE = 154 + const val TOTAL_SIZE = 256 + + const val PROVIDER_PASSPHRASE: Byte = 0x00 + const val PROVIDER_KEYFILE: Byte = 0x01 + const val PROVIDER_OS_KEYCHAIN: Byte = 0x02 + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Keyslot) return false + if (!salt.contentEquals(other.salt)) return false + if (argon2Params != other.argon2Params) return false + if (!encryptedDekBlob.contentEquals(other.encryptedDekBlob)) return false + if (!slotNonce.contentEquals(other.slotNonce)) return false + if (!reserved.contentEquals(other.reserved)) return false + return true + } + + override fun hashCode(): Int { + var result = salt.contentHashCode() + result = 31 * result + argon2Params.hashCode() + result = 31 * result + encryptedDekBlob.contentHashCode() + result = 31 * result + slotNonce.contentHashCode() + result = 31 * result + reserved.contentHashCode() + return result + } +} + +enum class VaultNamespace(val tag: Byte) { + OUTER(0x00), + HIDDEN(0x01); + + companion object { + fun fromTag(tag: Byte): VaultNamespace = + entries.firstOrNull { it.tag == tag } + ?: throw VaultAuthException("Unknown namespace tag: 0x${tag.toInt().and(0xFF).toString(16)}") + } +} diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/vault/VaultHeaderSerializer.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/vault/VaultHeaderSerializer.kt new file mode 100644 index 00000000..1ea2b8b0 --- /dev/null +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/vault/VaultHeaderSerializer.kt @@ -0,0 +1,178 @@ +package dev.stapler.stelekit.vault + +import arrow.core.Either +import arrow.core.left +import arrow.core.right + +/** + * Fixed-offset binary serializer / deserializer for the .stele-vault header format. + * + * Layout: + * [0] 4 bytes — magic "SKVT" + * [4] 1 byte — version + * [5] 8 bytes — random padding + * [13] 2048 bytes — 8 keyslots × 256 bytes each (slots 0-3: OUTER, slots 4-7: HIDDEN) + * [2061] 480 bytes — reserved + * [2541] 32 bytes — HIDDEN namespace MAC + * [2573] 32 bytes — OUTER namespace MAC (headerMac) + * Total: 2605 bytes + * + * Per-keyslot layout (256 bytes): + * [0] 32 salt + * [32] 4 Argon2 memory (LE uint32) + * [36] 2 Argon2 iterations (LE uint16) + * [38] 2 Argon2 parallelism (LE uint16) + * [40] 50 encrypted DEK blob (AEAD ciphertext) + * [90] 12 slot nonce + * [102] 154 reserved (reserved[0] is slot-activity marker) + */ +object VaultHeaderSerializer { + + fun serialize(header: VaultHeader): ByteArray { + require(header.keyslots.size == VaultHeader.KEYSLOT_COUNT) { + "Vault header must have exactly ${VaultHeader.KEYSLOT_COUNT} keyslots" + } + require(header.randomPadding.size == VaultHeader.PADDING_SIZE) + require(header.reserved.size == VaultHeader.RESERVED_SIZE) + require(header.hiddenHeaderMac.size == VaultHeader.MAC_SIZE) + require(header.headerMac.size == VaultHeader.MAC_SIZE) + + val buf = ByteArray(VaultHeader.TOTAL_SIZE) + var pos = 0 + + // Magic + VaultHeader.MAGIC.copyInto(buf, pos); pos += 4 + // Version + buf[pos] = header.version; pos += 1 + // Random padding + header.randomPadding.copyInto(buf, pos); pos += VaultHeader.PADDING_SIZE + + // 8 keyslots + for (slot in header.keyslots) { + val slotBytes = serializeKeyslot(slot) + require(slotBytes.size == Keyslot.TOTAL_SIZE) + slotBytes.copyInto(buf, pos) + pos += Keyslot.TOTAL_SIZE + } + + // Reserved + header.reserved.copyInto(buf, pos); pos += VaultHeader.RESERVED_SIZE + // HIDDEN namespace MAC + header.hiddenHeaderMac.copyInto(buf, pos); pos += VaultHeader.MAC_SIZE + // OUTER namespace MAC + header.headerMac.copyInto(buf, pos); pos += VaultHeader.MAC_SIZE + + check(pos == VaultHeader.TOTAL_SIZE) + return buf + } + + fun deserialize(bytes: ByteArray): Either { + if (bytes.size != VaultHeader.TOTAL_SIZE) { + return VaultError.CorruptedFile("Vault header wrong size: ${bytes.size} (expected ${VaultHeader.TOTAL_SIZE})").left() + } + + var pos = 0 + + // Magic check + val magic = bytes.sliceArray(pos until pos + 4); pos += 4 + if (!magic.contentEquals(VaultHeader.MAGIC)) { + return VaultError.NotAVault().left() + } + + // Version check + val version = bytes[pos]; pos += 1 + if (version != VaultHeader.SUPPORTED_VERSION) { + return VaultError.UnsupportedVersion(version.toInt() and 0xFF).left() + } + + // Random padding + val padding = bytes.sliceArray(pos until pos + VaultHeader.PADDING_SIZE); pos += VaultHeader.PADDING_SIZE + + // 8 keyslots + val keyslots = mutableListOf() + repeat(VaultHeader.KEYSLOT_COUNT) { + val slotBytes = bytes.sliceArray(pos until pos + Keyslot.TOTAL_SIZE) + keyslots.add(deserializeKeyslot(slotBytes)) + pos += Keyslot.TOTAL_SIZE + } + + // Reserved + val reserved = bytes.sliceArray(pos until pos + VaultHeader.RESERVED_SIZE); pos += VaultHeader.RESERVED_SIZE + + // HIDDEN namespace MAC + val hiddenMac = bytes.sliceArray(pos until pos + VaultHeader.MAC_SIZE); pos += VaultHeader.MAC_SIZE + + // OUTER namespace MAC + val mac = bytes.sliceArray(pos until pos + VaultHeader.MAC_SIZE) + + return VaultHeader( + version = version, + randomPadding = padding, + keyslots = keyslots, + reserved = reserved, + hiddenHeaderMac = hiddenMac, + headerMac = mac, + ).right() + } + + private fun serializeKeyslot(slot: Keyslot): ByteArray { + require(slot.salt.size == Keyslot.SALT_SIZE) + require(slot.encryptedDekBlob.size == Keyslot.ENCRYPTED_BLOB_SIZE) + require(slot.slotNonce.size == Keyslot.NONCE_SIZE) + require(slot.reserved.size == Keyslot.RESERVED_SIZE) + + val buf = ByteArray(Keyslot.TOTAL_SIZE) + var pos = 0 + + slot.salt.copyInto(buf, pos); pos += Keyslot.SALT_SIZE + writeInt32LE(buf, pos, slot.argon2Params.memory); pos += 4 + writeInt16LE(buf, pos, slot.argon2Params.iterations); pos += 2 + writeInt16LE(buf, pos, slot.argon2Params.parallelism); pos += 2 + slot.encryptedDekBlob.copyInto(buf, pos); pos += Keyslot.ENCRYPTED_BLOB_SIZE + slot.slotNonce.copyInto(buf, pos); pos += Keyslot.NONCE_SIZE + slot.reserved.copyInto(buf, pos); pos += Keyslot.RESERVED_SIZE + + check(pos == Keyslot.TOTAL_SIZE) + return buf + } + + private fun deserializeKeyslot(bytes: ByteArray): Keyslot { + var pos = 0 + val salt = bytes.sliceArray(pos until pos + Keyslot.SALT_SIZE); pos += Keyslot.SALT_SIZE + val memory = readInt32LE(bytes, pos); pos += 4 + val iterations = readInt16LE(bytes, pos); pos += 2 + val parallelism = readInt16LE(bytes, pos); pos += 2 + val blob = bytes.sliceArray(pos until pos + Keyslot.ENCRYPTED_BLOB_SIZE); pos += Keyslot.ENCRYPTED_BLOB_SIZE + val nonce = bytes.sliceArray(pos until pos + Keyslot.NONCE_SIZE); pos += Keyslot.NONCE_SIZE + val reserved = bytes.sliceArray(pos until pos + Keyslot.RESERVED_SIZE) + return Keyslot( + salt = salt, + argon2Params = Argon2Params(memory = memory, iterations = iterations, parallelism = parallelism), + encryptedDekBlob = blob, + slotNonce = nonce, + reserved = reserved, + ) + } + + private fun writeInt32LE(buf: ByteArray, offset: Int, value: Int) { + buf[offset] = (value and 0xFF).toByte() + buf[offset + 1] = ((value shr 8) and 0xFF).toByte() + buf[offset + 2] = ((value shr 16) and 0xFF).toByte() + buf[offset + 3] = ((value shr 24) and 0xFF).toByte() + } + + private fun readInt32LE(bytes: ByteArray, offset: Int): Int = + (bytes[offset].toInt() and 0xFF) or + ((bytes[offset + 1].toInt() and 0xFF) shl 8) or + ((bytes[offset + 2].toInt() and 0xFF) shl 16) or + ((bytes[offset + 3].toInt() and 0xFF) shl 24) + + private fun writeInt16LE(buf: ByteArray, offset: Int, value: Int) { + buf[offset] = (value and 0xFF).toByte() + buf[offset + 1] = ((value shr 8) and 0xFF).toByte() + } + + private fun readInt16LE(bytes: ByteArray, offset: Int): Int = + (bytes[offset].toInt() and 0xFF) or + ((bytes[offset + 1].toInt() and 0xFF) shl 8) +} diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/vault/VaultManager.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/vault/VaultManager.kt new file mode 100644 index 00000000..f22e3c52 --- /dev/null +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/vault/VaultManager.kt @@ -0,0 +1,646 @@ +package dev.stapler.stelekit.vault + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import dev.stapler.stelekit.coroutines.PlatformDispatcher +import dev.stapler.stelekit.logging.Logger +import kotlin.concurrent.Volatile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.withContext + +/** + * Manages vault header lifecycle: create, unlock, lock, keyslot add/remove. + * + * The DEK is held in memory only while the vault is unlocked. Calling [lock] zero-fills + * the DEK array and emits [VaultEvent.Locked]. + * + * Argon2id key derivation runs on [Dispatchers.Default] (CPU-bound, ~350ms–1s per slot). + * All 8 keyslots are always tried during unlock in constant order — no plaintext hint + * is used to skip slots, preserving deniability for hidden-volume passphrases. + * + * **Atomicity requirement**: [fileWriteBytes] MUST write atomically (e.g. write to a + * `.tmp` sibling file and rename it over the target). A non-atomic implementation risks + * partial writes on crash/power loss that permanently corrupt the vault header. + */ +class VaultManager( + private val crypto: CryptoEngine, + private val fileReadBytes: (path: String) -> ByteArray?, + private val fileWriteBytes: (path: String, data: ByteArray) -> Boolean, +) { + private val logger = Logger("VaultManager") + + // DROP_OLDEST ensures tryEmit always succeeds from non-suspend lock(); Locked is never silently dropped. + private val _vaultEvents = MutableSharedFlow( + replay = 1, + extraBufferCapacity = 8, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val vaultEvents: SharedFlow = _vaultEvents.asSharedFlow() + + /** + * Holds the DEK and namespace as a single atomic unit. + * A single @Volatile reference write is atomic on JVM, eliminating the torn-read window + * that existed when sessionDek and sessionNamespace were two separate @Volatile fields — + * a reader could previously observe sessionDek != null while sessionNamespace was still null. + */ + private data class Session(val dek: ByteArray, val namespace: VaultNamespace) + + @Volatile private var session: Session? = null + + sealed interface VaultEvent { + data object Locked : VaultEvent + data class Unlocked(val namespace: VaultNamespace) : VaultEvent + } + + /** + * The [dek] array is the **same object** that [VaultManager] stores in `session.dek`. + * When [lock] is called, `session` is set to null and the DEK is zeroed in-place — + * this also zeroes any [CryptoLayer] built from it. Callers MUST NOT store or copy the + * DEK array; pass it directly to [CryptoLayer] and let [VaultEvent.Locked] trigger cleanup. + */ + data class UnlockResult(val dek: ByteArray, val namespace: VaultNamespace) + + /** + * Create a new vault at [graphPath]/.stele-vault with a single passphrase keyslot. + * + * The vault is automatically unlocked after creation — [lock] manages DEK zeroing. + * Returns an [UnlockResult] so the caller can inject the DEK into a [CryptoLayer] + * without holding an orphaned ByteArray on the heap. + * + * [argon2Params] defaults to [DEFAULT_ARGON2_PARAMS]; supply a calibrated set for production use. + */ + suspend fun createVault( + graphPath: String, + passphrase: CharArray, + namespace: VaultNamespace = VaultNamespace.OUTER, + argon2Params: Argon2Params = DEFAULT_ARGON2_PARAMS, + ): Either = withContext(Dispatchers.Default) { + val dek = crypto.secureRandom(32) + var storedInSession = false + try { + validatePassphrase(passphrase) + val slotIndex = namespaceFirstSlot(namespace) + val keyslot = buildKeyslot(passphrase, dek, namespace, argon2Params, slotIndex) + + val allSlots = (0 until VaultHeader.KEYSLOT_COUNT).map { i -> + if (i == slotIndex) keyslot else randomSlot() + } + + val padding = crypto.secureRandom(VaultHeader.PADDING_SIZE) + val reserved = crypto.secureRandom(VaultHeader.RESERVED_SIZE) + + val header = VaultHeader( + randomPadding = padding, + keyslots = allSlots, + reserved = reserved, + hiddenHeaderMac = ByteArray(VaultHeader.MAC_SIZE), + headerMac = ByteArray(VaultHeader.MAC_SIZE), + ) + val partialBytes = VaultHeaderSerializer.serialize(header) + val macKey = deriveHeaderMacKey(dek) + val mac = when (namespace) { + VaultNamespace.OUTER -> computeHeaderMac(macKey, outerMacAuthData(partialBytes)) + VaultNamespace.HIDDEN -> computeHeaderMac(macKey, hiddenMacAuthData(partialBytes)) + } + crypto.clearBytes(macKey) + val finalHeader = when (namespace) { + VaultNamespace.OUTER -> header.copy( + headerMac = mac, + hiddenHeaderMac = crypto.secureRandom(VaultHeader.MAC_SIZE), // random, not zeros + ) + VaultNamespace.HIDDEN -> header.copy( + hiddenHeaderMac = mac, + headerMac = crypto.secureRandom(VaultHeader.MAC_SIZE), // random, not zeros + ) + } + val headerBytes = VaultHeaderSerializer.serialize(finalHeader) + + val vaultPath = vaultFilePath(graphPath) + if (!fileWriteBytes(vaultPath, headerBytes)) { + return@withContext VaultError.CorruptedFile("Failed to write vault file to $vaultPath").left() + } + + // Pre-create the hidden-reserve directory so it exists even before a hidden graph is written. + // Written as a random-byte sentinel — indistinguishable from encrypted file content. + val reservePath = hiddenReserveSentinelPath(graphPath) + if (!fileWriteBytes(reservePath, crypto.secureRandom(256))) { + // Non-fatal: the reserve sentinel is best-effort infrastructure. + // Log-worthy but not a reason to fail vault creation. + } + + // Store in session so lock() manages zeroing — symmetric with unlock(). + session = Session(dek, namespace) + storedInSession = true + _vaultEvents.tryEmit(VaultEvent.Unlocked(namespace)) + UnlockResult(dek = dek, namespace = namespace).right() + } finally { + passphrase.fill(' ') + if (!storedInSession) crypto.clearBytes(dek) + } + } + + /** + * Try all 8 keyslots in constant-time order, returning the DEK and namespace if any match. + * All slots are always tried — no plaintext skip — to preserve deniability. + * The passphrase CharArray is zero-filled after derivation regardless of outcome. + */ + suspend fun unlock( + graphPath: String, + passphrase: CharArray, + argon2Params: Argon2Params? = null, + ): Either = withContext(Dispatchers.Default) { + if (passphrase.any { it.isSurrogate() }) { + logger.warn("Passphrase contains supplementary Unicode characters — CESU-8 encoding used, may not interoperate with standard tools") + } + val vaultPath = vaultFilePath(graphPath) + val rawBytes = withContext(PlatformDispatcher.IO) { fileReadBytes(vaultPath) } + ?: return@withContext VaultError.NotAVault("Vault file not found at $vaultPath").left() + + val header = when (val r = VaultHeaderSerializer.deserialize(rawBytes)) { + is Either.Left -> return@withContext r + is Either.Right -> r.value + } + + val passwordBytes = passphrase.toByteArray() + try { + var validDek: ByteArray? = null + var validNamespace: VaultNamespace? = null + // Tracks whether a slot decrypted successfully but the header MAC failed, + // which means the correct passphrase was used but the vault was tampered. + var macFailed = false + + // Try all 8 slots in order — no plaintext hint skips any slot. + // Decoy slots fail AEAD decryption (expected), active slots succeed. + for ((index, slot) in header.keyslots.withIndex()) { + val params = argon2Params ?: slot.argon2Params + // Validate params before deriving — extreme values (memory = Int.MAX_VALUE, + // iterations = 65535, parallelism = 65535) in a crafted vault file would cause + // OOM / CPU DoS / thread exhaustion before the header MAC rejects the slot. + // Use `continue` rather than aborting: decoy slots have random bytes and can + // legitimately produce zero or extreme params (~1/2^32 per slot per field). + if (params.memory < 1 || params.iterations < 1 || params.parallelism < 1 + || params.memory > MAX_ARGON2_MEMORY_KIB + || params.iterations > MAX_ARGON2_ITERATIONS + || params.parallelism > MAX_ARGON2_PARALLELISM) { + continue + } + val keyslotKey = crypto.argon2id( + password = passwordBytes, + salt = slot.salt, + memory = params.memory, + iterations = params.iterations, + parallelism = params.parallelism, + outputLength = 32, + ) + try { + val plaintext = crypto.decryptAEAD(keyslotKey, slot.slotNonce, slot.encryptedDekBlob, byteArrayOf()) + // plaintext = DEK (32 bytes) + namespace_tag (1 byte) + provider_type (1 byte) + // First-match wins: validDek == null guard discards subsequent active slots. + // All 8 slots are still tried unconditionally for constant-time deniability. + if (plaintext.size == 34 && validDek == null) { + val dek = plaintext.sliceArray(0 until 32) + val ns = try { + VaultNamespace.fromTag(plaintext[32]) + } catch (_: VaultAuthException) { + crypto.clearBytes(dek) + crypto.clearBytes(plaintext) + continue + } + // Verify namespace-specific header MAC using the recovered DEK + val macKey = deriveHeaderMacKey(dek) + val authData = when (ns) { + VaultNamespace.OUTER -> outerMacAuthData(rawBytes) + VaultNamespace.HIDDEN -> hiddenMacAuthData(rawBytes) + } + val expectedMac = computeHeaderMac(macKey, authData) + crypto.clearBytes(macKey) + val storedMac = when (ns) { + VaultNamespace.OUTER -> header.headerMac + VaultNamespace.HIDDEN -> header.hiddenHeaderMac + } + if (constantTimeEquals(expectedMac, storedMac)) { + validDek = dek + validNamespace = ns + } else { + macFailed = true + crypto.clearBytes(dek) + } + crypto.clearBytes(expectedMac) + } + crypto.clearBytes(plaintext) + } catch (_: VaultAuthException) { + // Expected for decoy slots and wrong-passphrase slots — continue. + } finally { + // Always zero keyslotKey — `continue` in the inner catch would otherwise + // skip the clearBytes call, leaving the Argon2id-derived key in memory. + crypto.clearBytes(keyslotKey) + } + } + + if (validDek == null || validNamespace == null) { + if (validDek != null) crypto.clearBytes(validDek) + if (macFailed) { + logger.warn("unlock: correct passphrase but header MAC failed — possible vault tampering") + } + return@withContext VaultError.InvalidCredential().left() + } + + val newSession = Session(validDek, validNamespace) + session = newSession // single atomic reference write — no torn-read possible + _vaultEvents.tryEmit(VaultEvent.Unlocked(validNamespace)) + UnlockResult(dek = validDek, namespace = validNamespace).right() + } finally { + crypto.clearBytes(passwordBytes) + passphrase.fill(' ') + } + } + + /** + * Zero-fill the in-memory DEK and emit [VaultEvent.Locked]. + * Null is written before zeroing so concurrent [currentDek] callers see null immediately. + * + * **Mandatory call ordering**: callers MUST clear all [CryptoLayer] references + * (`graphWriter.cryptoLayer = null`, `graphLoader.cryptoLayer = null`) and flush + * pending saves (`graphWriter.flush()`) BEFORE calling this method. If `lock()` zeroes + * the DEK while an in-flight `encrypt()` call is still using it, the file is silently + * written with an all-zero key and becomes permanently corrupted. + * + * The [VaultEvent.Locked] handler in App.kt also flushes and clears references, but + * that fires AFTER the DEK is already zeroed — it is a safety net for unexpected lock + * events only, not the primary lock path. + */ + fun lock() { + val s = session + session = null // single atomic reference write — visible immediately to other threads (@Volatile) + s?.let { crypto.clearBytes(it.dek) } + _vaultEvents.tryEmit(VaultEvent.Locked) + } + + /** Returns the current in-memory DEK (null when locked). */ + fun currentDek(): ByteArray? = session?.dek + + /** + * Add a new passphrase keyslot to an existing vault. + * The caller must first unlock with an existing provider to supply [dek]. + */ + suspend fun addKeyslot( + graphPath: String, + dek: ByteArray, + passphrase: CharArray, + namespace: VaultNamespace = VaultNamespace.OUTER, + argon2Params: Argon2Params = DEFAULT_ARGON2_PARAMS, + ): Either = withContext(Dispatchers.Default) { + val localDek = dek.copyOf() // isolated from concurrent lock() zeroing of the live session array + try { + validatePassphrase(passphrase) + val vaultPath = vaultFilePath(graphPath) + val rawBytes = withContext(PlatformDispatcher.IO) { fileReadBytes(vaultPath) } + ?: return@withContext VaultError.NotAVault("Vault file not found").left() + val header = when (val r = VaultHeaderSerializer.deserialize(rawBytes)) { + is Either.Left -> return@withContext r + is Either.Right -> r.value + } + + // Verify the provided DEK is correct before mutating any slots. + // Without this check, a caller with a wrong DEK could overwrite active slots + // because the marker comparison would fail for all slots (false negatives). + // Use namespace-specific MAC so OUTER DEK cannot be verified against HIDDEN MAC. + val verifyMacKey = deriveHeaderMacKey(localDek) + val authData = when (namespace) { + VaultNamespace.OUTER -> outerMacAuthData(rawBytes) + VaultNamespace.HIDDEN -> hiddenMacAuthData(rawBytes) + } + val actualMac = computeHeaderMac(verifyMacKey, authData) + crypto.clearBytes(verifyMacKey) + val expectedMac = when (namespace) { + VaultNamespace.OUTER -> header.headerMac + VaultNamespace.HIDDEN -> header.hiddenHeaderMac + } + val dekValid = constantTimeEquals(actualMac, expectedMac) + crypto.clearBytes(actualMac) + if (!dekValid) { + return@withContext VaultError.InvalidCredential("Provided DEK does not match vault header").left() + } + + // Namespace guard: an active session may only add slots within its own namespace. + // Allowing an OUTER session to embed the OUTER DEK in a HIDDEN slot would let + // anyone with the outer passphrase automatically recover the "hidden" DEK. + val currentNs = session?.namespace + if (currentNs != null && namespace != currentNs) { + return@withContext VaultError.InvalidCredential( + "Active session namespace ($currentNs) cannot add keyslots to $namespace" + ).left() + } + + val targetSlots = namespaceSlotRange(namespace) + // A slot is "mine" if its reserved[0..3] matches the 4-byte DEK-derived marker. + // Slots without the marker (decoy slots) are safe to overwrite. + val emptySlotIndex = targetSlots.firstOrNull { index -> + !isSlotMine(header.keyslots[index], localDek, index, namespace) + } ?: return@withContext VaultError.SlotsFull().left() + + val newSlot = buildKeyslot(passphrase, localDek, namespace, argon2Params, emptySlotIndex) + val updatedSlots = header.keyslots.toMutableList() + updatedSlots[emptySlotIndex] = newSlot + + return@withContext writeUpdatedHeader(vaultPath, localDek, namespace, header.copy(keyslots = updatedSlots)) + } finally { + passphrase.fill(' ') + crypto.clearBytes(localDek) + // Do NOT zero the original `dek` param — it's the live session array owned by lock() + } + } + + /** + * Overwrite keyslot at [slotIndex] with random bytes (effectively removing it). + * The DEK is not re-encrypted — remaining providers can still unlock. + * + * Enforces two guards before mutating the header: + * 1. [slotIndex] must be a valid keyslot index (0 until KEYSLOT_COUNT). + * 2. [slotIndex] must belong to the currently authenticated namespace — an OUTER session + * cannot remove HIDDEN keyslots (slots 4–7) and vice versa. + */ + suspend fun removeKeyslot( + graphPath: String, + slotIndex: Int, + ): Either = withContext(Dispatchers.Default) { + val currentSession = session ?: return@withContext VaultError.InvalidCredential("Vault is locked").left() + val dek = currentSession.dek.copyOf() // isolated from concurrent lock() zeroing + val ns = currentSession.namespace + try { + if (slotIndex !in 0 until VaultHeader.KEYSLOT_COUNT) { + return@withContext VaultError.InvalidCredential("Slot index $slotIndex is out of range").left() + } + if (slotIndex !in namespaceSlotRange(ns)) { + return@withContext VaultError.InvalidCredential( + "Slot $slotIndex does not belong to the current namespace" + ).left() + } + + val vaultPath = vaultFilePath(graphPath) + val rawBytes = withContext(PlatformDispatcher.IO) { fileReadBytes(vaultPath) } + ?: return@withContext VaultError.NotAVault("Vault file not found").left() + val header = when (val r = VaultHeaderSerializer.deserialize(rawBytes)) { + is Either.Left -> return@withContext r + is Either.Right -> r.value + } + + // Refuse to remove the last active slot — it would permanently lock out the vault. + val activeCount = namespaceSlotRange(ns).count { i -> isSlotMine(header.keyslots[i], dek, i, ns) } + if (activeCount <= 1) { + return@withContext VaultError.InvalidCredential( + "Cannot remove the last keyslot in namespace $ns — vault would be permanently locked" + ).left() + } + + val updatedSlots = header.keyslots.toMutableList() + updatedSlots[slotIndex] = randomSlot() + return@withContext writeUpdatedHeader(vaultPath, dek, ns, header.copy(keyslots = updatedSlots)) + } finally { + crypto.clearBytes(dek) + } + } + + private fun writeUpdatedHeader( + vaultPath: String, + dek: ByteArray, + namespace: VaultNamespace, + header: VaultHeader, + ): Either { + val partialBytes = VaultHeaderSerializer.serialize(header.copy( + hiddenHeaderMac = ByteArray(VaultHeader.MAC_SIZE), + headerMac = ByteArray(VaultHeader.MAC_SIZE) + )) + val macKey = deriveHeaderMacKey(dek) + val mac = when (namespace) { + VaultNamespace.OUTER -> computeHeaderMac(macKey, outerMacAuthData(partialBytes)) + VaultNamespace.HIDDEN -> computeHeaderMac(macKey, hiddenMacAuthData(partialBytes)) + } + crypto.clearBytes(macKey) + val finalHeader = when (namespace) { + VaultNamespace.OUTER -> header.copy( + headerMac = mac, + hiddenHeaderMac = crypto.secureRandom(VaultHeader.MAC_SIZE), + ) + VaultNamespace.HIDDEN -> header.copy( + hiddenHeaderMac = mac, + headerMac = crypto.secureRandom(VaultHeader.MAC_SIZE), + ) + } + val headerBytes = VaultHeaderSerializer.serialize(finalHeader) + return if (fileWriteBytes(vaultPath, headerBytes)) { + Unit.right() + } else { + VaultError.CorruptedFile("Failed to write updated vault header").left() + } + } + + private fun buildKeyslot( + passphrase: CharArray, + dek: ByteArray, + namespace: VaultNamespace, + argon2Params: Argon2Params, + slotIndex: Int, + ): Keyslot { + val salt = crypto.secureRandom(Keyslot.SALT_SIZE) + val passwordBytes = passphrase.toByteArray() + var keyslotKey = byteArrayOf() + var plaintext = byteArrayOf() + var markerKey = byteArrayOf() + try { + keyslotKey = crypto.argon2id( + password = passwordBytes, + salt = salt, + memory = argon2Params.memory, + iterations = argon2Params.iterations, + parallelism = argon2Params.parallelism, + outputLength = 32, + ) + + // plaintext = DEK (32 bytes) + namespace_tag (1 byte) + provider_type (1 byte) + plaintext = dek + byteArrayOf(namespace.tag, Keyslot.PROVIDER_PASSPHRASE) + val nonce = crypto.secureRandom(Keyslot.NONCE_SIZE) + val blob = crypto.encryptAEAD(keyslotKey, nonce, plaintext, byteArrayOf()) + + // reserved[0..3]: 4-byte DEK-derived marker so addKeyslot can find owned slots + // without a plaintext providerType byte (1/2^32 false-positive rate for decoy slots). + // Adversaries without the DEK cannot verify this marker — deniability is preserved. + val reserved = crypto.secureRandom(Keyslot.RESERVED_SIZE) + markerKey = crypto.hkdfSha256( + ikm = dek, + salt = "slot-marker-v1".encodeToByteArray(), + info = byteArrayOf(slotIndex.toByte(), namespace.tag), + length = 4, + ) + reserved[0] = markerKey[0]; reserved[1] = markerKey[1] + reserved[2] = markerKey[2]; reserved[3] = markerKey[3] + + return Keyslot( + salt = salt, + argon2Params = argon2Params, + encryptedDekBlob = blob, + slotNonce = nonce, + reserved = reserved, + ) + } finally { + crypto.clearBytes(passwordBytes) + crypto.clearBytes(keyslotKey) + crypto.clearBytes(plaintext) + crypto.clearBytes(markerKey) + } + } + + private fun randomSlot(): Keyslot { + // Randomize params within a small bounded range so decoys are distinct but cheap to try. + // Each value is derived from a fresh secureRandom call, using modulo within the range. + val randomBytes = crypto.secureRandom(4) + val memoryKib = 1024 + ((randomBytes[0].toInt() and 0xFF) * 28) // 1024..8164 KiB + val iterations = 1 + (randomBytes[1].toInt() and 0x03) // 1..4 + val parallelism = 1 + (randomBytes[2].toInt() and 0x01) // 1..2 + return Keyslot( + salt = crypto.secureRandom(Keyslot.SALT_SIZE), + argon2Params = Argon2Params(memory = memoryKib, iterations = iterations, parallelism = parallelism), + encryptedDekBlob = crypto.secureRandom(Keyslot.ENCRYPTED_BLOB_SIZE), + slotNonce = crypto.secureRandom(Keyslot.NONCE_SIZE), + reserved = crypto.secureRandom(Keyslot.RESERVED_SIZE), + ) + } + + /** + * A slot is "mine" if [reserved][0..3] matches the 4-byte HKDF marker derived from [dek], + * [slotIndex], and [namespace]. Including [namespace] prevents an OUTER DEK holder from + * computing markers for HIDDEN slots — markers from different namespaces are cryptographically + * independent. Decoy slots have random reserved bytes; the probability of a false positive is 1/2^32. + * An adversary without the DEK cannot verify this marker, so it reveals nothing about active slots. + */ + private fun isSlotMine(slot: Keyslot, dek: ByteArray, slotIndex: Int, namespace: VaultNamespace): Boolean { + val markerKey = crypto.hkdfSha256( + ikm = dek, + salt = "slot-marker-v1".encodeToByteArray(), + info = byteArrayOf(slotIndex.toByte(), namespace.tag), + length = 4, + ) + val matches = constantTimeEquals(slot.reserved.sliceArray(0 until 4), markerKey) + crypto.clearBytes(markerKey) + return matches + } + + private fun namespaceFirstSlot(namespace: VaultNamespace) = when (namespace) { + VaultNamespace.OUTER -> 0 + VaultNamespace.HIDDEN -> 4 + } + + private fun namespaceSlotRange(namespace: VaultNamespace) = when (namespace) { + VaultNamespace.OUTER -> 0..3 + VaultNamespace.HIDDEN -> 4..7 + } + + /** Returns the authenticated region for the OUTER namespace MAC: bytes[0..1036] (contiguous). */ + private fun outerMacAuthData(rawBytes: ByteArray): ByteArray = + rawBytes.sliceArray(0 until VaultHeader.OUTER_MAC_AUTH_SIZE) + + /** + * Returns the authenticated region for the HIDDEN namespace MAC: + * bytes[0..12] (magic+version+padding) ++ bytes[1037..2060] (slots 4-7). + * Non-contiguous — neither range overlaps the OUTER keyslot region. + */ + private fun hiddenMacAuthData(rawBytes: ByteArray): ByteArray = + rawBytes.sliceArray(0 until VaultHeader.HEADER_PREFIX_SIZE) + + rawBytes.sliceArray(VaultHeader.HIDDEN_SLOT_START until VaultHeader.HIDDEN_SLOT_END) + + private fun deriveHeaderMacKey(dek: ByteArray): ByteArray = + crypto.hkdfSha256( + ikm = dek, + salt = "vault-header-mac".encodeToByteArray(), + info = "v1".encodeToByteArray(), + length = 32, + ) + + private fun computeHeaderMac(macKey: ByteArray, data: ByteArray): ByteArray = + crypto.hmacSha256(macKey, data) + + private fun constantTimeEquals(a: ByteArray, b: ByteArray): Boolean = + crypto.constantTimeEquals(a, b) + + /** + * Rejects passphrases containing surrogate characters (emoji and other supplementary + * Unicode code points >= U+10000). The [CharArray.toByteArray] extension uses CESU-8 + * which encodes surrogate pairs as 3+3 bytes instead of standard UTF-8's 4 bytes. + * Vaults created with such passphrases would be irrecoverable after a platform migration + * to a standard UTF-8 implementation. Fail-fast at creation/add time; warn at unlock time + * (to keep existing vaults accessible). + */ + private fun validatePassphrase(passphrase: CharArray) { + require(passphrase.none { it.isSurrogate() }) { + "Passphrase must not contain emoji or supplementary Unicode characters — use standard ASCII or Latin characters for portability" + } + } + + companion object { + /** Maximum Argon2id memory (KiB) accepted from stored vault params — 4 GiB. */ + const val MAX_ARGON2_MEMORY_KIB = 4 * 1024 * 1024 // 4 GiB in KiB + /** Maximum Argon2id iteration count accepted from stored vault params — prevents CPU DoS. */ + const val MAX_ARGON2_ITERATIONS = 64 + /** Maximum Argon2id parallelism accepted from stored vault params — prevents thread/OOM DoS. */ + const val MAX_ARGON2_PARALLELISM = 64 + + fun vaultFilePath(graphPath: String): String { + require(graphPath.isNotEmpty()) { "graphPath must not be empty" } + require(!graphPath.contains("..")) { "graphPath must not contain '..' path traversal" } + val base = if (graphPath.endsWith("/")) graphPath.dropLast(1) else graphPath + return "$base/.stele-vault" + } + + fun hiddenReserveSentinelPath(graphPath: String): String { + require(graphPath.isNotEmpty()) { "graphPath must not be empty" } + require(!graphPath.contains("..")) { "graphPath must not contain '..' path traversal" } + val base = if (graphPath.endsWith("/")) graphPath.dropLast(1) else graphPath + return "$base/_hidden_reserve/.stele-reserve" + } + } +} + +/** + * Encodes a CharArray to UTF-8 bytes without creating a String intermediate. + * Avoids heap-interning of the passphrase in a JVM String that cannot be zeroed. + * Handles BMP code points (U+0000–U+FFFF); surrogate pairs are encoded as three bytes each + * (CESU-8 style), which differs from standard UTF-8's four-byte encoding for U+10000+. + * + * **Compatibility warning**: passphrases containing emoji or other supplementary characters + * (U+10000 and above) will produce different bytes than a standard UTF-8 encoder. Any future + * re-implementation MUST replicate this encoding or such vaults become irrecoverable. + */ +private fun CharArray.toByteArray(): ByteArray { + // First pass: count output bytes to avoid boxing via ArrayList + var byteCount = 0 + for (c in this) { + byteCount += when { + c.code < 0x80 -> 1 + c.code < 0x800 -> 2 + else -> 3 + } + } + val out = ByteArray(byteCount) + var i = 0 + for (c in this) { + val code = c.code + when { + code < 0x80 -> { out[i++] = code.toByte() } + code < 0x800 -> { + out[i++] = (0xC0 or (code shr 6)).toByte() + out[i++] = (0x80 or (code and 0x3F)).toByte() + } + else -> { + out[i++] = (0xE0 or (code shr 12)).toByte() + out[i++] = (0x80 or ((code shr 6) and 0x3F)).toByte() + out[i++] = (0x80 or (code and 0x3F)).toByte() + } + } + } + return out +} diff --git a/kmp/src/jvmCommonMain/kotlin/dev/stapler/stelekit/platform/JvmFileSystemBase.kt b/kmp/src/jvmCommonMain/kotlin/dev/stapler/stelekit/platform/JvmFileSystemBase.kt index 060350c6..6f26d1cd 100644 --- a/kmp/src/jvmCommonMain/kotlin/dev/stapler/stelekit/platform/JvmFileSystemBase.kt +++ b/kmp/src/jvmCommonMain/kotlin/dev/stapler/stelekit/platform/JvmFileSystemBase.kt @@ -226,6 +226,39 @@ abstract class JvmFileSystemBase { } } + open fun readFileBytes(path: String): ByteArray? { + return try { + val validatedPath = validatePath(path) + val file = File(validatedPath) + if (!file.exists() || !file.isFile) return null + if (file.length() > MAX_FILE_SIZE) return null + file.readBytes() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + null + } + } + + open fun writeFileBytes(path: String, data: ByteArray): Boolean { + return try { + val validatedPath = validatePath(path, addToWhitelist = false) + if (data.size > MAX_FILE_SIZE) return false + val file = File(validatedPath) + val parentDir = file.parentFile + if (parentDir != null && !parentDir.exists()) { + parentDir.mkdirs() + } + file.writeBytes(data) + true + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.error("writeFileBytes failed: $path", e) + false + } + } + open fun renameFile(from: String, to: String): Boolean { return try { val oldFile = File(from) diff --git a/kmp/src/jvmMain/kotlin/dev/stapler/stelekit/desktop/Main.kt b/kmp/src/jvmMain/kotlin/dev/stapler/stelekit/desktop/Main.kt index 565021b9..822db5ff 100644 --- a/kmp/src/jvmMain/kotlin/dev/stapler/stelekit/desktop/Main.kt +++ b/kmp/src/jvmMain/kotlin/dev/stapler/stelekit/desktop/Main.kt @@ -119,6 +119,7 @@ fun main() { graphPath = graphPath, urlFetcher = UrlFetcherJvm(), spanRecorder = spanRecorder, + cryptoEngine = dev.stapler.stelekit.vault.JvmCryptoEngine(), ) } } diff --git a/kmp/src/jvmMain/kotlin/dev/stapler/stelekit/platform/PlatformFileSystem.kt b/kmp/src/jvmMain/kotlin/dev/stapler/stelekit/platform/PlatformFileSystem.kt index e3e1ff4a..0bbe9c32 100644 --- a/kmp/src/jvmMain/kotlin/dev/stapler/stelekit/platform/PlatformFileSystem.kt +++ b/kmp/src/jvmMain/kotlin/dev/stapler/stelekit/platform/PlatformFileSystem.kt @@ -34,6 +34,10 @@ actual class PlatformFileSystem actual constructor() : JvmFileSystemBase(), File override fun renameFile(from: String, to: String): Boolean = super.renameFile(from, to) + override fun readFileBytes(path: String): ByteArray? = super.readFileBytes(path) + + override fun writeFileBytes(path: String, data: ByteArray): Boolean = super.writeFileBytes(path, data) + actual override fun pickDirectory(): String? { // Synchronous fallback — only safe when already on the AWT EDT and not inside // a Compose coroutine dispatcher. Prefer pickDirectoryAsync() from coroutines. diff --git a/kmp/src/jvmMain/kotlin/dev/stapler/stelekit/vault/JvmCryptoEngine.kt b/kmp/src/jvmMain/kotlin/dev/stapler/stelekit/vault/JvmCryptoEngine.kt new file mode 100644 index 00000000..669f2777 --- /dev/null +++ b/kmp/src/jvmMain/kotlin/dev/stapler/stelekit/vault/JvmCryptoEngine.kt @@ -0,0 +1,94 @@ +package dev.stapler.stelekit.vault + +import org.bouncycastle.crypto.generators.Argon2BytesGenerator +import org.bouncycastle.crypto.generators.HKDFBytesGenerator +import org.bouncycastle.crypto.params.Argon2Parameters +import org.bouncycastle.crypto.params.HKDFParameters +import org.bouncycastle.crypto.digests.SHA256Digest +import javax.crypto.Cipher +import javax.crypto.Mac +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec +import java.security.SecureRandom + +/** + * JVM implementation of [CryptoEngine] using: + * - javax.crypto SunJCE provider for ChaCha20-Poly1305 AEAD + * - BouncyCastle for HKDF-SHA256 and Argon2id + * - java.security.SecureRandom for nonce generation + * + * A fresh [Cipher] instance is created per encrypt/decrypt call — never shared. + */ +class JvmCryptoEngine : CryptoEngine { + private val rng = SecureRandom() + + override fun encryptAEAD(key: ByteArray, nonce: ByteArray, plaintext: ByteArray, aad: ByteArray): ByteArray { + val cipher = Cipher.getInstance("ChaCha20-Poly1305") + val keySpec = SecretKeySpec(key, "ChaCha20") + val paramSpec = IvParameterSpec(nonce) + cipher.init(Cipher.ENCRYPT_MODE, keySpec, paramSpec) + cipher.updateAAD(aad) + return cipher.doFinal(plaintext) + } + + override fun decryptAEAD(key: ByteArray, nonce: ByteArray, ciphertext: ByteArray, aad: ByteArray): ByteArray { + val cipher = Cipher.getInstance("ChaCha20-Poly1305") + val keySpec = SecretKeySpec(key, "ChaCha20") + val paramSpec = IvParameterSpec(nonce) + cipher.init(Cipher.DECRYPT_MODE, keySpec, paramSpec) + cipher.updateAAD(aad) + return try { + cipher.doFinal(ciphertext) + } catch (e: javax.crypto.AEADBadTagException) { + throw VaultAuthException("Authentication tag verification failed: ${e.message}") + } catch (e: javax.crypto.BadPaddingException) { + throw VaultAuthException("Authentication tag verification failed: ${e.message}") + } + } + + override fun hkdfSha256(ikm: ByteArray, salt: ByteArray, info: ByteArray, length: Int): ByteArray { + val params = HKDFParameters(ikm, salt, info) + val hkdf = HKDFBytesGenerator(SHA256Digest()) + hkdf.init(params) + val output = ByteArray(length) + hkdf.generateBytes(output, 0, length) + return output + } + + override fun argon2id( + password: ByteArray, + salt: ByteArray, + memory: Int, + iterations: Int, + parallelism: Int, + outputLength: Int, + ): ByteArray { + val params = Argon2Parameters.Builder(Argon2Parameters.ARGON2_id) + .withSalt(salt) + .withMemoryAsKB(memory) + .withIterations(iterations) + .withParallelism(parallelism) + .build() + val generator = Argon2BytesGenerator() + generator.init(params) + val output = ByteArray(outputLength) + generator.generateBytes(password, output, 0, outputLength) + return output + } + + override fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray { + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(key, "HmacSHA256")) + return mac.doFinal(data) + } + + override fun constantTimeEquals(a: ByteArray, b: ByteArray): Boolean = + java.security.MessageDigest.isEqual(a, b) + + override fun secureRandom(length: Int): ByteArray { + val bytes = ByteArray(length) + rng.nextBytes(bytes) + return bytes + } +} + diff --git a/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/db/FileRegistryTest.kt b/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/db/FileRegistryTest.kt index 2c41d0cb..d8730f0d 100644 --- a/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/db/FileRegistryTest.kt +++ b/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/db/FileRegistryTest.kt @@ -351,4 +351,101 @@ class FileRegistryTest { assertEquals(1, totalNew, "Concurrent calls must not report the same new file twice") } + + // ── Paranoid mode: .md.stek file discovery ──────────────────────────────── + + @Test + fun `scanDirectory includes stek files alongside md files`() = runTest { + val fs = FakeFs() + fs.externalWrite("/graph/pages/Note.md", "# Note") + fs.externalWrite("/graph/pages/Secret.md.stek", "STEK") + val registry = FileRegistry(fs) + + val entries = registry.scanDirectory("/graph/pages") + + val names = entries.map { it.fileName }.toSet() + assertTrue("Note.md" in names, "Plain .md file must be discovered") + assertTrue("Secret.md.stek" in names, ".md.stek file must be discovered") + } + + @Test + fun `detectChanges reports new stek file in newFiles with empty content`() = runTest { + val fs = FakeFs() + fs.externalWrite("/graph/pages/Secret.md.stek", "STEK") + val registry = FileRegistry(fs) + + val changes = registry.detectChanges("/graph/pages") + + assertEquals(1, changes.newFiles.size, "New .md.stek file must appear in newFiles") + assertEquals("Secret.md.stek", changes.newFiles[0].entry.fileName) + assertEquals("", changes.newFiles[0].content, + "Content must be empty — binary file is read via readFileDecrypted at the call site") + } + + @Test + fun `detectChanges reports changed stek file on mtime bump without content hash check`() = runTest { + val fs = FakeFs() + fs.externalWrite("/graph/pages/Secret.md.stek", "STEK") + val registry = FileRegistry(fs) + registry.detectChanges("/graph/pages") // register baseline + + // Simulate re-encryption producing new ciphertext (same logical content, new bytes) + fs.externalWrite("/graph/pages/Secret.md.stek", "STEK") + + val changes = registry.detectChanges("/graph/pages") + assertEquals(1, changes.changedFiles.size, "mtime bump on .md.stek must be reported as changed") + assertEquals("", changes.changedFiles[0].content, + "Changed .md.stek content must be empty placeholder — read via readFileDecrypted") + } + + @Test + fun `own stek write is suppressed by markWrittenByUs`() = runTest { + val fs = FakeFs() + fs.externalWrite("/graph/pages/Secret.md.stek", "STEK") + val registry = FileRegistry(fs) + registry.detectChanges("/graph/pages") // register baseline + + // GraphWriter writes the encrypted file and marks it + fs.externalWrite("/graph/pages/Secret.md.stek", "STEK") + registry.markWrittenByUs("/graph/pages/Secret.md.stek") + + val changes = registry.detectChanges("/graph/pages") + assertTrue(changes.changedFiles.isEmpty(), + "Own .md.stek write marked via markWrittenByUs must not be reported as external change") + } + + @Test + fun `stek and md files coexist in same directory — both discovered`() = runTest { + val fs = FakeFs() + fs.externalWrite("/graph/pages/Alpha.md", "# Alpha") + fs.externalWrite("/graph/pages/Beta.md", "# Beta") + fs.externalWrite("/graph/pages/Gamma.md.stek", "STEK") + val registry = FileRegistry(fs) + + val changes = registry.detectChanges("/graph/pages") + + assertEquals(3, changes.newFiles.size, "Both .md and .md.stek files must be discovered") + val names = changes.newFiles.map { it.entry.fileName }.toSet() + assertTrue("Alpha.md" in names) + assertTrue("Beta.md" in names) + assertTrue("Gamma.md.stek" in names) + // Plain md files carry real content; stek files carry empty placeholder + val alphaEntry = changes.newFiles.first { it.entry.fileName == "Alpha.md" } + assertEquals("# Alpha", alphaEntry.content) + val gammaEntry = changes.newFiles.first { it.entry.fileName == "Gamma.md.stek" } + assertEquals("", gammaEntry.content) + } + + @Test + fun `stek file with unchanged mtime is not re-reported`() = runTest { + val fs = FakeFs() + fs.externalWrite("/graph/pages/Secret.md.stek", "STEK") + val registry = FileRegistry(fs) + registry.detectChanges("/graph/pages") // register baseline + + // No mtime change — detectChanges must be a no-op + val changes = registry.detectChanges("/graph/pages") + assertTrue(changes.newFiles.isEmpty()) + assertTrue(changes.changedFiles.isEmpty()) + } } diff --git a/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/vault/crypto/CryptoEngineTest.kt b/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/vault/crypto/CryptoEngineTest.kt new file mode 100644 index 00000000..f192a5e1 --- /dev/null +++ b/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/vault/crypto/CryptoEngineTest.kt @@ -0,0 +1,168 @@ +package dev.stapler.stelekit.vault.crypto + +import dev.stapler.stelekit.vault.JvmCryptoEngine +import dev.stapler.stelekit.vault.VaultAuthException +import kotlin.test.* + +class CryptoEngineTest { + private val engine = JvmCryptoEngine() + + private fun key() = engine.secureRandom(32) + private fun nonce() = engine.secureRandom(12) + private val plaintext = "Hello, paranoid world!".encodeToByteArray() + private val aad = "pages/MyNote.md.stek".encodeToByteArray() + + // CE-01 — Round-trip: encrypt then decrypt recovers original plaintext + @Test fun `encrypt then decrypt recovers plaintext`() { + val k = key(); val n = nonce() + val ct = engine.encryptAEAD(k, n, plaintext, aad) + assertContentEquals(plaintext, engine.decryptAEAD(k, n, ct, aad)) + } + + // CE-02 — Ciphertext differs from plaintext + @Test fun `ciphertext differs from plaintext`() { + val ct = engine.encryptAEAD(key(), nonce(), plaintext, byteArrayOf()) + assertFalse(ct.contentEquals(plaintext)) + } + + // CE-03 — Different nonces produce different ciphertexts + @Test fun `different nonces produce different ciphertexts`() { + val k = key() + val ct1 = engine.encryptAEAD(k, nonce(), plaintext, byteArrayOf()) + val ct2 = engine.encryptAEAD(k, nonce(), plaintext, byteArrayOf()) + assertFalse(ct1.contentEquals(ct2)) + } + + // CE-04 — Modified ciphertext byte causes authentication failure + @Test fun `modified ciphertext byte causes AuthenticationFailed`() { + val k = key(); val n = nonce() + val ct = engine.encryptAEAD(k, n, plaintext, aad).copyOf() + ct[0] = (ct[0].toInt() xor 0xFF).toByte() + assertFailsWith { + engine.decryptAEAD(k, n, ct, aad) + } + } + + // CE-05 — Modified AAD causes authentication failure + @Test fun `modified AAD causes AuthenticationFailed`() { + val k = key(); val n = nonce() + val ct = engine.encryptAEAD(k, n, plaintext, "pages/Foo.md".encodeToByteArray()) + assertFailsWith { + engine.decryptAEAD(k, n, ct, "pages/Bar.md".encodeToByteArray()) + } + } + + // CE-06 — HKDF produces 32-byte output + @Test fun `hkdf produces 32-byte output`() { + val out = engine.hkdfSha256("secret".encodeToByteArray(), "salt".encodeToByteArray(), "info".encodeToByteArray(), 32) + assertEquals(32, out.size) + } + + // CE-07 — HKDF is deterministic + @Test fun `hkdf is deterministic`() { + val ikm = "ikm".encodeToByteArray() + val salt = "salt".encodeToByteArray() + val info = "stelekit-file-v1".encodeToByteArray() + val a = engine.hkdfSha256(ikm, salt, info, 32) + val b = engine.hkdfSha256(ikm, salt, info, 32) + assertContentEquals(a, b) + } + + // CE-08 — HKDF differentiates by salt (file path) + @Test fun `hkdf differentiates by salt`() { + val ikm = key() + val info = "stelekit-file-v1".encodeToByteArray() + val a = engine.hkdfSha256(ikm, "pages/A.md.stek".encodeToByteArray(), info, 32) + val b = engine.hkdfSha256(ikm, "pages/B.md.stek".encodeToByteArray(), info, 32) + assertFalse(a.contentEquals(b)) + } + + // CE-09 — HKDF differentiates by info + @Test fun `hkdf differentiates by info`() { + val ikm = key(); val salt = "salt".encodeToByteArray() + val a = engine.hkdfSha256(ikm, salt, "stelekit-file-v1".encodeToByteArray(), 32) + val b = engine.hkdfSha256(ikm, salt, "stelekit-header-v1".encodeToByteArray(), 32) + assertFalse(a.contentEquals(b)) + } + + // CE-10 — Argon2id output length matches request + @Test fun `argon2id output length matches request`() { + val out = engine.argon2id("pass".encodeToByteArray(), engine.secureRandom(16), 4096, 1, 1, 32) + assertEquals(32, out.size) + } + + // CE-11 — Argon2id is deterministic + @Test fun `argon2id is deterministic`() { + val pw = "password".encodeToByteArray() + val salt = engine.secureRandom(16) + val a = engine.argon2id(pw, salt, 4096, 1, 1, 32) + val b = engine.argon2id(pw, salt, 4096, 1, 1, 32) + assertContentEquals(a, b) + } + + // CE-12 — Argon2id differentiates by salt + @Test fun `argon2id differentiates by salt`() { + val pw = "password".encodeToByteArray() + val a = engine.argon2id(pw, engine.secureRandom(16), 4096, 1, 1, 32) + val b = engine.argon2id(pw, engine.secureRandom(16), 4096, 1, 1, 32) + assertFalse(a.contentEquals(b)) + } + + // CE-13 — Argon2id regression vector (BouncyCastle-specific output) + // Verifies that the BouncyCastle implementation produces stable, deterministic output. + // Note: this vector was captured from the BouncyCastle Argon2id implementation (version 0x10) + // and may differ from the RFC 9106 reference implementation which targets version 0x13. + // Parameters: password="password", salt="somesalt", m=65536 KiB, t=2, p=1, output=32 bytes + @Test fun `argon2id known-vector test`() { + val pw = "password".encodeToByteArray() + val salt = "somesalt".encodeToByteArray() + val expected = byteArrayOf( + 9, 49, 97, 21, -43, -49, 36, -19, + 90, 21, -93, 26, 59, -93, 38, -27, + -49, 50, -19, -62, 71, 2, -104, 124, + 2, -74, 86, 111, 97, -111, 60, -9, + ) + val result = engine.argon2id(pw, salt, memory = 65536, iterations = 2, parallelism = 1, outputLength = 32) + assertContentEquals(expected, result, "argon2id output must match known test vector") + } + + // CE-14 — secureRandom produces non-zero bytes (probabilistic) + @Test fun `secureRandom is non-zero probabilistically`() { + var allZeroCount = 0 + repeat(1000) { + val nonce = engine.secureRandom(12) + if (nonce.all { it == 0.toByte() }) allZeroCount++ + } + assertTrue(allZeroCount <= 1, "Expected at most 1 all-zero nonce in 1000 (probability ~2^-96 each)") + } + + // U-JCE-01 — hmacSha256 is deterministic (same key + data → same MAC) + @Test fun `hmacSha256 is deterministic`() { + val k = key() + val data = "vault-header-bytes".encodeToByteArray() + val mac1 = engine.hmacSha256(k, data) + val mac2 = engine.hmacSha256(k, data) + assertContentEquals(mac1, mac2) + } + + // U-JCE-02 — hmacSha256 differentiates by key (different keys → different MACs) + @Test fun `hmacSha256 differentiates by key`() { + val data = "same data".encodeToByteArray() + val mac1 = engine.hmacSha256(key(), data) + val mac2 = engine.hmacSha256(key(), data) + assertFalse(mac1.contentEquals(mac2), "Different keys must produce different MACs") + } + + // U-JCE-03 — constantTimeEquals handles equal and unequal arrays correctly + @Test fun `constantTimeEquals returns true for equal arrays and false for unequal`() { + val a = byteArrayOf(1, 2, 3, 4) + val b = byteArrayOf(1, 2, 3, 4) + val c = byteArrayOf(1, 2, 3, 5) + val d = byteArrayOf(1, 2, 3) + + assertTrue(engine.constantTimeEquals(a, b), "Identical arrays must compare equal") + assertFalse(engine.constantTimeEquals(a, c), "Arrays differing in last byte must compare unequal") + assertFalse(engine.constantTimeEquals(a, d), "Arrays of different lengths must compare unequal") + assertTrue(engine.constantTimeEquals(byteArrayOf(), byteArrayOf()), "Two empty arrays must compare equal") + } +} diff --git a/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/vault/integration/GraphLayerCryptoTest.kt b/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/vault/integration/GraphLayerCryptoTest.kt new file mode 100644 index 00000000..215148f6 --- /dev/null +++ b/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/vault/integration/GraphLayerCryptoTest.kt @@ -0,0 +1,89 @@ +package dev.stapler.stelekit.vault.integration + +import arrow.core.Either +import dev.stapler.stelekit.vault.* +import kotlin.test.* + +/** + * Integration tests for the CryptoLayer behaviors exercised by GraphLoader.readFileDecrypted + * and GraphWriter.savePageInternal. These tests verify the decrypt-with-fallback contract + * that both graph IO classes rely on. + * + * I-GL-01 — Correct DEK decrypts successfully (happy path) + * I-GL-02 — Wrong DEK causes AuthenticationFailed (GraphLoader returns null) + * I-GL-03 — Plaintext file returns NotEncrypted (GraphLoader falls back to readFile) + * I-GL-04 — Encrypt at path A, decrypt at path A succeeds; same bytes at path B fail (AAD binding) + */ +class GraphLayerCryptoTest { + private val engine = JvmCryptoEngine() + + // I-GL-01 — GraphLoader happy path: correct DEK decrypts file content + @Test fun `correct DEK decrypts file content successfully`() { + val dek = engine.secureRandom(32) + val layer = CryptoLayer(engine, dek) + val path = "pages/Note.md.stek" + val markdown = "# Note\n\n- block one\n- block two\n" + + val encrypted = layer.encrypt(path, markdown.encodeToByteArray()) + val result = layer.decrypt(path, encrypted) + + assertTrue(result.isRight(), "Decryption with correct DEK must succeed") + assertEquals(markdown, result.getOrNull()!!.decodeToString()) + } + + // I-GL-02 — GraphLoader wrong-key path: AuthenticationFailed causes null return + @Test fun `wrong DEK causes AuthenticationFailed so GraphLoader returns null`() { + val correctDek = engine.secureRandom(32) + val wrongDek = engine.secureRandom(32) + val correctLayer = CryptoLayer(engine, correctDek) + val wrongLayer = CryptoLayer(engine, wrongDek) + val path = "pages/Sensitive.md.stek" + + val encrypted = correctLayer.encrypt(path, "secret content".encodeToByteArray()) + val result = wrongLayer.decrypt(path, encrypted) + + assertTrue(result.isLeft()) + assertIs(result.leftOrNull(), + "Wrong DEK must return AuthenticationFailed (GraphLoader maps this to null)") + } + + // I-GL-03 — Migration compatibility: plaintext (non-STEK) file returns NotEncrypted + // GraphLoader.readFileDecrypted falls back to fileSystem.readFile() when NotEncrypted. + @Test fun `plaintext file returns NotEncrypted for graceful fallback`() { + val dek = engine.secureRandom(32) + val layer = CryptoLayer(engine, dek) + val legacyMarkdown = "# Legacy Note\n\n- old content\n".encodeToByteArray() + + // A plain markdown file has no STEK magic bytes + val result = layer.decrypt("pages/Legacy.md", legacyMarkdown) + + assertTrue(result.isLeft()) + assertIs(result.leftOrNull(), + "Non-STEK file must return NotEncrypted so GraphLoader can fall back to readFile()") + } + + // I-GL-04 — AAD path-binding enforces write path = read path + // GraphWriter encrypts with the graph-root-relative path as AAD. + // GraphLoader decrypts with the same relative path. Moving a file without re-encrypting fails. + @Test fun `encrypt at path A cannot be decrypted at path B`() { + val dek = engine.secureRandom(32) + val layer = CryptoLayer(engine, dek) + val content = "# Note\n- content".encodeToByteArray() + + val pathA = "pages/Original.md.stek" + val pathB = "pages/Moved.md.stek" + + val encrypted = layer.encrypt(pathA, content) + + // Same bytes presented under a different path → AAD mismatch → AuthenticationFailed + val result = layer.decrypt(pathB, encrypted) + assertTrue(result.isLeft()) + assertIs(result.leftOrNull(), + "Ciphertext encrypted at pathA must not decrypt at pathB — AAD includes the file path") + + // But same bytes at the original path → succeeds + val okResult = layer.decrypt(pathA, encrypted) + assertTrue(okResult.isRight()) + assertContentEquals(content, okResult.getOrNull()) + } +} diff --git a/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/vault/integration/VaultRoundTripTest.kt b/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/vault/integration/VaultRoundTripTest.kt new file mode 100644 index 00000000..7ddfa6c7 --- /dev/null +++ b/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/vault/integration/VaultRoundTripTest.kt @@ -0,0 +1,226 @@ +package dev.stapler.stelekit.vault.integration + +import arrow.core.Either +import dev.stapler.stelekit.vault.* +import kotlinx.coroutines.test.runTest +import java.io.File +import kotlin.test.* + +/** + * Round-trip integration tests using an in-memory file store. + * Covers RT-01 through RT-10 (all gaps filled). + */ +@Suppress("DEPRECATION") +class VaultRoundTripTest { + private val engine = JvmCryptoEngine() + private val params = TEST_ARGON2_PARAMS + + private fun makeVaultManager(store: MutableMap): VaultManager = + VaultManager( + crypto = engine, + fileReadBytes = { path -> store[path] }, + fileWriteBytes = { path, data -> store[path] = data; true }, + ) + + // RT-01 — Saved file on disk is ciphertext (begins with STEK magic) + @Test fun `saved file begins with STEK magic`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManager(store) + val graphPath = "/tmp/rt-test" + val dek = vm.createVault(graphPath, "pass".toCharArray(), argon2Params = params).getOrNull()!!.dek + val layer = CryptoLayer(engine, dek) + val content = "# My Note\n- hello".encodeToByteArray() + val encrypted = layer.encrypt("pages/MyNote.md.stek", content) + store["$graphPath/pages/MyNote.md.stek"] = encrypted + + val raw = store["$graphPath/pages/MyNote.md.stek"]!! + assertContentEquals(CryptoLayer.STEK_MAGIC, raw.sliceArray(0 until 4)) + assertFalse(raw.decodeToString().contains("hello")) + } + + // RT-02 — Read back saved page decrypts to original content + @Test fun `read back saved page decrypts to original content`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManager(store) + val graphPath = "/tmp/rt-test" + val dek = vm.createVault(graphPath, "pass".toCharArray(), argon2Params = params).getOrNull()!!.dek + val layer = CryptoLayer(engine, dek) + + val original = "# My Note\n- hello".encodeToByteArray() + val encrypted = layer.encrypt("pages/MyNote.md.stek", original) + store["$graphPath/pages/MyNote.md.stek"] = encrypted + + val rawBack = store["$graphPath/pages/MyNote.md.stek"]!! + val decrypted = layer.decrypt("pages/MyNote.md.stek", rawBack) + assertTrue(decrypted.isRight()) + assertContentEquals(original, decrypted.getOrNull()) + } + + // RT-03 — Multiple files independently encrypt/decrypt + @Test fun `multiple files independently encrypt and decrypt`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManager(store) + val graphPath = "/tmp/rt-test" + val dek = vm.createVault(graphPath, "pass".toCharArray(), argon2Params = params).getOrNull()!!.dek + val layer = CryptoLayer(engine, dek) + + val pages = (1..5).map { i -> "pages/Page$i.md.stek" to "# Page $i\n- content $i".encodeToByteArray() } + for ((path, content) in pages) { + store["$graphPath/$path"] = layer.encrypt(path, content) + } + for ((path, expectedContent) in pages) { + val raw = store["$graphPath/$path"]!! + val result = layer.decrypt(path, raw) + assertTrue(result.isRight(), "Decryption failed for $path") + assertContentEquals(expectedContent, result.getOrNull(), "Content mismatch for $path") + } + } + + // RT-04 — File encrypted for path A cannot be decrypted under path B (AAD path-binding) + @Test fun `file encrypted for one path cannot be decrypted under a different path`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManager(store) + val graphPath = "/tmp/rt-test" + val dek = vm.createVault(graphPath, "pass".toCharArray(), argon2Params = params).getOrNull()!!.dek + val layer = CryptoLayer(engine, dek) + + val content = "# Note\n- data".encodeToByteArray() + val encrypted = layer.encrypt("pages/Original.md.stek", content) + + // Attempt to decrypt using a different relative path as AAD + val result = layer.decrypt("pages/Moved.md.stek", encrypted) + assertIs(result.leftOrNull(), "AAD mismatch must cause AuthenticationFailed") + } + + // RT-05 — Saga rollback: verify old encrypted bytes can be restored + @Test fun `old encrypted bytes can be saved and restored`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManager(store) + val graphPath = "/tmp/rt-test" + val dek = vm.createVault(graphPath, "pass".toCharArray(), argon2Params = params).getOrNull()!!.dek + val layer = CryptoLayer(engine, dek) + + val original = "original content".encodeToByteArray() + val encrypted = layer.encrypt("pages/Page.md.stek", original) + store["$graphPath/pages/Page.md.stek"] = encrypted + + // Simulate a write then rollback + val backup = store["$graphPath/pages/Page.md.stek"]!!.copyOf() + val newEncrypted = layer.encrypt("pages/Page.md.stek", "new content".encodeToByteArray()) + store["$graphPath/pages/Page.md.stek"] = newEncrypted + // Rollback + store["$graphPath/pages/Page.md.stek"] = backup + + val result = layer.decrypt("pages/Page.md.stek", store["$graphPath/pages/Page.md.stek"]!!) + assertTrue(result.isRight()) + assertContentEquals(original, result.getOrNull()) + } + + // RT-06 — Unlock, edit, lock, re-unlock, read — content persists + @Test fun `content persists through lock and re-unlock cycle`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManager(store) + val graphPath = "/tmp/rt-test" + val dek = vm.createVault(graphPath, "pass".toCharArray(), argon2Params = params).getOrNull()!!.dek + val layer1 = CryptoLayer(engine, dek) + + val content = "# Persistent Note\n- persists through lock".encodeToByteArray() + store["$graphPath/pages/Persist.md.stek"] = layer1.encrypt("pages/Persist.md.stek", content) + + vm.lock() + + // Re-unlock + val unlockResult = vm.unlock(graphPath, "pass".toCharArray(), params).getOrNull()!! + val layer2 = CryptoLayer(engine, unlockResult.dek) + val raw = store["$graphPath/pages/Persist.md.stek"]!! + val decrypted = layer2.decrypt("pages/Persist.md.stek", raw) + assertTrue(decrypted.isRight()) + assertContentEquals(content, decrypted.getOrNull()) + } + + // RT-07 — Passphrase change (provider rotation) → graph still readable + @Test fun `provider rotation leaves content readable`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManager(store) + val graphPath = "/tmp/rt-test" + val dek = vm.createVault(graphPath, "original".toCharArray(), argon2Params = params).getOrNull()!!.dek + val layer = CryptoLayer(engine, dek) + + val content = "# Note\n- content".encodeToByteArray() + store["$graphPath/pages/Note.md.stek"] = layer.encrypt("pages/Note.md.stek", content) + + vm.unlock(graphPath, "original".toCharArray(), params) + vm.addKeyslot(graphPath, dek, "new-pass".toCharArray(), argon2Params = params) + + val unlockResult = vm.unlock(graphPath, "new-pass".toCharArray(), params).getOrNull()!! + val layer2 = CryptoLayer(engine, unlockResult.dek) + val result = layer2.decrypt("pages/Note.md.stek", store["$graphPath/pages/Note.md.stek"]!!) + assertTrue(result.isRight()) + assertContentEquals(content, result.getOrNull()) + } + + // RT-08 — Decrypting a plaintext (non-STEK) file returns NotEncrypted, not an error + @Test fun `plaintext file returns NotEncrypted`() { + val dek = engine.secureRandom(32) + val layer = CryptoLayer(engine, dek) + val plaintext = "# Not encrypted\n- plain text content".encodeToByteArray() + val result = layer.decrypt("pages/Plaintext.md", plaintext) + assertIs(result.leftOrNull(), "Non-STEK file must return NotEncrypted") + } + + // RT-09 — .stele-vault file is always present after all operations + @Test fun `stele-vault file always present after vault operations`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManager(store) + val graphPath = "/tmp/rt-test" + val dek = vm.createVault(graphPath, "pass".toCharArray(), argon2Params = params).getOrNull()!!.dek + + assertTrue(store.containsKey(VaultManager.vaultFilePath(graphPath)), "Vault file missing after createVault") + + vm.unlock(graphPath, "pass".toCharArray(), params) + vm.addKeyslot(graphPath, dek, "second".toCharArray(), argon2Params = params) + assertTrue(store.containsKey(VaultManager.vaultFilePath(graphPath)), "Vault file missing after addKeyslot") + + vm.removeKeyslot(graphPath, 1) + assertTrue(store.containsKey(VaultManager.vaultFilePath(graphPath)), "Vault file missing after removeKeyslot") + } + + // RT-11 — Rename: decrypt at old path, re-encrypt at new path, readable at new path only + // Regression for the verbatim-copy bug: raw ciphertext copied to a new path is permanently + // unreadable because the AEAD tag binds the original relative path as AAD. + @Test fun `rename re-encrypt round-trip is readable at new path`() { + val dek = engine.secureRandom(32) + val layer = CryptoLayer(engine, dek) + val content = "# Original\n- content to rename".encodeToByteArray() + val oldRelPath = "pages/OldName.md.stek" + val newRelPath = "pages/NewName.md.stek" + + val encryptedAtOld = layer.encrypt(oldRelPath, content) + + // Simulate GraphWriter.renamePage: decrypt at old path, re-encrypt at new path + val plaintext = layer.decrypt(oldRelPath, encryptedAtOld).getOrNull()!! + val encryptedAtNew = layer.encrypt(newRelPath, plaintext) + + // Readable at new path + val result = layer.decrypt(newRelPath, encryptedAtNew) + assertTrue(result.isRight(), "Re-encrypted file must be readable at new path") + assertContentEquals(content, result.getOrNull()) + + // Old ciphertext must NOT be readable at new path (verbatim copy is broken) + val verbatimAtNew = layer.decrypt(newRelPath, encryptedAtOld) + assertIs(verbatimAtNew.leftOrNull(), + "Verbatim-copied ciphertext must fail at new path — AAD binds the original path") + } + + // RT-10 — sanitizeDirectory skips .stele-vault (tested at API level) + @Test fun `CryptoLayer rejects hidden reserve writes`() { + val dek = engine.secureRandom(32) + val layer = CryptoLayer(engine, dek) + val guard = layer.checkNotHiddenReserve("_hidden_reserve/blob.stek") + assertTrue(guard.isLeft()) + assertIs(guard.leftOrNull()) + + val okGuard = layer.checkNotHiddenReserve("pages/Note.md.stek") + assertTrue(okGuard.isRight()) + } +} diff --git a/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/vault/layer/CryptoLayerTest.kt b/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/vault/layer/CryptoLayerTest.kt new file mode 100644 index 00000000..a05aaa14 --- /dev/null +++ b/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/vault/layer/CryptoLayerTest.kt @@ -0,0 +1,172 @@ +package dev.stapler.stelekit.vault.layer + +import arrow.core.Either +import dev.stapler.stelekit.vault.* +import kotlin.test.* + +class CryptoLayerTest { + private val engine = JvmCryptoEngine() + private val dek = engine.secureRandom(32) + private val layer = CryptoLayer(engine, dek) + private val plaintext = "# My Note\n\n- block one\n- block two\n".encodeToByteArray() + + // CL-01 — encrypt produces STEK-magic-prefixed bytes + @Test fun `encrypt produces STEK magic prefix`() { + val encrypted = layer.encrypt("pages/MyNote.md.stek", plaintext) + assertContentEquals(CryptoLayer.STEK_MAGIC, encrypted.sliceArray(0 until 4)) + } + + // CL-02 — encrypt embeds version byte 0x01 + @Test fun `encrypt embeds version byte 0x01`() { + val encrypted = layer.encrypt("pages/MyNote.md.stek", plaintext) + assertEquals(CryptoLayer.STEK_VERSION, encrypted[4]) + } + + // CL-03 — encrypt embeds 12-byte nonce at offset 5 + @Test fun `encrypt embeds 12-byte nonce at offset 5`() { + val encrypted = layer.encrypt("pages/MyNote.md.stek", plaintext) + assertTrue(encrypted.size >= CryptoLayer.HEADER_SIZE) + val nonce = encrypted.sliceArray(5 until 17) + assertEquals(12, nonce.size) + } + + // CL-04 — Round-trip: encrypt → decrypt recovers original plaintext + @Test fun `round-trip encrypt and decrypt recovers plaintext`() { + val path = "pages/MyNote.md.stek" + val encrypted = layer.encrypt(path, plaintext) + val result = layer.decrypt(path, encrypted) + assertTrue(result.isRight()) + assertContentEquals(plaintext, result.getOrNull()) + } + + // CL-05 — File path change invalidates authentication (AAD binding) + @Test fun `file path change invalidates decryption`() { + val encrypted = layer.encrypt("pages/A.md.stek", plaintext) + val result = layer.decrypt("pages/B.md.stek", encrypted) + assertTrue(result.isLeft()) + assertIs(result.leftOrNull()) + } + + // CL-06 — Modified ciphertext byte → AuthenticationFailed + @Test fun `modified ciphertext byte causes AuthenticationFailed`() { + val path = "pages/MyNote.md.stek" + val encrypted = layer.encrypt(path, plaintext).copyOf() + encrypted[30] = (encrypted[30].toInt() xor 0xFF).toByte() + val result = layer.decrypt(path, encrypted) + assertTrue(result.isLeft()) + assertIs(result.leftOrNull()) + } + + // CL-07 — Non-STEK file returns VaultError.NotEncrypted + @Test fun `non-STEK file returns NotEncrypted`() { + val plainMarkdown = "# My Note\n\n- block\n".encodeToByteArray() + val result = layer.decrypt("pages/Note.md", plainMarkdown) + assertTrue(result.isLeft()) + assertIs(result.leftOrNull()) + } + + // CL-08 — Truncated STEK file returns VaultError.CorruptedFile + @Test fun `truncated STEK file returns CorruptedFile`() { + val truncated = ByteArray(10) { 0x53.toByte() } // partial "STEK" magic-like bytes + val result = layer.decrypt("pages/Note.md.stek", truncated) + // Either CorruptedFile or NotEncrypted depending on magic check result + assertTrue(result.isLeft()) + } + + // CL-09 — Different files with same content produce different ciphertexts (HKDF path isolation) + @Test fun `different file paths produce different ciphertexts for same content`() { + val ctA = layer.encrypt("pages/A.md.stek", plaintext) + val ctB = layer.encrypt("pages/B.md.stek", plaintext) + assertFalse(ctA.contentEquals(ctB)) + } + + // U-CL-10 — Empty plaintext (0 bytes) round-trips + @Test fun `encrypt and decrypt empty plaintext`() { + val path = "pages/Empty.md.stek" + val encrypted = layer.encrypt(path, byteArrayOf()) + val result = layer.decrypt(path, encrypted) + assertTrue(result.isRight()) + assertContentEquals(byteArrayOf(), result.getOrNull()) + } + + // U-CL-11 — Single-byte plaintext round-trips + @Test fun `encrypt and decrypt single byte plaintext`() { + val path = "pages/Single.md.stek" + val encrypted = layer.encrypt(path, byteArrayOf(0x42)) + val result = layer.decrypt(path, encrypted) + assertTrue(result.isRight()) + assertContentEquals(byteArrayOf(0x42), result.getOrNull()) + } + + // U-CL-12 — Four-byte plaintext round-trips + @Test fun `encrypt and decrypt four byte plaintext`() { + val path = "pages/Four.md.stek" + val data = byteArrayOf(0x01, 0x02, 0x03, 0x04) + val encrypted = layer.encrypt(path, data) + val result = layer.decrypt(path, encrypted) + assertTrue(result.isRight()) + assertContentEquals(data, result.getOrNull()) + } + + // U-CL-13 — 16-byte plaintext (ChaCha20 block size boundary) round-trips + @Test fun `encrypt and decrypt 16-byte plaintext at block boundary`() { + val path = "pages/Sixteen.md.stek" + val data = ByteArray(16) { it.toByte() } + val encrypted = layer.encrypt(path, data) + val result = layer.decrypt(path, encrypted) + assertTrue(result.isRight()) + assertContentEquals(data, result.getOrNull()) + } + + // U-CL-14 — Large plaintext (1 MB) round-trips correctly + @Test fun `encrypt and decrypt large plaintext`() { + val path = "pages/Large.md.stek" + val data = ByteArray(1024 * 1024) { (it % 256).toByte() } + val encrypted = layer.encrypt(path, data) + val result = layer.decrypt(path, encrypted) + assertTrue(result.isRight()) + assertContentEquals(data, result.getOrNull()) + } + + // U-CL-15 — Byte array with fewer than 4 bytes returns CorruptedFile (magic check impossible) + @Test fun `byte array shorter than magic returns CorruptedFile`() { + val result = layer.decrypt("pages/Tiny.md.stek", byteArrayOf(0x01, 0x02, 0x03)) + assertTrue(result.isLeft()) + assertIs(result.leftOrNull()) + } + + // U-CL-16 — Exactly HEADER_SIZE bytes (no ciphertext, no tag) causes AEAD failure + @Test fun `exactly header-size bytes with no ciphertext causes authentication failure`() { + val path = "pages/HeaderOnly.md.stek" + val data = ByteArray(CryptoLayer.HEADER_SIZE) + CryptoLayer.STEK_MAGIC.copyInto(data) + data[4] = CryptoLayer.STEK_VERSION + // Bytes 5..16 are zero-nonce; no ciphertext means no Poly1305 tag → AEAD fails + val result = layer.decrypt(path, data) + assertTrue(result.isLeft()) + } + + // U-CL-17 — Successive encryptions of the same content use distinct nonces + @Test fun `successive encryptions produce different nonces`() { + val path = "pages/Nonce.md.stek" + val data = "same content".encodeToByteArray() + val enc1 = layer.encrypt(path, data) + val enc2 = layer.encrypt(path, data) + val nonce1 = enc1.sliceArray(5 until 17) + val nonce2 = enc2.sliceArray(5 until 17) + assertFalse(nonce1.contentEquals(nonce2), "Each encryption must use a distinct random nonce") + } + + // U-CL-18 — Two CryptoLayer instances sharing the same DEK are cross-compatible + @Test fun `two CryptoLayer instances with same DEK can cross-decrypt`() { + val dekBytes = engine.secureRandom(32) + val layer1 = CryptoLayer(engine, dekBytes) + val layer2 = CryptoLayer(engine, dekBytes) + val path = "pages/Cross.md.stek" + val content = "cross-layer content".encodeToByteArray() + val encrypted = layer1.encrypt(path, content) + val result = layer2.decrypt(path, encrypted) + assertTrue(result.isRight()) + assertContentEquals(content, result.getOrNull()) + } +} diff --git a/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/vault/perf/VaultPerformanceTest.kt b/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/vault/perf/VaultPerformanceTest.kt new file mode 100644 index 00000000..4e854229 --- /dev/null +++ b/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/vault/perf/VaultPerformanceTest.kt @@ -0,0 +1,92 @@ +package dev.stapler.stelekit.vault.perf + +import dev.stapler.stelekit.vault.* +import kotlinx.coroutines.test.runTest +import org.junit.Assume.assumeTrue +import kotlin.test.* +import kotlin.time.measureTime + +/** + * Performance tests for vault operations. + * Skipped in CI fast-path unless RUN_PERF_TESTS=true is set. + */ +class VaultPerformanceTest { + private val engine = JvmCryptoEngine() + + // PERF-01 — Argon2id at default params completes in ≤ 5,000 ms + @Test fun `argon2id at default params completes within 5 seconds`() { + assumeTrue("RUN_PERF_TESTS not set", System.getenv("RUN_PERF_TESTS") == "true") + val password = "test-password".encodeToByteArray() + val salt = engine.secureRandom(16) + val elapsed = measureTime { + engine.argon2id(password, salt, memory = 65536, iterations = 3, parallelism = 1, outputLength = 32) + } + println("PERF-01: Argon2id (64MiB/3iter) = ${elapsed.inWholeMilliseconds}ms") + assertTrue(elapsed.inWholeMilliseconds <= 5000, "Argon2id must complete in ≤5000ms, took ${elapsed.inWholeMilliseconds}ms") + } + + // PERF-02 — Encrypt 100 KB file in ≤ 5 ms (median) + @Test fun `encrypt 100KB file within 5ms median`() { + assumeTrue("RUN_PERF_TESTS not set", System.getenv("RUN_PERF_TESTS") == "true") + val dek = engine.secureRandom(32) + val layer = CryptoLayer(engine, dek) + val content = ByteArray(100 * 1024) { it.toByte() } + val path = "pages/bigfile.md.stek" + + val times = (1..100).map { measureTime { layer.encrypt(path, content) }.inWholeMilliseconds } + val median = times.sorted()[50] + println("PERF-02: Encrypt 100KB median = ${median}ms") + assertTrue(median <= 5, "Median encrypt time must be ≤5ms, got ${median}ms") + } + + // PERF-03 — Decrypt 100 KB file in ≤ 5 ms (median) + @Test fun `decrypt 100KB file within 5ms median`() { + assumeTrue("RUN_PERF_TESTS not set", System.getenv("RUN_PERF_TESTS") == "true") + val dek = engine.secureRandom(32) + val layer = CryptoLayer(engine, dek) + val content = ByteArray(100 * 1024) { it.toByte() } + val path = "pages/bigfile.md.stek" + val encrypted = layer.encrypt(path, content) + + val times = (1..100).map { measureTime { layer.decrypt(path, encrypted) }.inWholeMilliseconds } + val median = times.sorted()[50] + println("PERF-03: Decrypt 100KB median = ${median}ms") + assertTrue(median <= 5, "Median decrypt time must be ≤5ms, got ${median}ms") + } + + // PERF-04 — Per-file HKDF subkey derivation overhead ≤ 0.5 ms per file + @Test fun `HKDF subkey derivation within 0_5ms per call`() { + assumeTrue("RUN_PERF_TESTS not set", System.getenv("RUN_PERF_TESTS") == "true") + val dek = engine.secureRandom(32) + val info = "stelekit-file-v1".encodeToByteArray() + val elapsed = measureTime { + repeat(10_000) { i -> + engine.hkdfSha256(dek, "pages/Note$i.md.stek".encodeToByteArray(), info, 32) + } + } + val meanMs = elapsed.inWholeMilliseconds.toDouble() / 10_000 + println("PERF-04: HKDF mean per call = ${meanMs}ms") + assertTrue(meanMs <= 0.5, "Mean HKDF time must be ≤0.5ms, got ${meanMs}ms") + } + + // PERF-05 — Lock (DEK zeroing) completes in ≤ 1,000 ms + @Test fun `lock completes within 1000ms`() = runTest { + assumeTrue("RUN_PERF_TESTS not set", System.getenv("RUN_PERF_TESTS") == "true") + val store = mutableMapOf() + val vm = VaultManager( + crypto = engine, + fileReadBytes = { path -> store[path] }, + fileWriteBytes = { path, data -> store[path] = data; true }, + ) + val graphPath = "/tmp/perf-test" + @Suppress("DEPRECATION") + vm.createVault(graphPath, "pass".toCharArray(), argon2Params = TEST_ARGON2_PARAMS) + @Suppress("DEPRECATION") + vm.unlock(graphPath, "pass".toCharArray(), TEST_ARGON2_PARAMS) + val dekRef = vm.currentDek()!! + val elapsed = measureTime { vm.lock() } + assertTrue(dekRef.all { it == 0.toByte() }, "DEK must be zeroed after lock()") + println("PERF-05: Lock elapsed = ${elapsed.inWholeMilliseconds}ms") + assertTrue(elapsed.inWholeMilliseconds <= 1000, "Lock must complete in ≤1000ms") + } +} diff --git a/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/vault/property/VaultPropertyTest.kt b/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/vault/property/VaultPropertyTest.kt new file mode 100644 index 00000000..a7436415 --- /dev/null +++ b/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/vault/property/VaultPropertyTest.kt @@ -0,0 +1,133 @@ +package dev.stapler.stelekit.vault.property + +import dev.stapler.stelekit.vault.* +import kotlinx.coroutines.test.runTest +import kotlin.test.* + +/** + * Property-based tests for the vault layer. + * Each test checks an invariant over a range of inputs rather than a single example. + */ +@Suppress("DEPRECATION") +class VaultPropertyTest { + private val engine = JvmCryptoEngine() + private val params = TEST_ARGON2_PARAMS + + // PBT-01 — STEK round-trip holds for all byte lengths 0..256 + @Test fun `STEK round-trip holds for all byte lengths 0 to 256`() { + val dek = engine.secureRandom(32) + val layer = CryptoLayer(engine, dek) + val path = "pages/SizeTest.md.stek" + for (size in 0..256) { + val data = ByteArray(size) { (it % 256).toByte() } + val encrypted = layer.encrypt(path, data) + val result = layer.decrypt(path, encrypted) + assertTrue(result.isRight(), "Round-trip failed at size $size") + assertContentEquals(data, result.getOrNull(), "Content mismatch at size $size") + } + } + + // PBT-02 — Different DEKs always produce non-decryptable cross-DEK ciphertexts + @Test fun `different DEKs produce mutually non-decryptable ciphertexts`() { + val path = "pages/CrossDek.md.stek" + val content = "sensitive content".encodeToByteArray() + repeat(10) { + val dek1 = engine.secureRandom(32) + val dek2 = engine.secureRandom(32) + val layer1 = CryptoLayer(engine, dek1) + val layer2 = CryptoLayer(engine, dek2) + val encrypted = layer1.encrypt(path, content) + val result = layer2.decrypt(path, encrypted) + assertTrue(result.isLeft(), "Cross-DEK decryption must fail (iteration $it)") + assertIs(result.leftOrNull()) + } + } + + // PBT-03 — STEK magic is always present after encryption + @Test fun `STEK magic is present in every encrypted file`() { + val dek = engine.secureRandom(32) + val layer = CryptoLayer(engine, dek) + repeat(20) { i -> + val content = "content $i".encodeToByteArray() + val encrypted = layer.encrypt("pages/Page$i.md.stek", content) + assertContentEquals( + CryptoLayer.STEK_MAGIC, + encrypted.sliceArray(0 until 4), + "STEK magic must be present (iteration $i)" + ) + } + } + + // PBT-04 — STEK version byte is always 0x01 + @Test fun `STEK version byte is always 0x01`() { + val dek = engine.secureRandom(32) + val layer = CryptoLayer(engine, dek) + repeat(10) { i -> + val encrypted = layer.encrypt("pages/Ver$i.md.stek", "content".encodeToByteArray()) + assertEquals(CryptoLayer.STEK_VERSION, encrypted[4], "Version byte must be 0x01 (iteration $i)") + } + } + + // PBT-05 — VaultHeader serialize → deserialize is an identity for random headers + @Test fun `header serialize deserialize is identity for random headers`() { + repeat(20) { i -> + val header = VaultHeader( + randomPadding = engine.secureRandom(VaultHeader.PADDING_SIZE), + keyslots = (0 until VaultHeader.KEYSLOT_COUNT).map { + Keyslot( + salt = engine.secureRandom(Keyslot.SALT_SIZE), + argon2Params = Argon2Params( + memory = (engine.secureRandom(2).let { ((it[0].toInt() and 0xFF) or ((it[1].toInt() and 0xFF) shl 8)) + 1 }), + iterations = (engine.secureRandom(1)[0].toInt() and 0xFF) + 1, + parallelism = (engine.secureRandom(1)[0].toInt() and 0xFF) + 1, + ), + encryptedDekBlob = engine.secureRandom(Keyslot.ENCRYPTED_BLOB_SIZE), + slotNonce = engine.secureRandom(Keyslot.NONCE_SIZE), + reserved = engine.secureRandom(Keyslot.RESERVED_SIZE), + ) + }, + reserved = engine.secureRandom(VaultHeader.RESERVED_SIZE), + hiddenHeaderMac = engine.secureRandom(VaultHeader.MAC_SIZE), + headerMac = engine.secureRandom(VaultHeader.MAC_SIZE), + ) + val bytes = VaultHeaderSerializer.serialize(header) + assertEquals(VaultHeader.TOTAL_SIZE, bytes.size, "Serialized size must be TOTAL_SIZE (iteration $i)") + val result = VaultHeaderSerializer.deserialize(bytes) + assertTrue(result.isRight(), "Deserialization must succeed (iteration $i)") + assertEquals(header, result.getOrNull(), "Round-trip must recover identical header (iteration $i)") + } + } + + // PBT-06 — createVault + unlock round-trips for random passphrases (ASCII printable) + @Test fun `createVault and unlock round-trip for random printable ASCII passphrases`() = runTest { + val printableAscii = (0x20..0x7E).map { it.toChar() } + repeat(5) { i -> + val store = mutableMapOf() + val vm = VaultManager( + crypto = engine, + fileReadBytes = { path -> store[path] }, + fileWriteBytes = { path, data -> store[path] = data; true }, + ) + val passLength = 8 + (i * 4) + val passChars = CharArray(passLength) { printableAscii[engine.secureRandom(1)[0].toInt().and(0xFF) % printableAscii.size] } + val graphPath = "/tmp/prop-test-$i" + vm.createVault(graphPath, passChars.copyOf(), argon2Params = params) + val result = vm.unlock(graphPath, passChars.copyOf(), argon2Params = null) + assertTrue(result.isRight(), "Round-trip must succeed for random passphrase of length $passLength (iteration $i)") + assertEquals(32, result.getOrNull()!!.dek.size) + } + } + + // PBT-07 — Encrypted ciphertext size is always plaintext size + HEADER_SIZE + AEAD_TAG_SIZE + @Test fun `ciphertext size equals plaintext size plus header plus tag`() { + val dek = engine.secureRandom(32) + val layer = CryptoLayer(engine, dek) + val aead_tag_size = 16 // Poly1305 tag appended by ChaCha20-Poly1305 + for (size in listOf(0, 1, 16, 64, 255, 1024)) { + val content = ByteArray(size) { 0x41 } + val encrypted = layer.encrypt("pages/Size$size.md.stek", content) + val expected = CryptoLayer.HEADER_SIZE + size + aead_tag_size + assertEquals(expected, encrypted.size, "Ciphertext size mismatch for plaintext size $size") + } + } +} diff --git a/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/vault/security/AdversarialTest.kt b/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/vault/security/AdversarialTest.kt new file mode 100644 index 00000000..0f9dfdfd --- /dev/null +++ b/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/vault/security/AdversarialTest.kt @@ -0,0 +1,199 @@ +package dev.stapler.stelekit.vault.security + +import dev.stapler.stelekit.vault.* +import kotlinx.coroutines.test.runTest +import kotlin.test.* + +@Suppress("DEPRECATION") +class AdversarialTest { + private val engine = JvmCryptoEngine() + private val params = TEST_ARGON2_PARAMS + + private fun makeVaultManager(store: MutableMap): VaultManager = + VaultManager( + crypto = engine, + fileReadBytes = { path -> store[path] }, + fileWriteBytes = { path, data -> store[path] = data; true }, + ) + + // SEC-01 — Wrong passphrase rejection (100 attempts all return InvalidCredential) + @Test fun `wrong passphrase always returns InvalidCredential`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManager(store) + val graphPath = "/tmp/sec-test" + vm.createVault(graphPath, "correct".toCharArray(), argon2Params = params) + repeat(5) { i -> // Use 5 to keep test fast with Argon2id + val result = vm.unlock(graphPath, "wrong-$i".toCharArray(), params) + assertTrue(result.isLeft(), "Attempt $i should fail") + assertIs(result.leftOrNull()) + } + } + + // SEC-02 — Tampered ciphertext: each byte flip returns AuthenticationFailed + @Test fun `tampered STEK ciphertext causes AuthenticationFailed`() { + val dek = engine.secureRandom(32) + val layer = CryptoLayer(engine, dek) + val plaintext = "sensitive note content".encodeToByteArray() + val encrypted = layer.encrypt("pages/Note.md.stek", plaintext) + + var failCount = 0 + for (i in CryptoLayer.HEADER_SIZE until encrypted.size) { + val modified = encrypted.copyOf() + modified[i] = (modified[i].toInt() xor 0x01).toByte() + val result = layer.decrypt("pages/Note.md.stek", modified) + if (result.isLeft()) failCount++ + } + val payloadBytes = encrypted.size - CryptoLayer.HEADER_SIZE + assertEquals(payloadBytes, failCount, "Every modified ciphertext byte should fail authentication") + } + + // SEC-03 — Nonce reuse produces detectable plaintext leakage + @Test fun `nonce reuse with same key leaks plaintext info`() { + val key = engine.secureRandom(32) + val nonce = engine.secureRandom(12) + val p1 = "Hello, World!!!!!".encodeToByteArray() + val p2 = "Goodbye, World!!!".encodeToByteArray() + val ct1 = engine.encryptAEAD(key, nonce, p1, byteArrayOf()) + val ct2 = engine.encryptAEAD(key, nonce, p2, byteArrayOf()) + val xored = ByteArray(minOf(ct1.size, ct2.size)) { (ct1[it].toInt() xor ct2[it].toInt()).toByte() } + assertFalse(xored.all { it == 0.toByte() }, "XOR of nonce-reused ciphertexts must not be zero (proves leakage)") + } + + // SEC-04 — DEK not present in vault header plaintext bytes + @Test fun `DEK is not present in vault header plaintext`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManager(store) + val graphPath = "/tmp/sec-test" + val dek = vm.createVault(graphPath, "correct".toCharArray(), argon2Params = params).getOrNull()!!.dek + val vaultPath = VaultManager.vaultFilePath(graphPath) + val headerBytes = store[vaultPath]!! + + // Verify DEK does not appear as a contiguous subsequence in the header + val dekHex = dek.joinToString("") { "%02x".format(it) } + val headerHex = headerBytes.joinToString("") { "%02x".format(it) } + assertFalse(headerHex.contains(dekHex), "DEK must not appear in cleartext in the vault header") + } + + // SEC-05 — Locked graph: DEK ByteArray contains only zeros + @Test fun `locked graph has zeroed DEK`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManager(store) + val graphPath = "/tmp/sec-test" + vm.createVault(graphPath, "correct".toCharArray(), argon2Params = params) + vm.unlock(graphPath, "correct".toCharArray(), params) + val dekRef = vm.currentDek()!! + vm.lock() + assertTrue(dekRef.all { it == 0.toByte() }, "All DEK bytes must be zero after lock()") + assertNull(vm.currentDek(), "currentDek() must return null after lock()") + } + + // SEC-06 — Relocation attack: moved file fails decryption + @Test fun `relocated file fails decryption`() { + val dek = engine.secureRandom(32) + val layer = CryptoLayer(engine, dek) + val content = "secret note".encodeToByteArray() + val encrypted = layer.encrypt("pages/A.md.stek", content) + val result = layer.decrypt("pages/B.md.stek", encrypted) + assertTrue(result.isLeft()) + assertIs(result.leftOrNull()) + } + + // SEC-11 — Passphrase CharArray is zeroed after unlock attempt + @Test fun `passphrase chararray is zeroed after unlock`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManager(store) + val graphPath = "/tmp/sec-test" + vm.createVault(graphPath, "correct".toCharArray(), argon2Params = params) + val passphrase = charArrayOf('c', 'o', 'r', 'r', 'e', 'c', 't') + vm.unlock(graphPath, passphrase, params) + assertTrue(passphrase.all { it == ' ' }, "Passphrase CharArray must be zero-filled after unlock()") + } + + // SEC-12 — File path used as AAD is graph-root-relative (not absolute) + @Test fun `relative path AAD works regardless of absolute graph path`() { + val dek = engine.secureRandom(32) + val layer = CryptoLayer(engine, dek) + val content = "# Note\n- content".encodeToByteArray() + // Encrypt using relative path (as CryptoLayer always uses) + val encrypted = layer.encrypt("pages/MyNote.md.stek", content) + // Decrypt using same relative path — success + val result = layer.decrypt("pages/MyNote.md.stek", encrypted) + assertTrue(result.isRight()) + assertContentEquals(content, result.getOrNull()) + } + + // SEC-09 — New cipher instance per encryption call (no shared state) + @Test fun `1000 successive encryptions produce distinct ciphertexts`() { + val key = engine.secureRandom(32) + val plaintext = "same content every time".encodeToByteArray() + val ciphertexts = (1..1000).map { + val nonce = engine.secureRandom(12) + engine.encryptAEAD(key, nonce, plaintext, byteArrayOf()) + } + val unique = ciphertexts.map { it.toHex() }.toSet() + assertEquals(1000, unique.size, "All 1000 ciphertexts must be distinct (nonce reuse would produce duplicates)") + } + + // SEC-13 — renamePage latent AAD bug: verbatim ciphertext copy breaks decryption at new path + // GraphWriter.renamePage copies encrypted bytes verbatim to the new path. Because STEK AAD is + // the *old* relative path, decryption at the new path fails AEAD authentication. + // This test documents the latent bug and provides a regression anchor when it is fixed. + @Test fun `verbatim ciphertext copy fails authentication at new path`() { + val dek = engine.secureRandom(32) + val layer = CryptoLayer(engine, dek) + val content = "# Note\n- content".encodeToByteArray() + + val oldPath = "pages/OldName.md.stek" + val newPath = "pages/NewName.md.stek" + + val encryptedAtOldPath = layer.encrypt(oldPath, content) + + // Verbatim byte copy to new path — AAD is still oldPath, so decrypt(newPath) must fail. + val result = layer.decrypt(newPath, encryptedAtOldPath) + assertTrue(result.isLeft(), "Verbatim ciphertext copy must fail AEAD at new path (AAD mismatch)") + assertIs(result.leftOrNull()) + } + + // SEC-14 — Encrypted file does not leak plaintext (ciphertext is not a superstring of plaintext) + @Test fun `ciphertext does not contain plaintext bytes`() { + val dek = engine.secureRandom(32) + val layer = CryptoLayer(engine, dek) + val content = "# Secret Note\n- very important content that must not be visible".encodeToByteArray() + val encrypted = layer.encrypt("pages/Secret.md.stek", content) + + val plaintextHex = content.toHex() + val ciphertextHex = encrypted.sliceArray(CryptoLayer.HEADER_SIZE until encrypted.size).toHex() + assertFalse(ciphertextHex.contains(plaintextHex), "Plaintext must not appear as a substring of ciphertext") + } + + // SEC-15 — Wrong DEK causes AuthenticationFailed (no silent decryption with wrong key) + @Test fun `wrong DEK causes AuthenticationFailed`() { + val correctDek = engine.secureRandom(32) + val wrongDek = engine.secureRandom(32) + val correctLayer = CryptoLayer(engine, correctDek) + val wrongLayer = CryptoLayer(engine, wrongDek) + + val path = "pages/Sensitive.md.stek" + val content = "confidential data".encodeToByteArray() + val encrypted = correctLayer.encrypt(path, content) + + val result = wrongLayer.decrypt(path, encrypted) + assertTrue(result.isLeft(), "Wrong DEK must not decrypt the ciphertext") + assertIs(result.leftOrNull()) + } + + // SEC-16 — Truncated ciphertext (valid STEK magic, no Poly1305 tag) causes error + @Test fun `truncated ciphertext after magic causes authentication failure`() { + val dek = engine.secureRandom(32) + val layer = CryptoLayer(engine, dek) + val content = "content".encodeToByteArray() + val encrypted = layer.encrypt("pages/Note.md.stek", content) + + // Keep only the STEK header bytes (magic + version + nonce), strip all ciphertext + val truncated = encrypted.sliceArray(0 until CryptoLayer.HEADER_SIZE) + val result = layer.decrypt("pages/Note.md.stek", truncated) + assertTrue(result.isLeft(), "Header-only file (no ciphertext) must fail") + } + + private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) } +} diff --git a/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/vault/security/KeyslotIntegrityTest.kt b/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/vault/security/KeyslotIntegrityTest.kt new file mode 100644 index 00000000..bf2b3df3 --- /dev/null +++ b/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/vault/security/KeyslotIntegrityTest.kt @@ -0,0 +1,95 @@ +package dev.stapler.stelekit.vault.security + +import arrow.core.Either +import dev.stapler.stelekit.vault.* +import kotlinx.coroutines.test.runTest +import kotlin.test.* +import kotlin.time.Duration.Companion.minutes + +@Suppress("DEPRECATION") +class KeyslotIntegrityTest { + private val engine = JvmCryptoEngine() + private val params = TEST_ARGON2_PARAMS + + private fun makeVaultManager(store: MutableMap): VaultManager = + VaultManager( + crypto = engine, + fileReadBytes = { path -> store[path] }, + fileWriteBytes = { path, data -> store[path] = data; true }, + ) + + // KI-01 — Any single keyslot byte mutation causes MAC verification failure + // Timeout is generous: 2573 bytes × 8 Argon2id calls on slow CI may exceed the default 60s. + @Test fun `any header byte mutation is detected`() = runTest(timeout = 5.minutes) { + val store = mutableMapOf() + val vm = makeVaultManager(store) + val graphPath = "/tmp/ki-test" + vm.createVault(graphPath, "correct".toCharArray(), argon2Params = params) + val vaultPath = VaultManager.vaultFilePath(graphPath) + val original = store[vaultPath]!! + + var detectedTampering = 0 + // Test mutations across bytes 0..1036 (the OUTER MAC-authenticated region, excluding MAC itself). + // createVault defaults to OUTER namespace, so the OUTER MAC covers bytes[0..1036]. + for (i in 0 until VaultHeader.OUTER_MAC_AUTH_SIZE) { + val mutated = original.copyOf() + mutated[i] = (mutated[i].toInt() xor 0x01).toByte() + store[vaultPath] = mutated + val result = vm.unlock(graphPath, "correct".toCharArray(), params) + if (result.isLeft()) detectedTampering++ + store[vaultPath] = original + } + // Every mutation must be detected — the OUTER header MAC covers bytes[0..1036], + // so even mutations in random padding or OUTER slot areas fail the MAC check. + assertEquals(VaultHeader.OUTER_MAC_AUTH_SIZE, detectedTampering, + "Expected 100% of bit flips to be detected, got $detectedTampering/${VaultHeader.OUTER_MAC_AUTH_SIZE}") + } + + // KI-02 — Truncated header bytes → deserialization error + @Test fun `truncated header returns error`() { + for (length in listOf(0, 1, 100, 2572)) { + val truncated = ByteArray(length) + val result = VaultHeaderSerializer.deserialize(truncated) + assertTrue(result.isLeft(), "Expected error for $length-byte truncated header") + } + } + + // KI-03 — Unused keyslot random padding does not affect MAC of active slots + @Test fun `valid unlock succeeds with active slots intact`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManager(store) + val graphPath = "/tmp/ki-test" + vm.createVault(graphPath, "correct".toCharArray(), argon2Params = params) + // Unlock should succeed — active slot 0 is valid, slots 1-7 are random + val result = vm.unlock(graphPath, "correct".toCharArray(), params) + assertTrue(result.isRight(), "Expected unlock to succeed with valid slot") + } + + // KI-04 — Header MAC key is derived from DEK, not hardcoded + @Test fun `header MAC key is derived from DEK via HKDF`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManager(store) + val graphPath = "/tmp/ki-test" + val dek = vm.createVault(graphPath, "correct".toCharArray(), argon2Params = params).getOrNull()!!.dek + val vaultPath = VaultManager.vaultFilePath(graphPath) + val rawBytes = store[vaultPath]!! + + // Compute expected MAC key using the same HKDF derivation as VaultManager + val expectedMacKey = engine.hkdfSha256( + ikm = dek, + salt = "vault-header-mac".encodeToByteArray(), + info = "v1".encodeToByteArray(), + length = 32, + ) + + // Compute HMAC-SHA256 over bytes[0..OUTER_MAC_AUTH_SIZE-1] (OUTER authenticated region) + val mac = javax.crypto.Mac.getInstance("HmacSHA256") + mac.init(javax.crypto.spec.SecretKeySpec(expectedMacKey, "HmacSHA256")) + val computedMac = mac.doFinal(rawBytes.sliceArray(0 until VaultHeader.OUTER_MAC_AUTH_SIZE)) + + // OUTER MAC is stored in the last 32 bytes (bytes[2573..2604]) + val outerMacOffset = VaultHeader.TOTAL_SIZE - VaultHeader.MAC_SIZE + val storedMac = rawBytes.sliceArray(outerMacOffset until VaultHeader.TOTAL_SIZE) + assertContentEquals(computedMac, storedMac, "OUTER header MAC must be derived from DEK via HKDF") + } +} diff --git a/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/vault/security/NoncePropertyTest.kt b/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/vault/security/NoncePropertyTest.kt new file mode 100644 index 00000000..de0cb856 --- /dev/null +++ b/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/vault/security/NoncePropertyTest.kt @@ -0,0 +1,33 @@ +package dev.stapler.stelekit.vault.security + +import dev.stapler.stelekit.vault.JvmCryptoEngine +import kotlin.test.* + +class NoncePropertyTest { + private val engine = JvmCryptoEngine() + + // NP-01 — 10,000 secureRandom(12) calls produce no duplicate 12-byte nonces + @Test fun `10000 nonces produce no duplicates`() { + val nonces = mutableSetOf() + repeat(10_000) { + val nonce = engine.secureRandom(12) + nonces.add(nonce.toHex()) + } + assertEquals(10_000, nonces.size, "Duplicate nonce detected in 10,000 samples") + } + + // NP-02 — Successive encryptions of the same plaintext produce distinct ciphertexts + @Test fun `successive encryptions produce distinct ciphertexts`() { + val key = engine.secureRandom(32) + val plaintext = ByteArray(1024) { it.toByte() } + val ciphertexts = mutableSetOf() + repeat(10_000) { + val nonce = engine.secureRandom(12) + val ct = engine.encryptAEAD(key, nonce, plaintext, byteArrayOf()) + ciphertexts.add(ct.toHex()) + } + assertEquals(10_000, ciphertexts.size, "Duplicate ciphertext detected in 10,000 samples") + } + + private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) } +} diff --git a/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/vault/vault/VaultHeaderSerializerTest.kt b/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/vault/vault/VaultHeaderSerializerTest.kt new file mode 100644 index 00000000..fc16b507 --- /dev/null +++ b/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/vault/vault/VaultHeaderSerializerTest.kt @@ -0,0 +1,118 @@ +package dev.stapler.stelekit.vault.vault + +import dev.stapler.stelekit.vault.* +import kotlin.test.* + +class VaultHeaderSerializerTest { + private val engine = dev.stapler.stelekit.vault.JvmCryptoEngine() + + private fun makeHeader(): VaultHeader { + return VaultHeader( + randomPadding = engine.secureRandom(VaultHeader.PADDING_SIZE), + keyslots = (0 until VaultHeader.KEYSLOT_COUNT).map { makeRandomSlot() }, + reserved = engine.secureRandom(VaultHeader.RESERVED_SIZE), + hiddenHeaderMac = engine.secureRandom(VaultHeader.MAC_SIZE), + headerMac = engine.secureRandom(VaultHeader.MAC_SIZE), + ) + } + + private fun makeRandomSlot() = Keyslot( + salt = engine.secureRandom(Keyslot.SALT_SIZE), + argon2Params = Argon2Params(memory = 4096, iterations = 1, parallelism = 1), + encryptedDekBlob = engine.secureRandom(Keyslot.ENCRYPTED_BLOB_SIZE), + slotNonce = engine.secureRandom(Keyslot.NONCE_SIZE), + reserved = engine.secureRandom(Keyslot.RESERVED_SIZE), + ) + + // VH-01 — Serialized header has correct total byte length + @Test fun `serialized header has correct total byte length`() { + val header = makeHeader() + val bytes = VaultHeaderSerializer.serialize(header) + assertEquals(VaultHeader.TOTAL_SIZE, bytes.size) + } + + // VH-02 — Magic bytes at offset 0 are "SKVT" + @Test fun `magic bytes at offset 0 are SKVT`() { + val bytes = VaultHeaderSerializer.serialize(makeHeader()) + assertContentEquals(byteArrayOf(0x53, 0x4B, 0x56, 0x54), bytes.sliceArray(0 until 4)) + } + + // VH-03 — Round-trip: serialize → deserialize recovers identical VaultHeader + @Test fun `round-trip serialize and deserialize recovers identical header`() { + val header = makeHeader() + val bytes = VaultHeaderSerializer.serialize(header) + val result = VaultHeaderSerializer.deserialize(bytes) + assertTrue(result.isRight()) + assertEquals(header, result.getOrNull()) + } + + // VH-04 — Wrong magic bytes → VaultError.NotAVault + @Test fun `wrong magic bytes returns NotAVault`() { + val bytes = VaultHeaderSerializer.serialize(makeHeader()).copyOf() + bytes[0] = 0x58; bytes[1] = 0x58; bytes[2] = 0x58; bytes[3] = 0x58 + val result = VaultHeaderSerializer.deserialize(bytes) + assertTrue(result.isLeft()) + assertIs(result.leftOrNull()) + } + + // VH-05 — Unknown version byte → VaultError.UnsupportedVersion + @Test fun `unknown version byte returns UnsupportedVersion`() { + val bytes = VaultHeaderSerializer.serialize(makeHeader()).copyOf() + bytes[4] = 0xFF.toByte() + val result = VaultHeaderSerializer.deserialize(bytes) + assertTrue(result.isLeft()) + assertIs(result.leftOrNull()) + } + + // VH-06 — Unused keyslots have non-zero bytes (random padding) + @Test fun `unused keyslots are filled with non-zero random bytes`() { + // Create 10 headers each with 8 random slots (all "unused") + var totalNonZeroFraction = 0.0 + repeat(10) { + val header = makeHeader() + val bytes = VaultHeaderSerializer.serialize(header) + val slotArea = bytes.sliceArray(13 until 13 + VaultHeader.KEYSLOT_COUNT * Keyslot.TOTAL_SIZE) + val nonZeroCount = slotArea.count { it != 0.toByte() } + totalNonZeroFraction += nonZeroCount.toDouble() / slotArea.size + } + val avgNonZero = totalNonZeroFraction / 10 + assertTrue(avgNonZero >= 0.95, "Expected at least 95% non-zero bytes in random slot area, got $avgNonZero") + } + + // U-HS-07 — Argon2 params at uint16 boundary values round-trip without sign extension + // iterations and parallelism are stored as LE uint16 (max 65535); memory is LE uint32. + // This guards against sign-extension bugs where the Kotlin Int (signed) misreads the stored value. + @Test fun `argon2 params at uint16 max round-trip correctly`() { + val maxU16 = 65535 + val slot = Keyslot( + salt = engine.secureRandom(Keyslot.SALT_SIZE), + argon2Params = Argon2Params(memory = maxU16, iterations = maxU16, parallelism = maxU16), + encryptedDekBlob = engine.secureRandom(Keyslot.ENCRYPTED_BLOB_SIZE), + slotNonce = engine.secureRandom(Keyslot.NONCE_SIZE), + reserved = engine.secureRandom(Keyslot.RESERVED_SIZE), + ) + val header = VaultHeader( + randomPadding = engine.secureRandom(VaultHeader.PADDING_SIZE), + keyslots = (0 until VaultHeader.KEYSLOT_COUNT).map { if (it == 0) slot else makeRandomSlot() }, + reserved = engine.secureRandom(VaultHeader.RESERVED_SIZE), + hiddenHeaderMac = engine.secureRandom(VaultHeader.MAC_SIZE), + headerMac = engine.secureRandom(VaultHeader.MAC_SIZE), + ) + val bytes = VaultHeaderSerializer.serialize(header) + val result = VaultHeaderSerializer.deserialize(bytes) + assertTrue(result.isRight()) + val recovered = result.getOrNull()!!.keyslots[0].argon2Params + assertEquals(maxU16, recovered.memory, "memory must survive uint16 boundary round-trip") + assertEquals(maxU16, recovered.iterations, "iterations must survive uint16 boundary round-trip") + assertEquals(maxU16, recovered.parallelism, "parallelism must survive uint16 boundary round-trip") + } + + // U-HS-08 — deserialize rejects vault files larger than TOTAL_SIZE + // Guards against garbage-appended files slipping through the size check. + @Test fun `vault file larger than TOTAL_SIZE returns CorruptedFile`() { + val oversized = ByteArray(VaultHeader.TOTAL_SIZE + 1) + val result = VaultHeaderSerializer.deserialize(oversized) + assertIs(result.leftOrNull(), + "File with ${VaultHeader.TOTAL_SIZE + 1} bytes must be rejected as CorruptedFile") + } +} diff --git a/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/vault/vault/VaultManagerTest.kt b/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/vault/vault/VaultManagerTest.kt new file mode 100644 index 00000000..8f56be64 --- /dev/null +++ b/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/vault/vault/VaultManagerTest.kt @@ -0,0 +1,514 @@ +package dev.stapler.stelekit.vault.vault + +import dev.stapler.stelekit.vault.* +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.* + +@Suppress("DEPRECATION") +class VaultManagerTest { + private val engine = JvmCryptoEngine() + private val params = TEST_ARGON2_PARAMS + + private fun makeVaultManager(): VaultManager { + val store = mutableMapOf() + return VaultManager( + crypto = engine, + fileReadBytes = { path -> store[path] }, + fileWriteBytes = { path, data -> store[path] = data; true }, + ) + } + + private fun makeVaultManagerWithStore(store: MutableMap): VaultManager { + return VaultManager( + crypto = engine, + fileReadBytes = { path -> store[path] }, + fileWriteBytes = { path, data -> store[path] = data; true }, + ) + } + + // VM-01 — createVault writes a readable .stele-vault file + @Test fun `createVault writes a valid vault file`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManagerWithStore(store) + val graphPath = "/tmp/test-graph" + val result = vm.createVault(graphPath, "correct".toCharArray(), argon2Params = params) + assertTrue(result.isRight()) + val vaultBytes = store[VaultManager.vaultFilePath(graphPath)] + assertNotNull(vaultBytes) + assertEquals(VaultHeader.TOTAL_SIZE, vaultBytes.size) + assertContentEquals(VaultHeader.MAGIC, vaultBytes.sliceArray(0 until 4)) + } + + // VM-02 — Unlock with correct passphrase returns Right(DEK) + @Test fun `unlock with correct passphrase returns dek`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManagerWithStore(store) + val graphPath = "/tmp/test-graph" + vm.createVault(graphPath, "correct".toCharArray(), argon2Params = params) + val result = vm.unlock(graphPath, "correct".toCharArray(), params) + assertTrue(result.isRight()) + val unlockResult = result.getOrNull()!! + assertEquals(32, unlockResult.dek.size) + assertEquals(VaultNamespace.OUTER, unlockResult.namespace) + } + + // VM-03 — Wrong passphrase returns Left(InvalidCredential) + @Test fun `wrong passphrase returns InvalidCredential`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManagerWithStore(store) + val graphPath = "/tmp/test-graph" + vm.createVault(graphPath, "correct".toCharArray(), argon2Params = params) + val result = vm.unlock(graphPath, "wrong".toCharArray(), params) + assertTrue(result.isLeft()) + assertIs(result.leftOrNull()) + } + + // VM-04 — All 8 keyslots are tried unconditionally for deniability; no plaintext skip + // optimization that would leak which slots are active (required by NFR-5). + @Test fun `all 8 keyslots are tried on unlock`() = runTest { + var decryptCount = 0 + val countingEngine = object : CryptoEngine by engine { + override fun decryptAEAD(key: ByteArray, nonce: ByteArray, ciphertext: ByteArray, aad: ByteArray): ByteArray { + decryptCount++ + return engine.decryptAEAD(key, nonce, ciphertext, aad) + } + } + val store = mutableMapOf() + val vm = VaultManager( + crypto = countingEngine, + fileReadBytes = { path -> store[path] }, + fileWriteBytes = { path, data -> store[path] = data; true }, + ) + val graphPath = "/tmp/test-graph" + vm.createVault(graphPath, "correct".toCharArray(), argon2Params = params) + decryptCount = 0 + vm.unlock(graphPath, "correct".toCharArray(), params) + assertEquals(8, decryptCount, "Expected exactly 8 decryptAEAD calls — all slots tried for deniability") + } + + // VM-05 — Add second keyslot (passphrase), unlock with new passphrase + @Test fun `add keyslot allows unlock with new passphrase`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManagerWithStore(store) + val graphPath = "/tmp/test-graph" + val r1 = vm.createVault(graphPath, "original".toCharArray(), argon2Params = params) + val dek = r1.getOrNull()!!.dek + vm.unlock(graphPath, "original".toCharArray(), params) + vm.addKeyslot(graphPath, dek, "second".toCharArray(), argon2Params = params) + // New passphrase works + val r2 = vm.unlock(graphPath, "second".toCharArray(), params) + assertTrue(r2.isRight()) + // Original passphrase still works + val r3 = vm.unlock(graphPath, "original".toCharArray(), params) + assertTrue(r3.isRight()) + } + + // VM-06 — Remove keyslot; removed provider cannot unlock + @Test fun `remove keyslot prevents unlock with that slot`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManagerWithStore(store) + val graphPath = "/tmp/test-graph" + val dek = vm.createVault(graphPath, "original".toCharArray(), argon2Params = params).getOrNull()!!.dek + vm.unlock(graphPath, "original".toCharArray(), params) + vm.addKeyslot(graphPath, dek, "second".toCharArray(), argon2Params = params) + // Remove slot 0 (original) + vm.removeKeyslot(graphPath, 0) + // Original no longer works + val r1 = vm.unlock(graphPath, "original".toCharArray(), params) + assertTrue(r1.isLeft()) + // Second still works + val r2 = vm.unlock(graphPath, "second".toCharArray(), params) + assertTrue(r2.isRight()) + } + + // VM-07 — Tampered byte in random-padding region (covered by MAC, outside any keyslot AEAD) + // causes the active keyslot to decrypt correctly but the header MAC check to fail. + // unlock() collapses HeaderTampered into InvalidCredential to preserve plausible deniability + // (MEDIUM-2): an adversary watching the UI must not distinguish "correct passphrase + tampered + // vault" from "wrong passphrase". The tamper condition is logged internally at WARN level only. + @Test fun `tampered header bytes return InvalidCredential`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManagerWithStore(store) + val graphPath = "/tmp/test-graph" + vm.createVault(graphPath, "correct".toCharArray(), argon2Params = params) + // Flip a byte at offset 5 (inside the 8-byte random padding, bytes 5..12). + // The active slot (slot 0) still decrypts correctly, but the MAC over bytes 0..2572 fails. + val vaultPath = VaultManager.vaultFilePath(graphPath) + val bytes = store[vaultPath]!!.copyOf() + bytes[5] = (bytes[5].toInt() xor 0xFF).toByte() + store[vaultPath] = bytes + val result = vm.unlock(graphPath, "correct".toCharArray(), params) + assertIs(result.leftOrNull()) + } + + // VM-08 — lock() zero-fills DEK byte array + @Test fun `lock zeros DEK byte array`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManagerWithStore(store) + val graphPath = "/tmp/test-graph" + vm.createVault(graphPath, "correct".toCharArray(), argon2Params = params) + val unlockResult = vm.unlock(graphPath, "correct".toCharArray(), params).getOrNull()!! + val dekRef = vm.currentDek()!! + vm.lock() + assertTrue(dekRef.all { it == 0.toByte() }, "DEK must be zeroed after lock()") + assertNull(vm.currentDek()) + } + + // VM-09 — lock() emits VaultLocked event + // vaultEvents has replay=1, so the Locked event is available immediately after lock(). + @Test fun `lock emits Locked event`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManagerWithStore(store) + val graphPath = "/tmp/test-graph" + vm.createVault(graphPath, "correct".toCharArray(), argon2Params = params) + vm.unlock(graphPath, "correct".toCharArray(), params) + vm.lock() + val event = vm.vaultEvents.first { it is VaultManager.VaultEvent.Locked } + assertIs(event) + } + + // VM-10 — rotateKeyslots (provider rotation): DEK unchanged, file decrypts after passphrase change + @Test fun `provider rotation does not change DEK`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManagerWithStore(store) + val graphPath = "/tmp/test-graph" + val dek = vm.createVault(graphPath, "original".toCharArray(), argon2Params = params).getOrNull()!!.dek + // Remove slot 0 and add new one with different passphrase + vm.unlock(graphPath, "original".toCharArray(), params) + vm.addKeyslot(graphPath, dek, "new-passphrase".toCharArray(), argon2Params = params) + val newUnlock = vm.unlock(graphPath, "new-passphrase".toCharArray(), params).getOrNull()!! + // DEK is the same (provider rotation, not DEK rotation) + assertContentEquals(dek, newUnlock.dek) + } + + // VM-12 — UnsupportedVersion on unknown header version + @Test fun `unsupported version returns UnsupportedVersion`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManagerWithStore(store) + val graphPath = "/tmp/test-graph" + vm.createVault(graphPath, "correct".toCharArray(), argon2Params = params) + val vaultPath = VaultManager.vaultFilePath(graphPath) + val bytes = store[vaultPath]!!.copyOf() + bytes[4] = 0xFF.toByte() // Overwrite version byte + store[vaultPath] = bytes + val result = vm.unlock(graphPath, "correct".toCharArray(), params) + assertTrue(result.isLeft()) + assertIs(result.leftOrNull()) + } + + // VM-13 — All slots overwritten with random data → InvalidCredential (all AEAD decryptions fail) + @Test fun `all random slots return InvalidCredential`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManagerWithStore(store) + val graphPath = "/tmp/test-graph" + vm.createVault(graphPath, "correct".toCharArray(), argon2Params = params) + // Overwrite the keyslot area (bytes 13..2060) with random data — all AEAD tags will fail. + val vaultPath = VaultManager.vaultFilePath(graphPath) + val bytes = store[vaultPath]!!.copyOf() + val randomSlotArea = engine.secureRandom(VaultHeader.KEYSLOT_COUNT * Keyslot.TOTAL_SIZE) + randomSlotArea.copyInto(bytes, 13) + store[vaultPath] = bytes + val result = vm.unlock(graphPath, "correct".toCharArray(), params) + assertIs(result.leftOrNull()) + } + + // VM-14 — Argon2id parameters stored in keyslot match parameters used at unlock + @Test fun `argon2 params stored in keyslot match creation params`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManagerWithStore(store) + val graphPath = "/tmp/test-graph" + val customParams = Argon2Params(memory = 8192, iterations = 2, parallelism = 1) + vm.createVault(graphPath, "correct".toCharArray(), argon2Params = customParams) + val vaultPath = VaultManager.vaultFilePath(graphPath) + val bytes = store[vaultPath]!! + val header = VaultHeaderSerializer.deserialize(bytes).getOrNull()!! + val slot = header.keyslots[0] + assertEquals(8192, slot.argon2Params.memory) + assertEquals(2, slot.argon2Params.iterations) + assertEquals(1, slot.argon2Params.parallelism) + } + + // VM-15 — All 4 OUTER keyslots can be filled; adding a 5th returns SlotsFull + @Test fun `can fill all outer keyslots and extra returns SlotsFull`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManagerWithStore(store) + val graphPath = "/tmp/test-graph" + // createVault fills OUTER slot 0 + val dek = vm.createVault(graphPath, "slot0".toCharArray(), argon2Params = params).getOrNull()!!.dek + // Fill remaining OUTER slots (1, 2, 3) + for (i in 1..3) { + val r = vm.addKeyslot(graphPath, dek, "slot$i".toCharArray(), argon2Params = params) + assertTrue(r.isRight(), "Expected OUTER slot $i to be added successfully") + } + // All 4 OUTER slots are now full; 5th OUTER call must fail with SlotsFull + val overflow = vm.addKeyslot(graphPath, dek, "overflow".toCharArray(), argon2Params = params) + assertIs(overflow.leftOrNull()) + } + + // VM-11 — Full DEK rotation re-encrypts: test that different DEKs produce different vault state + @Test fun `unlock returns consistent dek across multiple unlocks`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManagerWithStore(store) + val graphPath = "/tmp/test-graph" + vm.createVault(graphPath, "correct".toCharArray(), argon2Params = params) + val r1 = vm.unlock(graphPath, "correct".toCharArray(), params).getOrNull()!! + val r2 = vm.unlock(graphPath, "correct".toCharArray(), params).getOrNull()!! + assertContentEquals(r1.dek, r2.dek) + } + + // VM-16 — removeKeyslot on a locked vault returns InvalidCredential (sessionDek is null) + @Test fun `removeKeyslot on locked vault returns InvalidCredential`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManagerWithStore(store) + val graphPath = "/tmp/test-graph" + vm.createVault(graphPath, "original".toCharArray(), argon2Params = params) + // Vault is never unlocked — sessionDek is null + val result = vm.removeKeyslot(graphPath, 0) + assertIs(result.leftOrNull()) + } + + // VM-17 — addKeyslot with wrong DEK is rejected (MAC fails); active slots are unmodified + @Test fun `addKeyslot with wrong DEK is rejected without corrupting existing slots`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManagerWithStore(store) + val graphPath = "/tmp/test-graph" + vm.createVault(graphPath, "original".toCharArray(), argon2Params = params) + val wrongDek = engine.secureRandom(32) + // addKeyslot verifies the DEK via header MAC before modifying any slots. + // A wrong DEK cannot produce the correct MAC, so the operation must be rejected. + val r = vm.addKeyslot(graphPath, wrongDek, "injected".toCharArray(), argon2Params = params) + assertIs(r.leftOrNull(), "Wrong DEK must be rejected by addKeyslot") + // The vault must still be unlockable with the original passphrase + val unlock = vm.unlock(graphPath, "original".toCharArray(), params) + assertTrue(unlock.isRight(), "Original slot must be unmodified after rejected addKeyslot") + } + + // VM-18 — createVault writes a non-zero hidden-reserve sentinel file + @Test fun `createVault writes a non-zero hidden-reserve sentinel`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManagerWithStore(store) + val graphPath = "/tmp/test-graph" + vm.createVault(graphPath, "correct".toCharArray(), argon2Params = params) + val sentinelPath = VaultManager.hiddenReserveSentinelPath(graphPath) + val sentinelBytes = store[sentinelPath] + assertNotNull(sentinelBytes, "Sentinel file must be written") + assertTrue(sentinelBytes.size == 256, "Sentinel must be 256 bytes") + assertTrue(sentinelBytes.any { it != 0.toByte() }, "Sentinel must not be all-zero") + } + + // VM-19 — createVault with HIDDEN namespace places keyslot in slots 4–7 + @Test fun `createVault with HIDDEN namespace places keyslot in slots 4-7`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManagerWithStore(store) + val graphPath = "/tmp/test-graph" + vm.createVault(graphPath, "hidden-pass".toCharArray(), namespace = VaultNamespace.HIDDEN, argon2Params = params) + val result = vm.unlock(graphPath, "hidden-pass".toCharArray(), params) + assertTrue(result.isRight()) + assertEquals(VaultNamespace.HIDDEN, result.getOrNull()!!.namespace) + } + + // VM-20 — passphrase CharArray is zeroed after createVault + @Test fun `createVault zeros passphrase CharArray`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManagerWithStore(store) + val passphrase = "correct".toCharArray() + vm.createVault("/tmp/test-graph", passphrase, argon2Params = params) + assertTrue(passphrase.all { it == ' ' }, "Passphrase must be zeroed after createVault") + } + + // VM-21 — passphrase CharArray is zeroed after addKeyslot + @Test fun `addKeyslot zeros passphrase CharArray`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManagerWithStore(store) + val graphPath = "/tmp/test-graph" + val dek = vm.createVault(graphPath, "original".toCharArray(), argon2Params = params).getOrNull()!!.dek + val newPassphrase = "second".toCharArray() + vm.addKeyslot(graphPath, dek, newPassphrase, argon2Params = params) + assertTrue(newPassphrase.all { it == ' ' }, "Passphrase must be zeroed after addKeyslot") + } + + // VM-22 — Empty passphrase round-trips (zero-length input is valid but discouraged) + @Test fun `empty passphrase can create and unlock vault`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManagerWithStore(store) + vm.createVault("/tmp/test-graph", charArrayOf(), argon2Params = params) + val result = vm.unlock("/tmp/test-graph", charArrayOf(), params) + assertTrue(result.isRight(), "Empty passphrase must unlock the vault it created") + } + + // VM-23 — Emoji passphrase is rejected at vault creation time (MEDIUM-4 validation). + // U+1F511 KEY is encoded as a surrogate pair (two Kotlin Chars) — CESU-8 would produce + // different bytes than standard UTF-8, permanently locking out users after a platform migration. + // createVault must throw IllegalArgumentException before any cryptographic operations. + @Test fun `emoji passphrase is rejected at createVault`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManagerWithStore(store) + // U+1F511 KEY emoji — encoded as a surrogate pair in Kotlin Char (two chars) + val emoji = "🔑".toCharArray() // 🔑 + assertFailsWith { + vm.createVault("/tmp/test-graph", emoji, argon2Params = params) + } + // No vault file should have been written + assertNull(store[VaultManager.vaultFilePath("/tmp/test-graph")], "No vault file must be written for rejected passphrase") + } + + // VM-24 — Null-byte in passphrase is preserved (not treated as C string terminator) + @Test fun `null-byte passphrase differs from empty passphrase`() = runTest { + val store1 = mutableMapOf() + val store2 = mutableMapOf() + val vm1 = makeVaultManagerWithStore(store1) + val vm2 = makeVaultManagerWithStore(store2) + vm1.createVault("/tmp/test-graph", charArrayOf(''), argon2Params = params) + vm2.createVault("/tmp/test-graph", charArrayOf(), argon2Params = params) + // Null-byte vault cannot be unlocked with empty passphrase and vice versa + val r1 = vm1.unlock("/tmp/test-graph", charArrayOf(), params) + val r2 = vm2.unlock("/tmp/test-graph", charArrayOf(''), params) + assertTrue(r1.isLeft(), "Empty passphrase must not unlock null-byte vault") + assertTrue(r2.isLeft(), "Null-byte passphrase must not unlock empty-passphrase vault") + } + + // VM-25 — OUTER-authenticated session cannot remove HIDDEN keyslots (cross-namespace guard) + @Test fun `removeKeyslot rejects cross-namespace slot removal`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManagerWithStore(store) + val graphPath = "/tmp/test-graph" + // Create OUTER vault and unlock it + vm.createVault(graphPath, "outer-pass".toCharArray(), argon2Params = params) + vm.unlock(graphPath, "outer-pass".toCharArray(), params) + + // Attempt to remove a HIDDEN slot (index 4) while authenticated as OUTER + val result = vm.removeKeyslot(graphPath, slotIndex = 4) + assertIs(result.leftOrNull(), + "OUTER session must not remove HIDDEN keyslot — got: $result") + } + + // VM-26 — removeKeyslot rejects out-of-range slot index + @Test fun `removeKeyslot rejects out-of-range slot index`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManagerWithStore(store) + val graphPath = "/tmp/test-graph" + vm.createVault(graphPath, "pass".toCharArray(), argon2Params = params) + vm.unlock(graphPath, "pass".toCharArray(), params) + + val negative = vm.removeKeyslot(graphPath, slotIndex = -1) + assertIs(negative.leftOrNull(), + "Negative slot index must be rejected") + + val tooLarge = vm.removeKeyslot(graphPath, slotIndex = 8) + assertIs(tooLarge.leftOrNull(), + "Slot index >= KEYSLOT_COUNT must be rejected") + } + + // I-VM-07 — Production unlock path: argon2Params=null uses stored slot params + // All existing tests pass explicit params to unlock(); production code passes null so the + // stored params are read from the keyslot. This exercises the full deserialization path. + @Test fun `unlock with null argon2Params uses stored slot params`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManagerWithStore(store) + val graphPath = "/tmp/test-graph" + val customParams = Argon2Params(memory = 8192, iterations = 2, parallelism = 1) + vm.createVault(graphPath, "correct".toCharArray(), argon2Params = customParams) + // Pass null — uses params stored in the keyslot (the only correct production path) + val result = vm.unlock(graphPath, "correct".toCharArray(), argon2Params = null) + assertTrue(result.isRight(), "Production unlock path must succeed using stored slot params") + assertEquals(32, result.getOrNull()!!.dek.size) + assertEquals(VaultNamespace.OUTER, result.getOrNull()!!.namespace) + } + + // I-VM-08 — unlock emits an Unlocked event with the correct namespace + @Test fun `unlock emits Unlocked event with correct namespace`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManagerWithStore(store) + val graphPath = "/tmp/test-graph" + vm.createVault(graphPath, "correct".toCharArray(), argon2Params = params) + vm.unlock(graphPath, "correct".toCharArray(), params) + val event = vm.vaultEvents.first { it is VaultManager.VaultEvent.Unlocked } + assertIs(event) + assertEquals(VaultNamespace.OUTER, event.namespace) + } + + // I-VM-01 — createVault returns CorruptedFile when the underlying write fails + @Test fun `createVault returns CorruptedFile when write fails`() = runTest { + val vm = VaultManager( + crypto = engine, + fileReadBytes = { null }, + fileWriteBytes = { _, _ -> false }, + ) + val result = vm.createVault("/tmp/test-graph", "pass".toCharArray(), argon2Params = params) + assertTrue(result.isLeft()) + assertIs(result.leftOrNull()) + } + + // I-VM-02 — addKeyslot returns CorruptedFile when the header write fails + @Test fun `addKeyslot returns CorruptedFile when write fails`() = runTest { + // First createVault succeeds; then all subsequent writes fail + val store = mutableMapOf() + var writeCount = 0 + val vm = VaultManager( + crypto = engine, + fileReadBytes = { path -> store[path] }, + fileWriteBytes = { path, data -> + writeCount++ + if (writeCount <= 2) { store[path] = data; true } else false // allow create + sentinel, block addKeyslot + }, + ) + val graphPath = "/tmp/test-graph" + val dek = vm.createVault(graphPath, "original".toCharArray(), argon2Params = params).getOrNull()!!.dek + val result = vm.addKeyslot(graphPath, dek, "second".toCharArray(), argon2Params = params) + assertTrue(result.isLeft()) + assertIs(result.leftOrNull()) + // Vault must still be intact — the original slot must still unlock + val unlock = vm.unlock(graphPath, "original".toCharArray(), params) + assertTrue(unlock.isRight(), "Vault must remain unlockable after failed addKeyslot write") + } + + // I-VM-03 — removeKeyslot returns CorruptedFile when the header write fails + @Test fun `removeKeyslot returns CorruptedFile when write fails`() = runTest { + // createVault writes twice (vault header + reserve); addKeyslot writes once. + // Allow these 3, then block removeKeyslot (write 4) to exercise CorruptedFile path. + val store = mutableMapOf() + var writeCount = 0 + val vm = VaultManager( + crypto = engine, + fileReadBytes = { path -> store[path] }, + fileWriteBytes = { path, data -> + writeCount++ + if (writeCount <= 3) { store[path] = data; true } else false + }, + ) + val graphPath = "/tmp/test-graph" + val dek = vm.createVault(graphPath, "pass".toCharArray(), argon2Params = params).getOrNull()!!.dek + vm.unlock(graphPath, "pass".toCharArray(), params) + // Add a second slot so the last-slot guard passes, then a write failure returns CorruptedFile + vm.addKeyslot(graphPath, dek, "pass2".toCharArray(), argon2Params = params) + val result = vm.removeKeyslot(graphPath, slotIndex = 0) + assertTrue(result.isLeft()) + assertIs(result.leftOrNull()) + } + + // VM-27 — removeKeyslot on sole active slot returns InvalidCredential (GAP-N2 guard) + @Test fun `removeKeyslot on last active slot returns InvalidCredential`() = runTest { + val store = mutableMapOf() + val vm = makeVaultManagerWithStore(store) + val graphPath = "/tmp/test-graph" + vm.createVault(graphPath, "sole".toCharArray(), argon2Params = params) + vm.unlock(graphPath, "sole".toCharArray(), params) + // Only one active slot — removing it must be refused + val result = vm.removeKeyslot(graphPath, slotIndex = 0) + assertTrue(result.isLeft(), "Removing the last slot must return an error; got $result") + assertIs(result.leftOrNull()) + } + + // I-VM-04 — unlock returns NotAVault when the vault file does not exist + @Test fun `unlock returns NotAVault when vault file missing`() = runTest { + val vm = VaultManager( + crypto = engine, + fileReadBytes = { null }, + fileWriteBytes = { _, _ -> true }, + ) + val result = vm.unlock("/tmp/nonexistent-graph", "pass".toCharArray(), params) + assertTrue(result.isLeft()) + assertIs(result.leftOrNull()) + } +}