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   * Warning: This option is not supported in Expo Go when biometric authentication is available due to a missing NSFaceIDUsageDescription.
79   * In release builds or when using continuous native generation, make sure to use the `expo-secure-store` config plugin.
80   *
81   */
82  requireAuthentication?: boolean;
83  /**
84   * Custom message displayed to the user while `requireAuthentication` option is turned on.
85   */
86  authenticationPrompt?: string;
87  /**
88   * Specifies when the stored entry is accessible, using iOS's `kSecAttrAccessible` property.
89   * @see Apple's documentation on [keychain item accessibility](https://developer.apple.com/documentation/security/ksecattraccessible/).
90   * @default SecureStore.WHEN_UNLOCKED
91   * @platform ios
92   */
93  keychainAccessible?: KeychainAccessibilityConstant;
94};
95
96// @needsAudit
97/**
98 * Returns whether the SecureStore API is enabled on the current device. This does not check the app
99 * permissions.
100 *
101 * @return Promise which fulfils witch `boolean`, indicating whether the SecureStore API is available
102 * on the current device. Currently, this resolves `true` on Android and iOS only.
103 */
104export async function isAvailableAsync(): Promise<boolean> {
105  return !!ExpoSecureStore.getValueWithKeyAsync;
106}
107
108// @needsAudit
109/**
110 * Delete the value associated with the provided key.
111 *
112 * @param key The key that was used to store the associated value.
113 * @param options An [`SecureStoreOptions`](#securestoreoptions) object.
114 *
115 * @return A promise that will reject if the value couldn't be deleted.
116 */
117export async function deleteItemAsync(
118  key: string,
119  options: SecureStoreOptions = {}
120): Promise<void> {
121  _ensureValidKey(key);
122
123  if (!ExpoSecureStore.deleteValueWithKeyAsync) {
124    throw new UnavailabilityError('SecureStore', 'deleteItemAsync');
125  }
126  await ExpoSecureStore.deleteValueWithKeyAsync(key, options);
127}
128
129// @needsAudit
130/**
131 * Fetch the stored value associated with the provided key.
132 *
133 * @param key The key that was used to store the associated value.
134 * @param options An [`SecureStoreOptions`](#securestoreoptions) object.
135 *
136 * @return A promise that resolves to the previously stored value. It will return `null` if there is no entry
137 * for the given key or if the key has been invalidated. It will reject if an error occurs while retrieving the value.
138 *
139 * > Keys are invalidated by the system when biometrics change, such as adding a new fingerprint or changing the face profile used for face recognition.
140 * > After a key has been invalidated, it becomes impossible to read its value.
141 * > This only applies to values stored with `requireAuthentication` set to `true`.
142 */
143export async function getItemAsync(
144  key: string,
145  options: SecureStoreOptions = {}
146): Promise<string | null> {
147  _ensureValidKey(key);
148  return await ExpoSecureStore.getValueWithKeyAsync(key, options);
149}
150
151// @needsAudit
152/**
153 * Store a key–value pair.
154 *
155 * @param key The key to associate with the stored value. Keys may contain alphanumeric characters
156 * `.`, `-`, and `_`.
157 * @param value The value to store. Size limit is 2048 bytes.
158 * @param options An [`SecureStoreOptions`](#securestoreoptions) object.
159 *
160 * @return A promise that will reject if value cannot be stored on the device.
161 */
162export async function setItemAsync(
163  key: string,
164  value: string,
165  options: SecureStoreOptions = {}
166): Promise<void> {
167  _ensureValidKey(key);
168  if (!_isValidValue(value)) {
169    throw new Error(
170      `Invalid value provided to SecureStore. Values must be strings; consider JSON-encoding your values if they are serializable.`
171    );
172  }
173  if (!ExpoSecureStore.setValueWithKeyAsync) {
174    throw new UnavailabilityError('SecureStore', 'setItemAsync');
175  }
176  await ExpoSecureStore.setValueWithKeyAsync(value, key, options);
177}
178
179function _ensureValidKey(key: string) {
180  if (!_isValidKey(key)) {
181    throw new Error(
182      `Invalid key provided to SecureStore. Keys must not be empty and contain only alphanumeric characters, ".", "-", and "_".`
183    );
184  }
185}
186
187function _isValidKey(key: string) {
188  return typeof key === 'string' && /^[\w.-]+$/.test(key);
189}
190
191function _isValidValue(value: string) {
192  if (typeof value !== 'string') {
193    return false;
194  }
195  if (_byteCount(value) > VALUE_BYTES_LIMIT) {
196    console.warn(
197      'Provided value to SecureStore is larger than 2048 bytes. An attempt to store such a value will throw an error in SDK 35.'
198    );
199  }
200  return true;
201}
202
203// copy-pasted from https://stackoverflow.com/a/39488643
204function _byteCount(value: string) {
205  let bytes = 0;
206
207  for (let i = 0; i < value.length; i++) {
208    const codePoint = value.charCodeAt(i);
209
210    // Lone surrogates cannot be passed to encodeURI
211    if (codePoint >= 0xd800 && codePoint < 0xe000) {
212      if (codePoint < 0xdc00 && i + 1 < value.length) {
213        const next = value.charCodeAt(i + 1);
214
215        if (next >= 0xdc00 && next < 0xe000) {
216          bytes += 4;
217          i++;
218          continue;
219        }
220      }
221    }
222
223    bytes += codePoint < 0x80 ? 1 : codePoint < 0x800 ? 2 : 3;
224  }
225
226  return bytes;
227}
228