@oxy.so/protocol 1.0.0 → 1.1.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.
@@ -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;
@@ -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';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/protocol",
3
- "version": "1.0.0",
3
+ "version": "1.1.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",
@@ -81,7 +81,7 @@
81
81
  "directory": "packages/protocol"
82
82
  },
83
83
  "author": "OxyHQ",
84
- "license": "Apache-2.0",
84
+ "license": "SEE LICENSE IN LICENSE",
85
85
  "homepage": "https://oxy.so",
86
86
  "engines": {
87
87
  "node": ">=18.0.0"
@@ -114,7 +114,7 @@
114
114
  },
115
115
  "dependencies": {
116
116
  "@noble/curves": "^1.9.7",
117
- "@oxy.so/contracts": "^1.0.0",
117
+ "@oxy.so/contracts": "^1.3.0",
118
118
  "zod": "^3.25.64"
119
119
  },
120
120
  "peerDependencies": {
@@ -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
  // ---------------------------------------------------------------------------