@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
|
@@ -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
|
|
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
|
|
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}
|
|
742
|
-
*
|
|
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
|
|
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.
|
|
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",
|
|
@@ -181,8 +181,8 @@
|
|
|
181
181
|
"dependencies": {
|
|
182
182
|
"@noble/ciphers": "^2.4.0",
|
|
183
183
|
"@noble/hashes": "^2.4.0",
|
|
184
|
-
"@oxy.so/contracts": "^6.
|
|
185
|
-
"@oxy.so/protocol": "^1.
|
|
184
|
+
"@oxy.so/contracts": "^6.11.0",
|
|
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",
|
package/src/api/accounts.ts
CHANGED
|
@@ -18,13 +18,26 @@ 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 {
|
|
21
|
+
import type {
|
|
22
|
+
AgentKeyOperation,
|
|
23
|
+
AgentKeyOperationProof,
|
|
24
|
+
OrganizationIdentityKeyState,
|
|
25
|
+
OrganizationKeyApplyRequest,
|
|
26
|
+
OrganizationKeyChallenge,
|
|
27
|
+
OrganizationKeyChallengeRequest,
|
|
28
|
+
OrganizationKeyOperation,
|
|
29
|
+
} from '@oxy.so/contracts';
|
|
22
30
|
import {
|
|
23
31
|
agentKeyListResponseCheck,
|
|
24
32
|
agentKeyOperationChallengeCheck,
|
|
25
33
|
agentKeyOperationCheck,
|
|
26
34
|
agentKeyOperationResultCheck,
|
|
27
35
|
executeAgentKeyOperationCheck,
|
|
36
|
+
organizationIdentityKeyStateCheck,
|
|
37
|
+
organizationKeyApplyRequestCheck,
|
|
38
|
+
organizationKeyChallengeCheck,
|
|
39
|
+
organizationKeyChallengeRequestCheck,
|
|
40
|
+
organizationKeyProofClaims,
|
|
28
41
|
parseContract,
|
|
29
42
|
} from '@oxy.so/contracts/client';
|
|
30
43
|
import type { User } from '../models/interfaces';
|
|
@@ -457,6 +470,124 @@ export class AccountAgentKeysApi {
|
|
|
457
470
|
}
|
|
458
471
|
}
|
|
459
472
|
|
|
473
|
+
/** A secp256k1 key pair held on this device: lowercase hex, the public key uncompressed. */
|
|
474
|
+
export interface OrganizationKeyPair {
|
|
475
|
+
privateKey: string;
|
|
476
|
+
publicKey: string;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* `oxy.accounts.identityKey` — an organization's identity key (ADR 0061): its
|
|
481
|
+
* DID `#key-1`, the key Peable derives its FairCoin receive addresses from.
|
|
482
|
+
* Only the organization's owners and admins may read or change it; the key is
|
|
483
|
+
* created and held in Commons and never signs anybody in.
|
|
484
|
+
*
|
|
485
|
+
* `get` answers `publicKey: null` for an organization that has no key yet (its
|
|
486
|
+
* DID lists no `#key-1`): that is when a client shows its owners "Set up the
|
|
487
|
+
* organization's Peable wallet". A 403 (`ORGANIZATION_KEY_FORBIDDEN`) means the
|
|
488
|
+
* signed-in person is not an owner or admin of a claimed, active organization.
|
|
489
|
+
*/
|
|
490
|
+
export class AccountIdentityKeyApi {
|
|
491
|
+
constructor(private readonly ctx: OxyContext) {}
|
|
492
|
+
|
|
493
|
+
/** The organization's key (`null` without one) and every key it has had, oldest first. */
|
|
494
|
+
async get(accountId: string): Promise<OrganizationIdentityKeyState> {
|
|
495
|
+
return parseContract(
|
|
496
|
+
organizationIdentityKeyStateCheck,
|
|
497
|
+
await this.ctx.request<unknown>(
|
|
498
|
+
'GET',
|
|
499
|
+
`/accounts/${enc(accountId)}/identity-key`,
|
|
500
|
+
undefined,
|
|
501
|
+
{ cache: false },
|
|
502
|
+
),
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** A one-use challenge for one change, with the claims every signer signs. */
|
|
507
|
+
async requestChallenge(
|
|
508
|
+
accountId: string,
|
|
509
|
+
request: OrganizationKeyChallengeRequest,
|
|
510
|
+
): Promise<OrganizationKeyChallenge> {
|
|
511
|
+
return parseContract(
|
|
512
|
+
organizationKeyChallengeCheck,
|
|
513
|
+
await this.ctx.request<unknown>(
|
|
514
|
+
'POST',
|
|
515
|
+
`/accounts/${enc(accountId)}/identity-key/challenge`,
|
|
516
|
+
parseContract(organizationKeyChallengeRequestCheck, request),
|
|
517
|
+
{ cache: false },
|
|
518
|
+
),
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Apply a change with its signatures (see {@link link} for the whole ceremony). */
|
|
523
|
+
async apply(
|
|
524
|
+
accountId: string,
|
|
525
|
+
request: OrganizationKeyApplyRequest,
|
|
526
|
+
): Promise<OrganizationIdentityKeyState> {
|
|
527
|
+
return parseContract(
|
|
528
|
+
organizationIdentityKeyStateCheck,
|
|
529
|
+
await this.ctx.request<unknown>(
|
|
530
|
+
'POST',
|
|
531
|
+
`/accounts/${enc(accountId)}/identity-key`,
|
|
532
|
+
parseContract(organizationKeyApplyRequestCheck, request),
|
|
533
|
+
{ cache: false },
|
|
534
|
+
),
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Commons' whole ceremony. NATIVE-ONLY: the actor is THIS device's personal
|
|
540
|
+
* root (`KeyManager`), which must be the signed-in person's. `key` is the
|
|
541
|
+
* organization key the device generated (`create`, or `replace` with a new
|
|
542
|
+
* one); `previousKey` is the current organization key when the device holds
|
|
543
|
+
* it, so the replacement is recorded as proven by it.
|
|
544
|
+
*/
|
|
545
|
+
async link(
|
|
546
|
+
accountId: string,
|
|
547
|
+
input: {
|
|
548
|
+
operation: OrganizationKeyOperation;
|
|
549
|
+
key: OrganizationKeyPair;
|
|
550
|
+
previousKey?: OrganizationKeyPair;
|
|
551
|
+
},
|
|
552
|
+
): Promise<OrganizationIdentityKeyState> {
|
|
553
|
+
const { KeyManager, signIdentityProof } = await import('../crypto/index');
|
|
554
|
+
const [rootPrivateKey, rootPublicKey] = await Promise.all([
|
|
555
|
+
KeyManager.getPrivateKey(),
|
|
556
|
+
KeyManager.getPublicKey(),
|
|
557
|
+
]);
|
|
558
|
+
if (!rootPrivateKey || !rootPublicKey) {
|
|
559
|
+
throw new Error('No identity on this device to sign with');
|
|
560
|
+
}
|
|
561
|
+
const challenge = await this.requestChallenge(accountId, {
|
|
562
|
+
operation: input.operation,
|
|
563
|
+
publicKey: input.key.publicKey,
|
|
564
|
+
});
|
|
565
|
+
const root = rootPublicKey.trim().toLowerCase();
|
|
566
|
+
if (challenge.actorPublicKey !== root) {
|
|
567
|
+
throw new Error("This device's identity is not the signed-in account's");
|
|
568
|
+
}
|
|
569
|
+
const sign = async (signer: OrganizationKeyPair, publicKey: string) =>
|
|
570
|
+
(
|
|
571
|
+
await signIdentityProof(
|
|
572
|
+
{ privateKey: signer.privateKey, publicKey },
|
|
573
|
+
organizationKeyProofClaims(challenge, publicKey),
|
|
574
|
+
)
|
|
575
|
+
).signature;
|
|
576
|
+
const previousPublicKey = challenge.previousPublicKey;
|
|
577
|
+
return this.apply(accountId, {
|
|
578
|
+
operation: input.operation,
|
|
579
|
+
publicKey: challenge.publicKey,
|
|
580
|
+
challenge: challenge.challenge,
|
|
581
|
+
expiresAt: challenge.expiresAt,
|
|
582
|
+
actorSignature: await sign({ privateKey: rootPrivateKey, publicKey: root }, root),
|
|
583
|
+
keySignature: await sign(input.key, challenge.publicKey),
|
|
584
|
+
...(input.previousKey && previousPublicKey
|
|
585
|
+
? { previousKeySignature: await sign(input.previousKey, previousPublicKey) }
|
|
586
|
+
: {}),
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
460
591
|
export interface AccountInvitation {
|
|
461
592
|
id: string;
|
|
462
593
|
accountId: string;
|
|
@@ -540,11 +671,14 @@ export class AccountsApi {
|
|
|
540
671
|
readonly members: AccountMembersApi;
|
|
541
672
|
readonly invitations: AccountInvitationsApi;
|
|
542
673
|
readonly agentKeys: AccountAgentKeysApi;
|
|
674
|
+
/** An organization's identity key: its DID `#key-1` and Peable wallet key (ADR 0061). */
|
|
675
|
+
readonly identityKey: AccountIdentityKeyApi;
|
|
543
676
|
|
|
544
677
|
constructor(private readonly ctx: OxyContext) {
|
|
545
678
|
this.members = new AccountMembersApi(ctx);
|
|
546
679
|
this.invitations = new AccountInvitationsApi(ctx);
|
|
547
680
|
this.agentKeys = new AccountAgentKeysApi(ctx);
|
|
681
|
+
this.identityKey = new AccountIdentityKeyApi(ctx);
|
|
548
682
|
}
|
|
549
683
|
|
|
550
684
|
/**
|
|
@@ -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
|
}
|
package/src/crypto/index.ts
CHANGED
|
@@ -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';
|
package/src/crypto/keyManager.ts
CHANGED
|
@@ -30,7 +30,8 @@ import {
|
|
|
30
30
|
} from '@oxy.so/protocol/secp256k1';
|
|
31
31
|
import { isDev, logger } from '../logger';
|
|
32
32
|
import { deriveScopedSeedFromKey, signSocialReceiveDigest } from './identityDerivations';
|
|
33
|
-
import type
|
|
33
|
+
import { type IdentitySignerStore, registerIdentitySignerStore } from './identitySigner';
|
|
34
|
+
import { getOrganizationPrivateKey, isOrganizationAccountId } from './organizationKeys';
|
|
34
35
|
import {
|
|
35
36
|
type IdentityMarker,
|
|
36
37
|
clearIdentityMarker,
|
|
@@ -457,6 +458,7 @@ export class KeyManager {
|
|
|
457
458
|
*/
|
|
458
459
|
static setIdentitySignerStore(store: IdentitySignerStore | null): void {
|
|
459
460
|
KeyManager.identitySignerStore = store;
|
|
461
|
+
registerIdentitySignerStore(store);
|
|
460
462
|
KeyManager.invalidateSharedCache();
|
|
461
463
|
}
|
|
462
464
|
|
|
@@ -3237,14 +3239,26 @@ export class KeyManager {
|
|
|
3237
3239
|
* Both reproduce from the user's Oxy recovery phrase, so the seed is
|
|
3238
3240
|
* recoverable, and the IPC answer is byte-identical to the local one.
|
|
3239
3241
|
*
|
|
3242
|
+
* With `opts.accountId` the seed is derived from that ORGANIZATION's key
|
|
3243
|
+
* instead (ADR 0061 D6), from the same places: Commons' own copy, the iOS
|
|
3244
|
+
* keychain group, or Commons' identity host. It never falls back to the
|
|
3245
|
+
* personal key.
|
|
3246
|
+
*
|
|
3240
3247
|
* @param info Context/domain-binding label (distinct labels → independent seeds).
|
|
3248
|
+
* @param opts.accountId An organization's account id, for its key.
|
|
3241
3249
|
* @returns 32 bytes of derived keying material, or `null` on web / when no
|
|
3242
3250
|
* identity key is available to this app.
|
|
3243
3251
|
*/
|
|
3244
|
-
static async deriveScopedSeed(
|
|
3252
|
+
static async deriveScopedSeed(
|
|
3253
|
+
info: string,
|
|
3254
|
+
opts?: { accountId?: string },
|
|
3255
|
+
): Promise<Uint8Array | null> {
|
|
3245
3256
|
if (isWebPlatform()) {
|
|
3246
3257
|
return null;
|
|
3247
3258
|
}
|
|
3259
|
+
if (opts?.accountId !== undefined) {
|
|
3260
|
+
return KeyManager._deriveOrganizationScopedSeed(info, opts.accountId);
|
|
3261
|
+
}
|
|
3248
3262
|
if (isAndroid()) {
|
|
3249
3263
|
if (KeyManager.identitySignerStore) {
|
|
3250
3264
|
// Commons: its own key.
|
|
@@ -3263,6 +3277,28 @@ export class KeyManager {
|
|
|
3263
3277
|
return deriveScopedSeedFromKey(privateKey, info);
|
|
3264
3278
|
}
|
|
3265
3279
|
|
|
3280
|
+
/**
|
|
3281
|
+
* {@link deriveScopedSeed} over an ORGANIZATION key (ADR 0061 D6): Commons'
|
|
3282
|
+
* own copy inside Commons and on iOS the keychain group, everywhere else on
|
|
3283
|
+
* Android Commons' identity host. Never falls back to the personal key: an
|
|
3284
|
+
* organization this device cannot reach is `null`.
|
|
3285
|
+
*/
|
|
3286
|
+
private static async _deriveOrganizationScopedSeed(
|
|
3287
|
+
info: string,
|
|
3288
|
+
accountId: string,
|
|
3289
|
+
): Promise<Uint8Array | null> {
|
|
3290
|
+
if (!isOrganizationAccountId(accountId)) {
|
|
3291
|
+
return null;
|
|
3292
|
+
}
|
|
3293
|
+
if (isAndroid() && !KeyManager.identitySignerStore) {
|
|
3294
|
+
const bridge = await loadCommonsIdentityBridge();
|
|
3295
|
+
const seed = (await bridge?.deriveScopedSeed(info, accountId)) ?? null;
|
|
3296
|
+
return seed ? hexToBytes(seed) : null;
|
|
3297
|
+
}
|
|
3298
|
+
const privateKey = await getOrganizationPrivateKey(accountId);
|
|
3299
|
+
return privateKey ? deriveScopedSeedFromKey(privateKey, info) : null;
|
|
3300
|
+
}
|
|
3301
|
+
|
|
3266
3302
|
/**
|
|
3267
3303
|
* Sign a 32-byte digest (a transaction sighash, lowercase hex) with the
|
|
3268
3304
|
* identity's social-receive child key `index`: the non-hardened BIP32 child
|
|
@@ -3270,8 +3306,9 @@ export class KeyManager {
|
|
|
3270
3306
|
* signature is RFC 6979, low-S, DER hex; `publicKey` is the child's
|
|
3271
3307
|
* compressed public key for the scriptSig.
|
|
3272
3308
|
*
|
|
3273
|
-
* Key source: as {@link deriveScopedSeed}
|
|
3274
|
-
*
|
|
3309
|
+
* Key source: as {@link deriveScopedSeed}, including `opts.accountId` for an
|
|
3310
|
+
* organization's key (ADR 0061 D6). On Android outside Commons the signature
|
|
3311
|
+
* comes from Commons, which allows it for the wallet app only.
|
|
3275
3312
|
*
|
|
3276
3313
|
* @returns the signature, or `null` on web / when no identity key is
|
|
3277
3314
|
* available to this app.
|
|
@@ -3279,10 +3316,24 @@ export class KeyManager {
|
|
|
3279
3316
|
static async signSocialReceive(
|
|
3280
3317
|
index: number,
|
|
3281
3318
|
digest: string,
|
|
3319
|
+
opts?: { accountId?: string },
|
|
3282
3320
|
): Promise<CommonsSocialReceiveSignature | null> {
|
|
3283
3321
|
if (isWebPlatform()) {
|
|
3284
3322
|
return null;
|
|
3285
3323
|
}
|
|
3324
|
+
const accountId = opts?.accountId;
|
|
3325
|
+
if (accountId !== undefined) {
|
|
3326
|
+
// An organization key (ADR 0061 D6); never the personal one in its place.
|
|
3327
|
+
if (!isOrganizationAccountId(accountId)) {
|
|
3328
|
+
return null;
|
|
3329
|
+
}
|
|
3330
|
+
if (isAndroid() && !KeyManager.identitySignerStore) {
|
|
3331
|
+
const bridge = await loadCommonsIdentityBridge();
|
|
3332
|
+
return (await bridge?.signSocialReceive(index, digest, accountId)) ?? null;
|
|
3333
|
+
}
|
|
3334
|
+
const organizationKey = await getOrganizationPrivateKey(accountId);
|
|
3335
|
+
return organizationKey ? signSocialReceiveDigest(organizationKey, index, digest) : null;
|
|
3336
|
+
}
|
|
3286
3337
|
if (isAndroid() && !KeyManager.identitySignerStore) {
|
|
3287
3338
|
// Every app but Commons: Commons signs.
|
|
3288
3339
|
const bridge = await loadCommonsIdentityBridge();
|