@octane-xplat/secure-storage 0.9.0 → 0.11.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/README.md
CHANGED
|
@@ -1,24 +1,22 @@
|
|
|
1
1
|
# `@octane-xplat/secure-storage`
|
|
2
2
|
|
|
3
|
-
Keychain/Keystore-backed secret storage for Octane xplat apps. iOS writes
|
|
4
|
-
to the Keychain and Android to the Keystore via
|
|
5
|
-
`@nativescript/secure-storage`; Linux goes through the desktop host bridge
|
|
6
|
-
to the Secret Service API (`org.freedesktop.secrets`); web and the macOS
|
|
7
|
-
AppKit dev host report `supported: false` — IndexedDB and NSUserDefaults
|
|
8
|
-
are not a trust boundary, so the leaf refuses to pretend.
|
|
9
|
-
|
|
10
3
|
```sh
|
|
11
4
|
pnpm add @octane-xplat/secure-storage
|
|
12
5
|
```
|
|
13
6
|
|
|
7
|
+
Store session tokens without displaying their contents. iOS and macOS use
|
|
8
|
+
Keychain, Android uses the Keystore-backed plugin, and Linux uses the desktop
|
|
9
|
+
host's Secret Service adapter. Plain browsers report `supported: false`.
|
|
10
|
+
|
|
14
11
|
```ts
|
|
15
12
|
import { secureStorage } from '@octane-xplat/secure-storage'
|
|
16
13
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
await
|
|
20
|
-
const
|
|
21
|
-
await store.
|
|
14
|
+
// Call with a token issued by your app's backend; never log its value.
|
|
15
|
+
async function saveSession(token: string) {
|
|
16
|
+
if ((await secureStorage.ensure()) !== 'granted' || !secureStorage.impl) return null
|
|
17
|
+
const store = secureStorage.impl
|
|
18
|
+
if (!(await store.set('session-token', token))) return null
|
|
19
|
+
return await store.get('session-token')
|
|
22
20
|
}
|
|
23
21
|
```
|
|
24
22
|
|
|
@@ -27,8 +25,47 @@ implementation exists on this target — branch on it rather than catching —
|
|
|
27
25
|
`ensure()` resolves the permission/availability state, and `impl` is an
|
|
28
26
|
async string key-value store (`get` / `set` / `remove`).
|
|
29
27
|
|
|
28
|
+
```ts
|
|
29
|
+
if ((await secureStorage.ensure()) === 'granted') {
|
|
30
|
+
await secureStorage.impl!.remove('session-token')
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
30
34
|
For non-secret preferences, use `storage` from
|
|
31
35
|
[`@octane-xplat/platform`](../platform/README.md) instead.
|
|
32
36
|
|
|
33
|
-
Guide: [Using device features](../../docs/platform-services.md);
|
|
34
|
-
per-target availability: [platform notes](../../docs/platform-notes.md).
|
|
37
|
+
Guide: [Using device features](../../docs/platform/platform-services.md);
|
|
38
|
+
per-target availability: [platform notes](../../docs/notes/platform-notes.md).
|
|
39
|
+
|
|
40
|
+
## macOS AppKit
|
|
41
|
+
|
|
42
|
+
Install the leaf as an app runtime dependency. Use the CLI-owned
|
|
43
|
+
`pnpm xplat dev --targets macos` or `pnpm xplat build --targets macos` workflow;
|
|
44
|
+
it compiles the bundled Foundation/Security sources and loads their metadata
|
|
45
|
+
before your JavaScript. See [native prerequisites](../../docs/platform/macos-native.md#prepare-the-app).
|
|
46
|
+
No new UI dependency, host adapter, or runtime permission request is needed.
|
|
47
|
+
A custom host without the native leaf reports unsupported.
|
|
48
|
+
|
|
49
|
+
Keychain generic-password items use a service namespace owned by this leaf.
|
|
50
|
+
Packaged apps are scoped by bundle identifier; keep that identifier stable
|
|
51
|
+
across releases. CLI development hosts are scoped by the app's working directory.
|
|
52
|
+
Moving that directory changes the development namespace, and development entries
|
|
53
|
+
are separate from packaged entries. Items stay on this Mac and do not use iCloud
|
|
54
|
+
synchronization. `remove()` deletes only the named item in that namespace.
|
|
55
|
+
|
|
56
|
+
`ensure()` reports that the native implementation exists; it does not unlock the
|
|
57
|
+
Keychain or guarantee the next operation succeeds. No authentication prompt is
|
|
58
|
+
opened by the leaf. A missing key returns `null`; a failed read rejects with
|
|
59
|
+
`Keychain read failed`, without keys, values, or native exception text. Writing the
|
|
60
|
+
same key overwrites its value, and empty strings are preserved. Writes
|
|
61
|
+
return `false` when Keychain access fails. Removal returns `true` for an already
|
|
62
|
+
missing key and `false` for access failures. Handle these results in your sign-in
|
|
63
|
+
flow rather than falling back to preferences or browser storage.
|
|
64
|
+
|
|
65
|
+
From the repository root, `pnpm --filter @octane-xplat/secure-storage test:macos`
|
|
66
|
+
checks the adapter and sanitized failures. `test:packed` checks package types;
|
|
67
|
+
`test:macos-runtime` compiles a packed leaf and exercises real Keychain operations
|
|
68
|
+
in an isolated host, including persistence across launches, bundle identity isolation,
|
|
69
|
+
and a Vite build of its public import. That fixture creates
|
|
70
|
+
random payloads in memory, prints only status markers, and removes its test items.
|
|
71
|
+
It does not test UI input or locked-Keychain/signing policies.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@octane-xplat/secure-storage",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Keychain/Keystore-backed secure storage for Octane xplat — matching web + NativeScript implementations",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"android",
|
|
@@ -22,12 +22,17 @@
|
|
|
22
22
|
"directory": "packages/secure-storage"
|
|
23
23
|
},
|
|
24
24
|
"files": [
|
|
25
|
-
"src"
|
|
25
|
+
"src",
|
|
26
|
+
"types",
|
|
27
|
+
"platforms"
|
|
26
28
|
],
|
|
27
29
|
"type": "module",
|
|
28
30
|
"exports": {
|
|
29
31
|
".": {
|
|
30
|
-
"macos":
|
|
32
|
+
"macos": {
|
|
33
|
+
"types": "./types/index.macos.d.ts",
|
|
34
|
+
"default": "./src/index.macos.ts"
|
|
35
|
+
},
|
|
31
36
|
"default": "./src/index.ts"
|
|
32
37
|
},
|
|
33
38
|
"./*": "./src/*"
|
|
@@ -37,12 +42,13 @@
|
|
|
37
42
|
},
|
|
38
43
|
"dependencies": {
|
|
39
44
|
"@nativescript/secure-storage": "4.0.2",
|
|
40
|
-
"@octane-xplat/platform": "0.
|
|
45
|
+
"@octane-xplat/platform": "0.11.0"
|
|
41
46
|
},
|
|
42
47
|
"devDependencies": {
|
|
43
48
|
"@nativescript-community/octane": "0.2.4",
|
|
44
49
|
"@nativescript/core": "9.1.2",
|
|
45
|
-
"octane": "0.6.3"
|
|
50
|
+
"octane": "0.6.3",
|
|
51
|
+
"typescript": "5.9.3"
|
|
46
52
|
},
|
|
47
53
|
"peerDependencies": {
|
|
48
54
|
"@nativescript-community/octane": ">=0.2.1 <1",
|
|
@@ -63,7 +69,17 @@
|
|
|
63
69
|
"ios": "9.1.0"
|
|
64
70
|
}
|
|
65
71
|
},
|
|
72
|
+
"xplat": {
|
|
73
|
+
"macos": {
|
|
74
|
+
"frameworks": [
|
|
75
|
+
"Foundation",
|
|
76
|
+
"Security"
|
|
77
|
+
]
|
|
78
|
+
}
|
|
79
|
+
},
|
|
66
80
|
"scripts": {
|
|
67
|
-
"test:packed": "node tests/packed-consumer.mjs"
|
|
81
|
+
"test:packed": "node tests/macos.test.mjs && node tests/packed-consumer.mjs",
|
|
82
|
+
"test:macos": "node tests/macos.test.mjs",
|
|
83
|
+
"test:macos-runtime": "node tests/macos-runtime.mjs"
|
|
68
84
|
}
|
|
69
85
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#import "XplatSecureStorage.h"
|
|
2
|
+
#import <Security/Security.h>
|
|
3
|
+
|
|
4
|
+
@implementation XplatSecureStorage
|
|
5
|
+
+ (NSMutableDictionary *)query:(NSString *)key {
|
|
6
|
+
// Packaged apps use their bundle ID. CLI development hosts share an
|
|
7
|
+
// executable, so scope those entries to the app's working directory.
|
|
8
|
+
NSString *identity = NSBundle.mainBundle.bundleIdentifier;
|
|
9
|
+
if (!identity.length) {
|
|
10
|
+
identity = [@"dev:" stringByAppendingString:NSFileManager.defaultManager.currentDirectoryPath];
|
|
11
|
+
}
|
|
12
|
+
return [@{
|
|
13
|
+
(__bridge id)kSecClass: (__bridge id)kSecClassGenericPassword,
|
|
14
|
+
(__bridge id)kSecAttrService: [@"org.octane.xplat.secure-storage:" stringByAppendingString:identity],
|
|
15
|
+
(__bridge id)kSecAttrAccount: key,
|
|
16
|
+
(__bridge id)kSecAttrSynchronizable: @NO,
|
|
17
|
+
(__bridge id)kSecUseAuthenticationUI: (__bridge id)kSecUseAuthenticationUIFail,
|
|
18
|
+
} mutableCopy];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
+ (NSDictionary *)get:(NSString *)key {
|
|
22
|
+
NSMutableDictionary *query = [self query:key];
|
|
23
|
+
query[(__bridge id)kSecReturnData] = @YES;
|
|
24
|
+
query[(__bridge id)kSecMatchLimit] = (__bridge id)kSecMatchLimitOne;
|
|
25
|
+
CFTypeRef item = NULL;
|
|
26
|
+
OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, &item);
|
|
27
|
+
NSData *data = CFBridgingRelease(item);
|
|
28
|
+
NSString *value = status == errSecSuccess && [data isKindOfClass:NSData.class]
|
|
29
|
+
? [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] : nil;
|
|
30
|
+
if (status == errSecSuccess && !value) status = errSecDecode;
|
|
31
|
+
return value ? @{ @"status": @(status), @"value": value } : @{ @"status": @(status) };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
+ (BOOL)set:(NSDictionary *)options {
|
|
35
|
+
NSString *key = options[@"key"];
|
|
36
|
+
NSString *value = options[@"value"];
|
|
37
|
+
if (![key isKindOfClass:NSString.class] || ![value isKindOfClass:NSString.class]) return NO;
|
|
38
|
+
NSData *data = [value dataUsingEncoding:NSUTF8StringEncoding];
|
|
39
|
+
if (!data) return NO;
|
|
40
|
+
NSMutableDictionary *query = [self query:key];
|
|
41
|
+
NSDictionary *update = @{ (__bridge id)kSecValueData: data };
|
|
42
|
+
OSStatus status = SecItemUpdate((__bridge CFDictionaryRef)query, (__bridge CFDictionaryRef)update);
|
|
43
|
+
if (status == errSecSuccess) return YES;
|
|
44
|
+
if (status != errSecItemNotFound) return NO;
|
|
45
|
+
query[(__bridge id)kSecValueData] = data;
|
|
46
|
+
query[(__bridge id)kSecAttrAccessible] = (__bridge id)kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly;
|
|
47
|
+
status = SecItemAdd((__bridge CFDictionaryRef)query, NULL);
|
|
48
|
+
// Another caller can insert between update and add. Never delete an
|
|
49
|
+
// existing item to overwrite it: preserve its access controls and value.
|
|
50
|
+
if (status == errSecDuplicateItem) {
|
|
51
|
+
return SecItemUpdate((__bridge CFDictionaryRef)[self query:key], (__bridge CFDictionaryRef)update) == errSecSuccess;
|
|
52
|
+
}
|
|
53
|
+
return status == errSecSuccess;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
+ (BOOL)remove:(NSString *)key {
|
|
57
|
+
OSStatus status = SecItemDelete((__bridge CFDictionaryRef)[self query:key]);
|
|
58
|
+
return status == errSecSuccess || status == errSecItemNotFound;
|
|
59
|
+
}
|
|
60
|
+
@end
|
|
@@ -1,9 +1,55 @@
|
|
|
1
|
-
//
|
|
2
|
-
// which is not a trust boundary, so the capability stays unsupported.
|
|
1
|
+
// Native APIs are loaded by the CLI's platforms/macos leaf loader.
|
|
3
2
|
import type { Capability, SecureStore } from './types'
|
|
4
3
|
|
|
4
|
+
interface NativeSecureStorage {
|
|
5
|
+
get(key: string): { objectForKey(key: string): unknown }
|
|
6
|
+
set(options: { key: string; value: string }): boolean
|
|
7
|
+
remove(key: string): boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
declare const XplatSecureStorage: NativeSecureStorage | undefined
|
|
11
|
+
const native = () => (typeof XplatSecureStorage === 'undefined' ? null : XplatSecureStorage)
|
|
12
|
+
|
|
13
|
+
const store: SecureStore = {
|
|
14
|
+
async get(key) {
|
|
15
|
+
try {
|
|
16
|
+
const result = native()?.get(key)
|
|
17
|
+
if (!result || result.objectForKey('status') === -25300) {
|
|
18
|
+
return null
|
|
19
|
+
} // errSecItemNotFound
|
|
20
|
+
|
|
21
|
+
const value = result.objectForKey('value')
|
|
22
|
+
if (result.objectForKey('status') === 0 && typeof value === 'string') {
|
|
23
|
+
return value
|
|
24
|
+
}
|
|
25
|
+
} catch {
|
|
26
|
+
// Native exceptions may contain arguments. Never forward them.
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
throw new Error('Keychain read failed')
|
|
30
|
+
},
|
|
31
|
+
async set(key, value) {
|
|
32
|
+
try {
|
|
33
|
+
return native()?.set({ key, value }) ?? false
|
|
34
|
+
} catch {
|
|
35
|
+
return false
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
async remove(key) {
|
|
39
|
+
try {
|
|
40
|
+
return native()?.remove(key) ?? false
|
|
41
|
+
} catch {
|
|
42
|
+
return false
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
5
47
|
export const secureStorage: Capability<SecureStore> = {
|
|
6
|
-
supported
|
|
7
|
-
|
|
8
|
-
|
|
48
|
+
get supported() {
|
|
49
|
+
return native() !== null
|
|
50
|
+
},
|
|
51
|
+
ensure: async () => (native() ? 'granted' : 'unsupported'),
|
|
52
|
+
get impl() {
|
|
53
|
+
return native() ? store : null
|
|
54
|
+
},
|
|
9
55
|
}
|
package/src/types.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Optional capability — never throws for absence (docs/platform-services.md). */
|
|
1
|
+
/** Optional capability — never throws for absence (docs/platform/platform-services.md). */
|
|
2
2
|
export interface Capability<T> {
|
|
3
3
|
supported: boolean
|
|
4
4
|
ensure(): Promise<'granted' | 'denied' | 'unsupported'>
|