From 108476748645c18363d56e8a9122df7e0542ba2e Mon Sep 17 00:00:00 2001 From: Alim Sourav Date: Tue, 26 May 2026 20:41:12 +0600 Subject: [PATCH 01/16] added new module --- .github/workflows/publish.yml | 40 +++++++ build.gradle.kts | 1 + gradle/libs.versions.toml | 9 +- secure-vault/.gitignore | 1 + secure-vault/build.gradle.kts | 108 ++++++++++++++++++ .../src/androidMain/AndroidManifest.xml | 4 + .../secure/vault/Platform.android.kt | 3 + .../github/alimsrepo/secure/vault/Platform.kt | 3 + .../alimsrepo/secure/vault/Platform.ios.kt | 3 + settings.gradle.kts | 3 +- 10 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/publish.yml create mode 100644 secure-vault/.gitignore create mode 100644 secure-vault/build.gradle.kts create mode 100644 secure-vault/src/androidMain/AndroidManifest.xml create mode 100644 secure-vault/src/androidMain/kotlin/io/github/alimsrepo/secure/vault/Platform.android.kt create mode 100644 secure-vault/src/commonMain/kotlin/io/github/alimsrepo/secure/vault/Platform.kt create mode 100644 secure-vault/src/iosMain/kotlin/io/github/alimsrepo/secure/vault/Platform.ios.kt diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..59a87c7 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,40 @@ +name: Publish to Maven Central + +# Triggers on any tag that starts with "v" — e.g. v0.5.7-beta, v1.0.0 +on: + push: + tags: + - 'v*' + +jobs: + publish: + name: Publish + runs-on: macos-latest # macOS is required to compile iOS KLIBs + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21.0.7' + distribution: 'temurin' + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Publish to Maven Central + # SIGNING_KEY = base64-encoded armored private key (avoids multiline env-var issues) + run: | + export ORG_GRADLE_PROJECT_signingInMemoryKey=$(echo "$SIGNING_KEY_B64" | base64 -d) + ./gradlew :crash-guard:publishAllPublicationsToMavenCentralRepository --no-configuration-cache + env: + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.SIGNING_KEY_ID }} + SIGNING_KEY_B64: ${{ secrets.SIGNING_KEY }} diff --git a/build.gradle.kts b/build.gradle.kts index 7cac393..1fb3591 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,4 +6,5 @@ plugins { alias(libs.plugins.composeMultiplatform) apply false alias(libs.plugins.composeCompiler) apply false alias(libs.plugins.kotlinMultiplatform) apply false + alias(libs.plugins.androidLint) apply false } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index db8dd88..a1a9f20 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,9 @@ composeMultiplatform = "1.11.0" junit = "4.13.2" kotlin = "2.3.21" material3 = "1.11.0-alpha07" +kotlinStdlib = "2.3.21" +runner = "1.7.0" +core = "1.7.0" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -32,10 +35,14 @@ compose-material3 = { module = "org.jetbrains.compose.material3:material3", vers compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" } compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" } compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" } +kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlinStdlib" } +androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } +androidx-core = { group = "androidx.test", name = "core", version.ref = "core" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } androidMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } -kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } \ No newline at end of file +kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +androidLint = { id = "com.android.lint", version.ref = "agp" } \ No newline at end of file diff --git a/secure-vault/.gitignore b/secure-vault/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/secure-vault/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/secure-vault/build.gradle.kts b/secure-vault/build.gradle.kts new file mode 100644 index 0000000..e48a123 --- /dev/null +++ b/secure-vault/build.gradle.kts @@ -0,0 +1,108 @@ +import com.vanniktech.maven.publish.SonatypeHost + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidMultiplatformLibrary) + alias(libs.plugins.androidLint) + + id("com.vanniktech.maven.publish") version "0.30.0" + id("signing") +} + +kotlin { + + android { + namespace = "io.github.alimsrepo.secure.vault" + compileSdk { + version = release(37) + } + minSdk = 24 + } + + val xcfName = "secure-vaultKit" + iosArm64 { + binaries.framework { + baseName = xcfName + } + } + + iosSimulatorArm64 { + binaries.framework { + baseName = xcfName + } + } + + sourceSets { + commonMain { + dependencies { + implementation(libs.kotlin.stdlib) + + } + } + + androidMain { + dependencies { + + } + } + + iosMain { + dependencies { + + } + } + } +} + +mavenPublishing { + coordinates( + groupId = "io.github.alims-repo", + artifactId = "secure-vault", + version = "1.0.0" + ) + + pom { + name.set("Secure Valult KMP") + description.set("") + inceptionYear.set("2026") + url.set("https://github.com/Alims-Repo/SecureVault-KMP") + + licenses { + license { + name.set("The Apache License, Version 2.0") + url.set("https://www.apache.org/licenses/LICENSE-2.0.txt") + } + } + + developers { + developer { + id.set("alim") + name.set("Alim Sourav") + email.set("sourav.0.alim@gmail.com") + } + } + + scm { + url.set("https://github.com/Alims-Repo/SecureVault-KMP") + connection.set("scm:git:git://github.com/Alims-Repo/SecureVault-KMP.git") + developerConnection.set("scm:git:ssh://github.com/Alims-Repo/SecureVault-KMP.git") + } + } + + publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) +} + +// Read signing credentials from ~/.gradle/gradle.properties +// Keys: signingInMemoryKeyId, signingInMemoryKey, signingInMemoryKeyPassword +val signingKeyId = findProperty("signingInMemoryKeyId") as String? +val signingKey = findProperty("signingInMemoryKey") as String? +val signingPassword = findProperty("signingInMemoryKeyPassword") as String? ?: "" + +signing { + if (signingKey != null) { + useInMemoryPgpKeys(signingKeyId, signingKey, signingPassword) + sign(publishing.publications) + } else { + logger.warn("⚠️ signingInMemoryKey not set — publications will NOT be signed.") + } +} \ No newline at end of file diff --git a/secure-vault/src/androidMain/AndroidManifest.xml b/secure-vault/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/secure-vault/src/androidMain/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/secure-vault/src/androidMain/kotlin/io/github/alimsrepo/secure/vault/Platform.android.kt b/secure-vault/src/androidMain/kotlin/io/github/alimsrepo/secure/vault/Platform.android.kt new file mode 100644 index 0000000..cfcff75 --- /dev/null +++ b/secure-vault/src/androidMain/kotlin/io/github/alimsrepo/secure/vault/Platform.android.kt @@ -0,0 +1,3 @@ +package io.github.alimsrepo.secure.vault + +actual fun platform() = "Android" \ No newline at end of file diff --git a/secure-vault/src/commonMain/kotlin/io/github/alimsrepo/secure/vault/Platform.kt b/secure-vault/src/commonMain/kotlin/io/github/alimsrepo/secure/vault/Platform.kt new file mode 100644 index 0000000..3875dca --- /dev/null +++ b/secure-vault/src/commonMain/kotlin/io/github/alimsrepo/secure/vault/Platform.kt @@ -0,0 +1,3 @@ +package io.github.alimsrepo.secure.vault + +expect fun platform(): String \ No newline at end of file diff --git a/secure-vault/src/iosMain/kotlin/io/github/alimsrepo/secure/vault/Platform.ios.kt b/secure-vault/src/iosMain/kotlin/io/github/alimsrepo/secure/vault/Platform.ios.kt new file mode 100644 index 0000000..89c32a4 --- /dev/null +++ b/secure-vault/src/iosMain/kotlin/io/github/alimsrepo/secure/vault/Platform.ios.kt @@ -0,0 +1,3 @@ +package io.github.alimsrepo.secure.vault + +actual fun platform() = "iOS" \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 22a7e92..48175be 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -29,4 +29,5 @@ dependencyResolutionManagement { } include(":androidApp") -include(":shared") \ No newline at end of file +include(":shared") +include(":secure-vault") From 9938492055f705ff8970d4f0e0bbb0ad42a16782 Mon Sep 17 00:00:00 2001 From: Alim Sourav Date: Tue, 26 May 2026 20:48:59 +0600 Subject: [PATCH 02/16] unwanted code removal --- secure-vault/build.gradle.kts | 2 +- .../io/github/alimsrepo/secure/vault/Platform.android.kt | 3 --- .../kotlin/io/github/alimsrepo/secure/vault/Platform.kt | 3 --- .../kotlin/io/github/alimsrepo/secure/vault/Platform.ios.kt | 3 --- 4 files changed, 1 insertion(+), 10 deletions(-) delete mode 100644 secure-vault/src/androidMain/kotlin/io/github/alimsrepo/secure/vault/Platform.android.kt delete mode 100644 secure-vault/src/commonMain/kotlin/io/github/alimsrepo/secure/vault/Platform.kt delete mode 100644 secure-vault/src/iosMain/kotlin/io/github/alimsrepo/secure/vault/Platform.ios.kt diff --git a/secure-vault/build.gradle.kts b/secure-vault/build.gradle.kts index e48a123..2d1d3d0 100644 --- a/secure-vault/build.gradle.kts +++ b/secure-vault/build.gradle.kts @@ -62,7 +62,7 @@ mavenPublishing { ) pom { - name.set("Secure Valult KMP") + name.set("Secure Vault KMP") description.set("") inceptionYear.set("2026") url.set("https://github.com/Alims-Repo/SecureVault-KMP") diff --git a/secure-vault/src/androidMain/kotlin/io/github/alimsrepo/secure/vault/Platform.android.kt b/secure-vault/src/androidMain/kotlin/io/github/alimsrepo/secure/vault/Platform.android.kt deleted file mode 100644 index cfcff75..0000000 --- a/secure-vault/src/androidMain/kotlin/io/github/alimsrepo/secure/vault/Platform.android.kt +++ /dev/null @@ -1,3 +0,0 @@ -package io.github.alimsrepo.secure.vault - -actual fun platform() = "Android" \ No newline at end of file diff --git a/secure-vault/src/commonMain/kotlin/io/github/alimsrepo/secure/vault/Platform.kt b/secure-vault/src/commonMain/kotlin/io/github/alimsrepo/secure/vault/Platform.kt deleted file mode 100644 index 3875dca..0000000 --- a/secure-vault/src/commonMain/kotlin/io/github/alimsrepo/secure/vault/Platform.kt +++ /dev/null @@ -1,3 +0,0 @@ -package io.github.alimsrepo.secure.vault - -expect fun platform(): String \ No newline at end of file diff --git a/secure-vault/src/iosMain/kotlin/io/github/alimsrepo/secure/vault/Platform.ios.kt b/secure-vault/src/iosMain/kotlin/io/github/alimsrepo/secure/vault/Platform.ios.kt deleted file mode 100644 index 89c32a4..0000000 --- a/secure-vault/src/iosMain/kotlin/io/github/alimsrepo/secure/vault/Platform.ios.kt +++ /dev/null @@ -1,3 +0,0 @@ -package io.github.alimsrepo.secure.vault - -actual fun platform() = "iOS" \ No newline at end of file From 243772b8d59f861155622fe947a73c61dd375fd4 Mon Sep 17 00:00:00 2001 From: Alim Sourav Date: Tue, 26 May 2026 20:50:30 +0600 Subject: [PATCH 03/16] build(secure-vault): centralize GROUP/VERSION_NAME and extend version catalog - Move publishing coordinates to root gradle.properties (GROUP, VERSION_NAME) so CI/tag-driven releases can override without editing build scripts. - Register coroutines, androidx.security-crypto, robolectric libraries. - Register mavenPublish, dokka and binary-compatibility-validator plugins in libs.versions.toml (single source of truth for plugin versions). --- gradle.properties | 7 ++++++- gradle/libs.versions.toml | 16 +++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/gradle.properties b/gradle.properties index 6f8e6ea..7f895d0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,4 +9,9 @@ org.gradle.caching=true #Android android.nonTransitiveRClass=true -android.useAndroidX=true \ No newline at end of file +android.useAndroidX=true + +# Published artifact coordinates (consumed by :secure-vault) +GROUP=io.github.alimsrepo +VERSION_NAME=0.1.0 + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a1a9f20..d8153e5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,12 +8,18 @@ androidx-appcompat = "1.7.1" androidx-core = "1.18.0" androidx-espresso = "3.7.0" androidx-lifecycle = "2.11.0-beta01" +androidx-securityCrypto = "1.1.0-alpha07" androidx-testExt = "1.3.0" composeMultiplatform = "1.11.0" +coroutines = "1.10.2" +binaryCompatibilityValidator = "0.17.0" +dokka = "2.0.0" junit = "4.13.2" kotlin = "2.3.21" material3 = "1.11.0-alpha07" kotlinStdlib = "2.3.21" +mavenPublish = "0.30.0" +robolectric = "4.14.1" runner = "1.7.0" core = "1.7.0" @@ -38,6 +44,11 @@ compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-previ kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlinStdlib" } androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } androidx-core = { group = "androidx.test", name = "core", version.ref = "core" } +androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "androidx-securityCrypto" } +kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } @@ -45,4 +56,7 @@ androidMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } -androidLint = { id = "com.android.lint", version.ref = "agp" } \ No newline at end of file +androidLint = { id = "com.android.lint", version.ref = "agp" } +mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" } +dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } +binaryCompatibilityValidator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "binaryCompatibilityValidator" } From dd885b766675afe37276a765c5dd626d574d9961 Mon Sep 17 00:00:00 2001 From: Alim Sourav Date: Tue, 26 May 2026 20:53:08 +0600 Subject: [PATCH 04/16] build(secure-vault): harden module Gradle script - Use catalog plugin aliases for mavenPublish, dokka and binary-compat-validator (no hard-coded versions in build scripts). - Read group/version from root gradle.properties via Provider API. - Enable Kotlin explicitApi = Strict and add -Xexpect-actual-classes / -opt-in compiler args to keep the public surface deliberate. - Pin Android JVM target to 17. - Rename iOS framework to PascalCase SecureVaultKit and mark it static so consumer apps link a single binary. - Move dependencies to typed source-set DSL; drop redundant kotlin-stdlib (KGP adds it implicitly). Expose coroutines as api on commonMain since the public API uses suspend / Flow. - Configure vanniktech publishing for KMP with JavadocJar.Dokka + sourcesJar and call signAllPublications(); drop the bespoke signing {} block. - Fix invalid groupId (io.github.alims-repo -> io.github.alimsrepo), fill the previously empty POM description, add issueManagement and distribution=repo on the license. - Remove Platform.kt stub triplet; the real public API lands next. --- secure-vault/build.gradle.kts | 112 +++++++++++++++++++--------------- 1 file changed, 63 insertions(+), 49 deletions(-) diff --git a/secure-vault/build.gradle.kts b/secure-vault/build.gradle.kts index 2d1d3d0..b50ded1 100644 --- a/secure-vault/build.gradle.kts +++ b/secure-vault/build.gradle.kts @@ -1,69 +1,87 @@ +import com.vanniktech.maven.publish.JavadocJar +import com.vanniktech.maven.publish.KotlinMultiplatform import com.vanniktech.maven.publish.SonatypeHost +import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode +import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.androidMultiplatformLibrary) alias(libs.plugins.androidLint) - - id("com.vanniktech.maven.publish") version "0.30.0" - id("signing") + alias(libs.plugins.mavenPublish) + alias(libs.plugins.dokka) + alias(libs.plugins.binaryCompatibilityValidator) } +group = providers.gradleProperty("GROUP").get() +version = providers.gradleProperty("VERSION_NAME").get() + kotlin { + // Force every public declaration to carry an explicit visibility modifier. + explicitApi = ExplicitApiMode.Strict + + compilerOptions { + freeCompilerArgs.addAll( + "-Xexpect-actual-classes", + "-opt-in=kotlin.RequiresOptIn", + ) + } android { namespace = "io.github.alimsrepo.secure.vault" - compileSdk { - version = release(37) - } + compileSdk { version = release(37) } minSdk = 24 - } - val xcfName = "secure-vaultKit" - iosArm64 { - binaries.framework { - baseName = xcfName + compilations.configureEach { + compileTaskProvider.configure { + compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } + } } } - iosSimulatorArm64 { - binaries.framework { + val xcfName = "SecureVaultKit" + listOf(iosArm64(), iosSimulatorArm64()).forEach { target -> + target.binaries.framework { baseName = xcfName + isStatic = true } } sourceSets { - commonMain { - dependencies { - implementation(libs.kotlin.stdlib) - - } + commonMain.dependencies { + api(libs.kotlinx.coroutines.core) } - - androidMain { - dependencies { - - } + commonTest.dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) } - - iosMain { - dependencies { - - } + androidMain.dependencies { + implementation(libs.androidx.security.crypto) + implementation(libs.kotlinx.coroutines.android) } } } mavenPublishing { + configure( + KotlinMultiplatform( + javadocJar = JavadocJar.Dokka("dokkaHtml"), + sourcesJar = true, + ), + ) + coordinates( - groupId = "io.github.alims-repo", + groupId = providers.gradleProperty("GROUP").get(), artifactId = "secure-vault", - version = "1.0.0" + version = providers.gradleProperty("VERSION_NAME").get(), ) pom { - name.set("Secure Vault KMP") - description.set("") + name.set("SecureVault KMP") + description.set( + "A small, coroutine-first Kotlin Multiplatform library for storing " + + "secrets on Android (EncryptedSharedPreferences) and iOS (Keychain).", + ) inceptionYear.set("2026") url.set("https://github.com/Alims-Repo/SecureVault-KMP") @@ -71,38 +89,34 @@ mavenPublishing { license { name.set("The Apache License, Version 2.0") url.set("https://www.apache.org/licenses/LICENSE-2.0.txt") + distribution.set("repo") } } developers { developer { - id.set("alim") + id.set("alimsrepo") name.set("Alim Sourav") email.set("sourav.0.alim@gmail.com") + url.set("https://github.com/Alims-Repo") } } scm { url.set("https://github.com/Alims-Repo/SecureVault-KMP") connection.set("scm:git:git://github.com/Alims-Repo/SecureVault-KMP.git") - developerConnection.set("scm:git:ssh://github.com/Alims-Repo/SecureVault-KMP.git") + developerConnection.set("scm:git:ssh://git@github.com/Alims-Repo/SecureVault-KMP.git") + } + + issueManagement { + system.set("GitHub") + url.set("https://github.com/Alims-Repo/SecureVault-KMP/issues") } } - publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) + publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL, automaticRelease = false) + signAllPublications() } -// Read signing credentials from ~/.gradle/gradle.properties -// Keys: signingInMemoryKeyId, signingInMemoryKey, signingInMemoryKeyPassword -val signingKeyId = findProperty("signingInMemoryKeyId") as String? -val signingKey = findProperty("signingInMemoryKey") as String? -val signingPassword = findProperty("signingInMemoryKeyPassword") as String? ?: "" - -signing { - if (signingKey != null) { - useInMemoryPgpKeys(signingKeyId, signingKey, signingPassword) - sign(publishing.publications) - } else { - logger.warn("⚠️ signingInMemoryKey not set — publications will NOT be signed.") - } -} \ No newline at end of file +// Binary-compatibility validator pins the public ABI under /api. +// Run `./gradlew :secure-vault:apiDump` after intentional API changes. From a004ebc4db4a665c92299ba63b0d42acecef3ede Mon Sep 17 00:00:00 2001 From: Alim Sourav Date: Tue, 26 May 2026 20:55:45 +0600 Subject: [PATCH 05/16] feat(secure-vault): introduce public common API Replace the placeholder expect fun platform() with the real library contract: - SecureVault interface: suspend put/get/remove/contains/clear/keys with documented @throws and a coroutine-first shape. - VaultException sealed hierarchy: InvalidKey, CryptoFailure, Tampered, StorageUnavailable. Lets common code catch all storage errors uniformly without depending on java.* or platform.Security.*. - VaultConfig immutable data class + Accessibility enum modelling Keychain kSecAttrAccessible* semantics, with a namespace regex validated at construction time (fail-fast). - SecureVaultFactory expect class - the single platform entry point that hides Context / Service constructor differences from common code. - Internal requireValidKey() helper so both backends enforce the same key contract. Every public symbol is documented with KDoc and explicit visibility (the module compiles with explicitApi = Strict). The Android and iOS actuals follow in the next commits. --- .../alimsrepo/secure/vault/SecureVault.kt | 87 +++++++++++++++++++ .../secure/vault/SecureVaultFactory.kt | 48 ++++++++++ .../alimsrepo/secure/vault/VaultConfig.kt | 69 +++++++++++++++ .../alimsrepo/secure/vault/VaultException.kt | 50 +++++++++++ .../secure/vault/internal/Validation.kt | 20 +++++ 5 files changed, 274 insertions(+) create mode 100644 secure-vault/src/commonMain/kotlin/io/github/alimsrepo/secure/vault/SecureVault.kt create mode 100644 secure-vault/src/commonMain/kotlin/io/github/alimsrepo/secure/vault/SecureVaultFactory.kt create mode 100644 secure-vault/src/commonMain/kotlin/io/github/alimsrepo/secure/vault/VaultConfig.kt create mode 100644 secure-vault/src/commonMain/kotlin/io/github/alimsrepo/secure/vault/VaultException.kt create mode 100644 secure-vault/src/commonMain/kotlin/io/github/alimsrepo/secure/vault/internal/Validation.kt diff --git a/secure-vault/src/commonMain/kotlin/io/github/alimsrepo/secure/vault/SecureVault.kt b/secure-vault/src/commonMain/kotlin/io/github/alimsrepo/secure/vault/SecureVault.kt new file mode 100644 index 0000000..36f7b7d --- /dev/null +++ b/secure-vault/src/commonMain/kotlin/io/github/alimsrepo/secure/vault/SecureVault.kt @@ -0,0 +1,87 @@ +/* + * Copyright 2026 Alim Sourav + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + */ +package io.github.alimsrepo.secure.vault + +/** + * A small, coroutine-first façade over the platform's native secure storage: + * + * * **Android** — [EncryptedSharedPreferences](https://developer.android.com/topic/security/data) + * backed by an Android Keystore master key (AES-256 GCM for values, AES-256 SIV for keys). + * * **iOS** — Keychain Services (`kSecClassGenericPassword`). + * + * Instances are obtained from [SecureVaultFactory] and are safe to share across + * coroutines. All suspending functions are I/O bound and dispatch onto an + * appropriate background dispatcher; callers do not need to switch contexts. + * + * Every method that interacts with the backend declares [VaultException]; callers + * should treat any other [Throwable] as a programming error. + * + * @since 0.1.0 + */ +public interface SecureVault { + + /** + * Stores [value] under [key], overwriting any previous value. + * + * @throws VaultException.InvalidKey if [key] is blank. + * @throws VaultException.CryptoFailure if encryption fails. + * @throws VaultException.StorageUnavailable if the backend cannot be reached. + */ + @Throws(VaultException::class) + public suspend fun put(key: String, value: String) + + /** + * Returns the value previously stored under [key], or `null` if absent. + * + * @throws VaultException.InvalidKey if [key] is blank. + * @throws VaultException.CryptoFailure if decryption fails. + * @throws VaultException.Tampered if the ciphertext failed an integrity check. + * @throws VaultException.StorageUnavailable if the backend cannot be reached. + */ + @Throws(VaultException::class) + public suspend fun get(key: String): String? + + /** + * Removes the entry associated with [key]. No-op if absent. + * + * @throws VaultException.InvalidKey if [key] is blank. + * @throws VaultException.StorageUnavailable if the backend cannot be reached. + */ + @Throws(VaultException::class) + public suspend fun remove(key: String) + + /** + * Returns `true` iff a value is currently stored under [key]. + * + * @throws VaultException.InvalidKey if [key] is blank. + * @throws VaultException.StorageUnavailable if the backend cannot be reached. + */ + @Throws(VaultException::class) + public suspend fun contains(key: String): Boolean + + /** + * Removes every entry inside this vault's namespace. + * + * @throws VaultException.StorageUnavailable if the backend cannot be reached. + */ + @Throws(VaultException::class) + public suspend fun clear() + + /** + * Returns a snapshot of every key currently stored in this vault's namespace. + * + * The returned set is a defensive copy; mutating it has no effect on the vault. + * + * @throws VaultException.StorageUnavailable if the backend cannot be reached. + */ + @Throws(VaultException::class) + public suspend fun keys(): Set +} + diff --git a/secure-vault/src/commonMain/kotlin/io/github/alimsrepo/secure/vault/SecureVaultFactory.kt b/secure-vault/src/commonMain/kotlin/io/github/alimsrepo/secure/vault/SecureVaultFactory.kt new file mode 100644 index 0000000..8bf9cb4 --- /dev/null +++ b/secure-vault/src/commonMain/kotlin/io/github/alimsrepo/secure/vault/SecureVaultFactory.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2026 Alim Sourav + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + */ +package io.github.alimsrepo.secure.vault + +/** + * Platform entry point for building [SecureVault] instances. + * + * The `actual` declaration on each platform supplies whatever environment the + * native backend needs (e.g. an Android `Context`) so that common code can + * stay platform-agnostic. + * + * ### Android + * ```kotlin + * val factory = SecureVaultFactory(context) + * val vault = factory.create(VaultConfig(namespace = "com.acme.auth")) + * ``` + * + * ### iOS (Kotlin) + * ```kotlin + * val vault = SecureVaultFactory().create(VaultConfig(namespace = "com.acme.auth")) + * ``` + * + * ### iOS (Swift) + * ```swift + * let vault = SecureVaultFactory().create( + * config: VaultConfig(namespace: "com.acme.auth", accessibility: .afterFirstUnlock) + * ) + * ``` + * + * @since 0.1.0 + */ +public expect class SecureVaultFactory { + + /** + * Builds a [SecureVault] for the given [config]. Multiple calls with the + * same [VaultConfig.namespace] return independent instances that address + * the same underlying storage. + */ + public fun create(config: VaultConfig): SecureVault +} + diff --git a/secure-vault/src/commonMain/kotlin/io/github/alimsrepo/secure/vault/VaultConfig.kt b/secure-vault/src/commonMain/kotlin/io/github/alimsrepo/secure/vault/VaultConfig.kt new file mode 100644 index 0000000..b2c6c12 --- /dev/null +++ b/secure-vault/src/commonMain/kotlin/io/github/alimsrepo/secure/vault/VaultConfig.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2026 Alim Sourav + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + */ +package io.github.alimsrepo.secure.vault + +/** + * Controls when stored values may be decrypted. + * + * Maps to Keychain `kSecAttrAccessible*` constants on iOS and to user-presence + * requirements on Android Keystore. Choose the strictest setting your UX allows. + * + * @since 0.1.0 + */ +public enum class Accessibility { + + /** + * Values are readable after the device has been unlocked **at least once** + * since boot. Survives screen lock — suitable for background work. + * + * Maps to `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` on iOS. + */ + AfterFirstUnlock, + + /** + * Values are readable **only while the device is currently unlocked**. + * Highest practical security for routine secrets; the OS may evict cached + * keys when the screen locks. + * + * Maps to `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` on iOS. + */ + WhenUnlocked, +} + +/** + * Immutable configuration for a [SecureVault] instance. + * + * Two vaults built with the same [namespace] address the same data; two vaults + * with different [namespace]s are isolated from each other (and from any other + * EncryptedSharedPreferences / Keychain entries this app uses). + * + * @property namespace Logical bucket name. Reverse-DNS is recommended, e.g. + * `"com.acme.app.auth"`. Must match [NAMESPACE_REGEX]. + * @property accessibility When the OS is allowed to decrypt entries. See [Accessibility]. + * + * @since 0.1.0 + */ +public data class VaultConfig( + val namespace: String, + val accessibility: Accessibility = Accessibility.AfterFirstUnlock, +) { + init { + require(namespace.isNotBlank()) { "namespace must not be blank" } + require(NAMESPACE_REGEX.matches(namespace)) { + "namespace '$namespace' must match $NAMESPACE_REGEX" + } + } + + public companion object { + /** Allowed characters and length for [namespace]. */ + public val NAMESPACE_REGEX: Regex = Regex("[A-Za-z0-9._-]{1,64}") + } +} + diff --git a/secure-vault/src/commonMain/kotlin/io/github/alimsrepo/secure/vault/VaultException.kt b/secure-vault/src/commonMain/kotlin/io/github/alimsrepo/secure/vault/VaultException.kt new file mode 100644 index 0000000..ae74054 --- /dev/null +++ b/secure-vault/src/commonMain/kotlin/io/github/alimsrepo/secure/vault/VaultException.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2026 Alim Sourav + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + */ +package io.github.alimsrepo.secure.vault + +/** + * Hierarchy of recoverable errors that [SecureVault] operations may raise. + * + * Every public [SecureVault] method either succeeds or throws a [VaultException]; + * platform-specific errors are wrapped so callers can write cross-platform + * `try`/`catch` without depending on `java.*` or `platform.Security.*`. + * + * @since 0.1.0 + */ +public sealed class VaultException( + message: String, + cause: Throwable? = null, +) : RuntimeException(message, cause) { + + /** The supplied key was blank or otherwise illegal. */ + public class InvalidKey(key: String) : + VaultException("Invalid key: '$key'. Keys must be non-blank.") + + /** A cryptographic primitive (cipher, key-derivation, AEAD) failed. */ + public class CryptoFailure(cause: Throwable) : + VaultException("Cryptographic operation failed: ${cause.message}", cause) + + /** + * The underlying ciphertext failed an integrity check — the value has been + * tampered with, the master key has rotated, or the data was written by a + * different app/installation. + */ + public class Tampered(cause: Throwable) : + VaultException("Stored value failed integrity check", cause) + + /** + * The backend (Keystore / Keychain / SharedPreferences file) could not be + * reached. Typically transient — retry after the device is unlocked or the + * user has authenticated. + */ + public class StorageUnavailable(cause: Throwable? = null) : + VaultException("Secure storage backend unavailable", cause) +} + diff --git a/secure-vault/src/commonMain/kotlin/io/github/alimsrepo/secure/vault/internal/Validation.kt b/secure-vault/src/commonMain/kotlin/io/github/alimsrepo/secure/vault/internal/Validation.kt new file mode 100644 index 0000000..f5d7541 --- /dev/null +++ b/secure-vault/src/commonMain/kotlin/io/github/alimsrepo/secure/vault/internal/Validation.kt @@ -0,0 +1,20 @@ +/* + * Copyright 2026 Alim Sourav + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + */ +package io.github.alimsrepo.secure.vault.internal + +import io.github.alimsrepo.secure.vault.VaultException + +/** + * Validates a user-supplied entry key. Centralised so every backend enforces + * the same contract. + * + * @throws VaultException.InvalidKey if [key] is blank. + */ +internal fun requireValidKey(key: String) { + if (key.isBlank()) throw VaultException.InvalidKey(key) +} + From 9010841a7a43acd171c3f0546139ebc1e420a455 Mon Sep 17 00:00:00 2001 From: Alim Sourav Date: Tue, 26 May 2026 20:57:02 +0600 Subject: [PATCH 06/16] feat(secure-vault/android): EncryptedSharedPreferences backend - SecureVaultFactory.android: actual class with Context constructor; stores applicationContext only to avoid leaking Activity/Fragment contexts. - AndroidSecureVault: internal implementation using Jetpack Security's EncryptedSharedPreferences with AES-256 SIV keys + AES-256 GCM values, wrapped by an Android Keystore master key (AES256_GCM scheme). - Lazy, mutex-guarded prefs initialisation so constructors stay non-blocking and concurrent first-use callers cannot race the file creation. - Per-namespace file via the secure_vault__ prefix, isolating our data from any other SharedPreferences the host app uses. - All blocking I/O dispatched on Dispatchers.IO; platform exceptions (AEADBadTagException, GeneralSecurityException, IOException) are mapped to VaultException.{Tampered, CryptoFailure, StorageUnavailable} via a small inline runCatchingStorage helper so java.* never leaks across the library boundary. --- .../secure/vault/AndroidSecureVault.kt | 125 ++++++++++++++++++ .../vault/SecureVaultFactory.android.kt | 23 ++++ 2 files changed, 148 insertions(+) create mode 100644 secure-vault/src/androidMain/kotlin/io/github/alimsrepo/secure/vault/AndroidSecureVault.kt create mode 100644 secure-vault/src/androidMain/kotlin/io/github/alimsrepo/secure/vault/SecureVaultFactory.android.kt diff --git a/secure-vault/src/androidMain/kotlin/io/github/alimsrepo/secure/vault/AndroidSecureVault.kt b/secure-vault/src/androidMain/kotlin/io/github/alimsrepo/secure/vault/AndroidSecureVault.kt new file mode 100644 index 0000000..bbe041c --- /dev/null +++ b/secure-vault/src/androidMain/kotlin/io/github/alimsrepo/secure/vault/AndroidSecureVault.kt @@ -0,0 +1,125 @@ +/* + * Copyright 2026 Alim Sourav + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + */ +package io.github.alimsrepo.secure.vault + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import io.github.alimsrepo.secure.vault.internal.requireValidKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.IOException +import java.security.GeneralSecurityException +import javax.crypto.AEADBadTagException + +/** + * [SecureVault] backed by Jetpack Security's [EncryptedSharedPreferences]. + * + * * **Keys** are encrypted with AES-256 SIV (deterministic — required so we can + * look entries up by name). + * * **Values** are encrypted with AES-256 GCM (authenticated, randomised IV). + * * The wrapping key is stored in the Android Keystore under the alias + * [MasterKey.DEFAULT_MASTER_KEY_ALIAS]; rotating it invalidates the file, + * which we surface as [VaultException.Tampered]. + * + * The prefs handle is created lazily on first I/O so that constructor calls + * stay non-blocking. All public methods dispatch onto [Dispatchers.IO]. + */ +internal class AndroidSecureVault( + private val appContext: Context, + private val config: VaultConfig, +) : SecureVault { + + private val fileName: String = PREFIX + config.namespace + private val initMutex = Mutex() + + @Volatile private var prefs: SharedPreferences? = null + + private suspend fun prefs(): SharedPreferences { + prefs?.let { return it } + return initMutex.withLock { + prefs ?: withContext(Dispatchers.IO) { + runCatchingStorage { + val masterKey = MasterKey.Builder(appContext) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + EncryptedSharedPreferences.create( + appContext, + fileName, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + }.also { prefs = it } + } + } + } + + override suspend fun put(key: String, value: String) { + requireValidKey(key) + withContext(Dispatchers.IO) { + runCatchingStorage { + prefs().edit().putString(key, value).apply() + } + } + } + + override suspend fun get(key: String): String? { + requireValidKey(key) + return withContext(Dispatchers.IO) { + runCatchingStorage { prefs().getString(key, null) } + } + } + + override suspend fun remove(key: String) { + requireValidKey(key) + withContext(Dispatchers.IO) { + runCatchingStorage { prefs().edit().remove(key).apply() } + } + } + + override suspend fun contains(key: String): Boolean { + requireValidKey(key) + return withContext(Dispatchers.IO) { + runCatchingStorage { prefs().contains(key) } + } + } + + override suspend fun clear() { + withContext(Dispatchers.IO) { + runCatchingStorage { prefs().edit().clear().apply() } + } + } + + override suspend fun keys(): Set = withContext(Dispatchers.IO) { + runCatchingStorage { prefs().all.keys.toSet() } + } + + /** + * Translates platform exceptions into [VaultException] subtypes. Kept inline + * so we never leak `java.*` types across the public API. + */ + private inline fun runCatchingStorage(block: () -> T): T = + try { + block() + } catch (e: AEADBadTagException) { + throw VaultException.Tampered(e) + } catch (e: GeneralSecurityException) { + throw VaultException.CryptoFailure(e) + } catch (e: IOException) { + throw VaultException.StorageUnavailable(e) + } + + private companion object { + /** Prefix isolates our files from any other SharedPreferences the host app uses. */ + const val PREFIX = "secure_vault__" + } +} + diff --git a/secure-vault/src/androidMain/kotlin/io/github/alimsrepo/secure/vault/SecureVaultFactory.android.kt b/secure-vault/src/androidMain/kotlin/io/github/alimsrepo/secure/vault/SecureVaultFactory.android.kt new file mode 100644 index 0000000..f0fe537 --- /dev/null +++ b/secure-vault/src/androidMain/kotlin/io/github/alimsrepo/secure/vault/SecureVaultFactory.android.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2026 Alim Sourav + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + */ +package io.github.alimsrepo.secure.vault + +import android.content.Context + +/** + * Android implementation. Hold on to the application [Context] only — never the + * supplied one — so factories created inside an Activity or Fragment do not + * leak it. + */ +public actual class SecureVaultFactory(context: Context) { + + private val appContext: Context = context.applicationContext + + public actual fun create(config: VaultConfig): SecureVault = + AndroidSecureVault(appContext, config) +} + From 949ac7edaa5219f1a600366db668a07c6f361ff5 Mon Sep 17 00:00:00 2001 From: Alim Sourav Date: Tue, 26 May 2026 21:00:59 +0600 Subject: [PATCH 07/16] feat(secure-vault/ios): Keychain Services backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SecureVaultFactory.ios: zero-arg actual class; the Keychain is process-wide so no platform handle is needed at construction time. - IosSecureVault: internal implementation using SecItem{Add,Update,Copy,Delete} with kSecClassGenericPassword and per-namespace kSecAttrService isolation. - Accessibility maps onto kSecAttrAccessible{AfterFirstUnlock,WhenUnlocked} ThisDeviceOnly so secrets never sync via iCloud Keychain or migrate to a different device. - Queries are constructed as NSMutableDictionary then toll-free-bridged to CFDictionaryRef; kSec* string constants are bridged via NSCopying for use as dictionary keys. All blocking calls dispatched on Dispatchers.Default. - OSStatus codes are translated to VaultException subtypes: errSecDecode -> Tampered, errSecDuplicateItem -> CryptoFailure, anything else -> StorageUnavailable. errSecItemNotFound is treated as a normal absent result rather than an error. - put() performs SecItemUpdate first and falls back to SecItemAdd on errSecItemNotFound — Keychain has no native upsert. --- .../secure/vault/SecureVaultFactory.ios.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 secure-vault/src/iosMain/kotlin/io/github/alimsrepo/secure/vault/SecureVaultFactory.ios.kt diff --git a/secure-vault/src/iosMain/kotlin/io/github/alimsrepo/secure/vault/SecureVaultFactory.ios.kt b/secure-vault/src/iosMain/kotlin/io/github/alimsrepo/secure/vault/SecureVaultFactory.ios.kt new file mode 100644 index 0000000..3e500ae --- /dev/null +++ b/secure-vault/src/iosMain/kotlin/io/github/alimsrepo/secure/vault/SecureVaultFactory.ios.kt @@ -0,0 +1,18 @@ +/* + * Copyright 2026 Alim Sourav + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + */ +package io.github.alimsrepo.secure.vault + +/** + * iOS implementation. The Keychain is a process-wide singleton, so no + * platform handle is required at construction time. + */ +public actual class SecureVaultFactory public actual constructor() { + + public actual fun create(config: VaultConfig): SecureVault = + IosSecureVault(config) +} + From e6c949d25470a77dcff75422f38e7183c7b0bf14 Mon Sep 17 00:00:00 2001 From: Alim Sourav Date: Tue, 26 May 2026 21:02:11 +0600 Subject: [PATCH 08/16] feat(secure-vault/ios): add Keychain Services implementation Adds the IosSecureVault class that was missed from the previous commit. Uses SecItem{Add,Update,Copy,Delete} on kSecClassGenericPassword items, scoped per VaultConfig.namespace via kSecAttrService. - Accessibility maps to kSecAttrAccessible{AfterFirstUnlock,WhenUnlocked} ThisDeviceOnly so secrets never sync via iCloud or migrate across devices. - Queries are NSMutableDictionary instances toll-free bridged to CFDictionaryRef; kSec* constants are bridged via NSCopying for use as dictionary keys. - All blocking calls dispatched on Dispatchers.Default. - OSStatus codes translate to VaultException subtypes: errSecDecode -> Tampered errSecDuplicateItem -> CryptoFailure other failures -> StorageUnavailable errSecItemNotFound is a normal absent-result, not an error. - put() performs SecItemUpdate first and falls back to SecItemAdd on errSecItemNotFound, since Keychain has no native upsert. --- .../alimsrepo/secure/vault/IosSecureVault.kt | 208 ++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 secure-vault/src/iosMain/kotlin/io/github/alimsrepo/secure/vault/IosSecureVault.kt diff --git a/secure-vault/src/iosMain/kotlin/io/github/alimsrepo/secure/vault/IosSecureVault.kt b/secure-vault/src/iosMain/kotlin/io/github/alimsrepo/secure/vault/IosSecureVault.kt new file mode 100644 index 0000000..77de014 --- /dev/null +++ b/secure-vault/src/iosMain/kotlin/io/github/alimsrepo/secure/vault/IosSecureVault.kt @@ -0,0 +1,208 @@ +/* + * Copyright 2026 Alim Sourav + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + */ +@file:OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) + +package io.github.alimsrepo.secure.vault + +import io.github.alimsrepo.secure.vault.internal.requireValidKey +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.value +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import platform.CoreFoundation.CFDictionaryRef +import platform.CoreFoundation.CFTypeRef +import platform.CoreFoundation.CFTypeRefVar +import platform.CoreFoundation.kCFBooleanTrue +import platform.Foundation.CFBridgingRelease +import platform.Foundation.NSCopyingProtocol +import platform.Foundation.NSData +import platform.Foundation.NSDictionary +import platform.Foundation.NSMutableDictionary +import platform.Foundation.NSString +import platform.Foundation.NSUTF8StringEncoding +import platform.Foundation.create +import platform.Foundation.dataUsingEncoding +import platform.Security.SecItemAdd +import platform.Security.SecItemCopyMatching +import platform.Security.SecItemDelete +import platform.Security.SecItemUpdate +import platform.Security.errSecDecode +import platform.Security.errSecDuplicateItem +import platform.Security.errSecItemNotFound +import platform.Security.errSecSuccess +import platform.Security.kSecAttrAccessible +import platform.Security.kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly +import platform.Security.kSecAttrAccessibleWhenUnlockedThisDeviceOnly +import platform.Security.kSecAttrAccount +import platform.Security.kSecAttrService +import platform.Security.kSecClass +import platform.Security.kSecClassGenericPassword +import platform.Security.kSecMatchLimit +import platform.Security.kSecMatchLimitAll +import platform.Security.kSecMatchLimitOne +import platform.Security.kSecReturnAttributes +import platform.Security.kSecReturnData +import platform.Security.kSecValueData +import platform.darwin.OSStatus + +/** + * [SecureVault] backed by iOS Keychain Services using `kSecClassGenericPassword` + * items, scoped per [VaultConfig.namespace] via `kSecAttrService`. + * + * The Keychain is an OS-managed encrypted store; no application-level + * cryptography is performed here. [VaultConfig.accessibility] is mapped onto + * the `kSecAttrAccessible*` family using the `*ThisDeviceOnly` variants so + * secrets are never restored to a different device. + * + * Queries are built as `NSMutableDictionary` instances and cast to + * `CFDictionaryRef` via toll-free bridging — the canonical Kotlin/Native + * pattern for the Keychain APIs. + */ +internal class IosSecureVault( + private val config: VaultConfig, +) : SecureVault { + + private val service: String = config.namespace + + override suspend fun put(key: String, value: String): Unit = withContext(Dispatchers.Default) { + requireValidKey(key) + val data = value.toNSData() + + val update = SecItemUpdate( + query = baseQuery(account = key).asCF(), + attributesToUpdate = NSMutableDictionary().apply { + setObject(data, forKey = kSecValueData.asNSCopyingKey()) + }.asCF(), + ) + when (update) { + errSecSuccess -> Unit + errSecItemNotFound -> { + val attrs = baseQuery(account = key).apply { + setObject(config.accessibility.cfValue, forKey = kSecAttrAccessible.asNSCopyingKey()) + setObject(data, forKey = kSecValueData.asNSCopyingKey()) + } + SecItemAdd(attrs.asCF(), null).requireSuccess() + } + else -> update.requireSuccess() + } + } + + override suspend fun get(key: String): String? = withContext(Dispatchers.Default) { + requireValidKey(key) + memScoped { + val out = alloc() + val query = baseQuery(account = key).apply { + setObject(kCFBooleanTrue, forKey = kSecReturnData.asNSCopyingKey()) + setObject(kSecMatchLimitOne, forKey = kSecMatchLimit.asNSCopyingKey()) + } + when (val status = SecItemCopyMatching(query.asCF(), out.ptr)) { + errSecSuccess -> (CFBridgingRelease(out.value) as? NSData)?.toUtf8String() + errSecItemNotFound -> null + else -> { status.requireSuccess(); null } + } + } + } + + override suspend fun remove(key: String): Unit = withContext(Dispatchers.Default) { + requireValidKey(key) + val status = SecItemDelete(baseQuery(account = key).asCF()) + if (status != errSecSuccess && status != errSecItemNotFound) status.requireSuccess() + } + + override suspend fun contains(key: String): Boolean = withContext(Dispatchers.Default) { + requireValidKey(key) + when (val status = SecItemCopyMatching(baseQuery(account = key).asCF(), null)) { + errSecSuccess -> true + errSecItemNotFound -> false + else -> { status.requireSuccess(); false } + } + } + + override suspend fun clear(): Unit = withContext(Dispatchers.Default) { + val status = SecItemDelete(serviceScopedQuery().asCF()) + if (status != errSecSuccess && status != errSecItemNotFound) status.requireSuccess() + } + + override suspend fun keys(): Set = withContext(Dispatchers.Default) { + memScoped { + val out = alloc() + val query = serviceScopedQuery().apply { + setObject(kCFBooleanTrue, forKey = kSecReturnAttributes.asNSCopyingKey()) + setObject(kSecMatchLimitAll, forKey = kSecMatchLimit.asNSCopyingKey()) + } + when (val status = SecItemCopyMatching(query.asCF(), out.ptr)) { + errSecSuccess -> { + @Suppress("UNCHECKED_CAST") + val list = CFBridgingRelease(out.value) as? List> + list.orEmpty() + .mapNotNull { it[kSecAttrAccount] as? String } + .toSet() + } + errSecItemNotFound -> emptySet() + else -> { status.requireSuccess(); emptySet() } + } + } + } + + // ------------------------------------------------------------------ + // Internals + // ------------------------------------------------------------------ + + private fun baseQuery(account: String): NSMutableDictionary = NSMutableDictionary().apply { + setObject(kSecClassGenericPassword!!, forKey = kSecClass.asNSCopyingKey()) + setObject(service as NSString, forKey = kSecAttrService.asNSCopyingKey()) + setObject(account as NSString, forKey = kSecAttrAccount.asNSCopyingKey()) + } + + private fun serviceScopedQuery(): NSMutableDictionary = NSMutableDictionary().apply { + setObject(kSecClassGenericPassword!!, forKey = kSecClass.asNSCopyingKey()) + setObject(service as NSString, forKey = kSecAttrService.asNSCopyingKey()) + } + + private val Accessibility.cfValue: CFTypeRef + get() = when (this) { + Accessibility.AfterFirstUnlock -> kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly!! + Accessibility.WhenUnlocked -> kSecAttrAccessibleWhenUnlockedThisDeviceOnly!! + } + + private fun OSStatus.requireSuccess() { + if (this == errSecSuccess) return + val msg = "Keychain error: OSStatus=$this" + throw when (this) { + errSecDecode -> VaultException.Tampered(RuntimeException(msg)) + errSecDuplicateItem -> VaultException.CryptoFailure(RuntimeException(msg)) + else -> VaultException.StorageUnavailable(RuntimeException(msg)) + } + } +} + +// ------------------------------------------------------------------ +// File-private bridging helpers (toll-free CF <-> Foundation) +// ------------------------------------------------------------------ + +@Suppress("UNCHECKED_CAST") +private fun NSDictionary.asCF(): CFDictionaryRef? = this as CFDictionaryRef? + +/** + * Keychain attribute constants are typed as `CFStringRef?` in cinterop bindings; + * they are toll-free bridged to `NSString`, which conforms to `NSCopying` and + * is therefore valid as an NSDictionary key. + */ +@Suppress("UNCHECKED_CAST") +private fun CFTypeRef?.asNSCopyingKey(): NSCopyingProtocol = this as NSCopyingProtocol + +private fun String.toNSData(): NSData = + (this as NSString).dataUsingEncoding(NSUTF8StringEncoding) + ?: throw VaultException.CryptoFailure(RuntimeException("UTF-8 encode failed")) + +private fun NSData.toUtf8String(): String? = + NSString.create(data = this, encoding = NSUTF8StringEncoding) as String? + From 2d4616ff91b1d8056286a5bf2a9b6bedfec6da13 Mon Sep 17 00:00:00 2001 From: Alim Sourav Date: Tue, 26 May 2026 21:03:38 +0600 Subject: [PATCH 09/16] test(secure-vault): add commonTest contract suite + in-memory fake - FakeSecureVault: internal, mutex-guarded in-memory implementation kept in commonTest so it never enters the published API surface. - SecureVaultContractTest: abstract behavioural contract every backend must satisfy (round-trip, overwrite, contains/remove idempotency, keys snapshot, clear, blank-key rejection). Subclassing keeps the assertions in one place; the iOS Keychain backend will plug into the same harness once simulator tests are wired up. - FakeSecureVaultTest: instantiates the fake against the contract. - VaultConfigTest: regression tests for the namespace regex and default Accessibility. Android Robolectric tests are intentionally deferred until AGP's com.android.kotlin.multiplatform.library plugin exposes a stable host-test surface; tracked for v0.2. --- .../alimsrepo/secure/vault/FakeSecureVault.kt | 52 +++++++++++ .../secure/vault/FakeSecureVaultTest.kt | 13 +++ .../secure/vault/SecureVaultContractTest.kt | 93 +++++++++++++++++++ .../alimsrepo/secure/vault/VaultConfigTest.kt | 39 ++++++++ 4 files changed, 197 insertions(+) create mode 100644 secure-vault/src/commonTest/kotlin/io/github/alimsrepo/secure/vault/FakeSecureVault.kt create mode 100644 secure-vault/src/commonTest/kotlin/io/github/alimsrepo/secure/vault/FakeSecureVaultTest.kt create mode 100644 secure-vault/src/commonTest/kotlin/io/github/alimsrepo/secure/vault/SecureVaultContractTest.kt create mode 100644 secure-vault/src/commonTest/kotlin/io/github/alimsrepo/secure/vault/VaultConfigTest.kt diff --git a/secure-vault/src/commonTest/kotlin/io/github/alimsrepo/secure/vault/FakeSecureVault.kt b/secure-vault/src/commonTest/kotlin/io/github/alimsrepo/secure/vault/FakeSecureVault.kt new file mode 100644 index 0000000..116b6e8 --- /dev/null +++ b/secure-vault/src/commonTest/kotlin/io/github/alimsrepo/secure/vault/FakeSecureVault.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2026 Alim Sourav + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + */ +package io.github.alimsrepo.secure.vault + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import io.github.alimsrepo.secure.vault.internal.requireValidKey + +/** + * In-memory [SecureVault] used only by the test source set. Kept here (not in + * commonMain) so it stays out of the published artifact's API surface. + * + * The behaviour mirrors [AndroidSecureVault] / [IosSecureVault]: blank keys + * throw [VaultException.InvalidKey]; absent reads return `null`; `clear()` + * empties only this instance's namespace. + */ +internal class FakeSecureVault : SecureVault { + + private val mutex = Mutex() + private val data = mutableMapOf() + + override suspend fun put(key: String, value: String) { + requireValidKey(key) + mutex.withLock { data[key] = value } + } + + override suspend fun get(key: String): String? { + requireValidKey(key) + return mutex.withLock { data[key] } + } + + override suspend fun remove(key: String) { + requireValidKey(key) + mutex.withLock { data.remove(key) } + } + + override suspend fun contains(key: String): Boolean { + requireValidKey(key) + return mutex.withLock { key in data } + } + + override suspend fun clear() { + mutex.withLock { data.clear() } + } + + override suspend fun keys(): Set = mutex.withLock { data.keys.toSet() } +} + diff --git a/secure-vault/src/commonTest/kotlin/io/github/alimsrepo/secure/vault/FakeSecureVaultTest.kt b/secure-vault/src/commonTest/kotlin/io/github/alimsrepo/secure/vault/FakeSecureVaultTest.kt new file mode 100644 index 0000000..ffe71f9 --- /dev/null +++ b/secure-vault/src/commonTest/kotlin/io/github/alimsrepo/secure/vault/FakeSecureVaultTest.kt @@ -0,0 +1,13 @@ +/* + * Copyright 2026 Alim Sourav + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + */ +package io.github.alimsrepo.secure.vault + +/** Runs the shared [SecureVaultContractTest] against the in-memory fake. */ +internal class FakeSecureVaultTest : SecureVaultContractTest() { + override suspend fun vault(): SecureVault = FakeSecureVault() +} + diff --git a/secure-vault/src/commonTest/kotlin/io/github/alimsrepo/secure/vault/SecureVaultContractTest.kt b/secure-vault/src/commonTest/kotlin/io/github/alimsrepo/secure/vault/SecureVaultContractTest.kt new file mode 100644 index 0000000..d5922b3 --- /dev/null +++ b/secure-vault/src/commonTest/kotlin/io/github/alimsrepo/secure/vault/SecureVaultContractTest.kt @@ -0,0 +1,93 @@ +/* + * Copyright 2026 Alim Sourav + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + */ +package io.github.alimsrepo.secure.vault + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Behavioural contract every [SecureVault] implementation must satisfy. + * + * Subclasses (or the in-memory [FakeSecureVault]) supply a fresh instance via + * [vault]. Platform-specific tests will eventually extend this class to verify + * the real backends without rewriting the assertions. + */ +internal abstract class SecureVaultContractTest { + + protected abstract suspend fun vault(): SecureVault + + @Test + fun put_then_get_roundtrips() = runTest { + val v = vault() + v.put("token", "s3cret") + assertEquals("s3cret", v.get("token")) + } + + @Test + fun get_returns_null_for_unknown_key() = runTest { + assertNull(vault().get("missing")) + } + + @Test + fun put_overwrites_existing_value() = runTest { + val v = vault() + v.put("k", "v1") + v.put("k", "v2") + assertEquals("v2", v.get("k")) + } + + @Test + fun contains_reflects_membership() = runTest { + val v = vault() + assertFalse(v.contains("k")) + v.put("k", "v") + assertTrue(v.contains("k")) + } + + @Test + fun remove_is_idempotent() = runTest { + val v = vault() + v.put("k", "v") + v.remove("k") + v.remove("k") // must not throw + assertNull(v.get("k")) + } + + @Test + fun keys_returns_current_snapshot() = runTest { + val v = vault() + v.put("a", "1") + v.put("b", "2") + assertEquals(setOf("a", "b"), v.keys()) + v.remove("a") + assertEquals(setOf("b"), v.keys()) + } + + @Test + fun clear_empties_the_namespace() = runTest { + val v = vault() + v.put("a", "1") + v.put("b", "2") + v.clear() + assertTrue(v.keys().isEmpty()) + } + + @Test + fun blank_key_is_rejected_on_every_operation() = runTest { + val v = vault() + assertFailsWith { v.put(" ", "x") } + assertFailsWith { v.get("") } + assertFailsWith { v.contains("") } + assertFailsWith { v.remove("") } + } +} + diff --git a/secure-vault/src/commonTest/kotlin/io/github/alimsrepo/secure/vault/VaultConfigTest.kt b/secure-vault/src/commonTest/kotlin/io/github/alimsrepo/secure/vault/VaultConfigTest.kt new file mode 100644 index 0000000..9cafc26 --- /dev/null +++ b/secure-vault/src/commonTest/kotlin/io/github/alimsrepo/secure/vault/VaultConfigTest.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2026 Alim Sourav + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + */ +package io.github.alimsrepo.secure.vault + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +internal class VaultConfigTest { + + @Test + fun blank_namespace_is_rejected() { + assertFailsWith { VaultConfig(namespace = "") } + assertFailsWith { VaultConfig(namespace = " ") } + } + + @Test + fun namespace_with_illegal_chars_is_rejected() { + assertFailsWith { VaultConfig(namespace = "a/b") } + assertFailsWith { VaultConfig(namespace = "café") } + } + + @Test + fun valid_namespaces_are_accepted() { + VaultConfig("com.acme.auth") + VaultConfig("a") + VaultConfig("a_b-c.0") + } + + @Test + fun default_accessibility_is_after_first_unlock() { + assertEquals(Accessibility.AfterFirstUnlock, VaultConfig("ns").accessibility) + } +} + From 4db325319d78bafcd76d6ef89db35d89b65d9a09 Mon Sep 17 00:00:00 2001 From: Alim Sourav Date: Tue, 26 May 2026 21:05:33 +0600 Subject: [PATCH 10/16] docs: project-wide documentation pass - Replace the IDE-template README with a library-focused one: badges, 60-second install snippet, supported targets table, Android + iOS usage, VaultException handling guide, threading notes, build/publish commands. - Add LICENSE (Apache-2.0) at the repo root so vanniktech-maven-publish and Maven Central pick it up alongside the POM block. - Add CHANGELOG.md following Keep a Changelog 1.1.0 + SemVer; seed it with the 0.1.0 entry, including explicit "known limitations" so consumers know what is and isn't in scope. - Add SECURITY.md with a responsible-disclosure policy, supported-version matrix, and an honest threat-model section that names the OS guarantees we inherit and the things SecureVault explicitly does *not* defend against. - Add an .editorconfig pinning Kotlin formatting (4-space indent, LF, trailing newline, 120-col max, trailing commas allowed) so contributors and CI see the same style. --- .editorconfig | 23 ++++++++ CHANGELOG.md | 39 +++++++++++++ LICENSE | 21 +++++++ README.md | 153 ++++++++++++++++++++++++++++++++++++++++++++------ SECURITY.md | 43 ++++++++++++++ 5 files changed, 262 insertions(+), 17 deletions(-) create mode 100644 .editorconfig create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 SECURITY.md diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f1f6fe8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,23 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{kt,kts}] +ij_kotlin_allow_trailing_comma = true +ij_kotlin_allow_trailing_comma_on_call_site = true +ij_kotlin_imports_layout = * +ij_kotlin_packages_to_use_import_on_demand = unset +max_line_length = 120 + +[*.{yml,yaml,toml,md}] +indent_size = 2 + +[Makefile] +indent_style = tab + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4ab65e1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,39 @@ +# Changelog + +All notable changes to **SecureVault KMP** are documented here. +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.0] - 2026-05-26 +### Added +- Initial public API: + - `SecureVault` interface — `put` / `get` / `remove` / `contains` / + `clear` / `keys`, all `suspend`. + - `SecureVaultFactory` `expect class` per platform. + - `VaultConfig` + `Accessibility` enum. + - `VaultException` sealed hierarchy + (`InvalidKey`, `CryptoFailure`, `Tampered`, `StorageUnavailable`). +- Android backend powered by + [`androidx.security:security-crypto`](https://developer.android.com/jetpack/androidx/releases/security) + (AES-256 SIV keys + AES-256 GCM values via Android Keystore). +- iOS backend powered by Keychain Services with `kSecClassGenericPassword` + and per-namespace `kSecAttrService` isolation. +- `commonTest` behavioural contract (`SecureVaultContractTest`) + in-memory + `FakeSecureVault`. +- Maven Central publishing pipeline (vanniktech 0.30, Dokka HTML javadoc jar, + signed) and binary-compatibility-validator-enforced public ABI. + +### Known limitations +- Android Robolectric tests are deferred until AGP's + `com.android.kotlin.multiplatform.library` plugin exposes a stable + host-test surface. +- `Accessibility.WhenUnlocked` on Android currently maps to the same + EncryptedSharedPreferences scheme as `AfterFirstUnlock`; full user-presence + enforcement (biometric prompt) is tracked for v0.2. +- No JVM/Desktop target yet; planned for v0.2. + +[Unreleased]: https://github.com/Alims-Repo/SecureVault-KMP/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/Alims-Repo/SecureVault-KMP/releases/tag/v0.1.0 + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..558cf3f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + Copyright 2026 Alim Sourav + + The full license text is available at: + https://www.apache.org/licenses/LICENSE-2.0.txt + diff --git a/README.md b/README.md index 912bba0..5d2a7b8 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,143 @@ -This is a Kotlin Multiplatform project targeting Android, iOS. +# SecureVault KMP -* [/iosApp](./iosApp/iosApp) contains an iOS application. Even if you’re sharing your UI with Compose Multiplatform, - you need this entry point for your iOS app. This is also where you should add SwiftUI code for your project. +[![Maven Central](https://img.shields.io/maven-central/v/io.github.alimsrepo/secure-vault?style=flat-square)](https://central.sonatype.com/artifact/io.github.alimsrepo/secure-vault) +[![License](https://img.shields.io/badge/license-Apache--2.0-blue?style=flat-square)](LICENSE) +[![Kotlin](https://img.shields.io/badge/kotlin-2.3-blueviolet?style=flat-square&logo=kotlin)](https://kotlinlang.org) +[![Targets](https://img.shields.io/badge/targets-Android%20%7C%20iOS-success?style=flat-square)](#supported-targets) -* [/shared](./shared/src) is for code that will be shared across your Compose Multiplatform applications. - It contains several subfolders: - - [commonMain](./shared/src/commonMain/kotlin) is for code that’s common for all targets. - - Other folders are for Kotlin code that will be compiled for only the platform indicated in the folder name. - For example, if you want to use Apple’s CoreCrypto for the iOS part of your Kotlin app, - the [iosMain](./shared/src/iosMain/kotlin) folder would be the right place for such calls. - Similarly, if you want to edit the Desktop (JVM) specific part, the [jvmMain](./shared/src/jvmMain/kotlin) - folder is the appropriate location. +A small, coroutine-first Kotlin Multiplatform library for storing secrets on +**Android** (`EncryptedSharedPreferences` over the Android Keystore) and +**iOS** (Keychain Services). One API, two native backends, no hand-rolled +cryptography. -### Running the apps +```kotlin +val vault = SecureVaultFactory(context /* Android only */) + .create(VaultConfig(namespace = "com.acme.auth")) -Use the run configurations provided by the run widget in your IDE's toolbar. You can also use these commands and options: - -- Android app: `./gradlew :androidApp:assembleDebug` -- iOS app: open the [/iosApp](./iosApp) directory in Xcode and run it from there. +vault.put("session", "eyJhbGciOiJIUzI1NiJ9…") +val token: String? = vault.get("session") +``` --- -Learn more about [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html)… \ No newline at end of file +## Install + +`secure-vault` is published to Maven Central. + +```kotlin +// build.gradle.kts +dependencies { + implementation("io.github.alimsrepo:secure-vault:0.1.0") +} +``` + +KMP source set wiring: + +```kotlin +kotlin { + sourceSets { + commonMain.dependencies { + implementation("io.github.alimsrepo:secure-vault:0.1.0") + } + } +} +``` + +## Supported targets + +| Target | Backend | +|----------------------|-----------------------------------| +| `android` | `EncryptedSharedPreferences` | +| `iosArm64` | Keychain Services | +| `iosSimulatorArm64` | Keychain Services | + +Common-side consumers depend on `secure-vault`; the matching platform +artefact is selected by Gradle metadata automatically. + +## Usage + +### Common code + +```kotlin +class AuthRepository(private val vault: SecureVault) { + + suspend fun saveSession(token: String) = vault.put("session", token) + + suspend fun session(): String? = vault.get("session") + + suspend fun logout() = vault.remove("session") +} +``` + +### Android entry point + +```kotlin +class App : Application() { + override fun onCreate() { + super.onCreate() + val vault = SecureVaultFactory(this) + .create(VaultConfig(namespace = "com.acme.auth")) + // pass `vault` to your DI graph + } +} +``` + +### iOS entry point (Swift) + +```swift +let vault = SecureVaultFactory().create( + config: VaultConfig( + namespace: "com.acme.auth", + accessibility: .afterFirstUnlock + ) +) +``` + +## Error handling + +Every storage method throws a single sealed type — `VaultException`: + +```kotlin +try { + vault.put("token", value) +} catch (e: VaultException.InvalidKey) { /* programmer error */ } + catch (e: VaultException.Tampered) { /* nuke the namespace */ } + catch (e: VaultException.CryptoFailure) { /* report */ } + catch (e: VaultException.StorageUnavailable) { /* retry / surface */ } +``` + +## Threading + +All suspending methods dispatch onto an I/O-appropriate dispatcher +(`Dispatchers.IO` on Android, `Dispatchers.Default` on iOS); callers do not +need to switch context. Instances are safe to share across coroutines. + +## Security model + +See [SECURITY.md](SECURITY.md) for guarantees, non-goals, and the +responsible-disclosure policy. + +## Building locally + +```bash +./gradlew :secure-vault:build +./gradlew :secure-vault:apiDump # after intentional API changes +./gradlew :secure-vault:publishToMavenLocal +``` + +Publication requires `~/.gradle/gradle.properties` to declare +`mavenCentralUsername`, `mavenCentralPassword`, and the in-memory +`signingInMemoryKey` / `signingInMemoryKeyPassword` properties consumed by +the [vanniktech maven-publish](https://vanniktech.github.io/gradle-maven-publish-plugin/) +plugin. + +## License + +``` +Copyright 2026 Alim Sourav + +Licensed under the Apache License, Version 2.0. +You may obtain a copy of the License at + https://www.apache.org/licenses/LICENSE-2.0 +``` + diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..088c25d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,43 @@ +# Security Policy + +## Supported versions + +| Version | Supported | +|---------|-----------| +| 0.1.x | ✅ | + +## Reporting a vulnerability + +Please do **not** open a public GitHub issue for security reports. + +Instead, email **sourav.0.alim@gmail.com** with: + +- A description of the issue and its impact. +- Reproduction steps or a proof-of-concept. +- The affected version(s). + +You should receive an acknowledgement within **72 hours**. A fix or +mitigation will be coordinated privately, followed by a patch release and +a public disclosure crediting the reporter (unless anonymity is requested). + +## Threat model & non-goals + +SecureVault wraps the platform's native secure storage: + +- **Android** — `EncryptedSharedPreferences` backed by the Android Keystore. +- **iOS** — Keychain Services (`kSecClassGenericPassword`, `*ThisDeviceOnly`). + +It therefore inherits the OS guarantees and limitations. In particular, +SecureVault does **not** protect against: + +- A rooted/jailbroken device, or any attacker with code execution inside + the host process. +- Backups: stored values are excluded from device backups by the underlying + schemes, but users can still snapshot their device with developer tools. +- Memory inspection while a value is in flight (`String` is decrypted in + user space). + +If your threat model requires hardware-backed user-presence enforcement, +set `Accessibility.WhenUnlocked` and (on Android, in a future release) +opt into biometric prompts. + From 4efbebdd6d1778165bfeca89ab7a57a8bf12994d Mon Sep 17 00:00:00 2001 From: Alim Sourav Date: Tue, 26 May 2026 21:06:51 +0600 Subject: [PATCH 11/16] fix(secure-vault): drop top-level group/version assignment Setting `group`/`version` eagerly at the top of the build script finalises the maven-publish plugin's `groupId`/`version` properties before the `mavenPublishing { coordinates(...) }` block can configure them, causing: The value for extension 'mavenPublishing' property 'groupId$plugin' is final and cannot be changed any further. Vanniktech's coordinates() already propagates the values to `project.group` / `project.version`, so the top-level assignment is redundant. Removing it restores configuration ordering and the build script now scripts correctly. --- secure-vault/build.gradle.kts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/secure-vault/build.gradle.kts b/secure-vault/build.gradle.kts index b50ded1..f9ab924 100644 --- a/secure-vault/build.gradle.kts +++ b/secure-vault/build.gradle.kts @@ -13,8 +13,10 @@ plugins { alias(libs.plugins.binaryCompatibilityValidator) } -group = providers.gradleProperty("GROUP").get() -version = providers.gradleProperty("VERSION_NAME").get() +// Note: do NOT set `group`/`version` at the top level — the +// `mavenPublishing { coordinates(...) }` block below propagates them to the +// project. Assigning them eagerly here would finalise the plugin's +// `groupId`/`version` properties and break `coordinates(...)`. kotlin { // Force every public declaration to carry an explicit visibility modifier. From bdc37a4949ced2204429bd6d3bb93965c01a2ccf Mon Sep 17 00:00:00 2001 From: Alim Sourav Date: Tue, 26 May 2026 21:08:17 +0600 Subject: [PATCH 12/16] fix(secure-vault): drive coordinates from gradle.properties only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Even after removing the top-level group/version assignment, the mavenPublishing { coordinates(...) } call still fails on AGP 9.x: property 'groupId$plugin' is final and cannot be changed any further Root cause: AGP's com.android.kotlin.multiplatform.library plugin finalises the maven-publish groupId Property as a side-effect of registering the AAR publication, before our script reaches coordinates(...). Fix: drop coordinates(...) entirely. vanniktech-maven-publish reads GROUP / POM_ARTIFACT_ID / VERSION_NAME from gradle.properties and applies them via plugin convention — i.e. *before* any other plugin can finalise the Property. Adds POM_ARTIFACT_ID=secure-vault to root gradle.properties and replaces the in-script coordinates(...) block with a comment explaining why. --- gradle.properties | 6 +++++- secure-vault/build.gradle.kts | 11 ++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/gradle.properties b/gradle.properties index 7f895d0..f8e0331 100644 --- a/gradle.properties +++ b/gradle.properties @@ -11,7 +11,11 @@ org.gradle.caching=true android.nonTransitiveRClass=true android.useAndroidX=true -# Published artifact coordinates (consumed by :secure-vault) +# Published artifact coordinates (consumed by :secure-vault via +# vanniktech-maven-publish, which reads GROUP / POM_ARTIFACT_ID / VERSION_NAME +# from gradle.properties automatically ? do not call coordinates(...) in the +# build script, that races with AGP's publication wiring on AGP 9.x). GROUP=io.github.alimsrepo +POM_ARTIFACT_ID=secure-vault VERSION_NAME=0.1.0 diff --git a/secure-vault/build.gradle.kts b/secure-vault/build.gradle.kts index f9ab924..a124df9 100644 --- a/secure-vault/build.gradle.kts +++ b/secure-vault/build.gradle.kts @@ -72,11 +72,12 @@ mavenPublishing { ), ) - coordinates( - groupId = providers.gradleProperty("GROUP").get(), - artifactId = "secure-vault", - version = providers.gradleProperty("VERSION_NAME").get(), - ) + // Coordinates intentionally NOT set here — vanniktech reads GROUP, + // POM_ARTIFACT_ID and VERSION_NAME from gradle.properties automatically. + // Calling coordinates(...) on AGP 9.x races with the Android KMP library + // plugin, which finalises groupId early when registering the AAR + // publication, producing: + // "property 'groupId$plugin' is final and cannot be changed any further". pom { name.set("SecureVault KMP") From 33b3fdad2518c0240f73b787be9124bb2ea260c3 Mon Sep 17 00:00:00 2001 From: Alim Sourav Date: Tue, 26 May 2026 21:12:41 +0600 Subject: [PATCH 13/16] fixed group id --- gradle.properties | 2 +- secure-vault/build.gradle.kts | 8 -------- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/gradle.properties b/gradle.properties index f8e0331..1b3a7fb 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,7 +15,7 @@ android.useAndroidX=true # vanniktech-maven-publish, which reads GROUP / POM_ARTIFACT_ID / VERSION_NAME # from gradle.properties automatically ? do not call coordinates(...) in the # build script, that races with AGP's publication wiring on AGP 9.x). -GROUP=io.github.alimsrepo +GROUP=io.github.alims-repo POM_ARTIFACT_ID=secure-vault VERSION_NAME=0.1.0 diff --git a/secure-vault/build.gradle.kts b/secure-vault/build.gradle.kts index a124df9..e20c717 100644 --- a/secure-vault/build.gradle.kts +++ b/secure-vault/build.gradle.kts @@ -13,11 +13,6 @@ plugins { alias(libs.plugins.binaryCompatibilityValidator) } -// Note: do NOT set `group`/`version` at the top level — the -// `mavenPublishing { coordinates(...) }` block below propagates them to the -// project. Assigning them eagerly here would finalise the plugin's -// `groupId`/`version` properties and break `coordinates(...)`. - kotlin { // Force every public declaration to carry an explicit visibility modifier. explicitApi = ExplicitApiMode.Strict @@ -120,6 +115,3 @@ mavenPublishing { publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL, automaticRelease = false) signAllPublications() } - -// Binary-compatibility validator pins the public ABI under /api. -// Run `./gradlew :secure-vault:apiDump` after intentional API changes. From c460f5e5645527da45f4305c5cb718d52aba86a3 Mon Sep 17 00:00:00 2001 From: Alim Sourav Date: Tue, 26 May 2026 21:15:50 +0600 Subject: [PATCH 14/16] fixed ci/cd --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 59a87c7..8ed7faa 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -32,7 +32,7 @@ jobs: # SIGNING_KEY = base64-encoded armored private key (avoids multiline env-var issues) run: | export ORG_GRADLE_PROJECT_signingInMemoryKey=$(echo "$SIGNING_KEY_B64" | base64 -d) - ./gradlew :crash-guard:publishAllPublicationsToMavenCentralRepository --no-configuration-cache + ./gradlew :secure-vault:publishAllPublicationsToMavenCentralRepository --no-configuration-cache env: ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} From afd8ac42b4732155b8375940600e1d9c8ec7c367 Mon Sep 17 00:00:00 2001 From: Alim Sourav Date: Tue, 26 May 2026 21:17:00 +0600 Subject: [PATCH 15/16] docs: add GitHub Pages landing site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-file, dependency-free landing page at docs/index.html, ready to be served from the repo's GitHub Pages "/docs on main" source. - Dark, modern theme with gradient hero, sticky blurred navbar, custom SVG brand mark; Inter + JetBrains Mono via Google Fonts. - Sections: hero with one-line pitch and primary/secondary CTAs, tabbed install card (Kotlin DSL / version catalog / Groovy DSL), why-secure-vault feature grid (OS-native crypto, coroutine-first, sealed errors, namespaces, tiny API, ABI-stable), Android+iOS usage cards styled as IDE tabs, API table, supported-targets matrix, footer with Maven Central / changelog / security links. - Vanilla JS only: tab switching + copy-to-clipboard for the install snippet. No build step, no bundler, no framework — drop-in for Pages. - Inline minimal Kotlin/Swift syntax highlighting via .tk-* token classes so the page stays one file with zero runtime deps. --- docs/index.html | 797 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 797 insertions(+) create mode 100644 docs/index.html diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..0c1590d --- /dev/null +++ b/docs/index.html @@ -0,0 +1,797 @@ + + + + + + SecureVault KMP — secrets storage for Kotlin Multiplatform + + + + + + + + + + + + + + + + + +
+ +
+ +
+ + +
+
+
+ + v0.1.0 — Now on Maven Central +
+

Secrets storage,
done right for KMP.

+

+ A coroutine-first Kotlin Multiplatform library that wraps native secure storage — + EncryptedSharedPreferences on Android + and Keychain Services on iOS — behind one + tiny, suspendable API. No hand-rolled crypto. +

+ + + +
+
+ + + +
+
+ + +
+
dependencies {
+    implementation("io.github.alimsrepo:secure-vault:0.1.0")
+}
+
+ +
+
# gradle/libs.versions.toml
+[libraries]
+secure-vault = { module = "io.github.alimsrepo:secure-vault", version = "0.1.0" }
+
+ +
+
dependencies {
+    implementation 'io.github.alimsrepo:secure-vault:0.1.0'
+}
+
+
+
+
+
+ + +
+
+
Why SecureVault
+

Designed like a library you'd actually want to depend on.

+

+ Small surface, sealed errors, suspending I/O, explicit visibility — every public symbol earns its place. +

+ +
+
+
+ + + +
+

OS-native crypto

+

No bespoke ciphers. AES-256-GCM via the Android Keystore on one side, Keychain Services on the other — you inherit OS guarantees, not custom risk.

+
+ +
+
+ + + +
+

Coroutine-first

+

Every operation is suspend and dispatches onto the right I/O dispatcher per platform. No manual withContext, no blocked main threads.

+
+ +
+
+ + + +
+

Sealed errors

+

One VaultException hierarchy: InvalidKey, CryptoFailure, Tampered, StorageUnavailable. Catch what matters, ignore platform noise.

+
+ +
+
+ + + +
+

Namespaced

+

Vaults are scoped per VaultConfig.namespace. Isolate auth tokens from feature flags, or one product from another in the same app.

+
+ +
+
+ + + +
+

Tiny API

+

Six suspending methods total: put, get, remove, contains, clear, keys. That's the whole library.

+
+ +
+
+ + + +
+

ABI-stable

+

Public surface pinned with binary-compatibility-validator. Patch releases never break consumers — by construction.

+
+
+
+
+ + +
+
+
Usage
+

One API. Two native backends.

+

+ Write your business logic against SecureVault in commonMain. Build the right factory once per platform. +

+ +
+
+
+
+ + commonMain/AuthRepository.kt +
+
class AuthRepository(
+    private val vault: SecureVault,
+) {
+    suspend fun saveSession(token: String) =
+        vault.put("session", token)
+
+    suspend fun session(): String? =
+        vault.get("session")
+
+    suspend fun logout() =
+        vault.remove("session")
+}
+
+
+
+
+
+ + androidMain/App.kt +
+
class App : Application() {
+    override fun onCreate() {
+        super.onCreate()
+        val vault = SecureVaultFactory(this)
+            .create(VaultConfig("com.acme.auth"))
+    }
+}
+
+
+
+ + iosApp/ContentView.swift +
+
let vault = SecureVaultFactory().create(
+    config: VaultConfig(
+        namespace: "com.acme.auth",
+        accessibility: .afterFirstUnlock
+    )
+)
+
+
+
+
+
+ + +
+
+
API at a glance
+

Six methods. That's the whole contract.

+

+ All methods are suspend and may throw VaultException. Full KDoc shipped in the artefact's sources jar. +

+ + + + + + + + + + + + + +
SignatureDescription
put(key, value)Store a value, overwriting any previous one.
get(key)Return the value for key, or null if absent.
remove(key)Delete the entry. Idempotent — no-op if the key isn't present.
contains(key)Return true if a value is currently stored under key.
clear()Remove every entry inside this vault's namespace.
keys()Snapshot of the current key set in this namespace.
+
+
+ + +
+
+
Supported targets
+

Where it runs.

+

More targets (JVM/Desktop, watchOS) are planned for v0.2 — see the changelog for the roadmap.

+ + + + + + + + + + +
TargetBackend
androidEncryptedSharedPreferences (Android Keystore)
iosArm64Keychain Services (kSecClassGenericPassword)
iosSimulatorArm64Keychain Services (kSecClassGenericPassword)
+
+
+ +
+ + + + + + + + From e03225ae6f0272a7be43aa8ffb4c2727c62a5df0 Mon Sep 17 00:00:00 2001 From: Alim Sourav Date: Tue, 26 May 2026 21:43:38 +0600 Subject: [PATCH 16/16] fixed code errors --- gradle.properties | 4 ++-- secure-vault/build.gradle.kts | 8 +------- .../github/alimsrepo/secure/vault/SecureVault.kt | 14 ++++++++------ .../secure/vault/SecureVaultFactory.ios.kt | 2 +- 4 files changed, 12 insertions(+), 16 deletions(-) diff --git a/gradle.properties b/gradle.properties index 1b3a7fb..e243bb7 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,8 +4,8 @@ kotlin.daemon.jvmargs=-Xmx3072M #Gradle org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8 -org.gradle.configuration-cache=true -org.gradle.caching=true +org.gradle.configuration-cache=false +org.gradle.caching=false #Android android.nonTransitiveRClass=true diff --git a/secure-vault/build.gradle.kts b/secure-vault/build.gradle.kts index e20c717..a789139 100644 --- a/secure-vault/build.gradle.kts +++ b/secure-vault/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.androidMultiplatformLibrary) alias(libs.plugins.androidLint) + alias(libs.plugins.mavenPublish) alias(libs.plugins.dokka) alias(libs.plugins.binaryCompatibilityValidator) @@ -67,13 +68,6 @@ mavenPublishing { ), ) - // Coordinates intentionally NOT set here — vanniktech reads GROUP, - // POM_ARTIFACT_ID and VERSION_NAME from gradle.properties automatically. - // Calling coordinates(...) on AGP 9.x races with the Android KMP library - // plugin, which finalises groupId early when registering the AAR - // publication, producing: - // "property 'groupId$plugin' is final and cannot be changed any further". - pom { name.set("SecureVault KMP") description.set( diff --git a/secure-vault/src/commonMain/kotlin/io/github/alimsrepo/secure/vault/SecureVault.kt b/secure-vault/src/commonMain/kotlin/io/github/alimsrepo/secure/vault/SecureVault.kt index 36f7b7d..746653c 100644 --- a/secure-vault/src/commonMain/kotlin/io/github/alimsrepo/secure/vault/SecureVault.kt +++ b/secure-vault/src/commonMain/kotlin/io/github/alimsrepo/secure/vault/SecureVault.kt @@ -9,6 +9,8 @@ */ package io.github.alimsrepo.secure.vault +import kotlin.coroutines.cancellation.CancellationException + /** * A small, coroutine-first façade over the platform's native secure storage: * @@ -34,7 +36,7 @@ public interface SecureVault { * @throws VaultException.CryptoFailure if encryption fails. * @throws VaultException.StorageUnavailable if the backend cannot be reached. */ - @Throws(VaultException::class) + @Throws(VaultException::class, CancellationException::class) public suspend fun put(key: String, value: String) /** @@ -45,7 +47,7 @@ public interface SecureVault { * @throws VaultException.Tampered if the ciphertext failed an integrity check. * @throws VaultException.StorageUnavailable if the backend cannot be reached. */ - @Throws(VaultException::class) + @Throws(VaultException::class, CancellationException::class) public suspend fun get(key: String): String? /** @@ -54,7 +56,7 @@ public interface SecureVault { * @throws VaultException.InvalidKey if [key] is blank. * @throws VaultException.StorageUnavailable if the backend cannot be reached. */ - @Throws(VaultException::class) + @Throws(VaultException::class, CancellationException::class) public suspend fun remove(key: String) /** @@ -63,7 +65,7 @@ public interface SecureVault { * @throws VaultException.InvalidKey if [key] is blank. * @throws VaultException.StorageUnavailable if the backend cannot be reached. */ - @Throws(VaultException::class) + @Throws(VaultException::class, CancellationException::class) public suspend fun contains(key: String): Boolean /** @@ -71,7 +73,7 @@ public interface SecureVault { * * @throws VaultException.StorageUnavailable if the backend cannot be reached. */ - @Throws(VaultException::class) + @Throws(VaultException::class, CancellationException::class) public suspend fun clear() /** @@ -81,7 +83,7 @@ public interface SecureVault { * * @throws VaultException.StorageUnavailable if the backend cannot be reached. */ - @Throws(VaultException::class) + @Throws(VaultException::class, CancellationException::class) public suspend fun keys(): Set } diff --git a/secure-vault/src/iosMain/kotlin/io/github/alimsrepo/secure/vault/SecureVaultFactory.ios.kt b/secure-vault/src/iosMain/kotlin/io/github/alimsrepo/secure/vault/SecureVaultFactory.ios.kt index 3e500ae..c762a3f 100644 --- a/secure-vault/src/iosMain/kotlin/io/github/alimsrepo/secure/vault/SecureVaultFactory.ios.kt +++ b/secure-vault/src/iosMain/kotlin/io/github/alimsrepo/secure/vault/SecureVaultFactory.ios.kt @@ -10,7 +10,7 @@ package io.github.alimsrepo.secure.vault * iOS implementation. The Keychain is a process-wide singleton, so no * platform handle is required at construction time. */ -public actual class SecureVaultFactory public actual constructor() { +public actual class SecureVaultFactory public constructor() { public actual fun create(config: VaultConfig): SecureVault = IosSecureVault(config)