@oxyhq/core 4.0.1 → 5.1.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/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +6 -18
- package/dist/cjs/OxyServices.base.js +0 -21
- package/dist/cjs/crypto/keyManager.js +7 -7
- package/dist/cjs/crypto/polyfill.js +6 -5
- package/dist/cjs/crypto/signatureService.js +44 -220
- package/dist/cjs/index.js +4 -8
- package/dist/cjs/mixins/OxyServices.accounts.js +90 -0
- package/dist/cjs/mixins/OxyServices.assets.js +68 -2
- package/dist/cjs/mixins/OxyServices.auth.js +3 -3
- package/dist/cjs/mixins/OxyServices.civic.js +3 -3
- package/dist/cjs/mixins/OxyServices.language.js +2 -2
- package/dist/cjs/mixins/OxyServices.utility.js +7 -95
- package/dist/cjs/utils/cacheKey.js +17 -19
- package/dist/cjs/utils/deviceManager.js +2 -2
- package/dist/cjs/utils/platform.js +0 -14
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +6 -18
- package/dist/esm/OxyServices.base.js +0 -21
- package/dist/esm/crypto/keyManager.js +4 -4
- package/dist/esm/crypto/polyfill.js +5 -4
- package/dist/esm/crypto/signatureService.js +39 -214
- package/dist/esm/index.js +1 -2
- package/dist/esm/mixins/OxyServices.accounts.js +90 -0
- package/dist/esm/mixins/OxyServices.assets.js +67 -1
- package/dist/esm/mixins/OxyServices.auth.js +1 -1
- package/dist/esm/mixins/OxyServices.civic.js +3 -3
- package/dist/esm/mixins/OxyServices.language.js +1 -1
- package/dist/esm/mixins/OxyServices.utility.js +6 -94
- package/dist/esm/utils/cacheKey.js +17 -19
- package/dist/esm/utils/deviceManager.js +1 -1
- package/dist/esm/utils/platform.js +0 -12
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +3 -6
- package/dist/types/OxyServices.base.d.ts +0 -17
- package/dist/types/crypto/polyfill.d.ts +2 -2
- package/dist/types/crypto/signatureService.d.ts +18 -84
- package/dist/types/index.d.ts +4 -5
- package/dist/types/mixins/OxyServices.accounts.d.ts +66 -5
- package/dist/types/mixins/OxyServices.analytics.d.ts +0 -2
- package/dist/types/mixins/OxyServices.appData.d.ts +0 -2
- package/dist/types/mixins/OxyServices.assets.d.ts +42 -3
- package/dist/types/mixins/OxyServices.auth.d.ts +0 -2
- package/dist/types/mixins/OxyServices.civic.d.ts +3 -5
- package/dist/types/mixins/OxyServices.connectedApps.d.ts +0 -2
- package/dist/types/mixins/OxyServices.contacts.d.ts +0 -2
- package/dist/types/mixins/OxyServices.devices.d.ts +0 -2
- package/dist/types/mixins/OxyServices.features.d.ts +0 -2
- package/dist/types/mixins/OxyServices.fedcm.d.ts +0 -2
- package/dist/types/mixins/OxyServices.identity.d.ts +8 -5
- package/dist/types/mixins/OxyServices.language.d.ts +0 -2
- package/dist/types/mixins/OxyServices.links.d.ts +0 -2
- package/dist/types/mixins/OxyServices.location.d.ts +0 -2
- package/dist/types/mixins/OxyServices.nodes.d.ts +0 -44
- package/dist/types/mixins/OxyServices.payment.d.ts +0 -2
- package/dist/types/mixins/OxyServices.privacy.d.ts +0 -2
- package/dist/types/mixins/OxyServices.redirect.d.ts +0 -2
- package/dist/types/mixins/OxyServices.reputation.d.ts +0 -2
- package/dist/types/mixins/OxyServices.security.d.ts +0 -2
- package/dist/types/mixins/OxyServices.silent.d.ts +0 -2
- package/dist/types/mixins/OxyServices.sso.d.ts +0 -2
- package/dist/types/mixins/OxyServices.topics.d.ts +0 -2
- package/dist/types/mixins/OxyServices.user.d.ts +0 -2
- package/dist/types/mixins/OxyServices.utility.d.ts +0 -32
- package/dist/types/models/interfaces.d.ts +18 -0
- package/dist/types/server/auth.d.ts +0 -6
- package/dist/types/server/index.d.ts +1 -1
- package/dist/types/utils/cacheKey.d.ts +6 -7
- package/dist/types/utils/platform.d.ts +0 -8
- package/package.json +4 -7
- package/src/HttpService.ts +6 -22
- package/src/OxyServices.base.ts +0 -23
- package/src/__tests__/httpServiceCache.test.ts +0 -19
- package/src/crypto/__tests__/keyManager.atomicity.test.ts +2 -1
- package/src/crypto/__tests__/keyManager.test.ts +9 -7
- package/src/crypto/__tests__/signChallengeShared.test.ts +2 -1
- package/src/crypto/__tests__/signedRecord.test.ts +37 -150
- package/src/crypto/keyManager.ts +28 -17
- package/src/crypto/polyfill.ts +5 -4
- package/src/crypto/signatureService.ts +67 -255
- package/src/index.ts +4 -3
- package/src/mixins/OxyServices.accounts.ts +136 -3
- package/src/mixins/OxyServices.assets.ts +96 -2
- package/src/mixins/OxyServices.auth.ts +1 -1
- package/src/mixins/OxyServices.civic.ts +6 -17
- package/src/mixins/OxyServices.identity.ts +8 -2
- package/src/mixins/OxyServices.language.ts +1 -1
- package/src/mixins/OxyServices.nodes.ts +1 -12
- package/src/mixins/OxyServices.utility.ts +6 -119
- package/src/mixins/__tests__/OxyServices.civic.test.ts +2 -2
- package/src/mixins/__tests__/OxyServices.serviceAssetMetadata.test.ts +116 -0
- package/src/mixins/__tests__/accounts.test.ts +120 -0
- package/src/models/interfaces.ts +19 -0
- package/src/server/auth.ts +0 -7
- package/src/server/index.ts +0 -1
- package/src/utils/__tests__/cacheKey.test.ts +0 -0
- package/src/utils/cacheKey.ts +16 -21
- package/src/utils/deviceManager.ts +1 -1
- package/src/utils/platform.ts +0 -14
- package/dist/cjs/crypto/canonicalJson.js +0 -107
- package/dist/cjs/utils/platformCrypto.js +0 -165
- package/dist/cjs/utils/platformCrypto.native.js +0 -123
- package/dist/esm/crypto/canonicalJson.js +0 -104
- package/dist/esm/utils/platformCrypto.js +0 -125
- package/dist/esm/utils/platformCrypto.native.js +0 -80
- package/dist/types/crypto/canonicalJson.d.ts +0 -44
- package/dist/types/utils/platformCrypto.d.ts +0 -87
- package/dist/types/utils/platformCrypto.native.d.ts +0 -54
- package/src/crypto/__tests__/canonicalJson.test.ts +0 -116
- package/src/crypto/canonicalJson.ts +0 -120
- package/src/utils/platformCrypto.native.ts +0 -101
- package/src/utils/platformCrypto.ts +0 -145
|
@@ -1,108 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Signature Service - ECDSA Digital Signatures
|
|
3
|
-
*
|
|
4
|
-
* Handles signing and verification of messages
|
|
5
|
-
*
|
|
2
|
+
* Signature Service - ECDSA Digital Signatures (device-key bound)
|
|
3
|
+
*
|
|
4
|
+
* Handles signing and verification of messages with the user's DEVICE identity
|
|
5
|
+
* key (read from {@link KeyManager} / secure storage). All cryptography itself —
|
|
6
|
+
* canonical signing input, SHA-256, secp256k1 sign/verify, envelope assembly —
|
|
7
|
+
* is delegated to `@oxyhq/protocol`; this service only resolves the key from
|
|
8
|
+
* storage and orchestrates the protocol primitives.
|
|
6
9
|
*/
|
|
7
10
|
|
|
8
|
-
import { ec as EC } from 'elliptic';
|
|
9
11
|
import type { SignedRecordEnvelope } from '@oxyhq/contracts';
|
|
12
|
+
import {
|
|
13
|
+
signEnvelope,
|
|
14
|
+
signMessage,
|
|
15
|
+
verifySignature,
|
|
16
|
+
sha256,
|
|
17
|
+
loadExpoCrypto,
|
|
18
|
+
loadNodeCrypto,
|
|
19
|
+
isReactNative,
|
|
20
|
+
isNodeJS,
|
|
21
|
+
} from '@oxyhq/protocol';
|
|
10
22
|
import { KeyManager } from './keyManager';
|
|
11
|
-
import { canonicalize } from './canonicalJson';
|
|
12
|
-
import { isReactNative, isNodeJS } from '../utils/platform';
|
|
13
|
-
import { loadExpoCrypto, loadNodeCrypto } from '../utils/platformCrypto';
|
|
14
23
|
import { logger } from '../utils/loggerUtils';
|
|
15
|
-
import { isDev } from '../shared/utils/debugUtils';
|
|
16
|
-
|
|
17
|
-
const ec = new EC('secp256k1');
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* The signing-input portion of a {@link SignedRecordEnvelope}: every field
|
|
21
|
-
* EXCEPT the `publicKey` and `signature`. Both the client (when signing) and
|
|
22
|
-
* the server (when verifying) canonicalize exactly these fields, so they agree
|
|
23
|
-
* on the bytes that the signature covers.
|
|
24
|
-
*
|
|
25
|
-
* The v2 chain fields (`seq`/`prev`/`collection`/`rkey`) are optional: a v1
|
|
26
|
-
* envelope omits them and is signed over only the base fields; a v2 envelope
|
|
27
|
-
* carries them and includes them in the signed bytes.
|
|
28
|
-
*/
|
|
29
|
-
export type SignedRecordSigningFields = Pick<
|
|
30
|
-
SignedRecordEnvelope,
|
|
31
|
-
'version' | 'type' | 'subject' | 'issuer' | 'record' | 'issuedAt'
|
|
32
|
-
> &
|
|
33
|
-
Partial<Pick<SignedRecordEnvelope, 'seq' | 'prev' | 'collection' | 'rkey'>>;
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Compute the canonical signing input for a signed-record envelope.
|
|
37
|
-
*
|
|
38
|
-
* This is the single definition of "what the signature covers". `@oxyhq/core`
|
|
39
|
-
* (client signing) and `@oxyhq/api` (server verification) both call this, so a
|
|
40
|
-
* record signed by a client and verified by the server cannot drift.
|
|
41
|
-
*
|
|
42
|
-
* - **v1**: the canonical JSON of `{version, type, subject, issuer, record,
|
|
43
|
-
* issuedAt}` — BYTE-IDENTICAL to the original scheme, so every signature
|
|
44
|
-
* already in production keeps verifying.
|
|
45
|
-
* - **v2**: the canonical JSON additionally includes the hash-chain fields
|
|
46
|
-
* `{seq, prev, collection, rkey}`. Because {@link canonicalize} sorts keys,
|
|
47
|
-
* the on-the-wire field order is irrelevant; the resulting canonical key
|
|
48
|
-
* order is `collection, issuedAt, issuer, prev, record, rkey, seq, subject,
|
|
49
|
-
* type, version`. `prev` is `null` at genesis (serialized as `null`, not
|
|
50
|
-
* omitted), so it is always part of the signed bytes.
|
|
51
|
-
*/
|
|
52
|
-
export function signedRecordSigningInput(fields: SignedRecordSigningFields): string {
|
|
53
|
-
const { version, type, subject, issuer, record, issuedAt } = fields;
|
|
54
|
-
if (version === 2) {
|
|
55
|
-
const { seq, prev, collection, rkey } = fields;
|
|
56
|
-
return canonicalize({ version, type, subject, issuer, record, issuedAt, seq, prev, collection, rkey });
|
|
57
|
-
}
|
|
58
|
-
return canonicalize({ version, type, subject, issuer, record, issuedAt });
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Compute the `recordId` (content address) of a signed record: the SHA-256 hex
|
|
63
|
-
* digest of its canonical {@link signedRecordSigningInput}.
|
|
64
|
-
*
|
|
65
|
-
* Deterministic and stable across runtimes (it reuses the same canonicalization
|
|
66
|
-
* + SHA-256 the signature itself is built on). The recordId is what `prev`
|
|
67
|
-
* references in the per-subject hash chain, so `@oxyhq/core` (client) and
|
|
68
|
-
* `@oxyhq/api` (server) MUST compute it identically — both call this function.
|
|
69
|
-
* It is taken over the SIGNING input (excluding `publicKey`/`signature`), so it
|
|
70
|
-
* is a pure content address of the record's meaning, independent of who signed.
|
|
71
|
-
*/
|
|
72
|
-
export async function computeRecordId(fields: SignedRecordSigningFields): Promise<string> {
|
|
73
|
-
return sha256(signedRecordSigningInput(fields));
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Compute SHA-256 hash of a string
|
|
78
|
-
*/
|
|
79
|
-
async function sha256(message: string): Promise<string> {
|
|
80
|
-
// In React Native, use expo-crypto
|
|
81
|
-
if (isReactNative()) {
|
|
82
|
-
const Crypto = await loadExpoCrypto();
|
|
83
|
-
return Crypto.digestStringAsync(
|
|
84
|
-
Crypto.CryptoDigestAlgorithm.SHA256,
|
|
85
|
-
message
|
|
86
|
-
);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
if (isNodeJS()) {
|
|
90
|
-
try {
|
|
91
|
-
const nodeCrypto = await loadNodeCrypto();
|
|
92
|
-
return nodeCrypto.createHash('sha256').update(message).digest('hex');
|
|
93
|
-
} catch (error) {
|
|
94
|
-
// Node crypto failed to load — log and fall through to Web Crypto API
|
|
95
|
-
logger.warn('[oxy.crypto] Node crypto unavailable, falling back to Web Crypto', { component: 'SignatureService' }, error);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// Browser: use Web Crypto API
|
|
100
|
-
const encoder = new TextEncoder();
|
|
101
|
-
const data = encoder.encode(message);
|
|
102
|
-
const hashBuffer = await globalThis.crypto.subtle.digest('SHA-256', data);
|
|
103
|
-
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
104
|
-
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
|
105
|
-
}
|
|
106
24
|
|
|
107
25
|
export interface SignedMessage {
|
|
108
26
|
message: string;
|
|
@@ -158,78 +76,15 @@ export class SignatureService {
|
|
|
158
76
|
}
|
|
159
77
|
|
|
160
78
|
/**
|
|
161
|
-
* Sign a message using the stored private key
|
|
79
|
+
* Sign a message using the stored device private key
|
|
162
80
|
* Returns the signature in DER format (hex encoded)
|
|
163
81
|
*/
|
|
164
82
|
static async sign(message: string): Promise<string> {
|
|
165
|
-
const
|
|
166
|
-
if (!
|
|
83
|
+
const privateKey = await KeyManager.getPrivateKey();
|
|
84
|
+
if (!privateKey) {
|
|
167
85
|
throw new Error('No identity found. Please create or import an identity first.');
|
|
168
86
|
}
|
|
169
|
-
|
|
170
|
-
const messageHash = await sha256(message);
|
|
171
|
-
const signature = keyPair.sign(messageHash);
|
|
172
|
-
return signature.toDER('hex');
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* Sign a message with an explicit private key (without storing)
|
|
177
|
-
* Useful for one-time operations or testing
|
|
178
|
-
*/
|
|
179
|
-
static async signWithKey(message: string, privateKey: string): Promise<string> {
|
|
180
|
-
const keyPair = ec.keyFromPrivate(privateKey);
|
|
181
|
-
const messageHash = await sha256(message);
|
|
182
|
-
const signature = keyPair.sign(messageHash);
|
|
183
|
-
return signature.toDER('hex');
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* Verify a signature against a message and public key
|
|
188
|
-
*
|
|
189
|
-
* Returns false on any error (invalid signature, malformed input, etc.).
|
|
190
|
-
* Errors are logged at debug level so they're available when troubleshooting
|
|
191
|
-
* signature mismatches but don't surface to the caller.
|
|
192
|
-
*/
|
|
193
|
-
static async verify(message: string, signature: string, publicKey: string): Promise<boolean> {
|
|
194
|
-
try {
|
|
195
|
-
const key = ec.keyFromPublic(publicKey, 'hex');
|
|
196
|
-
const messageHash = await sha256(message);
|
|
197
|
-
return key.verify(messageHash, signature);
|
|
198
|
-
} catch (error) {
|
|
199
|
-
if (isDev()) {
|
|
200
|
-
logger.debug('[oxy.crypto] verify() returned false', { component: 'SignatureService' }, error);
|
|
201
|
-
}
|
|
202
|
-
return false;
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Synchronous verification (for Node.js backend)
|
|
208
|
-
* Uses crypto module directly for hashing
|
|
209
|
-
* Note: This method should only be used in Node.js environments
|
|
210
|
-
*/
|
|
211
|
-
static verifySync(message: string, signature: string, publicKey: string): boolean {
|
|
212
|
-
try {
|
|
213
|
-
if (!isNodeJS()) {
|
|
214
|
-
// In React Native, use async verify instead
|
|
215
|
-
throw new Error('verifySync should only be used in Node.js. Use verify() in React Native.');
|
|
216
|
-
}
|
|
217
|
-
// Intentionally using Function constructor here: this method is synchronous by design
|
|
218
|
-
// (Node.js backend hot-path) so we cannot use `await import()`. The Function constructor
|
|
219
|
-
// prevents Metro/bundlers from statically resolving the require. This is acceptable because
|
|
220
|
-
// verifySync is gated by isNodeJS() and will never execute in browser/RN environments.
|
|
221
|
-
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
|
222
|
-
const getCrypto = new Function('return require("crypto")');
|
|
223
|
-
const crypto = getCrypto();
|
|
224
|
-
const key = ec.keyFromPublic(publicKey, 'hex');
|
|
225
|
-
const messageHash = crypto.createHash('sha256').update(message).digest('hex');
|
|
226
|
-
return key.verify(messageHash, signature);
|
|
227
|
-
} catch (error) {
|
|
228
|
-
if (isDev()) {
|
|
229
|
-
logger.debug('[oxy.crypto] verifySync() returned false', { component: 'SignatureService' }, error);
|
|
230
|
-
}
|
|
231
|
-
return false;
|
|
232
|
-
}
|
|
87
|
+
return signMessage(message, privateKey);
|
|
233
88
|
}
|
|
234
89
|
|
|
235
90
|
/**
|
|
@@ -271,7 +126,7 @@ export class SignatureService {
|
|
|
271
126
|
|
|
272
127
|
// Verify signature
|
|
273
128
|
const messageWithTimestamp = `${message}:${timestamp}`;
|
|
274
|
-
return
|
|
129
|
+
return verifySignature(messageWithTimestamp, signature, publicKey);
|
|
275
130
|
}
|
|
276
131
|
|
|
277
132
|
/**
|
|
@@ -303,9 +158,10 @@ export class SignatureService {
|
|
|
303
158
|
* Mirrors {@link signChallenge} exactly — same message format
|
|
304
159
|
* (`auth:${publicKey}:${challenge}:${timestamp}`) so the server verification
|
|
305
160
|
* path is unchanged — but sources the shared public/private key from
|
|
306
|
-
* `KeyManager` and signs with
|
|
307
|
-
* same-device shared-keychain SSO (Mechanism A): a
|
|
308
|
-
* control of the shared identity to mint its own
|
|
161
|
+
* `KeyManager` and signs with the protocol's explicit-key {@link signMessage}.
|
|
162
|
+
* Used by "Sign in with Oxy" same-device shared-keychain SSO (Mechanism A): a
|
|
163
|
+
* sibling native app proves control of the shared identity to mint its own
|
|
164
|
+
* session.
|
|
309
165
|
*
|
|
310
166
|
* Throws if no shared identity exists (native-only; the shared keychain is
|
|
311
167
|
* unavailable on web).
|
|
@@ -319,7 +175,7 @@ export class SignatureService {
|
|
|
319
175
|
|
|
320
176
|
const timestamp = Date.now();
|
|
321
177
|
const message = `auth:${publicKey}:${challenge}:${timestamp}`;
|
|
322
|
-
const signature = await
|
|
178
|
+
const signature = await signMessage(message, privateKey);
|
|
323
179
|
|
|
324
180
|
return {
|
|
325
181
|
challenge: signature,
|
|
@@ -345,7 +201,7 @@ export class SignatureService {
|
|
|
345
201
|
}
|
|
346
202
|
|
|
347
203
|
const message = `auth:${publicKey}:${originalChallenge}:${timestamp}`;
|
|
348
|
-
return
|
|
204
|
+
return verifySignature(message, signature, publicKey);
|
|
349
205
|
}
|
|
350
206
|
|
|
351
207
|
/**
|
|
@@ -385,12 +241,12 @@ export class SignatureService {
|
|
|
385
241
|
}
|
|
386
242
|
|
|
387
243
|
const timestamp = Date.now();
|
|
388
|
-
|
|
244
|
+
|
|
389
245
|
// Create canonical string representation
|
|
390
246
|
const sortedKeys = Object.keys(data).sort();
|
|
391
247
|
const canonicalParts = sortedKeys.map(key => `${key}:${JSON.stringify(data[key])}`);
|
|
392
248
|
const canonicalString = canonicalParts.join('|');
|
|
393
|
-
|
|
249
|
+
|
|
394
250
|
const message = `request:${publicKey}:${timestamp}:${canonicalString}`;
|
|
395
251
|
const signature = await SignatureService.sign(message);
|
|
396
252
|
|
|
@@ -406,9 +262,11 @@ export class SignatureService {
|
|
|
406
262
|
*
|
|
407
263
|
* The envelope is self-issued: `issuer` equals `subject` (the signer's DID).
|
|
408
264
|
* The signature covers the canonical JSON of every field EXCEPT `publicKey`
|
|
409
|
-
* and `signature` (
|
|
410
|
-
*
|
|
411
|
-
*
|
|
265
|
+
* and `signature`; `alg` is `ES256K-DER-SHA256` (secp256k1 over the SHA-256 of
|
|
266
|
+
* the canonical bytes, DER-encoded). The cryptography is delegated to the
|
|
267
|
+
* protocol's {@link signEnvelope}, which derives the (uncompressed-hex)
|
|
268
|
+
* `publicKey` from the stored device key — identical to the registered
|
|
269
|
+
* verification method.
|
|
412
270
|
*
|
|
413
271
|
* Requires a stored identity (native secure storage); throws if none exists.
|
|
414
272
|
*
|
|
@@ -421,35 +279,22 @@ export class SignatureService {
|
|
|
421
279
|
subject: string,
|
|
422
280
|
record: Record<string, unknown>,
|
|
423
281
|
): Promise<SignedRecordEnvelope> {
|
|
424
|
-
const
|
|
425
|
-
if (!
|
|
282
|
+
const privateKey = await KeyManager.getPrivateKey();
|
|
283
|
+
if (!privateKey) {
|
|
426
284
|
throw new Error('No identity found. Please create or import an identity first.');
|
|
427
285
|
}
|
|
428
286
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
const signature = await SignatureService.sign(signingInput);
|
|
441
|
-
|
|
442
|
-
return {
|
|
443
|
-
version,
|
|
444
|
-
type,
|
|
445
|
-
subject,
|
|
446
|
-
issuer,
|
|
447
|
-
record,
|
|
448
|
-
issuedAt,
|
|
449
|
-
publicKey,
|
|
450
|
-
alg: 'ES256K-DER-SHA256',
|
|
451
|
-
signature,
|
|
452
|
-
};
|
|
287
|
+
return signEnvelope(
|
|
288
|
+
{
|
|
289
|
+
version: 1,
|
|
290
|
+
type,
|
|
291
|
+
subject,
|
|
292
|
+
issuer: subject,
|
|
293
|
+
record,
|
|
294
|
+
issuedAt: Date.now(),
|
|
295
|
+
},
|
|
296
|
+
privateKey,
|
|
297
|
+
);
|
|
453
298
|
}
|
|
454
299
|
|
|
455
300
|
/**
|
|
@@ -457,8 +302,8 @@ export class SignatureService {
|
|
|
457
302
|
* fields.
|
|
458
303
|
*
|
|
459
304
|
* Identical to {@link signRecord} (self-issued: `issuer === subject`; same
|
|
460
|
-
* `ES256K-DER-SHA256` scheme
|
|
461
|
-
*
|
|
305
|
+
* `ES256K-DER-SHA256` scheme) but `version` is `2` and the signed bytes
|
|
306
|
+
* additionally cover the chain fields:
|
|
462
307
|
*
|
|
463
308
|
* @param type - The record category.
|
|
464
309
|
* @param subject - The subject DID the record is about (also the issuer).
|
|
@@ -478,60 +323,27 @@ export class SignatureService {
|
|
|
478
323
|
record: Record<string, unknown>,
|
|
479
324
|
chain: { seq: number; prev: string | null; collection: string; rkey: string },
|
|
480
325
|
): Promise<SignedRecordEnvelope> {
|
|
481
|
-
const
|
|
482
|
-
if (!
|
|
326
|
+
const privateKey = await KeyManager.getPrivateKey();
|
|
327
|
+
if (!privateKey) {
|
|
483
328
|
throw new Error('No identity found. Please create or import an identity first.');
|
|
484
329
|
}
|
|
485
330
|
|
|
486
|
-
const version = 2 as const;
|
|
487
|
-
const issuer = subject;
|
|
488
|
-
const issuedAt = Date.now();
|
|
489
331
|
const { seq, prev, collection, rkey } = chain;
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
version,
|
|
506
|
-
type,
|
|
507
|
-
subject,
|
|
508
|
-
issuer,
|
|
509
|
-
record,
|
|
510
|
-
issuedAt,
|
|
511
|
-
seq,
|
|
512
|
-
prev,
|
|
513
|
-
collection,
|
|
514
|
-
rkey,
|
|
515
|
-
publicKey,
|
|
516
|
-
alg: 'ES256K-DER-SHA256',
|
|
517
|
-
signature,
|
|
518
|
-
};
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
/**
|
|
522
|
-
* Verify a signed-record envelope: recompute the canonical signing input from
|
|
523
|
-
* the envelope's own fields and check the signature against the envelope's
|
|
524
|
-
* `publicKey`.
|
|
525
|
-
*
|
|
526
|
-
* Note: this confirms the signature is internally consistent with the
|
|
527
|
-
* embedded `publicKey`. It does NOT establish that `publicKey` is an
|
|
528
|
-
* authorized verification method for `subject` — that authorization check is
|
|
529
|
-
* the server's responsibility (it asserts the key is a current verification
|
|
530
|
-
* method on the subject's DID).
|
|
531
|
-
*/
|
|
532
|
-
static async verifyRecord(envelope: SignedRecordEnvelope): Promise<boolean> {
|
|
533
|
-
const signingInput = signedRecordSigningInput(envelope);
|
|
534
|
-
return SignatureService.verify(signingInput, envelope.signature, envelope.publicKey);
|
|
332
|
+
return signEnvelope(
|
|
333
|
+
{
|
|
334
|
+
version: 2,
|
|
335
|
+
type,
|
|
336
|
+
subject,
|
|
337
|
+
issuer: subject,
|
|
338
|
+
record,
|
|
339
|
+
issuedAt: Date.now(),
|
|
340
|
+
seq,
|
|
341
|
+
prev,
|
|
342
|
+
collection,
|
|
343
|
+
rkey,
|
|
344
|
+
},
|
|
345
|
+
privateKey,
|
|
346
|
+
);
|
|
535
347
|
}
|
|
536
348
|
}
|
|
537
349
|
|
package/src/index.ts
CHANGED
|
@@ -125,6 +125,7 @@ export type {
|
|
|
125
125
|
TransferAccountOwnershipInput,
|
|
126
126
|
CreateAccountCredentialInput,
|
|
127
127
|
AccountSuccessResult,
|
|
128
|
+
SwitchAccountResult,
|
|
128
129
|
// Applications owned within the account graph (Application = OAuth client).
|
|
129
130
|
Application,
|
|
130
131
|
ApplicationType,
|
|
@@ -263,9 +264,8 @@ export {
|
|
|
263
264
|
IdentityPersistError,
|
|
264
265
|
} from './crypto/keyManager';
|
|
265
266
|
export type { KeyPair } from './crypto/keyManager';
|
|
266
|
-
export { SignatureService
|
|
267
|
-
export type { SignedMessage, AuthChallenge
|
|
268
|
-
export { canonicalize } from './crypto/canonicalJson';
|
|
267
|
+
export { SignatureService } from './crypto/signatureService';
|
|
268
|
+
export type { SignedMessage, AuthChallenge } from './crypto/signatureService';
|
|
269
269
|
export { RecoveryPhraseService } from './crypto/recoveryPhrase';
|
|
270
270
|
export type { RecoveryPhraseResult } from './crypto/recoveryPhrase';
|
|
271
271
|
|
|
@@ -324,6 +324,7 @@ export type {
|
|
|
324
324
|
AssetDeleteSummary,
|
|
325
325
|
AssetUpdateVisibilityRequest,
|
|
326
326
|
AssetUpdateVisibilityResponse,
|
|
327
|
+
ServiceAssetMetadata,
|
|
327
328
|
AccountStorageCategoryUsage,
|
|
328
329
|
AccountStorageUsageResponse,
|
|
329
330
|
SecurityEventType,
|
|
@@ -25,12 +25,18 @@
|
|
|
25
25
|
* member `_id`, and credentials by their `credentialId`. Never by name, slug, or
|
|
26
26
|
* handle.
|
|
27
27
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
28
|
+
* SWITCHING INTO AN ACCOUNT: `switchToAccount(accountId)` mints a REAL session
|
|
29
|
+
* for the target account and plants it as the active session — there is no
|
|
30
|
+
* per-request "acting-as" header. Identity is carried by the session/token, not
|
|
31
|
+
* a delegation header, so a switch propagates through reload and `refresh-all`
|
|
32
|
+
* exactly like a login.
|
|
31
33
|
*/
|
|
32
34
|
import type { User } from '../models/interfaces';
|
|
35
|
+
import type { SessionLoginResponse } from '../models/session';
|
|
33
36
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
37
|
+
import { normalizeUserIdentity } from '../utils/userIdentity';
|
|
38
|
+
import { isWeb } from '../utils/platform';
|
|
39
|
+
import { logger } from '../utils/loggerUtils';
|
|
34
40
|
import { CACHE_TIMES } from './mixinHelpers';
|
|
35
41
|
|
|
36
42
|
// ---------------------------------------------------------------------------
|
|
@@ -427,6 +433,29 @@ export interface AccountSuccessResult {
|
|
|
427
433
|
success: boolean;
|
|
428
434
|
}
|
|
429
435
|
|
|
436
|
+
/**
|
|
437
|
+
* Result of {@link OxyServicesAccountsMixin.switchToAccount} — the freshly
|
|
438
|
+
* minted session for the target account, in the SAME shape the canonical login
|
|
439
|
+
* / `claimSessionByToken` responses use (`SessionLoginResponse`), plus the
|
|
440
|
+
* device-local refresh-cookie slot index.
|
|
441
|
+
*
|
|
442
|
+
* `accessToken` is the first access token for the new session (already planted
|
|
443
|
+
* as the active token by `switchToAccount`). The refresh token is NOT in the
|
|
444
|
+
* body — the server sets it as the httpOnly `oxy_rt_<authuser>` cookie, which
|
|
445
|
+
* joins the device multi-account set so the switched session survives reload and
|
|
446
|
+
* propagates cross-domain via `/auth/refresh-all`. `user` is the target account.
|
|
447
|
+
*/
|
|
448
|
+
export interface SwitchAccountResult extends SessionLoginResponse {
|
|
449
|
+
/**
|
|
450
|
+
* The device-local refresh-cookie slot index (`oxy_rt_<authuser>`) the server
|
|
451
|
+
* assigned to the minted session. Surfaced so the consumer can register the
|
|
452
|
+
* new session in its device multi-account set exactly like a login response.
|
|
453
|
+
* Absent only when the server could not set the cookie (best-effort — the
|
|
454
|
+
* switch itself still succeeds).
|
|
455
|
+
*/
|
|
456
|
+
authuser?: number;
|
|
457
|
+
}
|
|
458
|
+
|
|
430
459
|
export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
431
460
|
return class extends Base {
|
|
432
461
|
constructor(...args: any[]) {
|
|
@@ -480,6 +509,110 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
480
509
|
}
|
|
481
510
|
}
|
|
482
511
|
|
|
512
|
+
/**
|
|
513
|
+
* Switch the active session INTO a managed account.
|
|
514
|
+
*
|
|
515
|
+
* Calls `POST /accounts/:id/switch` with the signed-in operator's bearer.
|
|
516
|
+
* The server authorises the operator (must hold `account:act_as` over the
|
|
517
|
+
* target, directly or inherited — else 403; 404 if missing/archived; 403 if
|
|
518
|
+
* the target is a personal account), then mints a REAL session for the
|
|
519
|
+
* target account and returns it in the canonical login / `claimSessionByToken`
|
|
520
|
+
* shape (`{ sessionId, deviceId, expiresAt, accessToken, user, authuser }`).
|
|
521
|
+
*
|
|
522
|
+
* Unlike the removed `X-Acting-As` delegation header, the returned session
|
|
523
|
+
* IS the new identity: this plants `accessToken` as the active token —
|
|
524
|
+
* exactly like `claimSessionByToken` / `verifyChallenge` — so every
|
|
525
|
+
* subsequent request authenticates as the target account.
|
|
526
|
+
*
|
|
527
|
+
* Joining the device multi-account set (so the switch survives a reload and
|
|
528
|
+
* propagates cross-domain via `/auth/refresh-all`) requires a SECOND call, to
|
|
529
|
+
* `POST /auth/session`, made here after the token is planted. The switch route
|
|
530
|
+
* lives at `/accounts/*`, OUTSIDE the `oxy_rt_<authuser>` cookie's `Path=/auth`
|
|
531
|
+
* scope, so the server never sees the device's existing slots from it and
|
|
532
|
+
* would clobber slot 0 (destroying the operator's own session). `/auth/session`
|
|
533
|
+
* runs where those cookies ARE visible, so the server allocates a NEW slot that
|
|
534
|
+
* coexists with the operator's and returns its `authuser`. This step is
|
|
535
|
+
* web-only (native multi-account uses stored sessions, not cookies) and
|
|
536
|
+
* best-effort — a failure leaves the in-session switch intact; the switched
|
|
537
|
+
* account simply won't survive a reload until the cookie is next established.
|
|
538
|
+
*
|
|
539
|
+
* After planting, the SDK's identity-scoped GET cache is fully cleared so
|
|
540
|
+
* every cached read re-fetches as the new account. (The consuming
|
|
541
|
+
* `OxyContext` additionally invalidates its React Query cache and updates
|
|
542
|
+
* session state from the returned `user`; this clears the SDK's own HTTP
|
|
543
|
+
* cache at the source — `setTokens` deliberately preserves the warm cache for
|
|
544
|
+
* same-user silent refreshes, so the sweep here is explicit.)
|
|
545
|
+
*
|
|
546
|
+
* @param accountId - The target account's Mongo `_id`.
|
|
547
|
+
* @returns The minted session (planted) plus the device `authuser` slot.
|
|
548
|
+
*/
|
|
549
|
+
async switchToAccount(accountId: string): Promise<SwitchAccountResult> {
|
|
550
|
+
try {
|
|
551
|
+
const res = await this.makeRequest<SwitchAccountResult>(
|
|
552
|
+
'POST',
|
|
553
|
+
`/accounts/${encodeURIComponent(accountId)}/switch`,
|
|
554
|
+
undefined,
|
|
555
|
+
{ cache: false },
|
|
556
|
+
);
|
|
557
|
+
|
|
558
|
+
// Plant the freshly minted session as the ACTIVE session, mirroring
|
|
559
|
+
// `claimSessionByToken` / `verifyChallenge`: the response body carries
|
|
560
|
+
// the first access token. The device refresh cookie is established below.
|
|
561
|
+
if (res?.accessToken) {
|
|
562
|
+
this.setTokens(res.accessToken);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Register the switched session in the device's multi-account set by
|
|
566
|
+
// establishing its first-party refresh cookie. This MUST be a separate
|
|
567
|
+
// call to `POST /auth/session`: the switch route is at `/accounts/*`,
|
|
568
|
+
// outside the `oxy_rt_<authuser>` cookie's `Path=/auth` scope, so it can
|
|
569
|
+
// never read the device's existing slots and would overwrite slot 0
|
|
570
|
+
// (destroying the operator's own session). `/auth/session` runs where the
|
|
571
|
+
// cookies ARE visible, so the server allocates a NEW slot that coexists
|
|
572
|
+
// with the operator's and returns its `authuser`. Web-only; best-effort.
|
|
573
|
+
let authuser = res.authuser;
|
|
574
|
+
if (isWeb()) {
|
|
575
|
+
try {
|
|
576
|
+
const established = await this.makeRequest<{ accessToken?: string; authuser?: number }>(
|
|
577
|
+
'POST',
|
|
578
|
+
'/auth/session',
|
|
579
|
+
undefined,
|
|
580
|
+
{ cache: false },
|
|
581
|
+
);
|
|
582
|
+
if (typeof established?.authuser === 'number') {
|
|
583
|
+
authuser = established.authuser;
|
|
584
|
+
}
|
|
585
|
+
// /auth/session mints a fresh access token off the same session;
|
|
586
|
+
// re-plant it so the active token matches the rotated cookie.
|
|
587
|
+
if (established?.accessToken) {
|
|
588
|
+
this.setTokens(established.accessToken);
|
|
589
|
+
}
|
|
590
|
+
} catch (error) {
|
|
591
|
+
logger.warn(
|
|
592
|
+
'[OxyServices] Failed to establish device refresh cookie after account switch; the switch is active in-session but may not survive a reload',
|
|
593
|
+
{ component: 'OxyServices', method: 'switchToAccount' },
|
|
594
|
+
error,
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Identity changed → drop the entire GET response cache so no entry
|
|
600
|
+
// personalised for the previous identity is reused. Cache keys are
|
|
601
|
+
// identity-scoped, so a different identity could not READ the old
|
|
602
|
+
// entries anyway, but clearing guarantees a clean refetch as the new
|
|
603
|
+
// account and frees the prior identity's resident data.
|
|
604
|
+
this.clearCache();
|
|
605
|
+
|
|
606
|
+
return {
|
|
607
|
+
...res,
|
|
608
|
+
...(typeof authuser === 'number' ? { authuser } : {}),
|
|
609
|
+
user: normalizeUserIdentity(res.user),
|
|
610
|
+
};
|
|
611
|
+
} catch (error) {
|
|
612
|
+
throw this.handleError(error);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
483
616
|
/**
|
|
484
617
|
* Create a new (non-personal) account. The caller becomes its `owner`.
|
|
485
618
|
* @param data - Account configuration: kind, optional parent, and profile.
|