@oxy.so/core 7.27.0 → 7.29.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/dist/cjs/crypto/index.js +19 -1
- package/dist/cjs/crypto/keyManager.js +106 -106
- package/dist/cjs/crypto/keychainGroupMigration.js +353 -0
- package/dist/cjs/crypto/keychainItems.js +70 -0
- package/dist/cjs/crypto/organizationKeys.js +80 -72
- package/dist/cjs/crypto/secureStoreOptions.js +2 -0
- package/dist/cjs/i18n/locales/compact/es-ES.generated.js +1 -1
- package/dist/cjs/i18n/locales/en-US.js +14 -0
- package/dist/cjs/i18n/locales/es-ES.js +14 -0
- package/dist/cjs/session/index.js +11 -1
- package/dist/cjs/utils/keychainAccessGroups.js +248 -0
- package/dist/esm/crypto/index.js +3 -0
- package/dist/esm/crypto/keyManager.js +63 -63
- package/dist/esm/crypto/keychainGroupMigration.js +348 -0
- package/dist/esm/crypto/keychainItems.js +61 -0
- package/dist/esm/crypto/organizationKeys.js +54 -46
- package/dist/esm/crypto/secureStoreOptions.js +1 -0
- package/dist/esm/i18n/locales/compact/es-ES.generated.js +1 -1
- package/dist/esm/i18n/locales/en-US.js +14 -0
- package/dist/esm/i18n/locales/es-ES.js +14 -0
- package/dist/esm/session/index.js +3 -0
- package/dist/esm/utils/keychainAccessGroups.js +231 -0
- package/dist/types/crypto/index.d.ts +4 -0
- package/dist/types/crypto/keyManager.d.ts +9 -1
- package/dist/types/crypto/keychainGroupMigration.d.ts +27 -0
- package/dist/types/crypto/keychainItems.d.ts +41 -0
- package/dist/types/crypto/organizationKeys.d.ts +11 -9
- package/dist/types/crypto/secureStoreOptions.d.ts +15 -0
- package/dist/types/i18n/locales/en-US.d.ts +14 -0
- package/dist/types/i18n/locales/es-ES.d.ts +14 -0
- package/dist/types/models/interfaces.d.ts +6 -1
- package/dist/types/session/index.d.ts +2 -0
- package/dist/types/utils/keychainAccessGroups.d.ts +104 -0
- package/package.json +2 -2
- package/src/crypto/index.ts +29 -0
- package/src/crypto/keyManager.ts +84 -99
- package/src/crypto/keychainGroupMigration.ts +440 -0
- package/src/crypto/keychainItems.ts +73 -0
- package/src/crypto/organizationKeys.ts +72 -57
- package/src/crypto/secureStoreOptions.ts +32 -0
- package/src/i18n/locales/compact/es-ES.generated.ts +1 -1
- package/src/i18n/locales/en-US.ts +14 -0
- package/src/i18n/locales/es-ES.ts +14 -0
- package/src/models/interfaces.ts +6 -0
- package/src/session/index.ts +11 -0
- package/src/utils/keychainAccessGroups.ts +308 -0
- package/src/types/expo-secure-store.d.ts +0 -21
|
@@ -664,6 +664,20 @@ const dict = {
|
|
|
664
664
|
month: 'Month',
|
|
665
665
|
year: 'Year',
|
|
666
666
|
},
|
|
667
|
+
pronouns: {
|
|
668
|
+
title: 'Pronouns',
|
|
669
|
+
subtitle: 'How people should refer to you',
|
|
670
|
+
label: 'Pronouns',
|
|
671
|
+
options: {
|
|
672
|
+
unspecified: 'Not set',
|
|
673
|
+
he_him: 'He/him',
|
|
674
|
+
she_her: 'She/her',
|
|
675
|
+
they_them: 'They/them',
|
|
676
|
+
he_they: 'He/they',
|
|
677
|
+
she_they: 'She/they',
|
|
678
|
+
it_its: 'It/its',
|
|
679
|
+
},
|
|
680
|
+
},
|
|
667
681
|
locations: {
|
|
668
682
|
title: 'Locations',
|
|
669
683
|
subtitle: "Places you've been or live",
|
|
@@ -366,6 +366,20 @@ const dict = {
|
|
|
366
366
|
month: 'Mes',
|
|
367
367
|
year: 'Año',
|
|
368
368
|
},
|
|
369
|
+
pronouns: {
|
|
370
|
+
title: 'Pronombres',
|
|
371
|
+
subtitle: 'Cómo deben referirse a ti las personas',
|
|
372
|
+
label: 'Pronombres',
|
|
373
|
+
options: {
|
|
374
|
+
unspecified: 'Sin definir',
|
|
375
|
+
he_him: 'He/him',
|
|
376
|
+
she_her: 'She/her',
|
|
377
|
+
they_them: 'They/them',
|
|
378
|
+
he_they: 'He/they',
|
|
379
|
+
she_they: 'She/they',
|
|
380
|
+
it_its: 'It/its',
|
|
381
|
+
},
|
|
382
|
+
},
|
|
369
383
|
locations: {
|
|
370
384
|
title: 'Ubicaciones',
|
|
371
385
|
subtitle: 'Lugares donde has estado o vives',
|
|
@@ -72,6 +72,9 @@ export { createWebAuthStateStore, createNativeAuthStateStore, createMemoryAuthSt
|
|
|
72
72
|
// Commons private identity key: an app that only needs a session must never be
|
|
73
73
|
// handed the key that signs identity approvals.
|
|
74
74
|
export { createSharedMirroringAuthStateStore, decideSharedDeviceJoin, decideSharedDevicePublish, normalizeSharedDeviceSessionRead, publishProvenDeviceCredential, readLocalDeviceCredential, } from './sharedDeviceCredential.js';
|
|
75
|
+
// Where the shared device credential (and an app's own items) live on iOS:
|
|
76
|
+
// docs/identity/ios-keychain-access-groups.md, ADR 0062.
|
|
77
|
+
export { KeychainAccessUnavailableError, keychainAccessGroupValue, ownKeychainItemPlan, readThroughPlan, deleteThroughPlan, resolveKeychainAccess, } from '../utils/keychainAccessGroups.js';
|
|
75
78
|
// Identity-bound sessions (the identity vault). The pin is the durable
|
|
76
79
|
// `{publicKey, accountId}` binding between this device's PRIMARY identity key
|
|
77
80
|
// and the account it authenticates as; it is what keeps such a client from
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* iOS keychain access groups: which group each Oxy keychain item lives in, and
|
|
3
|
+
* which apps may read it (docs/identity/ios-keychain-access-groups.md, ADR 0062).
|
|
4
|
+
*
|
|
5
|
+
* | Group | Held by | Item class |
|
|
6
|
+
* |-------------------------------|------------------------------|-------------------|
|
|
7
|
+
* | `<TEAM>.so.oxy.identity` | Commons only (listed FIRST) | `identity-secret` |
|
|
8
|
+
* | `<TEAM>.so.oxy.wallet` | Commons, the Peable wallet | `spending-key` |
|
|
9
|
+
* | `<TEAM>.group.so.oxy.shared` | every Oxy app | `shared` |
|
|
10
|
+
*
|
|
11
|
+
* - `identity-secret`: the personal root (primary and backup slots), the
|
|
12
|
+
* personal and organization recovery phrases, the organization index. Only
|
|
13
|
+
* Commons may read or write them.
|
|
14
|
+
* - `spending-key`: private keys an app other than Commons signs or derives
|
|
15
|
+
* with in process — the personal key's shared slot and organization pairs
|
|
16
|
+
* (`deriveScopedSeed`, `signSocialReceive`). Commons writes them; the Peable
|
|
17
|
+
* wallet reads them.
|
|
18
|
+
* - `shared`: what every Oxy app may read: the shared public key and the
|
|
19
|
+
* device-session credential.
|
|
20
|
+
*
|
|
21
|
+
* The ROLE of this binary comes from the keychain itself: an item written
|
|
22
|
+
* without a group lands in the app's default group, the first entry of its
|
|
23
|
+
* `keychain-access-groups` entitlement, and `@oxy.so/app-preset` lets only
|
|
24
|
+
* Commons put the identity group first and only the Peable wallet the wallet
|
|
25
|
+
* group. The same probe gives the team prefix, which `kSecAttrAccessGroup`
|
|
26
|
+
* needs and JavaScript cannot otherwise see.
|
|
27
|
+
*
|
|
28
|
+
* Android and web have no access groups: there every helper here answers the
|
|
29
|
+
* single-store behaviour (`ungrouped`), and Android's narrowing is Commons'
|
|
30
|
+
* identity host. An iOS build whose native module predates the probe is also
|
|
31
|
+
* `ungrouped` — exactly what that build's entitlements (one shared group) do.
|
|
32
|
+
*
|
|
33
|
+
* This module never imports the identity stack: services' device-session store
|
|
34
|
+
* and Peable's wallet storage use it too.
|
|
35
|
+
*/
|
|
36
|
+
import { loadKeychainDefaultAccessGroup } from '@oxy.so/protocol';
|
|
37
|
+
import { isIOS } from './platform.js';
|
|
38
|
+
/** The group names after the team prefix, exactly as the entitlements list them. */
|
|
39
|
+
export const KEYCHAIN_ACCESS_GROUPS = {
|
|
40
|
+
identity: 'so.oxy.identity',
|
|
41
|
+
wallet: 'so.oxy.wallet',
|
|
42
|
+
shared: 'group.so.oxy.shared',
|
|
43
|
+
};
|
|
44
|
+
/** Thrown while the probe cannot answer (before first unlock). Transient. */
|
|
45
|
+
export class KeychainAccessUnavailableError extends Error {
|
|
46
|
+
constructor(cause) {
|
|
47
|
+
super('The keychain cannot say which access groups this app holds right now.');
|
|
48
|
+
this.cause = cause;
|
|
49
|
+
this.name = 'KeychainAccessUnavailableError';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The single place each item class is assigned a group. A new kind of item
|
|
54
|
+
* (F1's retained keys, for one) picks its class and reuses everything below.
|
|
55
|
+
*/
|
|
56
|
+
export function keychainGroupFor(item) {
|
|
57
|
+
switch (item) {
|
|
58
|
+
case 'identity-secret':
|
|
59
|
+
return 'identity';
|
|
60
|
+
case 'spending-key':
|
|
61
|
+
return 'wallet';
|
|
62
|
+
case 'shared':
|
|
63
|
+
return 'shared';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Whether `role` holds the group `item` lives in. */
|
|
67
|
+
export function mayHoldKeychainItem(role, item) {
|
|
68
|
+
switch (role) {
|
|
69
|
+
case 'holder':
|
|
70
|
+
return true;
|
|
71
|
+
case 'wallet':
|
|
72
|
+
return item !== 'identity-secret';
|
|
73
|
+
case 'member':
|
|
74
|
+
return item === 'shared';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Parse the default access group the probe reported. Exported for tests. */
|
|
78
|
+
export function keychainAccessFromDefaultGroup(defaultGroup) {
|
|
79
|
+
const dot = defaultGroup.indexOf('.');
|
|
80
|
+
if (dot <= 0 || dot === defaultGroup.length - 1) {
|
|
81
|
+
// Not `<prefix>.<group>`: an unsigned simulator build. Nothing to split.
|
|
82
|
+
return { mode: 'ungrouped' };
|
|
83
|
+
}
|
|
84
|
+
const prefix = defaultGroup.slice(0, dot + 1);
|
|
85
|
+
const group = defaultGroup.slice(dot + 1);
|
|
86
|
+
const role = group === KEYCHAIN_ACCESS_GROUPS.identity
|
|
87
|
+
? 'holder'
|
|
88
|
+
: group === KEYCHAIN_ACCESS_GROUPS.wallet
|
|
89
|
+
? 'wallet'
|
|
90
|
+
: 'member';
|
|
91
|
+
return { mode: 'grouped', prefix, defaultGroup, role };
|
|
92
|
+
}
|
|
93
|
+
let resolvedAccess = null;
|
|
94
|
+
/**
|
|
95
|
+
* This binary's keychain access, memoized once known. THROWS
|
|
96
|
+
* {@link KeychainAccessUnavailableError} while the probe cannot answer, which
|
|
97
|
+
* every caller already treats as "storage unavailable, retry".
|
|
98
|
+
*/
|
|
99
|
+
export async function resolveKeychainAccess() {
|
|
100
|
+
if (resolvedAccess) {
|
|
101
|
+
return resolvedAccess;
|
|
102
|
+
}
|
|
103
|
+
if (!isIOS()) {
|
|
104
|
+
// Not memoized: until `@oxy.so/services` registers the platform, a phone
|
|
105
|
+
// reads as "not iOS", and caching that would keep the whole process on the
|
|
106
|
+
// pre-split behaviour.
|
|
107
|
+
return { mode: 'ungrouped' };
|
|
108
|
+
}
|
|
109
|
+
let probe;
|
|
110
|
+
try {
|
|
111
|
+
probe = await loadKeychainDefaultAccessGroup();
|
|
112
|
+
}
|
|
113
|
+
catch (cause) {
|
|
114
|
+
throw new KeychainAccessUnavailableError(cause);
|
|
115
|
+
}
|
|
116
|
+
if (probe.state === 'unavailable') {
|
|
117
|
+
throw new KeychainAccessUnavailableError(probe.cause);
|
|
118
|
+
}
|
|
119
|
+
resolvedAccess =
|
|
120
|
+
probe.state === 'resolved'
|
|
121
|
+
? keychainAccessFromDefaultGroup(probe.accessGroup)
|
|
122
|
+
: { mode: 'ungrouped' };
|
|
123
|
+
return resolvedAccess;
|
|
124
|
+
}
|
|
125
|
+
/** Forget the memoized access. Tests only. @internal */
|
|
126
|
+
export function resetKeychainAccessForTests() {
|
|
127
|
+
resolvedAccess = null;
|
|
128
|
+
}
|
|
129
|
+
/** The full `kSecAttrAccessGroup` value of `group`, or `undefined` when ungrouped. */
|
|
130
|
+
export function keychainAccessGroupValue(access, group) {
|
|
131
|
+
return access.mode === 'grouped' ? `${access.prefix}${KEYCHAIN_ACCESS_GROUPS[group]}` : undefined;
|
|
132
|
+
}
|
|
133
|
+
function option(value) {
|
|
134
|
+
return value === undefined ? {} : { accessGroup: value };
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* The option bags a READ of `item` tries, in order: its own group, then the
|
|
138
|
+
* shared group, where every item lived before the split (release 4 of the doc
|
|
139
|
+
* drops that fallback). Empty when this app may not hold the item: the caller
|
|
140
|
+
* answers `null` without touching the keychain.
|
|
141
|
+
*/
|
|
142
|
+
export function keychainReadPlan(access, item) {
|
|
143
|
+
if (access.mode === 'ungrouped') {
|
|
144
|
+
return [{}];
|
|
145
|
+
}
|
|
146
|
+
if (!mayHoldKeychainItem(access.role, item)) {
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
const own = keychainGroupFor(item);
|
|
150
|
+
const plan = [option(keychainAccessGroupValue(access, own))];
|
|
151
|
+
if (own !== 'shared') {
|
|
152
|
+
plan.push(option(keychainAccessGroupValue(access, 'shared')));
|
|
153
|
+
}
|
|
154
|
+
return plan;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Every place a DELETE of `item` must reach: the read plan, so a delete never
|
|
158
|
+
* leaves a pre-split copy behind in the shared group.
|
|
159
|
+
*/
|
|
160
|
+
export function keychainDeletePlan(access, item) {
|
|
161
|
+
return keychainReadPlan(access, item);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* The option a WRITE of `item` carries. THROWS when this app may not hold the
|
|
165
|
+
* item: an identity secret written by any app but Commons would land in that
|
|
166
|
+
* app's default group, a second holder nobody knows about.
|
|
167
|
+
*/
|
|
168
|
+
export function keychainWriteOption(access, item) {
|
|
169
|
+
if (access.mode === 'ungrouped') {
|
|
170
|
+
return {};
|
|
171
|
+
}
|
|
172
|
+
if (!mayHoldKeychainItem(access.role, item)) {
|
|
173
|
+
throw new Error(`This app (keychain role "${access.role}") may not hold a ${item} keychain item; only Commons writes the identity.`);
|
|
174
|
+
}
|
|
175
|
+
return option(keychainAccessGroupValue(access, keychainGroupFor(item)));
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* The plan for an app's OWN items (its session credential, its settings, an
|
|
179
|
+
* imported wallet's phrase) in Commons and the Peable wallet, whose default
|
|
180
|
+
* group the split changed: written to the default group explicitly — where a
|
|
181
|
+
* write without a group goes anyway — and read from it FIRST, then from every
|
|
182
|
+
* group (`{}`), where all of them were written before. Without the explicit
|
|
183
|
+
* order a read could return whichever of two copies the keychain met first.
|
|
184
|
+
* Deletes reach every group, as they always did.
|
|
185
|
+
*
|
|
186
|
+
* Every other app (`member`) and Android/web keep the calls they always made.
|
|
187
|
+
*/
|
|
188
|
+
export function ownKeychainItemPlan(access) {
|
|
189
|
+
if (access.mode === 'ungrouped' || access.role === 'member') {
|
|
190
|
+
return { read: [{}], write: {}, delete: [{}] };
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
read: [option(access.defaultGroup), {}],
|
|
194
|
+
write: option(access.defaultGroup),
|
|
195
|
+
delete: [{}],
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
/** Read `key` through `plan`: the first non-null answer. A thrown read propagates. */
|
|
199
|
+
export async function readThroughPlan(store, key, options, plan) {
|
|
200
|
+
for (const group of plan) {
|
|
201
|
+
const value = await store.getItemAsync(key, { ...options, ...group });
|
|
202
|
+
if (value !== null && value !== undefined) {
|
|
203
|
+
return value;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
/** Delete `key` from every location in `plan`. A thrown delete propagates. */
|
|
209
|
+
export async function deleteThroughPlan(store, key, options, plan) {
|
|
210
|
+
for (const group of plan) {
|
|
211
|
+
await store.deleteItemAsync(key, { ...options, ...group });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* `store` with every call routed to `item`'s groups for this app: reads walk
|
|
216
|
+
* the read plan, writes carry the write option (throwing for a role that may
|
|
217
|
+
* not hold the item), deletes reach every location. The constants of the
|
|
218
|
+
* wrapped store are kept, so `store.WHEN_UNLOCKED_THIS_DEVICE_ONLY` still reads.
|
|
219
|
+
*/
|
|
220
|
+
export function groupedKeychainStore(store, access, item) {
|
|
221
|
+
const plan = keychainReadPlan(access, item);
|
|
222
|
+
const wrapped = {
|
|
223
|
+
getItemAsync: (key, options) => readThroughPlan(store, key, options ?? {}, plan),
|
|
224
|
+
setItemAsync: async (key, value, options) => {
|
|
225
|
+
const group = keychainWriteOption(access, item);
|
|
226
|
+
await store.setItemAsync(key, value, { ...(options ?? {}), ...group });
|
|
227
|
+
},
|
|
228
|
+
deleteItemAsync: (key, options) => deleteThroughPlan(store, key, options ?? {}, plan),
|
|
229
|
+
};
|
|
230
|
+
return Object.assign(Object.create(store), wrapped);
|
|
231
|
+
}
|
|
@@ -19,6 +19,10 @@ export type { IdentityDeviceBackupStore } from './deviceBackup';
|
|
|
19
19
|
export type { IdentitySignerStore } from './identitySigner';
|
|
20
20
|
export { generateOrganizationKey, organizationKeyFromPhrase, storeOrganizationKey, getOrganizationPublicKey, getOrganizationPrivateKey, getOrganizationRecoveryPhrase, listOrganizationKeys, deleteOrganizationKey, syncOrganizationKeys, isOrganizationAccountId, isHealthyOrganizationKeyPair, } from './organizationKeys';
|
|
21
21
|
export type { OrganizationKeyPair, GeneratedOrganizationKey, StoredOrganizationKey, } from './organizationKeys';
|
|
22
|
+
export { KEYCHAIN_ACCESS_GROUPS, KeychainAccessUnavailableError, keychainGroupFor, mayHoldKeychainItem, resolveKeychainAccess, keychainAccessGroupValue, keychainReadPlan, keychainDeletePlan, keychainWriteOption, ownKeychainItemPlan, readThroughPlan, deleteThroughPlan, groupedKeychainStore, } from '../utils/keychainAccessGroups';
|
|
23
|
+
export type { KeychainAccess, KeychainAccessGroupName, KeychainGroupOption, KeychainItemClass, KeychainRole, KeychainStoreLike, } from '../utils/keychainAccessGroups';
|
|
24
|
+
export { ensureKeychainGroupsMigrated } from './keychainGroupMigration';
|
|
25
|
+
export type { KeychainGroupMigrationReport, KeychainItemMigrationOutcome, } from './keychainGroupMigration';
|
|
22
26
|
export { SignatureService } from './signatureService';
|
|
23
27
|
export type { SignedMessage, AuthChallenge } from './signatureService';
|
|
24
28
|
export { RecoveryPhraseService, BACKUP_KDF_ENCRYPTION_INFO } from './recoveryPhrase';
|
|
@@ -238,6 +238,13 @@ export declare class KeyManager {
|
|
|
238
238
|
* Called internally when shared identity is created/deleted/imported
|
|
239
239
|
*/
|
|
240
240
|
private static invalidateSharedCache;
|
|
241
|
+
/**
|
|
242
|
+
* iOS: write the personal key's cross-app slot — the private key to the
|
|
243
|
+
* wallet group (Commons and the Peable wallet), the public key to the shared
|
|
244
|
+
* group (every Oxy app). Throws in any app that may not hold the private key.
|
|
245
|
+
* @internal
|
|
246
|
+
*/
|
|
247
|
+
private static _writeIosSharedSlot;
|
|
241
248
|
/**
|
|
242
249
|
* Lowercase and pad to canonical 64-hex-char form.
|
|
243
250
|
*
|
|
@@ -717,7 +724,8 @@ export declare class KeyManager {
|
|
|
717
724
|
* `HDKey.fromMasterSeed` and never touches the identity key.
|
|
718
725
|
*
|
|
719
726
|
* Key source (native only):
|
|
720
|
-
* - iOS: the shared
|
|
727
|
+
* - iOS: the shared slot's private key in the wallet keychain group (Commons
|
|
728
|
+
* and the Peable wallet only; `null` in every other app), then this device's
|
|
721
729
|
* primary identity.
|
|
722
730
|
* - Android: Commons (the one app with a signer store) uses its own primary
|
|
723
731
|
* identity; every other app asks Commons over IPC and never reads a local
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** What happened to one item (or pair) in a run. */
|
|
2
|
+
export type KeychainItemMigrationOutcome = 'absent' | 'moved' | 'conflict' | 'unhealthy' | 'failed' | 'deferred';
|
|
3
|
+
export interface KeychainGroupMigrationReport {
|
|
4
|
+
/**
|
|
5
|
+
* - `not-applicable`: not Commons on a split-aware iOS build; nothing to move.
|
|
6
|
+
* - `complete`: nothing secret is left in the shared group.
|
|
7
|
+
* - `incomplete`: something stayed (`conflict`, `unhealthy` or `failed`);
|
|
8
|
+
* retried next launch.
|
|
9
|
+
* - `deferred`: the keychain could not be read (locked); retried on the next
|
|
10
|
+
* storage access.
|
|
11
|
+
*/
|
|
12
|
+
state: 'not-applicable' | 'complete' | 'incomplete' | 'deferred';
|
|
13
|
+
/** Outcome per item label (`primary`, `phrase`, `organization-1`, …). */
|
|
14
|
+
items: Record<string, KeychainItemMigrationOutcome>;
|
|
15
|
+
}
|
|
16
|
+
/** Run the migration once. Exported for tests; production code awaits the gate. */
|
|
17
|
+
export declare function runKeychainGroupMigration(): Promise<KeychainGroupMigrationReport>;
|
|
18
|
+
/**
|
|
19
|
+
* The gate every Commons identity-storage access awaits. One run per process
|
|
20
|
+
* (`complete`, `incomplete` and `not-applicable` are kept; `incomplete` is
|
|
21
|
+
* retried on the next launch); a `deferred` run is forgotten, so the next
|
|
22
|
+
* access tries again once the keychain unlocks. Concurrent callers share one
|
|
23
|
+
* run. Never throws.
|
|
24
|
+
*/
|
|
25
|
+
export declare function ensureKeychainGroupsMigrated(): Promise<KeychainGroupMigrationReport>;
|
|
26
|
+
/** Forget the memoized run. Tests only. @internal */
|
|
27
|
+
export declare function resetKeychainGroupMigrationForTests(): void;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The names of every identity item `KeyManager` and `./organizationKeys` keep
|
|
3
|
+
* in `expo-secure-store`, in one place so the access-group migration
|
|
4
|
+
* (`./keychainGroupMigration`) moves exactly what those modules read. Values
|
|
5
|
+
* are stable on-device identifiers: renaming one strands what is stored.
|
|
6
|
+
*/
|
|
7
|
+
/** Legacy (pre-v2) and shared-slot keys; the legacy ones have no keychain service. */
|
|
8
|
+
export declare const STORAGE_KEYS: {
|
|
9
|
+
readonly PRIVATE_KEY: "oxy_identity_private_key";
|
|
10
|
+
readonly PUBLIC_KEY: "oxy_identity_public_key";
|
|
11
|
+
readonly BACKUP_PRIVATE_KEY: "oxy_identity_backup_private_key";
|
|
12
|
+
readonly BACKUP_PUBLIC_KEY: "oxy_identity_backup_public_key";
|
|
13
|
+
readonly BACKUP_TIMESTAMP: "oxy_identity_backup_timestamp";
|
|
14
|
+
readonly SHARED_PRIVATE_KEY: "oxy_shared_identity_private_key";
|
|
15
|
+
readonly SHARED_PUBLIC_KEY: "oxy_shared_identity_public_key";
|
|
16
|
+
};
|
|
17
|
+
export declare const V2_PRIMARY_KEYCHAIN_SERVICE = "oxy_identity";
|
|
18
|
+
export declare const V2_BACKUP_KEYCHAIN_SERVICE = "oxy_identity_backup";
|
|
19
|
+
export declare const V2_STORAGE_KEYS: {
|
|
20
|
+
readonly PRIVATE_KEY: "oxy_identity_private_key_v2";
|
|
21
|
+
readonly PUBLIC_KEY: "oxy_identity_public_key_v2";
|
|
22
|
+
readonly BACKUP_PRIVATE_KEY: "oxy_identity_backup_private_key_v2";
|
|
23
|
+
readonly BACKUP_PUBLIC_KEY: "oxy_identity_backup_public_key_v2";
|
|
24
|
+
readonly BACKUP_TIMESTAMP: "oxy_identity_backup_timestamp_v2";
|
|
25
|
+
};
|
|
26
|
+
export declare const RECOVERY_MNEMONIC_KEYCHAIN_SERVICE = "oxy_identity_mnemonic";
|
|
27
|
+
export declare const RECOVERY_MNEMONIC_STORAGE_KEY = "oxy_identity_mnemonic_v1";
|
|
28
|
+
/** Organization keys (ADR 0061 D6). On iOS the pair has no keychain service. */
|
|
29
|
+
export declare const ORGANIZATION_ANDROID_KEYCHAIN_SERVICE = "oxy_org_identity";
|
|
30
|
+
export declare const ORGANIZATION_MNEMONIC_KEYCHAIN_SERVICE = "oxy_org_identity_mnemonic";
|
|
31
|
+
export declare const ORGANIZATION_INDEX_KEYCHAIN_SERVICE = "oxy_org_identity_index";
|
|
32
|
+
export declare const ORGANIZATION_INDEX_STORAGE_KEY = "oxy_org_identity_index_v1";
|
|
33
|
+
export declare const organizationPrivateKeyName: (accountId: string) => string;
|
|
34
|
+
export declare const organizationPublicKeyName: (accountId: string) => string;
|
|
35
|
+
export declare const organizationMnemonicName: (accountId: string) => string;
|
|
36
|
+
/** Whether `value` is an account id an organization key can be stored under. */
|
|
37
|
+
export declare function isOrganizationAccountId(value: string): boolean;
|
|
38
|
+
/** The account ids in a stored organization index; anything malformed is skipped. */
|
|
39
|
+
export declare function parseOrganizationIndex(raw: string | null): string[];
|
|
40
|
+
/** The stored form of an organization index: unique, sorted. */
|
|
41
|
+
export declare function serializeOrganizationIndex(ids: readonly string[]): string;
|
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
* other Oxy apps reach it the way they reach the personal identity, by
|
|
9
9
|
* derivation and never by the key:
|
|
10
10
|
*
|
|
11
|
-
* - iOS: the keychain
|
|
12
|
-
*
|
|
11
|
+
* - iOS: the pair in the wallet keychain group (`<TEAM>.so.oxy.wallet`), which
|
|
12
|
+
* only Commons and the Peable wallet hold; the phrase and the index in the
|
|
13
|
+
* identity group, Commons only (docs/identity/ios-keychain-access-groups.md).
|
|
14
|
+
* Only Commons writes them; every other app reads nothing.
|
|
13
15
|
* - Android: Commons' own expo-secure-store slot, mirrored into the identity
|
|
14
16
|
* signer store (`./identitySigner`) that Commons' identity host answers
|
|
15
17
|
* `accountId` requests from. Every other app asks Commons over IPC
|
|
@@ -17,11 +19,13 @@
|
|
|
17
19
|
*
|
|
18
20
|
* The phrase is kept only in Commons, on the device that created or imported
|
|
19
21
|
* it, so an owner can show it to another owner (ADR 0061 D5); it never reaches
|
|
20
|
-
* the
|
|
22
|
+
* the wallet or shared group, an IPC answer or a log.
|
|
21
23
|
*
|
|
22
24
|
* This module imports nothing from `./keyManager` (which imports it): the
|
|
23
25
|
* signer store comes from `./identitySigner`'s registry.
|
|
24
26
|
*/
|
|
27
|
+
import { isOrganizationAccountId } from './keychainItems';
|
|
28
|
+
export { isOrganizationAccountId };
|
|
25
29
|
/** A secp256k1 pair: private key 64 hex, public key lowercase uncompressed (`04…`, 130 hex). */
|
|
26
30
|
export interface OrganizationKeyPair {
|
|
27
31
|
privateKey: string;
|
|
@@ -35,8 +39,6 @@ export interface GeneratedOrganizationKey extends OrganizationKeyPair {
|
|
|
35
39
|
export interface StoredOrganizationKey extends OrganizationKeyPair {
|
|
36
40
|
mnemonic?: string;
|
|
37
41
|
}
|
|
38
|
-
/** Whether `value` is an account id an organization key can be stored under. */
|
|
39
|
-
export declare function isOrganizationAccountId(value: string): boolean;
|
|
40
42
|
/** True only when both halves are well-formed AND the public key derives from the private one. */
|
|
41
43
|
export declare function isHealthyOrganizationKeyPair(privateKey: string | null | undefined, publicKey: string | null | undefined): boolean;
|
|
42
44
|
/**
|
|
@@ -55,7 +57,7 @@ export declare function organizationKeyFromPhrase(phrase: string): Promise<Organ
|
|
|
55
57
|
/**
|
|
56
58
|
* Keep an organization key on this device. Commons only: on Android it needs
|
|
57
59
|
* the identity signer store Commons registers, so the identity host can serve
|
|
58
|
-
* it; on iOS only Commons
|
|
60
|
+
* it; on iOS only Commons holds the groups it writes (any other app throws).
|
|
59
61
|
*
|
|
60
62
|
* Refuses a pair whose public key does not derive from its private key, and a
|
|
61
63
|
* phrase that does not derive the pair. Overwrites whatever this device held for
|
|
@@ -64,15 +66,15 @@ export declare function organizationKeyFromPhrase(phrase: string): Promise<Organ
|
|
|
64
66
|
export declare function storeOrganizationKey(accountId: string, key: StoredOrganizationKey): Promise<void>;
|
|
65
67
|
/**
|
|
66
68
|
* The organization's public key as THIS device can reach it, or `null`.
|
|
67
|
-
* iOS: the keychain group. Android: Commons' own slot inside Commons, and
|
|
69
|
+
* iOS: the wallet keychain group (Commons, the Peable wallet). Android: Commons' own slot inside Commons, and
|
|
68
70
|
* Commons' identity host (`describe` with `accountId`) everywhere else. Compare
|
|
69
71
|
* it with the organization's DID `#key-1` before receiving (Peable ADR 0002).
|
|
70
72
|
*/
|
|
71
73
|
export declare function getOrganizationPublicKey(accountId: string): Promise<string | null>;
|
|
72
74
|
/**
|
|
73
75
|
* The organization's private key, where this app may hold it: Commons (both
|
|
74
|
-
* platforms) and, on iOS, the
|
|
75
|
-
*
|
|
76
|
+
* platforms) and, on iOS, the Peable wallet through the wallet keychain group.
|
|
77
|
+
* `null` everywhere else. Use it to sign within the app only; never transmit it.
|
|
76
78
|
*/
|
|
77
79
|
export declare function getOrganizationPrivateKey(accountId: string): Promise<string | null>;
|
|
78
80
|
/** The organization's phrase, if this device created or imported it with one. Commons only. */
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface OxySecureStoreOptions {
|
|
2
|
+
/** Keychain service name (Android keystore alias / iOS `kSecAttrService`). */
|
|
3
|
+
keychainService?: string;
|
|
4
|
+
/** Item accessibility: the `ExpoSecureStoreLike` constant, e.g. `WHEN_UNLOCKED_THIS_DEVICE_ONLY`. */
|
|
5
|
+
keychainAccessible?: number;
|
|
6
|
+
/** Prompt shown for biometric-protected items. */
|
|
7
|
+
authenticationPrompt?: string;
|
|
8
|
+
/** Whether Face ID / Touch ID is required to read the item. */
|
|
9
|
+
requireAuthentication?: boolean;
|
|
10
|
+
/**
|
|
11
|
+
* iOS `kSecAttrAccessGroup`: the FULL, team-prefixed entitlement value. Set
|
|
12
|
+
* only through `../utils/keychainAccessGroups`, never by hand.
|
|
13
|
+
*/
|
|
14
|
+
accessGroup?: string;
|
|
15
|
+
}
|
|
@@ -655,6 +655,20 @@ declare const dict: {
|
|
|
655
655
|
month: string;
|
|
656
656
|
year: string;
|
|
657
657
|
};
|
|
658
|
+
pronouns: {
|
|
659
|
+
title: string;
|
|
660
|
+
subtitle: string;
|
|
661
|
+
label: string;
|
|
662
|
+
options: {
|
|
663
|
+
unspecified: string;
|
|
664
|
+
he_him: string;
|
|
665
|
+
she_her: string;
|
|
666
|
+
they_them: string;
|
|
667
|
+
he_they: string;
|
|
668
|
+
she_they: string;
|
|
669
|
+
it_its: string;
|
|
670
|
+
};
|
|
671
|
+
};
|
|
658
672
|
locations: {
|
|
659
673
|
title: string;
|
|
660
674
|
subtitle: string;
|
|
@@ -366,6 +366,20 @@ declare const dict: {
|
|
|
366
366
|
month: string;
|
|
367
367
|
year: string;
|
|
368
368
|
};
|
|
369
|
+
pronouns: {
|
|
370
|
+
title: string;
|
|
371
|
+
subtitle: string;
|
|
372
|
+
label: string;
|
|
373
|
+
options: {
|
|
374
|
+
unspecified: string;
|
|
375
|
+
he_him: string;
|
|
376
|
+
she_her: string;
|
|
377
|
+
they_them: string;
|
|
378
|
+
he_they: string;
|
|
379
|
+
she_they: string;
|
|
380
|
+
it_its: string;
|
|
381
|
+
};
|
|
382
|
+
};
|
|
369
383
|
locations: {
|
|
370
384
|
title: string;
|
|
371
385
|
subtitle: string;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AccountKind, AccountCategoryId, AvatarCharacter, UserNameResponse, UserRelationship, ThemePreference, OxyNotificationEntityType, OxyNotificationType } from '@oxy.so/contracts';
|
|
1
|
+
import type { AccountKind, AccountCategoryId, AvatarCharacter, UserNameResponse, UserRelationship, ThemePreference, OxyNotificationEntityType, OxyNotificationType, Pronouns } from '@oxy.so/contracts';
|
|
2
2
|
export interface OxyConfig {
|
|
3
3
|
baseURL: string;
|
|
4
4
|
cloudURL?: string;
|
|
@@ -135,6 +135,11 @@ export interface User {
|
|
|
135
135
|
image?: string;
|
|
136
136
|
id?: string;
|
|
137
137
|
}>;
|
|
138
|
+
/**
|
|
139
|
+
* How a person account names its pronouns, from the closed list. An account
|
|
140
|
+
* that is not a person (organization, channel, bot, …) is always `unspecified`.
|
|
141
|
+
*/
|
|
142
|
+
pronouns?: Pronouns;
|
|
138
143
|
_count?: {
|
|
139
144
|
followers?: number;
|
|
140
145
|
following?: number;
|
|
@@ -29,6 +29,8 @@ export { createWebAuthStateStore, createNativeAuthStateStore, createMemoryAuthSt
|
|
|
29
29
|
export type { PersistedAuthState, AuthStateStore, AuthStateWriteGuard, NativeKeyValueStorage, } from './authStateStore';
|
|
30
30
|
export { createSharedMirroringAuthStateStore, decideSharedDeviceJoin, decideSharedDevicePublish, normalizeSharedDeviceSessionRead, publishProvenDeviceCredential, readLocalDeviceCredential, } from './sharedDeviceCredential';
|
|
31
31
|
export type { SharedDeviceCredential, SharedDeviceCredentialRead, SharedDeviceCredentialStore, SharedDeviceJoinDecision, SharedDeviceJoinSkipReason, SharedDevicePublishDecision, SharedDevicePublishOutcome, SharedDevicePublishSkipReason, } from './sharedDeviceCredential';
|
|
32
|
+
export { KeychainAccessUnavailableError, keychainAccessGroupValue, ownKeychainItemPlan, readThroughPlan, deleteThroughPlan, resolveKeychainAccess, } from '../utils/keychainAccessGroups';
|
|
33
|
+
export type { KeychainAccess, KeychainGroupOption } from '../utils/keychainAccessGroups';
|
|
32
34
|
export { createWebIdentityPinStore, createNativeIdentityPinStore, createMemoryIdentityPinStore, identityPinMatches, IDENTITY_PIN_STORAGE_KEY, } from './identityPin';
|
|
33
35
|
export type { IdentityPin, IdentityPinStore } from './identityPin';
|
|
34
36
|
export { resolveIdentityPin, establishIdentitySession, } from './identitySession';
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/** The group names after the team prefix, exactly as the entitlements list them. */
|
|
2
|
+
export declare const KEYCHAIN_ACCESS_GROUPS: {
|
|
3
|
+
readonly identity: "so.oxy.identity";
|
|
4
|
+
readonly wallet: "so.oxy.wallet";
|
|
5
|
+
readonly shared: "group.so.oxy.shared";
|
|
6
|
+
};
|
|
7
|
+
export type KeychainAccessGroupName = keyof typeof KEYCHAIN_ACCESS_GROUPS;
|
|
8
|
+
/** What a keychain item IS, which decides its group. See the table above. */
|
|
9
|
+
export type KeychainItemClass = 'identity-secret' | 'spending-key' | 'shared';
|
|
10
|
+
/** Which holder this binary is, from its default access group. */
|
|
11
|
+
export type KeychainRole = 'holder' | 'wallet' | 'member';
|
|
12
|
+
/**
|
|
13
|
+
* - `grouped`: iOS with the probe answered. `prefix` ends with its dot
|
|
14
|
+
* (`ABCDE12345.`); `defaultGroup` is the full default group string.
|
|
15
|
+
* - `ungrouped`: Android, web, or an iOS native build without the probe.
|
|
16
|
+
*/
|
|
17
|
+
export type KeychainAccess = {
|
|
18
|
+
mode: 'grouped';
|
|
19
|
+
prefix: string;
|
|
20
|
+
defaultGroup: string;
|
|
21
|
+
role: KeychainRole;
|
|
22
|
+
} | {
|
|
23
|
+
mode: 'ungrouped';
|
|
24
|
+
};
|
|
25
|
+
/** The `expo-secure-store` option this module sets (its real name since 58). */
|
|
26
|
+
export interface KeychainGroupOption {
|
|
27
|
+
accessGroup?: string;
|
|
28
|
+
}
|
|
29
|
+
/** Thrown while the probe cannot answer (before first unlock). Transient. */
|
|
30
|
+
export declare class KeychainAccessUnavailableError extends Error {
|
|
31
|
+
readonly cause?: unknown | undefined;
|
|
32
|
+
readonly name = "KeychainAccessUnavailableError";
|
|
33
|
+
constructor(cause?: unknown | undefined);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The single place each item class is assigned a group. A new kind of item
|
|
37
|
+
* (F1's retained keys, for one) picks its class and reuses everything below.
|
|
38
|
+
*/
|
|
39
|
+
export declare function keychainGroupFor(item: KeychainItemClass): KeychainAccessGroupName;
|
|
40
|
+
/** Whether `role` holds the group `item` lives in. */
|
|
41
|
+
export declare function mayHoldKeychainItem(role: KeychainRole, item: KeychainItemClass): boolean;
|
|
42
|
+
/** Parse the default access group the probe reported. Exported for tests. */
|
|
43
|
+
export declare function keychainAccessFromDefaultGroup(defaultGroup: string): KeychainAccess;
|
|
44
|
+
/**
|
|
45
|
+
* This binary's keychain access, memoized once known. THROWS
|
|
46
|
+
* {@link KeychainAccessUnavailableError} while the probe cannot answer, which
|
|
47
|
+
* every caller already treats as "storage unavailable, retry".
|
|
48
|
+
*/
|
|
49
|
+
export declare function resolveKeychainAccess(): Promise<KeychainAccess>;
|
|
50
|
+
/** Forget the memoized access. Tests only. @internal */
|
|
51
|
+
export declare function resetKeychainAccessForTests(): void;
|
|
52
|
+
/** The full `kSecAttrAccessGroup` value of `group`, or `undefined` when ungrouped. */
|
|
53
|
+
export declare function keychainAccessGroupValue(access: KeychainAccess, group: KeychainAccessGroupName): string | undefined;
|
|
54
|
+
/**
|
|
55
|
+
* The option bags a READ of `item` tries, in order: its own group, then the
|
|
56
|
+
* shared group, where every item lived before the split (release 4 of the doc
|
|
57
|
+
* drops that fallback). Empty when this app may not hold the item: the caller
|
|
58
|
+
* answers `null` without touching the keychain.
|
|
59
|
+
*/
|
|
60
|
+
export declare function keychainReadPlan(access: KeychainAccess, item: KeychainItemClass): KeychainGroupOption[];
|
|
61
|
+
/**
|
|
62
|
+
* Every place a DELETE of `item` must reach: the read plan, so a delete never
|
|
63
|
+
* leaves a pre-split copy behind in the shared group.
|
|
64
|
+
*/
|
|
65
|
+
export declare function keychainDeletePlan(access: KeychainAccess, item: KeychainItemClass): KeychainGroupOption[];
|
|
66
|
+
/**
|
|
67
|
+
* The option a WRITE of `item` carries. THROWS when this app may not hold the
|
|
68
|
+
* item: an identity secret written by any app but Commons would land in that
|
|
69
|
+
* app's default group, a second holder nobody knows about.
|
|
70
|
+
*/
|
|
71
|
+
export declare function keychainWriteOption(access: KeychainAccess, item: KeychainItemClass): KeychainGroupOption;
|
|
72
|
+
/**
|
|
73
|
+
* The plan for an app's OWN items (its session credential, its settings, an
|
|
74
|
+
* imported wallet's phrase) in Commons and the Peable wallet, whose default
|
|
75
|
+
* group the split changed: written to the default group explicitly — where a
|
|
76
|
+
* write without a group goes anyway — and read from it FIRST, then from every
|
|
77
|
+
* group (`{}`), where all of them were written before. Without the explicit
|
|
78
|
+
* order a read could return whichever of two copies the keychain met first.
|
|
79
|
+
* Deletes reach every group, as they always did.
|
|
80
|
+
*
|
|
81
|
+
* Every other app (`member`) and Android/web keep the calls they always made.
|
|
82
|
+
*/
|
|
83
|
+
export declare function ownKeychainItemPlan(access: KeychainAccess): {
|
|
84
|
+
read: KeychainGroupOption[];
|
|
85
|
+
write: KeychainGroupOption;
|
|
86
|
+
delete: KeychainGroupOption[];
|
|
87
|
+
};
|
|
88
|
+
/** The subset of `expo-secure-store` a grouped store wraps. */
|
|
89
|
+
export interface KeychainStoreLike {
|
|
90
|
+
getItemAsync(key: string, options?: object): Promise<string | null>;
|
|
91
|
+
setItemAsync(key: string, value: string, options?: object): Promise<void>;
|
|
92
|
+
deleteItemAsync(key: string, options?: object): Promise<void>;
|
|
93
|
+
}
|
|
94
|
+
/** Read `key` through `plan`: the first non-null answer. A thrown read propagates. */
|
|
95
|
+
export declare function readThroughPlan(store: KeychainStoreLike, key: string, options: object, plan: readonly KeychainGroupOption[]): Promise<string | null>;
|
|
96
|
+
/** Delete `key` from every location in `plan`. A thrown delete propagates. */
|
|
97
|
+
export declare function deleteThroughPlan(store: KeychainStoreLike, key: string, options: object, plan: readonly KeychainGroupOption[]): Promise<void>;
|
|
98
|
+
/**
|
|
99
|
+
* `store` with every call routed to `item`'s groups for this app: reads walk
|
|
100
|
+
* the read plan, writes carry the write option (throwing for a role that may
|
|
101
|
+
* not hold the item), deletes reach every location. The constants of the
|
|
102
|
+
* wrapped store are kept, so `store.WHEN_UNLOCKED_THIS_DEVICE_ONLY` still reads.
|
|
103
|
+
*/
|
|
104
|
+
export declare function groupedKeychainStore<S extends KeychainStoreLike>(store: S, access: KeychainAccess, item: KeychainItemClass): S;
|