@oxy.so/core 7.24.0 → 7.25.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.
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Organization keys on the device (ADR 0061 D6).
3
+ *
4
+ * An organization's identity key is created in Commons by one of its owners or
5
+ * admins, from a fresh 24-word phrase (`seed[0:32]`, the personal-root
6
+ * derivation), and is published as the organization's DID `#key-1`. Commons
7
+ * holds it beside the person's own root, under the organization's account id;
8
+ * other Oxy apps reach it the way they reach the personal identity, by
9
+ * derivation and never by the key:
10
+ *
11
+ * - iOS: the keychain access group `group.so.oxy.shared`, like the personal
12
+ * shared slot. Only Commons writes it.
13
+ * - Android: Commons' own expo-secure-store slot, mirrored into the identity
14
+ * signer store (`./identitySigner`) that Commons' identity host answers
15
+ * `accountId` requests from. Every other app asks Commons over IPC
16
+ * (`loadCommonsIdentityBridge`), and never gets the key.
17
+ *
18
+ * The phrase is kept only in Commons, on the device that created or imported
19
+ * it, so an owner can show it to another owner (ADR 0061 D5); it never reaches
20
+ * the keychain group, an IPC answer or a log.
21
+ *
22
+ * This module imports nothing from `./keyManager` (which imports it): the
23
+ * signer store comes from `./identitySigner`'s registry.
24
+ */
25
+ import { generateMnemonic, mnemonicToSeed, validateMnemonic } from '@scure/bip39';
26
+ import { wordlist } from '@scure/bip39/wordlists/english.js';
27
+ import { loadCommonsIdentityBridge, loadSecureStore, } from '@oxy.so/protocol';
28
+ import { deriveSecp256k1PublicKey, isValidSecp256k1PrivateKey, isValidSecp256k1PublicKey, normalizeSecp256k1PrivateKey, normalizeSecp256k1PublicKey, } from '@oxy.so/protocol/secp256k1';
29
+ import { logger } from '../logger/index.js';
30
+ import { isAndroid, isIOS, isWeb } from '../utils/platform.js';
31
+ import { registeredIdentitySignerStore } from './identitySigner.js';
32
+ /** Same set the server's account ids come from: a legacy 24-hex id or a lowercase uuid. */
33
+ const ACCOUNT_ID = /^(?:[0-9a-f]{24}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/;
34
+ const IOS_KEYCHAIN_GROUP = 'group.so.oxy.shared';
35
+ /** Android, Commons-local: its own Keystore key, shared with no personal slot. */
36
+ const ANDROID_KEYCHAIN_SERVICE = 'oxy_org_identity';
37
+ const MNEMONIC_KEYCHAIN_SERVICE = 'oxy_org_identity_mnemonic';
38
+ const INDEX_KEYCHAIN_SERVICE = 'oxy_org_identity_index';
39
+ const INDEX_STORAGE_KEY = 'oxy_org_identity_index_v1';
40
+ const ORGANIZATION_PHRASE_WORDS = 24;
41
+ /** Whether `value` is an account id an organization key can be stored under. */
42
+ export function isOrganizationAccountId(value) {
43
+ return ACCOUNT_ID.test(value);
44
+ }
45
+ function assertAccountId(accountId) {
46
+ if (!isOrganizationAccountId(accountId)) {
47
+ throw new Error('organization key: accountId must be a 24-hex or uuid account id');
48
+ }
49
+ }
50
+ const privateKeyName = (accountId) => `oxy_org_identity_private_key.${accountId}`;
51
+ const publicKeyName = (accountId) => `oxy_org_identity_public_key.${accountId}`;
52
+ const mnemonicName = (accountId) => `oxy_org_identity_mnemonic.${accountId}`;
53
+ function toHex(bytes) {
54
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
55
+ }
56
+ /** Lowercase uncompressed public key of `privateKey`. */
57
+ function publicKeyOf(privateKey) {
58
+ return deriveSecp256k1PublicKey(normalizeSecp256k1PrivateKey(privateKey));
59
+ }
60
+ /** True only when both halves are well-formed AND the public key derives from the private one. */
61
+ export function isHealthyOrganizationKeyPair(privateKey, publicKey) {
62
+ if (!privateKey || !publicKey)
63
+ return false;
64
+ if (!isValidSecp256k1PrivateKey(privateKey) || !isValidSecp256k1PublicKey(publicKey)) {
65
+ return false;
66
+ }
67
+ try {
68
+ return publicKeyOf(privateKey) === normalizeSecp256k1PublicKey(publicKey);
69
+ }
70
+ catch {
71
+ return false;
72
+ }
73
+ }
74
+ function normalizePhrase(phrase) {
75
+ return phrase.trim().toLowerCase().split(/\s+/).join(' ');
76
+ }
77
+ async function pairFromPhrase(phrase) {
78
+ const normalized = normalizePhrase(phrase);
79
+ if (normalized.split(' ').length !== ORGANIZATION_PHRASE_WORDS ||
80
+ !validateMnemonic(normalized, wordlist)) {
81
+ throw new Error('organization key: not a valid 24-word recovery phrase');
82
+ }
83
+ const seed = await mnemonicToSeed(normalized);
84
+ const privateKey = toHex(seed.subarray(0, 32));
85
+ return { privateKey, publicKey: publicKeyOf(privateKey) };
86
+ }
87
+ /**
88
+ * A fresh organization key: a 24-word phrase and the pair derived from it
89
+ * exactly as a personal root is (`seed[0:32]`). Stores NOTHING: the caller shows
90
+ * the phrase, links the key (`oxy.accounts.identityKey`), and only then calls
91
+ * {@link storeOrganizationKey}.
92
+ */
93
+ export async function generateOrganizationKey() {
94
+ const mnemonic = generateMnemonic(wordlist, 256);
95
+ return { mnemonic, ...(await pairFromPhrase(mnemonic)) };
96
+ }
97
+ /**
98
+ * The pair a 24-word organization phrase derives. Stores nothing; the caller
99
+ * checks the public key against the organization's DID `#key-1` before keeping
100
+ * it (ADR 0061 D5).
101
+ */
102
+ export async function organizationKeyFromPhrase(phrase) {
103
+ return pairFromPhrase(phrase);
104
+ }
105
+ async function secureStore() {
106
+ return loadSecureStore();
107
+ }
108
+ async function readIndex(store) {
109
+ const raw = await store.getItemAsync(INDEX_STORAGE_KEY, {
110
+ keychainService: INDEX_KEYCHAIN_SERVICE,
111
+ });
112
+ if (!raw)
113
+ return [];
114
+ try {
115
+ const parsed = JSON.parse(raw);
116
+ return Array.isArray(parsed)
117
+ ? parsed.filter((id) => typeof id === 'string' && isOrganizationAccountId(id))
118
+ : [];
119
+ }
120
+ catch (error) {
121
+ logger.warn('Organization key index unreadable; treating as empty', {
122
+ component: 'OrganizationKeys',
123
+ error: error instanceof Error ? error.name : 'unknown',
124
+ });
125
+ return [];
126
+ }
127
+ }
128
+ async function writeIndex(store, ids) {
129
+ await store.setItemAsync(INDEX_STORAGE_KEY, JSON.stringify([...new Set(ids)].sort()), {
130
+ keychainService: INDEX_KEYCHAIN_SERVICE,
131
+ });
132
+ }
133
+ /** Where this platform keeps the pair: the iOS keychain group, or Commons' own Android slot. */
134
+ function pairOptions(store, forPrivateWrite) {
135
+ if (isIOS()) {
136
+ return forPrivateWrite
137
+ ? {
138
+ keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
139
+ keychainAccessGroup: IOS_KEYCHAIN_GROUP,
140
+ }
141
+ : { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
142
+ }
143
+ return forPrivateWrite
144
+ ? {
145
+ keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
146
+ keychainService: ANDROID_KEYCHAIN_SERVICE,
147
+ }
148
+ : { keychainService: ANDROID_KEYCHAIN_SERVICE };
149
+ }
150
+ /** The pair from this app's own storage (iOS group; Android Commons slot, then signer store). */
151
+ async function readLocalPair(accountId) {
152
+ if (isWeb())
153
+ return null;
154
+ if (isAndroid()) {
155
+ const signer = registeredIdentitySignerStore();
156
+ if (!signer)
157
+ return null; // Only Commons holds organization keys on Android.
158
+ const store = await secureStore();
159
+ const options = pairOptions(store, false);
160
+ const privateKey = await store.getItemAsync(privateKeyName(accountId), options);
161
+ const publicKey = await store.getItemAsync(publicKeyName(accountId), options);
162
+ if (privateKey && publicKey && isHealthyOrganizationKeyPair(privateKey, publicKey)) {
163
+ return {
164
+ privateKey: normalizeSecp256k1PrivateKey(privateKey),
165
+ publicKey: normalizeSecp256k1PublicKey(publicKey),
166
+ };
167
+ }
168
+ // The signer store is a rung: a wiped expo-secure-store slot still has it.
169
+ const mirrored = (await signer.readOrganization?.(accountId)) ?? null;
170
+ return mirrored && isHealthyOrganizationKeyPair(mirrored.privateKey, mirrored.publicKey)
171
+ ? mirrored
172
+ : null;
173
+ }
174
+ if (!isIOS())
175
+ return null;
176
+ const store = await secureStore();
177
+ const options = pairOptions(store, false);
178
+ const privateKey = await store.getItemAsync(privateKeyName(accountId), options);
179
+ const publicKey = await store.getItemAsync(publicKeyName(accountId), options);
180
+ return privateKey && publicKey && isHealthyOrganizationKeyPair(privateKey, publicKey)
181
+ ? {
182
+ privateKey: normalizeSecp256k1PrivateKey(privateKey),
183
+ publicKey: normalizeSecp256k1PublicKey(publicKey),
184
+ }
185
+ : null;
186
+ }
187
+ /**
188
+ * Keep an organization key on this device. Commons only: on Android it needs
189
+ * the identity signer store Commons registers, so the identity host can serve
190
+ * it; on iOS only Commons calls it (the keychain group is shared).
191
+ *
192
+ * Refuses a pair whose public key does not derive from its private key, and a
193
+ * phrase that does not derive the pair. Overwrites whatever this device held for
194
+ * the organization (a replaced key is the caller's decision, ADR 0061 D7).
195
+ */
196
+ export async function storeOrganizationKey(accountId, key) {
197
+ assertAccountId(accountId);
198
+ if (isWeb())
199
+ throw new Error('organization key: only a native device holds one');
200
+ if (!isHealthyOrganizationKeyPair(key.privateKey, key.publicKey)) {
201
+ throw new Error('organization key: the public key does not derive from the private key');
202
+ }
203
+ const privateKey = normalizeSecp256k1PrivateKey(key.privateKey);
204
+ const publicKey = normalizeSecp256k1PublicKey(key.publicKey);
205
+ if (key.mnemonic !== undefined) {
206
+ const fromPhrase = await pairFromPhrase(key.mnemonic);
207
+ if (fromPhrase.publicKey !== publicKey) {
208
+ throw new Error('organization key: the phrase derives a different key');
209
+ }
210
+ }
211
+ const signer = isAndroid() ? registeredIdentitySignerStore() : null;
212
+ if (isAndroid() && !signer?.writeOrganization) {
213
+ throw new Error('On Android only Commons holds organization keys; this app has no identity signer store for them.');
214
+ }
215
+ const store = await secureStore();
216
+ await store.setItemAsync(privateKeyName(accountId), privateKey, pairOptions(store, true));
217
+ await store.setItemAsync(publicKeyName(accountId), publicKey, pairOptions(store, false));
218
+ if (signer?.writeOrganization &&
219
+ !(await signer.writeOrganization(accountId, privateKey, publicKey))) {
220
+ throw new Error(`The identity signer store (${signer.name}) did not confirm the organization key.`);
221
+ }
222
+ if (key.mnemonic !== undefined) {
223
+ await store.setItemAsync(mnemonicName(accountId), normalizePhrase(key.mnemonic), {
224
+ keychainService: MNEMONIC_KEYCHAIN_SERVICE,
225
+ keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
226
+ });
227
+ }
228
+ else {
229
+ await store.deleteItemAsync(mnemonicName(accountId), {
230
+ keychainService: MNEMONIC_KEYCHAIN_SERVICE,
231
+ });
232
+ }
233
+ await writeIndex(store, [...(await readIndex(store)), accountId]);
234
+ }
235
+ /**
236
+ * The organization's public key as THIS device can reach it, or `null`.
237
+ * iOS: the keychain group. Android: Commons' own slot inside Commons, and
238
+ * Commons' identity host (`describe` with `accountId`) everywhere else. Compare
239
+ * it with the organization's DID `#key-1` before receiving (Peable ADR 0002).
240
+ */
241
+ export async function getOrganizationPublicKey(accountId) {
242
+ if (!isOrganizationAccountId(accountId) || isWeb())
243
+ return null;
244
+ try {
245
+ if (isAndroid() && !registeredIdentitySignerStore()) {
246
+ const bridge = await loadCommonsIdentityBridge();
247
+ const description = (await bridge?.describe(accountId)) ?? null;
248
+ return description?.accountId === accountId ? description.publicKey : null;
249
+ }
250
+ return (await readLocalPair(accountId))?.publicKey ?? null;
251
+ }
252
+ catch (error) {
253
+ logger.warn('Failed to read an organization public key', {
254
+ component: 'OrganizationKeys',
255
+ error: error instanceof Error ? error.name : 'unknown',
256
+ });
257
+ return null;
258
+ }
259
+ }
260
+ /**
261
+ * The organization's private key, where this app may hold it: Commons (both
262
+ * platforms) and, on iOS, the keychain group. `null` on Android outside
263
+ * Commons. Use it to sign within the app only; never transmit it.
264
+ */
265
+ export async function getOrganizationPrivateKey(accountId) {
266
+ if (!isOrganizationAccountId(accountId))
267
+ return null;
268
+ try {
269
+ return (await readLocalPair(accountId))?.privateKey ?? null;
270
+ }
271
+ catch (error) {
272
+ logger.warn('Failed to read an organization private key', {
273
+ component: 'OrganizationKeys',
274
+ error: error instanceof Error ? error.name : 'unknown',
275
+ });
276
+ return null;
277
+ }
278
+ }
279
+ /** The organization's phrase, if this device created or imported it with one. Commons only. */
280
+ export async function getOrganizationRecoveryPhrase(accountId) {
281
+ if (!isOrganizationAccountId(accountId) || isWeb())
282
+ return null;
283
+ const store = await secureStore();
284
+ return store.getItemAsync(mnemonicName(accountId), {
285
+ keychainService: MNEMONIC_KEYCHAIN_SERVICE,
286
+ });
287
+ }
288
+ /** The organizations this device keeps a key for (Commons' own index). */
289
+ export async function listOrganizationKeys() {
290
+ if (isWeb())
291
+ return [];
292
+ return readIndex(await secureStore());
293
+ }
294
+ /** Forget an organization key on this device: its pair, its phrase, its signer copy and its index entry. */
295
+ export async function deleteOrganizationKey(accountId) {
296
+ assertAccountId(accountId);
297
+ if (isWeb())
298
+ return;
299
+ const store = await secureStore();
300
+ const options = pairOptions(store, false);
301
+ await store.deleteItemAsync(privateKeyName(accountId), options);
302
+ await store.deleteItemAsync(publicKeyName(accountId), options);
303
+ await store.deleteItemAsync(mnemonicName(accountId), {
304
+ keychainService: MNEMONIC_KEYCHAIN_SERVICE,
305
+ });
306
+ const signer = isAndroid() ? registeredIdentitySignerStore() : null;
307
+ await signer?.clearOrganization?.(accountId);
308
+ await writeIndex(store, (await readIndex(store)).filter((id) => id !== accountId));
309
+ }
310
+ /**
311
+ * Commons on Android: copy every held organization key into the identity
312
+ * signer store again, so the identity host answers for it after that file was
313
+ * rebuilt (its self-heal leaves it empty). Call on every boot, beside
314
+ * `KeyManager.syncSharedIdentity`. Resolves how many it mirrored. Elsewhere 0.
315
+ */
316
+ export async function syncOrganizationKeys() {
317
+ const signer = isAndroid() ? registeredIdentitySignerStore() : null;
318
+ if (!signer?.writeOrganization || !signer.readOrganization)
319
+ return 0;
320
+ const store = await secureStore();
321
+ let mirrored = 0;
322
+ for (const accountId of await readIndex(store)) {
323
+ const options = pairOptions(store, false);
324
+ const privateKey = await store.getItemAsync(privateKeyName(accountId), options);
325
+ const publicKey = await store.getItemAsync(publicKeyName(accountId), options);
326
+ if (!privateKey || !publicKey || !isHealthyOrganizationKeyPair(privateKey, publicKey))
327
+ continue;
328
+ const current = await signer.readOrganization(accountId);
329
+ if (current?.publicKey.toLowerCase() === publicKey.toLowerCase())
330
+ continue;
331
+ if (await signer.writeOrganization(accountId, normalizeSecp256k1PrivateKey(privateKey), publicKey.toLowerCase())) {
332
+ mirrored += 1;
333
+ }
334
+ }
335
+ return mirrored;
336
+ }
@@ -26,4 +26,22 @@ export interface IdentitySignerStore {
26
26
  write(privateKey: string, publicKey: string): Promise<boolean>;
27
27
  /** Drop the stored key pair. */
28
28
  clear(): Promise<void>;
29
+ /**
30
+ * An ORGANIZATION key Commons holds (ADR 0061 D6), by the organization's
31
+ * account id: the copy the identity host answers `accountId` requests from.
32
+ * Optional so a store built before organization keys still type-checks; a
33
+ * store without them cannot hold an organization key.
34
+ */
35
+ readOrganization?(accountId: string): Promise<{
36
+ privateKey: string;
37
+ publicKey: string;
38
+ } | null>;
39
+ /** Replace that organization's pair. Resolves whether a read-back confirmed it. */
40
+ writeOrganization?(accountId: string, privateKey: string, publicKey: string): Promise<boolean>;
41
+ /** Drop that organization's pair. */
42
+ clearOrganization?(accountId: string): Promise<void>;
29
43
  }
44
+ /** @internal */
45
+ export declare function registerIdentitySignerStore(store: IdentitySignerStore | null): void;
46
+ /** @internal */
47
+ export declare function registeredIdentitySignerStore(): IdentitySignerStore | null;
@@ -17,6 +17,8 @@ export { readIdentityMarker, updateIdentityMarker } from './identityMarker';
17
17
  export type { IdentityMarker } from './identityMarker';
18
18
  export type { IdentityDeviceBackupStore } from './deviceBackup';
19
19
  export type { IdentitySignerStore } from './identitySigner';
20
+ export { generateOrganizationKey, organizationKeyFromPhrase, storeOrganizationKey, getOrganizationPublicKey, getOrganizationPrivateKey, getOrganizationRecoveryPhrase, listOrganizationKeys, deleteOrganizationKey, syncOrganizationKeys, isOrganizationAccountId, isHealthyOrganizationKeyPair, } from './organizationKeys';
21
+ export type { OrganizationKeyPair, GeneratedOrganizationKey, StoredOrganizationKey, } from './organizationKeys';
20
22
  export { SignatureService } from './signatureService';
21
23
  export type { SignedMessage, AuthChallenge } from './signatureService';
22
24
  export { RecoveryPhraseService, BACKUP_KDF_ENCRYPTION_INFO } from './recoveryPhrase';
@@ -5,7 +5,7 @@
5
5
  * Private keys are stored securely using expo-secure-store and never leave the device.
6
6
  */
7
7
  import { type CommonsSocialReceiveSignature } from '@oxy.so/protocol';
8
- import type { IdentitySignerStore } from './identitySigner';
8
+ import { type IdentitySignerStore } from './identitySigner';
9
9
  import { type IdentityMarker } from './identityMarker';
10
10
  import { type IdentityDeviceBackupStore } from './deviceBackup';
11
11
  /**
@@ -726,11 +726,26 @@ export declare class KeyManager {
726
726
  * Both reproduce from the user's Oxy recovery phrase, so the seed is
727
727
  * recoverable, and the IPC answer is byte-identical to the local one.
728
728
  *
729
+ * With `opts.accountId` the seed is derived from that ORGANIZATION's key
730
+ * instead (ADR 0061 D6), from the same places: Commons' own copy, the iOS
731
+ * keychain group, or Commons' identity host. It never falls back to the
732
+ * personal key.
733
+ *
729
734
  * @param info Context/domain-binding label (distinct labels → independent seeds).
735
+ * @param opts.accountId An organization's account id, for its key.
730
736
  * @returns 32 bytes of derived keying material, or `null` on web / when no
731
737
  * identity key is available to this app.
732
738
  */
733
- static deriveScopedSeed(info: string): Promise<Uint8Array | null>;
739
+ static deriveScopedSeed(info: string, opts?: {
740
+ accountId?: string;
741
+ }): Promise<Uint8Array | null>;
742
+ /**
743
+ * {@link deriveScopedSeed} over an ORGANIZATION key (ADR 0061 D6): Commons'
744
+ * own copy inside Commons and on iOS the keychain group, everywhere else on
745
+ * Android Commons' identity host. Never falls back to the personal key: an
746
+ * organization this device cannot reach is `null`.
747
+ */
748
+ private static _deriveOrganizationScopedSeed;
734
749
  /**
735
750
  * Sign a 32-byte digest (a transaction sighash, lowercase hex) with the
736
751
  * identity's social-receive child key `index`: the non-hardened BIP32 child
@@ -738,13 +753,16 @@ export declare class KeyManager {
738
753
  * signature is RFC 6979, low-S, DER hex; `publicKey` is the child's
739
754
  * compressed public key for the scriptSig.
740
755
  *
741
- * Key source: as {@link deriveScopedSeed}. On Android outside Commons the
742
- * signature comes from Commons, which allows it for the wallet app only.
756
+ * Key source: as {@link deriveScopedSeed}, including `opts.accountId` for an
757
+ * organization's key (ADR 0061 D6). On Android outside Commons the signature
758
+ * comes from Commons, which allows it for the wallet app only.
743
759
  *
744
760
  * @returns the signature, or `null` on web / when no identity key is
745
761
  * available to this app.
746
762
  */
747
- static signSocialReceive(index: number, digest: string): Promise<CommonsSocialReceiveSignature | null>;
763
+ static signSocialReceive(index: number, digest: string, opts?: {
764
+ accountId?: string;
765
+ }): Promise<CommonsSocialReceiveSignature | null>;
748
766
  /**
749
767
  * Get a shortened version of the public key for display
750
768
  * Format: first 8 chars...last 8 chars
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Organization keys on the device (ADR 0061 D6).
3
+ *
4
+ * An organization's identity key is created in Commons by one of its owners or
5
+ * admins, from a fresh 24-word phrase (`seed[0:32]`, the personal-root
6
+ * derivation), and is published as the organization's DID `#key-1`. Commons
7
+ * holds it beside the person's own root, under the organization's account id;
8
+ * other Oxy apps reach it the way they reach the personal identity, by
9
+ * derivation and never by the key:
10
+ *
11
+ * - iOS: the keychain access group `group.so.oxy.shared`, like the personal
12
+ * shared slot. Only Commons writes it.
13
+ * - Android: Commons' own expo-secure-store slot, mirrored into the identity
14
+ * signer store (`./identitySigner`) that Commons' identity host answers
15
+ * `accountId` requests from. Every other app asks Commons over IPC
16
+ * (`loadCommonsIdentityBridge`), and never gets the key.
17
+ *
18
+ * The phrase is kept only in Commons, on the device that created or imported
19
+ * it, so an owner can show it to another owner (ADR 0061 D5); it never reaches
20
+ * the keychain group, an IPC answer or a log.
21
+ *
22
+ * This module imports nothing from `./keyManager` (which imports it): the
23
+ * signer store comes from `./identitySigner`'s registry.
24
+ */
25
+ /** A secp256k1 pair: private key 64 hex, public key lowercase uncompressed (`04…`, 130 hex). */
26
+ export interface OrganizationKeyPair {
27
+ privateKey: string;
28
+ publicKey: string;
29
+ }
30
+ /** A new organization key and the 24-word phrase it derives from. */
31
+ export interface GeneratedOrganizationKey extends OrganizationKeyPair {
32
+ mnemonic: string;
33
+ }
34
+ /** What {@link storeOrganizationKey} keeps: the pair, and the phrase when the device has it. */
35
+ export interface StoredOrganizationKey extends OrganizationKeyPair {
36
+ mnemonic?: string;
37
+ }
38
+ /** Whether `value` is an account id an organization key can be stored under. */
39
+ export declare function isOrganizationAccountId(value: string): boolean;
40
+ /** True only when both halves are well-formed AND the public key derives from the private one. */
41
+ export declare function isHealthyOrganizationKeyPair(privateKey: string | null | undefined, publicKey: string | null | undefined): boolean;
42
+ /**
43
+ * A fresh organization key: a 24-word phrase and the pair derived from it
44
+ * exactly as a personal root is (`seed[0:32]`). Stores NOTHING: the caller shows
45
+ * the phrase, links the key (`oxy.accounts.identityKey`), and only then calls
46
+ * {@link storeOrganizationKey}.
47
+ */
48
+ export declare function generateOrganizationKey(): Promise<GeneratedOrganizationKey>;
49
+ /**
50
+ * The pair a 24-word organization phrase derives. Stores nothing; the caller
51
+ * checks the public key against the organization's DID `#key-1` before keeping
52
+ * it (ADR 0061 D5).
53
+ */
54
+ export declare function organizationKeyFromPhrase(phrase: string): Promise<OrganizationKeyPair>;
55
+ /**
56
+ * Keep an organization key on this device. Commons only: on Android it needs
57
+ * the identity signer store Commons registers, so the identity host can serve
58
+ * it; on iOS only Commons calls it (the keychain group is shared).
59
+ *
60
+ * Refuses a pair whose public key does not derive from its private key, and a
61
+ * phrase that does not derive the pair. Overwrites whatever this device held for
62
+ * the organization (a replaced key is the caller's decision, ADR 0061 D7).
63
+ */
64
+ export declare function storeOrganizationKey(accountId: string, key: StoredOrganizationKey): Promise<void>;
65
+ /**
66
+ * 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
68
+ * Commons' identity host (`describe` with `accountId`) everywhere else. Compare
69
+ * it with the organization's DID `#key-1` before receiving (Peable ADR 0002).
70
+ */
71
+ export declare function getOrganizationPublicKey(accountId: string): Promise<string | null>;
72
+ /**
73
+ * The organization's private key, where this app may hold it: Commons (both
74
+ * platforms) and, on iOS, the keychain group. `null` on Android outside
75
+ * Commons. Use it to sign within the app only; never transmit it.
76
+ */
77
+ export declare function getOrganizationPrivateKey(accountId: string): Promise<string | null>;
78
+ /** The organization's phrase, if this device created or imported it with one. Commons only. */
79
+ export declare function getOrganizationRecoveryPhrase(accountId: string): Promise<string | null>;
80
+ /** The organizations this device keeps a key for (Commons' own index). */
81
+ export declare function listOrganizationKeys(): Promise<string[]>;
82
+ /** Forget an organization key on this device: its pair, its phrase, its signer copy and its index entry. */
83
+ export declare function deleteOrganizationKey(accountId: string): Promise<void>;
84
+ /**
85
+ * Commons on Android: copy every held organization key into the identity
86
+ * signer store again, so the identity host answers for it after that file was
87
+ * rebuilt (its self-heal leaves it empty). Call on every boot, beside
88
+ * `KeyManager.syncSharedIdentity`. Resolves how many it mirrored. Elsewhere 0.
89
+ */
90
+ export declare function syncOrganizationKeys(): Promise<number>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/core",
3
- "version": "7.24.0",
3
+ "version": "7.25.0",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -182,7 +182,7 @@
182
182
  "@noble/ciphers": "^2.4.0",
183
183
  "@noble/hashes": "^2.4.0",
184
184
  "@oxy.so/contracts": "^6.11.0",
185
- "@oxy.so/protocol": "^1.6.1",
185
+ "@oxy.so/protocol": "^1.7.0",
186
186
  "@oxy.so/telemetry": "^2.1.0",
187
187
  "@scure/bip39": "^2.4.0",
188
188
  "buffer": "^6.0.3",
@@ -23,4 +23,32 @@ export interface IdentitySignerStore {
23
23
  write(privateKey: string, publicKey: string): Promise<boolean>;
24
24
  /** Drop the stored key pair. */
25
25
  clear(): Promise<void>;
26
+ /**
27
+ * An ORGANIZATION key Commons holds (ADR 0061 D6), by the organization's
28
+ * account id: the copy the identity host answers `accountId` requests from.
29
+ * Optional so a store built before organization keys still type-checks; a
30
+ * store without them cannot hold an organization key.
31
+ */
32
+ readOrganization?(accountId: string): Promise<{ privateKey: string; publicKey: string } | null>;
33
+ /** Replace that organization's pair. Resolves whether a read-back confirmed it. */
34
+ writeOrganization?(accountId: string, privateKey: string, publicKey: string): Promise<boolean>;
35
+ /** Drop that organization's pair. */
36
+ clearOrganization?(accountId: string): Promise<void>;
37
+ }
38
+
39
+ /**
40
+ * The store Commons registered, readable by modules that `KeyManager` itself
41
+ * imports (`./organizationKeys`), which therefore cannot import `KeyManager`.
42
+ * Written only by `KeyManager.setIdentitySignerStore`. @internal
43
+ */
44
+ let registeredSignerStore: IdentitySignerStore | null = null;
45
+
46
+ /** @internal */
47
+ export function registerIdentitySignerStore(store: IdentitySignerStore | null): void {
48
+ registeredSignerStore = store;
49
+ }
50
+
51
+ /** @internal */
52
+ export function registeredIdentitySignerStore(): IdentitySignerStore | null {
53
+ return registeredSignerStore;
26
54
  }
@@ -26,6 +26,25 @@ export { readIdentityMarker, updateIdentityMarker } from './identityMarker';
26
26
  export type { IdentityMarker } from './identityMarker';
27
27
  export type { IdentityDeviceBackupStore } from './deviceBackup';
28
28
  export type { IdentitySignerStore } from './identitySigner';
29
+ // Organization keys on the device (docs/adr/0061-organization-identity-key.md D6)
30
+ export {
31
+ generateOrganizationKey,
32
+ organizationKeyFromPhrase,
33
+ storeOrganizationKey,
34
+ getOrganizationPublicKey,
35
+ getOrganizationPrivateKey,
36
+ getOrganizationRecoveryPhrase,
37
+ listOrganizationKeys,
38
+ deleteOrganizationKey,
39
+ syncOrganizationKeys,
40
+ isOrganizationAccountId,
41
+ isHealthyOrganizationKeyPair,
42
+ } from './organizationKeys';
43
+ export type {
44
+ OrganizationKeyPair,
45
+ GeneratedOrganizationKey,
46
+ StoredOrganizationKey,
47
+ } from './organizationKeys';
29
48
  export { SignatureService } from './signatureService';
30
49
  export type { SignedMessage, AuthChallenge } from './signatureService';
31
50
  export { RecoveryPhraseService, BACKUP_KDF_ENCRYPTION_INFO } from './recoveryPhrase';