@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,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,63 @@ 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
|
+
* 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
|
+
|
|
17
76
|
/**
|
|
18
77
|
* Compute SHA-256 hash of a string
|
|
19
78
|
*/
|
|
@@ -236,6 +295,39 @@ export class SignatureService {
|
|
|
236
295
|
};
|
|
237
296
|
}
|
|
238
297
|
|
|
298
|
+
/**
|
|
299
|
+
* Create a signed authentication challenge response using the SHARED identity
|
|
300
|
+
* key (the cross-app `group.so.oxy.shared` keychain key), not the primary
|
|
301
|
+
* device key.
|
|
302
|
+
*
|
|
303
|
+
* Mirrors {@link signChallenge} exactly — same message format
|
|
304
|
+
* (`auth:${publicKey}:${challenge}:${timestamp}`) so the server verification
|
|
305
|
+
* path is unchanged — but sources the shared public/private key from
|
|
306
|
+
* `KeyManager` and signs with `signWithKey`. Used by "Sign in with Oxy"
|
|
307
|
+
* same-device shared-keychain SSO (Mechanism A): a sibling native app proves
|
|
308
|
+
* control of the shared identity to mint its own session.
|
|
309
|
+
*
|
|
310
|
+
* Throws if no shared identity exists (native-only; the shared keychain is
|
|
311
|
+
* unavailable on web).
|
|
312
|
+
*/
|
|
313
|
+
static async signChallengeWithSharedKey(challenge: string): Promise<AuthChallenge> {
|
|
314
|
+
const publicKey = await KeyManager.getSharedPublicKey();
|
|
315
|
+
const privateKey = await KeyManager.getSharedPrivateKey();
|
|
316
|
+
if (!publicKey || !privateKey) {
|
|
317
|
+
throw new Error('No shared identity found. Cannot sign with the shared key.');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const timestamp = Date.now();
|
|
321
|
+
const message = `auth:${publicKey}:${challenge}:${timestamp}`;
|
|
322
|
+
const signature = await SignatureService.signWithKey(message, privateKey);
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
challenge: signature,
|
|
326
|
+
publicKey,
|
|
327
|
+
timestamp,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
239
331
|
/**
|
|
240
332
|
* Verify a challenge response
|
|
241
333
|
*/
|
|
@@ -308,6 +400,139 @@ export class SignatureService {
|
|
|
308
400
|
timestamp,
|
|
309
401
|
};
|
|
310
402
|
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Build a signed-record envelope for a self-issued identity/profile record.
|
|
406
|
+
*
|
|
407
|
+
* The envelope is self-issued: `issuer` equals `subject` (the signer's DID).
|
|
408
|
+
* The signature covers the canonical JSON of every field EXCEPT `publicKey`
|
|
409
|
+
* and `signature` (see {@link signedRecordSigningInput}); `alg` is
|
|
410
|
+
* `ES256K-DER-SHA256` (secp256k1 over the SHA-256 of the canonical bytes,
|
|
411
|
+
* DER-encoded), the same scheme this service uses everywhere else.
|
|
412
|
+
*
|
|
413
|
+
* Requires a stored identity (native secure storage); throws if none exists.
|
|
414
|
+
*
|
|
415
|
+
* @param type - The record category (`'identity'` or `'profile'`).
|
|
416
|
+
* @param subject - The subject DID the record is about (also the issuer).
|
|
417
|
+
* @param record - The arbitrary record payload to attest to.
|
|
418
|
+
*/
|
|
419
|
+
static async signRecord(
|
|
420
|
+
type: SignedRecordEnvelope['type'],
|
|
421
|
+
subject: string,
|
|
422
|
+
record: Record<string, unknown>,
|
|
423
|
+
): Promise<SignedRecordEnvelope> {
|
|
424
|
+
const publicKey = await KeyManager.getPublicKey();
|
|
425
|
+
if (!publicKey) {
|
|
426
|
+
throw new Error('No identity found. Please create or import an identity first.');
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const version = 1 as const;
|
|
430
|
+
const issuer = subject;
|
|
431
|
+
const issuedAt = Date.now();
|
|
432
|
+
const signingInput = signedRecordSigningInput({
|
|
433
|
+
version,
|
|
434
|
+
type,
|
|
435
|
+
subject,
|
|
436
|
+
issuer,
|
|
437
|
+
record,
|
|
438
|
+
issuedAt,
|
|
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
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Build a signed-record envelope (v2) carrying the per-subject hash-chain
|
|
457
|
+
* fields.
|
|
458
|
+
*
|
|
459
|
+
* Identical to {@link signRecord} (self-issued: `issuer === subject`; same
|
|
460
|
+
* `ES256K-DER-SHA256` scheme over {@link signedRecordSigningInput}) but
|
|
461
|
+
* `version` is `2` and the signed bytes additionally cover the chain fields:
|
|
462
|
+
*
|
|
463
|
+
* @param type - The record category.
|
|
464
|
+
* @param subject - The subject DID the record is about (also the issuer).
|
|
465
|
+
* @param record - The arbitrary record payload to attest to.
|
|
466
|
+
* @param chain - The hash-chain coordinates:
|
|
467
|
+
* - `seq` — strictly-increasing sequence number for this subject's chain.
|
|
468
|
+
* - `prev` — the `recordId` of the previous record, or `null` at genesis.
|
|
469
|
+
* - `collection` + `rkey` — the AtProto-style record key.
|
|
470
|
+
*
|
|
471
|
+
* The caller is responsible for fetching the current chain head (so `seq` /
|
|
472
|
+
* `prev` are correct) before signing. Requires a stored identity; throws if
|
|
473
|
+
* none exists.
|
|
474
|
+
*/
|
|
475
|
+
static async signRecordV2(
|
|
476
|
+
type: SignedRecordEnvelope['type'],
|
|
477
|
+
subject: string,
|
|
478
|
+
record: Record<string, unknown>,
|
|
479
|
+
chain: { seq: number; prev: string | null; collection: string; rkey: string },
|
|
480
|
+
): Promise<SignedRecordEnvelope> {
|
|
481
|
+
const publicKey = await KeyManager.getPublicKey();
|
|
482
|
+
if (!publicKey) {
|
|
483
|
+
throw new Error('No identity found. Please create or import an identity first.');
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const version = 2 as const;
|
|
487
|
+
const issuer = subject;
|
|
488
|
+
const issuedAt = Date.now();
|
|
489
|
+
const { seq, prev, collection, rkey } = chain;
|
|
490
|
+
const signingInput = signedRecordSigningInput({
|
|
491
|
+
version,
|
|
492
|
+
type,
|
|
493
|
+
subject,
|
|
494
|
+
issuer,
|
|
495
|
+
record,
|
|
496
|
+
issuedAt,
|
|
497
|
+
seq,
|
|
498
|
+
prev,
|
|
499
|
+
collection,
|
|
500
|
+
rkey,
|
|
501
|
+
});
|
|
502
|
+
const signature = await SignatureService.sign(signingInput);
|
|
503
|
+
|
|
504
|
+
return {
|
|
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);
|
|
535
|
+
}
|
|
311
536
|
}
|
|
312
537
|
|
|
313
538
|
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,
|
|
@@ -82,6 +89,8 @@ export {
|
|
|
82
89
|
getNormalizedUserHandle,
|
|
83
90
|
} from './utils/userHandle';
|
|
84
91
|
export type { CanonicalUserHandleInput, UserHandleInput } from './utils/userHandle';
|
|
92
|
+
export { normalizeProfileLinks } from './utils/profileLinks';
|
|
93
|
+
export type { ProfileLink, ProfileLinkMetadata } from './utils/profileLinks';
|
|
85
94
|
|
|
86
95
|
// ---------------------------------------------------------------------------
|
|
87
96
|
// Applications (multi-user apps: membership, roles, credentials)
|
|
@@ -159,6 +168,49 @@ export type {
|
|
|
159
168
|
ReverseReputationTransactionInput,
|
|
160
169
|
} from './mixins/OxyServices.reputation';
|
|
161
170
|
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// Self-sovereign identity (DID, signed records, auth-method ↔ VM mapping,
|
|
173
|
+
// verified domains). Wire shapes (DidDocument, SignedRecordEnvelope,
|
|
174
|
+
// AuthMethodsResponse, VerifiedDomain, DomainVerificationInstructions,
|
|
175
|
+
// ExportBundle) live in `@oxyhq/contracts` — import them directly from there.
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
export { buildUserDid } from './mixins/OxyServices.identity';
|
|
178
|
+
export type {
|
|
179
|
+
IdentityRecordType,
|
|
180
|
+
UnlinkableAuthMethodType,
|
|
181
|
+
LinkAuthMethodResult,
|
|
182
|
+
PublishRecordResult,
|
|
183
|
+
VerifyRecordResult,
|
|
184
|
+
VerifyDomainResult,
|
|
185
|
+
RemoveDomainResult,
|
|
186
|
+
} from './mixins/OxyServices.identity';
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// Civic / Commons "Oxy ID" (public signed cards + Oxy ID QR payload) and Fase 2
|
|
190
|
+
// anti-gaming (real-life attestation QR + validator/jury). Wire shapes
|
|
191
|
+
// (PublicCard, SignedPublicCard, RealLifeAttestationResult,
|
|
192
|
+
// ValidationRequestSummary, ValidationVoteResult, ValidationVerdict, …) live in
|
|
193
|
+
// `@oxyhq/contracts` — import them from there. The SDK adds the client verdict
|
|
194
|
+
// wrapper, the QR payload parsers/builders, and the submit inputs/results.
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
export {
|
|
197
|
+
parseIdPayload,
|
|
198
|
+
parseAttestPayload,
|
|
199
|
+
verifyPublicCardAttestation,
|
|
200
|
+
} from './mixins/OxyServices.civic';
|
|
201
|
+
export type {
|
|
202
|
+
CivicCardResult,
|
|
203
|
+
IdCardRef,
|
|
204
|
+
AttestQrPayload,
|
|
205
|
+
ParsedAttestPayload,
|
|
206
|
+
SubmitRealLifeAttestationInput,
|
|
207
|
+
DenyValidationResult,
|
|
208
|
+
VouchForPersonInput,
|
|
209
|
+
WithdrawVouchResult,
|
|
210
|
+
IssueCredentialInput,
|
|
211
|
+
RevokeCredentialResult,
|
|
212
|
+
} from './mixins/OxyServices.civic';
|
|
213
|
+
|
|
162
214
|
// ---------------------------------------------------------------------------
|
|
163
215
|
// Auth helpers (token refresh, error normalisation, retry policies)
|
|
164
216
|
// ---------------------------------------------------------------------------
|
|
@@ -206,8 +258,9 @@ export {
|
|
|
206
258
|
IdentityPersistError,
|
|
207
259
|
} from './crypto/keyManager';
|
|
208
260
|
export type { KeyPair } from './crypto/keyManager';
|
|
209
|
-
export { SignatureService } from './crypto/signatureService';
|
|
210
|
-
export type { SignedMessage, AuthChallenge } from './crypto/signatureService';
|
|
261
|
+
export { SignatureService, signedRecordSigningInput, computeRecordId } from './crypto/signatureService';
|
|
262
|
+
export type { SignedMessage, AuthChallenge, SignedRecordSigningFields } from './crypto/signatureService';
|
|
263
|
+
export { canonicalize } from './crypto/canonicalJson';
|
|
211
264
|
export { RecoveryPhraseService } from './crypto/recoveryPhrase';
|
|
212
265
|
export type { RecoveryPhraseResult } from './crypto/recoveryPhrase';
|
|
213
266
|
|
|
@@ -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
|
+
}
|