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. +[![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. + 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. +

+ + + +
+
+ + + +
+
+ + +
+
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)
+
+
+ +
+ + + + + + + + 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")