@oxy.so/core 7.29.0 → 7.31.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 +40 -1
- package/dist/cjs/crypto/index.js +11 -2
- package/dist/cjs/crypto/keyManager.js +69 -1
- package/dist/cjs/crypto/retiredKeys.js +371 -0
- package/dist/cjs/session/authStateStore.js +13 -0
- package/dist/cjs/session/index.js +5 -2
- package/dist/cjs/utils/keychainAccessGroups.js +107 -36
- package/dist/esm/api/identity.js +40 -1
- package/dist/esm/crypto/index.js +3 -0
- package/dist/esm/crypto/keyManager.js +69 -1
- package/dist/esm/crypto/retiredKeys.js +361 -0
- package/dist/esm/session/authStateStore.js +12 -0
- package/dist/esm/session/index.js +1 -1
- package/dist/esm/utils/keychainAccessGroups.js +108 -37
- package/dist/types/api/identity.d.ts +10 -0
- package/dist/types/crypto/identitySigner.d.ts +23 -0
- package/dist/types/crypto/index.d.ts +3 -1
- package/dist/types/crypto/keyManager.d.ts +23 -1
- package/dist/types/crypto/retiredKeys.d.ts +116 -0
- package/dist/types/session/authStateStore.d.ts +9 -0
- package/dist/types/session/index.d.ts +2 -2
- package/dist/types/utils/keychainAccessGroups.d.ts +72 -20
- package/package.json +3 -3
- package/src/api/identity.ts +46 -3
- package/src/crypto/identitySigner.ts +23 -0
- package/src/crypto/index.ts +13 -0
- package/src/crypto/keyManager.ts +81 -3
- package/src/crypto/keychainGroupMigration.ts +6 -1
- package/src/crypto/retiredKeys.ts +453 -0
- package/src/session/authStateStore.ts +14 -0
- package/src/session/index.ts +8 -1
- package/src/utils/keychainAccessGroups.ts +165 -38
|
@@ -40,6 +40,29 @@ export interface IdentitySignerStore {
|
|
|
40
40
|
writeOrganization?(accountId: string, privateKey: string, publicKey: string): Promise<boolean>;
|
|
41
41
|
/** Drop that organization's pair. */
|
|
42
42
|
clearOrganization?(accountId: string): Promise<void>;
|
|
43
|
+
/**
|
|
44
|
+
* The RETIRED key ring: keys a scope rotated away from (a personal rotation,
|
|
45
|
+
* or an organization key replacement, ADR 0061 D7), kept so the identity host
|
|
46
|
+
* can still derive and sign with them for the wallet when a request names
|
|
47
|
+
* their public key. Spending only: the host never signs anyone in with one.
|
|
48
|
+
*
|
|
49
|
+
* `scope` is `'personal'` or an organization's account id (24-hex or uuid);
|
|
50
|
+
* `publicKey` is the retired key's lowercase uncompressed SEC1 hex (130),
|
|
51
|
+
* which names its entry. The native side refuses anything else. Clearing the
|
|
52
|
+
* personal pair ({@link clear}) or an organization's never drops a ring.
|
|
53
|
+
* Optional so a store built before retired keys still type-checks; Commons
|
|
54
|
+
* offers all four or none.
|
|
55
|
+
*/
|
|
56
|
+
readRetired?(scope: string, publicKey: string): Promise<{
|
|
57
|
+
privateKey: string;
|
|
58
|
+
publicKey: string;
|
|
59
|
+
} | null>;
|
|
60
|
+
/** Keep a retired pair in `scope`'s ring. Resolves whether a read-back confirmed it. */
|
|
61
|
+
writeRetired?(scope: string, privateKey: string, publicKey: string): Promise<boolean>;
|
|
62
|
+
/** The public keys in `scope`'s ring (healthy entries only, sorted); `[]` when none. */
|
|
63
|
+
listRetired?(scope: string): Promise<string[]>;
|
|
64
|
+
/** Drop one retired key from `scope`'s ring. */
|
|
65
|
+
clearRetired?(scope: string, publicKey: string): Promise<void>;
|
|
43
66
|
}
|
|
44
67
|
/** @internal */
|
|
45
68
|
export declare function registerIdentitySignerStore(store: IdentitySignerStore | null): void;
|
|
@@ -20,9 +20,11 @@ export type { IdentitySignerStore } from './identitySigner';
|
|
|
20
20
|
export { generateOrganizationKey, organizationKeyFromPhrase, storeOrganizationKey, getOrganizationPublicKey, getOrganizationPrivateKey, getOrganizationRecoveryPhrase, listOrganizationKeys, deleteOrganizationKey, syncOrganizationKeys, isOrganizationAccountId, isHealthyOrganizationKeyPair, } from './organizationKeys';
|
|
21
21
|
export type { OrganizationKeyPair, GeneratedOrganizationKey, StoredOrganizationKey, } from './organizationKeys';
|
|
22
22
|
export { KEYCHAIN_ACCESS_GROUPS, KeychainAccessUnavailableError, keychainGroupFor, mayHoldKeychainItem, resolveKeychainAccess, keychainAccessGroupValue, keychainReadPlan, keychainDeletePlan, keychainWriteOption, ownKeychainItemPlan, readThroughPlan, deleteThroughPlan, groupedKeychainStore, } from '../utils/keychainAccessGroups';
|
|
23
|
-
export type { KeychainAccess, KeychainAccessGroupName, KeychainGroupOption, KeychainItemClass, KeychainRole, KeychainStoreLike, } from '../utils/keychainAccessGroups';
|
|
23
|
+
export type { KeychainAccess, KeychainAccessGroupName, KeychainGroup, KeychainGroupOption, KeychainItemClass, KeychainRole, KeychainStoreLike, OwnKeychainItemNames, } from '../utils/keychainAccessGroups';
|
|
24
24
|
export { ensureKeychainGroupsMigrated } from './keychainGroupMigration';
|
|
25
25
|
export type { KeychainGroupMigrationReport, KeychainItemMigrationOutcome, } from './keychainGroupMigration';
|
|
26
|
+
export { retireIdentityKey, listRetiredIdentityKeys, forgetRetiredIdentityKey, forgetRetiredIdentityKeys, syncRetiredKeys, retiredKeyFromPhrase, } from './retiredKeys';
|
|
27
|
+
export type { RetiredIdentityKey, RetiredKeyPair, RetiredKeySelector } from './retiredKeys';
|
|
26
28
|
export { SignatureService } from './signatureService';
|
|
27
29
|
export type { SignedMessage, AuthChallenge } from './signatureService';
|
|
28
30
|
export { RecoveryPhraseService, BACKUP_KDF_ENCRYPTION_INFO } from './recoveryPhrase';
|
|
@@ -739,13 +739,21 @@ export declare class KeyManager {
|
|
|
739
739
|
* keychain group, or Commons' identity host. It never falls back to the
|
|
740
740
|
* personal key.
|
|
741
741
|
*
|
|
742
|
+
* With `opts.publicKey` the seed is derived from the key of that account
|
|
743
|
+
* (personal, or `accountId`'s) whose public key it names: the current key
|
|
744
|
+
* when it matches, else a RETIRED key Commons kept after a rotation or
|
|
745
|
+
* replacement (`./retiredKeys`, ADR 0061 D7 as amended). Spending only: a
|
|
746
|
+
* retired key never signs in. Never falls back to another key.
|
|
747
|
+
*
|
|
742
748
|
* @param info Context/domain-binding label (distinct labels → independent seeds).
|
|
743
749
|
* @param opts.accountId An organization's account id, for its key.
|
|
750
|
+
* @param opts.publicKey Selects one key of that account by its public key.
|
|
744
751
|
* @returns 32 bytes of derived keying material, or `null` on web / when no
|
|
745
752
|
* identity key is available to this app.
|
|
746
753
|
*/
|
|
747
754
|
static deriveScopedSeed(info: string, opts?: {
|
|
748
755
|
accountId?: string;
|
|
756
|
+
publicKey?: string;
|
|
749
757
|
}): Promise<Uint8Array | null>;
|
|
750
758
|
/**
|
|
751
759
|
* {@link deriveScopedSeed} over an ORGANIZATION key (ADR 0061 D6): Commons'
|
|
@@ -762,7 +770,8 @@ export declare class KeyManager {
|
|
|
762
770
|
* compressed public key for the scriptSig.
|
|
763
771
|
*
|
|
764
772
|
* Key source: as {@link deriveScopedSeed}, including `opts.accountId` for an
|
|
765
|
-
* organization's key (ADR 0061 D6).
|
|
773
|
+
* organization's key (ADR 0061 D6) and `opts.publicKey` for one key of the
|
|
774
|
+
* account, current or retired. On Android outside Commons the signature
|
|
766
775
|
* comes from Commons, which allows it for the wallet app only.
|
|
767
776
|
*
|
|
768
777
|
* @returns the signature, or `null` on web / when no identity key is
|
|
@@ -770,7 +779,20 @@ export declare class KeyManager {
|
|
|
770
779
|
*/
|
|
771
780
|
static signSocialReceive(index: number, digest: string, opts?: {
|
|
772
781
|
accountId?: string;
|
|
782
|
+
publicKey?: string;
|
|
773
783
|
}): Promise<CommonsSocialReceiveSignature | null>;
|
|
784
|
+
/**
|
|
785
|
+
* The private key of the account (personal, or `accountId`'s organization)
|
|
786
|
+
* whose public key is `publicKey`, from this app's own storage: the current
|
|
787
|
+
* key when it matches, else the retired ring. iOS and Commons only; `null`
|
|
788
|
+
* for a key this device does not hold. Never another key in its place. @internal
|
|
789
|
+
*/
|
|
790
|
+
/**
|
|
791
|
+
* The selector in the identity host's encoding (lowercase uncompressed), so a
|
|
792
|
+
* compressed `#key-1` from a DID or a gateway row selects the same key. @internal
|
|
793
|
+
*/
|
|
794
|
+
private static _selectorKey;
|
|
795
|
+
private static _selectedPrivateKey;
|
|
774
796
|
/**
|
|
775
797
|
* Get a shortened version of the public key for display
|
|
776
798
|
* Format: first 8 chars...last 8 chars
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retired identity keys: the keys an account HAD, kept on the device for
|
|
3
|
+
* spending only (oxy ADR 0061 D7 as amended 2026-11-06, Peable ADR 0002).
|
|
4
|
+
*
|
|
5
|
+
* The Peable wallet derives from the account's identity key (`deriveScopedSeed`
|
|
6
|
+
* for its HD wallet, `signSocialReceive` for its social-receive addresses), and
|
|
7
|
+
* coins keep arriving under a key after it is replaced: payments already
|
|
8
|
+
* broadcast settle against the key they were reserved under, and late payers
|
|
9
|
+
* and `@username` senders hold old addresses. So a personal rotation
|
|
10
|
+
* (`oxy.identity.rotateKey`) and an organization replacement keep the replaced
|
|
11
|
+
* private key here, in a ring per account — `personal`, and one per
|
|
12
|
+
* organization account id — instead of deleting it.
|
|
13
|
+
*
|
|
14
|
+
* A retired key is NEVER a way to sign in, publish, receive a new reservation or
|
|
15
|
+
* rotate: nothing here signs a challenge, and the identity host refuses
|
|
16
|
+
* `proveIdentity` / `describe` for one. It is reached exactly as the current key
|
|
17
|
+
* is, by derivation and never by export, through the `publicKey` selector of
|
|
18
|
+
* `KeyManager.deriveScopedSeed` / `signSocialReceive`:
|
|
19
|
+
*
|
|
20
|
+
* - iOS: the private key in the wallet keychain group (ADR 0062's
|
|
21
|
+
* `spending-key`: Commons and the Peable wallet only), beside the current
|
|
22
|
+
* private keys; the ring's index (public keys and dates only) in the same
|
|
23
|
+
* group, so the wallet can list it. Only Commons writes either.
|
|
24
|
+
* - Android: Commons' own expo-secure-store slot, mirrored into the identity
|
|
25
|
+
* signer store (`./identitySigner`) that Commons' identity host answers
|
|
26
|
+
* `keyPublicKey` requests from (wallet only). Every other app asks over IPC
|
|
27
|
+
* (`loadCommonsIdentityBridge`) and never gets the key.
|
|
28
|
+
*
|
|
29
|
+
* A retired phrase is not kept: the person wrote it down, and a phrase is a
|
|
30
|
+
* second copy of the key that buys the wallet nothing.
|
|
31
|
+
*
|
|
32
|
+
* A key enters the ring only from Commons: the key being replaced (rotation,
|
|
33
|
+
* organization replacement), or a previous recovery phrase the person adds,
|
|
34
|
+
* which Commons accepts only when Oxy's key history names it as one of the
|
|
35
|
+
* account's retired keys. Never into the current slot.
|
|
36
|
+
*
|
|
37
|
+
* Security: a retired key that was compromised is also the attacker's. The
|
|
38
|
+
* ring makes the coins under it spendable from Peable again; it cannot make
|
|
39
|
+
* them safe. A rotation forced by a compromise must be followed at once by the
|
|
40
|
+
* wallet's "move to your current key" sweep, which races the attacker.
|
|
41
|
+
*
|
|
42
|
+
* This module imports nothing from `./keyManager` (which imports it).
|
|
43
|
+
*/
|
|
44
|
+
/** Whose ring: the person's (`accountId` absent) or an organization's. */
|
|
45
|
+
export interface RetiredKeySelector {
|
|
46
|
+
/** An organization's account id; absent for the personal ring. */
|
|
47
|
+
accountId?: string;
|
|
48
|
+
}
|
|
49
|
+
/** One retired key as the ring lists it: public data only. */
|
|
50
|
+
export interface RetiredIdentityKey {
|
|
51
|
+
/** Lowercase uncompressed secp256k1 public key (`04…`, 130 hex). */
|
|
52
|
+
publicKey: string;
|
|
53
|
+
/** The organization it was the key of; absent for a personal root. */
|
|
54
|
+
accountId?: string;
|
|
55
|
+
/** Unix milliseconds when this device kept it. */
|
|
56
|
+
retiredAt: number;
|
|
57
|
+
}
|
|
58
|
+
/** A pair to keep in the ring. */
|
|
59
|
+
export interface RetiredKeyPair {
|
|
60
|
+
privateKey: string;
|
|
61
|
+
publicKey: string;
|
|
62
|
+
}
|
|
63
|
+
/** The ring a selector names: `personal`, or the organization's account id. */
|
|
64
|
+
export declare function retiredKeyScope(selector?: RetiredKeySelector): string | null;
|
|
65
|
+
/**
|
|
66
|
+
* Keep `key` in the ring of `selector`'s account. Commons only. Idempotent: a
|
|
67
|
+
* key already kept stays, with its first date. Refuses a pair whose public key
|
|
68
|
+
* does not derive from its private key. Resolves once every copy confirmed it
|
|
69
|
+
* (Android: the signer store's read-back too), and throws otherwise, so a
|
|
70
|
+
* caller about to replace the key can stop before anything is lost.
|
|
71
|
+
*/
|
|
72
|
+
export declare function retireIdentityKey(key: RetiredKeyPair, selector?: RetiredKeySelector): Promise<void>;
|
|
73
|
+
/**
|
|
74
|
+
* The retired keys of `selector`'s account this app can reach, oldest first.
|
|
75
|
+
* iOS and Commons: the ring's index. Android elsewhere: Commons' identity host
|
|
76
|
+
* (the wallet only), which leaves out the scope's current key. After an
|
|
77
|
+
* interrupted rotation the local list can include the current key: compare
|
|
78
|
+
* with the DID's `#key-1` before treating an entry as retired.
|
|
79
|
+
*/
|
|
80
|
+
export declare function listRetiredIdentityKeys(selector?: RetiredKeySelector): Promise<RetiredIdentityKey[]>;
|
|
81
|
+
/**
|
|
82
|
+
* The private half of a retired key, where this app may hold it: Commons (both
|
|
83
|
+
* platforms) and, on iOS, the keychain group. `null` on Android outside Commons
|
|
84
|
+
* (ask the identity host instead) and for a key not in the ring. Sign within
|
|
85
|
+
* the app only; never transmit it. @internal — `KeyManager` reads it for the
|
|
86
|
+
* `publicKey` selector.
|
|
87
|
+
*/
|
|
88
|
+
export declare function getRetiredPrivateKey(publicKey: string, selector?: RetiredKeySelector): Promise<string | null>;
|
|
89
|
+
/**
|
|
90
|
+
* Forget a retired key on this device: its private half, its signer copy and
|
|
91
|
+
* its index entry. Commons only, and only on the person's explicit request
|
|
92
|
+
* (their coins under it are then spendable only with its phrase again).
|
|
93
|
+
*/
|
|
94
|
+
export declare function forgetRetiredIdentityKey(publicKey: string, selector?: RetiredKeySelector): Promise<void>;
|
|
95
|
+
/**
|
|
96
|
+
* Forget every retired key of one ring (the person's, or an organization's):
|
|
97
|
+
* called when the identity itself is deleted from this device, so a phone
|
|
98
|
+
* handed on keeps none of the person's old keys. Commons only. Resolves how
|
|
99
|
+
* many it removed.
|
|
100
|
+
*/
|
|
101
|
+
export declare function forgetRetiredIdentityKeys(selector?: RetiredKeySelector): Promise<number>;
|
|
102
|
+
/**
|
|
103
|
+
* Commons on Android: copy every retired key into the identity signer store
|
|
104
|
+
* again, so the identity host answers for it after that file was rebuilt (its
|
|
105
|
+
* self-heal leaves it empty). Call on every boot, beside `syncOrganizationKeys`.
|
|
106
|
+
* Resolves how many it mirrored. Elsewhere 0.
|
|
107
|
+
*/
|
|
108
|
+
export declare function syncRetiredKeys(): Promise<number>;
|
|
109
|
+
/**
|
|
110
|
+
* The pair a previous recovery phrase derives (`seed[0:32]`, the personal-root
|
|
111
|
+
* and organization-key derivation; 12–24 words). Stores nothing: the caller
|
|
112
|
+
* checks the public key against the account's key history (personal:
|
|
113
|
+
* `oxy.identity.keyHistory()`; organization: `oxy.accounts.identityKey.get`)
|
|
114
|
+
* and only then calls {@link retireIdentityKey}.
|
|
115
|
+
*/
|
|
116
|
+
export declare function retiredKeyFromPhrase(phrase: string): Promise<RetiredKeyPair>;
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*
|
|
17
17
|
* ESM-safe (no `require()`).
|
|
18
18
|
*/
|
|
19
|
+
import type { OwnKeychainItemNames } from '../utils/keychainAccessGroups';
|
|
19
20
|
/**
|
|
20
21
|
* The persisted session credential set for a single origin.
|
|
21
22
|
*
|
|
@@ -124,6 +125,14 @@ export interface NativeKeyValueStorage {
|
|
|
124
125
|
*/
|
|
125
126
|
export declare const AUTH_STATE_STORAGE_KEY = "oxy.auth.v1";
|
|
126
127
|
export declare const AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY = "oxy.auth.automatic-identity-signin-suppressed.v1";
|
|
128
|
+
/**
|
|
129
|
+
* What `ownKeychainItemPlan` (ADR 0062 D6) must know about a key a native
|
|
130
|
+
* session store keeps — this module's three and the identity pin: every Oxy app
|
|
131
|
+
* writes it under the same name, so on iOS a copy in the shared group may be
|
|
132
|
+
* another app's session and is never read as this app's. The sign-out marker
|
|
133
|
+
* can only withhold, so a shared copy of it still counts.
|
|
134
|
+
*/
|
|
135
|
+
export declare function nativeSessionItemNames(key: string): OwnKeychainItemNames;
|
|
127
136
|
/**
|
|
128
137
|
* Versioned BEST-EFFORT warm-token storage key. Holds the short-lived
|
|
129
138
|
* `{ accessToken, expiresAt }` pair only. Its write is genuinely non-fatal — a
|
|
@@ -25,12 +25,12 @@ export { resolveAccountDelegationAccess } from './accountDelegationAccess';
|
|
|
25
25
|
export type { AccountDelegationAccess, AccountDelegationNode, } from './accountDelegationAccess';
|
|
26
26
|
export { AccountDialogController, createAccountDialogController, } from './accountDialogController';
|
|
27
27
|
export type { AccountDialogControllerOptions, AccountDialogSnapshot, AccountDialogView, CommonsAvailability, ContextChoiceOutcome, SignInFailureReason, SignInFlowPhase, SignInFlowState, SignInProgress, } from './accountDialogController';
|
|
28
|
-
export { createWebAuthStateStore, createNativeAuthStateStore, createMemoryAuthStateStore, AUTH_STATE_STORAGE_KEY, } from './authStateStore';
|
|
28
|
+
export { createWebAuthStateStore, createNativeAuthStateStore, createMemoryAuthStateStore, nativeSessionItemNames, AUTH_STATE_STORAGE_KEY, AUTH_STATE_TOKEN_STORAGE_KEY, AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY, } from './authStateStore';
|
|
29
29
|
export type { PersistedAuthState, AuthStateStore, AuthStateWriteGuard, NativeKeyValueStorage, } from './authStateStore';
|
|
30
30
|
export { createSharedMirroringAuthStateStore, decideSharedDeviceJoin, decideSharedDevicePublish, normalizeSharedDeviceSessionRead, publishProvenDeviceCredential, readLocalDeviceCredential, } from './sharedDeviceCredential';
|
|
31
31
|
export type { SharedDeviceCredential, SharedDeviceCredentialRead, SharedDeviceCredentialStore, SharedDeviceJoinDecision, SharedDeviceJoinSkipReason, SharedDevicePublishDecision, SharedDevicePublishOutcome, SharedDevicePublishSkipReason, } from './sharedDeviceCredential';
|
|
32
32
|
export { KeychainAccessUnavailableError, keychainAccessGroupValue, ownKeychainItemPlan, readThroughPlan, deleteThroughPlan, resolveKeychainAccess, } from '../utils/keychainAccessGroups';
|
|
33
|
-
export type { KeychainAccess, KeychainGroupOption } from '../utils/keychainAccessGroups';
|
|
33
|
+
export type { KeychainAccess, KeychainGroupOption, OwnKeychainItemNames, } from '../utils/keychainAccessGroups';
|
|
34
34
|
export { createWebIdentityPinStore, createNativeIdentityPinStore, createMemoryIdentityPinStore, identityPinMatches, IDENTITY_PIN_STORAGE_KEY, } from './identityPin';
|
|
35
35
|
export type { IdentityPin, IdentityPinStore } from './identityPin';
|
|
36
36
|
export { resolveIdentityPin, establishIdentitySession, } from './identitySession';
|
|
@@ -5,13 +5,21 @@ export declare const KEYCHAIN_ACCESS_GROUPS: {
|
|
|
5
5
|
readonly shared: "group.so.oxy.shared";
|
|
6
6
|
};
|
|
7
7
|
export type KeychainAccessGroupName = keyof typeof KEYCHAIN_ACCESS_GROUPS;
|
|
8
|
+
/**
|
|
9
|
+
* Every group an item class can be assigned: the three Oxy groups, or `app`,
|
|
10
|
+
* this app's own group, whose name is the app's and so is not a constant.
|
|
11
|
+
*/
|
|
12
|
+
export type KeychainGroup = KeychainAccessGroupName | 'app';
|
|
8
13
|
/** What a keychain item IS, which decides its group. See the table above. */
|
|
9
|
-
export type KeychainItemClass = 'identity-secret' | 'spending-key' | 'shared';
|
|
14
|
+
export type KeychainItemClass = 'identity-secret' | 'spending-key' | 'shared' | 'app';
|
|
10
15
|
/** Which holder this binary is, from its default access group. */
|
|
11
16
|
export type KeychainRole = 'holder' | 'wallet' | 'member';
|
|
12
17
|
/**
|
|
13
18
|
* - `grouped`: iOS with the probe answered. `prefix` ends with its dot
|
|
14
|
-
* (`ABCDE12345.`); `defaultGroup` is the full default group string
|
|
19
|
+
* (`ABCDE12345.`); `defaultGroup` is the full default group string;
|
|
20
|
+
* `appGroup`, when the native module proved it, this app's own group
|
|
21
|
+
* (`ABCDE12345.so.oxy.mention`). Absent on a native build that predates that
|
|
22
|
+
* probe: see {@link ownKeychainItemPlan} for what such a build does.
|
|
15
23
|
* - `ungrouped`: Android, web, or an iOS native build without the probe.
|
|
16
24
|
*/
|
|
17
25
|
export type KeychainAccess = {
|
|
@@ -19,6 +27,7 @@ export type KeychainAccess = {
|
|
|
19
27
|
prefix: string;
|
|
20
28
|
defaultGroup: string;
|
|
21
29
|
role: KeychainRole;
|
|
30
|
+
appGroup?: string;
|
|
22
31
|
} | {
|
|
23
32
|
mode: 'ungrouped';
|
|
24
33
|
};
|
|
@@ -36,11 +45,15 @@ export declare class KeychainAccessUnavailableError extends Error {
|
|
|
36
45
|
* The single place each item class is assigned a group. A new kind of item
|
|
37
46
|
* (F1's retained keys, for one) picks its class and reuses everything below.
|
|
38
47
|
*/
|
|
39
|
-
export declare function keychainGroupFor(item: KeychainItemClass): KeychainAccessGroupName;
|
|
48
|
+
export declare function keychainGroupFor(item: Exclude<KeychainItemClass, 'app'>): KeychainAccessGroupName;
|
|
49
|
+
export declare function keychainGroupFor(item: KeychainItemClass): KeychainGroup;
|
|
40
50
|
/** Whether `role` holds the group `item` lives in. */
|
|
41
51
|
export declare function mayHoldKeychainItem(role: KeychainRole, item: KeychainItemClass): boolean;
|
|
42
|
-
/**
|
|
43
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Parse the default access group the probe reported, and the app's own group
|
|
54
|
+
* when the second probe proved it. Exported for tests.
|
|
55
|
+
*/
|
|
56
|
+
export declare function keychainAccessFromDefaultGroup(defaultGroup: string, appGroup?: string): KeychainAccess;
|
|
44
57
|
/**
|
|
45
58
|
* This binary's keychain access, memoized once known. THROWS
|
|
46
59
|
* {@link KeychainAccessUnavailableError} while the probe cannot answer, which
|
|
@@ -49,13 +62,18 @@ export declare function keychainAccessFromDefaultGroup(defaultGroup: string): Ke
|
|
|
49
62
|
export declare function resolveKeychainAccess(): Promise<KeychainAccess>;
|
|
50
63
|
/** Forget the memoized access. Tests only. @internal */
|
|
51
64
|
export declare function resetKeychainAccessForTests(): void;
|
|
52
|
-
/** The full `kSecAttrAccessGroup` value of `group`, or `undefined` when ungrouped. */
|
|
53
|
-
export declare function keychainAccessGroupValue(access: KeychainAccess, group: KeychainAccessGroupName): string | undefined;
|
|
54
65
|
/**
|
|
55
|
-
* The
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
|
|
66
|
+
* The full `kSecAttrAccessGroup` value of `group`, or `undefined` for "no
|
|
67
|
+
* group" (the app's default): always when ungrouped, and for `app` on a build
|
|
68
|
+
* that cannot place items there (see {@link ownKeychainItemPlan}).
|
|
69
|
+
*/
|
|
70
|
+
export declare function keychainAccessGroupValue(access: KeychainAccess, group: KeychainGroup): string | undefined;
|
|
71
|
+
/**
|
|
72
|
+
* The option bags a READ of `item` tries, in order: its own group, then, for
|
|
73
|
+
* an identity secret or a spending key, the shared group, where those lived
|
|
74
|
+
* before the split (release 4 of the doc drops that fallback). Empty when this
|
|
75
|
+
* app may not hold the item: the caller answers `null` without touching the
|
|
76
|
+
* keychain. An app's own items (`app`) take {@link ownKeychainItemPlan}.
|
|
59
77
|
*/
|
|
60
78
|
export declare function keychainReadPlan(access: KeychainAccess, item: KeychainItemClass): KeychainGroupOption[];
|
|
61
79
|
/**
|
|
@@ -70,17 +88,51 @@ export declare function keychainDeletePlan(access: KeychainAccess, item: Keychai
|
|
|
70
88
|
*/
|
|
71
89
|
export declare function keychainWriteOption(access: KeychainAccess, item: KeychainItemClass): KeychainGroupOption;
|
|
72
90
|
/**
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
91
|
+
* Who else writes an own item's NAME, which decides whose an earlier copy of
|
|
92
|
+
* it can be.
|
|
93
|
+
*
|
|
94
|
+
* - `this-app`: no other app uses the name (the Peable wallet's own items). A
|
|
95
|
+
* copy anywhere this app can read is its own.
|
|
96
|
+
* - `every-oxy-app`: every Oxy app writes the same name (the SDK's session
|
|
97
|
+
* store: `oxy.auth.v1`, the warm token, the identity pin). Before the
|
|
98
|
+
* app group, every iOS app but Commons and the Peable wallet wrote it to the
|
|
99
|
+
* ONE copy in the shared group, so a copy there may be another app's
|
|
100
|
+
* session, or Commons' vault session. Only this app's own locations are
|
|
101
|
+
* read, written or deleted; the shared copy is left to the apps still
|
|
102
|
+
* running a build that uses it.
|
|
103
|
+
* - `every-oxy-app-withholding`: as `every-oxy-app`, for a value whose only
|
|
104
|
+
* effect is to WITHHOLD (the explicit sign-out marker). A shared copy still
|
|
105
|
+
* counts — honouring another app's marker can keep this app from signing in
|
|
106
|
+
* automatically, never sign it in — and its delete reaches that copy, as a
|
|
107
|
+
* release always did before.
|
|
108
|
+
*/
|
|
109
|
+
export type OwnKeychainItemNames = 'this-app' | 'every-oxy-app' | 'every-oxy-app-withholding';
|
|
110
|
+
/**
|
|
111
|
+
* The plan for an app's OWN items (class `app`, ADR 0062 D6): written to the
|
|
112
|
+
* app's own group (`<prefix><bundle id>`), which no other app can read, and
|
|
113
|
+
* read from it FIRST. Then:
|
|
114
|
+
*
|
|
115
|
+
* - from where this app kept them before: Commons' and the Peable wallet's
|
|
116
|
+
* default group (D3), which no other app writes its own items to;
|
|
117
|
+
* - for `this-app` names, from every group (`{}`), where they were before the
|
|
118
|
+
* split; deletes reach every group;
|
|
119
|
+
* - for `every-oxy-app` names, nowhere else (see {@link OwnKeychainItemNames}),
|
|
120
|
+
* and deletes reach exactly the read locations, earlier ones first, so a
|
|
121
|
+
* failed delete never leaves an older copy as the only one;
|
|
122
|
+
* - for `every-oxy-app-withholding` names, the shared group last on read and
|
|
123
|
+
* first on delete.
|
|
124
|
+
*
|
|
125
|
+
* Nothing is copied or deleted to migrate: the next write lands in the app
|
|
126
|
+
* group and the read order makes it win, so a crash or a failure at any step
|
|
127
|
+
* leaves the value the app had.
|
|
80
128
|
*
|
|
81
|
-
*
|
|
129
|
+
* A native build whose module predates the app-group probe (`appGroup`
|
|
130
|
+
* absent) keeps its items where that build put them: Commons and the wallet in
|
|
131
|
+
* their default group, every other app the calls it always made (the one
|
|
132
|
+
* shared item, until that app's next native build). Android and web: no
|
|
133
|
+
* options.
|
|
82
134
|
*/
|
|
83
|
-
export declare function ownKeychainItemPlan(access: KeychainAccess): {
|
|
135
|
+
export declare function ownKeychainItemPlan(access: KeychainAccess, names?: OwnKeychainItemNames): {
|
|
84
136
|
read: KeychainGroupOption[];
|
|
85
137
|
write: KeychainGroupOption;
|
|
86
138
|
delete: KeychainGroupOption[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxy.so/core",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.31.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.12.0",
|
|
185
|
+
"@oxy.so/protocol": "^1.10.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/identity.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
identityLinkCreateResponseCheck,
|
|
24
24
|
identityLinkStateCheck,
|
|
25
25
|
} from '@oxy.so/contracts/client';
|
|
26
|
+
import { identityKeyHistoryCheck, type IdentityKeyHistory } from '@oxy.so/contracts/client';
|
|
26
27
|
import type {
|
|
27
28
|
AuthMethodsResponse,
|
|
28
29
|
BackupStatusResponse,
|
|
@@ -283,9 +284,8 @@ export class IdentityApi {
|
|
|
283
284
|
throw new Error('No authenticated user — sign in before rotating your key.');
|
|
284
285
|
}
|
|
285
286
|
|
|
286
|
-
const { KeyManager, RecoveryPhraseService, SignatureService, signMessage } =
|
|
287
|
-
'../crypto/index'
|
|
288
|
-
);
|
|
287
|
+
const { KeyManager, RecoveryPhraseService, SignatureService, signMessage, retireIdentityKey } =
|
|
288
|
+
await import('../crypto/index');
|
|
289
289
|
|
|
290
290
|
// 1. The NEW identity (in memory only). The UI may pre-derive + pre-show
|
|
291
291
|
// it and pass it back here so the phrase shown === the phrase committed.
|
|
@@ -296,6 +296,7 @@ export class IdentityApi {
|
|
|
296
296
|
// 2. Resolve the OLD signing capability from the chosen proof mode.
|
|
297
297
|
let oldPublicKey: string;
|
|
298
298
|
let signWithOldKey: (message: string) => Promise<string>;
|
|
299
|
+
let oldPrivateKeyForRing: string | null;
|
|
299
300
|
if (options.proof === 'phrase') {
|
|
300
301
|
const phrase = options.phrase?.trim();
|
|
301
302
|
if (!phrase) {
|
|
@@ -304,6 +305,7 @@ export class IdentityApi {
|
|
|
304
305
|
const oldPrivateKey = await RecoveryPhraseService.derivePrivateKeyFromPhrase(phrase);
|
|
305
306
|
oldPublicKey = KeyManager.derivePublicKey(oldPrivateKey);
|
|
306
307
|
signWithOldKey = (message) => signMessage(message, oldPrivateKey);
|
|
308
|
+
oldPrivateKeyForRing = oldPrivateKey;
|
|
307
309
|
} else {
|
|
308
310
|
const currentPublicKey = await KeyManager.getPublicKey();
|
|
309
311
|
if (!currentPublicKey) {
|
|
@@ -313,6 +315,30 @@ export class IdentityApi {
|
|
|
313
315
|
}
|
|
314
316
|
oldPublicKey = currentPublicKey;
|
|
315
317
|
signWithOldKey = (message) => SignatureService.sign(message);
|
|
318
|
+
oldPrivateKeyForRing = isWeb() ? null : await KeyManager.getPrivateKey();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// 2b. Keep the key being replaced in the retired ring BEFORE anything
|
|
322
|
+
// changes (oxy ADR 0061 D7 as amended, Peable ADR 0002): coins keep
|
|
323
|
+
// arriving under it after the swap, and the Peable wallet spends them
|
|
324
|
+
// through the `publicKey` selector of `deriveScopedSeed` /
|
|
325
|
+
// `signSocialReceive`. A device that cannot keep it does not rotate:
|
|
326
|
+
// the phrase would become the only way to those coins. Native only —
|
|
327
|
+
// on web no key is stored. Idempotent, so a retried rotation is fine.
|
|
328
|
+
if (!isWeb()) {
|
|
329
|
+
try {
|
|
330
|
+
if (!oldPrivateKeyForRing) throw new Error('no readable private key');
|
|
331
|
+
await retireIdentityKey({ privateKey: oldPrivateKeyForRing, publicKey: oldPublicKey });
|
|
332
|
+
} catch (retainError) {
|
|
333
|
+
logger.warn(
|
|
334
|
+
'Could not keep the current key as a retired key; not rotating.',
|
|
335
|
+
{ component: 'oxy.identity', method: 'rotateKey' },
|
|
336
|
+
retainError,
|
|
337
|
+
);
|
|
338
|
+
throw new Error(
|
|
339
|
+
'Your current key could not be kept on this device, so it was not rotated. Nothing changed.',
|
|
340
|
+
);
|
|
341
|
+
}
|
|
316
342
|
}
|
|
317
343
|
|
|
318
344
|
// 3. Request a single-use rotate_key challenge (bearer).
|
|
@@ -502,6 +528,23 @@ export class IdentityApi {
|
|
|
502
528
|
if (userId) keys.push(`GET:/u/${encodeURIComponent(userId)}/did.json`);
|
|
503
529
|
this.ctx.http.invalidateCache({ keys, prefixes: ['GET:/users/me'] });
|
|
504
530
|
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* The signed-in personal account's current root (`null` without one) and
|
|
534
|
+
* every root a rotation replaced, oldest first, all canonical (uncompressed,
|
|
535
|
+
* lowercase). Commons reads it to accept a previous recovery phrase only when
|
|
536
|
+
* it derives a key that WAS this account's root. A retired root is never any
|
|
537
|
+
* account's again. First-party only; a non-personal account is refused
|
|
538
|
+
* (`IDENTITY_NOT_PERSONAL_ACCOUNT`).
|
|
539
|
+
*/
|
|
540
|
+
async keyHistory(): Promise<IdentityKeyHistory> {
|
|
541
|
+
const res = await this.ctx.request<unknown>('GET', '/identity/key-history', undefined, {
|
|
542
|
+
cache: false,
|
|
543
|
+
});
|
|
544
|
+
const parsed = checkContract(identityKeyHistoryCheck, res);
|
|
545
|
+
if (!parsed) throw new Error('identity/key-history returned an unexpected response shape');
|
|
546
|
+
return parsed;
|
|
547
|
+
}
|
|
505
548
|
}
|
|
506
549
|
|
|
507
550
|
/** `oxy.identity.links` — joining Commons' root to an account (ADR 0029 D3). */
|
|
@@ -34,6 +34,29 @@ export interface IdentitySignerStore {
|
|
|
34
34
|
writeOrganization?(accountId: string, privateKey: string, publicKey: string): Promise<boolean>;
|
|
35
35
|
/** Drop that organization's pair. */
|
|
36
36
|
clearOrganization?(accountId: string): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* The RETIRED key ring: keys a scope rotated away from (a personal rotation,
|
|
39
|
+
* or an organization key replacement, ADR 0061 D7), kept so the identity host
|
|
40
|
+
* can still derive and sign with them for the wallet when a request names
|
|
41
|
+
* their public key. Spending only: the host never signs anyone in with one.
|
|
42
|
+
*
|
|
43
|
+
* `scope` is `'personal'` or an organization's account id (24-hex or uuid);
|
|
44
|
+
* `publicKey` is the retired key's lowercase uncompressed SEC1 hex (130),
|
|
45
|
+
* which names its entry. The native side refuses anything else. Clearing the
|
|
46
|
+
* personal pair ({@link clear}) or an organization's never drops a ring.
|
|
47
|
+
* Optional so a store built before retired keys still type-checks; Commons
|
|
48
|
+
* offers all four or none.
|
|
49
|
+
*/
|
|
50
|
+
readRetired?(
|
|
51
|
+
scope: string,
|
|
52
|
+
publicKey: string,
|
|
53
|
+
): Promise<{ privateKey: string; publicKey: string } | null>;
|
|
54
|
+
/** Keep a retired pair in `scope`'s ring. Resolves whether a read-back confirmed it. */
|
|
55
|
+
writeRetired?(scope: string, privateKey: string, publicKey: string): Promise<boolean>;
|
|
56
|
+
/** The public keys in `scope`'s ring (healthy entries only, sorted); `[]` when none. */
|
|
57
|
+
listRetired?(scope: string): Promise<string[]>;
|
|
58
|
+
/** Drop one retired key from `scope`'s ring. */
|
|
59
|
+
clearRetired?(scope: string, publicKey: string): Promise<void>;
|
|
37
60
|
}
|
|
38
61
|
|
|
39
62
|
/**
|
package/src/crypto/index.ts
CHANGED
|
@@ -64,16 +64,29 @@ export {
|
|
|
64
64
|
export type {
|
|
65
65
|
KeychainAccess,
|
|
66
66
|
KeychainAccessGroupName,
|
|
67
|
+
KeychainGroup,
|
|
67
68
|
KeychainGroupOption,
|
|
68
69
|
KeychainItemClass,
|
|
69
70
|
KeychainRole,
|
|
70
71
|
KeychainStoreLike,
|
|
72
|
+
OwnKeychainItemNames,
|
|
71
73
|
} from '../utils/keychainAccessGroups';
|
|
72
74
|
export { ensureKeychainGroupsMigrated } from './keychainGroupMigration';
|
|
73
75
|
export type {
|
|
74
76
|
KeychainGroupMigrationReport,
|
|
75
77
|
KeychainItemMigrationOutcome,
|
|
76
78
|
} from './keychainGroupMigration';
|
|
79
|
+
// Retired identity keys: kept for spending after a rotation or replacement
|
|
80
|
+
// (docs/adr/0061-organization-identity-key.md D7 as amended 2026-11-06)
|
|
81
|
+
export {
|
|
82
|
+
retireIdentityKey,
|
|
83
|
+
listRetiredIdentityKeys,
|
|
84
|
+
forgetRetiredIdentityKey,
|
|
85
|
+
forgetRetiredIdentityKeys,
|
|
86
|
+
syncRetiredKeys,
|
|
87
|
+
retiredKeyFromPhrase,
|
|
88
|
+
} from './retiredKeys';
|
|
89
|
+
export type { RetiredIdentityKey, RetiredKeyPair, RetiredKeySelector } from './retiredKeys';
|
|
77
90
|
export { SignatureService } from './signatureService';
|
|
78
91
|
export type { SignedMessage, AuthChallenge } from './signatureService';
|
|
79
92
|
export { RecoveryPhraseService, BACKUP_KDF_ENCRYPTION_INFO } from './recoveryPhrase';
|