@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.
- package/LICENSE +202 -0
- package/NOTICE +16 -0
- package/dist/cjs/.tsbuildinfo +1 -0
- package/dist/cjs/chain/continuity.js +54 -0
- package/dist/cjs/chain/engine.js +34 -0
- package/dist/cjs/chain/recordStore.js +25 -0
- package/dist/cjs/chain/types.js +22 -0
- package/dist/cjs/chain/verify.js +82 -0
- package/dist/cjs/envelope/canonicalJson.js +107 -0
- package/dist/cjs/envelope/recordId.js +60 -0
- package/dist/cjs/envelope/sign.js +75 -0
- package/dist/cjs/envelope/signingInput.js +32 -0
- package/dist/cjs/identity/resolver.js +50 -0
- package/dist/cjs/index.js +71 -0
- package/dist/cjs/node/constants.js +85 -0
- package/dist/cjs/node/didWebResolver.js +126 -0
- package/dist/cjs/node/httpFetch.js +61 -0
- package/dist/cjs/node/index.js +71 -0
- package/dist/cjs/node/nodeApp.js +344 -0
- package/dist/cjs/node/nodeClient.js +204 -0
- package/dist/cjs/node/rateLimit.js +187 -0
- package/dist/cjs/node/verifyRecord.js +51 -0
- package/dist/cjs/platform/crypto.js +186 -0
- package/dist/cjs/platform/crypto.native.js +204 -0
- package/dist/cjs/platform/expoTypes.js +24 -0
- package/dist/cjs/platform/platform.js +33 -0
- package/dist/cjs/secp256k1.js +148 -0
- package/dist/cjs/transparency/checkpoint.js +79 -0
- package/dist/cjs/transparency/tree.js +197 -0
- package/dist/esm/.tsbuildinfo +1 -0
- package/dist/esm/chain/continuity.js +51 -0
- package/dist/esm/chain/engine.js +31 -0
- package/dist/esm/chain/recordStore.js +24 -0
- package/dist/esm/chain/types.js +19 -0
- package/dist/esm/chain/verify.js +78 -0
- package/dist/esm/envelope/canonicalJson.js +104 -0
- package/dist/esm/envelope/recordId.js +56 -0
- package/dist/esm/envelope/sign.js +69 -0
- package/dist/esm/envelope/signingInput.js +29 -0
- package/dist/esm/identity/resolver.js +47 -0
- package/dist/esm/index.js +36 -0
- package/dist/esm/node/constants.js +82 -0
- package/dist/esm/node/didWebResolver.js +122 -0
- package/dist/esm/node/httpFetch.js +55 -0
- package/dist/esm/node/index.js +28 -0
- package/dist/esm/node/nodeApp.js +336 -0
- package/dist/esm/node/nodeClient.js +198 -0
- package/dist/esm/node/rateLimit.js +182 -0
- package/dist/esm/node/verifyRecord.js +48 -0
- package/dist/esm/platform/crypto.js +145 -0
- package/dist/esm/platform/crypto.native.js +196 -0
- package/dist/esm/platform/expoTypes.js +23 -0
- package/dist/esm/platform/platform.js +29 -0
- package/dist/esm/secp256k1.js +137 -0
- package/dist/esm/transparency/checkpoint.js +73 -0
- package/dist/esm/transparency/tree.js +189 -0
- package/dist/types/.tsbuildinfo +1 -0
- package/dist/types/chain/continuity.d.ts +28 -0
- package/dist/types/chain/engine.d.ts +27 -0
- package/dist/types/chain/recordStore.d.ts +85 -0
- package/dist/types/chain/types.d.ts +79 -0
- package/dist/types/chain/verify.d.ts +45 -0
- package/dist/types/envelope/canonicalJson.d.ts +44 -0
- package/dist/types/envelope/recordId.d.ts +30 -0
- package/dist/types/envelope/sign.d.ts +47 -0
- package/dist/types/envelope/signingInput.d.ts +33 -0
- package/dist/types/identity/resolver.d.ts +67 -0
- package/dist/types/index.d.ts +32 -0
- package/dist/types/node/constants.d.ts +80 -0
- package/dist/types/node/didWebResolver.d.ts +47 -0
- package/dist/types/node/httpFetch.d.ts +60 -0
- package/dist/types/node/index.d.ts +28 -0
- package/dist/types/node/nodeApp.d.ts +120 -0
- package/dist/types/node/nodeClient.d.ts +135 -0
- package/dist/types/node/rateLimit.d.ts +95 -0
- package/dist/types/node/verifyRecord.d.ts +41 -0
- package/dist/types/platform/crypto.d.ts +93 -0
- package/dist/types/platform/crypto.native.d.ts +77 -0
- package/dist/types/platform/expoTypes.d.ts +99 -0
- package/dist/types/platform/platform.d.ts +25 -0
- package/dist/types/secp256k1.d.ts +45 -0
- package/dist/types/transparency/checkpoint.d.ts +71 -0
- package/dist/types/transparency/tree.d.ts +135 -0
- package/package.json +157 -0
- package/src/__tests__/canonicalJson.test.ts +116 -0
- package/src/__tests__/chain.test.ts +279 -0
- package/src/__tests__/didWebResolver.test.ts +132 -0
- package/src/__tests__/envelope.test.ts +267 -0
- package/src/__tests__/nodeApp.test.ts +410 -0
- package/src/__tests__/nodeClient.test.ts +177 -0
- package/src/__tests__/nodeHarness.ts +151 -0
- package/src/__tests__/optionalNativePeers.test.ts +233 -0
- package/src/__tests__/rateLimit.test.ts +268 -0
- package/src/__tests__/runnerGuard.test.ts +85 -0
- package/src/__tests__/secp256k1.test.ts +118 -0
- package/src/__tests__/transparency.test.ts +353 -0
- package/src/chain/continuity.ts +59 -0
- package/src/chain/engine.ts +43 -0
- package/src/chain/recordStore.ts +98 -0
- package/src/chain/types.ts +85 -0
- package/src/chain/verify.ts +102 -0
- package/src/envelope/canonicalJson.ts +120 -0
- package/src/envelope/recordId.ts +63 -0
- package/src/envelope/sign.ts +86 -0
- package/src/envelope/signingInput.ts +48 -0
- package/src/identity/resolver.ts +90 -0
- package/src/index.ts +101 -0
- package/src/node/constants.ts +105 -0
- package/src/node/didWebResolver.ts +162 -0
- package/src/node/httpFetch.ts +88 -0
- package/src/node/index.ts +87 -0
- package/src/node/nodeApp.ts +471 -0
- package/src/node/nodeClient.ts +322 -0
- package/src/node/rateLimit.ts +233 -0
- package/src/node/verifyRecord.ts +60 -0
- package/src/platform/crypto.native.ts +251 -0
- package/src/platform/crypto.ts +172 -0
- package/src/platform/expoTypes.ts +99 -0
- package/src/platform/platform.ts +31 -0
- package/src/secp256k1.ts +207 -0
- package/src/transparency/checkpoint.ts +109 -0
- package/src/transparency/tree.ts +258 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Envelope verification — the ordered state machine that decides whether a
|
|
3
|
+
* signed-record envelope may be appended to its subject's chain.
|
|
4
|
+
*
|
|
5
|
+
* This is the engine that used to live, Oxy-specific, in
|
|
6
|
+
* `api/services/signedRecord.service.verifyEnvelope`. Every Oxy detail has been
|
|
7
|
+
* lifted out into the two injected collaborators:
|
|
8
|
+
* - the {@link VerificationMethodResolver} owns "is this key authorized for this
|
|
9
|
+
* issuer?" (self vs. custodial vs. untrusted),
|
|
10
|
+
* - the {@link RecordStore} owns the monotonicity frontier + the chain head.
|
|
11
|
+
*
|
|
12
|
+
* The ordered checks (first failure wins):
|
|
13
|
+
* 1. **shape** — the base `signedRecordEnvelopeSchema` (open `type`, opaque
|
|
14
|
+
* `record`). An app re-narrows `type` to its own set in its adapter.
|
|
15
|
+
* 2. **signature** — `verifyEnvelopeSignature` recomputes the canonical signing
|
|
16
|
+
* input and checks the secp256k1 signature against the embedded `publicKey`.
|
|
17
|
+
* 3. **issuer authorization** — `resolver.resolve(subject)` + {@link isAuthorizedKey}.
|
|
18
|
+
* 4. **freshness** — `issuedAt` not beyond the tolerated clock skew.
|
|
19
|
+
* 5. **monotonicity** — `issuedAt` strictly newer than the store's latest record
|
|
20
|
+
* for the same logical key (replay/rollback defence).
|
|
21
|
+
* 6. **continuity** — `checkContinuity` against the store's chain head (v1 skips).
|
|
22
|
+
*
|
|
23
|
+
* The `subject_mismatch` binding ("is the caller allowed to write for THIS
|
|
24
|
+
* subject?") is intentionally NOT here — it is an adapter-policy decision the
|
|
25
|
+
* caller makes before invoking the engine, not a property of the envelope.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { signedRecordEnvelopeSchema, type SignedRecordEnvelope } from '@oxy.so/contracts';
|
|
29
|
+
import { verifyEnvelopeSignature } from '../envelope/sign';
|
|
30
|
+
import { isAuthorizedKey, type VerificationMethodResolver } from '../identity/resolver';
|
|
31
|
+
import { checkContinuity } from './continuity';
|
|
32
|
+
import type { RecordStore } from './recordStore';
|
|
33
|
+
import type { VerifyOutcome } from './types';
|
|
34
|
+
|
|
35
|
+
/** Tunable verification options. */
|
|
36
|
+
export interface VerifyOptions {
|
|
37
|
+
/** Tolerated forward clock skew for `issuedAt`, in ms. Default: 5 minutes. */
|
|
38
|
+
clockSkewMs?: number;
|
|
39
|
+
/** Override "now" (ms epoch) for deterministic tests. Default: `Date.now()`. */
|
|
40
|
+
now?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Default tolerated forward clock skew for a record's `issuedAt` (5 minutes). */
|
|
44
|
+
export const DEFAULT_CLOCK_SKEW_MS = 5 * 60 * 1000;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Run the full verification state machine for `env` against the injected
|
|
48
|
+
* `store` (monotonicity + continuity) and `resolver` (issuer authorization).
|
|
49
|
+
* Returns a verdict; it never throws on a bad envelope.
|
|
50
|
+
*/
|
|
51
|
+
export async function verifyEnvelope(
|
|
52
|
+
store: RecordStore,
|
|
53
|
+
resolver: VerificationMethodResolver,
|
|
54
|
+
env: SignedRecordEnvelope,
|
|
55
|
+
opts: VerifyOptions = {},
|
|
56
|
+
): Promise<VerifyOutcome> {
|
|
57
|
+
// 1. Base envelope shape (open `type`, `record` opaque). An app's adapter
|
|
58
|
+
// re-narrows `type` to its own accepted set before/around this call.
|
|
59
|
+
if (!signedRecordEnvelopeSchema.safeParse(env).success) {
|
|
60
|
+
return { ok: false, reason: 'invalid_envelope' };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 2. Signature is internally consistent with the embedded `publicKey`. Cheap,
|
|
64
|
+
// pure crypto — rejected before any store/resolver I/O.
|
|
65
|
+
if (!(await verifyEnvelopeSignature(env))) {
|
|
66
|
+
return { ok: false, reason: 'bad_signature' };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Enforce that v2 envelopes strictly contain required chain fields.
|
|
70
|
+
if (env.version === 2 && (typeof env.seq !== 'number' || typeof env.collection !== 'string' || typeof env.rkey !== 'string')) {
|
|
71
|
+
return { ok: false, reason: 'invalid_envelope' };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 3. The signing key is an authorized writer for the issuer (self-issued ⇒ a
|
|
75
|
+
// current VM of the subject; custodial ⇒ the custodial key; else untrusted).
|
|
76
|
+
const resolved = await resolver.resolve(env.subject);
|
|
77
|
+
const authorization = isAuthorizedKey(resolved, env);
|
|
78
|
+
if (!authorization.ok) {
|
|
79
|
+
return authorization;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 4. Freshness: not issued beyond the tolerated forward clock skew.
|
|
83
|
+
const now = opts.now ?? Date.now();
|
|
84
|
+
const clockSkewMs = opts.clockSkewMs ?? DEFAULT_CLOCK_SKEW_MS;
|
|
85
|
+
if (env.issuedAt > now + clockSkewMs) {
|
|
86
|
+
return { ok: false, reason: 'issued_in_future' };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 5. Monotonicity: strictly newer than the latest record for the same key.
|
|
90
|
+
const latestIssuedAt = await store.latestIssuedAtForKey(env.subject, env);
|
|
91
|
+
if (latestIssuedAt !== null && env.issuedAt <= latestIssuedAt) {
|
|
92
|
+
return { ok: false, reason: 'stale_issued_at' };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 6. Continuity: the record extends the chain head by exactly one. Only v2 is
|
|
96
|
+
// chained, so v1 never reads the head (it has no chain to extend).
|
|
97
|
+
if (env.version !== 2) {
|
|
98
|
+
return { ok: true };
|
|
99
|
+
}
|
|
100
|
+
const head = await store.getHead(env.subject);
|
|
101
|
+
return checkContinuity(head, env);
|
|
102
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical JSON (RFC 8785 / JCS-style) serialization.
|
|
3
|
+
*
|
|
4
|
+
* `canonicalize(value)` produces a deterministic string for any JSON-compatible
|
|
5
|
+
* value so that a client which SIGNS a record and a server which VERIFIES it
|
|
6
|
+
* agree byte-for-byte on the signing input — regardless of the order in which
|
|
7
|
+
* object keys happen to be written, how the value was deserialized, or which
|
|
8
|
+
* runtime built it.
|
|
9
|
+
*
|
|
10
|
+
* This is the load-bearing primitive for the protocol's signed records
|
|
11
|
+
* (`signEnvelope` + `verifyEnvelopeSignature`): every implementation imports
|
|
12
|
+
* THIS function from `@oxy.so/protocol`, so cross-implementation number/string
|
|
13
|
+
* formatting differences cannot cause a verify mismatch.
|
|
14
|
+
*
|
|
15
|
+
* Rules (the JSON Canonicalization Scheme subset we need):
|
|
16
|
+
* - Objects: keys are sorted (ascending, by UTF-16 code unit — the default
|
|
17
|
+
* `Array.prototype.sort` order) and serialized recursively. Properties whose
|
|
18
|
+
* value is `undefined`, a function, or a symbol are OMITTED (matching
|
|
19
|
+
* `JSON.stringify` object semantics).
|
|
20
|
+
* - Arrays: element order is PRESERVED; `undefined`/function/symbol elements
|
|
21
|
+
* serialize to `null` (matching `JSON.stringify` array semantics).
|
|
22
|
+
* - `null`, booleans, strings, and finite numbers serialize via the standard
|
|
23
|
+
* JSON representation.
|
|
24
|
+
* - Values exposing a `toJSON()` method (e.g. `Date`) are replaced by its
|
|
25
|
+
* result first, then serialized — so a `Date` and its ISO-string equivalent
|
|
26
|
+
* canonicalize identically (the wire always carries the string form).
|
|
27
|
+
* - Non-finite numbers (`NaN`, `Infinity`) and `bigint` are not part of the
|
|
28
|
+
* JSON data model and throw, rather than silently producing `null`.
|
|
29
|
+
*
|
|
30
|
+
* Platform-agnostic — zero dependencies, no `require()`, no react/react-native/
|
|
31
|
+
* expo. Safe in the dual CJS + ESM build.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** Object exposing a `toJSON()` serialization hook (e.g. `Date`). */
|
|
35
|
+
interface ToJsonable {
|
|
36
|
+
toJSON: () => unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function hasToJSON(value: object): value is ToJsonable {
|
|
40
|
+
return typeof (value as { toJSON?: unknown }).toJSON === 'function';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Serialize a single value into its canonical JSON fragment. Recursive; called
|
|
45
|
+
* on each nested member. Object keys are sorted at every level.
|
|
46
|
+
*/
|
|
47
|
+
function serialize(value: unknown): string {
|
|
48
|
+
if (value === null) {
|
|
49
|
+
return 'null';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const valueType = typeof value;
|
|
53
|
+
|
|
54
|
+
if (valueType === 'number') {
|
|
55
|
+
if (!Number.isFinite(value)) {
|
|
56
|
+
throw new Error('canonicalize: non-finite numbers cannot be serialized');
|
|
57
|
+
}
|
|
58
|
+
return JSON.stringify(value);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (valueType === 'string' || valueType === 'boolean') {
|
|
62
|
+
return JSON.stringify(value);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (valueType === 'bigint') {
|
|
66
|
+
throw new Error('canonicalize: bigint values cannot be serialized');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (Array.isArray(value)) {
|
|
70
|
+
const items = value.map((item) => {
|
|
71
|
+
const itemType = typeof item;
|
|
72
|
+
// JSON array semantics: undefined / function / symbol become null so the
|
|
73
|
+
// element positions (and therefore the array length) are preserved.
|
|
74
|
+
if (item === undefined || itemType === 'function' || itemType === 'symbol') {
|
|
75
|
+
return 'null';
|
|
76
|
+
}
|
|
77
|
+
return serialize(item);
|
|
78
|
+
});
|
|
79
|
+
return `[${items.join(',')}]`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (valueType === 'object') {
|
|
83
|
+
const obj = value as object;
|
|
84
|
+
if (hasToJSON(obj)) {
|
|
85
|
+
return serialize(obj.toJSON());
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const record = obj as Record<string, unknown>;
|
|
89
|
+
const parts: string[] = [];
|
|
90
|
+
for (const key of Object.keys(record).sort()) {
|
|
91
|
+
const member = record[key];
|
|
92
|
+
const memberType = typeof member;
|
|
93
|
+
// JSON object semantics: properties with undefined / function / symbol
|
|
94
|
+
// values are omitted entirely.
|
|
95
|
+
if (member === undefined || memberType === 'function' || memberType === 'symbol') {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
parts.push(`${JSON.stringify(key)}:${serialize(member)}`);
|
|
99
|
+
}
|
|
100
|
+
return `{${parts.join(',')}}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// undefined / function / symbol at the top level have no JSON representation.
|
|
104
|
+
throw new Error(`canonicalize: cannot serialize a value of type ${valueType}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Produce the canonical JSON string for `value`.
|
|
109
|
+
*
|
|
110
|
+
* Deterministic: two structurally-equal values yield identical strings even if
|
|
111
|
+
* their object keys were written in different orders. Use this — never an
|
|
112
|
+
* ad-hoc `JSON.stringify` of a hand-sorted object — as the signing input for
|
|
113
|
+
* signed records, so client signing and server verification cannot drift.
|
|
114
|
+
*
|
|
115
|
+
* @throws if `value` (or any nested member used as the top-level/primitive)
|
|
116
|
+
* contains a non-finite number or a `bigint`, which have no JSON form.
|
|
117
|
+
*/
|
|
118
|
+
export function canonicalize(value: unknown): string {
|
|
119
|
+
return serialize(value);
|
|
120
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content hashing — SHA-256 + `recordId` (content address).
|
|
3
|
+
*
|
|
4
|
+
* `sha256` is the protocol's single platform-aware SHA-256: it uses
|
|
5
|
+
* `expo-crypto` on React Native, Node's built-in `crypto` on the server, and
|
|
6
|
+
* the Web Crypto API in the browser — always producing the same lowercase-hex
|
|
7
|
+
* digest. `computeRecordId` is `sha256(signedRecordSigningInput(fields))`: the
|
|
8
|
+
* content address that a record's chain `prev` pointer references.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { isReactNative, isNodeJS } from '../platform/platform';
|
|
12
|
+
import { loadExpoCrypto, loadNodeCrypto } from '../platform/crypto';
|
|
13
|
+
import { signedRecordSigningInput, type SignedRecordSigningFields } from './signingInput';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Compute the SHA-256 hash of a string, returned as lowercase hex.
|
|
17
|
+
*
|
|
18
|
+
* Platform-aware: `expo-crypto` (RN) → Node `crypto` (server) → Web Crypto
|
|
19
|
+
* (browser). The three paths produce byte-identical digests, so a record
|
|
20
|
+
* hashed on a device and re-hashed on the server agree.
|
|
21
|
+
*/
|
|
22
|
+
export async function sha256(message: string): Promise<string> {
|
|
23
|
+
// In React Native, use expo-crypto
|
|
24
|
+
if (isReactNative()) {
|
|
25
|
+
const Crypto = await loadExpoCrypto();
|
|
26
|
+
return Crypto.digestStringAsync(
|
|
27
|
+
Crypto.CryptoDigestAlgorithm.SHA256,
|
|
28
|
+
message,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (isNodeJS()) {
|
|
33
|
+
try {
|
|
34
|
+
const nodeCrypto = await loadNodeCrypto();
|
|
35
|
+
return nodeCrypto.createHash('sha256').update(message).digest('hex');
|
|
36
|
+
} catch {
|
|
37
|
+
// Node crypto failed to load — fall through to the Web Crypto API below,
|
|
38
|
+
// which is a correct, equivalent SHA-256 on any runtime that exposes it.
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Browser: use Web Crypto API
|
|
43
|
+
const encoder = new TextEncoder();
|
|
44
|
+
const data = encoder.encode(message);
|
|
45
|
+
const hashBuffer = await globalThis.crypto.subtle.digest('SHA-256', data);
|
|
46
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
47
|
+
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Compute the `recordId` (content address) of a signed record: the SHA-256 hex
|
|
52
|
+
* digest of its canonical {@link signedRecordSigningInput}.
|
|
53
|
+
*
|
|
54
|
+
* Deterministic and stable across runtimes (it reuses the same canonicalization
|
|
55
|
+
* + SHA-256 the signature itself is built on). The recordId is what `prev`
|
|
56
|
+
* references in the per-subject hash chain, so every implementation MUST
|
|
57
|
+
* compute it identically — all call this function. It is taken over the SIGNING
|
|
58
|
+
* input (excluding `publicKey`/`signature`), so it is a pure content address of
|
|
59
|
+
* the record's meaning, independent of who signed.
|
|
60
|
+
*/
|
|
61
|
+
export async function computeRecordId(fields: SignedRecordSigningFields): Promise<string> {
|
|
62
|
+
return sha256(signedRecordSigningInput(fields));
|
|
63
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signing & verification — explicit-key crypto for signed-record envelopes.
|
|
3
|
+
*
|
|
4
|
+
* Stateless: every function takes the key material explicitly (no KeyManager,
|
|
5
|
+
* no secure storage). `@oxy.so/core` binds these to a device key; nodes and the
|
|
6
|
+
* API verify with them. The scheme is `ES256K-DER-SHA256` everywhere:
|
|
7
|
+
* secp256k1 over the SHA-256 of the canonical bytes, DER-encoded.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { SignedRecordEnvelope } from '@oxy.so/contracts';
|
|
11
|
+
import { signedRecordSigningInput, type SignedRecordSigningFields } from './signingInput';
|
|
12
|
+
import { sha256 } from './recordId';
|
|
13
|
+
import {
|
|
14
|
+
deriveSecp256k1PublicKey,
|
|
15
|
+
signSecp256k1Digest,
|
|
16
|
+
verifySecp256k1Digest,
|
|
17
|
+
} from '../secp256k1';
|
|
18
|
+
|
|
19
|
+
/** The one signature algorithm identifier the protocol emits. */
|
|
20
|
+
const ALG = 'ES256K-DER-SHA256' as const;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Sign an arbitrary message with an explicit private key.
|
|
24
|
+
*
|
|
25
|
+
* Hashes the message with SHA-256, then signs the digest with secp256k1,
|
|
26
|
+
* returning the DER-encoded hex signature. The low-level primitive behind both
|
|
27
|
+
* {@link signEnvelope} and `@oxy.so/core`'s device-key signing helpers.
|
|
28
|
+
*/
|
|
29
|
+
export async function signMessage(message: string, privateKeyHex: string): Promise<string> {
|
|
30
|
+
const digest = await sha256(message);
|
|
31
|
+
return signSecp256k1Digest(privateKeyHex, digest);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Verify a DER-encoded signature over a message against a public key.
|
|
36
|
+
*
|
|
37
|
+
* Returns `false` on any error (invalid signature, malformed key/signature,
|
|
38
|
+
* etc.) rather than throwing, so callers can treat verification as a boolean.
|
|
39
|
+
*/
|
|
40
|
+
export async function verifySignature(
|
|
41
|
+
message: string,
|
|
42
|
+
signature: string,
|
|
43
|
+
publicKeyHex: string,
|
|
44
|
+
): Promise<boolean> {
|
|
45
|
+
try {
|
|
46
|
+
const digest = await sha256(message);
|
|
47
|
+
return verifySecp256k1Digest(publicKeyHex, digest, signature);
|
|
48
|
+
} catch {
|
|
49
|
+
// Malformed key / signature / input is not a valid signature.
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build a fully-signed {@link SignedRecordEnvelope} from its signing fields and
|
|
56
|
+
* an explicit private key.
|
|
57
|
+
*
|
|
58
|
+
* Computes the canonical {@link signedRecordSigningInput}, signs it
|
|
59
|
+
* (`ES256K-DER-SHA256`), and attaches the DERIVED `publicKey` (uncompressed
|
|
60
|
+
* hex — identical to `KeyManager`'s stored key for the same private key) plus
|
|
61
|
+
* the `alg`/`signature`. The signature covers every field EXCEPT
|
|
62
|
+
* `publicKey`/`signature`.
|
|
63
|
+
*/
|
|
64
|
+
export async function signEnvelope(
|
|
65
|
+
fields: SignedRecordSigningFields,
|
|
66
|
+
privateKeyHex: string,
|
|
67
|
+
): Promise<SignedRecordEnvelope> {
|
|
68
|
+
const signingInput = signedRecordSigningInput(fields);
|
|
69
|
+
const signature = await signMessage(signingInput, privateKeyHex);
|
|
70
|
+
const publicKey = deriveSecp256k1PublicKey(privateKeyHex);
|
|
71
|
+
return { ...fields, publicKey, alg: ALG, signature };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Verify a signed-record envelope: recompute the canonical signing input from
|
|
76
|
+
* the envelope's own fields and check the signature against the envelope's
|
|
77
|
+
* `publicKey`.
|
|
78
|
+
*
|
|
79
|
+
* This confirms the signature is internally consistent with the embedded
|
|
80
|
+
* `publicKey`. It does NOT establish that `publicKey` is an authorized
|
|
81
|
+
* verification method for `subject` — that authorization check belongs to the
|
|
82
|
+
* server / node owner check.
|
|
83
|
+
*/
|
|
84
|
+
export async function verifyEnvelopeSignature(envelope: SignedRecordEnvelope): Promise<boolean> {
|
|
85
|
+
return verifySignature(signedRecordSigningInput(envelope), envelope.signature, envelope.publicKey);
|
|
86
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signed-record signing input — "what the signature covers".
|
|
3
|
+
*
|
|
4
|
+
* The single definition shared by every implementation (client signing and
|
|
5
|
+
* server verification), so a record signed by one and verified by another
|
|
6
|
+
* cannot drift.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { SignedRecordEnvelope } from '@oxy.so/contracts';
|
|
10
|
+
import { canonicalize } from './canonicalJson';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The signing-input portion of a {@link SignedRecordEnvelope}: every field
|
|
14
|
+
* EXCEPT the `publicKey` and `signature`. Both the signer and the verifier
|
|
15
|
+
* canonicalize exactly these fields, so they agree on the bytes that the
|
|
16
|
+
* signature covers.
|
|
17
|
+
*
|
|
18
|
+
* The v2 chain fields (`seq`/`prev`/`collection`/`rkey`) are optional: a v1
|
|
19
|
+
* envelope omits them and is signed over only the base fields; a v2 envelope
|
|
20
|
+
* carries them and includes them in the signed bytes.
|
|
21
|
+
*/
|
|
22
|
+
export type SignedRecordSigningFields = Pick<
|
|
23
|
+
SignedRecordEnvelope,
|
|
24
|
+
'version' | 'type' | 'subject' | 'issuer' | 'record' | 'issuedAt'
|
|
25
|
+
> &
|
|
26
|
+
Partial<Pick<SignedRecordEnvelope, 'seq' | 'prev' | 'collection' | 'rkey'>>;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Compute the canonical signing input for a signed-record envelope.
|
|
30
|
+
*
|
|
31
|
+
* - **v1**: the canonical JSON of `{version, type, subject, issuer, record,
|
|
32
|
+
* issuedAt}` — BYTE-IDENTICAL to the original scheme, so every signature
|
|
33
|
+
* already in production keeps verifying.
|
|
34
|
+
* - **v2**: the canonical JSON additionally includes the hash-chain fields
|
|
35
|
+
* `{seq, prev, collection, rkey}`. Because {@link canonicalize} sorts keys,
|
|
36
|
+
* the on-the-wire field order is irrelevant; the resulting canonical key
|
|
37
|
+
* order is `collection, issuedAt, issuer, prev, record, rkey, seq, subject,
|
|
38
|
+
* type, version`. `prev` is `null` at genesis (serialized as `null`, not
|
|
39
|
+
* omitted), so it is always part of the signed bytes.
|
|
40
|
+
*/
|
|
41
|
+
export function signedRecordSigningInput(fields: SignedRecordSigningFields): string {
|
|
42
|
+
const { version, type, subject, issuer, record, issuedAt } = fields;
|
|
43
|
+
if (version === 2) {
|
|
44
|
+
const { seq, prev, collection, rkey } = fields;
|
|
45
|
+
return canonicalize({ version, type, subject, issuer, record, issuedAt, seq, prev, collection, rkey });
|
|
46
|
+
}
|
|
47
|
+
return canonicalize({ version, type, subject, issuer, record, issuedAt });
|
|
48
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verification-method resolution — the injected authorization policy the chain
|
|
3
|
+
* engine consults to decide whether an envelope's signing key is allowed to
|
|
4
|
+
* write to the subject's chain.
|
|
5
|
+
*
|
|
6
|
+
* The engine is identity-agnostic: it does NOT know about Oxy DIDs, the `User`
|
|
7
|
+
* model, custodial keys, or any app's notion of "current key". It asks an
|
|
8
|
+
* injected {@link VerificationMethodResolver} to resolve a subject DID to its
|
|
9
|
+
* current verification methods (plus the optional custodial issuer that may sign
|
|
10
|
+
* provenance records ABOUT the subject), then applies the uniform authorization
|
|
11
|
+
* rule in {@link isAuthorizedKey}.
|
|
12
|
+
*
|
|
13
|
+
* This is what lets the SAME engine serve Oxy identity/civic records (where the
|
|
14
|
+
* resolver reads `User.publicKey`/`authMethods` and the Oxy custodial key), a
|
|
15
|
+
* self-hosted node (where the authority is a configured owner key), and Mention
|
|
16
|
+
* posts (subject VMs from the Oxy DID + a Mention custodial server key) — each
|
|
17
|
+
* supplies its own resolver; the decision logic lives here, once.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { SignedRecordEnvelope } from '@oxy.so/contracts';
|
|
21
|
+
import type { RejectionReason } from '../chain/types';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The verification methods that may sign records on a subject's chain.
|
|
25
|
+
*
|
|
26
|
+
* - `currentPublicKeys` — the subject's OWN current signing keys (self-issued
|
|
27
|
+
* records: `issuer === subject`). Empty when the subject has no self-sovereign
|
|
28
|
+
* key (e.g. a custodial-only account).
|
|
29
|
+
* - `custodialIssuer` / `custodialPublicKey` — an optional custodial authority
|
|
30
|
+
* that may sign provenance records ABOUT the subject (`issuer ===
|
|
31
|
+
* custodialIssuer`), and the single public key it signs with. Both present or
|
|
32
|
+
* both absent. The custodial public key is published (it lives in a DID
|
|
33
|
+
* document), so a plain-equality comparison is sufficient — it is not a
|
|
34
|
+
* secret.
|
|
35
|
+
*/
|
|
36
|
+
export interface ResolvedVerificationMethods {
|
|
37
|
+
currentPublicKeys: string[];
|
|
38
|
+
custodialIssuer?: string;
|
|
39
|
+
custodialPublicKey?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface VerificationMethodResolver {
|
|
43
|
+
/**
|
|
44
|
+
* Resolve a subject DID to its current verification methods, or `null` when the
|
|
45
|
+
* subject is unknown / cannot be resolved (no key is then authorized).
|
|
46
|
+
*/
|
|
47
|
+
resolve(subjectDid: string): Promise<ResolvedVerificationMethods | null>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Verdict of the key-authorization decision (a subset of {@link RejectionReason}). */
|
|
51
|
+
export type KeyAuthorization =
|
|
52
|
+
| { ok: true }
|
|
53
|
+
| { ok: false; reason: Extract<RejectionReason, 'public_key_not_a_current_verification_method' | 'untrusted_issuer'> };
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Decide whether `env`'s signing key (`env.publicKey`) is authorized for its
|
|
57
|
+
* `issuer`, given the subject's resolved verification methods:
|
|
58
|
+
*
|
|
59
|
+
* - **Self-issued** (`issuer === subject`): the key MUST be one of the subject's
|
|
60
|
+
* `currentPublicKeys`; otherwise `public_key_not_a_current_verification_method`.
|
|
61
|
+
* - **Custodial** (`issuer === custodialIssuer`): the key MUST equal
|
|
62
|
+
* `custodialPublicKey`; otherwise `public_key_not_a_current_verification_method`.
|
|
63
|
+
* - **Any other issuer** (including an unresolvable subject): `untrusted_issuer`.
|
|
64
|
+
*
|
|
65
|
+
* The signature itself is checked separately (against `env.publicKey`), so this
|
|
66
|
+
* only decides whether that key is an authorized writer — it is not a trust
|
|
67
|
+
* shortcut.
|
|
68
|
+
*/
|
|
69
|
+
export function isAuthorizedKey(
|
|
70
|
+
resolved: ResolvedVerificationMethods | null,
|
|
71
|
+
env: SignedRecordEnvelope,
|
|
72
|
+
): KeyAuthorization {
|
|
73
|
+
if (resolved !== null && env.issuer === env.subject) {
|
|
74
|
+
return resolved.currentPublicKeys.includes(env.publicKey)
|
|
75
|
+
? { ok: true }
|
|
76
|
+
: { ok: false, reason: 'public_key_not_a_current_verification_method' };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (
|
|
80
|
+
resolved !== null &&
|
|
81
|
+
resolved.custodialIssuer !== undefined &&
|
|
82
|
+
env.issuer === resolved.custodialIssuer
|
|
83
|
+
) {
|
|
84
|
+
return resolved.custodialPublicKey !== undefined && env.publicKey === resolved.custodialPublicKey
|
|
85
|
+
? { ok: true }
|
|
86
|
+
: { ok: false, reason: 'public_key_not_a_current_verification_method' };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { ok: false, reason: 'untrusted_issuer' };
|
|
90
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @oxy.so/protocol — the app-agnostic Oxy Protocol base.
|
|
3
|
+
*
|
|
4
|
+
* The reusable substrate any Oxy app can use to decentralize its own content:
|
|
5
|
+
* the signed-record envelope grammar (canonical JSON, signing input, content
|
|
6
|
+
* address), explicit-key signing/verification, and the platform-aware crypto
|
|
7
|
+
* loaders. App-specific lexicons and the chain engine layer on top of this.
|
|
8
|
+
*
|
|
9
|
+
* Platform-agnostic root entry. Node-only pieces live under the `./node`
|
|
10
|
+
* subpath so they never enter React Native / web bundles.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Envelope — canonical JSON, signing input, content address, signing/verify
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
export { canonicalize } from './envelope/canonicalJson';
|
|
17
|
+
export { signedRecordSigningInput } from './envelope/signingInput';
|
|
18
|
+
export type { SignedRecordSigningFields } from './envelope/signingInput';
|
|
19
|
+
export { sha256, computeRecordId } from './envelope/recordId';
|
|
20
|
+
export {
|
|
21
|
+
signMessage,
|
|
22
|
+
verifySignature,
|
|
23
|
+
signEnvelope,
|
|
24
|
+
verifyEnvelopeSignature,
|
|
25
|
+
} from './envelope/sign';
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Chain — per-subject hash-chain engine over an injected RecordStore + resolver
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
export type {
|
|
31
|
+
ChainHead,
|
|
32
|
+
RejectionReason,
|
|
33
|
+
VerifyOutcome,
|
|
34
|
+
AppendOutcome,
|
|
35
|
+
} from './chain/types';
|
|
36
|
+
export { UNCHAINED_SEQ } from './chain/types';
|
|
37
|
+
export { checkContinuity } from './chain/continuity';
|
|
38
|
+
export type { RecordStore, BlobStore } from './chain/recordStore';
|
|
39
|
+
export { verifyEnvelope, DEFAULT_CLOCK_SKEW_MS } from './chain/verify';
|
|
40
|
+
export type { VerifyOptions } from './chain/verify';
|
|
41
|
+
export { verifyAndAppend } from './chain/engine';
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Transparency — Merkle commitment over chain heads + co-signable checkpoints
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
export {
|
|
47
|
+
EMPTY_TRANSPARENCY_ROOT,
|
|
48
|
+
transparencyLeafHash,
|
|
49
|
+
buildTransparencyTree,
|
|
50
|
+
buildTransparencyTreeFromHeads,
|
|
51
|
+
inclusionProof,
|
|
52
|
+
verifyInclusionProof,
|
|
53
|
+
} from './transparency/tree';
|
|
54
|
+
export type {
|
|
55
|
+
TransparencyHeadEntry,
|
|
56
|
+
TransparencyTree,
|
|
57
|
+
TransparencyTreeFromHeads,
|
|
58
|
+
InclusionProofCheck,
|
|
59
|
+
} from './transparency/tree';
|
|
60
|
+
export {
|
|
61
|
+
checkpointSigningInput,
|
|
62
|
+
checkpointHash,
|
|
63
|
+
signCheckpoint,
|
|
64
|
+
verifyCheckpointSignature,
|
|
65
|
+
} from './transparency/checkpoint';
|
|
66
|
+
export type {
|
|
67
|
+
TransparencyCheckpointFields,
|
|
68
|
+
TransparencyCheckpointSignature,
|
|
69
|
+
} from './transparency/checkpoint';
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Identity — injected verification-method resolution + authorization rule
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
export { isAuthorizedKey } from './identity/resolver';
|
|
75
|
+
export type {
|
|
76
|
+
VerificationMethodResolver,
|
|
77
|
+
ResolvedVerificationMethods,
|
|
78
|
+
KeyAuthorization,
|
|
79
|
+
} from './identity/resolver';
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Platform — runtime predicates + lazy crypto/storage loaders
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
export { isReactNative, isNodeJS } from './platform/platform';
|
|
85
|
+
export {
|
|
86
|
+
loadNodeCrypto,
|
|
87
|
+
loadExpoCrypto,
|
|
88
|
+
loadSecureStore,
|
|
89
|
+
loadAsyncStorage,
|
|
90
|
+
getRandomBytesRN,
|
|
91
|
+
loadSharedIdentityBridge,
|
|
92
|
+
} from './platform/crypto';
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Platform types — structural interfaces for Expo modules.
|
|
96
|
+
//
|
|
97
|
+
// Exported so @oxy.so/core and other consumers can type their own wrappers
|
|
98
|
+
// without importing `typeof import('expo-crypto')` / `typeof import('expo-secure-store')`
|
|
99
|
+
// (which would trigger NodeNext type pollution in server packages).
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
export type { ExpoCryptoLike, ExpoSecureStoreLike, SharedIdentityBridge } from './platform/expoTypes';
|