1import { UnavailabilityError } from 'expo-modules-core';
2
3import ExpoSecureStore from './ExpoSecureStore';
4
5export type KeychainAccessibilityConstant = number;
6
7// @needsAudit
8/**
9 * The data in the keychain item cannot be accessed after a restart until the device has been
10 * unlocked once by the user. This may be useful if you need to access the item when the phone
11 * is locked.
12 */
13export const AFTER_FIRST_UNLOCK: KeychainAccessibilityConstant = ExpoSecureStore.AFTER_FIRST_UNLOCK;
14
15// @needsAudit
16/**
17 * Similar to `AFTER_FIRST_UNLOCK`, except the entry is not migrated to a new device when restoring
18 * from a backup.
19 */
20export const AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY: KeychainAccessibilityConstant =
21  ExpoSecureStore.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY;
22
23// @needsAudit
24/**
25 * The data in the keychain item can always be accessed regardless of whether the device is locked.
26 * This is the least secure option.
27 */
28export const ALWAYS: KeychainAccessibilityConstant = ExpoSecureStore.ALWAYS;
29
30// @needsAudit
31/**
32 * Similar to `WHEN_UNLOCKED_THIS_DEVICE_ONLY`, except the user must have set a passcode in order to
33 * store an entry. If the user removes their passcode, the entry will be deleted.
34 */
35export const WHEN_PASSCODE_SET_THIS_DEVICE_ONLY: KeychainAccessibilityConstant =
36  ExpoSecureStore.WHEN_PASSCODE_SET_THIS_DEVICE_ONLY;
37
38// @needsAudit
39/**
40 * Similar to `ALWAYS`, except the entry is not migrated to a new device when restoring from a backup.
41 */
42export const ALWAYS_THIS_DEVICE_ONLY: KeychainAccessibilityConstant =
43  ExpoSecureStore.ALWAYS_THIS_DEVICE_ONLY;
44
45// @needsAudit
46/**
47 * The data in the keychain item can be accessed only while the device is unlocked by the user.
48 */
49export const WHEN_UNLOCKED: KeychainAccessibilityConstant = ExpoSecureStore.WHEN_UNLOCKED;
50
51// @needsAudit
52/**
53 * Similar to `WHEN_UNLOCKED`, except the entry is not migrated to a new device when restoring from
54 * a backup.
55 */
56export const WHEN_UNLOCKED_THIS_DEVICE_ONLY: KeychainAccessibilityConstant =
57  ExpoSecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY;
58
59const VALUE_BYTES_LIMIT = 2048;
60
61// @needsAudit
62export type SecureStoreOptions = {
63  /**
64   * - Android: Equivalent of the public/private key pair `Alias`.
65   * - iOS: The item's service, equivalent to [`kSecAttrService`](https://developer.apple.com/documentation/security/ksecattrservice/).
66   * > If the item is set with the `keychainService` option, it will be required to later fetch the value.
67   */
68  keychainService?: string;
69  /**
70   * Option responsible for enabling the usage of the user authentication methods available on the device while
71   * accessing data stored in SecureStore.
72   * - Android: Equivalent to [`setUserAuthenticationRequired(true)`](https://developer.android.com/reference/android/security/keystore/KeyGenParameterSpec.Builder#setUserAuthenticationRequired(boolean))
73   *   (requires API 23).
74   * - iOS: Equivalent to [`kSecAccessControlBiometryCurrentSet`](https://developer.apple.com/documentation/security/secaccesscontrolcreateflags/ksecaccesscontrolbiometrycurrentset/).
75   * Complete functionality is unlocked only with a freshly generated key - this would not work in tandem with the `keychainService`
76   * value used for the others non-authenticated operations.
77   */
78  requireAuthentication?: boolean;
79  /**
80   * Custom message displayed to the user while `requireAuthentication` option is turned on.
81   */
82  authenticationPrompt?: string;
83  /**
84   * Specifies when the stored entry is accessible, using iOS's `kSecAttrAccessible` property.
85   * @see Apple's documentation on [keychain item accessibility](https://developer.apple.com/documentation/security/ksecattraccessible/).
86   * @default SecureStore.WHEN_UNLOCKED
87   * @platform ios
88   */
89  keychainAccessible?: KeychainAccessibilityConstant;
90};
91
92// @needsAudit
93/**
94 * Returns whether the SecureStore API is enabled on the current device. This does not check the app
95 * permissions.
96 *
97 * @return Promise which fulfils witch `boolean`, indicating whether the SecureStore API is available
98 * on the current device. Currently, this resolves `true` on Android and iOS only.
99 */
100export async function isAvailableAsync(): Promise<boolean> {
101  return !!ExpoSecureStore.getValueWithKeyAsync;
102}
103
104// @needsAudit
105/**
106 * Delete the value associated with the provided key.
107 *
108 * @param key The key that was used to store the associated value.
109 * @param options An [`SecureStoreOptions`](#securestoreoptions) object.
110 *
111 * @return A promise that will reject if the value couldn't be deleted.
112 */
113export async function deleteItemAsync(
114  key: string,
115  options: SecureStoreOptions = {}
116): Promise<void> {
117  _ensureValidKey(key);
118
119  if (!ExpoSecureStore.deleteValueWithKeyAsync) {
120    throw new UnavailabilityError('SecureStore', 'deleteItemAsync');
121  }
122  await ExpoSecureStore.deleteValueWithKeyAsync(key, options);
123}
124
125// @needsAudit
126/**
127 * Fetch the stored value associated with the provided key.
128 *
129 * @param key The key that was used to store the associated value.
130 * @param options An [`SecureStoreOptions`](#securestoreoptions) object.
131 *
132 * @return A promise that resolves to the previously stored value. It will return `null` if there is no entry
133 * for the given key or if the key has been invalidated. It will reject if an error occurs while retrieving the value.
134 *
135 * > Keys are invalidated by the system when biometrics change, such as adding a new fingerprint or changing the face profile used for face recognition.
136 * > After a key has been invalidated, it becomes impossible to read its value.
137 * > This only applies to values stored with `requireAuthentication` set to `true`.
138 */
139export async function getItemAsync(
140  key: string,
141  options: SecureStoreOptions = {}
142): Promise<string | null> {
143  _ensureValidKey(key);
144  return await ExpoSecureStore.getValueWithKeyAsync(key, options);
145}
146
147// @needsAudit
148/**
149 * Store a key–value pair.
150 *
151 * @param key The key to associate with the stored value. Keys may contain alphanumeric characters
152 * `.`, `-`, and `_`.
153 * @param value The value to store. Size limit is 2048 bytes.
154 * @param options An [`SecureStoreOptions`](#securestoreoptions) object.
155 *
156 * @return A promise that will reject if value cannot be stored on the device.
157 */
158export async function setItemAsync(
159  key: string,
160  value: string,
161  options: SecureStoreOptions = {}
162): Promise<void> {
163  _ensureValidKey(key);
164  if (!_isValidValue(value)) {
165    throw new Error(
166      `Invalid value provided to SecureStore. Values must be strings; consider JSON-encoding your values if they are serializable.`
167    );
168  }
169  if (!ExpoSecureStore.setValueWithKeyAsync) {
170    throw new UnavailabilityError('SecureStore', 'setItemAsync');
171  }
172  await ExpoSecureStore.setValueWithKeyAsync(value, key, options);
173}
174
175function _ensureValidKey(key: string) {
176  if (!_isValidKey(key)) {
177    throw new Error(
178      `Invalid key provided to SecureStore. Keys must not be empty and contain only alphanumeric characters, ".", "-", and "_".`
179    );
180  }
181}
182
183function _isValidKey(key: string) {
184  return typeof key === 'string' && /^[\w.-]+$/.test(key);
185}
186
187function _isValidValue(value: string) {
188  if (typeof value !== 'string') {
189    return false;
190  }
191  if (_byteCount(value) > VALUE_BYTES_LIMIT) {
192    console.warn(
193      'Provided value to SecureStore is larger than 2048 bytes. An attempt to store such a value will throw an error in SDK 35.'
194    );
195  }
196  return true;
197}
198
199// copy-pasted from https://stackoverflow.com/a/39488643
200function _byteCount(value: string) {
201  let bytes = 0;
202
203  for (let i = 0; i < value.length; i++) {
204    const codePoint = value.charCodeAt(i);
205
206    // Lone surrogates cannot be passed to encodeURI
207    if (codePoint >= 0xd800 && codePoint < 0xe000) {
208      if (codePoint < 0xdc00 && i + 1 < value.length) {
209        const next = value.charCodeAt(i + 1);
210
211        if (next >= 0xdc00 && next < 0xe000) {
212          bytes += 4;
213          i++;
214          continue;
215        }
216      }
217    }
218
219    bytes += codePoint < 0x80 ? 1 : codePoint < 0x800 ? 2 : 3;
220  }
221
222  return bytes;
223}
224