@oxy.so/protocol 1.0.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.
Files changed (122) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +16 -0
  3. package/dist/cjs/.tsbuildinfo +1 -0
  4. package/dist/cjs/chain/continuity.js +54 -0
  5. package/dist/cjs/chain/engine.js +34 -0
  6. package/dist/cjs/chain/recordStore.js +25 -0
  7. package/dist/cjs/chain/types.js +22 -0
  8. package/dist/cjs/chain/verify.js +82 -0
  9. package/dist/cjs/envelope/canonicalJson.js +107 -0
  10. package/dist/cjs/envelope/recordId.js +60 -0
  11. package/dist/cjs/envelope/sign.js +75 -0
  12. package/dist/cjs/envelope/signingInput.js +32 -0
  13. package/dist/cjs/identity/resolver.js +50 -0
  14. package/dist/cjs/index.js +71 -0
  15. package/dist/cjs/node/constants.js +85 -0
  16. package/dist/cjs/node/didWebResolver.js +126 -0
  17. package/dist/cjs/node/httpFetch.js +61 -0
  18. package/dist/cjs/node/index.js +71 -0
  19. package/dist/cjs/node/nodeApp.js +344 -0
  20. package/dist/cjs/node/nodeClient.js +204 -0
  21. package/dist/cjs/node/rateLimit.js +187 -0
  22. package/dist/cjs/node/verifyRecord.js +51 -0
  23. package/dist/cjs/platform/crypto.js +186 -0
  24. package/dist/cjs/platform/crypto.native.js +204 -0
  25. package/dist/cjs/platform/expoTypes.js +24 -0
  26. package/dist/cjs/platform/platform.js +33 -0
  27. package/dist/cjs/secp256k1.js +148 -0
  28. package/dist/cjs/transparency/checkpoint.js +79 -0
  29. package/dist/cjs/transparency/tree.js +197 -0
  30. package/dist/esm/.tsbuildinfo +1 -0
  31. package/dist/esm/chain/continuity.js +51 -0
  32. package/dist/esm/chain/engine.js +31 -0
  33. package/dist/esm/chain/recordStore.js +24 -0
  34. package/dist/esm/chain/types.js +19 -0
  35. package/dist/esm/chain/verify.js +78 -0
  36. package/dist/esm/envelope/canonicalJson.js +104 -0
  37. package/dist/esm/envelope/recordId.js +56 -0
  38. package/dist/esm/envelope/sign.js +69 -0
  39. package/dist/esm/envelope/signingInput.js +29 -0
  40. package/dist/esm/identity/resolver.js +47 -0
  41. package/dist/esm/index.js +36 -0
  42. package/dist/esm/node/constants.js +82 -0
  43. package/dist/esm/node/didWebResolver.js +122 -0
  44. package/dist/esm/node/httpFetch.js +55 -0
  45. package/dist/esm/node/index.js +28 -0
  46. package/dist/esm/node/nodeApp.js +336 -0
  47. package/dist/esm/node/nodeClient.js +198 -0
  48. package/dist/esm/node/rateLimit.js +182 -0
  49. package/dist/esm/node/verifyRecord.js +48 -0
  50. package/dist/esm/platform/crypto.js +145 -0
  51. package/dist/esm/platform/crypto.native.js +196 -0
  52. package/dist/esm/platform/expoTypes.js +23 -0
  53. package/dist/esm/platform/platform.js +29 -0
  54. package/dist/esm/secp256k1.js +137 -0
  55. package/dist/esm/transparency/checkpoint.js +73 -0
  56. package/dist/esm/transparency/tree.js +189 -0
  57. package/dist/types/.tsbuildinfo +1 -0
  58. package/dist/types/chain/continuity.d.ts +28 -0
  59. package/dist/types/chain/engine.d.ts +27 -0
  60. package/dist/types/chain/recordStore.d.ts +85 -0
  61. package/dist/types/chain/types.d.ts +79 -0
  62. package/dist/types/chain/verify.d.ts +45 -0
  63. package/dist/types/envelope/canonicalJson.d.ts +44 -0
  64. package/dist/types/envelope/recordId.d.ts +30 -0
  65. package/dist/types/envelope/sign.d.ts +47 -0
  66. package/dist/types/envelope/signingInput.d.ts +33 -0
  67. package/dist/types/identity/resolver.d.ts +67 -0
  68. package/dist/types/index.d.ts +32 -0
  69. package/dist/types/node/constants.d.ts +80 -0
  70. package/dist/types/node/didWebResolver.d.ts +47 -0
  71. package/dist/types/node/httpFetch.d.ts +60 -0
  72. package/dist/types/node/index.d.ts +28 -0
  73. package/dist/types/node/nodeApp.d.ts +120 -0
  74. package/dist/types/node/nodeClient.d.ts +135 -0
  75. package/dist/types/node/rateLimit.d.ts +95 -0
  76. package/dist/types/node/verifyRecord.d.ts +41 -0
  77. package/dist/types/platform/crypto.d.ts +93 -0
  78. package/dist/types/platform/crypto.native.d.ts +77 -0
  79. package/dist/types/platform/expoTypes.d.ts +99 -0
  80. package/dist/types/platform/platform.d.ts +25 -0
  81. package/dist/types/secp256k1.d.ts +45 -0
  82. package/dist/types/transparency/checkpoint.d.ts +71 -0
  83. package/dist/types/transparency/tree.d.ts +135 -0
  84. package/package.json +157 -0
  85. package/src/__tests__/canonicalJson.test.ts +116 -0
  86. package/src/__tests__/chain.test.ts +279 -0
  87. package/src/__tests__/didWebResolver.test.ts +132 -0
  88. package/src/__tests__/envelope.test.ts +267 -0
  89. package/src/__tests__/nodeApp.test.ts +410 -0
  90. package/src/__tests__/nodeClient.test.ts +177 -0
  91. package/src/__tests__/nodeHarness.ts +151 -0
  92. package/src/__tests__/optionalNativePeers.test.ts +233 -0
  93. package/src/__tests__/rateLimit.test.ts +268 -0
  94. package/src/__tests__/runnerGuard.test.ts +85 -0
  95. package/src/__tests__/secp256k1.test.ts +118 -0
  96. package/src/__tests__/transparency.test.ts +353 -0
  97. package/src/chain/continuity.ts +59 -0
  98. package/src/chain/engine.ts +43 -0
  99. package/src/chain/recordStore.ts +98 -0
  100. package/src/chain/types.ts +85 -0
  101. package/src/chain/verify.ts +102 -0
  102. package/src/envelope/canonicalJson.ts +120 -0
  103. package/src/envelope/recordId.ts +63 -0
  104. package/src/envelope/sign.ts +86 -0
  105. package/src/envelope/signingInput.ts +48 -0
  106. package/src/identity/resolver.ts +90 -0
  107. package/src/index.ts +101 -0
  108. package/src/node/constants.ts +105 -0
  109. package/src/node/didWebResolver.ts +162 -0
  110. package/src/node/httpFetch.ts +88 -0
  111. package/src/node/index.ts +87 -0
  112. package/src/node/nodeApp.ts +471 -0
  113. package/src/node/nodeClient.ts +322 -0
  114. package/src/node/rateLimit.ts +233 -0
  115. package/src/node/verifyRecord.ts +60 -0
  116. package/src/platform/crypto.native.ts +251 -0
  117. package/src/platform/crypto.ts +172 -0
  118. package/src/platform/expoTypes.ts +99 -0
  119. package/src/platform/platform.ts +31 -0
  120. package/src/secp256k1.ts +207 -0
  121. package/src/transparency/checkpoint.ts +109 -0
  122. package/src/transparency/tree.ts +258 -0
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Platform Crypto / Storage — React Native Variant
3
+ *
4
+ * Companion to `./crypto.ts`. See the doc-comment at the top of that file for
5
+ * the full design.
6
+ *
7
+ * Metro auto-selects this file in any non-web build (`preferNativePlatform`
8
+ * is `true` for iOS / Android, so `*.native.js` shadows `*.js` during
9
+ * source-extension resolution inside `node_modules/@oxy.so/protocol/dist/`). On
10
+ * iOS / Android `<base>.ios.js` / `<base>.android.js` would shadow this file
11
+ * if they existed, but they don't — `.native.js` is the shared RN variant.
12
+ *
13
+ * - The default variant references Node's `'crypto'` and would crash Metro
14
+ * if bundled into an RN app.
15
+ * - This variant references the RN-only modules (`expo-crypto`,
16
+ * `expo-secure-store`, `@react-native-async-storage/async-storage`),
17
+ * each behind Metro's optional-dependency mechanism (see below).
18
+ *
19
+ * Both variants expose the same surface; importers don't care which one
20
+ * they got.
21
+ *
22
+ * # Why `try { require('literal') } catch` and not a static import?
23
+ *
24
+ * Those three RN modules are declared OPTIONAL peer dependencies in
25
+ * `package.json`. A static `import` contradicts that: an optional peer that is
26
+ * omitted does not degrade, it fails to RESOLVE, and Metro aborts the whole
27
+ * bundle. Because `@oxy.so/core`'s `crypto/polyfill` imports `@oxy.so/protocol`
28
+ * from its root entry, this file is in the eager graph of EVERY React Native
29
+ * app on `@oxy.so/core` — so a single undeclared optional peer broke the native
30
+ * bundle of every app that did not happen to install it, with a resolution
31
+ * error pointing at a dependency the app never mentions.
32
+ *
33
+ * Metro treats a `require()` of a STRING LITERAL that sits inside a `try`
34
+ * block as an optional dependency: it resolves it when present, and when
35
+ * absent emits a stub that throws on evaluation instead of failing the build.
36
+ * The `catch` turns that into a `null` module handle, and the loader below
37
+ * throws an actionable error naming the missing package the first time the
38
+ * capability is actually used. Bundle-time hard failure becomes a
39
+ * capability-scoped runtime failure — which is exactly what "optional peer"
40
+ * is supposed to mean.
41
+ *
42
+ * Two constraints this shape has to respect, both learned the hard way:
43
+ *
44
+ * - The specifier MUST be a literal. A runtime-computed `require(variable)`
45
+ * is unresolvable for Metro (that is the bug the shared-identity bridge
46
+ * below documents) and silently yields nothing in a consuming repo.
47
+ * - The load MUST stay synchronous. `getRandomBytesRN` backs
48
+ * `globalThis.crypto.getRandomValues` in `@oxy.so/core`'s polyfill, which
49
+ * cannot await anything.
50
+ *
51
+ * `expo-modules-core` is a NON-optional peer (every RN app has it via `expo`),
52
+ * so it stays a plain static import.
53
+ */
54
+ import { requireOptionalNativeModule } from 'expo-modules-core';
55
+ let expoCryptoModule = null;
56
+ let expoCryptoError;
57
+ try {
58
+ expoCryptoModule = require('expo-crypto');
59
+ }
60
+ catch (error) {
61
+ expoCryptoError = error;
62
+ }
63
+ let secureStoreModule = null;
64
+ let secureStoreError;
65
+ try {
66
+ secureStoreModule = require('expo-secure-store');
67
+ }
68
+ catch (error) {
69
+ secureStoreError = error;
70
+ }
71
+ let asyncStorageModule = null;
72
+ let asyncStorageError;
73
+ try {
74
+ // Babel's default-import interop unwraps `.default` for us on a static
75
+ // import; a raw `require` has to do it by hand. The `?? namespace` fallback
76
+ // covers a host that hands back a real ESM namespace with no `default`.
77
+ const namespace = require('@react-native-async-storage/async-storage');
78
+ asyncStorageModule = namespace.default ?? namespace;
79
+ }
80
+ catch (error) {
81
+ asyncStorageError = error;
82
+ }
83
+ /**
84
+ * Actionable error for a missing optional peer. Carries the underlying Metro
85
+ * resolution message so the failure is never silent — the `catch` above only
86
+ * defers the report to the point where the capability is actually needed.
87
+ */
88
+ function missingOptionalPeerError(packageName, capability, cause) {
89
+ const sentences = [
90
+ `[oxy.protocol.crypto] '${packageName}' is not installed, so ${capability} is unavailable in this app.`,
91
+ 'It is an optional peer dependency of @oxy.so/protocol that the React Native runtime needs —',
92
+ `install it with \`npx expo install ${packageName}\`.`,
93
+ ];
94
+ if (cause instanceof Error) {
95
+ sentences.push(`Underlying error: ${cause.message}`);
96
+ }
97
+ return new Error(sentences.join(' '));
98
+ }
99
+ // ---------------------------------------------------------------------------
100
+ // Node `crypto` — never available in RN.
101
+ // ---------------------------------------------------------------------------
102
+ export async function loadNodeCrypto() {
103
+ // Unreachable in practice: every caller gates with `isNodeJS()` before
104
+ // invoking this. If it somehow does fire, throw immediately with a clear
105
+ // diagnostic rather than letting Metro / Hermes attempt to find a
106
+ // non-existent module at runtime.
107
+ throw new Error("[oxy.protocol.crypto] Node's built-in 'crypto' module is not available " +
108
+ 'in a React Native runtime. Use the RN-specific helpers ' +
109
+ '(loadExpoCrypto, getRandomBytesRN) or the Web Crypto API (`globalThis.crypto`).');
110
+ }
111
+ // ---------------------------------------------------------------------------
112
+ // expo-crypto — RN cryptographic primitives.
113
+ //
114
+ // The real module satisfies `ExpoCryptoLike` structurally; the structural
115
+ // interface narrows the surface so consumers never pull expo's own types into
116
+ // their compilation (see expoTypes.ts).
117
+ // ---------------------------------------------------------------------------
118
+ export async function loadExpoCrypto() {
119
+ if (!expoCryptoModule) {
120
+ throw missingOptionalPeerError('expo-crypto', 'React Native cryptography', expoCryptoError);
121
+ }
122
+ return expoCryptoModule;
123
+ }
124
+ // ---------------------------------------------------------------------------
125
+ // expo-secure-store — RN keychain / keystore.
126
+ // ---------------------------------------------------------------------------
127
+ export async function loadSecureStore() {
128
+ if (!secureStoreModule) {
129
+ throw missingOptionalPeerError('expo-secure-store', 'on-device identity storage', secureStoreError);
130
+ }
131
+ return secureStoreModule;
132
+ }
133
+ // ---------------------------------------------------------------------------
134
+ // @react-native-async-storage/async-storage — RN persistent KV storage.
135
+ // ---------------------------------------------------------------------------
136
+ export async function loadAsyncStorage() {
137
+ if (!asyncStorageModule) {
138
+ throw missingOptionalPeerError('@react-native-async-storage/async-storage', 'device/session persistence', asyncStorageError);
139
+ }
140
+ // Mirror the shape callers historically used (`module.default.<method>`)
141
+ // so the call sites don't have to know whether the underlying module
142
+ // ships ESM or CJS-with-default.
143
+ return { default: asyncStorageModule };
144
+ }
145
+ /**
146
+ * Synchronous random-bytes via `expo-crypto.getRandomBytes`.
147
+ *
148
+ * Synchronous by contract: `@oxy.so/core`'s crypto polyfill uses this to back
149
+ * `globalThis.crypto.getRandomValues`, which cannot await. That is why
150
+ * `expo-crypto` is resolved with a synchronous `require` at module scope rather
151
+ * than a dynamic `import()`.
152
+ */
153
+ export function getRandomBytesRN(byteCount) {
154
+ if (!expoCryptoModule) {
155
+ throw missingOptionalPeerError('expo-crypto', 'the React Native CSPRNG (crypto.getRandomValues)', expoCryptoError);
156
+ }
157
+ return expoCryptoModule.getRandomBytes(byteCount);
158
+ }
159
+ // ---------------------------------------------------------------------------
160
+ // Shared identity bridge — `@oxy.so/expo-oxy-identity` (native-only, OPTIONAL).
161
+ //
162
+ // `@oxy.so/expo-oxy-identity` is the in-repo Expo module autolinked into the
163
+ // identity apps (Commons + the reader RPs). We resolve its NATIVE module
164
+ // directly via expo-modules-core's `requireOptionalNativeModule('OxyIdentity')`
165
+ // — a static import Metro always resolves — instead of dynamically importing the
166
+ // module's JS wrapper. A runtime-computed `import(moduleName)` compiled to a
167
+ // `require(variable)` in the CJS build, which Metro cannot resolve in a consuming
168
+ // repo (the bridge silently resolved `null` there — the cross-app SSO bug). Since
169
+ // the native module is what actually holds the shared identity, going through the
170
+ // native registry is both correct and Metro-safe. `requireOptionalNativeModule`
171
+ // returns `null` (never throws) when the module is not autolinked (web, or apps
172
+ // that don't ship it), so `@oxy.so/core`'s `KeyManager` cleanly falls back to its
173
+ // package-private store.
174
+ // ---------------------------------------------------------------------------
175
+ let sharedIdentityBridgePromise = null;
176
+ export function loadSharedIdentityBridge() {
177
+ if (!sharedIdentityBridgePromise) {
178
+ sharedIdentityBridgePromise = Promise.resolve().then(() => {
179
+ const native = requireOptionalNativeModule('OxyIdentity');
180
+ if (native &&
181
+ typeof native.getShared === 'function' &&
182
+ typeof native.putShared === 'function' &&
183
+ typeof native.hasShared === 'function' &&
184
+ typeof native.clearShared === 'function') {
185
+ return {
186
+ getShared: native.getShared.bind(native),
187
+ putShared: native.putShared.bind(native),
188
+ hasShared: native.hasShared.bind(native),
189
+ clearShared: native.clearShared.bind(native),
190
+ };
191
+ }
192
+ return null;
193
+ });
194
+ }
195
+ return sharedIdentityBridgePromise;
196
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Structural interfaces for Expo platform modules.
3
+ *
4
+ * These replace `typeof import('expo-crypto')` and
5
+ * `typeof import('expo-secure-store')` in the built declaration files of
6
+ * `@oxy.so/protocol` and `@oxy.so/core`.
7
+ *
8
+ * ## Why structural interfaces instead of `typeof import('expo-*')`?
9
+ *
10
+ * Under NodeNext module resolution (used by `@oxy.so/api` and `@oxy.so/node`),
11
+ * `expo-crypto` ships with `"exports": {}` (empty exports map). TypeScript
12
+ * traverses into the package anyway via the `types` field, which transitively
13
+ * loads `expo-modules-core`. That pollution makes `setInterval`/`setTimeout`
14
+ * resolve to DOM's `number` return type rather than Node's `NodeJS.Timeout`,
15
+ * producing ~10 spurious `TS2322` / `TS2339` errors in every consumer that
16
+ * uses Node timer APIs — none of which reference protocol types at all.
17
+ *
18
+ * Structural interfaces break the transitive expo-modules-core dependency
19
+ * entirely: consumers that don't have Expo installed see clean types, and
20
+ * the actual RN runtime (which DOES have Expo installed) still works because
21
+ * the real modules satisfy these interfaces structurally.
22
+ */
23
+ export {};
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Platform Detection — runtime predicates.
3
+ *
4
+ * Detects the host runtime WITHOUT importing from 'react-native', so the
5
+ * protocol's crypto modules can be used in web / Node.js / React Native
6
+ * environments without bundlers failing on react-native imports.
7
+ *
8
+ * Only the two predicates the protocol's platform-crypto loaders need live
9
+ * here. Richer platform detection (`getPlatformOS`, `isWeb`, `isNative`, …)
10
+ * is an SDK concern and stays in `@oxy.so/core`.
11
+ */
12
+ /**
13
+ * Check if running in React Native.
14
+ *
15
+ * Selects the React Native crypto variant (`expo-crypto` /
16
+ * `expo-secure-store` / async-storage) over the Node/web variant.
17
+ */
18
+ export function isReactNative() {
19
+ return typeof navigator !== 'undefined' && navigator.product === 'ReactNative';
20
+ }
21
+ /**
22
+ * Check if running in Node.js.
23
+ *
24
+ * Gates use of Node's built-in `crypto` (the synchronous SHA-256 path and
25
+ * `randomBytes`) and the `await import('node:crypto')` loader.
26
+ */
27
+ export function isNodeJS() {
28
+ return typeof process !== 'undefined' && process.versions != null && process.versions.node != null;
29
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * secp256k1 primitives shared by every Oxy runtime.
3
+ *
4
+ * This module owns the curve binding and the wire formats used across Oxy:
5
+ * 32-byte private keys, compressed or uncompressed SEC1 public keys,
6
+ * RFC 6979 deterministic ECDSA signatures encoded as DER, and the 32-byte
7
+ * ECDH x-coordinate. Callers never receive a library-specific key object.
8
+ */
9
+ import { secp256k1 } from "@noble/curves/secp256k1.js";
10
+ const HEX = /^[0-9a-fA-F]+$/;
11
+ const COMPRESSED_PUBLIC_KEY = /^(02|03)[0-9a-fA-F]{64}$/;
12
+ const UNCOMPRESSED_PUBLIC_KEY = /^04[0-9a-fA-F]{128}$/;
13
+ function bytesToHex(bytes) {
14
+ let result = "";
15
+ for (const byte of bytes) {
16
+ result += byte.toString(16).padStart(2, "0");
17
+ }
18
+ return result;
19
+ }
20
+ function hexToBytes(value, label) {
21
+ if (value.length === 0 || value.length % 2 !== 0 || !HEX.test(value)) {
22
+ throw new Error(`${label} must be an even-length hexadecimal string`);
23
+ }
24
+ const bytes = new Uint8Array(value.length / 2);
25
+ for (let index = 0; index < bytes.length; index += 1) {
26
+ bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);
27
+ }
28
+ return bytes;
29
+ }
30
+ /**
31
+ * Normalize a legacy short/cased scalar to canonical 32-byte lowercase hex.
32
+ * The scalar must be within the secp256k1 order; zero and overflow are rejected.
33
+ */
34
+ export function normalizeSecp256k1PrivateKey(privateKeyHex) {
35
+ if (typeof privateKeyHex !== "string" ||
36
+ privateKeyHex.length === 0 ||
37
+ privateKeyHex.length > 64 ||
38
+ !HEX.test(privateKeyHex)) {
39
+ throw new Error("secp256k1 private key must contain 1 to 64 hexadecimal characters");
40
+ }
41
+ const normalized = privateKeyHex.toLowerCase().padStart(64, "0");
42
+ const privateKey = hexToBytes(normalized, "secp256k1 private key");
43
+ if (!secp256k1.utils.isValidSecretKey(privateKey)) {
44
+ throw new Error("secp256k1 private key is outside the valid scalar range");
45
+ }
46
+ return normalized;
47
+ }
48
+ /** True when a value is a valid secp256k1 scalar (legacy short hex accepted). */
49
+ export function isValidSecp256k1PrivateKey(privateKeyHex) {
50
+ try {
51
+ normalizeSecp256k1PrivateKey(privateKeyHex);
52
+ return true;
53
+ }
54
+ catch {
55
+ return false;
56
+ }
57
+ }
58
+ function parsePrivateKey(privateKeyHex) {
59
+ return hexToBytes(normalizeSecp256k1PrivateKey(privateKeyHex), "secp256k1 private key");
60
+ }
61
+ function parsePublicKey(publicKeyHex) {
62
+ if (typeof publicKeyHex !== "string" ||
63
+ (!COMPRESSED_PUBLIC_KEY.test(publicKeyHex) &&
64
+ !UNCOMPRESSED_PUBLIC_KEY.test(publicKeyHex))) {
65
+ throw new Error("secp256k1 public key must be a compressed or uncompressed SEC1 hex key");
66
+ }
67
+ const publicKey = hexToBytes(publicKeyHex, "secp256k1 public key");
68
+ // Parsing validates the SEC1 prefix, coordinate range, and curve equation.
69
+ secp256k1.Point.fromBytes(publicKey);
70
+ return publicKey;
71
+ }
72
+ function parseDigest(digestHex) {
73
+ if (typeof digestHex !== "string" || digestHex.length !== 64) {
74
+ throw new Error("secp256k1 digest must be exactly 32 bytes of hexadecimal data");
75
+ }
76
+ return hexToBytes(digestHex, "secp256k1 digest");
77
+ }
78
+ /** True for a valid compressed or uncompressed SEC1 secp256k1 public key. */
79
+ export function isValidSecp256k1PublicKey(publicKeyHex) {
80
+ try {
81
+ parsePublicKey(publicKeyHex);
82
+ return true;
83
+ }
84
+ catch {
85
+ return false;
86
+ }
87
+ }
88
+ /** Generate a canonical private key and its uncompressed public key. */
89
+ export function generateSecp256k1KeyPair() {
90
+ const privateKey = secp256k1.utils.randomSecretKey();
91
+ return {
92
+ privateKey: bytesToHex(privateKey),
93
+ publicKey: bytesToHex(secp256k1.getPublicKey(privateKey, false)),
94
+ };
95
+ }
96
+ /** Derive a compressed or uncompressed SEC1 public key from a private scalar. */
97
+ export function deriveSecp256k1PublicKey(privateKeyHex, compressed = false) {
98
+ return bytesToHex(secp256k1.getPublicKey(parsePrivateKey(privateKeyHex), compressed));
99
+ }
100
+ /** Parse and re-encode a public key in canonical lowercase SEC1 form. */
101
+ export function normalizeSecp256k1PublicKey(publicKeyHex, compressed = false) {
102
+ const point = secp256k1.Point.fromBytes(parsePublicKey(publicKeyHex));
103
+ return point.toHex(compressed);
104
+ }
105
+ /**
106
+ * Sign a 32-byte digest with deterministic RFC 6979 ECDSA and return DER hex.
107
+ *
108
+ * `lowS` defaults to false because historical Oxy signatures used elliptic's
109
+ * default and therefore may occupy either half of the curve order. Verification
110
+ * accepts both forms; callers that require low-S normalization opt in explicitly.
111
+ */
112
+ export function signSecp256k1Digest(privateKeyHex, digestHex, options = {}) {
113
+ const signature = secp256k1.sign(parseDigest(digestHex), parsePrivateKey(privateKeyHex), {
114
+ lowS: options.lowS ?? false,
115
+ });
116
+ return signature.toHex("der");
117
+ }
118
+ /** Verify a DER-encoded ECDSA signature, accepting historical high-S signatures. */
119
+ export function verifySecp256k1Digest(publicKeyHex, digestHex, signatureDerHex) {
120
+ const publicKey = parsePublicKey(publicKeyHex);
121
+ const digest = parseDigest(digestHex);
122
+ const signature = hexToBytes(signatureDerHex, "secp256k1 DER signature");
123
+ // Parse once up front so malformed or non-DER input is rejected explicitly.
124
+ secp256k1.Signature.fromBytes(signature, "der");
125
+ return secp256k1.verify(signature, digest, publicKey, {
126
+ format: "der",
127
+ lowS: false,
128
+ });
129
+ }
130
+ /** Derive the fixed-width 32-byte ECDH x-coordinate shared secret. */
131
+ export function deriveSecp256k1SharedSecret(privateKeyHex, publicKeyHex) {
132
+ const encodedPoint = secp256k1.getSharedSecret(parsePrivateKey(privateKeyHex), parsePublicKey(publicKeyHex), true);
133
+ if (encodedPoint.length !== 33) {
134
+ throw new Error("secp256k1 ECDH returned an unexpected point encoding");
135
+ }
136
+ return encodedPoint.slice(1);
137
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Transparency checkpoint — the signed, hash-linked commitment to a tree root.
3
+ *
4
+ * A checkpoint is what the operator PUBLISHES: "at `periodEnd` I committed to
5
+ * `root` over `treeSize` subjects, and the previous checkpoint hashed to
6
+ * `prevCheckpointHash`". The `prevCheckpointHash` link makes the checkpoint
7
+ * sequence itself append-only: once any checkpoint is anchored publicly, none of
8
+ * its ancestors can be rewritten without breaking the chain of hashes.
9
+ *
10
+ * ## Why the signature covers ONLY the five fields
11
+ *
12
+ * The signing input is derived from exactly `{index, periodEnd, treeSize, root,
13
+ * prevCheckpointHash}` — never from the surrounding storage document, and never
14
+ * from other signatures. That is what makes a checkpoint CO-SIGNABLE: the
15
+ * operator and any number of independent witnesses (e.g. user-run
16
+ * `@oxy.so/node` deployments) each sign the identical bytes with their own key,
17
+ * with zero coordination and in any order. Two conflicting roots for one
18
+ * `index`, each carrying valid signatures, is then transferable proof of
19
+ * equivocation that needs no cooperation from the operator to demonstrate.
20
+ *
21
+ * Extra fields on the object passed in are ignored by design, so handing this a
22
+ * database document cannot change what was signed.
23
+ */
24
+ import { canonicalize } from '../envelope/canonicalJson.js';
25
+ import { sha256 } from '../envelope/recordId.js';
26
+ import { signMessage, verifySignature } from '../envelope/sign.js';
27
+ import { deriveSecp256k1PublicKey } from '../secp256k1.js';
28
+ /** Domain prefix for checkpoint signing bytes. */
29
+ const CHECKPOINT_PREFIX = 'oxy.transparency.checkpoint.v1:';
30
+ /** The one signature algorithm the protocol emits. */
31
+ const ALG = 'ES256K-DER-SHA256';
32
+ /**
33
+ * The exact bytes every co-signer signs: the canonical JSON of the five signed
34
+ * fields under the checkpoint domain prefix. Key order in the input object is
35
+ * irrelevant; extra properties are dropped.
36
+ */
37
+ export function checkpointSigningInput(fields) {
38
+ return `${CHECKPOINT_PREFIX}${canonicalize({
39
+ index: fields.index,
40
+ periodEnd: fields.periodEnd,
41
+ treeSize: fields.treeSize,
42
+ root: fields.root,
43
+ prevCheckpointHash: fields.prevCheckpointHash,
44
+ })}`;
45
+ }
46
+ /**
47
+ * The content address of a checkpoint — what the NEXT checkpoint's
48
+ * `prevCheckpointHash` references.
49
+ */
50
+ export async function checkpointHash(fields) {
51
+ return sha256(checkpointSigningInput(fields));
52
+ }
53
+ /**
54
+ * Sign a checkpoint with an explicit private key. Used by the operator and,
55
+ * identically, by every witness that co-signs the same checkpoint.
56
+ */
57
+ export async function signCheckpoint(fields, privateKeyHex) {
58
+ return {
59
+ publicKey: deriveSecp256k1PublicKey(privateKeyHex),
60
+ alg: ALG,
61
+ signature: await signMessage(checkpointSigningInput(fields), privateKeyHex),
62
+ };
63
+ }
64
+ /**
65
+ * Verify one signature over a checkpoint's signed fields.
66
+ *
67
+ * Confirms the signature matches the embedded `publicKey` for exactly these
68
+ * fields. It does NOT establish that the key belongs to a trusted operator or an
69
+ * accepted witness — that policy lives with the verifier's key list.
70
+ */
71
+ export async function verifyCheckpointSignature(fields, signature) {
72
+ return verifySignature(checkpointSigningInput(fields), signature.signature, signature.publicKey);
73
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Transparency Merkle tree — leaf hashing, root computation, and inclusion
3
+ * proofs over a snapshot of chain heads.
4
+ *
5
+ * ## What this is for
6
+ *
7
+ * A per-subject hash chain proves nobody edited a record. It does NOT prove the
8
+ * SERVER didn't serve two different histories to two parties (equivocation), or
9
+ * quietly drop a record. That gap is closed by committing to every subject's
10
+ * chain head at once, publishing the commitment, and letting anyone verify that
11
+ * their own head is inside it — which is exactly what this tree does. The
12
+ * commitment (the root) is what gets signed into a checkpoint (`./checkpoint`)
13
+ * and anchored on a public chain.
14
+ *
15
+ * ## Shape
16
+ *
17
+ * The tree follows RFC 6962 (Certificate Transparency) so the algorithms are
18
+ * standard and independently reimplementable: leaves and interior nodes are
19
+ * hashed under DISTINCT domain prefixes (a leaf hash can never be reinterpreted
20
+ * as an interior node), an odd level splits so the LEFT subtree is the largest
21
+ * power of two below the size, and a single-leaf tree's root is the leaf itself.
22
+ *
23
+ * A built tree keeps every LEVEL, not just its leaves, because that is what
24
+ * makes serving proofs cheap: {@link inclusionProof} reads siblings straight out
25
+ * of the levels instead of re-hashing subtrees, so one build amortizes over all
26
+ * the proofs cut from it (a checkpoint is built once and proved thousands of
27
+ * times, once per subject that audits it).
28
+ *
29
+ * Crucially, {@link verifyInclusionProof} needs only the verifier's OWN leaf,
30
+ * its index, the tree size, the audit path, and the signed root — never the
31
+ * other leaves. So a device or node can audit its own history against a
32
+ * published checkpoint without downloading anyone else's data.
33
+ *
34
+ * The leaf order is fixed by {@link buildTransparencyTreeFromHeads}: ascending
35
+ * by `subjectDid` in UTF-16 code-unit order. Order is part of the commitment, so
36
+ * every implementation MUST sort identically or the roots diverge.
37
+ */
38
+ import { sha256 } from '../envelope/recordId.js';
39
+ import { canonicalize } from '../envelope/canonicalJson.js';
40
+ /** Domain prefix for a leaf hash. Distinct from {@link NODE_PREFIX}. */
41
+ const LEAF_PREFIX = 'oxy.transparency.leaf.v1:';
42
+ /** Domain prefix for an interior node hash. Distinct from {@link LEAF_PREFIX}. */
43
+ const NODE_PREFIX = 'oxy.transparency.node.v1:';
44
+ /** The pre-image of {@link EMPTY_TRANSPARENCY_ROOT}. */
45
+ const EMPTY_PREIMAGE = 'oxy.transparency.empty.v1';
46
+ /**
47
+ * The root of a tree with zero leaves: `sha256("oxy.transparency.empty.v1")`.
48
+ *
49
+ * Hard-coded because hashing is async and this is needed as a value; a Jest
50
+ * regression test pins it against the live hash of {@link EMPTY_PREIMAGE}.
51
+ */
52
+ export const EMPTY_TRANSPARENCY_ROOT = '315338df4bc34de7d057b583a082268016888323c79c0376586406a64f441b1e';
53
+ /**
54
+ * Hash one subject's head into a leaf.
55
+ *
56
+ * The pre-image is the canonical JSON of the three committed fields under the
57
+ * leaf domain prefix — so a DID containing a delimiter-like character cannot
58
+ * forge another subject's leaf (JSON escaping makes the encoding unambiguous),
59
+ * and a leaf can never collide with an interior node.
60
+ */
61
+ export async function transparencyLeafHash(entry) {
62
+ const preimage = canonicalize({
63
+ subjectDid: entry.subjectDid,
64
+ seq: entry.seq,
65
+ headRecordId: entry.headRecordId,
66
+ });
67
+ return sha256(`${LEAF_PREFIX}${preimage}`);
68
+ }
69
+ /** Hash an interior node over its ordered children. */
70
+ async function nodeHash(left, right) {
71
+ return sha256(`${NODE_PREFIX}${left}${right}`);
72
+ }
73
+ /**
74
+ * Build a tree over already-hashed leaves, in the given order.
75
+ *
76
+ * Levels are built bottom-up, each one pairing the level below and carrying a
77
+ * trailing odd node up unchanged — which yields exactly RFC 6962's tree, whose
78
+ * recursive definition splits at the largest power of two below the size.
79
+ * `transparency.test.ts` pins that equivalence against a direct transcription of
80
+ * the RFC's MTH for every size up to 40, so the shape can never silently drift.
81
+ *
82
+ * The order IS part of the commitment — prefer
83
+ * {@link buildTransparencyTreeFromHeads}, which owns the canonical ordering.
84
+ */
85
+ export async function buildTransparencyTree(leaves) {
86
+ const levels = [[...leaves]];
87
+ let current = levels[0];
88
+ while (current.length > 1) {
89
+ const pairCount = Math.floor(current.length / 2);
90
+ const parents = await Promise.all(Array.from({ length: pairCount }, (_unused, pair) => nodeHash(current[pair * 2], current[pair * 2 + 1])));
91
+ if (current.length % 2 === 1) {
92
+ parents.push(current[current.length - 1]);
93
+ }
94
+ levels.push(parents);
95
+ current = parents;
96
+ }
97
+ const top = levels[levels.length - 1];
98
+ return {
99
+ root: top.length === 1 ? top[0] : EMPTY_TRANSPARENCY_ROOT,
100
+ treeSize: leaves.length,
101
+ levels,
102
+ };
103
+ }
104
+ /**
105
+ * Build the canonical tree for a snapshot of chain heads.
106
+ *
107
+ * Sorts ascending by `subjectDid` in UTF-16 code-unit order — NEVER
108
+ * `localeCompare`, whose order is locale-dependent and would make two
109
+ * verifiers compute different roots from identical data.
110
+ *
111
+ * Throws on a duplicate `subjectDid`: a snapshot must commit to exactly one head
112
+ * per subject, and silently keeping one of two would hide the other's history.
113
+ */
114
+ export async function buildTransparencyTreeFromHeads(entries) {
115
+ const ordered = [...entries].sort((a, b) => a.subjectDid < b.subjectDid ? -1 : a.subjectDid > b.subjectDid ? 1 : 0);
116
+ const indexBySubject = {};
117
+ ordered.forEach((entry, index) => {
118
+ if (indexBySubject[entry.subjectDid] !== undefined) {
119
+ throw new Error(`Duplicate subject in transparency snapshot: ${entry.subjectDid}`);
120
+ }
121
+ indexBySubject[entry.subjectDid] = index;
122
+ });
123
+ const leaves = await Promise.all(ordered.map(transparencyLeafHash));
124
+ return { ...(await buildTransparencyTree(leaves)), indexBySubject };
125
+ }
126
+ /**
127
+ * The audit path proving `index` is committed in `tree` (RFC 6962 PATH): each
128
+ * step is the sibling subtree hash, leaf-adjacent first.
129
+ *
130
+ * Pure index arithmetic over the tree's levels — no hashing, so cutting a proof
131
+ * costs O(log n) array reads however large the checkpoint is.
132
+ */
133
+ export function inclusionProof(tree, index) {
134
+ if (!Number.isInteger(index) || index < 0 || index >= tree.treeSize) {
135
+ throw new Error(`Leaf index ${index} is outside a tree of ${tree.treeSize} leaves`);
136
+ }
137
+ const proof = [];
138
+ let position = index;
139
+ for (let level = 0; level < tree.levels.length - 1; level += 1) {
140
+ const nodes = tree.levels[level];
141
+ const sibling = position % 2 === 0 ? position + 1 : position - 1;
142
+ // A trailing odd node is carried up unpaired, so it contributes no step.
143
+ if (sibling < nodes.length) {
144
+ proof.push(nodes[sibling]);
145
+ }
146
+ position = Math.floor(position / 2);
147
+ }
148
+ return proof;
149
+ }
150
+ /**
151
+ * Verify an audit path against a committed root (RFC 6962 §2.1.1).
152
+ *
153
+ * Recomputes the root from the leaf upward using only the path, so the verifier
154
+ * never needs another subject's data. Returns `false` for every failure mode —
155
+ * tampered leaf, replayed index, truncated path, foreign root — rather than
156
+ * throwing, so callers treat auditing as a boolean.
157
+ */
158
+ export async function verifyInclusionProof(check) {
159
+ const { leaf, index, treeSize, proof, root } = check;
160
+ if (!Number.isInteger(index) || !Number.isInteger(treeSize)) {
161
+ return false;
162
+ }
163
+ if (index < 0 || treeSize <= 0 || index >= treeSize) {
164
+ return false;
165
+ }
166
+ let fn = index;
167
+ let sn = treeSize - 1;
168
+ let computed = leaf;
169
+ for (const sibling of proof) {
170
+ if (sn === 0) {
171
+ // The path claims more levels than the tree has.
172
+ return false;
173
+ }
174
+ if (fn % 2 === 1 || fn === sn) {
175
+ computed = await nodeHash(sibling, computed);
176
+ while (fn % 2 === 0 && fn !== 0) {
177
+ fn = Math.floor(fn / 2);
178
+ sn = Math.floor(sn / 2);
179
+ }
180
+ }
181
+ else {
182
+ computed = await nodeHash(computed, sibling);
183
+ }
184
+ fn = Math.floor(fn / 2);
185
+ sn = Math.floor(sn / 2);
186
+ }
187
+ // `sn > 0` means the path was truncated before reaching the root.
188
+ return sn === 0 && computed === root;
189
+ }