Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,44 @@ The resulting Android authenticator combinations are:

If you pass `withBiometrics: true` without a `biometricPrompt` object at all, the prompt falls back to the defaults listed above.

### iOS Service and Access Group

On iOS, an item is identified by its `key`, a keychain service and an access group. `service` and `accessGroup` are both optional. If omitted, the service is the app's bundle identifier and no access group is set.

| Option | Explanation | Default |
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- |
| `service` | The keychain service (`kSecAttrService`). | The app's bundle identifier |
| `accessGroup` | The keychain access group (`kSecAttrAccessGroup`). The app (and any extension reading the item) must list it in its `keychain-access-groups` entitlement, or use an app group ID (`group.*`) it has. Otherwise the call fails with `[op-s2] Missing entitlement`. | None (see below) |

#### Sharing an item with an app extension

Pass both options explicitly, with the same values in the app and in the extension:

```ts
const scope = {
// The extension has its own bundle identifier, so the default service
// would differ between the two. Pin it to one value.
service: 'com.example.app',
// An app group (or keychain access group) both targets are entitled to.
accessGroup: 'group.com.example.app',
};

set({ key: 'token', value: 'secret', ...scope });
const { value } = get({ key: 'token', ...scope });
del({ key: 'token', ...scope });
```

#### Notes

- Empty strings are treated as unset.
- If you pass `service` or `accessGroup` for an item, pass the same values in every `set`, `get` and `del` call for it:
- With a different `service`, the item isn't found.
- Without `accessGroup`, the call isn't limited to one group. A `set` or `del` then also deletes the copy stored in your shared group, and a `get` may return a copy from another group.
- If the default `service` can't be determined (a nil bundle identifier), or a given `service` or `accessGroup` can't be converted, the operation returns an `error` rather than running a query that matches every service or group.
- A non-string `service` or `accessGroup` is rejected rather than silently using the default scope, on both platforms: `set` returns an `error`, and `get` and `del` throw, as they do for a missing `key`.
- Otherwise both options are ignored on Android, where items are always private to the app.
- If you've never passed either option, upgrading changes nothing: items saved by earlier versions of op-s2 are still found.

## Secure Enclave

The library already uses the [Secure Enclave](https://support.apple.com/en-gb/guide/security/sec59b0b31ff/web) to encrypt the data before saving it on the keychain. However, it is not possible to store data directly on the Secure Enclave as it is meant to store the private part of asymmetrical keys, meaning data saved there cannot be retrieved to user space.
Expand Down
25 changes: 24 additions & 1 deletion android/bindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,16 @@ static BiometricPromptOptions extractBiometricPromptOptions(jsi::Runtime &rt, co
return options;
}

static const char *validateItemScope(jsi::Runtime &rt, const jsi::Object &params) {
auto isInvalid = [&](const char *name) {
jsi::Value v = params.getProperty(rt, name);
return !v.isNull() && !v.isUndefined() && !v.isString();
};
if (isInvalid("service")) return "service property is not a string";
if (isInvalid("accessGroup")) return "accessGroup property is not a string";
return nullptr;
}

void install(
jsi::Runtime &rt,
std::function<void(const char *, const char *, bool, BiometricPromptOptions)> setFn,
Expand Down Expand Up @@ -92,6 +102,11 @@ void install(
return res;
}

if (const char *scopeError = validateItemScope(rt, params)) {
res.setProperty(rt, "error", scopeError);
return res;
}

std::string key = params.getProperty(rt, "key").asString(rt).utf8(rt);
std::string val = params.getProperty(rt, "value").asString(rt).utf8(rt);

Expand Down Expand Up @@ -140,6 +155,10 @@ void install(

BiometricPromptOptions promptOptions = extractBiometricPromptOptions(rt, params);

if (const char *scopeError = validateItemScope(rt, params)) {
throw jsi::JSError(rt, scopeError);
}

auto res = jsi::Object(rt);

try {
Expand Down Expand Up @@ -185,9 +204,13 @@ void install(

BiometricPromptOptions promptOptions = extractBiometricPromptOptions(rt, params);

if (const char *scopeError = validateItemScope(rt, params)) {
throw jsi::JSError(rt, scopeError);
}

_del(key.c_str(), withBiometrics, promptOptions);

return {};
return jsi::Object(rt);
});

jsi::Object module = jsi::Object(rt);
Expand Down
167 changes: 167 additions & 0 deletions example/src/tests/s2.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { del, get, set, ACCESSIBILITY } from '@op-engineering/op-s2';
import { describe, expect, it } from '@op-engineering/op-test';
import { Platform } from 'react-native';

// PRODUCT_BUNDLE_IDENTIFIER of the example app, which op-s2 uses as the
// default keychain service on iOS.
const EXAMPLE_BUNDLE_ID = 'com.op.s2';

// Toggle to `true` when running on a simulator/emulator (where biometrics are
// not enrolled and the prompt short-circuits with "Biometrics not available").
Expand Down Expand Up @@ -188,6 +193,168 @@ describe('accessibility option (iOS)', () => {
});
});

describe('service / accessGroup validation', () => {
it('rejects a non-string service or accessGroup', () => {
const key = 'key_service_invalid';

const { error: serviceError } = set({
key,
value: 'myValue',
// @ts-ignore
service: 123,
});
expect(serviceError).toEqual('service property is not a string');

const { error: groupError } = set({
key,
value: 'myValue',
// @ts-ignore
accessGroup: 123,
});
expect(groupError).toEqual('accessGroup property is not a string');

// Like a missing key, an invalid param makes get/del throw.
let getError: unknown;
try {
// @ts-ignore
get({ key, service: 123 });
} catch (e) {
getError = e;
}
expect(String(getError)).toContain('service property is not a string');

let delError: unknown;
try {
// @ts-ignore
del({ key, accessGroup: 123 });
} catch (e) {
delError = e;
}
expect(String(delError)).toContain('accessGroup property is not a string');

expect(get({ key }).error).toEqual('[op-s2] Item not found');
});

it('returns an object without error from a valid del', () => {
const key = 'key_del_result';

set({ key, value: 'myValue' });
const { error } = del({ key });
expect(error).toBe(undefined);
expect(get({ key }).error).toEqual('[op-s2] Item not found');
});
});

if (Platform.OS === 'ios') {
describe('service / accessGroup options (iOS)', () => {
it('set/get with a custom service', () => {
const key = 'key_service';

const { error: setError } = set({
key,
value: 'myServiceValue',
service: 'svcA',
});
expect(setError).toBe(undefined);

const { value, error } = get({ key, service: 'svcA' });
expect(error).toBe(undefined);
expect(value).toEqual('myServiceValue');

del({ key, service: 'svcA' });
});

it('items in different services do not collide', () => {
const key = 'key_service_isolation';

set({ key, value: 'valueA', service: 'svcA' });
set({ key, value: 'valueB', service: 'svcB' });

expect(get({ key, service: 'svcA' }).value).toEqual('valueA');
expect(get({ key, service: 'svcB' }).value).toEqual('valueB');

const { value, error } = get({ key });
expect(value).toBe(undefined);
expect(error).toEqual('[op-s2] Item not found');

del({ key, service: 'svcA' });
expect(get({ key, service: 'svcA' }).error).toEqual(
'[op-s2] Item not found'
);
expect(get({ key, service: 'svcB' }).value).toEqual('valueB');

del({ key, service: 'svcB' });
});

it('defaults the service to the bundle identifier', () => {
const key = 'key_service_default';

set({ key, value: 'myDefaultValue' });

const { value, error } = get({ key, service: EXAMPLE_BUNDLE_ID });
expect(error).toBe(undefined);
expect(value).toEqual('myDefaultValue');

del({ key, service: EXAMPLE_BUNDLE_ID });
expect(get({ key }).error).toEqual('[op-s2] Item not found');
});

it('treats empty service and accessGroup as unset', () => {
const key = 'key_service_empty';

const { error: setError } = set({
key,
value: 'myEmptyScopeValue',
service: '',
accessGroup: '',
});
expect(setError).toBe(undefined);

const { value, error } = get({ key });
expect(error).toBe(undefined);
expect(value).toEqual('myEmptyScopeValue');

del({ key, service: '', accessGroup: '' });
expect(get({ key }).error).toEqual('[op-s2] Item not found');
});

it('fails with an access group the app is not entitled to', () => {
const key = 'key_access_group_unentitled';

const { error } = set({
key,
value: 'myValue',
accessGroup: 'group.op.s2.not-entitled',
});
expect(error).toEqual('[op-s2] Missing entitlement');

// Clean up in case the write unexpectedly succeeded on this setup.
del({ key, accessGroup: 'group.op.s2.not-entitled' });
});
});
} else {
describe('service / accessGroup options (Android)', () => {
it('ignores service and accessGroup', () => {
const key = 'key_service_android';

const { error: setError } = set({
key,
value: 'myAndroidValue',
service: 'svcA',
accessGroup: 'group.op.s2.not-entitled',
});
expect(setError).toBe(undefined);

const { value, error } = get({ key });
expect(error).toBe(undefined);
expect(value).toEqual('myAndroidValue');

del({ key, service: 'svcB' });
expect(get({ key }).error).toEqual('[op-s2] Item not found');
});
});
}

if (RUN_BIOMETRIC_TESTS) {
describe('biometricPrompt options', () => {
it('set/get with only title/subtitle (defaults: no device credential, strong biometric)', () => {
Expand Down
Loading
Loading