@ninsei-labs/sdk 0.43.1 → 0.43.3
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/CHANGELOG.md +29 -0
- package/README.md +1 -1
- package/package.json +9 -3
- package/src/provider/envFile.mjs +106 -0
- package/src/provider/identity.mjs +43 -0
- package/src/provider/sign.mjs +164 -0
- package/src/public/order.d.ts +12 -0
- package/src/public/order.mjs +5 -0
- package/src/public/provider.d.ts +93 -0
- package/src/public/provider.mjs +39 -0
- package/src/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,32 @@
|
|
|
1
|
+
## 0.43.3 — 2026-10-18
|
|
2
|
+
|
|
3
|
+
THE SAME CONTENT AS 0.43.2, PUBLISHED UNDER A NEW NUMBER. The tag-triggered release for 0.43.2 stopped in
|
|
4
|
+
`pnpm/action-setup` before building anything: the workflow named pnpm 12.10.1 while `packageManager` names
|
|
5
|
+
12.11.2, and the action refuses two versions (`ERR_PNPM_BAD_PM_VERSION`). Nothing reached the registry. The
|
|
6
|
+
workflow now reads `packageManager` and pins no version of its own; this release carries that fix and the
|
|
7
|
+
0.43.2 content unchanged.
|
|
8
|
+
|
|
9
|
+
- `./provider` (Node only) and the two bound verification helpers in `./order` are as described in 0.43.2.
|
|
10
|
+
- The display-name draw uses `randomInt`, as fixed in 0.43.2.
|
|
11
|
+
- No package code changed between 0.43.2 and 0.43.3; only the release pipeline did.
|
|
12
|
+
|
|
13
|
+
## 0.43.2 — 2026-10-18
|
|
14
|
+
|
|
15
|
+
THE PROVIDER SIDE AND THE BOUND VERIFICATION HELPERS ARE PUBLIC. Two things the demo and the node used to reach
|
|
16
|
+
through the node's own files are now package surface: `./order` exports `orderQuoteMissingFields` and
|
|
17
|
+
`recoverOrderQuoteKey` (the spec functions bound to the SDK's primitives), and a new Node-only subpath
|
|
18
|
+
`./provider` carries the provider's identity, signing and env-file helpers. A consumer no longer re-derives the
|
|
19
|
+
provider code rule or the public-key path, and the browser entries stay free of `node:fs` and `node:crypto`.
|
|
20
|
+
|
|
21
|
+
- `./order` gains two names: `orderQuoteMissingFields(quote)` and `recoverOrderQuoteKey(quote)`. Both are the
|
|
22
|
+
spec's own functions bound to `primitives.mjs`, so verification has one implementation, not two.
|
|
23
|
+
- `./provider` is a separate entry because it reaches the filesystem and `node:crypto`: the provider code of an
|
|
24
|
+
address, the public key path beside a private key, key generation, payload signing and verification, and the
|
|
25
|
+
settings-file helpers (`findEnvFile`, `readEnvFile`, `applyEnvFile`, `substituteEnvVars`).
|
|
26
|
+
- The checks gained a `provider` case: the provider code is the address's own name and stable, the public key
|
|
27
|
+
path follows the private one, a settings file is found, read and applied, and the bound quote verification
|
|
28
|
+
names what is missing and recovers nothing from an empty quote.
|
|
29
|
+
|
|
1
30
|
## 0.43.1 — 2026-10-12
|
|
2
31
|
|
|
3
32
|
THE DECLINE, THE REFUND AND THE MAKER'S RECLAIM EACH CARRY THEIR OWN STATE, AND THE TERMINAL CHECK READS THE
|
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ value that looks like data is more dangerous than a refusal.
|
|
|
32
32
|
compares the list of what the SDK returns with what is declared in the contract (both ways).
|
|
33
33
|
|
|
34
34
|
EVERY PUBLISHED SUBPATH CARRIES ITS OWN DECLARATIONS: `.` (`index.d.ts`), `./xmr`, `./order`, `./evm`,
|
|
35
|
-
`./core` and `./
|
|
35
|
+
`./core`, `./recovery` and `./provider` (`src/public/<entry>.d.ts`). Each of them declares exactly the symbols its barrel
|
|
36
36
|
re-exports, so a typed consumer sees the same surface the runtime has, and nothing else.
|
|
37
37
|
`tools/check.mjs --only=types` keeps the two sides equal (a subpath without a `types` target, a declarations
|
|
38
38
|
file that does not read as declarations, or a symbol declared on one side only is red), and
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ninsei-labs/sdk",
|
|
3
|
-
"version": "0.43.
|
|
3
|
+
"version": "0.43.3",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public",
|
|
6
6
|
"registry": "https://registry.npmjs.org/"
|
|
7
7
|
},
|
|
8
|
-
"packageManager": "pnpm@12.
|
|
8
|
+
"packageManager": "pnpm@12.11.2",
|
|
9
9
|
"description": "XMR <-> EVM swap engine: quotes, guards, order steps, recovery (no DOM, no user-facing strings)",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"type": "module",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"check:surface": "node tools/check.mjs --only=surface",
|
|
18
18
|
"check:mirror": "node tools/check.mjs --only=mirror",
|
|
19
19
|
"check:pack": "node tools/check.mjs --only=pack",
|
|
20
|
+
"check:pack-teeth": "node tools/check-pack-teeth.mjs",
|
|
20
21
|
"check:secrets": "node tools/check.mjs --only=secrets",
|
|
21
22
|
"check:types": "node tools/check.mjs --only=types",
|
|
22
23
|
"check:types-teeth": "node tools/check-types-teeth.mjs",
|
|
@@ -26,7 +27,8 @@
|
|
|
26
27
|
"check:decline-teeth": "node tools/check-decline-teeth.mjs",
|
|
27
28
|
"check:terminal": "node tools/check.mjs --only=terminal",
|
|
28
29
|
"check:terminal-teeth": "node tools/check-terminal-teeth.mjs",
|
|
29
|
-
"check:sweep": "node tools/sweep-refusal.test.mjs",
|
|
30
|
+
"check:sweep": "node tools/sweep-refusal.test.mjs",
|
|
31
|
+
"sync:version": "node tools/sync-version.mjs"
|
|
30
32
|
},
|
|
31
33
|
"types": "./index.d.ts",
|
|
32
34
|
"exports": {
|
|
@@ -57,6 +59,10 @@
|
|
|
57
59
|
"./recovery": {
|
|
58
60
|
"types": "./src/public/recovery.d.ts",
|
|
59
61
|
"default": "./src/public/recovery.mjs"
|
|
62
|
+
},
|
|
63
|
+
"./provider": {
|
|
64
|
+
"types": "./src/public/provider.d.ts",
|
|
65
|
+
"default": "./src/public/provider.mjs"
|
|
60
66
|
}
|
|
61
67
|
},
|
|
62
68
|
"files": [
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// THE SERVICE SETTINGS FILE: WHERE IT LIES AND HOW IT IS READ.
|
|
2
|
+
//
|
|
3
|
+
// The module deliberately imports NOTHING from config.mjs: the entry point connects it BEFORE the config loads, so that
|
|
4
|
+
// the values get into the environment in time. config.mjs reads process.env at module load time, and static
|
|
5
|
+
// imports in ESM run before any code of the file - so 'read the file, then import the config' is
|
|
6
|
+
// possible only through a dynamic import and only from a separate module.
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
|
|
11
|
+
// WHERE TO LOOK WHEN THE FLAG IS NOT GIVEN: the current directory (the command is run from the node tree), then rfq/ under it (the layout of the tree this node was cut from)
|
|
12
|
+
// (the same run from the repository root), then .env - that is also a frequent name for a settings file. What was found
|
|
13
|
+
// is printed: a silent read of 'some' file is exactly the case where people later look for why the settings are wrong.
|
|
14
|
+
//
|
|
15
|
+
// ORDER: THE LOCAL FILE OUTRANKS THE PRODUCTION ONE. The local contour puts its settings BESIDE the production ones
|
|
16
|
+
// (rfq.env.local), and while the search went from the plain name to .local, the developer tool picked up the production
|
|
17
|
+
// file: the wallet path /opt/ninsei/..., the production price feed and its age - and refused with 'nothing to price the order with' where
|
|
18
|
+
// the settings were in place (the quote checks fell exactly on this, and `npm run payout` with them).
|
|
19
|
+
// The reverse order can err in one direction only: on the server there is no .local file at all, and the production
|
|
20
|
+
// one remains the only one. An explicit --env= still outranks everything.
|
|
21
|
+
export const ENV_CANDIDATES = ["rfq.env.local", "rfq.env.local", "rfq.env", "rfq.env", ".env.local", ".env"];
|
|
22
|
+
|
|
23
|
+
export function findEnvFile(cwd, candidates = ENV_CANDIDATES) {
|
|
24
|
+
for (const rel of candidates) {
|
|
25
|
+
const full = join(cwd, rel);
|
|
26
|
+
if (existsSync(full)) return full;
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// A PATH OF A FOREIGN MACHINE IN A SETTINGS FILE IS AN ERROR, NOT A SETTING.
|
|
32
|
+
//
|
|
33
|
+
// The trap was found in battle on 25.09.2026. The local files (*.env.local) travelled to the server with the directory upload
|
|
34
|
+
// (the documented rsync commands had no --exclude '*.env.local'), the tool honestly took the local file -
|
|
35
|
+
// it outranks the production one, see below - start directory and the result was:
|
|
36
|
+
//
|
|
37
|
+
// /opt/ninsei/keys
|
|
38
|
+
//
|
|
39
|
+
// The check is one-sided DELIBERATELY: the reverse case - production paths /opt/... on a developer machine - is legitimate,
|
|
40
|
+
// because the repository holds copies of the production files, and they must be readable. The function is pure: the platform comes
|
|
41
|
+
// as a parameter, so its behaviour is checkable on any machine.
|
|
42
|
+
export function foreignPathProblem(file, values, platform = process.platform) {
|
|
43
|
+
if (platform === "win32") return null;
|
|
44
|
+
for (const [key, value] of Object.entries(values || {})) {
|
|
45
|
+
const v = String(value).trim();
|
|
46
|
+
if (/^(?:[A-Za-z]:[\\/]|\\\\)/.test(v)) return { file, key, value: v };
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function foreignPathMessage(p) {
|
|
52
|
+
return "settings file " + p.file + " sets a Windows path in setting " + p.key + " (" + p.value
|
|
53
|
+
+ "), and the system is not Windows. This is how a local file (*.env.local) copied from a developer "
|
|
54
|
+
+ "machine looks: it must not be on the server, the service reads <service>/<service>.env. Delete the local "
|
|
55
|
+
+ "file and repeat.";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// We read by the systemd rules: comments and broken lines are skipped, export is stripped, quotes are stripped,
|
|
59
|
+
// spaces are trimmed.
|
|
60
|
+
export function readEnvFile(path) {
|
|
61
|
+
const out = {};
|
|
62
|
+
for (const raw of readFileSync(path, "utf8").split("\n")) {
|
|
63
|
+
const line = raw.trim();
|
|
64
|
+
if (!line || line.startsWith("#")) continue;
|
|
65
|
+
const eq = line.indexOf("=");
|
|
66
|
+
if (eq < 1) continue;
|
|
67
|
+
const key = line.slice(0, eq).replace(/^export\s+/, "").trim();
|
|
68
|
+
let value = line.slice(eq + 1).trim();
|
|
69
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
70
|
+
value = value.slice(1, -1);
|
|
71
|
+
}
|
|
72
|
+
if (key) out[key] = value;
|
|
73
|
+
}
|
|
74
|
+
// The file is read, but the values may be FROM A FOREIGN MACHINE - then it is a refusal, not a setting.
|
|
75
|
+
const foreign = foreignPathProblem(path, out);
|
|
76
|
+
if (foreign) throw new Error(foreignPathMessage(foreign));
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// WE APPLY THE FILE TO THE ENVIRONMENT. The file is the SERVICE's configuration, so it outranks what happened to be
|
|
81
|
+
// exported in the shell; explicit command flags are parsed later and outrank both. We return the key names
|
|
82
|
+
// without values: printing setting values into the log is not allowed.
|
|
83
|
+
// VARIABLE SUBSTITUTION LIVES HERE, NOT IN EVERY TOOL. In the local file the settings are written as
|
|
84
|
+
// ${STACK_DATA}/rfq-wallet.json, and they are substituted by the launcher (the stack itself). A tool that does not do this
|
|
85
|
+
// sees literally "${STACK_DATA}" - and the consequences are not only 'wallet not found': by the same literal path it
|
|
86
|
+
// creates an EMPTY quote database and then reports 'the log is empty' on a live contour, that is, the refusal looks like
|
|
87
|
+
// absence of data. Before, the substitution was copied into one tool; a copy is a sign that the place is shared.
|
|
88
|
+
export function substituteEnvVars(value, { dataDir, root }) {
|
|
89
|
+
return String(value)
|
|
90
|
+
.replace(/\$\{STACK_DATA\}/g, dataDir)
|
|
91
|
+
.replace(/\$\{ROOT\}/g, root);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function applyEnvFile(path, target = process.env) {
|
|
95
|
+
const values = readEnvFile(path);
|
|
96
|
+
const dataDir = target.STACK_DATA || join(tmpdir(), "ninsei-stack");
|
|
97
|
+
const root = target.ROOT || process.cwd();
|
|
98
|
+
const substituted = {};
|
|
99
|
+
for (const [k, v] of Object.entries(values)) substituted[k] = substituteEnvVars(v, { dataDir, root });
|
|
100
|
+
// THE SUBSTITUTION MAY BRING A FOREIGN PATH: ${STACK_DATA} expands to the directory of THE machine where it is run.
|
|
101
|
+
// We check after the substitution too - that is exactly how the production case with /opt/ninsei/ surfaced.
|
|
102
|
+
const foreign = foreignPathProblem(path, substituted);
|
|
103
|
+
if (foreign) throw new Error(foreignPathMessage(foreign));
|
|
104
|
+
for (const [k, v] of Object.entries(substituted)) target[k] = v;
|
|
105
|
+
return Object.keys(values);
|
|
106
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// RFQ NODE IDENTITY: the code from the PROVIDER ADDRESS and a human-readable name.
|
|
2
|
+
//
|
|
3
|
+
// THIS IS THE NODE'S RESPONSIBILITY, NOT OUR BACKEND'S. The node derives its code from its address itself, picks the
|
|
4
|
+
// name itself and only sends them in the packet. Our backend accepts and CHECKS the shape, but invents nothing:
|
|
5
|
+
// if we assigned the code or the name, we would be defining the provider's identity instead of him.
|
|
6
|
+
//
|
|
7
|
+
// WHY FROM THE ADDRESS AND NOT FROM THE KEY. A provider's identity is his EVM address: a quote signature is made with an EIP-712
|
|
8
|
+
// wallet key, and the CHAIN checks that the key belongs to the address (maker registry, providerOf). The Ed25519 key
|
|
9
|
+
// stays with the node, but does something else: the Monero halves are derived from it and the market packets are signed with it. The code
|
|
10
|
+
// must follow the one the chain confirms - the address. The former code was computed from the ed25519 key and
|
|
11
|
+
// therefore changed with it, not with the identity (owner's decision: backward compatibility is not needed).
|
|
12
|
+
import { createHash, randomBytes, randomInt } from "node:crypto";
|
|
13
|
+
|
|
14
|
+
// Code shape. Strict: "lp-" and exactly 8 hex in lower case.
|
|
15
|
+
export const PROVIDER_CODE_RE = /^lp-[0-9a-f]{8}$/;
|
|
16
|
+
|
|
17
|
+
// A human-readable name shorter than this limit: long names break tables and logs.
|
|
18
|
+
export const DISPLAY_NAME_MAX = 48;
|
|
19
|
+
|
|
20
|
+
// PROVIDER CODE. Computed from the provider's EVM ADDRESS (lower case, with 0x), not from the ed25519 key.
|
|
21
|
+
//
|
|
22
|
+
// Why deterministic and from one address: the code must be THE SAME everywhere the maker is seen (decision 14),
|
|
23
|
+
// and must not change when the node restarts. The address IS the identity, so the code survives a key change and does not
|
|
24
|
+
// depend on which of the accepted keys signed the quote this time.
|
|
25
|
+
export function providerCode(address) {
|
|
26
|
+
const a = String(address == null ? "" : address).trim().toLowerCase();
|
|
27
|
+
if (!/^0x[0-9a-f]{40}$/.test(a)) {
|
|
28
|
+
throw new Error("provider code is derived from an EVM address, got " + JSON.stringify(String(address).slice(0, 24)));
|
|
29
|
+
}
|
|
30
|
+
return "lp-" + createHash("sha256").update(a).digest("hex").slice(0, 8);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// A RANDOM NAME - for the case when no name is set in the config. The node must not stay nameless: in market
|
|
34
|
+
// view a provider has to be called something for a human. The alphabet is Latin simply because this is a generated name;
|
|
35
|
+
// a manually set one may be anything - identity is defined by the code, not by the name.
|
|
36
|
+
const WORDS = ["north", "harbor", "lumen", "cedar", "atlas", "quartz", "verde", "nova", "orbit", "delta"];
|
|
37
|
+
|
|
38
|
+
export function randomDisplayName() {
|
|
39
|
+
// randomInt, not a modulo of random bytes: 65536 % 10 leaves six words one draw more likely than the other
|
|
40
|
+
// four. The difference is invisible in a display name, but a biased draw has no reason to exist at all.
|
|
41
|
+
const word = WORDS[randomInt(WORDS.length)];
|
|
42
|
+
return word + "-" + randomBytes(2).toString("hex");
|
|
43
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Signing the quote packet of the RFQ node. Ed25519, canonical packet.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS FILE IS COPIED AND NOT TAKEN FROM OUR SIGNER APPLICATION. The node runs AT THE PROVIDER: he has no
|
|
4
|
+
// signer application of ours, and must not. An application that pulls code from a neighbouring directory is not
|
|
5
|
+
// standalone - and the node must start on its own.
|
|
6
|
+
//
|
|
7
|
+
// THE RISK OF THE THREE IMPLEMENTATIONS DIVERGING (the feeder, rfq, the interface backend) IS CLOSED NOT BY COMMON CODE, BUT BY A CHECK.
|
|
8
|
+
// the check on our side signs the same packet with both senders and confirms it with our
|
|
9
|
+
// backend, and also compares the signatures against each other. They cannot diverge unnoticed.
|
|
10
|
+
//
|
|
11
|
+
// Canonical form: all fields except signature, keys sorted, no spaces. The order of serialisation is part of
|
|
12
|
+
// the contract: if it diverges, the signatures stop matching for everyone at once.
|
|
13
|
+
|
|
14
|
+
import { generateKeyPairSync, sign as nodeSign, verify as nodeVerify, createPrivateKey, createPublicKey } from "node:crypto";
|
|
15
|
+
import { readFileSync, writeFileSync, chmodSync, existsSync, statSync, mkdirSync } from "node:fs";
|
|
16
|
+
import { dirname } from "node:path";
|
|
17
|
+
|
|
18
|
+
// The canonical form of the packet: the signature is excluded, the keys are sorted, there are no spaces.
|
|
19
|
+
export function canonical(payload) {
|
|
20
|
+
const { signature, ...rest } = payload || {};
|
|
21
|
+
const sortKeys = (v) => {
|
|
22
|
+
if (Array.isArray(v)) return v.map(sortKeys);
|
|
23
|
+
if (v && typeof v === "object") {
|
|
24
|
+
return Object.keys(v).sort().reduce((acc, k) => {
|
|
25
|
+
acc[k] = sortKeys(v[k]);
|
|
26
|
+
return acc;
|
|
27
|
+
}, {});
|
|
28
|
+
}
|
|
29
|
+
return v;
|
|
30
|
+
};
|
|
31
|
+
return JSON.stringify(sortKeys(rest));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function signPayload(payload, privateKeyPem, keyId) {
|
|
35
|
+
// IMPORTANT: the FINAL packet is signed, already including keyId. Otherwise the signature is computed over one set of
|
|
36
|
+
// bytes while the check is over another (the check sees keyId in the packet), and one's own signature does not match.
|
|
37
|
+
// Verified by a round-trip test: before this fix 'own packet' gave false.
|
|
38
|
+
const pkg = { ...payload, keyId };
|
|
39
|
+
const signature = nodeSign(null, Buffer.from(canonical(pkg), "utf8"), createPrivateKey(privateKeyPem)).toString("base64");
|
|
40
|
+
return { ...pkg, signature };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The check is needed by the interface backend when it turns signature checking on. It is not called now,
|
|
44
|
+
// but it lies next to the signing: signing and checking must be computed the same way, otherwise they diverge.
|
|
45
|
+
export function verifyPayload(payload, publicKeyPem) {
|
|
46
|
+
const { signature, ...rest } = payload || {};
|
|
47
|
+
if (!signature) return false;
|
|
48
|
+
try {
|
|
49
|
+
return nodeVerify(null, Buffer.from(canonical(rest), "utf8"), createPublicKey(publicKeyPem), Buffer.from(signature, "base64"));
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The public key path: beside the private one, by the same name without ".key" and with ".pub" at the end.
|
|
56
|
+
// <path>.key -> <path>.pub
|
|
57
|
+
// That way the operator does not look for the file by eye and does not copy the key from the terminal output: both files appear together.
|
|
58
|
+
export function publicKeyPath(keyFile) {
|
|
59
|
+
return String(keyFile).replace(/\.key$/i, "") + ".pub";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Key pair generation: the private one goes to a file with permissions 0600, the public one - TO A FILE BESIDE it and to the output
|
|
63
|
+
// (the public one is not a secret, it is handy both to print and to keep as a file; the private one NEVER gets into the log).
|
|
64
|
+
//
|
|
65
|
+
// Overwriting an existing key is possible ONLY explicitly (force). Otherwise - a refusal: to silently wipe someone else's key
|
|
66
|
+
// means breaking the provider, and he learns about it by rejected quotes rather than by a message.
|
|
67
|
+
export function generateKeyFiles({ keyFile, force = false } = {}) {
|
|
68
|
+
if (!keyFile) throw new Error("key file is not specified");
|
|
69
|
+
if (existsSync(keyFile) && !force) {
|
|
70
|
+
const e = new Error("key already exists: " + keyFile + " - it was NOT overwritten");
|
|
71
|
+
e.code = "KEY_EXISTS";
|
|
72
|
+
throw e;
|
|
73
|
+
}
|
|
74
|
+
// We create the keys directory ourselves: the keys lie apart from the code (keys/), and it may be absent on a fresh install.
|
|
75
|
+
try { mkdirSync(dirname(keyFile), { recursive: true }); } catch { /* it is already there - not a reason to fall */ }
|
|
76
|
+
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
|
|
77
|
+
const priv = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
|
78
|
+
const pub = publicKey.export({ type: "spki", format: "pem" }).toString();
|
|
79
|
+
writeFileSync(keyFile, priv, { mode: 0o600 });
|
|
80
|
+
try { chmodSync(keyFile, 0o600); } catch { /* on some file systems there are no permissions - not a reason to fall */ }
|
|
81
|
+
const pubFile = publicKeyPath(keyFile);
|
|
82
|
+
writeFileSync(pubFile, pub, { mode: 0o644 });
|
|
83
|
+
return { keyFile, pubFile, publicKeyPem: pub };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Reading a key with a permission check: if the file is available to anyone besides the owner - a refusal, not silent work.
|
|
87
|
+
// THE RAW KEY BYTES - the material for deriving the halves for orders. For an ed25519 key this is exactly
|
|
88
|
+
// 32 bytes of a uniform seed: HMAC and hashing over them give a result indistinguishable from random.
|
|
89
|
+
//
|
|
90
|
+
// AN IMPORTANT CONSEQUENCE ONE MUST KNOW: the derivation requires the RAW key in the process. So a node whose
|
|
91
|
+
// signing key has moved into hardware (HSM, a hardware wallet) will not be able to issue an order quote - and this is
|
|
92
|
+
// an honest limitation, not a breakdown: while the key is there, the node is both identified and serves such quotes.
|
|
93
|
+
export function privateKeySeedBytes(privateKeyPem) {
|
|
94
|
+
const ko = createPrivateKey(privateKeyPem);
|
|
95
|
+
const jwk = ko.export({ format: "jwk" });
|
|
96
|
+
if (!jwk || !jwk.d) throw new Error("could not get raw bytes from the signing key (an ed25519 key is required)");
|
|
97
|
+
const bytes = Buffer.from(jwk.d, "base64url");
|
|
98
|
+
if (bytes.length < 16) throw new Error("signing key is shorter than 16 bytes: unusable for half derivation");
|
|
99
|
+
return new Uint8Array(bytes);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function loadPrivateKey(keyFile) {
|
|
103
|
+
const st = statSync(keyFile);
|
|
104
|
+
// The permission check - only where permissions exist. On Windows there are no POSIX mode bits (the file is always seen
|
|
105
|
+
// as 666), and access is defined by ACLs, not by these bits: refusing on Windows would mean not letting the
|
|
106
|
+
// service start locally at all. This is not a weakening - on Linux the check works as it worked.
|
|
107
|
+
if (process.platform !== "win32" && (st.mode & 0o077) !== 0) {
|
|
108
|
+
throw new Error("key file is accessible to others: " + keyFile + " (mode 0600 is required)");
|
|
109
|
+
}
|
|
110
|
+
return readFileSync(keyFile, "utf8");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// THE NODE'S PUBLIC KEY: THE ONE IT SIGNS WITH, AND NOT THE CONTENT OF THE FILE BESIDE IT (partner request to the node).
|
|
114
|
+
//
|
|
115
|
+
// WHY FROM THE LOADED KEY AND NOT FROM THE .pub FILE. The signature is made by the key the node read at start
|
|
116
|
+
// (loadPrivateKey), so ITS public key must go out: the file beside it could be left from a former
|
|
117
|
+
// key, come from a backup or be substituted. A reader who took the key from the file would check the signature
|
|
118
|
+
// with the wrong key and would take a live node for a foreign one.
|
|
119
|
+
//
|
|
120
|
+
// WHY THE FILE IS STILL READ. A divergence of the file and the working key is a STATE, and it is NAMED
|
|
121
|
+
// (nodePublicKeyFileMatches: false), not hidden: other tools and a human read the file, and by it they
|
|
122
|
+
// judge the node. The file is re-read on EVERY read, not remembered at start: a file substituted beside the
|
|
123
|
+
// key must be visible at once, not at the next node start.
|
|
124
|
+
export function publicKeyBase64From(publicKeyPem) {
|
|
125
|
+
return createPublicKey(publicKeyPem).export({ type: "spki", format: "der" }).toString("base64");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// The SIGNING public key from a private PEM - with the same SPKI DER export as in the .pub file.
|
|
129
|
+
// createPublicKey accepts the PRIVATE PEM itself and derives the public key from it; a repeated call on a KeyObject
|
|
130
|
+
// (createPublicKey(createPublicKey(pem))) refuses - "expected private", so there is NO nesting here.
|
|
131
|
+
export function signingPublicKeyBase64(privateKeyPem) {
|
|
132
|
+
return publicKeyBase64From(privateKeyPem);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The content of the .pub file in the same form (base64 of SPKI DER), or the reason why it is absent.
|
|
136
|
+
export function publicKeyFileBase64(pubFile) {
|
|
137
|
+
if (!pubFile) return { key: null, why: "the path of the public key file is not known" };
|
|
138
|
+
if (!existsSync(pubFile)) return { key: null, why: "the public key file is missing: " + pubFile };
|
|
139
|
+
try { return { key: publicKeyBase64From(readFileSync(pubFile, "utf8")), why: null }; }
|
|
140
|
+
catch (e) { return { key: null, why: "the public key file could not be read: " + String((e && e.message) || e).slice(0, 140) }; }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// THE REPORT FOR THE nodePublicKey FIELD IN GET /v1/info. The shape is assembled by ONE place - here; the listener only
|
|
144
|
+
// returns it (makerApi.mjs), and the node passes its key (index.mjs), so the rule does not multiply.
|
|
145
|
+
// key - the public key the node SIGNS with; file - what lies in the file beside it; matches - whether they
|
|
146
|
+
// match (true/false, and null means 'nothing to compare with'); why - the reasons of the fields left null.
|
|
147
|
+
export function nodePublicKeyReport({ privateKeyPem = null, keyFile = null, pubFile = null } = {}) {
|
|
148
|
+
const pubPath = pubFile || (keyFile ? publicKeyPath(keyFile) : null);
|
|
149
|
+
const file = publicKeyFileBase64(pubPath);
|
|
150
|
+
const why = {};
|
|
151
|
+
if (file.why) why.nodePublicKeyFile = file.why;
|
|
152
|
+
if (!privateKeyPem) {
|
|
153
|
+
why.nodePublicKey = "the node has no signing key it could read" + (keyFile ? " (" + keyFile + ")" : "") + ", so the key it signs with is unknown";
|
|
154
|
+
return { key: null, file: file.key, matches: null, why };
|
|
155
|
+
}
|
|
156
|
+
let key = null;
|
|
157
|
+
try { key = signingPublicKeyBase64(privateKeyPem); }
|
|
158
|
+
catch (e) { why.nodePublicKey = "the signing key could not be read: " + String((e && e.message) || e).slice(0, 140); }
|
|
159
|
+
// A DIVERGENCE IS A NAMED STATE, NOT SILENCE: false is the name of the state, and both keys stand side by side,
|
|
160
|
+
// so that one can sort it out without third-party tools. The node works with ITS OWN key meanwhile - it is loaded and
|
|
161
|
+
// the packets are signed with it; the file does not replace the signature.
|
|
162
|
+
const matches = key === null || file.key === null ? null : key === file.key;
|
|
163
|
+
return { key, file: file.key, matches, why };
|
|
164
|
+
}
|
package/src/public/order.d.ts
CHANGED
|
@@ -159,3 +159,15 @@ export declare function signQuoteDigest(request: {
|
|
|
159
159
|
* verified at all. Null is a refusal for the caller to name, never consent.
|
|
160
160
|
*/
|
|
161
161
|
export declare function recoverQuoteKey(request: { readonly crypto: QuoteCrypto; readonly quote: OrderQuote }): string | null;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Names of the signed-set fields the quote does not carry, bound to the SDK's own primitives: the same answer as
|
|
165
|
+
* `quoteMissingFields`, offered so a verifier does not have to pass crypto it already trusts twice.
|
|
166
|
+
*/
|
|
167
|
+
export declare function orderQuoteMissingFields(quote: Partial<OrderQuote> | null | undefined): string[];
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The address that signed the quote, recovered with the SDK's own primitives, or null when the quote cannot be
|
|
171
|
+
* verified at all. Null is a refusal for the caller to name, never consent.
|
|
172
|
+
*/
|
|
173
|
+
export declare function recoverOrderQuoteKey(quote: OrderQuote): string | null;
|
package/src/public/order.mjs
CHANGED
|
@@ -20,3 +20,8 @@ export {
|
|
|
20
20
|
recoverQuoteKey,
|
|
21
21
|
signQuoteDigest,
|
|
22
22
|
} from "../quoteEip712Spec.mjs";
|
|
23
|
+
|
|
24
|
+
// THE BOUND VERIFICATION HELPERS. The spec above is handed crypto by the caller; these two bind it to the SDK's
|
|
25
|
+
// own primitives, so a consumer verifies a maker's quote without assembling the binding itself. Verification
|
|
26
|
+
// only: the provider's signing key never enters this package.
|
|
27
|
+
export { orderQuoteMissingFields, recoverOrderQuoteKey } from "../quoteEip712.mjs";
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// DECLARATIONS FOR THE `./provider` ENTRY: the node-side helpers, Node only.
|
|
2
|
+
//
|
|
3
|
+
// These declarations name what `src/public/provider.mjs` re-exports: identity, signing and env files. Every
|
|
4
|
+
// signature is copied from the modules the barrel re-exports (`src/provider/identity.mjs`, `sign.mjs`,
|
|
5
|
+
// `envFile.mjs`), and a symbol the barrel does not re-export is not declared here.
|
|
6
|
+
//
|
|
7
|
+
// NOT FOR A BROWSER. These functions use `node:crypto`, `node:fs` and `node:path`; a page must import from the
|
|
8
|
+
// browser entries instead.
|
|
9
|
+
|
|
10
|
+
/** The shape of a provider code: `lp-` and eight lowercase hex digits, derived from the provider's address. */
|
|
11
|
+
export declare const PROVIDER_CODE_RE: RegExp;
|
|
12
|
+
|
|
13
|
+
/** The longest display name a provider may set. */
|
|
14
|
+
export declare const DISPLAY_NAME_MAX: number;
|
|
15
|
+
|
|
16
|
+
/** The provider code of an address: stable for the address, independent of any key. Throws on a malformed address. */
|
|
17
|
+
export declare function providerCode(address: string): string;
|
|
18
|
+
|
|
19
|
+
/** A display name the operator may keep or replace: a word and four hex digits. */
|
|
20
|
+
export declare function randomDisplayName(): string;
|
|
21
|
+
|
|
22
|
+
/** The canonical JSON a signature covers: keys sorted at every level, so two writers agree on the bytes. */
|
|
23
|
+
export declare function canonical(payload: unknown): string;
|
|
24
|
+
|
|
25
|
+
/** A signed package: the payload's fields plus the detached signature and the key identity that made it. */
|
|
26
|
+
export interface SignedPayload {
|
|
27
|
+
readonly [field: string]: unknown;
|
|
28
|
+
readonly signature: string;
|
|
29
|
+
readonly keyId?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Signs the canonical form of the payload with a PEM private key. */
|
|
33
|
+
export declare function signPayload(payload: Record<string, unknown>, privateKeyPem: string, keyId?: string): SignedPayload;
|
|
34
|
+
|
|
35
|
+
/** Verifies a signed package against a PEM public key. False means refused, never "probably fine". */
|
|
36
|
+
export declare function verifyPayload(pkg: SignedPayload, publicKeyPem: string): boolean;
|
|
37
|
+
|
|
38
|
+
/** The public key file that belongs to a private key file: `x.key` becomes `x.pub`. */
|
|
39
|
+
export declare function publicKeyPath(keyFile: string): string;
|
|
40
|
+
|
|
41
|
+
/** Creates a key pair on disk and writes the public half beside it. Refuses to overwrite unless `force`. */
|
|
42
|
+
export declare function generateKeyFiles(options?: { readonly keyFile?: string; readonly force?: boolean }): {
|
|
43
|
+
readonly key?: string;
|
|
44
|
+
readonly file?: string;
|
|
45
|
+
readonly why?: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** The raw seed bytes of a PEM private key, for consumers that must not keep the PEM. */
|
|
49
|
+
export declare function privateKeySeedBytes(privateKeyPem: string): Uint8Array;
|
|
50
|
+
|
|
51
|
+
/** Reads a PEM private key from a file. */
|
|
52
|
+
export declare function loadPrivateKey(keyFile: string): string;
|
|
53
|
+
|
|
54
|
+
/** The base64 SPKI form of a public key, from either a public or a private PEM. */
|
|
55
|
+
export declare function publicKeyBase64From(publicKeyPem: string): string;
|
|
56
|
+
|
|
57
|
+
/** The base64 public key that belongs to a private key. */
|
|
58
|
+
export declare function signingPublicKeyBase64(privateKeyPem: string): string;
|
|
59
|
+
|
|
60
|
+
/** The base64 public key held in a public key file, or a named reason when it is missing. */
|
|
61
|
+
export declare function publicKeyFileBase64(pubFile: string): { readonly key: string | null; readonly why?: string };
|
|
62
|
+
|
|
63
|
+
/** What the node reports about its own key: the key, its file, and whether the two halves agree. */
|
|
64
|
+
export declare function nodePublicKeyReport(options?: {
|
|
65
|
+
readonly privateKeyPem?: string | null;
|
|
66
|
+
readonly keyFile?: string | null;
|
|
67
|
+
readonly pubFile?: string | null;
|
|
68
|
+
}): { readonly key: string | null; readonly file?: string | null; readonly matches?: boolean | null; readonly why?: string };
|
|
69
|
+
|
|
70
|
+
/** The settings files looked up, in order, when none is named. */
|
|
71
|
+
export declare const ENV_CANDIDATES: readonly string[];
|
|
72
|
+
|
|
73
|
+
/** The first settings file that exists under `cwd`, or null. */
|
|
74
|
+
export declare function findEnvFile(cwd: string, candidates?: readonly string[]): string | null;
|
|
75
|
+
|
|
76
|
+
/** A Windows path found in a settings file on a non-Windows system, or null when the file is usable. */
|
|
77
|
+
export declare function foreignPathProblem(
|
|
78
|
+
file: string,
|
|
79
|
+
values: Record<string, string>,
|
|
80
|
+
platform?: string,
|
|
81
|
+
): { readonly file: string; readonly key: string; readonly value: string } | null;
|
|
82
|
+
|
|
83
|
+
/** The sentence that explains a foreign path to an operator. */
|
|
84
|
+
export declare function foreignPathMessage(problem: { readonly file: string; readonly key: string; readonly value: string }): string;
|
|
85
|
+
|
|
86
|
+
/** Reads a settings file into a plain object of its values. */
|
|
87
|
+
export declare function readEnvFile(path: string): Record<string, string>;
|
|
88
|
+
|
|
89
|
+
/** Substitutes `${STACK_DATA}` and `${ROOT}` in a setting value. */
|
|
90
|
+
export declare function substituteEnvVars(value: unknown, paths: { readonly dataDir: string; readonly root: string }): string;
|
|
91
|
+
|
|
92
|
+
/** Applies a settings file to a target object (the process environment by default) and returns the names set. */
|
|
93
|
+
export declare function applyEnvFile(path: string, target?: Record<string, string | undefined>): string[];
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// THE PROVIDER SIDE, NODE ONLY: identity, signing and env files for the machine that runs the node.
|
|
2
|
+
//
|
|
3
|
+
// Separate entry, like ./adapters, because these helpers reach the filesystem and node:crypto. A browser
|
|
4
|
+
// consumer must never pull them in, and a Node consumer gets them with one import.
|
|
5
|
+
//
|
|
6
|
+
// WHY THEY LIVE HERE. The demo's backend verifies a maker's quote and builds an allow list from the provider's
|
|
7
|
+
// key; the node signs tickets with the same key. One implementation in the package means the identity rule and
|
|
8
|
+
// the key-path rule are not re-derived per consumer.
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
PROVIDER_CODE_RE,
|
|
12
|
+
DISPLAY_NAME_MAX,
|
|
13
|
+
providerCode,
|
|
14
|
+
randomDisplayName,
|
|
15
|
+
} from "../provider/identity.mjs";
|
|
16
|
+
|
|
17
|
+
export {
|
|
18
|
+
canonical,
|
|
19
|
+
signPayload,
|
|
20
|
+
verifyPayload,
|
|
21
|
+
publicKeyPath,
|
|
22
|
+
generateKeyFiles,
|
|
23
|
+
privateKeySeedBytes,
|
|
24
|
+
loadPrivateKey,
|
|
25
|
+
publicKeyBase64From,
|
|
26
|
+
signingPublicKeyBase64,
|
|
27
|
+
publicKeyFileBase64,
|
|
28
|
+
nodePublicKeyReport,
|
|
29
|
+
} from "../provider/sign.mjs";
|
|
30
|
+
|
|
31
|
+
export {
|
|
32
|
+
ENV_CANDIDATES,
|
|
33
|
+
findEnvFile,
|
|
34
|
+
foreignPathProblem,
|
|
35
|
+
foreignPathMessage,
|
|
36
|
+
readEnvFile,
|
|
37
|
+
substituteEnvVars,
|
|
38
|
+
applyEnvFile,
|
|
39
|
+
} from "../provider/envFile.mjs";
|
package/src/version.mjs
CHANGED
|
@@ -6,4 +6,4 @@
|
|
|
6
6
|
// tools/sync-version.mjs. Do not edit it by hand - set the version in package.json and run `npm run sync:version`.
|
|
7
7
|
// The `version` check in tools/check.mjs refuses a mismatch, and refuses a runtime read of package.json here.
|
|
8
8
|
|
|
9
|
-
export const SDK_VERSION = "0.43.
|
|
9
|
+
export const SDK_VERSION = "0.43.3";
|