@oxyhq/core 3.10.1 → 3.11.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/AuthManager.js +9 -2
- package/dist/cjs/HttpService.js +27 -9
- package/dist/cjs/OxyServices.base.js +3 -2
- package/dist/cjs/crypto/canonicalJson.js +107 -0
- package/dist/cjs/crypto/keyManager.js +67 -8
- package/dist/cjs/crypto/signatureService.js +103 -0
- package/dist/cjs/index.js +15 -4
- package/dist/cjs/mixins/OxyServices.assets.js +16 -1
- package/dist/cjs/mixins/OxyServices.auth.js +190 -1
- package/dist/cjs/mixins/OxyServices.identity.js +291 -0
- package/dist/cjs/mixins/OxyServices.sso.js +28 -1
- package/dist/cjs/mixins/OxyServices.user.js +1 -0
- package/dist/cjs/mixins/index.js +3 -0
- package/dist/cjs/server/cors.js +20 -21
- package/dist/cjs/server/rateLimit.js +32 -8
- package/dist/cjs/utils/ssoReturn.js +1 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/AuthManager.js +9 -2
- package/dist/esm/HttpService.js +27 -9
- package/dist/esm/OxyServices.base.js +3 -2
- package/dist/esm/crypto/canonicalJson.js +104 -0
- package/dist/esm/crypto/keyManager.js +67 -8
- package/dist/esm/crypto/signatureService.js +102 -0
- package/dist/esm/index.js +9 -1
- package/dist/esm/mixins/OxyServices.assets.js +16 -1
- package/dist/esm/mixins/OxyServices.auth.js +190 -1
- package/dist/esm/mixins/OxyServices.identity.js +287 -0
- package/dist/esm/mixins/OxyServices.sso.js +28 -1
- package/dist/esm/mixins/OxyServices.user.js +1 -0
- package/dist/esm/mixins/index.js +3 -0
- package/dist/esm/server/cors.js +20 -21
- package/dist/esm/server/rateLimit.js +32 -8
- package/dist/esm/utils/ssoReturn.js +1 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +3 -0
- package/dist/types/OxyServices.d.ts +2 -2
- package/dist/types/crypto/canonicalJson.d.ts +44 -0
- package/dist/types/crypto/keyManager.d.ts +7 -0
- package/dist/types/crypto/signatureService.d.ts +61 -0
- package/dist/types/index.d.ts +6 -2
- package/dist/types/mixins/OxyServices.auth.d.ts +136 -0
- package/dist/types/mixins/OxyServices.identity.d.ts +249 -0
- package/dist/types/mixins/OxyServices.sso.d.ts +4 -1
- package/dist/types/mixins/index.d.ts +2 -1
- package/dist/types/models/interfaces.d.ts +3 -0
- package/dist/types/server/cors.d.ts +5 -5
- package/dist/types/utils/ssoReturn.d.ts +1 -1
- package/package.json +2 -2
- package/src/AuthManager.ts +8 -2
- package/src/HttpService.ts +36 -8
- package/src/OxyServices.base.ts +3 -2
- package/src/OxyServices.ts +1 -1
- package/src/__tests__/authManager.security.test.ts +31 -0
- package/src/__tests__/httpServiceCsrf.test.ts +75 -0
- package/src/crypto/__tests__/canonicalJson.test.ts +116 -0
- package/src/crypto/__tests__/keyManager.atomicity.test.ts +41 -2
- package/src/crypto/__tests__/signChallengeShared.test.ts +64 -0
- package/src/crypto/__tests__/signedRecord.test.ts +125 -0
- package/src/crypto/canonicalJson.ts +120 -0
- package/src/crypto/keyManager.ts +62 -12
- package/src/crypto/signatureService.ts +126 -0
- package/src/index.ts +27 -2
- package/src/mixins/OxyServices.assets.ts +16 -1
- package/src/mixins/OxyServices.auth.ts +309 -1
- package/src/mixins/OxyServices.identity.ts +445 -0
- package/src/mixins/OxyServices.sso.ts +30 -1
- package/src/mixins/OxyServices.user.ts +1 -0
- package/src/mixins/__tests__/OxyServices.identity.test.ts +364 -0
- package/src/mixins/__tests__/assetCredentials.test.ts +47 -0
- package/src/mixins/__tests__/commonsSignIn.test.ts +277 -0
- package/src/mixins/__tests__/serviceAuth.test.ts +19 -0
- package/src/mixins/__tests__/sso.test.ts +31 -0
- package/src/mixins/index.ts +4 -0
- package/src/models/interfaces.ts +3 -0
- package/src/server/__tests__/cors.test.ts +5 -1
- package/src/server/__tests__/rateLimit.test.ts +116 -0
- package/src/server/cors.ts +25 -20
- package/src/server/rateLimit.ts +39 -8
- package/src/utils/__tests__/consumeSsoReturn.test.ts +1 -1
- package/src/utils/__tests__/ssoReturn.test.ts +1 -1
- package/src/utils/ssoReturn.ts +2 -2
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signed-record envelope tests.
|
|
3
|
+
*
|
|
4
|
+
* Exercises the full client-side path: `SignatureService.signRecord` builds an
|
|
5
|
+
* envelope whose signature covers the canonical JSON of every field EXCEPT
|
|
6
|
+
* `publicKey`/`signature`, and `SignatureService.verifyRecord` round-trips it.
|
|
7
|
+
* We mock `KeyManager`'s key access with a REAL elliptic secp256k1 keypair, so
|
|
8
|
+
* the signing/verification is genuine cryptography (not a stub).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { ec as EC } from 'elliptic';
|
|
12
|
+
import type { SignedRecordEnvelope } from '@oxyhq/contracts';
|
|
13
|
+
import { KeyManager } from '../keyManager';
|
|
14
|
+
import { SignatureService, signedRecordSigningInput } from '../signatureService';
|
|
15
|
+
import { canonicalize } from '../canonicalJson';
|
|
16
|
+
|
|
17
|
+
const ec = new EC('secp256k1');
|
|
18
|
+
|
|
19
|
+
describe('SignatureService.signRecord / verifyRecord', () => {
|
|
20
|
+
const keyPair = ec.genKeyPair();
|
|
21
|
+
const publicKey = keyPair.getPublic('hex');
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue(publicKey);
|
|
25
|
+
jest.spyOn(KeyManager, 'getKeyPairObject').mockResolvedValue(keyPair);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
jest.restoreAllMocks();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('builds a well-formed self-issued envelope', async () => {
|
|
33
|
+
const subject = 'did:web:oxy.so:u:abc123';
|
|
34
|
+
const record = { displayName: 'Nate', bio: 'builder' };
|
|
35
|
+
|
|
36
|
+
const envelope = await SignatureService.signRecord('profile', subject, record);
|
|
37
|
+
|
|
38
|
+
expect(envelope.version).toBe(1);
|
|
39
|
+
expect(envelope.type).toBe('profile');
|
|
40
|
+
expect(envelope.subject).toBe(subject);
|
|
41
|
+
expect(envelope.issuer).toBe(subject); // self-issued
|
|
42
|
+
expect(envelope.record).toEqual(record);
|
|
43
|
+
expect(envelope.publicKey).toBe(publicKey);
|
|
44
|
+
expect(envelope.alg).toBe('ES256K-DER-SHA256');
|
|
45
|
+
expect(typeof envelope.signature).toBe('string');
|
|
46
|
+
expect(envelope.signature.length).toBeGreaterThan(0);
|
|
47
|
+
expect(typeof envelope.issuedAt).toBe('number');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('round-trips: a freshly signed record verifies', async () => {
|
|
51
|
+
const envelope = await SignatureService.signRecord('identity', 'did:web:oxy.so:u:u1', {
|
|
52
|
+
handle: '@nate',
|
|
53
|
+
nested: { a: 1, b: [2, 3] },
|
|
54
|
+
});
|
|
55
|
+
await expect(SignatureService.verifyRecord(envelope)).resolves.toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('signs the canonical JSON of every field except publicKey + signature', async () => {
|
|
59
|
+
const subject = 'did:web:oxy.so:u:u2';
|
|
60
|
+
const record = { z: 1, a: 2 };
|
|
61
|
+
const envelope = await SignatureService.signRecord('profile', subject, record);
|
|
62
|
+
|
|
63
|
+
const expectedInput = canonicalize({
|
|
64
|
+
version: envelope.version,
|
|
65
|
+
type: envelope.type,
|
|
66
|
+
subject: envelope.subject,
|
|
67
|
+
issuer: envelope.issuer,
|
|
68
|
+
record: envelope.record,
|
|
69
|
+
issuedAt: envelope.issuedAt,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// The helper reproduces exactly that input from the envelope.
|
|
73
|
+
expect(signedRecordSigningInput(envelope)).toBe(expectedInput);
|
|
74
|
+
// And it omits publicKey/signature.
|
|
75
|
+
expect(expectedInput).not.toContain(envelope.publicKey);
|
|
76
|
+
expect(expectedInput).not.toContain(envelope.signature);
|
|
77
|
+
|
|
78
|
+
// The signature verifies against that exact input + the embedded key.
|
|
79
|
+
await expect(
|
|
80
|
+
SignatureService.verify(expectedInput, envelope.signature, envelope.publicKey),
|
|
81
|
+
).resolves.toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('tamper detection', () => {
|
|
85
|
+
let envelope: SignedRecordEnvelope;
|
|
86
|
+
|
|
87
|
+
beforeEach(async () => {
|
|
88
|
+
envelope = await SignatureService.signRecord('profile', 'did:web:oxy.so:u:u3', {
|
|
89
|
+
displayName: 'Original',
|
|
90
|
+
score: 10,
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('rejects a mutated record', async () => {
|
|
95
|
+
const tampered: SignedRecordEnvelope = {
|
|
96
|
+
...envelope,
|
|
97
|
+
record: { ...envelope.record, displayName: 'Tampered' },
|
|
98
|
+
};
|
|
99
|
+
await expect(SignatureService.verifyRecord(tampered)).resolves.toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('rejects a changed subject', async () => {
|
|
103
|
+
const tampered: SignedRecordEnvelope = { ...envelope, subject: 'did:web:oxy.so:u:evil' };
|
|
104
|
+
await expect(SignatureService.verifyRecord(tampered)).resolves.toBe(false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('rejects a changed issuedAt', async () => {
|
|
108
|
+
const tampered: SignedRecordEnvelope = { ...envelope, issuedAt: envelope.issuedAt + 1 };
|
|
109
|
+
await expect(SignatureService.verifyRecord(tampered)).resolves.toBe(false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('rejects verification against an unrelated public key', async () => {
|
|
113
|
+
const otherKey = ec.genKeyPair().getPublic('hex');
|
|
114
|
+
const tampered: SignedRecordEnvelope = { ...envelope, publicKey: otherKey };
|
|
115
|
+
await expect(SignatureService.verifyRecord(tampered)).resolves.toBe(false);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('throws when no identity is stored', async () => {
|
|
120
|
+
jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue(null);
|
|
121
|
+
await expect(
|
|
122
|
+
SignatureService.signRecord('identity', 'did:web:oxy.so:u:u4', {}),
|
|
123
|
+
).rejects.toThrow(/No identity found/);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical JSON (RFC 8785 / JCS-style) serialization.
|
|
3
|
+
*
|
|
4
|
+
* `canonicalize(value)` produces a deterministic string for any JSON-compatible
|
|
5
|
+
* value so that a client which SIGNS a record and a server which VERIFIES it
|
|
6
|
+
* agree byte-for-byte on the signing input — regardless of the order in which
|
|
7
|
+
* object keys happen to be written, how the value was deserialized, or which
|
|
8
|
+
* runtime built it.
|
|
9
|
+
*
|
|
10
|
+
* This is the load-bearing primitive for the self-sovereign identity layer's
|
|
11
|
+
* signed records (`SignatureService.signRecord` + the API's record-verify path):
|
|
12
|
+
* both sides import THIS function from `@oxyhq/core`, so cross-implementation
|
|
13
|
+
* number/string formatting differences cannot cause a verify mismatch.
|
|
14
|
+
*
|
|
15
|
+
* Rules (the JSON Canonicalization Scheme subset we need):
|
|
16
|
+
* - Objects: keys are sorted (ascending, by UTF-16 code unit — the default
|
|
17
|
+
* `Array.prototype.sort` order) and serialized recursively. Properties whose
|
|
18
|
+
* value is `undefined`, a function, or a symbol are OMITTED (matching
|
|
19
|
+
* `JSON.stringify` object semantics).
|
|
20
|
+
* - Arrays: element order is PRESERVED; `undefined`/function/symbol elements
|
|
21
|
+
* serialize to `null` (matching `JSON.stringify` array semantics).
|
|
22
|
+
* - `null`, booleans, strings, and finite numbers serialize via the standard
|
|
23
|
+
* JSON representation.
|
|
24
|
+
* - Values exposing a `toJSON()` method (e.g. `Date`) are replaced by its
|
|
25
|
+
* result first, then serialized — so a `Date` and its ISO-string equivalent
|
|
26
|
+
* canonicalize identically (the wire always carries the string form).
|
|
27
|
+
* - Non-finite numbers (`NaN`, `Infinity`) and `bigint` are not part of the
|
|
28
|
+
* JSON data model and throw, rather than silently producing `null`.
|
|
29
|
+
*
|
|
30
|
+
* Platform-agnostic — zero dependencies, no `require()`, no react/react-native/
|
|
31
|
+
* expo. Safe in the dual CJS + ESM build.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** Object exposing a `toJSON()` serialization hook (e.g. `Date`). */
|
|
35
|
+
interface ToJsonable {
|
|
36
|
+
toJSON: () => unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function hasToJSON(value: object): value is ToJsonable {
|
|
40
|
+
return typeof (value as { toJSON?: unknown }).toJSON === 'function';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Serialize a single value into its canonical JSON fragment. Recursive; called
|
|
45
|
+
* on each nested member. Object keys are sorted at every level.
|
|
46
|
+
*/
|
|
47
|
+
function serialize(value: unknown): string {
|
|
48
|
+
if (value === null) {
|
|
49
|
+
return 'null';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const valueType = typeof value;
|
|
53
|
+
|
|
54
|
+
if (valueType === 'number') {
|
|
55
|
+
if (!Number.isFinite(value)) {
|
|
56
|
+
throw new Error('canonicalize: non-finite numbers cannot be serialized');
|
|
57
|
+
}
|
|
58
|
+
return JSON.stringify(value);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (valueType === 'string' || valueType === 'boolean') {
|
|
62
|
+
return JSON.stringify(value);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (valueType === 'bigint') {
|
|
66
|
+
throw new Error('canonicalize: bigint values cannot be serialized');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (Array.isArray(value)) {
|
|
70
|
+
const items = value.map((item) => {
|
|
71
|
+
const itemType = typeof item;
|
|
72
|
+
// JSON array semantics: undefined / function / symbol become null so the
|
|
73
|
+
// element positions (and therefore the array length) are preserved.
|
|
74
|
+
if (item === undefined || itemType === 'function' || itemType === 'symbol') {
|
|
75
|
+
return 'null';
|
|
76
|
+
}
|
|
77
|
+
return serialize(item);
|
|
78
|
+
});
|
|
79
|
+
return `[${items.join(',')}]`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (valueType === 'object') {
|
|
83
|
+
const obj = value as object;
|
|
84
|
+
if (hasToJSON(obj)) {
|
|
85
|
+
return serialize(obj.toJSON());
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const record = obj as Record<string, unknown>;
|
|
89
|
+
const parts: string[] = [];
|
|
90
|
+
for (const key of Object.keys(record).sort()) {
|
|
91
|
+
const member = record[key];
|
|
92
|
+
const memberType = typeof member;
|
|
93
|
+
// JSON object semantics: properties with undefined / function / symbol
|
|
94
|
+
// values are omitted entirely.
|
|
95
|
+
if (member === undefined || memberType === 'function' || memberType === 'symbol') {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
parts.push(`${JSON.stringify(key)}:${serialize(member)}`);
|
|
99
|
+
}
|
|
100
|
+
return `{${parts.join(',')}}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// undefined / function / symbol at the top level have no JSON representation.
|
|
104
|
+
throw new Error(`canonicalize: cannot serialize a value of type ${valueType}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Produce the canonical JSON string for `value`.
|
|
109
|
+
*
|
|
110
|
+
* Deterministic: two structurally-equal values yield identical strings even if
|
|
111
|
+
* their object keys were written in different orders. Use this — never an
|
|
112
|
+
* ad-hoc `JSON.stringify` of a hand-sorted object — as the signing input for
|
|
113
|
+
* signed records, so client signing and server verification cannot drift.
|
|
114
|
+
*
|
|
115
|
+
* @throws if `value` (or any nested member used as the top-level/primitive)
|
|
116
|
+
* contains a non-finite number or a `bigint`, which have no JSON form.
|
|
117
|
+
*/
|
|
118
|
+
export function canonicalize(value: unknown): string {
|
|
119
|
+
return serialize(value);
|
|
120
|
+
}
|
package/src/crypto/keyManager.ts
CHANGED
|
@@ -85,9 +85,9 @@ const STORAGE_KEYS = {
|
|
|
85
85
|
/**
|
|
86
86
|
* iOS Keychain Access Group for sharing identities across Oxy apps
|
|
87
87
|
* All Oxy apps must have this access group enabled in their entitlements
|
|
88
|
-
* Format: [Team ID].
|
|
88
|
+
* Format: [Team ID].so.oxy.shared or group.so.oxy.shared
|
|
89
89
|
*/
|
|
90
|
-
const IOS_KEYCHAIN_GROUP = 'group.
|
|
90
|
+
const IOS_KEYCHAIN_GROUP = 'group.so.oxy.shared';
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
93
|
* Android Account Manager type for shared authentication
|
|
@@ -795,11 +795,25 @@ export class KeyManager {
|
|
|
795
795
|
}
|
|
796
796
|
|
|
797
797
|
// Step 3: The new primary is durable and functional. NOW it is safe to
|
|
798
|
-
// refresh the backup to the new key.
|
|
799
|
-
//
|
|
800
|
-
//
|
|
801
|
-
//
|
|
802
|
-
//
|
|
798
|
+
// refresh the backup to the new key. This is part of the successful write
|
|
799
|
+
// contract: returning success while the backup still belongs to the
|
|
800
|
+
// previous identity would allow a later restore with an absent primary to
|
|
801
|
+
// silently switch the device back to the previous account. Snapshot the
|
|
802
|
+
// backup first so a partial backup refresh can be rolled back along with
|
|
803
|
+
// the primary before surfacing the failure.
|
|
804
|
+
let priorBackupPrivate: string | null;
|
|
805
|
+
let priorBackupPublic: string | null;
|
|
806
|
+
let priorBackupTimestamp: string | null;
|
|
807
|
+
try {
|
|
808
|
+
priorBackupPrivate = await store.getItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY);
|
|
809
|
+
priorBackupPublic = await store.getItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY);
|
|
810
|
+
priorBackupTimestamp = await store.getItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP);
|
|
811
|
+
} catch (error) {
|
|
812
|
+
logger.error('Failed to snapshot identity backup before refresh', error, { component: 'KeyManager' });
|
|
813
|
+
await KeyManager._rollbackPrimary(store, priorPrivate, priorPublic);
|
|
814
|
+
throw new IdentityPersistError('Failed to snapshot identity backup before refresh', error);
|
|
815
|
+
}
|
|
816
|
+
|
|
803
817
|
try {
|
|
804
818
|
await store.setItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY, canonicalPrivate, {
|
|
805
819
|
keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
@@ -807,11 +821,10 @@ export class KeyManager {
|
|
|
807
821
|
await store.setItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY, canonicalPublic);
|
|
808
822
|
await store.setItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP, Date.now().toString());
|
|
809
823
|
} catch (error) {
|
|
810
|
-
logger.
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
);
|
|
824
|
+
logger.error('Failed to refresh identity backup after primary write', error, { component: 'KeyManager' });
|
|
825
|
+
await KeyManager._rollbackBackup(store, priorBackupPrivate, priorBackupPublic, priorBackupTimestamp);
|
|
826
|
+
await KeyManager._rollbackPrimary(store, priorPrivate, priorPublic);
|
|
827
|
+
throw new IdentityPersistError('Failed to refresh identity backup after primary write', error);
|
|
815
828
|
}
|
|
816
829
|
|
|
817
830
|
// Update cache only after we are certain the identity is durable.
|
|
@@ -819,6 +832,43 @@ export class KeyManager {
|
|
|
819
832
|
KeyManager.cachedHasIdentity = true;
|
|
820
833
|
}
|
|
821
834
|
|
|
835
|
+
/**
|
|
836
|
+
* Restore the backup slot to a previously-snapshotted state. Best-effort so
|
|
837
|
+
* the original persistence error remains the one surfaced to the caller.
|
|
838
|
+
*
|
|
839
|
+
* @internal
|
|
840
|
+
*/
|
|
841
|
+
private static async _rollbackBackup(
|
|
842
|
+
store: Awaited<ReturnType<typeof initSecureStore>>,
|
|
843
|
+
priorBackupPrivate: string | null,
|
|
844
|
+
priorBackupPublic: string | null,
|
|
845
|
+
priorBackupTimestamp: string | null,
|
|
846
|
+
): Promise<void> {
|
|
847
|
+
try {
|
|
848
|
+
if (priorBackupPrivate) {
|
|
849
|
+
await store.setItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY, priorBackupPrivate, {
|
|
850
|
+
keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
851
|
+
});
|
|
852
|
+
} else {
|
|
853
|
+
try { await store.deleteItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY); } catch { /* best effort */ }
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
if (priorBackupPublic) {
|
|
857
|
+
await store.setItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY, priorBackupPublic);
|
|
858
|
+
} else {
|
|
859
|
+
try { await store.deleteItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY); } catch { /* best effort */ }
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
if (priorBackupTimestamp) {
|
|
863
|
+
await store.setItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP, priorBackupTimestamp);
|
|
864
|
+
} else {
|
|
865
|
+
try { await store.deleteItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP); } catch { /* best effort */ }
|
|
866
|
+
}
|
|
867
|
+
} catch (rollbackError) {
|
|
868
|
+
logger.error('Failed to roll back identity backup after a failed refresh', rollbackError, { component: 'KeyManager' });
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
822
872
|
/**
|
|
823
873
|
* Restore the primary slot to a previously-snapshotted (privA, pubA) pair,
|
|
824
874
|
* or delete it entirely if there was no prior identity. Best-effort: every
|
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { ec as EC } from 'elliptic';
|
|
9
|
+
import type { SignedRecordEnvelope } from '@oxyhq/contracts';
|
|
9
10
|
import { KeyManager } from './keyManager';
|
|
11
|
+
import { canonicalize } from './canonicalJson';
|
|
10
12
|
import { isReactNative, isNodeJS } from '../utils/platform';
|
|
11
13
|
import { loadExpoCrypto, loadNodeCrypto } from '../utils/platformCrypto';
|
|
12
14
|
import { logger } from '../utils/loggerUtils';
|
|
@@ -14,6 +16,30 @@ import { isDev } from '../shared/utils/debugUtils';
|
|
|
14
16
|
|
|
15
17
|
const ec = new EC('secp256k1');
|
|
16
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
|
+
export type SignedRecordSigningFields = Pick<
|
|
26
|
+
SignedRecordEnvelope,
|
|
27
|
+
'version' | 'type' | 'subject' | 'issuer' | 'record' | 'issuedAt'
|
|
28
|
+
>;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Compute the canonical signing input for a signed-record envelope.
|
|
32
|
+
*
|
|
33
|
+
* This is the single definition of "what the signature covers": the canonical
|
|
34
|
+
* JSON of `{version, type, subject, issuer, record, issuedAt}`. `@oxyhq/core`
|
|
35
|
+
* (client signing) and `@oxyhq/api` (server verification) both call this, so a
|
|
36
|
+
* record signed by a client and verified by the server cannot drift.
|
|
37
|
+
*/
|
|
38
|
+
export function signedRecordSigningInput(fields: SignedRecordSigningFields): string {
|
|
39
|
+
const { version, type, subject, issuer, record, issuedAt } = fields;
|
|
40
|
+
return canonicalize({ version, type, subject, issuer, record, issuedAt });
|
|
41
|
+
}
|
|
42
|
+
|
|
17
43
|
/**
|
|
18
44
|
* Compute SHA-256 hash of a string
|
|
19
45
|
*/
|
|
@@ -236,6 +262,39 @@ export class SignatureService {
|
|
|
236
262
|
};
|
|
237
263
|
}
|
|
238
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Create a signed authentication challenge response using the SHARED identity
|
|
267
|
+
* key (the cross-app `group.so.oxy.shared` keychain key), not the primary
|
|
268
|
+
* device key.
|
|
269
|
+
*
|
|
270
|
+
* Mirrors {@link signChallenge} exactly — same message format
|
|
271
|
+
* (`auth:${publicKey}:${challenge}:${timestamp}`) so the server verification
|
|
272
|
+
* path is unchanged — but sources the shared public/private key from
|
|
273
|
+
* `KeyManager` and signs with `signWithKey`. Used by "Sign in with Oxy"
|
|
274
|
+
* same-device shared-keychain SSO (Mechanism A): a sibling native app proves
|
|
275
|
+
* control of the shared identity to mint its own session.
|
|
276
|
+
*
|
|
277
|
+
* Throws if no shared identity exists (native-only; the shared keychain is
|
|
278
|
+
* unavailable on web).
|
|
279
|
+
*/
|
|
280
|
+
static async signChallengeWithSharedKey(challenge: string): Promise<AuthChallenge> {
|
|
281
|
+
const publicKey = await KeyManager.getSharedPublicKey();
|
|
282
|
+
const privateKey = await KeyManager.getSharedPrivateKey();
|
|
283
|
+
if (!publicKey || !privateKey) {
|
|
284
|
+
throw new Error('No shared identity found. Cannot sign with the shared key.');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const timestamp = Date.now();
|
|
288
|
+
const message = `auth:${publicKey}:${challenge}:${timestamp}`;
|
|
289
|
+
const signature = await SignatureService.signWithKey(message, privateKey);
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
challenge: signature,
|
|
293
|
+
publicKey,
|
|
294
|
+
timestamp,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
239
298
|
/**
|
|
240
299
|
* Verify a challenge response
|
|
241
300
|
*/
|
|
@@ -308,6 +367,73 @@ export class SignatureService {
|
|
|
308
367
|
timestamp,
|
|
309
368
|
};
|
|
310
369
|
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Build a signed-record envelope for a self-issued identity/profile record.
|
|
373
|
+
*
|
|
374
|
+
* The envelope is self-issued: `issuer` equals `subject` (the signer's DID).
|
|
375
|
+
* The signature covers the canonical JSON of every field EXCEPT `publicKey`
|
|
376
|
+
* and `signature` (see {@link signedRecordSigningInput}); `alg` is
|
|
377
|
+
* `ES256K-DER-SHA256` (secp256k1 over the SHA-256 of the canonical bytes,
|
|
378
|
+
* DER-encoded), the same scheme this service uses everywhere else.
|
|
379
|
+
*
|
|
380
|
+
* Requires a stored identity (native secure storage); throws if none exists.
|
|
381
|
+
*
|
|
382
|
+
* @param type - The record category (`'identity'` or `'profile'`).
|
|
383
|
+
* @param subject - The subject DID the record is about (also the issuer).
|
|
384
|
+
* @param record - The arbitrary record payload to attest to.
|
|
385
|
+
*/
|
|
386
|
+
static async signRecord(
|
|
387
|
+
type: SignedRecordEnvelope['type'],
|
|
388
|
+
subject: string,
|
|
389
|
+
record: Record<string, unknown>,
|
|
390
|
+
): Promise<SignedRecordEnvelope> {
|
|
391
|
+
const publicKey = await KeyManager.getPublicKey();
|
|
392
|
+
if (!publicKey) {
|
|
393
|
+
throw new Error('No identity found. Please create or import an identity first.');
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const version = 1 as const;
|
|
397
|
+
const issuer = subject;
|
|
398
|
+
const issuedAt = Date.now();
|
|
399
|
+
const signingInput = signedRecordSigningInput({
|
|
400
|
+
version,
|
|
401
|
+
type,
|
|
402
|
+
subject,
|
|
403
|
+
issuer,
|
|
404
|
+
record,
|
|
405
|
+
issuedAt,
|
|
406
|
+
});
|
|
407
|
+
const signature = await SignatureService.sign(signingInput);
|
|
408
|
+
|
|
409
|
+
return {
|
|
410
|
+
version,
|
|
411
|
+
type,
|
|
412
|
+
subject,
|
|
413
|
+
issuer,
|
|
414
|
+
record,
|
|
415
|
+
issuedAt,
|
|
416
|
+
publicKey,
|
|
417
|
+
alg: 'ES256K-DER-SHA256',
|
|
418
|
+
signature,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Verify a signed-record envelope: recompute the canonical signing input from
|
|
424
|
+
* the envelope's own fields and check the signature against the envelope's
|
|
425
|
+
* `publicKey`.
|
|
426
|
+
*
|
|
427
|
+
* Note: this confirms the signature is internally consistent with the
|
|
428
|
+
* embedded `publicKey`. It does NOT establish that `publicKey` is an
|
|
429
|
+
* authorized verification method for `subject` — that authorization check is
|
|
430
|
+
* the server's responsibility (it asserts the key is a current verification
|
|
431
|
+
* method on the subject's DID).
|
|
432
|
+
*/
|
|
433
|
+
static async verifyRecord(envelope: SignedRecordEnvelope): Promise<boolean> {
|
|
434
|
+
const signingInput = signedRecordSigningInput(envelope);
|
|
435
|
+
return SignatureService.verify(signingInput, envelope.signature, envelope.publicKey);
|
|
436
|
+
}
|
|
311
437
|
}
|
|
312
438
|
|
|
313
439
|
export default SignatureService;
|
package/src/index.ts
CHANGED
|
@@ -51,6 +51,13 @@ export type { SilentAuthOptions } from './mixins/OxyServices.silent';
|
|
|
51
51
|
export type { RedirectAuthOptions } from './mixins/OxyServices.redirect';
|
|
52
52
|
export { ServiceCredentialMismatchError } from './mixins/OxyServices.auth';
|
|
53
53
|
export type { ServiceTokenResponse } from './mixins/OxyServices.auth';
|
|
54
|
+
// "Sign in with Oxy" — handoff (Workstream C)
|
|
55
|
+
export type {
|
|
56
|
+
CommonsSignInHandle,
|
|
57
|
+
CommonsSignInStatus,
|
|
58
|
+
CommonsApprovalInfo,
|
|
59
|
+
CommonsSignInActionResult,
|
|
60
|
+
} from './mixins/OxyServices.auth';
|
|
54
61
|
export type { ServiceApp, ServiceActingAsVerification } from './mixins/OxyServices.utility';
|
|
55
62
|
export type {
|
|
56
63
|
CreateManagedAccountInput,
|
|
@@ -159,6 +166,23 @@ export type {
|
|
|
159
166
|
ReverseReputationTransactionInput,
|
|
160
167
|
} from './mixins/OxyServices.reputation';
|
|
161
168
|
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// Self-sovereign identity (DID, signed records, auth-method ↔ VM mapping,
|
|
171
|
+
// verified domains). Wire shapes (DidDocument, SignedRecordEnvelope,
|
|
172
|
+
// AuthMethodsResponse, VerifiedDomain, DomainVerificationInstructions,
|
|
173
|
+
// ExportBundle) live in `@oxyhq/contracts` — import them directly from there.
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
export { buildUserDid } from './mixins/OxyServices.identity';
|
|
176
|
+
export type {
|
|
177
|
+
IdentityRecordType,
|
|
178
|
+
UnlinkableAuthMethodType,
|
|
179
|
+
LinkAuthMethodResult,
|
|
180
|
+
PublishRecordResult,
|
|
181
|
+
VerifyRecordResult,
|
|
182
|
+
VerifyDomainResult,
|
|
183
|
+
RemoveDomainResult,
|
|
184
|
+
} from './mixins/OxyServices.identity';
|
|
185
|
+
|
|
162
186
|
// ---------------------------------------------------------------------------
|
|
163
187
|
// Auth helpers (token refresh, error normalisation, retry policies)
|
|
164
188
|
// ---------------------------------------------------------------------------
|
|
@@ -206,8 +230,9 @@ export {
|
|
|
206
230
|
IdentityPersistError,
|
|
207
231
|
} from './crypto/keyManager';
|
|
208
232
|
export type { KeyPair } from './crypto/keyManager';
|
|
209
|
-
export { SignatureService } from './crypto/signatureService';
|
|
210
|
-
export type { SignedMessage, AuthChallenge } from './crypto/signatureService';
|
|
233
|
+
export { SignatureService, signedRecordSigningInput } from './crypto/signatureService';
|
|
234
|
+
export type { SignedMessage, AuthChallenge, SignedRecordSigningFields } from './crypto/signatureService';
|
|
235
|
+
export { canonicalize } from './crypto/canonicalJson';
|
|
211
236
|
export { RecoveryPhraseService } from './crypto/recoveryPhrase';
|
|
212
237
|
export type { RecoveryPhraseResult } from './crypto/recoveryPhrase';
|
|
213
238
|
|
|
@@ -486,7 +486,9 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
|
|
|
486
486
|
public async fetchAssetContent(url: string, type: 'text'): Promise<string>;
|
|
487
487
|
public async fetchAssetContent(url: string, type: 'blob'): Promise<Blob>;
|
|
488
488
|
public async fetchAssetContent(url: string, type: 'text' | 'blob') {
|
|
489
|
-
const response = await fetch(url, {
|
|
489
|
+
const response = await fetch(url, {
|
|
490
|
+
credentials: shouldSendAssetCredentials(url, this.getBaseURL()) ? 'include' : 'omit',
|
|
491
|
+
});
|
|
490
492
|
if (!response?.ok) {
|
|
491
493
|
throw new Error(`Failed to fetch asset content (status ${response?.status})`);
|
|
492
494
|
}
|
|
@@ -494,3 +496,16 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
|
|
|
494
496
|
}
|
|
495
497
|
};
|
|
496
498
|
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Only send ambient credentials (cookies) when the asset URL is same-origin with
|
|
502
|
+
* the configured API base. Caller-supplied cross-origin asset URLs must not leak
|
|
503
|
+
* the user's cookies to arbitrary hosts.
|
|
504
|
+
*/
|
|
505
|
+
function shouldSendAssetCredentials(url: string, baseURL: string): boolean {
|
|
506
|
+
try {
|
|
507
|
+
return new URL(url).origin === new URL(baseURL).origin;
|
|
508
|
+
} catch {
|
|
509
|
+
return false;
|
|
510
|
+
}
|
|
511
|
+
}
|