@oxy.so/protocol 1.0.1 → 1.1.1
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/LICENSE +675 -201
- package/NOTICE +7 -2
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/auth/registrationPow.js +149 -0
- package/dist/cjs/index.js +9 -1
- package/dist/cjs/platform/crypto.js +6 -10
- package/dist/cjs/platform/crypto.native.js +14 -46
- package/dist/cjs/platform/optionalPeer.js +22 -0
- package/dist/cjs/platform/random.js +18 -0
- package/dist/cjs/platform/random.native.js +52 -0
- package/dist/cjs/random.js +25 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/auth/registrationPow.js +143 -0
- package/dist/esm/index.js +4 -0
- package/dist/esm/platform/crypto.js +4 -9
- package/dist/esm/platform/crypto.native.js +10 -43
- package/dist/esm/platform/optionalPeer.js +19 -0
- package/dist/esm/platform/random.js +15 -0
- package/dist/esm/platform/random.native.js +48 -0
- package/dist/esm/random.js +19 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/auth/registrationPow.d.ts +102 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/platform/crypto.d.ts +1 -7
- package/dist/types/platform/crypto.native.d.ts +2 -10
- package/dist/types/platform/optionalPeer.d.ts +9 -0
- package/dist/types/platform/random.d.ts +13 -0
- package/dist/types/platform/random.native.d.ts +33 -0
- package/dist/types/random.d.ts +19 -0
- package/package.json +21 -4
- package/src/__tests__/randomEntry.test.ts +107 -0
- package/src/__tests__/registrationPow.test.ts +73 -0
- package/src/auth/registrationPow.ts +146 -0
- package/src/index.ts +10 -0
- package/src/platform/crypto.native.ts +10 -48
- package/src/platform/crypto.ts +4 -9
- package/src/platform/optionalPeer.ts +23 -0
- package/src/platform/random.native.ts +56 -0
- package/src/platform/random.ts +18 -0
- package/src/random.ts +20 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registration proof-of-work — the message format and difficulty check a
|
|
3
|
+
* signup grinds a nonce against, shared verbatim between the client (which
|
|
4
|
+
* SOLVES it) and the server (which CHECKS it).
|
|
5
|
+
*
|
|
6
|
+
* ## Why this exists
|
|
7
|
+
*
|
|
8
|
+
* `POST /auth/register` mints an account from nothing more than a freshly
|
|
9
|
+
* generated secp256k1 keypair and a signature over
|
|
10
|
+
* `oxy:register:{publicKey}:{timestamp}` — computationally free. A PoW nonce
|
|
11
|
+
* makes each account cost a small, real amount of CPU time to mint, without
|
|
12
|
+
* asking a real signer for anything beyond what they already do locally: no
|
|
13
|
+
* server round trip, no third-party CAPTCHA, no extra user-visible step.
|
|
14
|
+
*
|
|
15
|
+
* ## Single source, on purpose
|
|
16
|
+
*
|
|
17
|
+
* The client must SOLVE the exact message the server CHECKS, so the message
|
|
18
|
+
* format and the difficulty threshold live here once, in a package both
|
|
19
|
+
* `@oxy.so/core` (the client SDK) and the API already depend on for
|
|
20
|
+
* secp256k1 — see `packages/contracts/src/username.ts`'s docblock for why
|
|
21
|
+
* this codebase treats "the same rule declared twice" as a bug class, not a
|
|
22
|
+
* style preference. This file is deliberately CRYPTO-FREE: it is pure
|
|
23
|
+
* string/bit arithmetic over a caller-supplied hex digest, so it needs no
|
|
24
|
+
* platform hashing primitive and is trivially unit-testable with fixed
|
|
25
|
+
* digests. Each side hashes with whatever SHA-256 it already has — Node's
|
|
26
|
+
* `crypto` server-side (`packages/api/src/controllers/session.controller.ts`),
|
|
27
|
+
* `@noble/hashes/sha256` client-side (`packages/core/src/crypto/registrationPow.ts`,
|
|
28
|
+
* already a `@oxy.so/core` dependency — see `crypto/kdf.ts`,
|
|
29
|
+
* `crypto/identityProof.ts`) — and both produce byte-identical digests over
|
|
30
|
+
* the same message, because SHA-256 has exactly one correct output for a
|
|
31
|
+
* given input regardless of implementation.
|
|
32
|
+
*
|
|
33
|
+
* ## What this is NOT
|
|
34
|
+
*
|
|
35
|
+
* Not a defense against a targeted, patient attacker: nothing stops one from
|
|
36
|
+
* choosing their OWN `timestamp` a few minutes in the future and grinding the
|
|
37
|
+
* nonce ahead of time, banking a queue of pre-solved (timestamp, nonce) pairs
|
|
38
|
+
* to spend inside the 5-minute signature freshness window
|
|
39
|
+
* (`SignatureService.isTimestampFresh` / `MAX_SIGNATURE_AGE_MS`). Closing that
|
|
40
|
+
* needs a server-issued, single-use challenge — a real round trip and server
|
|
41
|
+
* state this deliberately does not add, because the goal here is raising the
|
|
42
|
+
* COST of unattended bulk signup, not proving liveness. Combined with a
|
|
43
|
+
* per-IP rate limit on `/auth/register`, it still meaningfully raises the
|
|
44
|
+
* price of automated mass account creation over today's zero-cost baseline.
|
|
45
|
+
*
|
|
46
|
+
* ## Rollout: advisory only, for now
|
|
47
|
+
*
|
|
48
|
+
* `POST /auth/register`'s `powNonce` field is OPTIONAL on the wire and the
|
|
49
|
+
* server does not yet reject a missing or failing one — see the comment at
|
|
50
|
+
* the check site in `packages/api/src/controllers/session.controller.ts` for
|
|
51
|
+
* why (every Oxy app consumes the SDK as a published package, not this
|
|
52
|
+
* monorepo live, so hard-enforcing before every app has picked up a client
|
|
53
|
+
* new enough to send it would break their signup outright) and exactly what
|
|
54
|
+
* to change to flip it to a hard 400.
|
|
55
|
+
*/
|
|
56
|
+
/**
|
|
57
|
+
* The message a registration PoW nonce is grinded against, given the same
|
|
58
|
+
* `publicKey`/`timestamp` the registration signature (`oxy:register:…`)
|
|
59
|
+
* already commits to. A distinct prefix (`register-pow`, not `register`) so a
|
|
60
|
+
* solved PoW nonce can never be replayed as a registration signature input or
|
|
61
|
+
* vice versa — the two are unrelated preimages even for the same
|
|
62
|
+
* `publicKey`/`timestamp` pair.
|
|
63
|
+
*/
|
|
64
|
+
export declare function registrationPowMessage(publicKey: string, timestamp: number, nonce: string): string;
|
|
65
|
+
/**
|
|
66
|
+
* The number of leading zero bits {@link meetsRegistrationPowDifficulty}
|
|
67
|
+
* requires of a solved nonce's digest.
|
|
68
|
+
*
|
|
69
|
+
* 16 bits ⇒ 65,536 SHA-256 attempts on average to solve. The client grinds
|
|
70
|
+
* with `@noble/hashes/sha256` — a pure-JS, SYNCHRONOUS implementation with
|
|
71
|
+
* "identical behaviour on web, Node, and React Native with zero WebCrypto /
|
|
72
|
+
* native-module dependency" (its own doc comment in `crypto/kdf.ts`) — so,
|
|
73
|
+
* unlike a solve loop built on a platform hashing primitive that crosses a
|
|
74
|
+
* JS↔native bridge per call (`expo-crypto`'s `digestStringAsync`, which this
|
|
75
|
+
* deliberately avoids for exactly that reason), there is no per-attempt
|
|
76
|
+
* bridge cost to budget for. The remaining uncertainty is Hermes' lack of a
|
|
77
|
+
* JIT, which can make a tight pure-JS loop meaningfully slower than V8 — 16
|
|
78
|
+
* bits is chosen to stay a sub-second grind even under that penalty, without
|
|
79
|
+
* reaching for a bound high enough to make Hermes specifically the deciding
|
|
80
|
+
* factor. Retune this constant (it is the only place the number is declared)
|
|
81
|
+
* once there is real on-device telemetry across the low end of the supported
|
|
82
|
+
* device range.
|
|
83
|
+
*/
|
|
84
|
+
export declare const REGISTRATION_POW_DIFFICULTY_BITS = 16;
|
|
85
|
+
/**
|
|
86
|
+
* Count the leading zero BITS of a lowercase hex digest.
|
|
87
|
+
*
|
|
88
|
+
* Stops at the first non-zero nibble — a solved nonce only ever needs to beat
|
|
89
|
+
* a bound in the tens of bits, so scanning the full 256-bit digest is wasted
|
|
90
|
+
* work. A character outside `[0-9a-f]` (not a real digest — a caller passed
|
|
91
|
+
* something malformed) stops the count where it is rather than throwing: the
|
|
92
|
+
* caller compares the result against a difficulty, and an under-count from bad
|
|
93
|
+
* input correctly fails that comparison instead of crashing a request path.
|
|
94
|
+
*/
|
|
95
|
+
export declare function countLeadingZeroBits(hexDigest: string): number;
|
|
96
|
+
/**
|
|
97
|
+
* Whether a SHA-256 hex digest — of {@link registrationPowMessage} — clears
|
|
98
|
+
* `difficultyBits` leading zero bits. The one predicate both the client's
|
|
99
|
+
* solve loop and the server's check call, so neither can drift from what
|
|
100
|
+
* "solved" means.
|
|
101
|
+
*/
|
|
102
|
+
export declare function meetsRegistrationPowDifficulty(hexDigest: string, difficultyBits: number): boolean;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -25,6 +25,7 @@ export { EMPTY_TRANSPARENCY_ROOT, transparencyLeafHash, buildTransparencyTree, b
|
|
|
25
25
|
export type { TransparencyHeadEntry, TransparencyTree, TransparencyTreeFromHeads, InclusionProofCheck, } from './transparency/tree';
|
|
26
26
|
export { checkpointSigningInput, checkpointHash, signCheckpoint, verifyCheckpointSignature, } from './transparency/checkpoint';
|
|
27
27
|
export type { TransparencyCheckpointFields, TransparencyCheckpointSignature, } from './transparency/checkpoint';
|
|
28
|
+
export { registrationPowMessage, countLeadingZeroBits, meetsRegistrationPowDifficulty, REGISTRATION_POW_DIFFICULTY_BITS, } from './auth/registrationPow';
|
|
28
29
|
export { isAuthorizedKey } from './identity/resolver';
|
|
29
30
|
export type { VerificationMethodResolver, ResolvedVerificationMethods, KeyAuthorization, } from './identity/resolver';
|
|
30
31
|
export { isReactNative, isNodeJS } from './platform/platform';
|
|
@@ -83,11 +83,5 @@ export declare function loadAsyncStorage(): Promise<{
|
|
|
83
83
|
removeItem: (key: string) => Promise<void>;
|
|
84
84
|
};
|
|
85
85
|
}>;
|
|
86
|
-
|
|
87
|
-
* Synchronous random-bytes via `expo-crypto.getRandomBytes`. Only available
|
|
88
|
-
* in the React Native variant. The default variant throws because Node and
|
|
89
|
-
* browsers have their own native CSPRNGs (`crypto.randomBytes` and
|
|
90
|
-
* `crypto.getRandomValues` respectively) — callers should use those.
|
|
91
|
-
*/
|
|
92
|
-
export declare function getRandomBytesRN(_byteCount: number): Uint8Array;
|
|
86
|
+
export { getRandomBytesRN } from './random';
|
|
93
87
|
export declare function loadSharedIdentityBridge(): Promise<SharedIdentityBridge | null>;
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* Those three RN modules are declared OPTIONAL peer dependencies in
|
|
25
25
|
* `package.json`. A static `import` contradicts that: an optional peer that is
|
|
26
26
|
* omitted does not degrade, it fails to RESOLVE, and Metro aborts the whole
|
|
27
|
-
* bundle. Because `@oxy.so/core`
|
|
27
|
+
* bundle. Because `@oxy.so/core` imports `@oxy.so/protocol`
|
|
28
28
|
* from its root entry, this file is in the eager graph of EVERY React Native
|
|
29
29
|
* app on `@oxy.so/core` — so a single undeclared optional peer broke the native
|
|
30
30
|
* bundle of every app that did not happen to install it, with a resolution
|
|
@@ -65,13 +65,5 @@ export declare function loadSecureStore(): Promise<ExpoSecureStoreLike>;
|
|
|
65
65
|
export declare function loadAsyncStorage(): Promise<{
|
|
66
66
|
default: AsyncStorageLike;
|
|
67
67
|
}>;
|
|
68
|
-
|
|
69
|
-
* Synchronous random-bytes via `expo-crypto.getRandomBytes`.
|
|
70
|
-
*
|
|
71
|
-
* Synchronous by contract: `@oxy.so/core`'s crypto polyfill uses this to back
|
|
72
|
-
* `globalThis.crypto.getRandomValues`, which cannot await. That is why
|
|
73
|
-
* `expo-crypto` is resolved with a synchronous `require` at module scope rather
|
|
74
|
-
* than a dynamic `import()`.
|
|
75
|
-
*/
|
|
76
|
-
export declare function getRandomBytesRN(byteCount: number): Uint8Array;
|
|
68
|
+
export { getRandomBytesRN } from './random.native';
|
|
77
69
|
export declare function loadSharedIdentityBridge(): Promise<SharedIdentityBridge | null>;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Actionable error for a missing optional React Native peer.
|
|
3
|
+
*
|
|
4
|
+
* Shared by the React Native platform variants (`crypto.native.ts`,
|
|
5
|
+
* `random.native.ts`). It carries the underlying Metro resolution message so
|
|
6
|
+
* the failure is never silent: the variants' `try { require(...) } catch` only
|
|
7
|
+
* defers the report to the point where the capability is actually used.
|
|
8
|
+
*/
|
|
9
|
+
export declare function missingOptionalPeerError(packageName: string, capability: string, cause: unknown): Error;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synchronous CSPRNG source — default variant (Node.js, browsers, bundlers).
|
|
3
|
+
*
|
|
4
|
+
* Companion to `./random.native.ts`, which Metro substitutes on iOS / Android.
|
|
5
|
+
* Node and browsers own a native CSPRNG (`node:crypto`, `globalThis.crypto`),
|
|
6
|
+
* so `getRandomBytesRN` only exists here to keep both variants' surfaces
|
|
7
|
+
* identical, and throws if anything reaches it outside React Native.
|
|
8
|
+
*
|
|
9
|
+
* Like its sibling, this module imports nothing: it is part of the
|
|
10
|
+
* `@oxy.so/protocol/random` entry that `@oxy.so/core`'s crypto polyfill loads
|
|
11
|
+
* BEFORE any crypto library is evaluated.
|
|
12
|
+
*/
|
|
13
|
+
export declare function getRandomBytesRN(_byteCount: number): Uint8Array;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synchronous CSPRNG source — React Native variant.
|
|
3
|
+
*
|
|
4
|
+
* Companion to `./random.ts`; Metro substitutes this file on iOS / Android
|
|
5
|
+
* (see `./crypto.ts` for how the `.native` split works).
|
|
6
|
+
*
|
|
7
|
+
* This module is deliberately tiny and imports NOTHING but `expo-crypto`: it
|
|
8
|
+
* backs `@oxy.so/core`'s `globalThis.crypto.getRandomValues` polyfill, which
|
|
9
|
+
* must be fully installed before any module that captures `globalThis.crypto`
|
|
10
|
+
* at evaluation time (`@noble/hashes` 1.x's `crypto.js` does exactly that) is
|
|
11
|
+
* evaluated. Reaching a crypto library from here would let that library
|
|
12
|
+
* capture the missing global first and throw
|
|
13
|
+
* `crypto.getRandomValues must be defined` for the lifetime of the app.
|
|
14
|
+
*
|
|
15
|
+
* `expo-crypto` is an OPTIONAL peer, so it is resolved with a string-literal
|
|
16
|
+
* `require` inside a `try` (Metro's optional-dependency form; see
|
|
17
|
+
* `./crypto.native.ts`), and the load stays synchronous because
|
|
18
|
+
* `getRandomValues` cannot await.
|
|
19
|
+
*/
|
|
20
|
+
import type { ExpoCryptoLike } from './expoTypes';
|
|
21
|
+
/**
|
|
22
|
+
* The resolved `expo-crypto` module, or an actionable error naming the missing
|
|
23
|
+
* peer and the capability that needed it. Shared with `./crypto.native.ts` so
|
|
24
|
+
* the optional peer is resolved in exactly one place.
|
|
25
|
+
*/
|
|
26
|
+
export declare function requireExpoCrypto(capability: string): ExpoCryptoLike;
|
|
27
|
+
/**
|
|
28
|
+
* Synchronous random bytes via `expo-crypto.getRandomBytes`.
|
|
29
|
+
*
|
|
30
|
+
* Synchronous by contract: `@oxy.so/core`'s crypto polyfill uses this to back
|
|
31
|
+
* `globalThis.crypto.getRandomValues`, which cannot await.
|
|
32
|
+
*/
|
|
33
|
+
export declare function getRandomBytesRN(byteCount: number): Uint8Array;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@oxy.so/protocol/random` — the platform randomness source, and nothing else.
|
|
3
|
+
*
|
|
4
|
+
* `@oxy.so/core`'s crypto polyfill installs `globalThis.crypto.getRandomValues`
|
|
5
|
+
* on hosts that lack it (React Native / Hermes). `@noble/hashes` 1.x captures
|
|
6
|
+
* `globalThis.crypto` ONCE, when its `crypto.js` is evaluated, so the polyfill
|
|
7
|
+
* has to be installed before any `@noble/*` module is evaluated. ES imports are
|
|
8
|
+
* evaluated before the importing module's body, so whatever the polyfill
|
|
9
|
+
* imports is evaluated first: importing the ROOT entry (which reaches
|
|
10
|
+
* `@noble/curves` through the envelope signer) let noble capture `undefined`
|
|
11
|
+
* and broke identity creation on Android.
|
|
12
|
+
*
|
|
13
|
+
* This entry therefore reaches no crypto library, no `@oxy.so/*` package and no
|
|
14
|
+
* third-party module other than the optional `expo-crypto` peer (RN variant
|
|
15
|
+
* only). `src/__tests__/randomEntry.test.ts` walks its module graph to keep it
|
|
16
|
+
* that way.
|
|
17
|
+
*/
|
|
18
|
+
export { isNodeJS, isReactNative } from './platform/platform';
|
|
19
|
+
export { getRandomBytesRN } from './platform/random';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxy.so/protocol",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
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",
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
],
|
|
13
13
|
"node": [
|
|
14
14
|
"dist/types/node/index.d.ts"
|
|
15
|
+
],
|
|
16
|
+
"random": [
|
|
17
|
+
"dist/types/random.d.ts"
|
|
15
18
|
]
|
|
16
19
|
}
|
|
17
20
|
},
|
|
@@ -56,11 +59,25 @@
|
|
|
56
59
|
},
|
|
57
60
|
"default": "./dist/esm/secp256k1.js"
|
|
58
61
|
},
|
|
62
|
+
"./random": {
|
|
63
|
+
"react-native": "./dist/esm/random.js",
|
|
64
|
+
"import": {
|
|
65
|
+
"types": "./dist/types/random.d.ts",
|
|
66
|
+
"default": "./dist/esm/random.js"
|
|
67
|
+
},
|
|
68
|
+
"require": {
|
|
69
|
+
"types": "./dist/types/random.d.ts",
|
|
70
|
+
"default": "./dist/cjs/random.js"
|
|
71
|
+
},
|
|
72
|
+
"default": "./dist/esm/random.js"
|
|
73
|
+
},
|
|
59
74
|
"./package.json": "./package.json"
|
|
60
75
|
},
|
|
61
76
|
"react-native": {
|
|
62
77
|
"./dist/esm/platform/crypto.js": "./dist/esm/platform/crypto.native.js",
|
|
63
|
-
"./dist/cjs/platform/crypto.js": "./dist/cjs/platform/crypto.native.js"
|
|
78
|
+
"./dist/cjs/platform/crypto.js": "./dist/cjs/platform/crypto.native.js",
|
|
79
|
+
"./dist/esm/platform/random.js": "./dist/esm/platform/random.native.js",
|
|
80
|
+
"./dist/cjs/platform/random.js": "./dist/cjs/platform/random.native.js"
|
|
64
81
|
},
|
|
65
82
|
"files": [
|
|
66
83
|
"NOTICE",
|
|
@@ -81,7 +98,7 @@
|
|
|
81
98
|
"directory": "packages/protocol"
|
|
82
99
|
},
|
|
83
100
|
"author": "OxyHQ",
|
|
84
|
-
"license": "
|
|
101
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
85
102
|
"homepage": "https://oxy.so",
|
|
86
103
|
"engines": {
|
|
87
104
|
"node": ">=18.0.0"
|
|
@@ -114,7 +131,7 @@
|
|
|
114
131
|
},
|
|
115
132
|
"dependencies": {
|
|
116
133
|
"@noble/curves": "^1.9.7",
|
|
117
|
-
"@oxy.so/contracts": "^1.
|
|
134
|
+
"@oxy.so/contracts": "^1.4.0",
|
|
118
135
|
"zod": "^3.25.64"
|
|
119
136
|
},
|
|
120
137
|
"peerDependencies": {
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@oxy.so/protocol/random` must reach no module other than its own platform
|
|
3
|
+
* files and the optional `expo-crypto` peer.
|
|
4
|
+
*
|
|
5
|
+
* `@oxy.so/core`'s crypto polyfill imports this entry to install
|
|
6
|
+
* `globalThis.crypto.getRandomValues` on React Native. Everything the polyfill
|
|
7
|
+
* imports is evaluated BEFORE the polyfill body, and `@noble/hashes` 1.x
|
|
8
|
+
* captures `globalThis.crypto` once, at evaluation. When the polyfill imported
|
|
9
|
+
* the root entry instead, noble was reached through the envelope signer,
|
|
10
|
+
* captured `undefined`, and every Android identity creation failed with
|
|
11
|
+
* `crypto.getRandomValues must be defined`. This guard keeps the entry's graph
|
|
12
|
+
* dependency-free, including the `.native` siblings Metro substitutes.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
16
|
+
import { dirname, join, resolve } from 'node:path';
|
|
17
|
+
|
|
18
|
+
const SRC_DIR = resolve(__dirname, '..');
|
|
19
|
+
const RANDOM_ENTRY = join(SRC_DIR, 'random.ts');
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Every value-level module specifier: static imports/re-exports, literal
|
|
23
|
+
* `require()`s and `import()`s. Comments are stripped first so prose that
|
|
24
|
+
* mentions a module (these files document why they avoid them) is not counted.
|
|
25
|
+
*/
|
|
26
|
+
function valueSpecifiers(rawSource: string): string[] {
|
|
27
|
+
const source = rawSource.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
|
28
|
+
const specifiers: string[] = [];
|
|
29
|
+
const patterns = [
|
|
30
|
+
/(?:^|\n)\s*(?:import|export)\s+(?!type\s)[\s\S]*?\sfrom\s+['"]([^'"]+)['"]/g,
|
|
31
|
+
/(?:^|\n)\s*import\s+['"]([^'"]+)['"]/g,
|
|
32
|
+
/\brequire\(\s*['"]([^'"]+)['"]\s*\)/g,
|
|
33
|
+
/\bimport\(\s*['"]([^'"]+)['"]\s*\)/g,
|
|
34
|
+
];
|
|
35
|
+
for (const pattern of patterns) {
|
|
36
|
+
let match = pattern.exec(source);
|
|
37
|
+
while (match !== null) {
|
|
38
|
+
specifiers.push(match[1]);
|
|
39
|
+
match = pattern.exec(source);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return specifiers;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function resolveRelative(fromFile: string, specifier: string): string {
|
|
46
|
+
const base = resolve(dirname(fromFile), specifier);
|
|
47
|
+
for (const candidate of [`${base}.ts`, join(base, 'index.ts')]) {
|
|
48
|
+
if (existsSync(candidate)) {
|
|
49
|
+
return candidate;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
throw new Error(`unresolved relative import '${specifier}' in ${fromFile}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function walk(entry: string): { files: string[]; external: string[] } {
|
|
56
|
+
const seen = new Set<string>();
|
|
57
|
+
const external = new Set<string>();
|
|
58
|
+
const queue = [entry];
|
|
59
|
+
while (queue.length > 0) {
|
|
60
|
+
const file = queue.shift() as string;
|
|
61
|
+
if (seen.has(file)) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
seen.add(file);
|
|
65
|
+
const nativeSibling = file.replace(/\.ts$/, '.native.ts');
|
|
66
|
+
if (!file.endsWith('.native.ts') && existsSync(nativeSibling)) {
|
|
67
|
+
queue.push(nativeSibling);
|
|
68
|
+
}
|
|
69
|
+
for (const specifier of valueSpecifiers(readFileSync(file, 'utf8'))) {
|
|
70
|
+
if (specifier.startsWith('.')) {
|
|
71
|
+
queue.push(resolveRelative(file, specifier));
|
|
72
|
+
} else {
|
|
73
|
+
external.add(specifier);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { files: [...seen], external: [...external] };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe('@oxy.so/protocol/random entry', () => {
|
|
81
|
+
it('reaches no third-party module other than the optional expo-crypto peer', () => {
|
|
82
|
+
expect(walk(RANDOM_ENTRY).external).toEqual(['expo-crypto']);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('includes the React Native variant that Metro substitutes', () => {
|
|
86
|
+
// Sanity check on the walker: without the native sibling the guard above
|
|
87
|
+
// would pass vacuously on the default (import-free) variant.
|
|
88
|
+
expect(walk(RANDOM_ENTRY).files).toEqual(
|
|
89
|
+
expect.arrayContaining([join(SRC_DIR, 'platform', 'random.native.ts')]),
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('is exported as a package subpath', () => {
|
|
94
|
+
const manifest = JSON.parse(readFileSync(resolve(SRC_DIR, '..', 'package.json'), 'utf8')) as {
|
|
95
|
+
exports: Record<string, unknown>;
|
|
96
|
+
};
|
|
97
|
+
expect(manifest.exports['./random']).toBeDefined();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('exposes the platform predicates and the RN randomness source', () => {
|
|
101
|
+
const entry = require('../random') as typeof import('../random');
|
|
102
|
+
expect(typeof entry.isNodeJS).toBe('function');
|
|
103
|
+
expect(typeof entry.isReactNative).toBe('function');
|
|
104
|
+
expect(entry.isNodeJS()).toBe(true);
|
|
105
|
+
expect(() => entry.getRandomBytesRN(8)).toThrow(/outside React Native/);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import {
|
|
2
|
+
countLeadingZeroBits,
|
|
3
|
+
meetsRegistrationPowDifficulty,
|
|
4
|
+
registrationPowMessage,
|
|
5
|
+
REGISTRATION_POW_DIFFICULTY_BITS,
|
|
6
|
+
} from "../auth/registrationPow";
|
|
7
|
+
|
|
8
|
+
describe("registrationPowMessage", () => {
|
|
9
|
+
test("binds publicKey, timestamp and nonce, distinct from the registration signature message", () => {
|
|
10
|
+
expect(registrationPowMessage("pk", 1700000000000, "42")).toBe(
|
|
11
|
+
"oxy:register-pow:pk:1700000000000:42",
|
|
12
|
+
);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("a different nonce is a different message", () => {
|
|
16
|
+
expect(registrationPowMessage("pk", 1, "a")).not.toBe(
|
|
17
|
+
registrationPowMessage("pk", 1, "b"),
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("countLeadingZeroBits", () => {
|
|
23
|
+
test.each([
|
|
24
|
+
["", 0],
|
|
25
|
+
["f", 0],
|
|
26
|
+
["8000", 0],
|
|
27
|
+
["0", 4],
|
|
28
|
+
["00", 8],
|
|
29
|
+
["01", 7],
|
|
30
|
+
["0f", 4],
|
|
31
|
+
["1f", 3],
|
|
32
|
+
["2f", 2],
|
|
33
|
+
["4f", 1],
|
|
34
|
+
["0000f", 16],
|
|
35
|
+
// Case-insensitive: the digest producers this compares against are
|
|
36
|
+
// lowercase, but the counter must not silently under-count an uppercase one.
|
|
37
|
+
["00F", 8],
|
|
38
|
+
])("counts %s as %i leading zero bits", (hex, expected) => {
|
|
39
|
+
expect(countLeadingZeroBits(hex)).toBe(expected);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("stops at the first non-zero nibble rather than scanning the whole digest", () => {
|
|
43
|
+
expect(countLeadingZeroBits(`00f${"0".repeat(60)}`)).toBe(8);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("a non-hex character ends the count instead of throwing", () => {
|
|
47
|
+
expect(countLeadingZeroBits("0z")).toBe(4);
|
|
48
|
+
expect(() => countLeadingZeroBits("not-hex-at-all")).not.toThrow();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("meetsRegistrationPowDifficulty", () => {
|
|
53
|
+
test("passes a digest that clears the bound", () => {
|
|
54
|
+
expect(meetsRegistrationPowDifficulty("00f0", 8)).toBe(true);
|
|
55
|
+
expect(meetsRegistrationPowDifficulty("00f0", 9)).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("an exact match at the bound passes", () => {
|
|
59
|
+
expect(meetsRegistrationPowDifficulty("00f0", 8)).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("the exported difficulty constant is a defensible, positive bound", () => {
|
|
63
|
+
// Pinned as a regression check on the constant itself: a change here is a
|
|
64
|
+
// deliberate retune, not an accidental one, and shows up in review.
|
|
65
|
+
expect(REGISTRATION_POW_DIFFICULTY_BITS).toBe(16);
|
|
66
|
+
expect(meetsRegistrationPowDifficulty("0".repeat(64), REGISTRATION_POW_DIFFICULTY_BITS)).toBe(
|
|
67
|
+
true,
|
|
68
|
+
);
|
|
69
|
+
expect(meetsRegistrationPowDifficulty("f".repeat(64), REGISTRATION_POW_DIFFICULTY_BITS)).toBe(
|
|
70
|
+
false,
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registration proof-of-work — the message format and difficulty check a
|
|
3
|
+
* signup grinds a nonce against, shared verbatim between the client (which
|
|
4
|
+
* SOLVES it) and the server (which CHECKS it).
|
|
5
|
+
*
|
|
6
|
+
* ## Why this exists
|
|
7
|
+
*
|
|
8
|
+
* `POST /auth/register` mints an account from nothing more than a freshly
|
|
9
|
+
* generated secp256k1 keypair and a signature over
|
|
10
|
+
* `oxy:register:{publicKey}:{timestamp}` — computationally free. A PoW nonce
|
|
11
|
+
* makes each account cost a small, real amount of CPU time to mint, without
|
|
12
|
+
* asking a real signer for anything beyond what they already do locally: no
|
|
13
|
+
* server round trip, no third-party CAPTCHA, no extra user-visible step.
|
|
14
|
+
*
|
|
15
|
+
* ## Single source, on purpose
|
|
16
|
+
*
|
|
17
|
+
* The client must SOLVE the exact message the server CHECKS, so the message
|
|
18
|
+
* format and the difficulty threshold live here once, in a package both
|
|
19
|
+
* `@oxy.so/core` (the client SDK) and the API already depend on for
|
|
20
|
+
* secp256k1 — see `packages/contracts/src/username.ts`'s docblock for why
|
|
21
|
+
* this codebase treats "the same rule declared twice" as a bug class, not a
|
|
22
|
+
* style preference. This file is deliberately CRYPTO-FREE: it is pure
|
|
23
|
+
* string/bit arithmetic over a caller-supplied hex digest, so it needs no
|
|
24
|
+
* platform hashing primitive and is trivially unit-testable with fixed
|
|
25
|
+
* digests. Each side hashes with whatever SHA-256 it already has — Node's
|
|
26
|
+
* `crypto` server-side (`packages/api/src/controllers/session.controller.ts`),
|
|
27
|
+
* `@noble/hashes/sha256` client-side (`packages/core/src/crypto/registrationPow.ts`,
|
|
28
|
+
* already a `@oxy.so/core` dependency — see `crypto/kdf.ts`,
|
|
29
|
+
* `crypto/identityProof.ts`) — and both produce byte-identical digests over
|
|
30
|
+
* the same message, because SHA-256 has exactly one correct output for a
|
|
31
|
+
* given input regardless of implementation.
|
|
32
|
+
*
|
|
33
|
+
* ## What this is NOT
|
|
34
|
+
*
|
|
35
|
+
* Not a defense against a targeted, patient attacker: nothing stops one from
|
|
36
|
+
* choosing their OWN `timestamp` a few minutes in the future and grinding the
|
|
37
|
+
* nonce ahead of time, banking a queue of pre-solved (timestamp, nonce) pairs
|
|
38
|
+
* to spend inside the 5-minute signature freshness window
|
|
39
|
+
* (`SignatureService.isTimestampFresh` / `MAX_SIGNATURE_AGE_MS`). Closing that
|
|
40
|
+
* needs a server-issued, single-use challenge — a real round trip and server
|
|
41
|
+
* state this deliberately does not add, because the goal here is raising the
|
|
42
|
+
* COST of unattended bulk signup, not proving liveness. Combined with a
|
|
43
|
+
* per-IP rate limit on `/auth/register`, it still meaningfully raises the
|
|
44
|
+
* price of automated mass account creation over today's zero-cost baseline.
|
|
45
|
+
*
|
|
46
|
+
* ## Rollout: advisory only, for now
|
|
47
|
+
*
|
|
48
|
+
* `POST /auth/register`'s `powNonce` field is OPTIONAL on the wire and the
|
|
49
|
+
* server does not yet reject a missing or failing one — see the comment at
|
|
50
|
+
* the check site in `packages/api/src/controllers/session.controller.ts` for
|
|
51
|
+
* why (every Oxy app consumes the SDK as a published package, not this
|
|
52
|
+
* monorepo live, so hard-enforcing before every app has picked up a client
|
|
53
|
+
* new enough to send it would break their signup outright) and exactly what
|
|
54
|
+
* to change to flip it to a hard 400.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Leading zero BITS contributed by one lowercase hex nibble.
|
|
59
|
+
*
|
|
60
|
+
* A hex digit is 4 bits: `'0'` contributes all 4, `'8'`–`'f'` (binary `1xxx`)
|
|
61
|
+
* contribute none because their own leading bit is already `1`. Spelled out
|
|
62
|
+
* as a table rather than derived with `Math.clz32` so the mapping is checkable
|
|
63
|
+
* by eye against the difficulty this gates.
|
|
64
|
+
*/
|
|
65
|
+
const NIBBLE_LEADING_ZERO_BITS: Readonly<Record<string, number>> = {
|
|
66
|
+
'0': 4,
|
|
67
|
+
'1': 3,
|
|
68
|
+
'2': 2,
|
|
69
|
+
'3': 2,
|
|
70
|
+
'4': 1,
|
|
71
|
+
'5': 1,
|
|
72
|
+
'6': 1,
|
|
73
|
+
'7': 1,
|
|
74
|
+
'8': 0,
|
|
75
|
+
'9': 0,
|
|
76
|
+
a: 0,
|
|
77
|
+
b: 0,
|
|
78
|
+
c: 0,
|
|
79
|
+
d: 0,
|
|
80
|
+
e: 0,
|
|
81
|
+
f: 0,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The message a registration PoW nonce is grinded against, given the same
|
|
86
|
+
* `publicKey`/`timestamp` the registration signature (`oxy:register:…`)
|
|
87
|
+
* already commits to. A distinct prefix (`register-pow`, not `register`) so a
|
|
88
|
+
* solved PoW nonce can never be replayed as a registration signature input or
|
|
89
|
+
* vice versa — the two are unrelated preimages even for the same
|
|
90
|
+
* `publicKey`/`timestamp` pair.
|
|
91
|
+
*/
|
|
92
|
+
export function registrationPowMessage(publicKey: string, timestamp: number, nonce: string): string {
|
|
93
|
+
return `oxy:register-pow:${publicKey}:${timestamp}:${nonce}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The number of leading zero bits {@link meetsRegistrationPowDifficulty}
|
|
98
|
+
* requires of a solved nonce's digest.
|
|
99
|
+
*
|
|
100
|
+
* 16 bits ⇒ 65,536 SHA-256 attempts on average to solve. The client grinds
|
|
101
|
+
* with `@noble/hashes/sha256` — a pure-JS, SYNCHRONOUS implementation with
|
|
102
|
+
* "identical behaviour on web, Node, and React Native with zero WebCrypto /
|
|
103
|
+
* native-module dependency" (its own doc comment in `crypto/kdf.ts`) — so,
|
|
104
|
+
* unlike a solve loop built on a platform hashing primitive that crosses a
|
|
105
|
+
* JS↔native bridge per call (`expo-crypto`'s `digestStringAsync`, which this
|
|
106
|
+
* deliberately avoids for exactly that reason), there is no per-attempt
|
|
107
|
+
* bridge cost to budget for. The remaining uncertainty is Hermes' lack of a
|
|
108
|
+
* JIT, which can make a tight pure-JS loop meaningfully slower than V8 — 16
|
|
109
|
+
* bits is chosen to stay a sub-second grind even under that penalty, without
|
|
110
|
+
* reaching for a bound high enough to make Hermes specifically the deciding
|
|
111
|
+
* factor. Retune this constant (it is the only place the number is declared)
|
|
112
|
+
* once there is real on-device telemetry across the low end of the supported
|
|
113
|
+
* device range.
|
|
114
|
+
*/
|
|
115
|
+
export const REGISTRATION_POW_DIFFICULTY_BITS = 16;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Count the leading zero BITS of a lowercase hex digest.
|
|
119
|
+
*
|
|
120
|
+
* Stops at the first non-zero nibble — a solved nonce only ever needs to beat
|
|
121
|
+
* a bound in the tens of bits, so scanning the full 256-bit digest is wasted
|
|
122
|
+
* work. A character outside `[0-9a-f]` (not a real digest — a caller passed
|
|
123
|
+
* something malformed) stops the count where it is rather than throwing: the
|
|
124
|
+
* caller compares the result against a difficulty, and an under-count from bad
|
|
125
|
+
* input correctly fails that comparison instead of crashing a request path.
|
|
126
|
+
*/
|
|
127
|
+
export function countLeadingZeroBits(hexDigest: string): number {
|
|
128
|
+
let bits = 0;
|
|
129
|
+
for (const char of hexDigest.toLowerCase()) {
|
|
130
|
+
const nibbleZeroBits = NIBBLE_LEADING_ZERO_BITS[char];
|
|
131
|
+
if (nibbleZeroBits === undefined) break;
|
|
132
|
+
bits += nibbleZeroBits;
|
|
133
|
+
if (nibbleZeroBits < 4) break;
|
|
134
|
+
}
|
|
135
|
+
return bits;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Whether a SHA-256 hex digest — of {@link registrationPowMessage} — clears
|
|
140
|
+
* `difficultyBits` leading zero bits. The one predicate both the client's
|
|
141
|
+
* solve loop and the server's check call, so neither can drift from what
|
|
142
|
+
* "solved" means.
|
|
143
|
+
*/
|
|
144
|
+
export function meetsRegistrationPowDifficulty(hexDigest: string, difficultyBits: number): boolean {
|
|
145
|
+
return countLeadingZeroBits(hexDigest) >= difficultyBits;
|
|
146
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -68,6 +68,16 @@ export type {
|
|
|
68
68
|
TransparencyCheckpointSignature,
|
|
69
69
|
} from './transparency/checkpoint';
|
|
70
70
|
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Auth — registration proof-of-work (message format + difficulty check)
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
export {
|
|
75
|
+
registrationPowMessage,
|
|
76
|
+
countLeadingZeroBits,
|
|
77
|
+
meetsRegistrationPowDifficulty,
|
|
78
|
+
REGISTRATION_POW_DIFFICULTY_BITS,
|
|
79
|
+
} from './auth/registrationPow';
|
|
80
|
+
|
|
71
81
|
// ---------------------------------------------------------------------------
|
|
72
82
|
// Identity — injected verification-method resolution + authorization rule
|
|
73
83
|
// ---------------------------------------------------------------------------
|