strata-storage 2.4.3 → 2.6.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/AI-INTEGRATION-GUIDE.md +115 -261
- package/README.md +426 -182
- package/android/AGENTS.md +51 -0
- package/android/CLAUDE.md +89 -0
- package/android/build.gradle +1 -1
- package/android/src/main/java/com/strata/storage/FilesystemStorage.java +287 -0
- package/android/src/main/java/com/strata/storage/SQLiteStorage.java +260 -203
- package/android/src/main/java/com/stratastorage/StrataStoragePlugin.java +357 -69
- package/dist/README.md +426 -182
- package/dist/adapters/capacitor/FilesystemAdapter.d.ts.map +1 -1
- package/dist/adapters/capacitor/FilesystemAdapter.js +2 -1
- package/dist/adapters/capacitor/PreferencesAdapter.d.ts.map +1 -1
- package/dist/adapters/capacitor/PreferencesAdapter.js +2 -1
- package/dist/adapters/capacitor/SecureAdapter.d.ts.map +1 -1
- package/dist/adapters/capacitor/SecureAdapter.js +2 -1
- package/dist/adapters/capacitor/SqliteAdapter.d.ts.map +1 -1
- package/dist/adapters/capacitor/SqliteAdapter.js +2 -1
- package/dist/adapters/web/CacheAdapter.d.ts.map +1 -1
- package/dist/adapters/web/CacheAdapter.js +11 -3
- package/dist/adapters/web/CookieAdapter.d.ts +37 -1
- package/dist/adapters/web/CookieAdapter.d.ts.map +1 -1
- package/dist/adapters/web/CookieAdapter.js +89 -9
- package/dist/adapters/web/IndexedDBAdapter.d.ts.map +1 -1
- package/dist/adapters/web/IndexedDBAdapter.js +10 -2
- package/dist/adapters/web/LocalStorageAdapter.d.ts +31 -0
- package/dist/adapters/web/LocalStorageAdapter.d.ts.map +1 -1
- package/dist/adapters/web/LocalStorageAdapter.js +92 -19
- package/dist/adapters/web/MemoryAdapter.d.ts +24 -0
- package/dist/adapters/web/MemoryAdapter.d.ts.map +1 -1
- package/dist/adapters/web/MemoryAdapter.js +69 -18
- package/dist/adapters/web/SessionStorageAdapter.d.ts +24 -0
- package/dist/adapters/web/SessionStorageAdapter.d.ts.map +1 -1
- package/dist/adapters/web/SessionStorageAdapter.js +71 -9
- package/dist/adapters/web/URLAdapter.d.ts +59 -0
- package/dist/adapters/web/URLAdapter.d.ts.map +1 -0
- package/dist/adapters/web/URLAdapter.js +234 -0
- package/dist/adapters/web/index.d.ts +1 -0
- package/dist/adapters/web/index.d.ts.map +1 -1
- package/dist/adapters/web/index.js +1 -0
- package/dist/android/AGENTS.md +51 -0
- package/dist/android/CLAUDE.md +89 -0
- package/dist/android/build.gradle +1 -1
- package/dist/android/src/main/java/com/strata/storage/FilesystemStorage.java +287 -0
- package/dist/android/src/main/java/com/strata/storage/SQLiteStorage.java +260 -203
- package/dist/android/src/main/java/com/stratastorage/StrataStoragePlugin.java +357 -69
- package/dist/capacitor.d.ts.map +1 -1
- package/dist/capacitor.js +2 -1
- package/dist/core/BaseAdapter.d.ts +8 -0
- package/dist/core/BaseAdapter.d.ts.map +1 -1
- package/dist/core/BaseAdapter.js +34 -14
- package/dist/core/Strata.d.ts +56 -2
- package/dist/core/Strata.d.ts.map +1 -1
- package/dist/core/Strata.js +501 -53
- package/dist/features/encryption.d.ts.map +1 -1
- package/dist/features/encryption.js +3 -2
- package/dist/features/integrity.d.ts +16 -0
- package/dist/features/integrity.d.ts.map +1 -0
- package/dist/features/integrity.js +28 -0
- package/dist/features/observer.d.ts.map +1 -1
- package/dist/features/observer.js +2 -1
- package/dist/features/query.d.ts +7 -1
- package/dist/features/query.d.ts.map +1 -1
- package/dist/features/query.js +9 -2
- package/dist/features/sync.d.ts.map +1 -1
- package/dist/features/sync.js +4 -3
- package/dist/index.d.ts +35 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +55 -30
- package/dist/integrations/angular/index.d.ts +158 -0
- package/dist/integrations/angular/index.d.ts.map +1 -0
- package/dist/integrations/angular/index.js +395 -0
- package/dist/integrations/index.d.ts +15 -0
- package/dist/integrations/index.d.ts.map +1 -0
- package/dist/integrations/index.js +18 -0
- package/dist/integrations/react/index.d.ts +75 -0
- package/dist/integrations/react/index.d.ts.map +1 -0
- package/dist/integrations/react/index.js +191 -0
- package/dist/integrations/vue/index.d.ts +103 -0
- package/dist/integrations/vue/index.d.ts.map +1 -0
- package/dist/integrations/vue/index.js +274 -0
- package/dist/ios/AGENTS.md +48 -0
- package/dist/ios/CLAUDE.md +84 -0
- package/dist/ios/Plugin/FilesystemStorage.swift +218 -0
- package/dist/ios/Plugin/KeychainStorage.swift +139 -50
- package/dist/ios/Plugin/SQLiteStorage.swift +279 -147
- package/dist/ios/Plugin/StrataStoragePlugin.m +23 -0
- package/dist/ios/Plugin/StrataStoragePlugin.swift +272 -65
- package/dist/package.json +21 -5
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +2 -1
- package/dist/types/index.d.ts +58 -9
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +0 -13
- package/dist/utils/errors.d.ts +7 -0
- package/dist/utils/errors.d.ts.map +1 -1
- package/dist/utils/errors.js +15 -3
- package/dist/utils/index.d.ts +63 -5
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +109 -16
- package/dist/utils/logger.d.ts +31 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +63 -0
- package/ios/AGENTS.md +48 -0
- package/ios/CLAUDE.md +84 -0
- package/ios/Plugin/FilesystemStorage.swift +218 -0
- package/ios/Plugin/KeychainStorage.swift +139 -50
- package/ios/Plugin/SQLiteStorage.swift +279 -147
- package/ios/Plugin/StrataStoragePlugin.m +23 -0
- package/ios/Plugin/StrataStoragePlugin.swift +272 -65
- package/package.json +31 -20
- package/scripts/build.js +16 -5
- package/scripts/configure.js +2 -6
- package/scripts/postinstall.js +2 -2
- package/Readme.md +0 -271
package/ios/CLAUDE.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# CLAUDE.md — ios/
|
|
2
|
+
|
|
3
|
+
Last Updated: 2026-05-27
|
|
4
|
+
|
|
5
|
+
## iOS Native Plugin
|
|
6
|
+
|
|
7
|
+
Swift implementation of native storage backends for Capacitor.
|
|
8
|
+
|
|
9
|
+
### Files
|
|
10
|
+
|
|
11
|
+
| File | Purpose |
|
|
12
|
+
|------|---------|
|
|
13
|
+
| `Plugin/StrataStoragePlugin.swift` | Main Capacitor plugin entry point |
|
|
14
|
+
| `Plugin/UserDefaultsStorage.swift` | UserDefaults-based general storage |
|
|
15
|
+
| `Plugin/KeychainStorage.swift` | Keychain-based secure storage |
|
|
16
|
+
| `Plugin/SQLiteStorage.swift` | SQLite database storage (multi-store, v2.6.0) |
|
|
17
|
+
| `Plugin/FilesystemStorage.swift` | File-per-key storage under `NSDocumentsDirectory/strata_storage/` (new in v2.6.0) |
|
|
18
|
+
|
|
19
|
+
### Configuration
|
|
20
|
+
|
|
21
|
+
- Pod spec: `StrataStorage.podspec` (root)
|
|
22
|
+
- Minimum iOS version: defined in podspec
|
|
23
|
+
- Language: Swift
|
|
24
|
+
|
|
25
|
+
## v2.6.0 Changes
|
|
26
|
+
|
|
27
|
+
### SQLite multi-store
|
|
28
|
+
`SQLiteStorage.swift` now accepts `database` and `table` parameters from the
|
|
29
|
+
Capacitor call options. Each unique `(database, table)` pair opens a separate
|
|
30
|
+
`.db` file in the app's documents directory. Table identifiers are sanitised to
|
|
31
|
+
`[A-Za-z0-9_]` before use in SQL. The full `StorageValue` wrapper is serialised
|
|
32
|
+
to JSON and stored in a single `value` column; native `get` deserialises and
|
|
33
|
+
returns the full wrapper so TTL, tags, and metadata survive the round-trip.
|
|
34
|
+
Text/blob binds use `SQLITE_TRANSIENT` (removes a latent use-after-free for
|
|
35
|
+
transient Swift buffers). `size(detailed: true)` returns per-column byte
|
|
36
|
+
breakdown.
|
|
37
|
+
|
|
38
|
+
### FilesystemStorage.swift (new)
|
|
39
|
+
Stores each key as `NSDocumentsDirectory/strata_storage/<sanitised-key>.json`.
|
|
40
|
+
Writes are atomic: the value is first written to
|
|
41
|
+
`strata_storage/.staging/<key>.tmp`, then renamed into place. Temp files live in
|
|
42
|
+
the staging subdirectory, so they are never exposed by `keys()` and are not
|
|
43
|
+
deleted by a targeted `remove()`. `isAvailable()` returns `true` on device.
|
|
44
|
+
`size(detailed: true)` supported.
|
|
45
|
+
|
|
46
|
+
> **Pending on-device verification** — native code is complete and reviewed; see
|
|
47
|
+
> `docs/guides/platforms/device-verification.md` for the test matrix.
|
|
48
|
+
|
|
49
|
+
## Rules
|
|
50
|
+
|
|
51
|
+
### Security (IRON-SOLID)
|
|
52
|
+
- Sensitive data MUST use `KeychainStorage`
|
|
53
|
+
- NEVER store credentials, tokens, or secrets in `UserDefaultsStorage`
|
|
54
|
+
- Keychain items should use appropriate access control flags
|
|
55
|
+
|
|
56
|
+
### Plugin Architecture
|
|
57
|
+
- All native methods are exposed through `StrataStoragePlugin.swift`
|
|
58
|
+
- Plugin bridges Capacitor JS calls to native Swift implementations
|
|
59
|
+
- Each storage backend is a separate Swift file
|
|
60
|
+
- The `CAP_PLUGIN` macro must list every callable method; missing entries cause
|
|
61
|
+
silent `undefined` returns on the JS side
|
|
62
|
+
|
|
63
|
+
### SQLite
|
|
64
|
+
- `SQLiteStorage.swift` opens or creates the `.db` file identified by the
|
|
65
|
+
`database` call option (default `strata_storage.db`)
|
|
66
|
+
- Handles table creation, migrations, and CRUD operations
|
|
67
|
+
- Use parameterized queries — NEVER string-interpolate SQL
|
|
68
|
+
- Use `SQLITE_TRANSIENT` for text/blob binds
|
|
69
|
+
|
|
70
|
+
### Filesystem
|
|
71
|
+
- Key strings are sanitised before use as filenames (`/` → `_`, etc.)
|
|
72
|
+
- Staging renames ensure atomic writes; never read from `.staging/`
|
|
73
|
+
- `keys()` lists `.json` files only, ignoring staging artifacts
|
|
74
|
+
|
|
75
|
+
### Testing
|
|
76
|
+
- Native code is tested through the Capacitor bridge
|
|
77
|
+
- Test via the demo app on iOS simulator following
|
|
78
|
+
`docs/guides/platforms/device-verification.md`
|
|
79
|
+
- Verify all adapter methods work end-to-end
|
|
80
|
+
|
|
81
|
+
### Before Modifying
|
|
82
|
+
- Understand the Capacitor plugin protocol
|
|
83
|
+
- Test on iOS simulator after changes
|
|
84
|
+
- Verify podspec still resolves
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import os.log
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Filesystem-backed storage: one file per key under
|
|
6
|
+
* `<Documents>/strata_storage/`.
|
|
7
|
+
*
|
|
8
|
+
* Value-shape contract (matches src/plugin/definitions.ts, same as SQLite):
|
|
9
|
+
* - File contents are the JSON-serialized FULL `StorageValue` wrapper
|
|
10
|
+
* `{ value, created, updated, expires?, tags?, metadata?, ... }`.
|
|
11
|
+
* - `get` parses the bytes back into the wrapper object; the plugin resolves
|
|
12
|
+
* `{ value: wrapper }`. Missing file OR non-object bytes → miss (no crash).
|
|
13
|
+
*
|
|
14
|
+
* File naming: the storage key is percent-encoded with an allowed set that
|
|
15
|
+
* EXCLUDES `/` (and `%`), so the encoding is reversible and never escapes the
|
|
16
|
+
* storage directory. `decodeFileName` reverses it for `keys()`.
|
|
17
|
+
*
|
|
18
|
+
* Durability: writes go to a temp file in the same directory and are then
|
|
19
|
+
* atomically replaced into place, so a crash mid-write cannot leave a torn
|
|
20
|
+
* file at the real path.
|
|
21
|
+
*/
|
|
22
|
+
@objc public class FilesystemStorage: NSObject {
|
|
23
|
+
private let directory: URL
|
|
24
|
+
private let stagingDirectory: URL
|
|
25
|
+
private let logger = OSLog(subsystem: "com.strata.storage", category: "FilesystemStorage")
|
|
26
|
+
|
|
27
|
+
/// Reserved staging subdirectory name (inside the storage dir) holding
|
|
28
|
+
/// in-flight temp files for atomic writes. Enumeration skips it by name, so
|
|
29
|
+
/// a temp file can never collide with an encoded key file (e.g. a real key
|
|
30
|
+
/// literally named ".tmp-x").
|
|
31
|
+
private static let stagingDirName = ".strata-staging"
|
|
32
|
+
|
|
33
|
+
/// Characters allowed verbatim in an on-disk file name. Anything else
|
|
34
|
+
/// (notably `/` and `%`) is percent-escaped, keeping the name reversible
|
|
35
|
+
/// and confined to a single path component.
|
|
36
|
+
private static let fileNameAllowed: CharacterSet = {
|
|
37
|
+
var set = CharacterSet.alphanumerics
|
|
38
|
+
set.insert(charactersIn: "-_. ")
|
|
39
|
+
return set
|
|
40
|
+
}()
|
|
41
|
+
|
|
42
|
+
@objc public override init() {
|
|
43
|
+
let base = (try? FileManager.default.url(
|
|
44
|
+
for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true
|
|
45
|
+
)) ?? URL(fileURLWithPath: NSTemporaryDirectory())
|
|
46
|
+
let storageDir = base.appendingPathComponent("strata_storage", isDirectory: true)
|
|
47
|
+
self.directory = storageDir
|
|
48
|
+
self.stagingDirectory = storageDir.appendingPathComponent(FilesystemStorage.stagingDirName, isDirectory: true)
|
|
49
|
+
super.init()
|
|
50
|
+
ensureDirectory()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private func ensureDirectory() {
|
|
54
|
+
for dir in [directory, stagingDirectory] {
|
|
55
|
+
if !FileManager.default.fileExists(atPath: dir.path) {
|
|
56
|
+
do {
|
|
57
|
+
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
58
|
+
} catch {
|
|
59
|
+
os_log("Failed to create filesystem storage dir: %{public}@", log: logger, type: .error, error.localizedDescription)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// Reversible encoding of a storage key → safe single-component file name.
|
|
66
|
+
private func encodeFileName(_ key: String) -> String {
|
|
67
|
+
// addingPercentEncoding only returns nil for invalid unichar sequences,
|
|
68
|
+
// which a Swift String key cannot contain; fall back defensively.
|
|
69
|
+
return key.addingPercentEncoding(withAllowedCharacters: FilesystemStorage.fileNameAllowed) ?? key
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/// Reverses `encodeFileName`. Returns nil if the name cannot be decoded.
|
|
73
|
+
private func decodeFileName(_ name: String) -> String? {
|
|
74
|
+
return name.removingPercentEncoding
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private func fileURL(forKey key: String) -> URL {
|
|
78
|
+
return directory.appendingPathComponent(encodeFileName(key), isDirectory: false)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Persist the full `StorageValue` wrapper for `key`. The wrapper is
|
|
83
|
+
* JSON-encoded and written atomically (temp file + replace).
|
|
84
|
+
*/
|
|
85
|
+
@objc public func set(key: String, wrapper: [String: Any]) throws -> Bool {
|
|
86
|
+
ensureDirectory()
|
|
87
|
+
let data = try JSONSerialization.data(withJSONObject: wrapper, options: [])
|
|
88
|
+
let target = fileURL(forKey: key)
|
|
89
|
+
|
|
90
|
+
// Atomic write: stage to a unique temp file in the reserved staging
|
|
91
|
+
// subdir (same volume → atomic rename), then replace. The staging subdir
|
|
92
|
+
// keeps temp names out of the key namespace entirely.
|
|
93
|
+
let temp = stagingDirectory.appendingPathComponent(UUID().uuidString, isDirectory: false)
|
|
94
|
+
do {
|
|
95
|
+
try data.write(to: temp, options: .atomic)
|
|
96
|
+
if FileManager.default.fileExists(atPath: target.path) {
|
|
97
|
+
_ = try FileManager.default.replaceItemAt(target, withItemAt: temp)
|
|
98
|
+
} else {
|
|
99
|
+
try FileManager.default.moveItem(at: temp, to: target)
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
// Clean up the temp file on any failure so we don't leak partials.
|
|
103
|
+
try? FileManager.default.removeItem(at: temp)
|
|
104
|
+
throw error
|
|
105
|
+
}
|
|
106
|
+
return true
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Read the wrapper object for `key`. Missing file OR bytes that are not a
|
|
111
|
+
* JSON object → nil (treated as a miss; never throws on corrupt data).
|
|
112
|
+
*/
|
|
113
|
+
@objc public func get(key: String) -> [String: Any]? {
|
|
114
|
+
let url = fileURL(forKey: key)
|
|
115
|
+
guard let data = try? Data(contentsOf: url) else {
|
|
116
|
+
return nil
|
|
117
|
+
}
|
|
118
|
+
guard let wrapper = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
|
|
119
|
+
return nil
|
|
120
|
+
}
|
|
121
|
+
return wrapper
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
@objc public func remove(key: String) -> Bool {
|
|
125
|
+
let url = fileURL(forKey: key)
|
|
126
|
+
if !FileManager.default.fileExists(atPath: url.path) {
|
|
127
|
+
return true // already absent — treat as success/idempotent
|
|
128
|
+
}
|
|
129
|
+
do {
|
|
130
|
+
try FileManager.default.removeItem(at: url)
|
|
131
|
+
return true
|
|
132
|
+
} catch {
|
|
133
|
+
os_log("Failed to remove filesystem key: %{public}@", log: logger, type: .error, error.localizedDescription)
|
|
134
|
+
return false
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/// Clears stored entries. When `prefix` is set, only keys starting with it
|
|
139
|
+
/// are removed; otherwise the whole storage directory contents are removed.
|
|
140
|
+
@objc public func clear(prefix: String? = nil) -> Bool {
|
|
141
|
+
let names = (try? FileManager.default.contentsOfDirectory(atPath: directory.path)) ?? []
|
|
142
|
+
var ok = true
|
|
143
|
+
for name in names {
|
|
144
|
+
// Skip the staging subdirectory (never a key).
|
|
145
|
+
if name == FilesystemStorage.stagingDirName { continue }
|
|
146
|
+
guard let key = decodeFileName(name) else { continue }
|
|
147
|
+
if let prefix = prefix, !key.hasPrefix(prefix) { continue }
|
|
148
|
+
do {
|
|
149
|
+
try FileManager.default.removeItem(at: directory.appendingPathComponent(name, isDirectory: false))
|
|
150
|
+
} catch {
|
|
151
|
+
ok = false
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// On a full clear, also drop any orphaned in-flight temp files.
|
|
155
|
+
if prefix == nil, let temps = try? FileManager.default.contentsOfDirectory(atPath: stagingDirectory.path) {
|
|
156
|
+
for temp in temps {
|
|
157
|
+
try? FileManager.default.removeItem(at: stagingDirectory.appendingPathComponent(temp, isDirectory: false))
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return ok
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/// Lists stored keys, decoding file names back to their original keys.
|
|
164
|
+
/// When `pattern` is set, applies the same prefix/contains matching the
|
|
165
|
+
/// other backends use.
|
|
166
|
+
@objc public func keys(pattern: String? = nil) -> [String] {
|
|
167
|
+
let names = (try? FileManager.default.contentsOfDirectory(atPath: directory.path)) ?? []
|
|
168
|
+
var keys: [String] = []
|
|
169
|
+
for name in names {
|
|
170
|
+
if name == FilesystemStorage.stagingDirName { continue }
|
|
171
|
+
guard let key = decodeFileName(name) else { continue }
|
|
172
|
+
keys.append(key)
|
|
173
|
+
}
|
|
174
|
+
guard let pattern = pattern else { return keys }
|
|
175
|
+
return keys.filter { $0.hasPrefix(pattern) || $0.contains(pattern) }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Aggregate sizes over the storage directory.
|
|
180
|
+
*
|
|
181
|
+
* `count` = number of entry files; `values` = sum of file byte sizes;
|
|
182
|
+
* `keys` = sum of decoded-key UTF-8 byte lengths; `metadata` = sum of the
|
|
183
|
+
* serialized `metadata` segment per file (0 when absent or unparseable);
|
|
184
|
+
* `total` = `keys` + `values`.
|
|
185
|
+
*/
|
|
186
|
+
@objc public func size() -> [String: Int] {
|
|
187
|
+
let names = (try? FileManager.default.contentsOfDirectory(atPath: directory.path)) ?? []
|
|
188
|
+
var count = 0
|
|
189
|
+
var keysSize = 0
|
|
190
|
+
var valuesSize = 0
|
|
191
|
+
var metadataSize = 0
|
|
192
|
+
|
|
193
|
+
for name in names {
|
|
194
|
+
if name == FilesystemStorage.stagingDirName { continue }
|
|
195
|
+
guard let key = decodeFileName(name) else { continue }
|
|
196
|
+
let url = directory.appendingPathComponent(name, isDirectory: false)
|
|
197
|
+
count += 1
|
|
198
|
+
keysSize += key.data(using: .utf8)?.count ?? 0
|
|
199
|
+
|
|
200
|
+
if let data = try? Data(contentsOf: url) {
|
|
201
|
+
valuesSize += data.count
|
|
202
|
+
if let wrapper = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
|
|
203
|
+
let metadata = wrapper["metadata"],
|
|
204
|
+
let metadataData = try? JSONSerialization.data(withJSONObject: metadata, options: []) {
|
|
205
|
+
metadataSize += metadataData.count
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return [
|
|
211
|
+
"count": count,
|
|
212
|
+
"keys": keysSize,
|
|
213
|
+
"values": valuesSize,
|
|
214
|
+
"metadata": metadataSize,
|
|
215
|
+
"total": keysSize + valuesSize
|
|
216
|
+
]
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -1,19 +1,72 @@
|
|
|
1
1
|
import Foundation
|
|
2
2
|
import Security
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Keychain-backed secure storage.
|
|
6
|
+
*
|
|
7
|
+
* Security notes:
|
|
8
|
+
* - Items default to `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`,
|
|
9
|
+
* which keeps data available to background tasks after the first unlock
|
|
10
|
+
* while preventing it from leaving the device via encrypted backups.
|
|
11
|
+
* `kSecAttrAccessibleAlways` / `kSecAttrAccessibleAlwaysThisDeviceOnly`
|
|
12
|
+
* are deprecated and insecure and are intentionally NOT used.
|
|
13
|
+
* - Callers may pass a stricter accessibility class from JS via the
|
|
14
|
+
* `accessible` option (see KeychainAccessible in definitions.ts).
|
|
15
|
+
* - Secret values are never logged.
|
|
16
|
+
*/
|
|
4
17
|
@objc public class KeychainStorage: NSObject {
|
|
5
18
|
private let service: String
|
|
6
19
|
private let accessGroup: String?
|
|
7
|
-
|
|
20
|
+
private let defaultAccessible: CFString
|
|
21
|
+
|
|
8
22
|
@objc public init(service: String? = nil, accessGroup: String? = nil) {
|
|
9
23
|
self.service = service ?? Bundle.main.bundleIdentifier ?? "StrataStorage"
|
|
10
24
|
self.accessGroup = accessGroup
|
|
25
|
+
// After-first-unlock + this-device-only is a safe, widely-recommended
|
|
26
|
+
// default for app secrets. It is more permissive than whenUnlocked
|
|
27
|
+
// (so background access works) but still excluded from backups.
|
|
28
|
+
self.defaultAccessible = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
|
11
29
|
super.init()
|
|
12
30
|
}
|
|
13
|
-
|
|
14
|
-
|
|
31
|
+
|
|
32
|
+
/// Maps a JS `KeychainAccessible` string to the matching Security framework
|
|
33
|
+
/// constant. Unknown / nil values fall back to the secure default.
|
|
34
|
+
private func accessibleConstant(for raw: String?) -> CFString {
|
|
35
|
+
switch raw {
|
|
36
|
+
case "whenUnlocked":
|
|
37
|
+
return kSecAttrAccessibleWhenUnlocked
|
|
38
|
+
case "afterFirstUnlock":
|
|
39
|
+
return kSecAttrAccessibleAfterFirstUnlock
|
|
40
|
+
case "whenUnlockedThisDeviceOnly":
|
|
41
|
+
return kSecAttrAccessibleWhenUnlockedThisDeviceOnly
|
|
42
|
+
case "afterFirstUnlockThisDeviceOnly":
|
|
43
|
+
return kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
|
44
|
+
case "whenPasscodeSetThisDeviceOnly":
|
|
45
|
+
return kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly
|
|
46
|
+
default:
|
|
47
|
+
return defaultAccessible
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// Builds an NSError that surfaces the OSStatus to the JS bridge without
|
|
52
|
+
/// leaking any stored secret value.
|
|
53
|
+
private func keychainError(_ status: OSStatus, operation: String) -> NSError {
|
|
54
|
+
let message: String
|
|
55
|
+
if #available(iOS 11.3, *), let str = SecCopyErrorMessageString(status, nil) as String? {
|
|
56
|
+
message = str
|
|
57
|
+
} else {
|
|
58
|
+
message = "OSStatus \(status)"
|
|
59
|
+
}
|
|
60
|
+
return NSError(
|
|
61
|
+
domain: "StrataStorage.KeychainStorage",
|
|
62
|
+
code: Int(status),
|
|
63
|
+
userInfo: [NSLocalizedDescriptionKey: "Keychain \(operation) failed: \(message)"]
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@objc public func set(key: String, value: Any, accessible: String? = nil) throws -> Bool {
|
|
15
68
|
let data: Data
|
|
16
|
-
|
|
69
|
+
|
|
17
70
|
if let dataValue = value as? Data {
|
|
18
71
|
data = dataValue
|
|
19
72
|
} else if let stringValue = value as? String {
|
|
@@ -23,28 +76,43 @@ import Security
|
|
|
23
76
|
let jsonData = try JSONSerialization.data(withJSONObject: value, options: [])
|
|
24
77
|
data = jsonData
|
|
25
78
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
79
|
+
|
|
80
|
+
// Remove any existing item first so the accessibility class is applied
|
|
81
|
+
// cleanly (SecItemUpdate cannot change accessibility reliably).
|
|
82
|
+
let deleteQuery = createQuery(key: key)
|
|
83
|
+
SecItemDelete(deleteQuery as CFDictionary)
|
|
84
|
+
|
|
85
|
+
var newItem = createQuery(key: key, accessible: accessibleConstant(for: accessible))
|
|
31
86
|
newItem[kSecValueData as String] = data
|
|
32
|
-
|
|
87
|
+
|
|
33
88
|
let status = SecItemAdd(newItem as CFDictionary, nil)
|
|
34
|
-
|
|
89
|
+
guard status == errSecSuccess else {
|
|
90
|
+
throw keychainError(status, operation: "set")
|
|
91
|
+
}
|
|
92
|
+
return true
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Convenience overload kept for existing Objective-C callers.
|
|
96
|
+
@objc public func set(key: String, value: Any) throws -> Bool {
|
|
97
|
+
return try set(key: key, value: value, accessible: nil)
|
|
35
98
|
}
|
|
36
|
-
|
|
99
|
+
|
|
37
100
|
@objc public func get(key: String) throws -> Any? {
|
|
38
101
|
var query = createQuery(key: key)
|
|
39
102
|
query[kSecReturnData as String] = true
|
|
40
103
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
|
41
|
-
|
|
104
|
+
|
|
42
105
|
var result: AnyObject?
|
|
43
106
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
44
|
-
|
|
45
|
-
|
|
107
|
+
|
|
108
|
+
if status == errSecItemNotFound {
|
|
109
|
+
return nil
|
|
110
|
+
}
|
|
111
|
+
guard status == errSecSuccess else {
|
|
112
|
+
throw keychainError(status, operation: "get")
|
|
113
|
+
}
|
|
46
114
|
guard let data = result as? Data else { return nil }
|
|
47
|
-
|
|
115
|
+
|
|
48
116
|
// Try to parse as JSON first, fallback to string
|
|
49
117
|
if let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []) {
|
|
50
118
|
return jsonObject
|
|
@@ -52,35 +120,41 @@ import Security
|
|
|
52
120
|
return String(data: data, encoding: .utf8)
|
|
53
121
|
}
|
|
54
122
|
}
|
|
55
|
-
|
|
123
|
+
|
|
56
124
|
@objc public func remove(key: String) throws -> Bool {
|
|
57
125
|
let query = createQuery(key: key)
|
|
58
126
|
let status = SecItemDelete(query as CFDictionary)
|
|
59
|
-
|
|
127
|
+
guard status == errSecSuccess || status == errSecItemNotFound else {
|
|
128
|
+
throw keychainError(status, operation: "remove")
|
|
129
|
+
}
|
|
130
|
+
return true
|
|
60
131
|
}
|
|
61
|
-
|
|
132
|
+
|
|
62
133
|
@objc public func clear(prefix: String? = nil) throws -> Bool {
|
|
63
134
|
if let prefix = prefix {
|
|
64
135
|
// Clear only keys with the given prefix
|
|
65
136
|
let keysToRemove = try keys(pattern: prefix)
|
|
66
|
-
var allSuccess = true
|
|
67
137
|
for key in keysToRemove {
|
|
68
|
-
|
|
69
|
-
allSuccess = false
|
|
70
|
-
}
|
|
138
|
+
_ = try remove(key: key)
|
|
71
139
|
}
|
|
72
|
-
return
|
|
140
|
+
return true
|
|
73
141
|
} else {
|
|
74
|
-
// Clear all keys
|
|
75
|
-
|
|
142
|
+
// Clear all keys for this service (and access group, if any).
|
|
143
|
+
var query: [String: Any] = [
|
|
76
144
|
kSecClass as String: kSecClassGenericPassword,
|
|
77
145
|
kSecAttrService as String: service
|
|
78
146
|
]
|
|
147
|
+
if let accessGroup = accessGroup {
|
|
148
|
+
query[kSecAttrAccessGroup as String] = accessGroup
|
|
149
|
+
}
|
|
79
150
|
let status = SecItemDelete(query as CFDictionary)
|
|
80
|
-
|
|
151
|
+
guard status == errSecSuccess || status == errSecItemNotFound else {
|
|
152
|
+
throw keychainError(status, operation: "clear")
|
|
153
|
+
}
|
|
154
|
+
return true
|
|
81
155
|
}
|
|
82
156
|
}
|
|
83
|
-
|
|
157
|
+
|
|
84
158
|
@objc public func keys(pattern: String? = nil) throws -> [String] {
|
|
85
159
|
var query: [String: Any] = [
|
|
86
160
|
kSecClass as String: kSecClassGenericPassword,
|
|
@@ -88,57 +162,72 @@ import Security
|
|
|
88
162
|
kSecReturnAttributes as String: true,
|
|
89
163
|
kSecMatchLimit as String: kSecMatchLimitAll
|
|
90
164
|
]
|
|
91
|
-
|
|
165
|
+
|
|
92
166
|
if let accessGroup = accessGroup {
|
|
93
167
|
query[kSecAttrAccessGroup as String] = accessGroup
|
|
94
168
|
}
|
|
95
|
-
|
|
169
|
+
|
|
96
170
|
var result: AnyObject?
|
|
97
171
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
98
|
-
|
|
172
|
+
|
|
173
|
+
if status == errSecItemNotFound {
|
|
174
|
+
return []
|
|
175
|
+
}
|
|
99
176
|
guard status == errSecSuccess,
|
|
100
|
-
let items = result as? [[String: Any]] else {
|
|
101
|
-
|
|
177
|
+
let items = result as? [[String: Any]] else {
|
|
178
|
+
if status == errSecSuccess { return [] }
|
|
179
|
+
throw keychainError(status, operation: "keys")
|
|
180
|
+
}
|
|
181
|
+
|
|
102
182
|
let allKeys = items.compactMap { $0[kSecAttrAccount as String] as? String }
|
|
103
|
-
|
|
183
|
+
|
|
104
184
|
guard let pattern = pattern else {
|
|
105
185
|
return allKeys
|
|
106
186
|
}
|
|
107
|
-
|
|
108
|
-
// Filter keys by pattern (simple prefix matching)
|
|
187
|
+
|
|
188
|
+
// Filter keys by pattern (simple prefix / contains matching)
|
|
109
189
|
return allKeys.filter { key in
|
|
110
190
|
key.hasPrefix(pattern) || key.contains(pattern)
|
|
111
191
|
}
|
|
112
192
|
}
|
|
113
|
-
|
|
114
|
-
private func createQuery(key: String) -> [String: Any] {
|
|
193
|
+
|
|
194
|
+
private func createQuery(key: String, accessible: CFString? = nil) -> [String: Any] {
|
|
115
195
|
var query: [String: Any] = [
|
|
116
196
|
kSecClass as String: kSecClassGenericPassword,
|
|
117
197
|
kSecAttrService as String: service,
|
|
118
|
-
kSecAttrAccount as String: key
|
|
119
|
-
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
|
|
198
|
+
kSecAttrAccount as String: key
|
|
120
199
|
]
|
|
121
|
-
|
|
200
|
+
|
|
201
|
+
// Accessibility only needs to be set when adding/writing an item.
|
|
202
|
+
// Including it on read/delete queries can over-constrain matching.
|
|
203
|
+
if let accessible = accessible {
|
|
204
|
+
query[kSecAttrAccessible as String] = accessible
|
|
205
|
+
}
|
|
206
|
+
|
|
122
207
|
if let accessGroup = accessGroup {
|
|
123
208
|
query[kSecAttrAccessGroup as String] = accessGroup
|
|
124
209
|
}
|
|
125
|
-
|
|
210
|
+
|
|
126
211
|
return query
|
|
127
212
|
}
|
|
128
|
-
|
|
213
|
+
|
|
129
214
|
@objc public func size() throws -> (total: Int, count: Int) {
|
|
130
215
|
let allKeys = try keys()
|
|
131
216
|
var totalSize = 0
|
|
132
|
-
|
|
217
|
+
|
|
133
218
|
for key in allKeys {
|
|
134
|
-
if let
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
219
|
+
if let value = try get(key: key) {
|
|
220
|
+
if let data = value as? Data {
|
|
221
|
+
totalSize += data.count
|
|
222
|
+
} else if let string = value as? String {
|
|
223
|
+
totalSize += string.data(using: .utf8)?.count ?? 0
|
|
224
|
+
} else if let jsonData = try? JSONSerialization.data(withJSONObject: value, options: []) {
|
|
225
|
+
totalSize += jsonData.count
|
|
226
|
+
}
|
|
138
227
|
}
|
|
139
228
|
totalSize += key.data(using: .utf8)?.count ?? 0
|
|
140
229
|
}
|
|
141
|
-
|
|
230
|
+
|
|
142
231
|
return (total: totalSize, count: allKeys.count)
|
|
143
232
|
}
|
|
144
|
-
}
|
|
233
|
+
}
|