@oxyhq/core 3.10.1 → 3.12.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 +189 -0
- package/dist/cjs/index.js +30 -4
- package/dist/cjs/mixins/OxyServices.assets.js +16 -1
- package/dist/cjs/mixins/OxyServices.auth.js +190 -1
- package/dist/cjs/mixins/OxyServices.civic.js +611 -0
- 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 +6 -0
- package/dist/cjs/server/cors.js +20 -21
- package/dist/cjs/server/rateLimit.js +32 -8
- package/dist/cjs/utils/profileLinks.js +52 -0
- 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 +187 -0
- package/dist/esm/index.js +19 -1
- package/dist/esm/mixins/OxyServices.assets.js +16 -1
- package/dist/esm/mixins/OxyServices.auth.js +190 -1
- package/dist/esm/mixins/OxyServices.civic.js +605 -0
- 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 +6 -0
- package/dist/esm/server/cors.js +20 -21
- package/dist/esm/server/rateLimit.js +32 -8
- package/dist/esm/utils/profileLinks.js +49 -0
- 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 +112 -0
- package/dist/types/index.d.ts +10 -2
- package/dist/types/mixins/OxyServices.auth.d.ts +136 -0
- package/dist/types/mixins/OxyServices.civic.d.ts +512 -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 +3 -1
- package/dist/types/models/interfaces.d.ts +3 -0
- package/dist/types/server/cors.d.ts +5 -5
- package/dist/types/utils/profileLinks.d.ts +36 -0
- 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 +345 -0
- package/src/crypto/canonicalJson.ts +120 -0
- package/src/crypto/keyManager.ts +62 -12
- package/src/crypto/signatureService.ts +225 -0
- package/src/index.ts +55 -2
- package/src/mixins/OxyServices.assets.ts +16 -1
- package/src/mixins/OxyServices.auth.ts +309 -1
- package/src/mixins/OxyServices.civic.ts +956 -0
- 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.civic.test.ts +1097 -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 +8 -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__/profileLinks.test.ts +126 -0
- package/src/utils/__tests__/ssoReturn.test.ts +1 -1
- package/src/utils/profileLinks.ts +74 -0
- package/src/utils/ssoReturn.ts +2 -2
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical JSON tests.
|
|
3
|
+
*
|
|
4
|
+
* The whole point of `canonicalize` is that two structurally-equal values
|
|
5
|
+
* produce identical strings regardless of how their keys were ordered, so a
|
|
6
|
+
* client which signs and a server which verifies agree on the signing input.
|
|
7
|
+
* These tests pin that determinism, the array-order guarantee, the nesting
|
|
8
|
+
* behaviour, and the JSON value/omit semantics.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { canonicalize } from '../canonicalJson';
|
|
12
|
+
|
|
13
|
+
describe('canonicalize', () => {
|
|
14
|
+
describe('object key ordering', () => {
|
|
15
|
+
it('produces identical output regardless of insertion order', () => {
|
|
16
|
+
const a = canonicalize({ b: 1, a: 2, c: 3 });
|
|
17
|
+
const b = canonicalize({ c: 3, a: 2, b: 1 });
|
|
18
|
+
const c = canonicalize({ a: 2, b: 1, c: 3 });
|
|
19
|
+
expect(a).toBe(b);
|
|
20
|
+
expect(b).toBe(c);
|
|
21
|
+
expect(a).toBe('{"a":2,"b":1,"c":3}');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('sorts keys recursively at every level', () => {
|
|
25
|
+
const value = {
|
|
26
|
+
z: { y: 1, x: 2 },
|
|
27
|
+
a: { c: 3, b: { e: 5, d: 4 } },
|
|
28
|
+
};
|
|
29
|
+
expect(canonicalize(value)).toBe(
|
|
30
|
+
'{"a":{"b":{"d":4,"e":5},"c":3},"z":{"x":2,"y":1}}',
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('is order-insensitive across deep nesting', () => {
|
|
35
|
+
const first = canonicalize({
|
|
36
|
+
outer: { inner: { p: 1, q: 2 }, lead: 'x' },
|
|
37
|
+
meta: { issuedAt: 10, version: 1 },
|
|
38
|
+
});
|
|
39
|
+
const second = canonicalize({
|
|
40
|
+
meta: { version: 1, issuedAt: 10 },
|
|
41
|
+
outer: { lead: 'x', inner: { q: 2, p: 1 } },
|
|
42
|
+
});
|
|
43
|
+
expect(first).toBe(second);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('array ordering', () => {
|
|
48
|
+
it('preserves array element order (never sorts arrays)', () => {
|
|
49
|
+
expect(canonicalize([3, 1, 2])).toBe('[3,1,2]');
|
|
50
|
+
expect(canonicalize(['b', 'a', 'c'])).toBe('["b","a","c"]');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('distinguishes arrays that differ only in order', () => {
|
|
54
|
+
expect(canonicalize([1, 2])).not.toBe(canonicalize([2, 1]));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('canonicalizes objects inside arrays without reordering the array', () => {
|
|
58
|
+
const value = [
|
|
59
|
+
{ b: 1, a: 2 },
|
|
60
|
+
{ d: 3, c: 4 },
|
|
61
|
+
];
|
|
62
|
+
expect(canonicalize(value)).toBe('[{"a":2,"b":1},{"c":4,"d":3}]');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('primitives', () => {
|
|
67
|
+
it('serializes null, booleans, strings and numbers as JSON', () => {
|
|
68
|
+
expect(canonicalize(null)).toBe('null');
|
|
69
|
+
expect(canonicalize(true)).toBe('true');
|
|
70
|
+
expect(canonicalize(false)).toBe('false');
|
|
71
|
+
expect(canonicalize('hi')).toBe('"hi"');
|
|
72
|
+
expect(canonicalize(42)).toBe('42');
|
|
73
|
+
expect(canonicalize(-1.5)).toBe('-1.5');
|
|
74
|
+
expect(canonicalize(0)).toBe('0');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('escapes strings the same way JSON does', () => {
|
|
78
|
+
expect(canonicalize('a"b\\c\n')).toBe(JSON.stringify('a"b\\c\n'));
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('JSON value/omit semantics', () => {
|
|
83
|
+
it('omits object properties whose value is undefined', () => {
|
|
84
|
+
expect(canonicalize({ a: 1, b: undefined, c: 3 })).toBe('{"a":1,"c":3}');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('renders undefined array elements as null (preserving length)', () => {
|
|
88
|
+
expect(canonicalize([1, undefined, 3])).toBe('[1,null,3]');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('respects toJSON (Date and its ISO string canonicalize identically)', () => {
|
|
92
|
+
const date = new Date('2026-06-26T00:00:00.000Z');
|
|
93
|
+
expect(canonicalize(date)).toBe(JSON.stringify(date.toISOString()));
|
|
94
|
+
expect(canonicalize({ at: date })).toBe(
|
|
95
|
+
canonicalize({ at: '2026-06-26T00:00:00.000Z' }),
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('rejects values outside the JSON data model', () => {
|
|
101
|
+
it('throws on non-finite numbers', () => {
|
|
102
|
+
expect(() => canonicalize(NaN)).toThrow();
|
|
103
|
+
expect(() => canonicalize(Infinity)).toThrow();
|
|
104
|
+
expect(() => canonicalize({ x: Infinity })).toThrow();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('throws on bigint', () => {
|
|
108
|
+
expect(() => canonicalize(BigInt(1))).toThrow();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('throws on a bare undefined / function at the top level', () => {
|
|
112
|
+
expect(() => canonicalize(undefined)).toThrow();
|
|
113
|
+
expect(() => canonicalize(() => 1)).toThrow();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -21,7 +21,7 @@ import { setPlatformOS } from '../../utils/platform';
|
|
|
21
21
|
// Fault-injectable in-memory secure store. `failPlan` lets a test make a
|
|
22
22
|
// specific (op, key) pair throw to simulate a keychain that fails mid-write or
|
|
23
23
|
// is transiently locked.
|
|
24
|
-
const failPlan: { failKey?: string; failOp?: 'set' | 'get' } = {};
|
|
24
|
+
const failPlan: { failKey?: string; failOp?: 'set' | 'get'; failTimes?: number } = {};
|
|
25
25
|
|
|
26
26
|
jest.mock(
|
|
27
27
|
'expo-secure-store',
|
|
@@ -29,6 +29,14 @@ jest.mock(
|
|
|
29
29
|
const store = new Map<string, string>();
|
|
30
30
|
const maybeFail = (op: 'set' | 'get', key: string) => {
|
|
31
31
|
if (failPlan.failOp === op && failPlan.failKey === key) {
|
|
32
|
+
if (failPlan.failTimes !== undefined) {
|
|
33
|
+
failPlan.failTimes -= 1;
|
|
34
|
+
if (failPlan.failTimes <= 0) {
|
|
35
|
+
failPlan.failKey = undefined;
|
|
36
|
+
failPlan.failOp = undefined;
|
|
37
|
+
failPlan.failTimes = undefined;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
32
40
|
throw new Error(`Simulated ${op} failure for ${key}`);
|
|
33
41
|
}
|
|
34
42
|
};
|
|
@@ -51,6 +59,7 @@ jest.mock(
|
|
|
51
59
|
store.clear();
|
|
52
60
|
failPlan.failKey = undefined;
|
|
53
61
|
failPlan.failOp = undefined;
|
|
62
|
+
failPlan.failTimes = undefined;
|
|
54
63
|
},
|
|
55
64
|
__getStore__: () => store,
|
|
56
65
|
__failPlan__: failPlan,
|
|
@@ -92,7 +101,7 @@ jest.mock('../../utils/platformCrypto', () => ({
|
|
|
92
101
|
interface SecureStoreTestHandle {
|
|
93
102
|
__resetStore__: () => void;
|
|
94
103
|
__getStore__: () => Map<string, string>;
|
|
95
|
-
__failPlan__: { failKey?: string; failOp?: 'set' | 'get' };
|
|
104
|
+
__failPlan__: { failKey?: string; failOp?: 'set' | 'get'; failTimes?: number };
|
|
96
105
|
}
|
|
97
106
|
|
|
98
107
|
describe('KeyManager atomicity & recoverability under flaky storage', () => {
|
|
@@ -146,6 +155,36 @@ describe('KeyManager atomicity & recoverability under flaky storage', () => {
|
|
|
146
155
|
expect(m.get('oxy_identity_backup_public_key')).toBe(originalPublic);
|
|
147
156
|
});
|
|
148
157
|
|
|
158
|
+
it('a failed final backup refresh rejects and rolls back instead of succeeding with a stale backup', async () => {
|
|
159
|
+
const originalPublic = await KeyManager.createIdentity();
|
|
160
|
+
const ss = (await import('expo-secure-store' as string)) as unknown as SecureStoreTestHandle;
|
|
161
|
+
const originalPriv = ss.__getStore__().get('oxy_identity_private_key');
|
|
162
|
+
resetCaches();
|
|
163
|
+
|
|
164
|
+
// Let the new primary write and verify, then fail exactly once while
|
|
165
|
+
// refreshing the backup to the new identity. This used to return success
|
|
166
|
+
// with primary=B and backup=A, enabling a later absent-primary restore to
|
|
167
|
+
// silently switch back to A.
|
|
168
|
+
ss.__failPlan__.failOp = 'set';
|
|
169
|
+
ss.__failPlan__.failKey = 'oxy_identity_backup_public_key';
|
|
170
|
+
ss.__failPlan__.failTimes = 1;
|
|
171
|
+
await expect(KeyManager.createIdentity({ overwrite: true })).rejects.toBeDefined();
|
|
172
|
+
|
|
173
|
+
resetCaches();
|
|
174
|
+
|
|
175
|
+
// The operation failed atomically: primary and backup both still identify
|
|
176
|
+
// the original account, so callers cannot observe success with a stale
|
|
177
|
+
// cross-account backup.
|
|
178
|
+
expect(await KeyManager.hasIdentity()).toBe(true);
|
|
179
|
+
expect(await KeyManager.getPublicKey()).toBe(originalPublic);
|
|
180
|
+
const m = ss.__getStore__();
|
|
181
|
+
expect(m.get('oxy_identity_private_key')).toBe(originalPriv);
|
|
182
|
+
expect(m.get('oxy_identity_public_key')).toBe(originalPublic);
|
|
183
|
+
expect(m.get('oxy_identity_backup_private_key')).toBe(originalPriv);
|
|
184
|
+
expect(m.get('oxy_identity_backup_public_key')).toBe(originalPublic);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
|
|
149
188
|
it('restoreIdentityFromBackup does NOT clobber a healthy primary that is only transiently unreadable', async () => {
|
|
150
189
|
const original = await KeyManager.createIdentity();
|
|
151
190
|
const ss = (await import('expo-secure-store' as string)) as unknown as SecureStoreTestHandle;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `signChallengeWithSharedKey` tests.
|
|
3
|
+
*
|
|
4
|
+
* Verifies the shared-key challenge signer mirrors `signChallenge` exactly —
|
|
5
|
+
* same `auth:${publicKey}:${challenge}:${timestamp}` message format so the
|
|
6
|
+
* server verification path is unchanged — but sources the SHARED key from
|
|
7
|
+
* `KeyManager` (not the primary device key). We mock the shared key access with
|
|
8
|
+
* a REAL elliptic secp256k1 keypair so signing/verification is genuine.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { ec as EC } from 'elliptic';
|
|
12
|
+
import { KeyManager } from '../keyManager';
|
|
13
|
+
import { SignatureService } from '../signatureService';
|
|
14
|
+
|
|
15
|
+
const ec = new EC('secp256k1');
|
|
16
|
+
|
|
17
|
+
describe('SignatureService.signChallengeWithSharedKey', () => {
|
|
18
|
+
const sharedKeyPair = ec.genKeyPair();
|
|
19
|
+
const sharedPublicKey = sharedKeyPair.getPublic('hex');
|
|
20
|
+
const sharedPrivateKey = sharedKeyPair.getPrivate('hex');
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
jest.restoreAllMocks();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('signs with the shared key and uses the unchanged message format', async () => {
|
|
27
|
+
jest.spyOn(KeyManager, 'getSharedPublicKey').mockResolvedValue(sharedPublicKey);
|
|
28
|
+
jest.spyOn(KeyManager, 'getSharedPrivateKey').mockResolvedValue(sharedPrivateKey);
|
|
29
|
+
// Guard: it must NOT fall back to the primary device key.
|
|
30
|
+
const primarySpy = jest.spyOn(KeyManager, 'getPublicKey');
|
|
31
|
+
|
|
32
|
+
const result = await SignatureService.signChallengeWithSharedKey('chal-123');
|
|
33
|
+
|
|
34
|
+
expect(result.publicKey).toBe(sharedPublicKey);
|
|
35
|
+
expect(typeof result.challenge).toBe('string'); // the signature
|
|
36
|
+
expect(typeof result.timestamp).toBe('number');
|
|
37
|
+
expect(primarySpy).not.toHaveBeenCalled();
|
|
38
|
+
|
|
39
|
+
// The signature verifies against the SAME message format `signChallenge`
|
|
40
|
+
// uses, proving the format is unchanged and the shared key signed it.
|
|
41
|
+
const message = `auth:${sharedPublicKey}:chal-123:${result.timestamp}`;
|
|
42
|
+
await expect(
|
|
43
|
+
SignatureService.verify(message, result.challenge, sharedPublicKey),
|
|
44
|
+
).resolves.toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('throws when no shared identity exists', async () => {
|
|
48
|
+
jest.spyOn(KeyManager, 'getSharedPublicKey').mockResolvedValue(null);
|
|
49
|
+
jest.spyOn(KeyManager, 'getSharedPrivateKey').mockResolvedValue(null);
|
|
50
|
+
|
|
51
|
+
await expect(
|
|
52
|
+
SignatureService.signChallengeWithSharedKey('chal-123'),
|
|
53
|
+
).rejects.toThrow(/No shared identity/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('throws when the shared private key is missing even if the public key is present', async () => {
|
|
57
|
+
jest.spyOn(KeyManager, 'getSharedPublicKey').mockResolvedValue(sharedPublicKey);
|
|
58
|
+
jest.spyOn(KeyManager, 'getSharedPrivateKey').mockResolvedValue(null);
|
|
59
|
+
|
|
60
|
+
await expect(
|
|
61
|
+
SignatureService.signChallengeWithSharedKey('chal-123'),
|
|
62
|
+
).rejects.toThrow(/No shared identity/);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,345 @@
|
|
|
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, computeRecordId } 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
|
+
});
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Critical regression guard: the v1 signing input MUST be byte-identical to the
|
|
129
|
+
* original scheme. Every `identity`/`profile` record already in production was
|
|
130
|
+
* signed over exactly these bytes, so any change to {@link signedRecordSigningInput}
|
|
131
|
+
* for v1 would invalidate them all. This locks the exact canonical string.
|
|
132
|
+
*/
|
|
133
|
+
describe('signedRecordSigningInput — v1 byte stability (regression guard)', () => {
|
|
134
|
+
it('produces the exact original canonical bytes for a v1 record', () => {
|
|
135
|
+
const input = signedRecordSigningInput({
|
|
136
|
+
version: 1,
|
|
137
|
+
type: 'identity',
|
|
138
|
+
subject: 'did:web:oxy.so:u:u1',
|
|
139
|
+
issuer: 'did:web:oxy.so:u:u1',
|
|
140
|
+
record: { handle: '@nate' },
|
|
141
|
+
issuedAt: 1750000000000,
|
|
142
|
+
});
|
|
143
|
+
expect(input).toBe(
|
|
144
|
+
'{"issuedAt":1750000000000,"issuer":"did:web:oxy.so:u:u1","record":{"handle":"@nate"},"subject":"did:web:oxy.so:u:u1","type":"identity","version":1}',
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('does NOT include any v2 chain fields for a v1 record even if they are passed', () => {
|
|
149
|
+
const input = signedRecordSigningInput({
|
|
150
|
+
version: 1,
|
|
151
|
+
type: 'identity',
|
|
152
|
+
subject: 'did:web:oxy.so:u:u1',
|
|
153
|
+
issuer: 'did:web:oxy.so:u:u1',
|
|
154
|
+
record: { handle: '@nate' },
|
|
155
|
+
issuedAt: 1750000000000,
|
|
156
|
+
// These must be ignored by the v1 branch — proving v1 bytes are immutable.
|
|
157
|
+
seq: 5,
|
|
158
|
+
prev: 'deadbeef',
|
|
159
|
+
collection: 'app.oxy.identity',
|
|
160
|
+
rkey: 'self',
|
|
161
|
+
});
|
|
162
|
+
expect(input).not.toContain('seq');
|
|
163
|
+
expect(input).not.toContain('prev');
|
|
164
|
+
expect(input).not.toContain('collection');
|
|
165
|
+
expect(input).not.toContain('rkey');
|
|
166
|
+
expect(input).toBe(
|
|
167
|
+
'{"issuedAt":1750000000000,"issuer":"did:web:oxy.so:u:u1","record":{"handle":"@nate"},"subject":"did:web:oxy.so:u:u1","type":"identity","version":1}',
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('SignatureService.signRecordV2 / verifyRecord (v2 hash chain)', () => {
|
|
173
|
+
const keyPair = ec.genKeyPair();
|
|
174
|
+
const publicKey = keyPair.getPublic('hex');
|
|
175
|
+
|
|
176
|
+
beforeEach(() => {
|
|
177
|
+
jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue(publicKey);
|
|
178
|
+
jest.spyOn(KeyManager, 'getKeyPairObject').mockResolvedValue(keyPair);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
afterEach(() => {
|
|
182
|
+
jest.restoreAllMocks();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const chain = {
|
|
186
|
+
seq: 0,
|
|
187
|
+
prev: null as string | null,
|
|
188
|
+
collection: 'app.oxy.reputation',
|
|
189
|
+
rkey: 'rt_1',
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
it('builds a well-formed v2 envelope carrying the chain fields', async () => {
|
|
193
|
+
const subject = 'did:web:oxy.so:u:abc123';
|
|
194
|
+
const envelope = await SignatureService.signRecordV2(
|
|
195
|
+
'reputation_attestation',
|
|
196
|
+
subject,
|
|
197
|
+
{ points: 25 },
|
|
198
|
+
chain,
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
expect(envelope.version).toBe(2);
|
|
202
|
+
expect(envelope.type).toBe('reputation_attestation');
|
|
203
|
+
expect(envelope.subject).toBe(subject);
|
|
204
|
+
expect(envelope.issuer).toBe(subject); // self-issued
|
|
205
|
+
expect(envelope.seq).toBe(0);
|
|
206
|
+
expect(envelope.prev).toBeNull();
|
|
207
|
+
expect(envelope.collection).toBe('app.oxy.reputation');
|
|
208
|
+
expect(envelope.rkey).toBe('rt_1');
|
|
209
|
+
expect(envelope.publicKey).toBe(publicKey);
|
|
210
|
+
expect(envelope.alg).toBe('ES256K-DER-SHA256');
|
|
211
|
+
expect(typeof envelope.signature).toBe('string');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('round-trips: a freshly signed v2 record verifies', async () => {
|
|
215
|
+
const envelope = await SignatureService.signRecordV2(
|
|
216
|
+
'validation_verdict',
|
|
217
|
+
'did:web:oxy.so:u:v1',
|
|
218
|
+
{ verdict: 'approve' },
|
|
219
|
+
{ seq: 3, prev: 'a'.repeat(64), collection: 'app.oxy.validation', rkey: 'req_9' },
|
|
220
|
+
);
|
|
221
|
+
await expect(SignatureService.verifyRecord(envelope)).resolves.toBe(true);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('covers the chain fields in the signed bytes (tampering breaks verify)', async () => {
|
|
225
|
+
const envelope = await SignatureService.signRecordV2(
|
|
226
|
+
'reputation_attestation',
|
|
227
|
+
'did:web:oxy.so:u:t1',
|
|
228
|
+
{ points: 8 },
|
|
229
|
+
chain,
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
await expect(
|
|
233
|
+
SignatureService.verifyRecord({ ...envelope, seq: 1 }),
|
|
234
|
+
).resolves.toBe(false);
|
|
235
|
+
await expect(
|
|
236
|
+
SignatureService.verifyRecord({ ...envelope, prev: 'b'.repeat(64) }),
|
|
237
|
+
).resolves.toBe(false);
|
|
238
|
+
await expect(
|
|
239
|
+
SignatureService.verifyRecord({ ...envelope, collection: 'app.evil' }),
|
|
240
|
+
).resolves.toBe(false);
|
|
241
|
+
await expect(
|
|
242
|
+
SignatureService.verifyRecord({ ...envelope, rkey: 'other' }),
|
|
243
|
+
).resolves.toBe(false);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('a v2 envelope signs over DIFFERENT bytes than the same base fields as v1', async () => {
|
|
247
|
+
const subject = 'did:web:oxy.so:u:diff';
|
|
248
|
+
const record = { x: 1 };
|
|
249
|
+
const v2 = await SignatureService.signRecordV2('profile', subject, record, chain);
|
|
250
|
+
|
|
251
|
+
const v1Input = signedRecordSigningInput({
|
|
252
|
+
version: 1,
|
|
253
|
+
type: 'profile',
|
|
254
|
+
subject,
|
|
255
|
+
issuer: subject,
|
|
256
|
+
record,
|
|
257
|
+
issuedAt: v2.issuedAt,
|
|
258
|
+
});
|
|
259
|
+
const v2Input = signedRecordSigningInput(v2);
|
|
260
|
+
expect(v2Input).not.toBe(v1Input);
|
|
261
|
+
expect(v2Input).toContain('"seq":0');
|
|
262
|
+
expect(v2Input).toContain('"prev":null');
|
|
263
|
+
expect(v2Input).toContain('"collection":"app.oxy.reputation"');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('throws when no identity is stored', async () => {
|
|
267
|
+
jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue(null);
|
|
268
|
+
await expect(
|
|
269
|
+
SignatureService.signRecordV2('identity', 'did:web:oxy.so:u:u4', {}, chain),
|
|
270
|
+
).rejects.toThrow(/No identity found/);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe('computeRecordId — deterministic content address', () => {
|
|
275
|
+
const base: SignedRecordEnvelope = {
|
|
276
|
+
version: 2,
|
|
277
|
+
type: 'reputation_attestation',
|
|
278
|
+
subject: 'did:web:oxy.so:u:rid',
|
|
279
|
+
issuer: 'did:web:oxy.so',
|
|
280
|
+
record: { points: 25 },
|
|
281
|
+
issuedAt: 1750000000000,
|
|
282
|
+
seq: 0,
|
|
283
|
+
prev: null,
|
|
284
|
+
collection: 'app.oxy.reputation',
|
|
285
|
+
rkey: 'rt_1',
|
|
286
|
+
publicKey: '03oxykey',
|
|
287
|
+
alg: 'ES256K-DER-SHA256',
|
|
288
|
+
signature: 'sig-is-not-part-of-the-id',
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
it('is a 64-char lowercase hex SHA-256 digest', async () => {
|
|
292
|
+
const id = await computeRecordId(base);
|
|
293
|
+
expect(id).toMatch(/^[0-9a-f]{64}$/);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('is deterministic for identical signing fields', async () => {
|
|
297
|
+
const a = await computeRecordId(base);
|
|
298
|
+
const b = await computeRecordId(base);
|
|
299
|
+
expect(a).toBe(b);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('equals sha256 of the canonical signing input (content address, excludes publicKey/signature)', async () => {
|
|
303
|
+
const id = await computeRecordId(base);
|
|
304
|
+
const expected = await SignatureService.hashMessage(signedRecordSigningInput(base));
|
|
305
|
+
expect(id).toBe(expected);
|
|
306
|
+
// Changing only signature/publicKey does NOT change the recordId.
|
|
307
|
+
const sameId = await computeRecordId({
|
|
308
|
+
...base,
|
|
309
|
+
signature: 'totally-different',
|
|
310
|
+
publicKey: 'different-key',
|
|
311
|
+
});
|
|
312
|
+
expect(sameId).toBe(id);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('changes when any signed field changes', async () => {
|
|
316
|
+
const id = await computeRecordId(base);
|
|
317
|
+
expect(await computeRecordId({ ...base, seq: 1 })).not.toBe(id);
|
|
318
|
+
expect(await computeRecordId({ ...base, record: { points: 26 } })).not.toBe(id);
|
|
319
|
+
expect(await computeRecordId({ ...base, prev: 'a'.repeat(64) })).not.toBe(id);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('a v1 record and a v2 record with the same base fields have different recordIds', async () => {
|
|
323
|
+
const v1Id = await computeRecordId({
|
|
324
|
+
version: 1,
|
|
325
|
+
type: 'profile',
|
|
326
|
+
subject: 'did:web:oxy.so:u:rid',
|
|
327
|
+
issuer: 'did:web:oxy.so:u:rid',
|
|
328
|
+
record: { points: 25 },
|
|
329
|
+
issuedAt: 1750000000000,
|
|
330
|
+
});
|
|
331
|
+
const v2Id = await computeRecordId({
|
|
332
|
+
version: 2,
|
|
333
|
+
type: 'profile',
|
|
334
|
+
subject: 'did:web:oxy.so:u:rid',
|
|
335
|
+
issuer: 'did:web:oxy.so:u:rid',
|
|
336
|
+
record: { points: 25 },
|
|
337
|
+
issuedAt: 1750000000000,
|
|
338
|
+
seq: 0,
|
|
339
|
+
prev: null,
|
|
340
|
+
collection: 'app.oxy.profile',
|
|
341
|
+
rkey: 'self',
|
|
342
|
+
});
|
|
343
|
+
expect(v1Id).not.toBe(v2Id);
|
|
344
|
+
});
|
|
345
|
+
});
|