@oxy.so/protocol 1.1.3 → 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.
@@ -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, loadSharedIdentityBridge, } from './platform/crypto';
33
- export type { ExpoCryptoLike, ExpoSecureStoreLike, SharedIdentityBridge } from './platform/expoTypes';
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, SharedIdentityBridge } from './expoTypes';
75
- export type { ExpoCryptoLike, ExpoSecureStoreLike, SharedIdentityBridge };
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 loadSharedIdentityBridge(): Promise<SharedIdentityBridge | null>;
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, SharedIdentityBridge } from './expoTypes';
55
- export type { ExpoCryptoLike, ExpoSecureStoreLike, SharedIdentityBridge };
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 loadSharedIdentityBridge(): Promise<SharedIdentityBridge | null>;
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 `@oxy.so/expo-oxy-identity` native module — the
78
- * cross-app shared Oxy identity bridge.
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 the keypair crosses the process boundary through a
81
- * signature-protected `ContentProvider` hosted by Commons; on iOS every method
82
- * is a no-op (the Keychain Access Group path in `@oxy.so/core`'s `KeyManager`
83
- * owns iOS sharing). Typed structurally here so `@oxy.so/protocol` and
84
- * `@oxy.so/core` can reference the bridge without a hard dependency on the
85
- * optional native module.
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 SharedIdentityBridge {
88
- /** Read the shared keypair, or null when none is available on this device. */
89
- getShared(): Promise<{
90
- privateKey: string;
91
- publicKey: string;
92
- } | null>;
93
- /** Persist the shared keypair into this app's hardware-backed store (Commons only). */
94
- putShared(privateKey: string, publicKey: string): Promise<void>;
95
- /** Whether a shared identity is readable on this device. */
96
- hasShared(): Promise<boolean>;
97
- /** Remove the shared identity from this app's local store (best-effort). */
98
- clearShared(): Promise<void>;
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.1.3",
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": "^3.0.0",
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
- loadSharedIdentityBridge,
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 { ExpoCryptoLike, ExpoSecureStoreLike, SharedIdentityBridge } from './platform/expoTypes';
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 { ExpoCryptoLike, ExpoSecureStoreLike, SharedIdentityBridge } from './expoTypes';
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 { ExpoCryptoLike, ExpoSecureStoreLike, SharedIdentityBridge };
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
- // Shared identity bridge — `@oxy.so/expo-oxy-identity` (native-only, OPTIONAL).
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
- // `@oxy.so/expo-oxy-identity` is the in-repo Expo module autolinked into the
176
- // identity apps (Commons + the reader RPs). We resolve its NATIVE module
177
- // directly via expo-modules-core's `requireOptionalNativeModule('OxyIdentity')`
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
- let sharedIdentityBridgePromise: Promise<SharedIdentityBridge | null> | null = null;
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 loadSharedIdentityBridge(): Promise<SharedIdentityBridge | null> {
192
- if (!sharedIdentityBridgePromise) {
193
- sharedIdentityBridgePromise = Promise.resolve().then(() => {
194
- const native = requireOptionalNativeModule<Partial<SharedIdentityBridge>>('OxyIdentity');
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.getShared === 'function' &&
198
- typeof native.putShared === 'function' &&
199
- typeof native.hasShared === 'function' &&
200
- typeof native.clearShared === 'function'
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
- return null;
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 sharedIdentityBridgePromise;
280
+ return commonsIdentityBridgePromise;
213
281
  }
@@ -73,11 +73,25 @@
73
73
  */
74
74
 
75
75
  import { isReactNative } from './platform';
76
- import type { ExpoCryptoLike, ExpoSecureStoreLike, SharedIdentityBridge } from './expoTypes';
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 { ExpoCryptoLike, ExpoSecureStoreLike, SharedIdentityBridge };
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
- // Shared identity bridge — `@oxy.so/expo-oxy-identity` (native-only).
171
+ // Commons identity bridge — the `OxyIdentity` native module (native-only).
158
172
  //
159
- // The default (web / Node) variant has no cross-app identity channel, so this
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 loadSharedIdentityBridge(): Promise<SharedIdentityBridge | null> {
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
- * Structural interface for the `@oxy.so/expo-oxy-identity` native module — the
81
- * cross-app shared Oxy identity bridge.
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 Android the keypair crosses the process boundary through a
84
- * signature-protected `ContentProvider` hosted by Commons; on iOS every method
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 SharedIdentityBridge {
91
- /** Read the shared keypair, or null when none is available on this device. */
92
- getShared(): Promise<{ privateKey: string; publicKey: string } | null>;
93
- /** Persist the shared keypair into this app's hardware-backed store (Commons only). */
94
- putShared(privateKey: string, publicKey: string): Promise<void>;
95
- /** Whether a shared identity is readable on this device. */
96
- hasShared(): Promise<boolean>;
97
- /** Remove the shared identity from this app's local store (best-effort). */
98
- clearShared(): Promise<void>;
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,