@oxy.so/protocol 1.1.4 → 1.2.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/index.js +2 -2
- package/dist/cjs/platform/crypto.js +4 -7
- package/dist/cjs/platform/crypto.native.js +64 -34
- package/dist/cjs/secp256k1.js +24 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/platform/crypto.js +3 -6
- package/dist/esm/platform/crypto.native.js +63 -33
- package/dist/esm/secp256k1.js +23 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +2 -2
- package/dist/types/platform/crypto.d.ts +3 -3
- package/dist/types/platform/crypto.native.d.ts +3 -3
- package/dist/types/platform/expoTypes.d.ts +52 -20
- package/dist/types/secp256k1.d.ts +8 -0
- package/package.json +2 -2
- package/src/__tests__/commonsIdentityBridge.test.ts +109 -0
- package/src/index.ts +9 -2
- package/src/platform/crypto.native.ts +101 -33
- package/src/platform/crypto.ts +19 -8
- package/src/platform/expoTypes.ts +55 -17
- package/src/secp256k1.ts +27 -0
package/dist/types/index.d.ts
CHANGED
|
@@ -29,5 +29,5 @@ export { registrationPowMessage, countLeadingZeroBits, meetsRegistrationPowDiffi
|
|
|
29
29
|
export { isAuthorizedKey } from './identity/resolver';
|
|
30
30
|
export type { VerificationMethodResolver, ResolvedVerificationMethods, KeyAuthorization, } from './identity/resolver';
|
|
31
31
|
export { isReactNative, isNodeJS } from './platform/platform';
|
|
32
|
-
export { loadNodeCrypto, loadExpoCrypto, loadSecureStore, loadAsyncStorage, getRandomBytesRN,
|
|
33
|
-
export type { ExpoCryptoLike, ExpoSecureStoreLike,
|
|
32
|
+
export { loadNodeCrypto, loadExpoCrypto, loadSecureStore, loadAsyncStorage, getRandomBytesRN, loadCommonsIdentityBridge, } from './platform/crypto';
|
|
33
|
+
export type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike, } from './platform/expoTypes';
|
|
@@ -71,8 +71,8 @@
|
|
|
71
71
|
* see a file containing only specifiers it can understand — no tricks, no
|
|
72
72
|
* runtime parsing risks.
|
|
73
73
|
*/
|
|
74
|
-
import type { ExpoCryptoLike, ExpoSecureStoreLike
|
|
75
|
-
export type { ExpoCryptoLike, ExpoSecureStoreLike,
|
|
74
|
+
import type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike } from './expoTypes';
|
|
75
|
+
export type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike, };
|
|
76
76
|
export declare function loadNodeCrypto(): Promise<typeof import('crypto')>;
|
|
77
77
|
export declare function loadExpoCrypto(): Promise<ExpoCryptoLike>;
|
|
78
78
|
export declare function loadSecureStore(): Promise<ExpoSecureStoreLike>;
|
|
@@ -84,4 +84,4 @@ export declare function loadAsyncStorage(): Promise<{
|
|
|
84
84
|
};
|
|
85
85
|
}>;
|
|
86
86
|
export { getRandomBytesRN } from './random';
|
|
87
|
-
export declare function
|
|
87
|
+
export declare function loadCommonsIdentityBridge(): Promise<CommonsIdentityBridge | null>;
|
|
@@ -51,8 +51,8 @@
|
|
|
51
51
|
* `expo-modules-core` is a NON-optional peer (every RN app has it via `expo`),
|
|
52
52
|
* so it stays a plain static import.
|
|
53
53
|
*/
|
|
54
|
-
import type { ExpoCryptoLike, ExpoSecureStoreLike
|
|
55
|
-
export type { ExpoCryptoLike, ExpoSecureStoreLike,
|
|
54
|
+
import type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike } from './expoTypes';
|
|
55
|
+
export type { CommonsIdentityBridge, CommonsIdentityDescription, CommonsIdentityProof, CommonsSocialReceiveSignature, ExpoCryptoLike, ExpoSecureStoreLike, };
|
|
56
56
|
/** Persistent KV storage surface used from `@react-native-async-storage/async-storage`. */
|
|
57
57
|
type AsyncStorageLike = {
|
|
58
58
|
getItem: (key: string) => Promise<string | null>;
|
|
@@ -66,4 +66,4 @@ export declare function loadAsyncStorage(): Promise<{
|
|
|
66
66
|
default: AsyncStorageLike;
|
|
67
67
|
}>;
|
|
68
68
|
export { getRandomBytesRN } from './random.native';
|
|
69
|
-
export declare function
|
|
69
|
+
export declare function loadCommonsIdentityBridge(): Promise<CommonsIdentityBridge | null>;
|
|
@@ -73,27 +73,59 @@ export interface ExpoSecureStoreLike {
|
|
|
73
73
|
*/
|
|
74
74
|
readonly WHEN_UNLOCKED: number;
|
|
75
75
|
}
|
|
76
|
+
/** What Commons says about the identity it holds (`describe`). */
|
|
77
|
+
export interface CommonsIdentityDescription {
|
|
78
|
+
/** Protocol version of the Commons identity host. */
|
|
79
|
+
v: number;
|
|
80
|
+
/** The identity's public key, lowercase uncompressed SEC1 hex. */
|
|
81
|
+
publicKey: string;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* A signed server challenge from Commons (`proveIdentity`). The fields plug
|
|
85
|
+
* straight into `POST /auth/verify`: Commons signed
|
|
86
|
+
* `sha256("auth:${publicKey}:${challenge}:${timestamp}")`, DER hex.
|
|
87
|
+
*/
|
|
88
|
+
export interface CommonsIdentityProof {
|
|
89
|
+
publicKey: string;
|
|
90
|
+
signature: string;
|
|
91
|
+
timestamp: number;
|
|
92
|
+
}
|
|
93
|
+
/** A social-receive input signature from Commons (`signSocialReceive`). */
|
|
94
|
+
export interface CommonsSocialReceiveSignature {
|
|
95
|
+
/** DER hex, low-S, over the 32-byte digest the caller sent. */
|
|
96
|
+
signature: string;
|
|
97
|
+
/** The child key's compressed SEC1 public key, for the scriptSig. */
|
|
98
|
+
publicKey: string;
|
|
99
|
+
}
|
|
76
100
|
/**
|
|
77
|
-
* Structural interface for the
|
|
78
|
-
*
|
|
101
|
+
* Structural interface for the `OxyIdentity` native module in
|
|
102
|
+
* `@oxy.so/services`: the client side of the identity Commons holds.
|
|
79
103
|
*
|
|
80
|
-
* On Android
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
104
|
+
* On Android every method is one signature-protected `ContentProvider.call()`
|
|
105
|
+
* into Commons (`so.oxy.commons[.dev].identity`). Commons is the only app that
|
|
106
|
+
* holds the private key, and no method returns it or anything it can be
|
|
107
|
+
* recovered from: callers get the public key, signatures and domain-separated
|
|
108
|
+
* derivations, the way apps get tokens from `AccountManager`. Every method
|
|
109
|
+
* resolves `null` when Commons is not installed, holds no identity, or refuses
|
|
110
|
+
* the caller.
|
|
111
|
+
*
|
|
112
|
+
* On iOS the module is a stub that resolves `null`: the Keychain Access Group
|
|
113
|
+
* path in `@oxy.so/core`'s `KeyManager` owns iOS sharing.
|
|
86
114
|
*/
|
|
87
|
-
export interface
|
|
88
|
-
/**
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
115
|
+
export interface CommonsIdentityBridge {
|
|
116
|
+
/** The public key of the identity Commons holds. */
|
|
117
|
+
describe(): Promise<CommonsIdentityDescription | null>;
|
|
118
|
+
/** Sign a server challenge (64 lowercase hex) for `POST /auth/verify`. */
|
|
119
|
+
proveIdentity(challenge: string): Promise<CommonsIdentityProof | null>;
|
|
120
|
+
/**
|
|
121
|
+
* HKDF-SHA256 over the identity key, 32 bytes as lowercase hex, identical to
|
|
122
|
+
* `KeyManager.deriveScopedSeed(info)` on the device that holds the key.
|
|
123
|
+
* Commons allows each caller package only its own `info` labels.
|
|
124
|
+
*/
|
|
125
|
+
deriveScopedSeed(info: string): Promise<string | null>;
|
|
126
|
+
/**
|
|
127
|
+
* Sign a 32-byte digest (64 hex) with the social-receive child key `index`
|
|
128
|
+
* of the identity. Allowed for the wallet app only.
|
|
129
|
+
*/
|
|
130
|
+
signSocialReceive(index: number, digest: string): Promise<CommonsSocialReceiveSignature | null>;
|
|
99
131
|
}
|
|
@@ -41,5 +41,13 @@ export declare function normalizeSecp256k1PublicKey(publicKeyHex: string, compre
|
|
|
41
41
|
export declare function signSecp256k1Digest(privateKeyHex: string, digestHex: string, options?: Secp256k1SignatureOptions): string;
|
|
42
42
|
/** Verify a DER-encoded ECDSA signature, accepting historical high-S signatures. */
|
|
43
43
|
export declare function verifySecp256k1Digest(publicKeyHex: string, digestHex: string, signatureDerHex: string): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* `(privateKey + tweak) mod n`, the scalar step of BIP32 non-hardened child
|
|
46
|
+
* derivation (`CKDpriv`: the tweak is `IL`, the left half of the HMAC-SHA512).
|
|
47
|
+
*
|
|
48
|
+
* Throws, as BIP32 requires, when the tweak is not below the curve order or
|
|
49
|
+
* the sum is zero: that child is invalid and the caller must not use it.
|
|
50
|
+
*/
|
|
51
|
+
export declare function tweakAddSecp256k1PrivateKey(privateKeyHex: string, tweakHex: string): string;
|
|
44
52
|
/** Derive the fixed-width 32-byte ECDH x-coordinate shared secret. */
|
|
45
53
|
export declare function deriveSecp256k1SharedSecret(privateKeyHex: string, publicKeyHex: string): Uint8Array;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxy.so/protocol",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Oxy Protocol — the app-agnostic base substrate: signed-record envelope, canonical JSON, signature/verification, and platform crypto. Reused by any Oxy app to decentralize its own content.",
|
|
5
5
|
"main": "dist/cjs/index.js",
|
|
6
6
|
"module": "dist/esm/index.js",
|
|
@@ -131,7 +131,7 @@
|
|
|
131
131
|
},
|
|
132
132
|
"dependencies": {
|
|
133
133
|
"@noble/curves": "^1.9.7",
|
|
134
|
-
"@oxy.so/contracts": "^4.
|
|
134
|
+
"@oxy.so/contracts": "^4.4.0",
|
|
135
135
|
"zod": "^3.25.64"
|
|
136
136
|
},
|
|
137
137
|
"peerDependencies": {
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Commons identity bridge narrows every native answer to its exact shape.
|
|
3
|
+
*
|
|
4
|
+
* What `OxyIdentity` returns crossed a process boundary (a ContentProvider call
|
|
5
|
+
* into Commons), so a partial or malformed answer must read as "Commons could
|
|
6
|
+
* not help" (`null`), never as a half-filled value a caller then signs in with.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const PUBLIC_KEY = `04${'ab'.repeat(64)}`;
|
|
10
|
+
|
|
11
|
+
function mockNative(native: unknown): void {
|
|
12
|
+
jest.doMock('expo-modules-core', () => ({ requireOptionalNativeModule: () => native }), { virtual: true });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function loadBridge() {
|
|
16
|
+
const mod = await import('../platform/crypto.native');
|
|
17
|
+
return mod.loadCommonsIdentityBridge();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('loadCommonsIdentityBridge (native)', () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
jest.resetModules();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('null when the module is not linked', async () => {
|
|
26
|
+
mockNative(null);
|
|
27
|
+
await expect(loadBridge()).resolves.toBeNull();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('null when the module predates the Commons methods', async () => {
|
|
31
|
+
mockNative({ getShared: async () => null });
|
|
32
|
+
await expect(loadBridge()).resolves.toBeNull();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('passes well-formed answers through', async () => {
|
|
36
|
+
const calls: unknown[][] = [];
|
|
37
|
+
mockNative({
|
|
38
|
+
describe: async () => ({ v: 2, publicKey: PUBLIC_KEY }),
|
|
39
|
+
proveIdentity: async (challenge: string) => {
|
|
40
|
+
calls.push(['proveIdentity', challenge]);
|
|
41
|
+
return { publicKey: PUBLIC_KEY, signature: '3044aa', timestamp: 1_700_000_000_000 };
|
|
42
|
+
},
|
|
43
|
+
deriveScopedSeed: async (info: string) => {
|
|
44
|
+
calls.push(['deriveScopedSeed', info]);
|
|
45
|
+
return 'cd'.repeat(32);
|
|
46
|
+
},
|
|
47
|
+
signSocialReceive: async (index: number, digest: string) => {
|
|
48
|
+
calls.push(['signSocialReceive', index, digest]);
|
|
49
|
+
return { signature: '3045bb', publicKey: `02${'ef'.repeat(32)}` };
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
const bridge = await loadBridge();
|
|
53
|
+
expect(bridge).not.toBeNull();
|
|
54
|
+
await expect(bridge?.describe()).resolves.toEqual({ v: 2, publicKey: PUBLIC_KEY });
|
|
55
|
+
await expect(bridge?.proveIdentity('00'.repeat(32))).resolves.toEqual({
|
|
56
|
+
publicKey: PUBLIC_KEY,
|
|
57
|
+
signature: '3044aa',
|
|
58
|
+
timestamp: 1_700_000_000_000,
|
|
59
|
+
});
|
|
60
|
+
await expect(bridge?.deriveScopedSeed('peable/faircoin/v1')).resolves.toBe('cd'.repeat(32));
|
|
61
|
+
await expect(bridge?.signSocialReceive(3, '11'.repeat(32))).resolves.toEqual({
|
|
62
|
+
signature: '3045bb',
|
|
63
|
+
publicKey: `02${'ef'.repeat(32)}`,
|
|
64
|
+
});
|
|
65
|
+
expect(calls).toEqual([
|
|
66
|
+
['proveIdentity', '00'.repeat(32)],
|
|
67
|
+
['deriveScopedSeed', 'peable/faircoin/v1'],
|
|
68
|
+
['signSocialReceive', 3, '11'.repeat(32)],
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('malformed or partial answers, and rejections, are null', async () => {
|
|
73
|
+
mockNative({
|
|
74
|
+
describe: async () => ({ v: 2, publicKey: 'not-hex' }),
|
|
75
|
+
proveIdentity: async () => ({ publicKey: PUBLIC_KEY, signature: '3044aa' }),
|
|
76
|
+
deriveScopedSeed: async () => 'cd'.repeat(16),
|
|
77
|
+
signSocialReceive: async () => {
|
|
78
|
+
throw new Error('refused');
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
const bridge = await loadBridge();
|
|
82
|
+
await expect(bridge?.describe()).resolves.toBeNull();
|
|
83
|
+
await expect(bridge?.proveIdentity('00'.repeat(32))).resolves.toBeNull();
|
|
84
|
+
await expect(bridge?.deriveScopedSeed('x')).resolves.toBeNull();
|
|
85
|
+
await expect(bridge?.signSocialReceive(0, '11'.repeat(32))).resolves.toBeNull();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('the web / Node variant never has a bridge', async () => {
|
|
89
|
+
const mod = await import('../platform/crypto');
|
|
90
|
+
await expect(mod.loadCommonsIdentityBridge()).resolves.toBeNull();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('tweakAddSecp256k1PrivateKey', () => {
|
|
95
|
+
test('adds modulo the curve order and rejects invalid children', async () => {
|
|
96
|
+
const { tweakAddSecp256k1PrivateKey } = await import('../secp256k1');
|
|
97
|
+
expect(tweakAddSecp256k1PrivateKey('01', `${'0'.repeat(63)}2`)).toBe(`${'0'.repeat(63)}3`);
|
|
98
|
+
const nMinusOne = 'fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364140';
|
|
99
|
+
// (n - 1) + 2 wraps to 1.
|
|
100
|
+
expect(tweakAddSecp256k1PrivateKey(nMinusOne, `${'0'.repeat(63)}2`)).toBe(`${'0'.repeat(63)}1`);
|
|
101
|
+
// (n - 1) + 1 is zero: an invalid child.
|
|
102
|
+
expect(() => tweakAddSecp256k1PrivateKey(nMinusOne, `${'0'.repeat(63)}1`)).toThrow('zero');
|
|
103
|
+
// A tweak at or above n is invalid.
|
|
104
|
+
expect(() =>
|
|
105
|
+
tweakAddSecp256k1PrivateKey('01', 'fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141'),
|
|
106
|
+
).toThrow('curve order');
|
|
107
|
+
expect(() => tweakAddSecp256k1PrivateKey('01', 'zz')).toThrow();
|
|
108
|
+
});
|
|
109
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -98,7 +98,7 @@ export {
|
|
|
98
98
|
loadSecureStore,
|
|
99
99
|
loadAsyncStorage,
|
|
100
100
|
getRandomBytesRN,
|
|
101
|
-
|
|
101
|
+
loadCommonsIdentityBridge,
|
|
102
102
|
} from './platform/crypto';
|
|
103
103
|
|
|
104
104
|
// ---------------------------------------------------------------------------
|
|
@@ -108,4 +108,11 @@ export {
|
|
|
108
108
|
// without importing `typeof import('expo-crypto')` / `typeof import('expo-secure-store')`
|
|
109
109
|
// (which would trigger NodeNext type pollution in server packages).
|
|
110
110
|
// ---------------------------------------------------------------------------
|
|
111
|
-
export type {
|
|
111
|
+
export type {
|
|
112
|
+
CommonsIdentityBridge,
|
|
113
|
+
CommonsIdentityDescription,
|
|
114
|
+
CommonsIdentityProof,
|
|
115
|
+
CommonsSocialReceiveSignature,
|
|
116
|
+
ExpoCryptoLike,
|
|
117
|
+
ExpoSecureStoreLike,
|
|
118
|
+
} from './platform/expoTypes';
|
|
@@ -53,13 +53,27 @@
|
|
|
53
53
|
*/
|
|
54
54
|
|
|
55
55
|
import { requireOptionalNativeModule } from 'expo-modules-core';
|
|
56
|
-
import type {
|
|
56
|
+
import type {
|
|
57
|
+
CommonsIdentityBridge,
|
|
58
|
+
CommonsIdentityDescription,
|
|
59
|
+
CommonsIdentityProof,
|
|
60
|
+
CommonsSocialReceiveSignature,
|
|
61
|
+
ExpoCryptoLike,
|
|
62
|
+
ExpoSecureStoreLike,
|
|
63
|
+
} from './expoTypes';
|
|
57
64
|
import { missingOptionalPeerError } from './optionalPeer';
|
|
58
65
|
import { requireExpoCrypto } from './random.native';
|
|
59
66
|
|
|
60
67
|
// Re-export the interfaces so consumers can import them from the same
|
|
61
68
|
// entry-point they use for the loaders (mirrors the default variant).
|
|
62
|
-
export type {
|
|
69
|
+
export type {
|
|
70
|
+
CommonsIdentityBridge,
|
|
71
|
+
CommonsIdentityDescription,
|
|
72
|
+
CommonsIdentityProof,
|
|
73
|
+
CommonsSocialReceiveSignature,
|
|
74
|
+
ExpoCryptoLike,
|
|
75
|
+
ExpoSecureStoreLike,
|
|
76
|
+
};
|
|
63
77
|
|
|
64
78
|
// ---------------------------------------------------------------------------
|
|
65
79
|
// Optional peer resolution.
|
|
@@ -170,44 +184,98 @@ export async function loadAsyncStorage(): Promise<{ default: AsyncStorageLike }>
|
|
|
170
184
|
export { getRandomBytesRN } from './random.native';
|
|
171
185
|
|
|
172
186
|
// ---------------------------------------------------------------------------
|
|
173
|
-
//
|
|
187
|
+
// Commons identity bridge — the `OxyIdentity` native module in
|
|
188
|
+
// `@oxy.so/services` (native-only, OPTIONAL).
|
|
189
|
+
//
|
|
190
|
+
// Resolved through expo-modules-core's `requireOptionalNativeModule`, a static
|
|
191
|
+
// import Metro always resolves, never through a runtime-computed
|
|
192
|
+
// `import(moduleName)`: that compiles to `require(variable)` in the CJS build,
|
|
193
|
+
// which Metro cannot resolve in a consuming repo (the bridge silently resolved
|
|
194
|
+
// `null` there once). `requireOptionalNativeModule` returns `null` when the
|
|
195
|
+
// module is not autolinked (web, or an app without `@oxy.so/services`).
|
|
174
196
|
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
// — a static import Metro always resolves — instead of dynamically importing the
|
|
179
|
-
// module's JS wrapper. A runtime-computed `import(moduleName)` compiled to a
|
|
180
|
-
// `require(variable)` in the CJS build, which Metro cannot resolve in a consuming
|
|
181
|
-
// repo (the bridge silently resolved `null` there — the cross-app SSO bug). Since
|
|
182
|
-
// the native module is what actually holds the shared identity, going through the
|
|
183
|
-
// native registry is both correct and Metro-safe. `requireOptionalNativeModule`
|
|
184
|
-
// returns `null` (never throws) when the module is not autolinked (web, or apps
|
|
185
|
-
// that don't ship it), so `@oxy.so/core`'s `KeyManager` cleanly falls back to its
|
|
186
|
-
// package-private store.
|
|
197
|
+
// Everything the native side returns is UNTRUSTED here: it crossed a process
|
|
198
|
+
// boundary. Each answer is narrowed to its exact shape, and anything else is
|
|
199
|
+
// `null` ("Commons could not help"), never a partial value.
|
|
187
200
|
// ---------------------------------------------------------------------------
|
|
188
201
|
|
|
189
|
-
|
|
202
|
+
interface OxyIdentityNativeModule {
|
|
203
|
+
describe(): Promise<unknown>;
|
|
204
|
+
proveIdentity(challenge: string): Promise<unknown>;
|
|
205
|
+
deriveScopedSeed(info: string): Promise<unknown>;
|
|
206
|
+
signSocialReceive(index: number, digest: string): Promise<unknown>;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const LOWER_HEX = /^[0-9a-f]+$/;
|
|
210
|
+
|
|
211
|
+
function record(value: unknown): Record<string, unknown> | null {
|
|
212
|
+
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function hexField(value: unknown, length?: number): string | null {
|
|
216
|
+
if (typeof value !== 'string' || !LOWER_HEX.test(value)) return null;
|
|
217
|
+
if (length !== undefined && value.length !== length) return null;
|
|
218
|
+
return value;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function narrowDescription(value: unknown): CommonsIdentityDescription | null {
|
|
222
|
+
const r = record(value);
|
|
223
|
+
const publicKey = hexField(r?.publicKey, 130);
|
|
224
|
+
const v = typeof r?.v === 'number' ? r.v : Number(r?.v);
|
|
225
|
+
if (!publicKey || !Number.isInteger(v)) return null;
|
|
226
|
+
return { v, publicKey };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function narrowProof(value: unknown): CommonsIdentityProof | null {
|
|
230
|
+
const r = record(value);
|
|
231
|
+
const publicKey = hexField(r?.publicKey, 130);
|
|
232
|
+
const signature = hexField(r?.signature);
|
|
233
|
+
const timestamp = typeof r?.timestamp === 'number' ? r.timestamp : Number(r?.timestamp);
|
|
234
|
+
if (!publicKey || !signature || !Number.isSafeInteger(timestamp) || timestamp <= 0) return null;
|
|
235
|
+
return { publicKey, signature, timestamp };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function narrowSocialReceive(value: unknown): CommonsSocialReceiveSignature | null {
|
|
239
|
+
const r = record(value);
|
|
240
|
+
const signature = hexField(r?.signature);
|
|
241
|
+
const publicKey = hexField(r?.publicKey, 66);
|
|
242
|
+
if (!signature || !publicKey) return null;
|
|
243
|
+
return { signature, publicKey };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Run one native call; a rejection or an unexpected shape is `null`. */
|
|
247
|
+
async function ask<T>(call: () => Promise<unknown>, narrow: (value: unknown) => T | null): Promise<T | null> {
|
|
248
|
+
try {
|
|
249
|
+
return narrow(await call());
|
|
250
|
+
} catch {
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
let commonsIdentityBridgePromise: Promise<CommonsIdentityBridge | null> | null = null;
|
|
190
256
|
|
|
191
|
-
export function
|
|
192
|
-
if (!
|
|
193
|
-
|
|
194
|
-
const native = requireOptionalNativeModule<Partial<
|
|
257
|
+
export function loadCommonsIdentityBridge(): Promise<CommonsIdentityBridge | null> {
|
|
258
|
+
if (!commonsIdentityBridgePromise) {
|
|
259
|
+
commonsIdentityBridgePromise = Promise.resolve().then(() => {
|
|
260
|
+
const native = requireOptionalNativeModule<Partial<OxyIdentityNativeModule>>('OxyIdentity');
|
|
195
261
|
if (
|
|
196
|
-
native
|
|
197
|
-
typeof native.
|
|
198
|
-
typeof native.
|
|
199
|
-
typeof native.
|
|
200
|
-
typeof native.
|
|
262
|
+
!native ||
|
|
263
|
+
typeof native.describe !== 'function' ||
|
|
264
|
+
typeof native.proveIdentity !== 'function' ||
|
|
265
|
+
typeof native.deriveScopedSeed !== 'function' ||
|
|
266
|
+
typeof native.signSocialReceive !== 'function'
|
|
201
267
|
) {
|
|
202
|
-
return
|
|
203
|
-
getShared: native.getShared.bind(native),
|
|
204
|
-
putShared: native.putShared.bind(native),
|
|
205
|
-
hasShared: native.hasShared.bind(native),
|
|
206
|
-
clearShared: native.clearShared.bind(native),
|
|
207
|
-
} satisfies SharedIdentityBridge;
|
|
268
|
+
return null;
|
|
208
269
|
}
|
|
209
|
-
|
|
270
|
+
const m = native as OxyIdentityNativeModule;
|
|
271
|
+
return {
|
|
272
|
+
describe: () => ask(() => m.describe(), narrowDescription),
|
|
273
|
+
proveIdentity: (challenge: string) => ask(() => m.proveIdentity(challenge), narrowProof),
|
|
274
|
+
deriveScopedSeed: (info: string) => ask(() => m.deriveScopedSeed(info), (v) => hexField(v, 64)),
|
|
275
|
+
signSocialReceive: (index: number, digest: string) =>
|
|
276
|
+
ask(() => m.signSocialReceive(index, digest), narrowSocialReceive),
|
|
277
|
+
} satisfies CommonsIdentityBridge;
|
|
210
278
|
});
|
|
211
279
|
}
|
|
212
|
-
return
|
|
280
|
+
return commonsIdentityBridgePromise;
|
|
213
281
|
}
|
package/src/platform/crypto.ts
CHANGED
|
@@ -73,11 +73,25 @@
|
|
|
73
73
|
*/
|
|
74
74
|
|
|
75
75
|
import { isReactNative } from './platform';
|
|
76
|
-
import type {
|
|
76
|
+
import type {
|
|
77
|
+
CommonsIdentityBridge,
|
|
78
|
+
CommonsIdentityDescription,
|
|
79
|
+
CommonsIdentityProof,
|
|
80
|
+
CommonsSocialReceiveSignature,
|
|
81
|
+
ExpoCryptoLike,
|
|
82
|
+
ExpoSecureStoreLike,
|
|
83
|
+
} from './expoTypes';
|
|
77
84
|
|
|
78
85
|
// Re-export the interfaces so consumers can import them from the same
|
|
79
86
|
// entry-point they use for the loaders.
|
|
80
|
-
export type {
|
|
87
|
+
export type {
|
|
88
|
+
CommonsIdentityBridge,
|
|
89
|
+
CommonsIdentityDescription,
|
|
90
|
+
CommonsIdentityProof,
|
|
91
|
+
CommonsSocialReceiveSignature,
|
|
92
|
+
ExpoCryptoLike,
|
|
93
|
+
ExpoSecureStoreLike,
|
|
94
|
+
};
|
|
81
95
|
|
|
82
96
|
// ---------------------------------------------------------------------------
|
|
83
97
|
// Node `crypto` — Node built-in
|
|
@@ -154,14 +168,11 @@ export async function loadAsyncStorage(): Promise<{
|
|
|
154
168
|
export { getRandomBytesRN } from './random';
|
|
155
169
|
|
|
156
170
|
// ---------------------------------------------------------------------------
|
|
157
|
-
//
|
|
171
|
+
// Commons identity bridge — the `OxyIdentity` native module (native-only).
|
|
158
172
|
//
|
|
159
|
-
//
|
|
160
|
-
// always resolves to `null`. `@oxy.so/core`'s `KeyManager` treats `null` as "no
|
|
161
|
-
// bridge" and falls back to its package-private store — which is correct on web
|
|
162
|
-
// (there is no shared identity there).
|
|
173
|
+
// Web and Node have no Commons to ask, so this always resolves to `null`.
|
|
163
174
|
// ---------------------------------------------------------------------------
|
|
164
175
|
|
|
165
|
-
export function
|
|
176
|
+
export function loadCommonsIdentityBridge(): Promise<CommonsIdentityBridge | null> {
|
|
166
177
|
return Promise.resolve(null);
|
|
167
178
|
}
|
|
@@ -76,24 +76,62 @@ export interface ExpoSecureStoreLike {
|
|
|
76
76
|
readonly WHEN_UNLOCKED: number;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/** What Commons says about the identity it holds (`describe`). */
|
|
80
|
+
export interface CommonsIdentityDescription {
|
|
81
|
+
/** Protocol version of the Commons identity host. */
|
|
82
|
+
v: number;
|
|
83
|
+
/** The identity's public key, lowercase uncompressed SEC1 hex. */
|
|
84
|
+
publicKey: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
79
87
|
/**
|
|
80
|
-
*
|
|
81
|
-
*
|
|
88
|
+
* A signed server challenge from Commons (`proveIdentity`). The fields plug
|
|
89
|
+
* straight into `POST /auth/verify`: Commons signed
|
|
90
|
+
* `sha256("auth:${publicKey}:${challenge}:${timestamp}")`, DER hex.
|
|
91
|
+
*/
|
|
92
|
+
export interface CommonsIdentityProof {
|
|
93
|
+
publicKey: string;
|
|
94
|
+
signature: string;
|
|
95
|
+
timestamp: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A social-receive input signature from Commons (`signSocialReceive`). */
|
|
99
|
+
export interface CommonsSocialReceiveSignature {
|
|
100
|
+
/** DER hex, low-S, over the 32-byte digest the caller sent. */
|
|
101
|
+
signature: string;
|
|
102
|
+
/** The child key's compressed SEC1 public key, for the scriptSig. */
|
|
103
|
+
publicKey: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Structural interface for the `OxyIdentity` native module in
|
|
108
|
+
* `@oxy.so/services`: the client side of the identity Commons holds.
|
|
109
|
+
*
|
|
110
|
+
* On Android every method is one signature-protected `ContentProvider.call()`
|
|
111
|
+
* into Commons (`so.oxy.commons[.dev].identity`). Commons is the only app that
|
|
112
|
+
* holds the private key, and no method returns it or anything it can be
|
|
113
|
+
* recovered from: callers get the public key, signatures and domain-separated
|
|
114
|
+
* derivations, the way apps get tokens from `AccountManager`. Every method
|
|
115
|
+
* resolves `null` when Commons is not installed, holds no identity, or refuses
|
|
116
|
+
* the caller.
|
|
82
117
|
*
|
|
83
|
-
* On
|
|
84
|
-
*
|
|
85
|
-
* is a no-op (the Keychain Access Group path in `@oxy.so/core`'s `KeyManager`
|
|
86
|
-
* owns iOS sharing). Typed structurally here so `@oxy.so/protocol` and
|
|
87
|
-
* `@oxy.so/core` can reference the bridge without a hard dependency on the
|
|
88
|
-
* optional native module.
|
|
118
|
+
* On iOS the module is a stub that resolves `null`: the Keychain Access Group
|
|
119
|
+
* path in `@oxy.so/core`'s `KeyManager` owns iOS sharing.
|
|
89
120
|
*/
|
|
90
|
-
export interface
|
|
91
|
-
/**
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
121
|
+
export interface CommonsIdentityBridge {
|
|
122
|
+
/** The public key of the identity Commons holds. */
|
|
123
|
+
describe(): Promise<CommonsIdentityDescription | null>;
|
|
124
|
+
/** Sign a server challenge (64 lowercase hex) for `POST /auth/verify`. */
|
|
125
|
+
proveIdentity(challenge: string): Promise<CommonsIdentityProof | null>;
|
|
126
|
+
/**
|
|
127
|
+
* HKDF-SHA256 over the identity key, 32 bytes as lowercase hex, identical to
|
|
128
|
+
* `KeyManager.deriveScopedSeed(info)` on the device that holds the key.
|
|
129
|
+
* Commons allows each caller package only its own `info` labels.
|
|
130
|
+
*/
|
|
131
|
+
deriveScopedSeed(info: string): Promise<string | null>;
|
|
132
|
+
/**
|
|
133
|
+
* Sign a 32-byte digest (64 hex) with the social-receive child key `index`
|
|
134
|
+
* of the identity. Allowed for the wallet app only.
|
|
135
|
+
*/
|
|
136
|
+
signSocialReceive(index: number, digest: string): Promise<CommonsSocialReceiveSignature | null>;
|
|
99
137
|
}
|
package/src/secp256k1.ts
CHANGED
|
@@ -190,6 +190,33 @@ export function verifySecp256k1Digest(
|
|
|
190
190
|
});
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
+
/**
|
|
194
|
+
* `(privateKey + tweak) mod n`, the scalar step of BIP32 non-hardened child
|
|
195
|
+
* derivation (`CKDpriv`: the tweak is `IL`, the left half of the HMAC-SHA512).
|
|
196
|
+
*
|
|
197
|
+
* Throws, as BIP32 requires, when the tweak is not below the curve order or
|
|
198
|
+
* the sum is zero: that child is invalid and the caller must not use it.
|
|
199
|
+
*/
|
|
200
|
+
export function tweakAddSecp256k1PrivateKey(
|
|
201
|
+
privateKeyHex: string,
|
|
202
|
+
tweakHex: string,
|
|
203
|
+
): string {
|
|
204
|
+
const n = secp256k1.Point.Fn.ORDER;
|
|
205
|
+
const key = BigInt(`0x${normalizeSecp256k1PrivateKey(privateKeyHex)}`);
|
|
206
|
+
if (typeof tweakHex !== "string" || tweakHex.length !== 64 || !HEX.test(tweakHex)) {
|
|
207
|
+
throw new Error("secp256k1 tweak must be exactly 32 bytes of hexadecimal data");
|
|
208
|
+
}
|
|
209
|
+
const tweak = BigInt(`0x${tweakHex}`);
|
|
210
|
+
if (tweak >= n) {
|
|
211
|
+
throw new Error("secp256k1 tweak is not below the curve order");
|
|
212
|
+
}
|
|
213
|
+
const child = (key + tweak) % n;
|
|
214
|
+
if (child === 0n) {
|
|
215
|
+
throw new Error("secp256k1 tweaked private key is zero");
|
|
216
|
+
}
|
|
217
|
+
return child.toString(16).padStart(64, "0");
|
|
218
|
+
}
|
|
219
|
+
|
|
193
220
|
/** Derive the fixed-width 32-byte ECDH x-coordinate shared secret. */
|
|
194
221
|
export function deriveSecp256k1SharedSecret(
|
|
195
222
|
privateKeyHex: string,
|