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/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 0000000..8ed7faa
--- /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 :secure-vault: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/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.
+[](https://central.sonatype.com/artifact/io.github.alimsrepo/secure-vault)
+[](LICENSE)
+[](https://kotlinlang.org)
+[](#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.
+
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/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.
+
+
+
+
+
+
+ build.gradle.kts
+ libs.versions.toml
+ build.gradle
+
+
+
Copy
+
+
+
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.
+
+
+
+
+
+
+
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" )
+}
+
+
+
+
+
+
class App : Application () {
+ override fun onCreate () {
+ super .onCreate ()
+ val vault = SecureVaultFactory (this )
+ .create (VaultConfig ("com.acme.auth" ))
+ }
+}
+
+
+
+
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.
+
+
+
+
+ Signature Description
+
+
+ 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.
+
+
+
+ Target Backend
+
+
+ android EncryptedSharedPreferences (Android Keystore)
+ iosArm64 Keychain Services (kSecClassGenericPassword)
+ iosSimulatorArm64 Keychain Services (kSecClassGenericPassword)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/gradle.properties b/gradle.properties
index 6f8e6ea..e243bb7 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -4,9 +4,18 @@ 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
-android.useAndroidX=true
\ No newline at end of file
+android.useAndroidX=true
+
+# 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.alims-repo
+POM_ARTIFACT_ID=secure-vault
+VERSION_NAME=0.1.0
+
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index db8dd88..d8153e5 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -8,11 +8,20 @@ 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"
[libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
@@ -32,10 +41,22 @@ 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" }
+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" }
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" }
+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" }
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..a789139
--- /dev/null
+++ b/secure-vault/build.gradle.kts
@@ -0,0 +1,111 @@
+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)
+
+ alias(libs.plugins.mavenPublish)
+ alias(libs.plugins.dokka)
+ alias(libs.plugins.binaryCompatibilityValidator)
+}
+
+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) }
+ minSdk = 24
+
+ compilations.configureEach {
+ compileTaskProvider.configure {
+ compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
+ }
+ }
+ }
+
+ val xcfName = "SecureVaultKit"
+ listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
+ target.binaries.framework {
+ baseName = xcfName
+ isStatic = true
+ }
+ }
+
+ sourceSets {
+ commonMain.dependencies {
+ api(libs.kotlinx.coroutines.core)
+ }
+ commonTest.dependencies {
+ implementation(libs.kotlin.test)
+ implementation(libs.kotlinx.coroutines.test)
+ }
+ androidMain.dependencies {
+ implementation(libs.androidx.security.crypto)
+ implementation(libs.kotlinx.coroutines.android)
+ }
+ }
+}
+
+mavenPublishing {
+ configure(
+ KotlinMultiplatform(
+ javadocJar = JavadocJar.Dokka("dokkaHtml"),
+ sourcesJar = true,
+ ),
+ )
+
+ pom {
+ 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")
+
+ licenses {
+ 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("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://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, automaticRelease = false)
+ signAllPublications()
+}
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/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)
+}
+
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..746653c
--- /dev/null
+++ b/secure-vault/src/commonMain/kotlin/io/github/alimsrepo/secure/vault/SecureVault.kt
@@ -0,0 +1,89 @@
+/*
+ * 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
+
+import kotlin.coroutines.cancellation.CancellationException
+
+/**
+ * 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, CancellationException::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, CancellationException::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, CancellationException::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, CancellationException::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, CancellationException::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, CancellationException::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)
+}
+
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)
+ }
+}
+
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?
+
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..c762a3f
--- /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 constructor() {
+
+ public actual fun create(config: VaultConfig): SecureVault =
+ IosSecureVault(config)
+}
+
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")