@capnative/capacitor-secure-storage 0.0.0-stage → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CapacitorSecureStorage.podspec +17 -0
- package/LICENSE +21 -0
- package/Package.swift +28 -0
- package/README.md +154 -2
- package/android/build.gradle +60 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/io/github/marioshtika/securestorage/EncryptedPayload.kt +35 -0
- package/android/src/main/java/io/github/marioshtika/securestorage/KeystoreCipher.kt +107 -0
- package/android/src/main/java/io/github/marioshtika/securestorage/SecureStorage.kt +80 -0
- package/android/src/main/java/io/github/marioshtika/securestorage/SecureStorageException.kt +21 -0
- package/android/src/main/java/io/github/marioshtika/securestorage/SecureStoragePlugin.kt +64 -0
- package/dist/esm/definitions.d.ts +46 -0
- package/dist/esm/definitions.js +3 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.js +7 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/web.d.ts +27 -0
- package/dist/esm/web.js +24 -0
- package/dist/esm/web.js.map +1 -0
- package/ios/Sources/SecureStoragePlugin/KeychainStore.swift +120 -0
- package/ios/Sources/SecureStoragePlugin/SecureStorageError.swift +27 -0
- package/ios/Sources/SecureStoragePlugin/SecureStoragePlugin.swift +73 -0
- package/ios/Tests/SecureStoragePluginTests/KeychainStoreTests.swift +99 -0
- package/package.json +60 -4
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
|
|
4
|
+
|
|
5
|
+
Pod::Spec.new do |s|
|
|
6
|
+
s.name = 'CapacitorSecureStorage'
|
|
7
|
+
s.version = package['version']
|
|
8
|
+
s.summary = package['description']
|
|
9
|
+
s.license = package['license']
|
|
10
|
+
s.homepage = 'https://github.com/marioshtika/capacitor-secure-storage'
|
|
11
|
+
s.author = package['author']
|
|
12
|
+
s.source = { :git => 'https://github.com/marioshtika/capacitor-secure-storage.git', :tag => s.version.to_s }
|
|
13
|
+
s.source_files = 'ios/Sources/**/*.{swift,h,m,c,cc,mm,cpp}'
|
|
14
|
+
s.ios.deployment_target = '15.0'
|
|
15
|
+
s.dependency 'Capacitor'
|
|
16
|
+
s.swift_version = '5.9'
|
|
17
|
+
end
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 marioshtika
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/Package.swift
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// swift-tools-version: 5.9
|
|
2
|
+
import PackageDescription
|
|
3
|
+
|
|
4
|
+
let package = Package(
|
|
5
|
+
name: "CapacitorSecureStorage",
|
|
6
|
+
platforms: [.iOS(.v15)],
|
|
7
|
+
products: [
|
|
8
|
+
.library(
|
|
9
|
+
name: "CapacitorSecureStorage",
|
|
10
|
+
targets: ["SecureStoragePlugin"])
|
|
11
|
+
],
|
|
12
|
+
dependencies: [
|
|
13
|
+
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0")
|
|
14
|
+
],
|
|
15
|
+
targets: [
|
|
16
|
+
.target(
|
|
17
|
+
name: "SecureStoragePlugin",
|
|
18
|
+
dependencies: [
|
|
19
|
+
.product(name: "Capacitor", package: "capacitor-swift-pm"),
|
|
20
|
+
.product(name: "Cordova", package: "capacitor-swift-pm")
|
|
21
|
+
],
|
|
22
|
+
path: "ios/Sources/SecureStoragePlugin"),
|
|
23
|
+
.testTarget(
|
|
24
|
+
name: "SecureStoragePluginTests",
|
|
25
|
+
dependencies: ["SecureStoragePlugin"],
|
|
26
|
+
path: "ios/Tests/SecureStoragePluginTests")
|
|
27
|
+
]
|
|
28
|
+
)
|
package/README.md
CHANGED
|
@@ -1,3 +1,155 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Capacitor Secure Storage
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A Capacitor plugin for storing string key-value data securely using the **iOS Keychain** and the **Android Keystore** (AES-256-GCM).
|
|
4
|
+
|
|
5
|
+
## Compatibility
|
|
6
|
+
|
|
7
|
+
| Plugin version | Capacitor compatibility | Maintained |
|
|
8
|
+
| -------------- | ----------------------- | ---------- |
|
|
9
|
+
| v8.\*.\* | v8.\*.\* | ✅ |
|
|
10
|
+
|
|
11
|
+
## Supported platforms
|
|
12
|
+
|
|
13
|
+
| Platform | Support | Minimum version |
|
|
14
|
+
| -------- | ------- | -------------------------- |
|
|
15
|
+
| iOS | ✅ | iOS 15 |
|
|
16
|
+
| Android | ✅ | API level 24 (Android 7.0) |
|
|
17
|
+
| Web | ❌ | — |
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install @capnative/capacitor-secure-storage
|
|
23
|
+
npx cap sync
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Basic usage
|
|
27
|
+
|
|
28
|
+
```typescript
|
|
29
|
+
import { SecureStorage } from '@capnative/capacitor-secure-storage';
|
|
30
|
+
|
|
31
|
+
await SecureStorage.set({
|
|
32
|
+
key: 'access_token',
|
|
33
|
+
value: 'my-secret-token'
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const { value } = await SecureStorage.get({
|
|
37
|
+
key: 'access_token'
|
|
38
|
+
});
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## API
|
|
42
|
+
|
|
43
|
+
<docgen-index>
|
|
44
|
+
|
|
45
|
+
* [`set(...)`](#set)
|
|
46
|
+
* [`get(...)`](#get)
|
|
47
|
+
* [`has(...)`](#has)
|
|
48
|
+
* [`remove(...)`](#remove)
|
|
49
|
+
* [`clear()`](#clear)
|
|
50
|
+
|
|
51
|
+
</docgen-index>
|
|
52
|
+
|
|
53
|
+
<docgen-api>
|
|
54
|
+
<!--Update the source file JSDoc comments and rerun docgen to update the docs below-->
|
|
55
|
+
|
|
56
|
+
### set(...)
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
set(options: { key: string; value: string; }) => Promise<void>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Stores a string value securely. If the key already exists, its value is overwritten. Empty string values are allowed.
|
|
63
|
+
|
|
64
|
+
**Options:**
|
|
65
|
+
|
|
66
|
+
| Param | Type |
|
|
67
|
+
| --- | --- |
|
|
68
|
+
| **`options`** | <code>{ key: string; value: string; }</code> |
|
|
69
|
+
|
|
70
|
+
--------------------
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
### get(...)
|
|
74
|
+
|
|
75
|
+
```typescript
|
|
76
|
+
get(options: { key: string; }) => Promise<{ value: string | null; }>
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Retrieves a stored value. The value is `null` if the key does not exist.
|
|
80
|
+
|
|
81
|
+
**Options:**
|
|
82
|
+
|
|
83
|
+
| Param | Type |
|
|
84
|
+
| --- | --- |
|
|
85
|
+
| **`options`** | <code>{ key: string; }</code> |
|
|
86
|
+
|
|
87
|
+
**Returns:** <code>Promise<{ value: string | null; }></code>
|
|
88
|
+
|
|
89
|
+
--------------------
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
### has(...)
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
has(options: { key: string; }) => Promise<{ value: boolean; }>
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Checks whether a key exists.
|
|
99
|
+
|
|
100
|
+
**Options:**
|
|
101
|
+
|
|
102
|
+
| Param | Type |
|
|
103
|
+
| --- | --- |
|
|
104
|
+
| **`options`** | <code>{ key: string; }</code> |
|
|
105
|
+
|
|
106
|
+
**Returns:** <code>Promise<{ value: boolean; }></code>
|
|
107
|
+
|
|
108
|
+
--------------------
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
### remove(...)
|
|
112
|
+
|
|
113
|
+
```typescript
|
|
114
|
+
remove(options: { key: string; }) => Promise<void>
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Deletes a key. Succeeds even if the key does not exist.
|
|
118
|
+
|
|
119
|
+
**Options:**
|
|
120
|
+
|
|
121
|
+
| Param | Type |
|
|
122
|
+
| --- | --- |
|
|
123
|
+
| **`options`** | <code>{ key: string; }</code> |
|
|
124
|
+
|
|
125
|
+
--------------------
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
### clear()
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
clear() => Promise<void>
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Deletes every value stored by this plugin and nothing else.
|
|
135
|
+
|
|
136
|
+
--------------------
|
|
137
|
+
|
|
138
|
+
</docgen-api>
|
|
139
|
+
|
|
140
|
+
Keys must be non-empty and at most 256 UTF-8 bytes. Values must be strings.
|
|
141
|
+
|
|
142
|
+
## Errors
|
|
143
|
+
|
|
144
|
+
If an operation fails, its promise rejects with a stable `code` and a safe message. Handle errors using `code`, not the message text.
|
|
145
|
+
|
|
146
|
+
| Code | Platform | Meaning |
|
|
147
|
+
| ---- | -------- | ------- |
|
|
148
|
+
| `INVALID_KEY` | iOS, Android | The key is empty or exceeds 256 UTF-8 bytes. |
|
|
149
|
+
| `INVALID_VALUE` | iOS, Android | The value passed to `set` is not a string. |
|
|
150
|
+
| `STORAGE_ERROR` | iOS, Android | An unexpected storage operation failed, or Android could not persist a change. |
|
|
151
|
+
| `KEYCHAIN_ERROR` | iOS | The iOS Keychain operation failed. |
|
|
152
|
+
| `KEYSTORE_ERROR` | Android | The Android Keystore could not be accessed or updated. |
|
|
153
|
+
| `ENCRYPTION_ERROR` | Android | The value could not be encrypted. |
|
|
154
|
+
| `DECRYPTION_ERROR` | Android | The stored value is corrupt, tampered with, or cannot be decrypted with the available Keystore key. |
|
|
155
|
+
| `UNAVAILABLE` | Web | Secure storage is not supported on the web; there is no insecure storage fallback. |
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
ext {
|
|
2
|
+
junitVersion = project.hasProperty('junitVersion') ? rootProject.ext.junitVersion : '4.13.2'
|
|
3
|
+
androidxJunitVersion = project.hasProperty('androidxJunitVersion') ? rootProject.ext.androidxJunitVersion : '1.2.1'
|
|
4
|
+
androidxTestRunnerVersion = project.hasProperty('androidxTestRunnerVersion') ? rootProject.ext.androidxTestRunnerVersion : '1.6.2'
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
buildscript {
|
|
8
|
+
ext.kotlin_version = project.hasProperty('kotlin_version') ? rootProject.ext.kotlin_version : '2.2.20'
|
|
9
|
+
repositories {
|
|
10
|
+
google()
|
|
11
|
+
mavenCentral()
|
|
12
|
+
}
|
|
13
|
+
dependencies {
|
|
14
|
+
classpath 'com.android.tools.build:gradle:8.13.0'
|
|
15
|
+
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
apply plugin: 'com.android.library'
|
|
20
|
+
apply plugin: 'kotlin-android'
|
|
21
|
+
|
|
22
|
+
android {
|
|
23
|
+
namespace = "io.github.marioshtika.securestorage"
|
|
24
|
+
compileSdk = project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 36
|
|
25
|
+
defaultConfig {
|
|
26
|
+
minSdk = project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 24
|
|
27
|
+
targetSdk = project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 36
|
|
28
|
+
versionCode 1
|
|
29
|
+
versionName "1.0"
|
|
30
|
+
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
|
31
|
+
}
|
|
32
|
+
buildTypes {
|
|
33
|
+
release {
|
|
34
|
+
minifyEnabled false
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
lintOptions {
|
|
38
|
+
abortOnError = false
|
|
39
|
+
}
|
|
40
|
+
compileOptions {
|
|
41
|
+
sourceCompatibility JavaVersion.VERSION_21
|
|
42
|
+
targetCompatibility JavaVersion.VERSION_21
|
|
43
|
+
}
|
|
44
|
+
kotlinOptions {
|
|
45
|
+
jvmTarget = '21'
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
repositories {
|
|
50
|
+
google()
|
|
51
|
+
mavenCentral()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
dependencies {
|
|
55
|
+
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
|
56
|
+
implementation project(':capacitor-android')
|
|
57
|
+
testImplementation "junit:junit:$junitVersion"
|
|
58
|
+
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
|
59
|
+
androidTestImplementation "androidx.test:runner:$androidxTestRunnerVersion"
|
|
60
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
package io.github.marioshtika.securestorage
|
|
2
|
+
|
|
3
|
+
import android.util.Base64
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Persistent representation of an encrypted value: `version:base64(iv):base64(ciphertext+tag)`.
|
|
7
|
+
* The GCM authentication tag is appended to the ciphertext by the JCA provider.
|
|
8
|
+
*/
|
|
9
|
+
class EncryptedPayload(val iv: ByteArray, val cipherText: ByteArray) {
|
|
10
|
+
|
|
11
|
+
fun encode(): String =
|
|
12
|
+
listOf(VERSION, b64(iv), b64(cipherText)).joinToString(SEPARATOR)
|
|
13
|
+
|
|
14
|
+
companion object {
|
|
15
|
+
const val VERSION = "v1"
|
|
16
|
+
private const val SEPARATOR = ":"
|
|
17
|
+
const val IV_LENGTH_BYTES = 12
|
|
18
|
+
const val TAG_LENGTH_BYTES = 16
|
|
19
|
+
|
|
20
|
+
private fun b64(bytes: ByteArray) = Base64.encodeToString(bytes, Base64.NO_WRAP)
|
|
21
|
+
|
|
22
|
+
/** Parses a stored string; returns null if it is malformed. */
|
|
23
|
+
fun decode(encoded: String): EncryptedPayload? {
|
|
24
|
+
val parts = encoded.split(SEPARATOR)
|
|
25
|
+
if (parts.size != 3 || parts[0] != VERSION) return null
|
|
26
|
+
return try {
|
|
27
|
+
val iv = Base64.decode(parts[1], Base64.NO_WRAP)
|
|
28
|
+
val ct = Base64.decode(parts[2], Base64.NO_WRAP)
|
|
29
|
+
if (iv.size != IV_LENGTH_BYTES || ct.size < TAG_LENGTH_BYTES) null else EncryptedPayload(iv, ct)
|
|
30
|
+
} catch (e: IllegalArgumentException) {
|
|
31
|
+
null
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
package io.github.marioshtika.securestorage
|
|
2
|
+
|
|
3
|
+
import android.security.keystore.KeyGenParameterSpec
|
|
4
|
+
import android.security.keystore.KeyProperties
|
|
5
|
+
import java.security.GeneralSecurityException
|
|
6
|
+
import java.security.KeyStore
|
|
7
|
+
import javax.crypto.Cipher
|
|
8
|
+
import javax.crypto.KeyGenerator
|
|
9
|
+
import javax.crypto.SecretKey
|
|
10
|
+
import javax.crypto.spec.GCMParameterSpec
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* AES-256-GCM encryption using a non-exportable key held in the Android Keystore.
|
|
14
|
+
* The raw key is never available to this process or persisted anywhere else.
|
|
15
|
+
* No custom cryptography: only the platform JCA/Keystore primitives are used.
|
|
16
|
+
*/
|
|
17
|
+
class KeystoreCipher(private val alias: String = DEFAULT_ALIAS) {
|
|
18
|
+
|
|
19
|
+
private val keyLock = Any()
|
|
20
|
+
|
|
21
|
+
/** Encrypts [plainText]. A fresh random IV is generated by the Keystore for every call. */
|
|
22
|
+
fun encrypt(plainText: ByteArray, associatedData: ByteArray): EncryptedPayload {
|
|
23
|
+
try {
|
|
24
|
+
val cipher = Cipher.getInstance(TRANSFORMATION)
|
|
25
|
+
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
|
|
26
|
+
cipher.updateAAD(associatedData)
|
|
27
|
+
val cipherText = cipher.doFinal(plainText)
|
|
28
|
+
return EncryptedPayload(cipher.iv, cipherText)
|
|
29
|
+
} catch (e: GeneralSecurityException) {
|
|
30
|
+
throw SecureStorageException(SecureStorageException.ENCRYPTION_ERROR, "Failed to encrypt value", e)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Decrypts and authenticates [payload]; throws if the data was tampered with or the key is gone. */
|
|
35
|
+
fun decrypt(payload: EncryptedPayload, associatedData: ByteArray): ByteArray {
|
|
36
|
+
val key = try {
|
|
37
|
+
getExistingKey()
|
|
38
|
+
} catch (e: GeneralSecurityException) {
|
|
39
|
+
throw SecureStorageException(SecureStorageException.KEYSTORE_ERROR, "Failed to access Android Keystore", e)
|
|
40
|
+
} ?: throw SecureStorageException(
|
|
41
|
+
SecureStorageException.DECRYPTION_ERROR,
|
|
42
|
+
"Encryption key is unavailable; the stored value cannot be decrypted",
|
|
43
|
+
)
|
|
44
|
+
try {
|
|
45
|
+
val cipher = Cipher.getInstance(TRANSFORMATION)
|
|
46
|
+
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(EncryptedPayload.TAG_LENGTH_BYTES * 8, payload.iv))
|
|
47
|
+
cipher.updateAAD(associatedData)
|
|
48
|
+
return cipher.doFinal(payload.cipherText)
|
|
49
|
+
} catch (e: GeneralSecurityException) {
|
|
50
|
+
throw SecureStorageException(SecureStorageException.DECRYPTION_ERROR, "Failed to decrypt value", e)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Whether the Keystore currently holds the key (used by tests). */
|
|
55
|
+
fun hasKey(): Boolean = try {
|
|
56
|
+
getExistingKey() != null
|
|
57
|
+
} catch (e: GeneralSecurityException) {
|
|
58
|
+
false
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Deletes the Keystore key. All previously encrypted values become undecryptable. */
|
|
62
|
+
fun deleteKey() {
|
|
63
|
+
synchronized(keyLock) {
|
|
64
|
+
try {
|
|
65
|
+
keyStore().deleteEntry(alias)
|
|
66
|
+
} catch (e: Exception) {
|
|
67
|
+
throw SecureStorageException(SecureStorageException.KEYSTORE_ERROR, "Failed to delete key", e)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private fun keyStore(): KeyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
|
|
73
|
+
|
|
74
|
+
private fun getExistingKey(): SecretKey? = keyStore().getKey(alias, null) as? SecretKey
|
|
75
|
+
|
|
76
|
+
private fun getOrCreateKey(): SecretKey {
|
|
77
|
+
try {
|
|
78
|
+
getExistingKey()?.let { return it }
|
|
79
|
+
// Only key creation is serialized; encrypt/decrypt calls run concurrently.
|
|
80
|
+
synchronized(keyLock) {
|
|
81
|
+
getExistingKey()?.let { return it }
|
|
82
|
+
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
|
|
83
|
+
generator.init(
|
|
84
|
+
KeyGenParameterSpec.Builder(
|
|
85
|
+
alias,
|
|
86
|
+
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
|
|
87
|
+
)
|
|
88
|
+
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
|
89
|
+
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
|
90
|
+
.setKeySize(256)
|
|
91
|
+
// Required (default true): the Keystore generates a unique IV per encryption.
|
|
92
|
+
.setRandomizedEncryptionRequired(true)
|
|
93
|
+
.build(),
|
|
94
|
+
)
|
|
95
|
+
return generator.generateKey()
|
|
96
|
+
}
|
|
97
|
+
} catch (e: GeneralSecurityException) {
|
|
98
|
+
throw SecureStorageException(SecureStorageException.KEYSTORE_ERROR, "Failed to access Android Keystore", e)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
companion object {
|
|
103
|
+
const val DEFAULT_ALIAS = "capacitor-secure-storage-key"
|
|
104
|
+
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
|
|
105
|
+
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
package io.github.marioshtika.securestorage
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.content.SharedPreferences
|
|
5
|
+
import java.security.MessageDigest
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Persists values encrypted by [KeystoreCipher] in a dedicated SharedPreferences file.
|
|
9
|
+
* The file is only a persistence layer: it contains hashed key names and ciphertext, never plaintext values.
|
|
10
|
+
* The key name is bound to the ciphertext as GCM associated data, so entries cannot be swapped between keys.
|
|
11
|
+
*/
|
|
12
|
+
class SecureStorage(
|
|
13
|
+
private val prefs: SharedPreferences,
|
|
14
|
+
private val cipher: KeystoreCipher = KeystoreCipher(),
|
|
15
|
+
) {
|
|
16
|
+
|
|
17
|
+
constructor(context: Context, prefsName: String = PREFS_NAME, alias: String = KeystoreCipher.DEFAULT_ALIAS) :
|
|
18
|
+
this(
|
|
19
|
+
context.applicationContext.getSharedPreferences(prefsName, Context.MODE_PRIVATE),
|
|
20
|
+
KeystoreCipher(alias),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
fun set(key: String, value: String) {
|
|
24
|
+
val storageKey = storageKey(key)
|
|
25
|
+
val payload = cipher.encrypt(value.toByteArray(Charsets.UTF_8), storageKey.toByteArray(Charsets.UTF_8))
|
|
26
|
+
// commit() is synchronous so failures are reported; we are never on the UI thread here.
|
|
27
|
+
if (!prefs.edit().putString(storageKey, payload.encode()).commit()) {
|
|
28
|
+
throw SecureStorageException(SecureStorageException.STORAGE_ERROR, "Failed to persist value")
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Returns the decrypted value, or null if the key doesn't exist. Throws if decryption fails. */
|
|
33
|
+
fun get(key: String): String? {
|
|
34
|
+
val storageKey = storageKey(key)
|
|
35
|
+
val encoded = prefs.getString(storageKey, null) ?: return null
|
|
36
|
+
val payload = EncryptedPayload.decode(encoded)
|
|
37
|
+
?: throw SecureStorageException(SecureStorageException.DECRYPTION_ERROR, "Stored value is corrupted")
|
|
38
|
+
val bytes = cipher.decrypt(payload, storageKey.toByteArray(Charsets.UTF_8))
|
|
39
|
+
return String(bytes, Charsets.UTF_8)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
fun has(key: String): Boolean = prefs.contains(storageKey(key))
|
|
43
|
+
|
|
44
|
+
fun remove(key: String) {
|
|
45
|
+
if (!prefs.edit().remove(storageKey(key)).commit()) {
|
|
46
|
+
throw SecureStorageException(SecureStorageException.STORAGE_ERROR, "Failed to remove value")
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Removes only entries of this plugin's dedicated preferences file. The Keystore key is kept. */
|
|
51
|
+
fun clear() {
|
|
52
|
+
if (!prefs.edit().clear().commit()) {
|
|
53
|
+
throw SecureStorageException(SecureStorageException.STORAGE_ERROR, "Failed to clear values")
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
companion object {
|
|
58
|
+
const val PREFS_NAME = "capacitor_secure_storage"
|
|
59
|
+
const val MAX_KEY_BYTES = 256
|
|
60
|
+
|
|
61
|
+
/** Throws INVALID_KEY unless [key] is a non-empty string of at most [MAX_KEY_BYTES] UTF-8 bytes. */
|
|
62
|
+
fun validateKey(key: String?): String {
|
|
63
|
+
if (key.isNullOrEmpty()) {
|
|
64
|
+
throw SecureStorageException(SecureStorageException.INVALID_KEY, "Key must be a non-empty string")
|
|
65
|
+
}
|
|
66
|
+
if (key.toByteArray(Charsets.UTF_8).size > MAX_KEY_BYTES) {
|
|
67
|
+
throw SecureStorageException(
|
|
68
|
+
SecureStorageException.INVALID_KEY,
|
|
69
|
+
"Key must be at most $MAX_KEY_BYTES bytes",
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
return key
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Key names are hashed so that they are not stored in plaintext either. */
|
|
76
|
+
fun storageKey(key: String): String =
|
|
77
|
+
MessageDigest.getInstance("SHA-256").digest(key.toByteArray(Charsets.UTF_8))
|
|
78
|
+
.joinToString("") { "%02x".format(it) }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
package io.github.marioshtika.securestorage
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A storage failure with a stable error code and a safe message.
|
|
5
|
+
* Messages must never contain secrets, key material, ciphertext or decrypted data,
|
|
6
|
+
* and the underlying cause is intentionally never forwarded to JavaScript.
|
|
7
|
+
*/
|
|
8
|
+
class SecureStorageException(
|
|
9
|
+
val code: String,
|
|
10
|
+
message: String,
|
|
11
|
+
cause: Throwable? = null,
|
|
12
|
+
) : Exception(message, cause) {
|
|
13
|
+
companion object {
|
|
14
|
+
const val INVALID_KEY = "INVALID_KEY"
|
|
15
|
+
const val INVALID_VALUE = "INVALID_VALUE"
|
|
16
|
+
const val STORAGE_ERROR = "STORAGE_ERROR"
|
|
17
|
+
const val KEYSTORE_ERROR = "KEYSTORE_ERROR"
|
|
18
|
+
const val ENCRYPTION_ERROR = "ENCRYPTION_ERROR"
|
|
19
|
+
const val DECRYPTION_ERROR = "DECRYPTION_ERROR"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
package io.github.marioshtika.securestorage
|
|
2
|
+
|
|
3
|
+
import com.getcapacitor.JSObject
|
|
4
|
+
import com.getcapacitor.Plugin
|
|
5
|
+
import com.getcapacitor.PluginCall
|
|
6
|
+
import com.getcapacitor.PluginMethod
|
|
7
|
+
import com.getcapacitor.annotation.CapacitorPlugin
|
|
8
|
+
|
|
9
|
+
@CapacitorPlugin(name = "SecureStorage")
|
|
10
|
+
class SecureStoragePlugin : Plugin() {
|
|
11
|
+
|
|
12
|
+
private val storage: SecureStorage by lazy { SecureStorage(context) }
|
|
13
|
+
|
|
14
|
+
// Plugin methods run on Capacitor's background plugin thread (not the UI thread).
|
|
15
|
+
// SharedPreferences is thread-safe and only key creation is locked, so calls run concurrently.
|
|
16
|
+
|
|
17
|
+
@PluginMethod
|
|
18
|
+
fun set(call: PluginCall) = handle(call) {
|
|
19
|
+
val key = SecureStorage.validateKey(call.getString("key"))
|
|
20
|
+
val value = call.getString("value")
|
|
21
|
+
?: throw SecureStorageException(SecureStorageException.INVALID_VALUE, "Value must be a string")
|
|
22
|
+
storage.set(key, value)
|
|
23
|
+
call.resolve()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
@PluginMethod
|
|
27
|
+
fun get(call: PluginCall) = handle(call) {
|
|
28
|
+
val key = SecureStorage.validateKey(call.getString("key"))
|
|
29
|
+
val result = JSObject()
|
|
30
|
+
result.put("value", storage.get(key) ?: JSObject.NULL)
|
|
31
|
+
call.resolve(result)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@PluginMethod
|
|
35
|
+
fun has(call: PluginCall) = handle(call) {
|
|
36
|
+
val key = SecureStorage.validateKey(call.getString("key"))
|
|
37
|
+
val result = JSObject()
|
|
38
|
+
result.put("value", storage.has(key))
|
|
39
|
+
call.resolve(result)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@PluginMethod
|
|
43
|
+
fun remove(call: PluginCall) = handle(call) {
|
|
44
|
+
storage.remove(SecureStorage.validateKey(call.getString("key")))
|
|
45
|
+
call.resolve()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
@PluginMethod
|
|
49
|
+
fun clear(call: PluginCall) = handle(call) {
|
|
50
|
+
storage.clear()
|
|
51
|
+
call.resolve()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private fun handle(call: PluginCall, block: () -> Unit) {
|
|
55
|
+
try {
|
|
56
|
+
block()
|
|
57
|
+
} catch (e: SecureStorageException) {
|
|
58
|
+
// Only the safe message and code are exposed; the cause is never forwarded.
|
|
59
|
+
call.reject(e.message, e.code)
|
|
60
|
+
} catch (e: Exception) {
|
|
61
|
+
call.reject("Unexpected storage error", SecureStorageException.STORAGE_ERROR)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error codes reported by the native implementations (as `error.code`).
|
|
3
|
+
*/
|
|
4
|
+
export type SecureStorageErrorCode = 'INVALID_KEY' | 'INVALID_VALUE' | 'STORAGE_ERROR' | 'KEYCHAIN_ERROR' | 'KEYSTORE_ERROR' | 'ENCRYPTION_ERROR' | 'DECRYPTION_ERROR' | 'UNAVAILABLE';
|
|
5
|
+
/** Maximum key length (in UTF-8 bytes) accepted on every platform. */
|
|
6
|
+
export declare const MAX_KEY_LENGTH = 256;
|
|
7
|
+
export interface SecureStoragePlugin {
|
|
8
|
+
/**
|
|
9
|
+
* Store a string value securely.
|
|
10
|
+
* If the key already exists, overwrite its value.
|
|
11
|
+
*/
|
|
12
|
+
set(options: {
|
|
13
|
+
key: string;
|
|
14
|
+
value: string;
|
|
15
|
+
}): Promise<void>;
|
|
16
|
+
/**
|
|
17
|
+
* Retrieve a previously stored value.
|
|
18
|
+
*
|
|
19
|
+
* Returns null if the key does not exist.
|
|
20
|
+
*/
|
|
21
|
+
get(options: {
|
|
22
|
+
key: string;
|
|
23
|
+
}): Promise<{
|
|
24
|
+
value: string | null;
|
|
25
|
+
}>;
|
|
26
|
+
/**
|
|
27
|
+
* Check whether a key exists.
|
|
28
|
+
*/
|
|
29
|
+
has(options: {
|
|
30
|
+
key: string;
|
|
31
|
+
}): Promise<{
|
|
32
|
+
value: boolean;
|
|
33
|
+
}>;
|
|
34
|
+
/**
|
|
35
|
+
* Delete a key.
|
|
36
|
+
*
|
|
37
|
+
* Succeeds even if the key does not exist.
|
|
38
|
+
*/
|
|
39
|
+
remove(options: {
|
|
40
|
+
key: string;
|
|
41
|
+
}): Promise<void>;
|
|
42
|
+
/**
|
|
43
|
+
* Remove all values stored by this plugin.
|
|
44
|
+
*/
|
|
45
|
+
clear(): Promise<void>;
|
|
46
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"AAaA,sEAAsE;AACtE,MAAM,CAAC,MAAM,cAAc,GAAG,GAAG,CAAC"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { registerPlugin } from '@capacitor/core';
|
|
2
|
+
const SecureStorage = registerPlugin('SecureStorage', {
|
|
3
|
+
web: () => import('./web').then((m) => new m.SecureStorageWeb()),
|
|
4
|
+
});
|
|
5
|
+
export * from './definitions';
|
|
6
|
+
export { SecureStorage };
|
|
7
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAIjD,MAAM,aAAa,GAAG,cAAc,CAAsB,eAAe,EAAE;IACzE,GAAG,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,CAAC,gBAAgB,EAAE,CAAC;CACjE,CAAC,CAAC;AAEH,cAAc,eAAe,CAAC;AAC9B,OAAO,EAAE,aAAa,EAAE,CAAC"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { WebPlugin } from '@capacitor/core';
|
|
2
|
+
import type { SecureStoragePlugin } from './definitions';
|
|
3
|
+
/**
|
|
4
|
+
* The web has no secure storage facility. Rather than silently falling back to
|
|
5
|
+
* localStorage (which would be a security vulnerability), every method rejects
|
|
6
|
+
* with an `UNAVAILABLE` error.
|
|
7
|
+
*/
|
|
8
|
+
export declare class SecureStorageWeb extends WebPlugin implements SecureStoragePlugin {
|
|
9
|
+
set(_options: {
|
|
10
|
+
key: string;
|
|
11
|
+
value: string;
|
|
12
|
+
}): Promise<void>;
|
|
13
|
+
get(_options: {
|
|
14
|
+
key: string;
|
|
15
|
+
}): Promise<{
|
|
16
|
+
value: string | null;
|
|
17
|
+
}>;
|
|
18
|
+
has(_options: {
|
|
19
|
+
key: string;
|
|
20
|
+
}): Promise<{
|
|
21
|
+
value: boolean;
|
|
22
|
+
}>;
|
|
23
|
+
remove(_options: {
|
|
24
|
+
key: string;
|
|
25
|
+
}): Promise<void>;
|
|
26
|
+
clear(): Promise<void>;
|
|
27
|
+
}
|
package/dist/esm/web.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { WebPlugin } from '@capacitor/core';
|
|
2
|
+
/**
|
|
3
|
+
* The web has no secure storage facility. Rather than silently falling back to
|
|
4
|
+
* localStorage (which would be a security vulnerability), every method rejects
|
|
5
|
+
* with an `UNAVAILABLE` error.
|
|
6
|
+
*/
|
|
7
|
+
export class SecureStorageWeb extends WebPlugin {
|
|
8
|
+
async set(_options) {
|
|
9
|
+
throw this.unavailable('SecureStorage is not supported on the web');
|
|
10
|
+
}
|
|
11
|
+
async get(_options) {
|
|
12
|
+
throw this.unavailable('SecureStorage is not supported on the web');
|
|
13
|
+
}
|
|
14
|
+
async has(_options) {
|
|
15
|
+
throw this.unavailable('SecureStorage is not supported on the web');
|
|
16
|
+
}
|
|
17
|
+
async remove(_options) {
|
|
18
|
+
throw this.unavailable('SecureStorage is not supported on the web');
|
|
19
|
+
}
|
|
20
|
+
async clear() {
|
|
21
|
+
throw this.unavailable('SecureStorage is not supported on the web');
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=web.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"web.js","sourceRoot":"","sources":["../../src/web.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAI5C;;;;GAIG;AACH,MAAM,OAAO,gBAAiB,SAAQ,SAAS;IAC7C,KAAK,CAAC,GAAG,CAAC,QAAwC;QAChD,MAAM,IAAI,CAAC,WAAW,CAAC,2CAA2C,CAAC,CAAC;IACtE,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,QAAyB;QACjC,MAAM,IAAI,CAAC,WAAW,CAAC,2CAA2C,CAAC,CAAC;IACtE,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,QAAyB;QACjC,MAAM,IAAI,CAAC,WAAW,CAAC,2CAA2C,CAAC,CAAC;IACtE,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,QAAyB;QACpC,MAAM,IAAI,CAAC,WAAW,CAAC,2CAA2C,CAAC,CAAC;IACtE,CAAC;IAED,KAAK,CAAC,KAAK;QACT,MAAM,IAAI,CAAC,WAAW,CAAC,2CAA2C,CAAC,CAAC;IACtE,CAAC;CACF"}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Security
|
|
3
|
+
|
|
4
|
+
/// Thin wrapper around Keychain Services (generic password items).
|
|
5
|
+
///
|
|
6
|
+
/// Every item is scoped to a dedicated `kSecAttrService`, so `clear()` can never touch
|
|
7
|
+
/// unrelated Keychain entries. Items use `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`
|
|
8
|
+
/// and are never synchronized through iCloud Keychain. Values are never logged.
|
|
9
|
+
final class KeychainStore {
|
|
10
|
+
static let defaultService = "capacitor-secure-storage"
|
|
11
|
+
static let maxKeyBytes = 256
|
|
12
|
+
|
|
13
|
+
private let service: String
|
|
14
|
+
|
|
15
|
+
init(service: String = KeychainStore.defaultService) {
|
|
16
|
+
self.service = service
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
static func validate(key: String?) throws -> String {
|
|
20
|
+
guard let key = key, !key.isEmpty else {
|
|
21
|
+
throw SecureStorageError.invalidKey("Key must be a non-empty string")
|
|
22
|
+
}
|
|
23
|
+
guard key.utf8.count <= maxKeyBytes else {
|
|
24
|
+
throw SecureStorageError.invalidKey("Key must be at most \(maxKeyBytes) bytes")
|
|
25
|
+
}
|
|
26
|
+
return key
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/// Base query identifying this plugin's items (optionally a single account).
|
|
30
|
+
private func baseQuery(account: String? = nil) -> [String: Any] {
|
|
31
|
+
var query: [String: Any] = [
|
|
32
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
33
|
+
kSecAttrService as String: service,
|
|
34
|
+
// Match any sync state so remove()/clear() also catch items synced by older versions.
|
|
35
|
+
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
|
|
36
|
+
]
|
|
37
|
+
if let account = account {
|
|
38
|
+
query[kSecAttrAccount as String] = account
|
|
39
|
+
}
|
|
40
|
+
return query
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func set(_ value: String, for key: String) throws {
|
|
44
|
+
let data = Data(value.utf8)
|
|
45
|
+
let updateStatus = SecItemUpdate(
|
|
46
|
+
baseQuery(account: key) as CFDictionary,
|
|
47
|
+
[kSecValueData as String: data] as CFDictionary
|
|
48
|
+
)
|
|
49
|
+
switch updateStatus {
|
|
50
|
+
case errSecSuccess:
|
|
51
|
+
return
|
|
52
|
+
case errSecItemNotFound:
|
|
53
|
+
var attributes = baseQuery(account: key)
|
|
54
|
+
attributes[kSecValueData as String] = data
|
|
55
|
+
attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
|
56
|
+
// Replace the "any" match with an explicit non-synchronizable attribute.
|
|
57
|
+
attributes[kSecAttrSynchronizable as String] = kCFBooleanFalse
|
|
58
|
+
let addStatus = SecItemAdd(attributes as CFDictionary, nil)
|
|
59
|
+
if addStatus == errSecDuplicateItem {
|
|
60
|
+
// Lost a race with a concurrent set(); the item now exists, so update it.
|
|
61
|
+
let retry = SecItemUpdate(
|
|
62
|
+
baseQuery(account: key) as CFDictionary,
|
|
63
|
+
[kSecValueData as String: data] as CFDictionary
|
|
64
|
+
)
|
|
65
|
+
guard retry == errSecSuccess else { throw SecureStorageError.keychain(retry) }
|
|
66
|
+
} else if addStatus != errSecSuccess {
|
|
67
|
+
throw SecureStorageError.keychain(addStatus)
|
|
68
|
+
}
|
|
69
|
+
default:
|
|
70
|
+
throw SecureStorageError.keychain(updateStatus)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// Returns nil if the key does not exist.
|
|
75
|
+
func get(_ key: String) throws -> String? {
|
|
76
|
+
var query = baseQuery(account: key)
|
|
77
|
+
query[kSecReturnData as String] = kCFBooleanTrue
|
|
78
|
+
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
|
79
|
+
var result: CFTypeRef?
|
|
80
|
+
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
81
|
+
switch status {
|
|
82
|
+
case errSecSuccess:
|
|
83
|
+
guard let data = result as? Data, let string = String(data: data, encoding: .utf8) else {
|
|
84
|
+
throw SecureStorageError.invalidValue("Stored value is not valid UTF-8")
|
|
85
|
+
}
|
|
86
|
+
return string
|
|
87
|
+
case errSecItemNotFound:
|
|
88
|
+
return nil
|
|
89
|
+
default:
|
|
90
|
+
throw SecureStorageError.keychain(status)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
func has(_ key: String) throws -> Bool {
|
|
95
|
+
var query = baseQuery(account: key)
|
|
96
|
+
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
|
97
|
+
let status = SecItemCopyMatching(query as CFDictionary, nil)
|
|
98
|
+
switch status {
|
|
99
|
+
case errSecSuccess: return true
|
|
100
|
+
case errSecItemNotFound: return false
|
|
101
|
+
default: throw SecureStorageError.keychain(status)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/// A missing key is not an error.
|
|
106
|
+
func remove(_ key: String) throws {
|
|
107
|
+
let status = SecItemDelete(baseQuery(account: key) as CFDictionary)
|
|
108
|
+
guard status == errSecSuccess || status == errSecItemNotFound else {
|
|
109
|
+
throw SecureStorageError.keychain(status)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/// Deletes only items belonging to this store's service.
|
|
114
|
+
func clear() throws {
|
|
115
|
+
let status = SecItemDelete(baseQuery() as CFDictionary)
|
|
116
|
+
guard status == errSecSuccess || status == errSecItemNotFound else {
|
|
117
|
+
throw SecureStorageError.keychain(status)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Plugin errors with stable codes and safe messages.
|
|
4
|
+
/// Messages never contain secret values, keys' contents or Keychain item data.
|
|
5
|
+
enum SecureStorageError: Error, Equatable {
|
|
6
|
+
case invalidKey(String)
|
|
7
|
+
case invalidValue(String)
|
|
8
|
+
case keychain(OSStatus)
|
|
9
|
+
|
|
10
|
+
var code: String {
|
|
11
|
+
switch self {
|
|
12
|
+
case .invalidKey: return "INVALID_KEY"
|
|
13
|
+
case .invalidValue: return "INVALID_VALUE"
|
|
14
|
+
case .keychain: return "KEYCHAIN_ERROR"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
var message: String {
|
|
19
|
+
switch self {
|
|
20
|
+
case .invalidKey(let reason), .invalidValue(let reason):
|
|
21
|
+
return reason
|
|
22
|
+
case .keychain(let status):
|
|
23
|
+
// Only the numeric OSStatus is exposed; it carries no sensitive data.
|
|
24
|
+
return "Keychain operation failed (OSStatus \(status))"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import Capacitor
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
@objc(SecureStoragePlugin)
|
|
5
|
+
public class SecureStoragePlugin: CAPPlugin, CAPBridgedPlugin {
|
|
6
|
+
public let identifier = "SecureStoragePlugin"
|
|
7
|
+
public let jsName = "SecureStorage"
|
|
8
|
+
public let pluginMethods: [CAPPluginMethod] = [
|
|
9
|
+
CAPPluginMethod(name: "set", returnType: CAPPluginReturnPromise),
|
|
10
|
+
CAPPluginMethod(name: "get", returnType: CAPPluginReturnPromise),
|
|
11
|
+
CAPPluginMethod(name: "has", returnType: CAPPluginReturnPromise),
|
|
12
|
+
CAPPluginMethod(name: "remove", returnType: CAPPluginReturnPromise),
|
|
13
|
+
CAPPluginMethod(name: "clear", returnType: CAPPluginReturnPromise)
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
private let store = KeychainStore()
|
|
17
|
+
// Keychain calls are thread-safe; a concurrent queue keeps them off the main thread
|
|
18
|
+
// without serializing unrelated operations.
|
|
19
|
+
private let queue = DispatchQueue(label: "capacitor-secure-storage", qos: .userInitiated, attributes: .concurrent)
|
|
20
|
+
|
|
21
|
+
@objc func set(_ call: CAPPluginCall) {
|
|
22
|
+
run(call) { [store] in
|
|
23
|
+
let key = try KeychainStore.validate(key: call.getString("key"))
|
|
24
|
+
guard let value = call.getString("value") else {
|
|
25
|
+
throw SecureStorageError.invalidValue("Value must be a string")
|
|
26
|
+
}
|
|
27
|
+
try store.set(value, for: key)
|
|
28
|
+
call.resolve()
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
@objc func get(_ call: CAPPluginCall) {
|
|
33
|
+
run(call) { [store] in
|
|
34
|
+
let key = try KeychainStore.validate(key: call.getString("key"))
|
|
35
|
+
let value = try store.get(key)
|
|
36
|
+
call.resolve(["value": value ?? NSNull()])
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@objc func has(_ call: CAPPluginCall) {
|
|
41
|
+
run(call) { [store] in
|
|
42
|
+
let key = try KeychainStore.validate(key: call.getString("key"))
|
|
43
|
+
call.resolve(["value": try store.has(key)])
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@objc func remove(_ call: CAPPluginCall) {
|
|
48
|
+
run(call) { [store] in
|
|
49
|
+
let key = try KeychainStore.validate(key: call.getString("key"))
|
|
50
|
+
try store.remove(key)
|
|
51
|
+
call.resolve()
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@objc func clear(_ call: CAPPluginCall) {
|
|
56
|
+
run(call) { [store] in
|
|
57
|
+
try store.clear()
|
|
58
|
+
call.resolve()
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private func run(_ call: CAPPluginCall, _ work: @escaping () throws -> Void) {
|
|
63
|
+
queue.async {
|
|
64
|
+
do {
|
|
65
|
+
try work()
|
|
66
|
+
} catch let error as SecureStorageError {
|
|
67
|
+
call.reject(error.message, error.code)
|
|
68
|
+
} catch {
|
|
69
|
+
call.reject("Unexpected storage error", "STORAGE_ERROR")
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import Security
|
|
2
|
+
import XCTest
|
|
3
|
+
@testable import SecureStoragePlugin
|
|
4
|
+
|
|
5
|
+
final class KeychainStoreTests: XCTestCase {
|
|
6
|
+
private let service = "capacitor-secure-storage-tests"
|
|
7
|
+
private var store: KeychainStore!
|
|
8
|
+
|
|
9
|
+
override func setUpWithError() throws {
|
|
10
|
+
store = KeychainStore(service: service)
|
|
11
|
+
try store.clear()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
override func tearDownWithError() throws {
|
|
15
|
+
try store.clear()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
func testSetGetHasRemove() throws {
|
|
19
|
+
try store.set("secret-value", for: "access_token")
|
|
20
|
+
XCTAssertEqual(try store.get("access_token"), "secret-value")
|
|
21
|
+
XCTAssertTrue(try store.has("access_token"))
|
|
22
|
+
try store.remove("access_token")
|
|
23
|
+
XCTAssertNil(try store.get("access_token"))
|
|
24
|
+
XCTAssertFalse(try store.has("access_token"))
|
|
25
|
+
XCTAssertNoThrow(try store.remove("access_token"))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func testOverwrite() throws {
|
|
29
|
+
try store.set("one", for: "k")
|
|
30
|
+
try store.set("two", for: "k")
|
|
31
|
+
XCTAssertEqual(try store.get("k"), "two")
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func testMissingKey() throws {
|
|
35
|
+
XCTAssertNil(try store.get("missing"))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func testUnicodeEmojiEmptyAndNullBytes() throws {
|
|
39
|
+
try store.set("значение 🙂 日本語", for: "ключ-🔑")
|
|
40
|
+
XCTAssertEqual(try store.get("ключ-🔑"), "значение 🙂 日本語")
|
|
41
|
+
try store.set("", for: "empty")
|
|
42
|
+
XCTAssertEqual(try store.get("empty"), "")
|
|
43
|
+
try store.set("a\u{0}b", for: "nul")
|
|
44
|
+
XCTAssertEqual(try store.get("nul"), "a\u{0}b")
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func testPersistsAcrossInstances() throws {
|
|
48
|
+
try store.set("v", for: "k")
|
|
49
|
+
XCTAssertEqual(try KeychainStore(service: service).get("k"), "v")
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
func testClearOnlyAffectsOwnService() throws {
|
|
53
|
+
let other = KeychainStore(service: service + ".other")
|
|
54
|
+
try other.clear()
|
|
55
|
+
try store.set("1", for: "a")
|
|
56
|
+
try other.set("2", for: "a")
|
|
57
|
+
try store.clear()
|
|
58
|
+
XCTAssertNil(try store.get("a"))
|
|
59
|
+
XCTAssertEqual(try other.get("a"), "2")
|
|
60
|
+
try other.clear()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
func testStoredInKeychainWithDeviceOnlyAccessibility() throws {
|
|
64
|
+
try store.set("v", for: "k")
|
|
65
|
+
let query: [String: Any] = [
|
|
66
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
67
|
+
kSecAttrService as String: service,
|
|
68
|
+
kSecAttrAccount as String: "k",
|
|
69
|
+
kSecReturnAttributes as String: true,
|
|
70
|
+
kSecMatchLimit as String: kSecMatchLimitOne
|
|
71
|
+
]
|
|
72
|
+
var result: CFTypeRef?
|
|
73
|
+
XCTAssertEqual(SecItemCopyMatching(query as CFDictionary, &result), errSecSuccess)
|
|
74
|
+
let attributes = result as? [String: Any]
|
|
75
|
+
XCTAssertEqual(attributes?[kSecAttrAccessible as String] as? String,
|
|
76
|
+
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
func testDoesNotUseUserDefaults() throws {
|
|
80
|
+
try store.set("plain-secret-check", for: "ud")
|
|
81
|
+
let dump = UserDefaults.standard.dictionaryRepresentation().description
|
|
82
|
+
XCTAssertFalse(dump.contains("plain-secret-check"))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
func testKeyValidation() {
|
|
86
|
+
XCTAssertThrowsError(try KeychainStore.validate(key: "")) {
|
|
87
|
+
XCTAssertEqual(($0 as? SecureStorageError)?.code, "INVALID_KEY")
|
|
88
|
+
}
|
|
89
|
+
XCTAssertThrowsError(try KeychainStore.validate(key: nil))
|
|
90
|
+
XCTAssertThrowsError(try KeychainStore.validate(key: String(repeating: "a", count: 257)))
|
|
91
|
+
XCTAssertNoThrow(try KeychainStore.validate(key: "ключ-🔑"))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
func testKeychainErrorMessageIsSafe() {
|
|
95
|
+
let error = SecureStorageError.keychain(errSecAuthFailed)
|
|
96
|
+
XCTAssertEqual(error.code, "KEYCHAIN_ERROR")
|
|
97
|
+
XCTAssertFalse(error.message.isEmpty)
|
|
98
|
+
}
|
|
99
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@capnative/capacitor-secure-storage",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "8.0.0",
|
|
4
|
+
"description": "Secure key-value storage for Capacitor using iOS Keychain and Android Keystore (AES-GCM).",
|
|
5
|
+
"main": "dist/esm/index.js",
|
|
6
|
+
"module": "dist/esm/index.js",
|
|
7
|
+
"types": "dist/esm/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"android/src/main/",
|
|
10
|
+
"android/build.gradle",
|
|
11
|
+
"dist/",
|
|
12
|
+
"ios/Sources",
|
|
13
|
+
"ios/Tests",
|
|
14
|
+
"Package.swift",
|
|
15
|
+
"CapacitorSecureStorage.podspec"
|
|
16
|
+
],
|
|
17
|
+
"author": "marioshtika",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/marioshtika/capacitor-secure-storage.git"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"capacitor",
|
|
25
|
+
"plugin",
|
|
26
|
+
"native",
|
|
27
|
+
"secure-storage",
|
|
28
|
+
"keychain",
|
|
29
|
+
"keystore"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "npm run clean && tsc",
|
|
33
|
+
"clean": "rm -rf dist",
|
|
34
|
+
"test": "vitest run",
|
|
35
|
+
"typecheck": "tsc --noEmit -p tsconfig.test.json",
|
|
36
|
+
"verify:ios": "swift build && swift test",
|
|
37
|
+
"verify:android": "cd android && gradle clean build test",
|
|
38
|
+
"prepublishOnly": "npm run build"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@capacitor/android": "^8.0.0",
|
|
42
|
+
"@capacitor/core": "^8.0.0",
|
|
43
|
+
"@capacitor/ios": "^8.0.0",
|
|
44
|
+
"typescript": "^5.9.0",
|
|
45
|
+
"vitest": "^3.0.0"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"@capacitor/core": ">=8.0.0"
|
|
49
|
+
},
|
|
50
|
+
"capacitor": {
|
|
51
|
+
"ios": {
|
|
52
|
+
"src": "ios"
|
|
53
|
+
},
|
|
54
|
+
"android": {
|
|
55
|
+
"src": "android"
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"type": "module",
|
|
59
|
+
"publishConfig": {
|
|
60
|
+
"access": "public"
|
|
61
|
+
}
|
|
62
|
+
}
|