@oxy.so/core 7.23.2 → 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.
- package/dist/cjs/api/accounts.js +102 -1
- package/dist/cjs/crypto/identitySigner.js +16 -0
- package/dist/cjs/crypto/index.js +14 -1
- package/dist/cjs/crypto/keyManager.js +48 -4
- package/dist/cjs/crypto/organizationKeys.js +349 -0
- package/dist/esm/api/accounts.js +68 -1
- package/dist/esm/crypto/identitySigner.js +14 -1
- package/dist/esm/crypto/index.js +2 -0
- package/dist/esm/crypto/keyManager.js +48 -4
- package/dist/esm/crypto/organizationKeys.js +336 -0
- package/dist/types/api/accounts.d.ts +41 -1
- package/dist/types/crypto/identitySigner.d.ts +18 -0
- package/dist/types/crypto/index.d.ts +2 -0
- package/dist/types/crypto/keyManager.d.ts +23 -5
- package/dist/types/crypto/organizationKeys.d.ts +90 -0
- package/dist/types/server/appAssertion.d.ts +1 -1
- package/package.json +3 -3
- package/src/api/accounts.ts +135 -1
- package/src/crypto/identitySigner.ts +28 -0
- package/src/crypto/index.ts +19 -0
- package/src/crypto/keyManager.ts +55 -4
- package/src/crypto/organizationKeys.ts +399 -0
package/dist/esm/api/accounts.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { agentKeyListResponseCheck, agentKeyOperationChallengeCheck, agentKeyOperationCheck, agentKeyOperationResultCheck, executeAgentKeyOperationCheck, parseContract, } from '@oxy.so/contracts/client';
|
|
1
|
+
import { agentKeyListResponseCheck, agentKeyOperationChallengeCheck, agentKeyOperationCheck, agentKeyOperationResultCheck, executeAgentKeyOperationCheck, organizationIdentityKeyStateCheck, organizationKeyApplyRequestCheck, organizationKeyChallengeCheck, organizationKeyChallengeRequestCheck, organizationKeyProofClaims, parseContract, } from '@oxy.so/contracts/client';
|
|
2
2
|
import { normalizeUserIdentity } from '../utils/userIdentity.js';
|
|
3
3
|
import { evictOxyIdentityCache } from '../utils/identityCacheSweep.js';
|
|
4
4
|
import { evictOxyAccountForestCache, oxyAccountDetailCacheKey, OXY_ACCOUNT_PER_ACCOUNT_CACHE_PREFIX, } from '../utils/accountCacheSweep.js';
|
|
@@ -75,6 +75,72 @@ export class AccountAgentKeysApi {
|
|
|
75
75
|
return parseContract(agentKeyOperationResultCheck, response);
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* `oxy.accounts.identityKey` — an organization's identity key (ADR 0061): its
|
|
80
|
+
* DID `#key-1`, the key Peable derives its FairCoin receive addresses from.
|
|
81
|
+
* Only the organization's owners and admins may read or change it; the key is
|
|
82
|
+
* created and held in Commons and never signs anybody in.
|
|
83
|
+
*
|
|
84
|
+
* `get` answers `publicKey: null` for an organization that has no key yet (its
|
|
85
|
+
* DID lists no `#key-1`): that is when a client shows its owners "Set up the
|
|
86
|
+
* organization's Peable wallet". A 403 (`ORGANIZATION_KEY_FORBIDDEN`) means the
|
|
87
|
+
* signed-in person is not an owner or admin of a claimed, active organization.
|
|
88
|
+
*/
|
|
89
|
+
export class AccountIdentityKeyApi {
|
|
90
|
+
constructor(ctx) {
|
|
91
|
+
this.ctx = ctx;
|
|
92
|
+
}
|
|
93
|
+
/** The organization's key (`null` without one) and every key it has had, oldest first. */
|
|
94
|
+
async get(accountId) {
|
|
95
|
+
return parseContract(organizationIdentityKeyStateCheck, await this.ctx.request('GET', `/accounts/${enc(accountId)}/identity-key`, undefined, { cache: false }));
|
|
96
|
+
}
|
|
97
|
+
/** A one-use challenge for one change, with the claims every signer signs. */
|
|
98
|
+
async requestChallenge(accountId, request) {
|
|
99
|
+
return parseContract(organizationKeyChallengeCheck, await this.ctx.request('POST', `/accounts/${enc(accountId)}/identity-key/challenge`, parseContract(organizationKeyChallengeRequestCheck, request), { cache: false }));
|
|
100
|
+
}
|
|
101
|
+
/** Apply a change with its signatures (see {@link link} for the whole ceremony). */
|
|
102
|
+
async apply(accountId, request) {
|
|
103
|
+
return parseContract(organizationIdentityKeyStateCheck, await this.ctx.request('POST', `/accounts/${enc(accountId)}/identity-key`, parseContract(organizationKeyApplyRequestCheck, request), { cache: false }));
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Commons' whole ceremony. NATIVE-ONLY: the actor is THIS device's personal
|
|
107
|
+
* root (`KeyManager`), which must be the signed-in person's. `key` is the
|
|
108
|
+
* organization key the device generated (`create`, or `replace` with a new
|
|
109
|
+
* one); `previousKey` is the current organization key when the device holds
|
|
110
|
+
* it, so the replacement is recorded as proven by it.
|
|
111
|
+
*/
|
|
112
|
+
async link(accountId, input) {
|
|
113
|
+
const { KeyManager, signIdentityProof } = await import('../crypto/index.js');
|
|
114
|
+
const [rootPrivateKey, rootPublicKey] = await Promise.all([
|
|
115
|
+
KeyManager.getPrivateKey(),
|
|
116
|
+
KeyManager.getPublicKey(),
|
|
117
|
+
]);
|
|
118
|
+
if (!rootPrivateKey || !rootPublicKey) {
|
|
119
|
+
throw new Error('No identity on this device to sign with');
|
|
120
|
+
}
|
|
121
|
+
const challenge = await this.requestChallenge(accountId, {
|
|
122
|
+
operation: input.operation,
|
|
123
|
+
publicKey: input.key.publicKey,
|
|
124
|
+
});
|
|
125
|
+
const root = rootPublicKey.trim().toLowerCase();
|
|
126
|
+
if (challenge.actorPublicKey !== root) {
|
|
127
|
+
throw new Error("This device's identity is not the signed-in account's");
|
|
128
|
+
}
|
|
129
|
+
const sign = async (signer, publicKey) => (await signIdentityProof({ privateKey: signer.privateKey, publicKey }, organizationKeyProofClaims(challenge, publicKey))).signature;
|
|
130
|
+
const previousPublicKey = challenge.previousPublicKey;
|
|
131
|
+
return this.apply(accountId, {
|
|
132
|
+
operation: input.operation,
|
|
133
|
+
publicKey: challenge.publicKey,
|
|
134
|
+
challenge: challenge.challenge,
|
|
135
|
+
expiresAt: challenge.expiresAt,
|
|
136
|
+
actorSignature: await sign({ privateKey: rootPrivateKey, publicKey: root }, root),
|
|
137
|
+
keySignature: await sign(input.key, challenge.publicKey),
|
|
138
|
+
...(input.previousKey && previousPublicKey
|
|
139
|
+
? { previousKeySignature: await sign(input.previousKey, previousPublicKey) }
|
|
140
|
+
: {}),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
78
144
|
/** Consent-based invitations; never cached or queued as offline mutations. */
|
|
79
145
|
export class AccountInvitationsApi {
|
|
80
146
|
constructor(ctx) {
|
|
@@ -109,6 +175,7 @@ export class AccountsApi {
|
|
|
109
175
|
this.members = new AccountMembersApi(ctx);
|
|
110
176
|
this.invitations = new AccountInvitationsApi(ctx);
|
|
111
177
|
this.agentKeys = new AccountAgentKeysApi(ctx);
|
|
178
|
+
this.identityKey = new AccountIdentityKeyApi(ctx);
|
|
112
179
|
}
|
|
113
180
|
/**
|
|
114
181
|
* List the accounts the caller can access: their own personal (root)
|
|
@@ -1 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The store Commons registered, readable by modules that `KeyManager` itself
|
|
3
|
+
* imports (`./organizationKeys`), which therefore cannot import `KeyManager`.
|
|
4
|
+
* Written only by `KeyManager.setIdentitySignerStore`. @internal
|
|
5
|
+
*/
|
|
6
|
+
let registeredSignerStore = null;
|
|
7
|
+
/** @internal */
|
|
8
|
+
export function registerIdentitySignerStore(store) {
|
|
9
|
+
registeredSignerStore = store;
|
|
10
|
+
}
|
|
11
|
+
/** @internal */
|
|
12
|
+
export function registeredIdentitySignerStore() {
|
|
13
|
+
return registeredSignerStore;
|
|
14
|
+
}
|
package/dist/esm/crypto/index.js
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
import './polyfill.js';
|
|
16
16
|
export { KeyManager, IdentityAlreadyExistsError, IdentityPersistError, IdentityUnavailableError, } from './keyManager.js';
|
|
17
17
|
export { readIdentityMarker, updateIdentityMarker } from './identityMarker.js';
|
|
18
|
+
// Organization keys on the device (docs/adr/0061-organization-identity-key.md D6)
|
|
19
|
+
export { generateOrganizationKey, organizationKeyFromPhrase, storeOrganizationKey, getOrganizationPublicKey, getOrganizationPrivateKey, getOrganizationRecoveryPhrase, listOrganizationKeys, deleteOrganizationKey, syncOrganizationKeys, isOrganizationAccountId, isHealthyOrganizationKeyPair, } from './organizationKeys.js';
|
|
18
20
|
export { SignatureService } from './signatureService.js';
|
|
19
21
|
export { RecoveryPhraseService, BACKUP_KDF_ENCRYPTION_INFO } from './recoveryPhrase.js';
|
|
20
22
|
// Low-level primitives (encrypted backup, device transfer)
|
|
@@ -9,6 +9,8 @@ import { isReactNative, isNodeJS, loadAsyncStorage, loadCommonsIdentityBridge, l
|
|
|
9
9
|
import { deriveSecp256k1PublicKey, generateSecp256k1KeyPair, isValidSecp256k1PrivateKey, isValidSecp256k1PublicKey, normalizeSecp256k1PrivateKey, normalizeSecp256k1PublicKey, signSecp256k1Digest, verifySecp256k1Digest, } from '@oxy.so/protocol/secp256k1';
|
|
10
10
|
import { isDev, logger } from '../logger/index.js';
|
|
11
11
|
import { deriveScopedSeedFromKey, signSocialReceiveDigest } from './identityDerivations.js';
|
|
12
|
+
import { registerIdentitySignerStore } from './identitySigner.js';
|
|
13
|
+
import { getOrganizationPrivateKey, isOrganizationAccountId } from './organizationKeys.js';
|
|
12
14
|
import { clearIdentityMarker, readIdentityMarker, updateIdentityMarker, writeIdentityMarker, } from './identityMarker.js';
|
|
13
15
|
import { DEVICE_BACKUP_VERSION, parseDeviceBackup, serializeDeviceBackup, } from './deviceBackup.js';
|
|
14
16
|
/**
|
|
@@ -257,6 +259,7 @@ export class KeyManager {
|
|
|
257
259
|
*/
|
|
258
260
|
static setIdentitySignerStore(store) {
|
|
259
261
|
KeyManager.identitySignerStore = store;
|
|
262
|
+
registerIdentitySignerStore(store);
|
|
260
263
|
KeyManager.invalidateSharedCache();
|
|
261
264
|
}
|
|
262
265
|
/** Whether this app registered an identity signer store (Commons on Android). */
|
|
@@ -2573,14 +2576,23 @@ export class KeyManager {
|
|
|
2573
2576
|
* Both reproduce from the user's Oxy recovery phrase, so the seed is
|
|
2574
2577
|
* recoverable, and the IPC answer is byte-identical to the local one.
|
|
2575
2578
|
*
|
|
2579
|
+
* With `opts.accountId` the seed is derived from that ORGANIZATION's key
|
|
2580
|
+
* instead (ADR 0061 D6), from the same places: Commons' own copy, the iOS
|
|
2581
|
+
* keychain group, or Commons' identity host. It never falls back to the
|
|
2582
|
+
* personal key.
|
|
2583
|
+
*
|
|
2576
2584
|
* @param info Context/domain-binding label (distinct labels → independent seeds).
|
|
2585
|
+
* @param opts.accountId An organization's account id, for its key.
|
|
2577
2586
|
* @returns 32 bytes of derived keying material, or `null` on web / when no
|
|
2578
2587
|
* identity key is available to this app.
|
|
2579
2588
|
*/
|
|
2580
|
-
static async deriveScopedSeed(info) {
|
|
2589
|
+
static async deriveScopedSeed(info, opts) {
|
|
2581
2590
|
if (isWebPlatform()) {
|
|
2582
2591
|
return null;
|
|
2583
2592
|
}
|
|
2593
|
+
if (opts?.accountId !== undefined) {
|
|
2594
|
+
return KeyManager._deriveOrganizationScopedSeed(info, opts.accountId);
|
|
2595
|
+
}
|
|
2584
2596
|
if (isAndroid()) {
|
|
2585
2597
|
if (KeyManager.identitySignerStore) {
|
|
2586
2598
|
// Commons: its own key.
|
|
@@ -2597,6 +2609,24 @@ export class KeyManager {
|
|
|
2597
2609
|
}
|
|
2598
2610
|
return deriveScopedSeedFromKey(privateKey, info);
|
|
2599
2611
|
}
|
|
2612
|
+
/**
|
|
2613
|
+
* {@link deriveScopedSeed} over an ORGANIZATION key (ADR 0061 D6): Commons'
|
|
2614
|
+
* own copy inside Commons and on iOS the keychain group, everywhere else on
|
|
2615
|
+
* Android Commons' identity host. Never falls back to the personal key: an
|
|
2616
|
+
* organization this device cannot reach is `null`.
|
|
2617
|
+
*/
|
|
2618
|
+
static async _deriveOrganizationScopedSeed(info, accountId) {
|
|
2619
|
+
if (!isOrganizationAccountId(accountId)) {
|
|
2620
|
+
return null;
|
|
2621
|
+
}
|
|
2622
|
+
if (isAndroid() && !KeyManager.identitySignerStore) {
|
|
2623
|
+
const bridge = await loadCommonsIdentityBridge();
|
|
2624
|
+
const seed = (await bridge?.deriveScopedSeed(info, accountId)) ?? null;
|
|
2625
|
+
return seed ? hexToBytes(seed) : null;
|
|
2626
|
+
}
|
|
2627
|
+
const privateKey = await getOrganizationPrivateKey(accountId);
|
|
2628
|
+
return privateKey ? deriveScopedSeedFromKey(privateKey, info) : null;
|
|
2629
|
+
}
|
|
2600
2630
|
/**
|
|
2601
2631
|
* Sign a 32-byte digest (a transaction sighash, lowercase hex) with the
|
|
2602
2632
|
* identity's social-receive child key `index`: the non-hardened BIP32 child
|
|
@@ -2604,16 +2634,30 @@ export class KeyManager {
|
|
|
2604
2634
|
* signature is RFC 6979, low-S, DER hex; `publicKey` is the child's
|
|
2605
2635
|
* compressed public key for the scriptSig.
|
|
2606
2636
|
*
|
|
2607
|
-
* Key source: as {@link deriveScopedSeed}
|
|
2608
|
-
*
|
|
2637
|
+
* Key source: as {@link deriveScopedSeed}, including `opts.accountId` for an
|
|
2638
|
+
* organization's key (ADR 0061 D6). On Android outside Commons the signature
|
|
2639
|
+
* comes from Commons, which allows it for the wallet app only.
|
|
2609
2640
|
*
|
|
2610
2641
|
* @returns the signature, or `null` on web / when no identity key is
|
|
2611
2642
|
* available to this app.
|
|
2612
2643
|
*/
|
|
2613
|
-
static async signSocialReceive(index, digest) {
|
|
2644
|
+
static async signSocialReceive(index, digest, opts) {
|
|
2614
2645
|
if (isWebPlatform()) {
|
|
2615
2646
|
return null;
|
|
2616
2647
|
}
|
|
2648
|
+
const accountId = opts?.accountId;
|
|
2649
|
+
if (accountId !== undefined) {
|
|
2650
|
+
// An organization key (ADR 0061 D6); never the personal one in its place.
|
|
2651
|
+
if (!isOrganizationAccountId(accountId)) {
|
|
2652
|
+
return null;
|
|
2653
|
+
}
|
|
2654
|
+
if (isAndroid() && !KeyManager.identitySignerStore) {
|
|
2655
|
+
const bridge = await loadCommonsIdentityBridge();
|
|
2656
|
+
return (await bridge?.signSocialReceive(index, digest, accountId)) ?? null;
|
|
2657
|
+
}
|
|
2658
|
+
const organizationKey = await getOrganizationPrivateKey(accountId);
|
|
2659
|
+
return organizationKey ? signSocialReceiveDigest(organizationKey, index, digest) : null;
|
|
2660
|
+
}
|
|
2617
2661
|
if (isAndroid() && !KeyManager.identitySignerStore) {
|
|
2618
2662
|
// Every app but Commons: Commons signs.
|
|
2619
2663
|
const bridge = await loadCommonsIdentityBridge();
|
|
@@ -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
|
+
}
|
|
@@ -18,7 +18,7 @@ import type { AccountAccessGrant, OrganizationType } from '@oxy.so/contracts';
|
|
|
18
18
|
* no per-request "acting-as" header. Identity is carried by the session/token,
|
|
19
19
|
* so a switch propagates through reload and cross-domain exactly like a login.
|
|
20
20
|
*/
|
|
21
|
-
import type { AgentKeyOperation, AgentKeyOperationProof } from '@oxy.so/contracts';
|
|
21
|
+
import type { AgentKeyOperation, AgentKeyOperationProof, OrganizationIdentityKeyState, OrganizationKeyApplyRequest, OrganizationKeyChallenge, OrganizationKeyChallengeRequest, OrganizationKeyOperation } from '@oxy.so/contracts';
|
|
22
22
|
import type { User } from '../models/interfaces';
|
|
23
23
|
import type { AccountCategoryId, AccountKind, AvatarCharacter, ChildAccountKind } from '@oxy.so/contracts';
|
|
24
24
|
import type { SessionLoginResponse } from '../models/session';
|
|
@@ -384,6 +384,44 @@ export declare class AccountAgentKeysApi {
|
|
|
384
384
|
revokedMethodIds: string[];
|
|
385
385
|
}>;
|
|
386
386
|
}
|
|
387
|
+
/** A secp256k1 key pair held on this device: lowercase hex, the public key uncompressed. */
|
|
388
|
+
export interface OrganizationKeyPair {
|
|
389
|
+
privateKey: string;
|
|
390
|
+
publicKey: string;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* `oxy.accounts.identityKey` — an organization's identity key (ADR 0061): its
|
|
394
|
+
* DID `#key-1`, the key Peable derives its FairCoin receive addresses from.
|
|
395
|
+
* Only the organization's owners and admins may read or change it; the key is
|
|
396
|
+
* created and held in Commons and never signs anybody in.
|
|
397
|
+
*
|
|
398
|
+
* `get` answers `publicKey: null` for an organization that has no key yet (its
|
|
399
|
+
* DID lists no `#key-1`): that is when a client shows its owners "Set up the
|
|
400
|
+
* organization's Peable wallet". A 403 (`ORGANIZATION_KEY_FORBIDDEN`) means the
|
|
401
|
+
* signed-in person is not an owner or admin of a claimed, active organization.
|
|
402
|
+
*/
|
|
403
|
+
export declare class AccountIdentityKeyApi {
|
|
404
|
+
private readonly ctx;
|
|
405
|
+
constructor(ctx: OxyContext);
|
|
406
|
+
/** The organization's key (`null` without one) and every key it has had, oldest first. */
|
|
407
|
+
get(accountId: string): Promise<OrganizationIdentityKeyState>;
|
|
408
|
+
/** A one-use challenge for one change, with the claims every signer signs. */
|
|
409
|
+
requestChallenge(accountId: string, request: OrganizationKeyChallengeRequest): Promise<OrganizationKeyChallenge>;
|
|
410
|
+
/** Apply a change with its signatures (see {@link link} for the whole ceremony). */
|
|
411
|
+
apply(accountId: string, request: OrganizationKeyApplyRequest): Promise<OrganizationIdentityKeyState>;
|
|
412
|
+
/**
|
|
413
|
+
* Commons' whole ceremony. NATIVE-ONLY: the actor is THIS device's personal
|
|
414
|
+
* root (`KeyManager`), which must be the signed-in person's. `key` is the
|
|
415
|
+
* organization key the device generated (`create`, or `replace` with a new
|
|
416
|
+
* one); `previousKey` is the current organization key when the device holds
|
|
417
|
+
* it, so the replacement is recorded as proven by it.
|
|
418
|
+
*/
|
|
419
|
+
link(accountId: string, input: {
|
|
420
|
+
operation: OrganizationKeyOperation;
|
|
421
|
+
key: OrganizationKeyPair;
|
|
422
|
+
previousKey?: OrganizationKeyPair;
|
|
423
|
+
}): Promise<OrganizationIdentityKeyState>;
|
|
424
|
+
}
|
|
387
425
|
export interface AccountInvitation {
|
|
388
426
|
id: string;
|
|
389
427
|
accountId: string;
|
|
@@ -425,6 +463,8 @@ export declare class AccountsApi {
|
|
|
425
463
|
readonly members: AccountMembersApi;
|
|
426
464
|
readonly invitations: AccountInvitationsApi;
|
|
427
465
|
readonly agentKeys: AccountAgentKeysApi;
|
|
466
|
+
/** An organization's identity key: its DID `#key-1` and Peable wallet key (ADR 0061). */
|
|
467
|
+
readonly identityKey: AccountIdentityKeyApi;
|
|
428
468
|
constructor(ctx: OxyContext);
|
|
429
469
|
/**
|
|
430
470
|
* List the accounts the caller can access: their own personal (root)
|
|
@@ -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';
|