@oxy.so/core 7.32.0 → 7.33.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/identity.js +11 -3
- package/dist/cjs/crypto/index.js +6 -2
- package/dist/cjs/crypto/keyManager.js +108 -10
- package/dist/cjs/crypto/retiredKeys.js +246 -57
- package/dist/esm/api/identity.js +11 -3
- package/dist/esm/crypto/index.js +1 -1
- package/dist/esm/crypto/keyManager.js +109 -11
- package/dist/esm/crypto/retiredKeys.js +241 -57
- package/dist/types/crypto/index.d.ts +1 -1
- package/dist/types/crypto/keyManager.d.ts +45 -1
- package/dist/types/crypto/retiredKeys.d.ts +86 -21
- package/package.json +1 -1
- package/src/api/identity.ts +15 -3
- package/src/crypto/index.ts +4 -0
- package/src/crypto/keyManager.ts +123 -16
- package/src/crypto/retiredKeys.ts +293 -59
|
@@ -290,9 +290,46 @@ export declare class KeyManager {
|
|
|
290
290
|
* `signSocialReceive`) is fresh, so a cached key here would let a caller
|
|
291
291
|
* compare a new seed against an old key.
|
|
292
292
|
*
|
|
293
|
+
* iOS, in an app that holds the private key (Commons, the Peable wallet:
|
|
294
|
+
* keychain roles `holder` and `wallet`): DERIVED from the private key in the
|
|
295
|
+
* wallet group, never read from the public-key item, which sits in the shared
|
|
296
|
+
* group every Oxy app can write (R5-1); `null` when that app holds no private
|
|
297
|
+
* key. Every other iOS app reads the public-key item: it holds nothing it
|
|
298
|
+
* could derive from, and it never spends. Money code uses
|
|
299
|
+
* {@link getIdentityPublicKeyForMoney}.
|
|
300
|
+
*
|
|
293
301
|
* @returns Shared public key or null if no shared identity exists
|
|
294
302
|
*/
|
|
295
303
|
static getSharedPublicKey(): Promise<string | null>;
|
|
304
|
+
/**
|
|
305
|
+
* Whether this iOS app holds spending keys (keychain role `holder` or
|
|
306
|
+
* `wallet`), so a public key it uses for money must be derived from them.
|
|
307
|
+
* Throws while the keychain cannot say. @internal
|
|
308
|
+
*/
|
|
309
|
+
private static _holdsSpendingKeys;
|
|
310
|
+
/**
|
|
311
|
+
* THE public key of the identity a money holder (the Peable wallet, Commons)
|
|
312
|
+
* receives and spends with: the personal key, or with `opts.accountId` that
|
|
313
|
+
* organization's (ADR 0061 D6). It is the public half of exactly the private
|
|
314
|
+
* key {@link deriveScopedSeed} and {@link signSocialReceive} use, so an
|
|
315
|
+
* address shown from it is one this app can spend (R5-1).
|
|
316
|
+
*
|
|
317
|
+
* - iOS (Commons, the Peable wallet): DERIVED from the private key in the
|
|
318
|
+
* restricted keychain groups (personal: the wallet group's slot, then
|
|
319
|
+
* Commons' primary; organization: its pair). Never the shared group's
|
|
320
|
+
* public-key item, which every Oxy app can write. `null` when no private
|
|
321
|
+
* key is held, and in every other iOS app.
|
|
322
|
+
* - Android: inside Commons derived from its own key; elsewhere Commons'
|
|
323
|
+
* identity host (`describe`), which answers from Commons' own signer
|
|
324
|
+
* store over signature-protected IPC.
|
|
325
|
+
* - Web: `null`.
|
|
326
|
+
*
|
|
327
|
+
* Compare the answer with the account's published `#key-1` before showing a
|
|
328
|
+
* receive address (Peable ADR 0002). Lowercase uncompressed (`04…`).
|
|
329
|
+
*/
|
|
330
|
+
static getIdentityPublicKeyForMoney(opts?: {
|
|
331
|
+
accountId?: string;
|
|
332
|
+
}): Promise<string | null>;
|
|
296
333
|
/**
|
|
297
334
|
* The shared private key, where this app may hold it: iOS only.
|
|
298
335
|
*
|
|
@@ -743,17 +780,23 @@ export declare class KeyManager {
|
|
|
743
780
|
* (personal, or `accountId`'s) whose public key it names: the current key
|
|
744
781
|
* when it matches, else a RETIRED key Commons kept after a rotation or
|
|
745
782
|
* replacement (`./retiredKeys`, ADR 0061 D7 as amended). Spending only: a
|
|
746
|
-
* retired key never signs in. Never falls back to another key.
|
|
783
|
+
* retired key never signs in. Never falls back to another key. A PERSONAL
|
|
784
|
+
* retired key is reached only with `opts.ownerAccountId`, the signed-in
|
|
785
|
+
* person's account id, and only when the ring keeps it for that account
|
|
786
|
+
* (R5-2); on Android outside Commons the identity host holds only the
|
|
787
|
+
* personal keys of the person Commons is signed in as.
|
|
747
788
|
*
|
|
748
789
|
* @param info Context/domain-binding label (distinct labels → independent seeds).
|
|
749
790
|
* @param opts.accountId An organization's account id, for its key.
|
|
750
791
|
* @param opts.publicKey Selects one key of that account by its public key.
|
|
792
|
+
* @param opts.ownerAccountId The person's account id, for a personal retired key.
|
|
751
793
|
* @returns 32 bytes of derived keying material, or `null` on web / when no
|
|
752
794
|
* identity key is available to this app.
|
|
753
795
|
*/
|
|
754
796
|
static deriveScopedSeed(info: string, opts?: {
|
|
755
797
|
accountId?: string;
|
|
756
798
|
publicKey?: string;
|
|
799
|
+
ownerAccountId?: string;
|
|
757
800
|
}): Promise<Uint8Array | null>;
|
|
758
801
|
/**
|
|
759
802
|
* {@link deriveScopedSeed} over an ORGANIZATION key (ADR 0061 D6): Commons'
|
|
@@ -780,6 +823,7 @@ export declare class KeyManager {
|
|
|
780
823
|
static signSocialReceive(index: number, digest: string, opts?: {
|
|
781
824
|
accountId?: string;
|
|
782
825
|
publicKey?: string;
|
|
826
|
+
ownerAccountId?: string;
|
|
783
827
|
}): Promise<CommonsSocialReceiveSignature | null>;
|
|
784
828
|
/**
|
|
785
829
|
* The private key of the account (personal, or `accountId`'s organization)
|
|
@@ -41,10 +41,32 @@
|
|
|
41
41
|
*
|
|
42
42
|
* This module imports nothing from `./keyManager` (which imports it).
|
|
43
43
|
*/
|
|
44
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* Whose ring: the person's (`accountId` absent) or an organization's.
|
|
46
|
+
*
|
|
47
|
+
* The personal ring is BOUND TO ITS OWNER (R5-2): every personal entry records
|
|
48
|
+
* the Oxy account id of the person whose key it was, and a personal read
|
|
49
|
+
* reaches only the entries of `ownerAccountId`. A phone outlives the person on
|
|
50
|
+
* it ("Start over" keeps the ring: it is money, recoverable from that account's
|
|
51
|
+
* backup), so the next person on the phone must never list, derive from or
|
|
52
|
+
* sign with a previous person's keys. A personal read without `ownerAccountId`
|
|
53
|
+
* reaches nothing. An entry whose owner is not known (a key the access-group
|
|
54
|
+
* migration kept, an entry written before 7.33.0) is QUARANTINED: invisible to
|
|
55
|
+
* every reader until Commons binds it with {@link claimRetiredIdentityKeys},
|
|
56
|
+
* which requires the account's key history to name it.
|
|
57
|
+
*/
|
|
45
58
|
export interface RetiredKeySelector {
|
|
46
59
|
/** An organization's account id; absent for the personal ring. */
|
|
47
60
|
accountId?: string;
|
|
61
|
+
/**
|
|
62
|
+
* The person's Oxy account id, for the PERSONAL ring: whose entries are
|
|
63
|
+
* reached. Ignored for an organization's ring (its owner is `accountId`).
|
|
64
|
+
*/
|
|
65
|
+
ownerAccountId?: string;
|
|
66
|
+
}
|
|
67
|
+
/** Thrown when the ring's index cannot be read as the ring: never treated as empty (R5-9). */
|
|
68
|
+
export declare class RetiredKeyRingUnreadableError extends Error {
|
|
69
|
+
constructor(reason: string);
|
|
48
70
|
}
|
|
49
71
|
/** One retired key as the ring lists it: public data only. */
|
|
50
72
|
export interface RetiredIdentityKey {
|
|
@@ -63,25 +85,53 @@ export interface RetiredKeyPair {
|
|
|
63
85
|
/** The ring a selector names: `personal`, or the organization's account id. */
|
|
64
86
|
export declare function retiredKeyScope(selector?: RetiredKeySelector): string | null;
|
|
65
87
|
/**
|
|
66
|
-
* Keep `key` in the ring of `selector`'s account. Commons only.
|
|
67
|
-
*
|
|
68
|
-
*
|
|
88
|
+
* Keep `key` in the ring of `selector`'s account. Commons only. A PERSONAL key
|
|
89
|
+
* needs `selector.ownerAccountId`, the person whose key it was (R5-2): the
|
|
90
|
+
* signed-in account at a rotation, the account whose key history or backup
|
|
91
|
+
* named it. Idempotent: a key already kept stays, with its first date; a
|
|
92
|
+
* quarantined entry of the same key is bound to that owner; an entry bound to
|
|
93
|
+
* a DIFFERENT owner is refused. Refuses a pair whose public key does not
|
|
94
|
+
* derive from its private key. Resolves once every copy confirmed it
|
|
69
95
|
* (Android: the signer store's read-back too), and throws otherwise, so a
|
|
70
96
|
* caller about to replace the key can stop before anything is lost.
|
|
71
97
|
*/
|
|
72
98
|
export declare function retireIdentityKey(key: RetiredKeyPair, selector?: RetiredKeySelector): Promise<void>;
|
|
73
99
|
/**
|
|
74
100
|
* {@link retireIdentityKey} from inside the iOS access-group migration, which
|
|
75
|
-
* keeps a
|
|
76
|
-
*
|
|
101
|
+
* keeps a pre-split copy before deleting it from the shared group and cannot
|
|
102
|
+
* await itself. A personal key without `ownerAccountId` is kept QUARANTINED:
|
|
103
|
+
* held (it may be money) but reached by nobody until
|
|
104
|
+
* {@link claimRetiredIdentityKeys} binds it. @internal
|
|
77
105
|
*/
|
|
78
106
|
export declare function retireIdentityKeyDuringMigration(key: RetiredKeyPair, selector?: RetiredKeySelector): Promise<void>;
|
|
107
|
+
/**
|
|
108
|
+
* Bind the QUARANTINED personal entries whose public key `publicKeys` names to
|
|
109
|
+
* `ownerAccountId` (R5-2, R5-3). Commons only, with the signed-in person's
|
|
110
|
+
* account id and that account's key history from Oxy
|
|
111
|
+
* (`oxy.identity.keyHistory()`: retired and current public keys): a key the
|
|
112
|
+
* account's own history names is that account's. Entries bound to anyone, and
|
|
113
|
+
* keys the history does not name, are left as they are. Resolves how many it
|
|
114
|
+
* bound; call {@link syncRetiredKeys} after it on Android.
|
|
115
|
+
*/
|
|
116
|
+
export declare function claimRetiredIdentityKeys(ownerAccountId: string, publicKeys: readonly string[]): Promise<number>;
|
|
117
|
+
/**
|
|
118
|
+
* How many personal entries are quarantined (no known owner). Commons shows
|
|
119
|
+
* them as "keys from before" the person can claim by adding the phrase, and
|
|
120
|
+
* {@link claimRetiredIdentityKeys} binds the ones the account's history names.
|
|
121
|
+
*/
|
|
122
|
+
export declare function countQuarantinedRetiredKeys(): Promise<number>;
|
|
79
123
|
/**
|
|
80
124
|
* The retired keys of `selector`'s account this app can reach, oldest first.
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
125
|
+
* The personal ring answers only `selector.ownerAccountId`'s entries (none
|
|
126
|
+
* without it; R5-2). iOS and Commons: the ring's index. Android elsewhere:
|
|
127
|
+
* Commons' identity host (the wallet only), which holds only the personal
|
|
128
|
+
* entries of the person Commons is signed in as and leaves out the scope's
|
|
129
|
+
* current key. After an interrupted rotation the local list can include the
|
|
130
|
+
* current key: compare with the DID's `#key-1` before treating an entry as
|
|
131
|
+
* retired, and check every entry against the account's key history.
|
|
132
|
+
*
|
|
133
|
+
* STRICT (R5-6): a ring that cannot be read throws (the keychain's error, or
|
|
134
|
+
* {@link RetiredKeyRingUnreadableError}); it never reads as empty.
|
|
85
135
|
*/
|
|
86
136
|
export declare function listRetiredIdentityKeys(selector?: RetiredKeySelector): Promise<RetiredIdentityKey[]>;
|
|
87
137
|
/**
|
|
@@ -108,23 +158,38 @@ export declare function forgetRetiredIdentityKey(publicKey: string, selector?: R
|
|
|
108
158
|
*/
|
|
109
159
|
export declare function forgetRetiredIdentityKeys(selector?: RetiredKeySelector): Promise<number>;
|
|
110
160
|
/**
|
|
111
|
-
* Commons on Android:
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
161
|
+
* Commons on Android: make the identity signer store hold exactly what the
|
|
162
|
+
* identity host may answer — every organization ring, and the personal entries
|
|
163
|
+
* of `ownerAccountId` (the person Commons is signed in as) — so the host
|
|
164
|
+
* answers for them after that file was rebuilt (its self-heal leaves it
|
|
165
|
+
* empty), and never for a previous person's keys (R5-2): every other personal
|
|
166
|
+
* entry is removed from the signer store (its own slot keeps it). Without
|
|
167
|
+
* `ownerAccountId` no personal entry is mirrored. Call on every boot and after
|
|
168
|
+
* every sign-in, beside `syncOrganizationKeys`. Resolves how many it mirrored.
|
|
169
|
+
* Elsewhere 0.
|
|
115
170
|
*/
|
|
116
|
-
export declare function syncRetiredKeys(): Promise<number>;
|
|
171
|
+
export declare function syncRetiredKeys(ownerAccountId?: string): Promise<number>;
|
|
117
172
|
/**
|
|
118
|
-
* Every retired key of `selector`'s ring
|
|
119
|
-
*
|
|
120
|
-
* under the CURRENT phrase
|
|
121
|
-
* phrase does (R4-4). Commons
|
|
122
|
-
*
|
|
123
|
-
*
|
|
173
|
+
* Every retired key of `selector`'s ring (personal: `ownerAccountId`'s entries)
|
|
174
|
+
* WITH its private half, for the encrypted identity backup
|
|
175
|
+
* (`oxy.identity.backup.create`), which carries them under the CURRENT phrase
|
|
176
|
+
* so a lost phone costs no more than the current phrase does (R4-4). Commons
|
|
177
|
+
* only. STRICT (R5-6): an unreadable ring, or a listed entry whose private
|
|
178
|
+
* half is missing or does not derive its public key, THROWS — a backup is
|
|
179
|
+
* never sealed without a key the ring says it holds. Never log or transmit the
|
|
180
|
+
* result except inside that encrypted envelope.
|
|
124
181
|
*/
|
|
125
182
|
export declare function exportRetiredIdentityKeys(selector?: RetiredKeySelector): Promise<Array<RetiredKeyPair & {
|
|
126
183
|
retiredAt: number;
|
|
127
184
|
}>>;
|
|
185
|
+
/**
|
|
186
|
+
* Commons on Android, when the identity leaves this device ("Start over", a
|
|
187
|
+
* forced delete): remove every PERSONAL entry from the identity signer store,
|
|
188
|
+
* so the identity host answers no other app with them before the next person
|
|
189
|
+
* signs in (R5-2). Commons' own slot and index keep them (money, bound to
|
|
190
|
+
* their owner). Elsewhere a no-op.
|
|
191
|
+
*/
|
|
192
|
+
export declare function unmirrorPersonalRetiredKeys(): Promise<void>;
|
|
128
193
|
/**
|
|
129
194
|
* The pair a previous key's raw PRIVATE KEY gives (64 hex, an optional `0x`),
|
|
130
195
|
* for a person whose old identity was imported as a private key and so has no
|
package/package.json
CHANGED
package/src/api/identity.ts
CHANGED
|
@@ -337,7 +337,10 @@ export class IdentityApi {
|
|
|
337
337
|
if (!isWeb()) {
|
|
338
338
|
try {
|
|
339
339
|
if (!oldPrivateKeyForRing) throw new Error('no readable private key');
|
|
340
|
-
await retireIdentityKey(
|
|
340
|
+
await retireIdentityKey(
|
|
341
|
+
{ privateKey: oldPrivateKeyForRing, publicKey: oldPublicKey },
|
|
342
|
+
{ ownerAccountId: userId },
|
|
343
|
+
);
|
|
341
344
|
} catch (retainError) {
|
|
342
345
|
logger.warn(
|
|
343
346
|
'Could not keep the current key as a retired key; not rotating.',
|
|
@@ -896,9 +899,11 @@ export class IdentityBackupApi {
|
|
|
896
899
|
private async retiredKeysToCarry(currentPublicKey: string): Promise<BackupRetiredKey[]> {
|
|
897
900
|
if (isWeb()) return [];
|
|
898
901
|
const { exportRetiredIdentityKeys } = await import('../crypto/index');
|
|
902
|
+
const userId = this.ctx.oxy.session.userId;
|
|
903
|
+
if (!userId) throw new Error('No authenticated user — sign in before backing up.');
|
|
899
904
|
let held: BackupRetiredKey[];
|
|
900
905
|
try {
|
|
901
|
-
held = await exportRetiredIdentityKeys();
|
|
906
|
+
held = await exportRetiredIdentityKeys({ ownerAccountId: userId });
|
|
902
907
|
} catch {
|
|
903
908
|
return []; // Not Commons: this app holds no ring to carry.
|
|
904
909
|
}
|
|
@@ -966,6 +971,10 @@ export class IdentityBackupApi {
|
|
|
966
971
|
*/
|
|
967
972
|
private async keepCarriedRetiredKeys(payload: BackupPayload): Promise<number> {
|
|
968
973
|
const { KeyManager, retireIdentityKey } = await import('../crypto/index');
|
|
974
|
+
// The backup is the signed-in account's (its phrase opened it): its keys
|
|
975
|
+
// are kept for that account only (R5-2).
|
|
976
|
+
const ownerAccountId = this.ctx.oxy.session.userId;
|
|
977
|
+
if (!ownerAccountId) throw new Error('No authenticated user — sign in before restoring.');
|
|
969
978
|
let kept = 0;
|
|
970
979
|
for (const key of Array.isArray(payload.retiredKeys) ? payload.retiredKeys : []) {
|
|
971
980
|
try {
|
|
@@ -976,7 +985,10 @@ export class IdentityBackupApi {
|
|
|
976
985
|
) {
|
|
977
986
|
continue;
|
|
978
987
|
}
|
|
979
|
-
await retireIdentityKey(
|
|
988
|
+
await retireIdentityKey(
|
|
989
|
+
{ privateKey: key.privateKey, publicKey: key.publicKey },
|
|
990
|
+
{ ownerAccountId },
|
|
991
|
+
);
|
|
980
992
|
kept += 1;
|
|
981
993
|
} catch (error) {
|
|
982
994
|
logger.warn(
|
package/src/crypto/index.ts
CHANGED
|
@@ -92,6 +92,10 @@ export {
|
|
|
92
92
|
retiredKeyFromPhrase,
|
|
93
93
|
retiredKeyFromPrivateKey,
|
|
94
94
|
exportRetiredIdentityKeys,
|
|
95
|
+
claimRetiredIdentityKeys,
|
|
96
|
+
countQuarantinedRetiredKeys,
|
|
97
|
+
unmirrorPersonalRetiredKeys,
|
|
98
|
+
RetiredKeyRingUnreadableError,
|
|
95
99
|
} from './retiredKeys';
|
|
96
100
|
export type { RetiredIdentityKey, RetiredKeyPair, RetiredKeySelector } from './retiredKeys';
|
|
97
101
|
export { SignatureService } from './signatureService';
|
package/src/crypto/keyManager.ts
CHANGED
|
@@ -32,7 +32,7 @@ import { isDev, logger } from '../logger';
|
|
|
32
32
|
import { deriveScopedSeedFromKey, signSocialReceiveDigest } from './identityDerivations';
|
|
33
33
|
import { type IdentitySignerStore, registerIdentitySignerStore } from './identitySigner';
|
|
34
34
|
import { getOrganizationPrivateKey, isOrganizationAccountId } from './organizationKeys';
|
|
35
|
-
import { getRetiredPrivateKey, retiredKeyScope } from './retiredKeys';
|
|
35
|
+
import { getRetiredPrivateKey, retiredKeyScope, unmirrorPersonalRetiredKeys } from './retiredKeys';
|
|
36
36
|
import {
|
|
37
37
|
type KeychainItemClass,
|
|
38
38
|
groupedKeychainStore,
|
|
@@ -1070,6 +1070,14 @@ export class KeyManager {
|
|
|
1070
1070
|
* `signSocialReceive`) is fresh, so a cached key here would let a caller
|
|
1071
1071
|
* compare a new seed against an old key.
|
|
1072
1072
|
*
|
|
1073
|
+
* iOS, in an app that holds the private key (Commons, the Peable wallet:
|
|
1074
|
+
* keychain roles `holder` and `wallet`): DERIVED from the private key in the
|
|
1075
|
+
* wallet group, never read from the public-key item, which sits in the shared
|
|
1076
|
+
* group every Oxy app can write (R5-1); `null` when that app holds no private
|
|
1077
|
+
* key. Every other iOS app reads the public-key item: it holds nothing it
|
|
1078
|
+
* could derive from, and it never spends. Money code uses
|
|
1079
|
+
* {@link getIdentityPublicKeyForMoney}.
|
|
1080
|
+
*
|
|
1073
1081
|
* @returns Shared public key or null if no shared identity exists
|
|
1074
1082
|
*/
|
|
1075
1083
|
static async getSharedPublicKey(): Promise<string | null> {
|
|
@@ -1077,6 +1085,16 @@ export class KeyManager {
|
|
|
1077
1085
|
return null;
|
|
1078
1086
|
}
|
|
1079
1087
|
|
|
1088
|
+
if (isIOS() && (await KeyManager._holdsSpendingKeys())) {
|
|
1089
|
+
// Not cached: a rotation in Commons changes it under the wallet.
|
|
1090
|
+
try {
|
|
1091
|
+
const privateKey = await KeyManager.getSharedPrivateKey();
|
|
1092
|
+
return privateKey ? KeyManager.derivePublicKey(privateKey) : null;
|
|
1093
|
+
} catch {
|
|
1094
|
+
return null;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1080
1098
|
// Return cached value if available (iOS only; see above).
|
|
1081
1099
|
if (!isAndroid() && KeyManager.cachedSharedPublicKey !== null) {
|
|
1082
1100
|
return KeyManager.cachedSharedPublicKey;
|
|
@@ -1111,6 +1129,70 @@ export class KeyManager {
|
|
|
1111
1129
|
}
|
|
1112
1130
|
}
|
|
1113
1131
|
|
|
1132
|
+
/**
|
|
1133
|
+
* Whether this iOS app holds spending keys (keychain role `holder` or
|
|
1134
|
+
* `wallet`), so a public key it uses for money must be derived from them.
|
|
1135
|
+
* Throws while the keychain cannot say. @internal
|
|
1136
|
+
*/
|
|
1137
|
+
private static async _holdsSpendingKeys(): Promise<boolean> {
|
|
1138
|
+
const access = await resolveKeychainAccess();
|
|
1139
|
+
return access.mode === 'grouped' && access.role !== 'member';
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/**
|
|
1143
|
+
* THE public key of the identity a money holder (the Peable wallet, Commons)
|
|
1144
|
+
* receives and spends with: the personal key, or with `opts.accountId` that
|
|
1145
|
+
* organization's (ADR 0061 D6). It is the public half of exactly the private
|
|
1146
|
+
* key {@link deriveScopedSeed} and {@link signSocialReceive} use, so an
|
|
1147
|
+
* address shown from it is one this app can spend (R5-1).
|
|
1148
|
+
*
|
|
1149
|
+
* - iOS (Commons, the Peable wallet): DERIVED from the private key in the
|
|
1150
|
+
* restricted keychain groups (personal: the wallet group's slot, then
|
|
1151
|
+
* Commons' primary; organization: its pair). Never the shared group's
|
|
1152
|
+
* public-key item, which every Oxy app can write. `null` when no private
|
|
1153
|
+
* key is held, and in every other iOS app.
|
|
1154
|
+
* - Android: inside Commons derived from its own key; elsewhere Commons'
|
|
1155
|
+
* identity host (`describe`), which answers from Commons' own signer
|
|
1156
|
+
* store over signature-protected IPC.
|
|
1157
|
+
* - Web: `null`.
|
|
1158
|
+
*
|
|
1159
|
+
* Compare the answer with the account's published `#key-1` before showing a
|
|
1160
|
+
* receive address (Peable ADR 0002). Lowercase uncompressed (`04…`).
|
|
1161
|
+
*/
|
|
1162
|
+
static async getIdentityPublicKeyForMoney(opts?: { accountId?: string }): Promise<string | null> {
|
|
1163
|
+
if (isWebPlatform()) {
|
|
1164
|
+
return null;
|
|
1165
|
+
}
|
|
1166
|
+
const accountId = opts?.accountId;
|
|
1167
|
+
if (accountId !== undefined) {
|
|
1168
|
+
if (!isOrganizationAccountId(accountId)) return null;
|
|
1169
|
+
if (isAndroid() && !KeyManager.identitySignerStore) {
|
|
1170
|
+
const bridge = await loadCommonsIdentityBridge();
|
|
1171
|
+
const description = (await bridge?.describe(accountId)) ?? null;
|
|
1172
|
+
return description?.accountId === accountId && description.publicKey
|
|
1173
|
+
? KeyManager.canonicalPublicKey(description.publicKey)
|
|
1174
|
+
: null;
|
|
1175
|
+
}
|
|
1176
|
+
const organizationKey = await getOrganizationPrivateKey(accountId);
|
|
1177
|
+
return organizationKey ? KeyManager.derivePublicKey(organizationKey) : null;
|
|
1178
|
+
}
|
|
1179
|
+
if (isAndroid()) {
|
|
1180
|
+
if (KeyManager.identitySignerStore) {
|
|
1181
|
+
const local = await KeyManager.getPrivateKey();
|
|
1182
|
+
return local ? KeyManager.derivePublicKey(local) : null;
|
|
1183
|
+
}
|
|
1184
|
+
const bridge = await loadCommonsIdentityBridge();
|
|
1185
|
+
const publicKey = (await bridge?.describe())?.publicKey ?? null;
|
|
1186
|
+
return publicKey ? KeyManager.canonicalPublicKey(publicKey) : null;
|
|
1187
|
+
}
|
|
1188
|
+
if (!isIOS()) {
|
|
1189
|
+
return null;
|
|
1190
|
+
}
|
|
1191
|
+
const privateKey =
|
|
1192
|
+
(await KeyManager.getSharedPrivateKey()) ?? (await KeyManager.getPrivateKey());
|
|
1193
|
+
return privateKey ? KeyManager.derivePublicKey(privateKey) : null;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1114
1196
|
/**
|
|
1115
1197
|
* The shared private key, where this app may hold it: iOS only.
|
|
1116
1198
|
*
|
|
@@ -2612,10 +2694,10 @@ export class KeyManager {
|
|
|
2612
2694
|
await KeyManager._clearSharedSlot(store);
|
|
2613
2695
|
// The retired ring is NOT cleared (R4-4): it is money under the person's
|
|
2614
2696
|
// previous keys, and "Start over" or a forced delete must not cost it.
|
|
2615
|
-
//
|
|
2616
|
-
//
|
|
2617
|
-
//
|
|
2618
|
-
// clears it, explicitly (`forgetRetiredIdentityKeys
|
|
2697
|
+
// Every personal entry is bound to its owner's account id (R5-2), so the
|
|
2698
|
+
// same person signing in again finds it, and a different person on this
|
|
2699
|
+
// phone never lists, derives from or signs with it. Only deleting the Oxy
|
|
2700
|
+
// ACCOUNT clears it, explicitly (`forgetRetiredIdentityKeys`).
|
|
2619
2701
|
}
|
|
2620
2702
|
|
|
2621
2703
|
// The device backup goes on EVERY delete, forced or not: it is restored
|
|
@@ -2624,8 +2706,19 @@ export class KeyManager {
|
|
|
2624
2706
|
await KeyManager._clearDeviceBackup();
|
|
2625
2707
|
|
|
2626
2708
|
// Same for the Android signer store: Commons' identity host must stop
|
|
2627
|
-
// answering for a deleted identity, forced or not
|
|
2709
|
+
// answering for a deleted identity, forced or not — its personal retired
|
|
2710
|
+
// keys included (R5-2): they stay in Commons' own ring, bound to their
|
|
2711
|
+
// owner, but the host must not hand them to the next person on the phone.
|
|
2628
2712
|
await KeyManager._clearIdentitySigner();
|
|
2713
|
+
try {
|
|
2714
|
+
await unmirrorPersonalRetiredKeys();
|
|
2715
|
+
} catch (error) {
|
|
2716
|
+
logger.warn(
|
|
2717
|
+
'Failed to withdraw the personal retired keys from the identity host',
|
|
2718
|
+
{ component: 'KeyManager' },
|
|
2719
|
+
error,
|
|
2720
|
+
);
|
|
2721
|
+
}
|
|
2629
2722
|
|
|
2630
2723
|
// Clear the marker AFTER key deletion succeeds — a marker must never outlive
|
|
2631
2724
|
// its identity (a leftover marker would route a truly-absent device to
|
|
@@ -3238,17 +3331,22 @@ export class KeyManager {
|
|
|
3238
3331
|
* (personal, or `accountId`'s) whose public key it names: the current key
|
|
3239
3332
|
* when it matches, else a RETIRED key Commons kept after a rotation or
|
|
3240
3333
|
* replacement (`./retiredKeys`, ADR 0061 D7 as amended). Spending only: a
|
|
3241
|
-
* retired key never signs in. Never falls back to another key.
|
|
3334
|
+
* retired key never signs in. Never falls back to another key. A PERSONAL
|
|
3335
|
+
* retired key is reached only with `opts.ownerAccountId`, the signed-in
|
|
3336
|
+
* person's account id, and only when the ring keeps it for that account
|
|
3337
|
+
* (R5-2); on Android outside Commons the identity host holds only the
|
|
3338
|
+
* personal keys of the person Commons is signed in as.
|
|
3242
3339
|
*
|
|
3243
3340
|
* @param info Context/domain-binding label (distinct labels → independent seeds).
|
|
3244
3341
|
* @param opts.accountId An organization's account id, for its key.
|
|
3245
3342
|
* @param opts.publicKey Selects one key of that account by its public key.
|
|
3343
|
+
* @param opts.ownerAccountId The person's account id, for a personal retired key.
|
|
3246
3344
|
* @returns 32 bytes of derived keying material, or `null` on web / when no
|
|
3247
3345
|
* identity key is available to this app.
|
|
3248
3346
|
*/
|
|
3249
3347
|
static async deriveScopedSeed(
|
|
3250
3348
|
info: string,
|
|
3251
|
-
opts?: { accountId?: string; publicKey?: string },
|
|
3349
|
+
opts?: { accountId?: string; publicKey?: string; ownerAccountId?: string },
|
|
3252
3350
|
): Promise<Uint8Array | null> {
|
|
3253
3351
|
if (isWebPlatform()) {
|
|
3254
3352
|
return null;
|
|
@@ -3262,7 +3360,7 @@ export class KeyManager {
|
|
|
3262
3360
|
(await bridge?.deriveRetiredScopedSeed?.(info, selected, opts.accountId)) ?? null;
|
|
3263
3361
|
return seed ? hexToBytes(seed) : null;
|
|
3264
3362
|
}
|
|
3265
|
-
const selected = await KeyManager._selectedPrivateKey(opts
|
|
3363
|
+
const selected = await KeyManager._selectedPrivateKey({ ...opts, publicKey: opts.publicKey });
|
|
3266
3364
|
return selected ? deriveScopedSeedFromKey(selected, info) : null;
|
|
3267
3365
|
}
|
|
3268
3366
|
if (opts?.accountId !== undefined) {
|
|
@@ -3326,7 +3424,7 @@ export class KeyManager {
|
|
|
3326
3424
|
static async signSocialReceive(
|
|
3327
3425
|
index: number,
|
|
3328
3426
|
digest: string,
|
|
3329
|
-
opts?: { accountId?: string; publicKey?: string },
|
|
3427
|
+
opts?: { accountId?: string; publicKey?: string; ownerAccountId?: string },
|
|
3330
3428
|
): Promise<CommonsSocialReceiveSignature | null> {
|
|
3331
3429
|
if (isWebPlatform()) {
|
|
3332
3430
|
return null;
|
|
@@ -3341,7 +3439,7 @@ export class KeyManager {
|
|
|
3341
3439
|
null
|
|
3342
3440
|
);
|
|
3343
3441
|
}
|
|
3344
|
-
const selected = await KeyManager._selectedPrivateKey(opts
|
|
3442
|
+
const selected = await KeyManager._selectedPrivateKey({ ...opts, publicKey: opts.publicKey });
|
|
3345
3443
|
return selected ? signSocialReceiveDigest(selected, index, digest) : null;
|
|
3346
3444
|
}
|
|
3347
3445
|
const accountId = opts?.accountId;
|
|
@@ -3382,10 +3480,12 @@ export class KeyManager {
|
|
|
3382
3480
|
return KeyManager.isValidPublicKey(publicKey) ? KeyManager.canonicalPublicKey(publicKey) : null;
|
|
3383
3481
|
}
|
|
3384
3482
|
|
|
3385
|
-
private static async _selectedPrivateKey(
|
|
3386
|
-
publicKey: string
|
|
3387
|
-
accountId?: string
|
|
3388
|
-
|
|
3483
|
+
private static async _selectedPrivateKey(opts: {
|
|
3484
|
+
publicKey: string;
|
|
3485
|
+
accountId?: string;
|
|
3486
|
+
ownerAccountId?: string;
|
|
3487
|
+
}): Promise<string | null> {
|
|
3488
|
+
const { publicKey, accountId, ownerAccountId } = opts;
|
|
3389
3489
|
if (retiredKeyScope({ accountId }) === null || !KeyManager.isValidPublicKey(publicKey)) {
|
|
3390
3490
|
return null;
|
|
3391
3491
|
}
|
|
@@ -3399,7 +3499,14 @@ export class KeyManager {
|
|
|
3399
3499
|
if (current && KeyManager.canonicalPublicKey(KeyManager.derivePublicKey(current)) === wanted) {
|
|
3400
3500
|
return current;
|
|
3401
3501
|
}
|
|
3402
|
-
return getRetiredPrivateKey(
|
|
3502
|
+
return getRetiredPrivateKey(
|
|
3503
|
+
wanted,
|
|
3504
|
+
accountId !== undefined
|
|
3505
|
+
? { accountId }
|
|
3506
|
+
: ownerAccountId !== undefined
|
|
3507
|
+
? { ownerAccountId }
|
|
3508
|
+
: undefined,
|
|
3509
|
+
);
|
|
3403
3510
|
}
|
|
3404
3511
|
|
|
3405
3512
|
/**
|