@ninsei-labs/sdk 0.53.0 → 0.54.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/CHANGELOG.md +28 -1
- package/README.md +9 -4
- package/index.d.ts +119 -5
- package/package.json +13 -1
- package/src/index.mjs +2 -0
- package/src/public/recovery.d.ts +14 -3
- package/src/public/recovery.mjs +1 -0
- package/src/quotes.mjs +245 -1
- package/src/recovery-key.mjs +49 -9
- package/src/relayFeatures.mjs +191 -0
- package/src/relayFirmClient.mjs +86 -0
- package/src/relayQuote.mjs +209 -0
- package/src/relayRequest.mjs +188 -0
- package/src/relayTerms.mjs +145 -0
- package/src/relayView.mjs +110 -0
- package/src/swaps.mjs +199 -2
- package/src/version.mjs +1 -1
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// ONE FIRM REQUEST: THE TERMS, THE CLIENT HALF AND THE ENCRYPTED VIEW MATERIAL.
|
|
2
|
+
//
|
|
3
|
+
// The relay signs the payload as the exact UTF-8 string it forwards, so this module serialises it once. Every
|
|
4
|
+
// field that the node or the relay checks is taken from the caller's terms or from the signed features; nothing
|
|
5
|
+
// is filled in from a second source.
|
|
6
|
+
//
|
|
7
|
+
// The builder does not create the client half: the caller has already built one order side and proved it in
|
|
8
|
+
// this context. It normalises the proof's spellings and refuses a half whose point is not the one in its proof.
|
|
9
|
+
import { SdkError } from "./errors.mjs";
|
|
10
|
+
import { ed25519, keccak256 } from "./primitives.mjs";
|
|
11
|
+
import { verifyNodeFeatures } from "./relayFeatures.mjs";
|
|
12
|
+
import { relayViewInfo, sealViewEnvelope } from "./relayView.mjs";
|
|
13
|
+
import { firmTermsDigest, firmTermsMissingFields } from "./relayTerms.mjs";
|
|
14
|
+
|
|
15
|
+
const hexToBytes = (value, field, width) => {
|
|
16
|
+
const text = String(value === undefined || value === null ? "" : value).replace(/^0x/, "");
|
|
17
|
+
if (!new RegExp("^[0-9a-fA-F]{" + width * 2 + "}$").test(text)) {
|
|
18
|
+
throw new SdkError("bad-input", { field });
|
|
19
|
+
}
|
|
20
|
+
const out = new Uint8Array(width);
|
|
21
|
+
for (let i = 0; i < width; i += 1) out[i] = parseInt(text.slice(i * 2, i * 2 + 2), 16);
|
|
22
|
+
return out;
|
|
23
|
+
};
|
|
24
|
+
const hex = (bytes) => {
|
|
25
|
+
let out = "0x";
|
|
26
|
+
for (const byte of bytes) out += byte.toString(16).padStart(2, "0");
|
|
27
|
+
return out;
|
|
28
|
+
};
|
|
29
|
+
const normHex = (value, width, field) => hex(hexToBytes(value, field, width));
|
|
30
|
+
const scalar = (value, field) => {
|
|
31
|
+
let n = null;
|
|
32
|
+
try { n = BigInt(value); } catch { n = null; }
|
|
33
|
+
if (n === null || n <= 0n || n >= ed25519.Point.Fn.ORDER) throw new SdkError("bad-input", { field });
|
|
34
|
+
return n;
|
|
35
|
+
};
|
|
36
|
+
const scalarBytes = (value, field) => {
|
|
37
|
+
let n = scalar(value, field);
|
|
38
|
+
const out = new Uint8Array(32);
|
|
39
|
+
for (let i = 31; i >= 0 && n > 0n; i -= 1) { out[i] = Number(n & 0xffn); n >>= 8n; }
|
|
40
|
+
return out;
|
|
41
|
+
};
|
|
42
|
+
const pointOf = (value, field) => hex(ed25519.Point.BASE.multiply(scalar(value, field)).toBytes());
|
|
43
|
+
const assertPoint = (value, field) => {
|
|
44
|
+
const bytes = hexToBytes(value, field, 32);
|
|
45
|
+
try { ed25519.Point.fromHex(hex(bytes).slice(2)); } catch { throw new SdkError("bad-input", { field }); }
|
|
46
|
+
return hex(bytes);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const PROOF_FIELDS = ["XA", "XB", "proofs"];
|
|
50
|
+
function normaliseProof(proof) {
|
|
51
|
+
if (!proof || typeof proof !== "object") throw new SdkError("bad-input", { field: "clientHalf.proof" });
|
|
52
|
+
for (const field of PROOF_FIELDS) {
|
|
53
|
+
if (proof[field] === undefined || proof[field] === null || proof[field] === "") {
|
|
54
|
+
throw new SdkError("bad-input", { field: "clientHalf.proof." + field });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (!Array.isArray(proof.proofs) || proof.proofs.length !== 253) {
|
|
58
|
+
throw new SdkError("bad-input", { field: "clientHalf.proof.proofs" });
|
|
59
|
+
}
|
|
60
|
+
const xa = normHex(proof.XA, 33, "clientHalf.proof.XA");
|
|
61
|
+
const xb = normHex(proof.XB, 32, "clientHalf.proof.XB");
|
|
62
|
+
const proofs = proof.proofs.map((entry, index) => {
|
|
63
|
+
if (!entry || typeof entry !== "object" || !entry.sig) {
|
|
64
|
+
throw new SdkError("bad-input", { field: "clientHalf.proof.proofs." + index });
|
|
65
|
+
}
|
|
66
|
+
const sig = {};
|
|
67
|
+
for (const field of ["eA", "eB", "a0", "a1", "b0", "b1"]) {
|
|
68
|
+
sig[field] = normHex(entry.sig[field], 32, "clientHalf.proof.proofs." + index + ".sig." + field);
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
cA: normHex(entry.cA, 33, "clientHalf.proof.proofs." + index + ".cA"),
|
|
72
|
+
cB: normHex(entry.cB, 32, "clientHalf.proof.proofs." + index + ".cB"),
|
|
73
|
+
sig,
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
return { XA: xa, XB: xb, proofs };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Build the exact request payload.
|
|
81
|
+
*
|
|
82
|
+
* `features` is the signed public object the relay forwarded; `terms` is the client's request terms (its
|
|
83
|
+
* `featuresHash` may be omitted and is filled from the verified features). `clientHalf` is
|
|
84
|
+
* `{commitHalf, edPoint, proof}`; `nodeViewScalar` and `clientViewScalar` are the two view halves the client
|
|
85
|
+
* chose. On ask only the node scalar is sent; on bid both.
|
|
86
|
+
*/
|
|
87
|
+
export async function buildFirmRequest({
|
|
88
|
+
features, terms, clientHalf, edViewPointClient, edViewPointNode,
|
|
89
|
+
nodeViewScalar, clientViewScalar = null, side = null,
|
|
90
|
+
} = {}) {
|
|
91
|
+
const verdict = verifyNodeFeatures({ features, terms });
|
|
92
|
+
const requestSide = String(side || terms?.side || "");
|
|
93
|
+
if (requestSide !== "ask" && requestSide !== "bid") throw new SdkError("bad-input", { field: "side" });
|
|
94
|
+
if (terms?.side !== undefined && terms.side !== requestSide) throw new SdkError("bad-input", { field: "terms.side" });
|
|
95
|
+
if (!terms || typeof terms !== "object" || !terms.order || typeof terms.order !== "object") {
|
|
96
|
+
throw new SdkError("bad-input", { field: "terms" });
|
|
97
|
+
}
|
|
98
|
+
if (terms.featuresHash !== undefined && terms.featuresHash !== null && terms.featuresHash !== ""
|
|
99
|
+
&& String(terms.featuresHash).toLowerCase() !== verdict.featuresHash) {
|
|
100
|
+
throw new SdkError("quote-refused", { step: "features", why: "hash" });
|
|
101
|
+
}
|
|
102
|
+
const order = terms.order;
|
|
103
|
+
const orderFields = ["chainId", "factory", "locker", "claimer", "amount", "readyBy", "t1", "salt"];
|
|
104
|
+
for (const field of orderFields) {
|
|
105
|
+
if (order[field] === undefined || order[field] === null || order[field] === "") {
|
|
106
|
+
throw new SdkError("bad-input", { field: "terms.order." + field });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (String(order.chainId) !== String(terms.market || "").split(":")[0]) {
|
|
110
|
+
throw new SdkError("bad-input", { field: "terms.market" });
|
|
111
|
+
}
|
|
112
|
+
const half = clientHalf || {};
|
|
113
|
+
const commitHalf = normHex(half.commitHalf, 32, "clientHalf.commitHalf");
|
|
114
|
+
const point = normHex(half.edPoint, 32, "clientHalf.edPoint");
|
|
115
|
+
const proof = normaliseProof(half.proof);
|
|
116
|
+
if (proof.XB !== point) throw new SdkError("bad-input", { field: "clientHalf.edPoint" });
|
|
117
|
+
const maker = normHex(terms.maker, 20, "terms.maker");
|
|
118
|
+
const locker = normHex(order.locker, 20, "terms.order.locker");
|
|
119
|
+
const claimer = normHex(order.claimer, 20, "terms.order.claimer");
|
|
120
|
+
if (locker === claimer) throw new SdkError("bad-input", { field: "terms.order.roles" });
|
|
121
|
+
if (requestSide === "ask") {
|
|
122
|
+
if (claimer !== maker) throw new SdkError("bad-input", { field: "terms.order.claimer" });
|
|
123
|
+
if (String(terms.claimTip) !== "0") throw new SdkError("bad-input", { field: "terms.claimTip" });
|
|
124
|
+
} else if (locker !== maker) {
|
|
125
|
+
throw new SdkError("bad-input", { field: "terms.order.locker" });
|
|
126
|
+
}
|
|
127
|
+
const clientViewPoint = assertPoint(edViewPointClient, "edViewPointClient");
|
|
128
|
+
const nodeViewPoint = assertPoint(edViewPointNode, "edViewPointNode");
|
|
129
|
+
const nodeScalar = scalarBytes(nodeViewScalar, "nodeViewScalar");
|
|
130
|
+
if (pointOf(nodeViewScalar, "nodeViewScalar").toLowerCase() !== nodeViewPoint.toLowerCase()) {
|
|
131
|
+
throw new SdkError("bad-input", { field: "edViewPointNode" });
|
|
132
|
+
}
|
|
133
|
+
let clientScalar = null;
|
|
134
|
+
if (requestSide === "bid") {
|
|
135
|
+
clientScalar = scalarBytes(clientViewScalar, "clientViewScalar");
|
|
136
|
+
if (pointOf(clientViewScalar, "clientViewScalar").toLowerCase() !== clientViewPoint.toLowerCase()) {
|
|
137
|
+
throw new SdkError("bad-input", { field: "edViewPointClient" });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const requestTerms = {
|
|
141
|
+
market: String(terms.market),
|
|
142
|
+
side: requestSide,
|
|
143
|
+
maker: normHex(terms.maker, 20, "terms.maker"),
|
|
144
|
+
registry: normHex(terms.registry, 20, "terms.registry"),
|
|
145
|
+
featuresHash: verdict.featuresHash,
|
|
146
|
+
xmrNetwork: String(terms.xmrNetwork),
|
|
147
|
+
order: {
|
|
148
|
+
chainId: String(order.chainId),
|
|
149
|
+
factory: normHex(order.factory, 20, "terms.order.factory"),
|
|
150
|
+
locker: normHex(order.locker, 20, "terms.order.locker"),
|
|
151
|
+
claimer: normHex(order.claimer, 20, "terms.order.claimer"),
|
|
152
|
+
amount: String(order.amount),
|
|
153
|
+
readyBy: Number(order.readyBy),
|
|
154
|
+
t1: Number(order.t1),
|
|
155
|
+
salt: normHex(order.salt, 32, "terms.order.salt"),
|
|
156
|
+
},
|
|
157
|
+
xmrAmount: String(terms.xmrAmount),
|
|
158
|
+
claimTip: String(terms.claimTip),
|
|
159
|
+
};
|
|
160
|
+
const missing = firmTermsMissingFields(requestTerms);
|
|
161
|
+
if (missing.length) throw new SdkError("bad-input", { field: "terms", missing });
|
|
162
|
+
const termsDigest = hex(firmTermsDigest({ crypto: { keccak256 }, terms: requestTerms }));
|
|
163
|
+
const info = relayViewInfo({
|
|
164
|
+
termsDigest,
|
|
165
|
+
commitHalf,
|
|
166
|
+
edPoint: point,
|
|
167
|
+
edViewPointClient: clientViewPoint,
|
|
168
|
+
edViewPointNode: nodeViewPoint,
|
|
169
|
+
});
|
|
170
|
+
const plaintext = new Uint8Array(requestSide === "ask" ? 32 : 64);
|
|
171
|
+
plaintext.set(nodeScalar, 0);
|
|
172
|
+
if (requestSide === "bid") plaintext.set(clientScalar, 32);
|
|
173
|
+
const viewEnvelope = await sealViewEnvelope({ hpkeKey: verdict.hpkeKey, info, plaintext });
|
|
174
|
+
const payload = JSON.stringify({
|
|
175
|
+
terms: requestTerms,
|
|
176
|
+
clientHalf: { commitHalf, edPoint: point, proof },
|
|
177
|
+
edViewPointClient: clientViewPoint,
|
|
178
|
+
edViewPointNode: nodeViewPoint,
|
|
179
|
+
viewEnvelope,
|
|
180
|
+
});
|
|
181
|
+
return {
|
|
182
|
+
payload,
|
|
183
|
+
terms: requestTerms,
|
|
184
|
+
viewEnvelope,
|
|
185
|
+
edViewPointClient: clientViewPoint,
|
|
186
|
+
edViewPointNode: nodeViewPoint,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// THE EIP-712 TERMS A RELAY FIRM REQUEST SIGNS.
|
|
2
|
+
//
|
|
3
|
+
// The relay forwards the client's exact payload as a string and signs its UTF-8 bytes. The same terms are
|
|
4
|
+
// also the HPKE context: `relayViewInfo` takes this digest, so a ciphertext is bound to the amounts, roles,
|
|
5
|
+
// deadlines and salt in the request rather than to a separately supplied label.
|
|
6
|
+
//
|
|
7
|
+
// The domain is the relay domain, not the factory domain: `{name: "Ninsei Relay", version: "1"}` has no
|
|
8
|
+
// chainId and no verifyingContract. The inner `OrderQuote` keeps its own factory domain; the two signatures
|
|
9
|
+
// are deliberately separate.
|
|
10
|
+
//
|
|
11
|
+
// The type strings and field order are wire format. A rearrangement produces another digest, and the node
|
|
12
|
+
// refuses the request before it decrypts anything.
|
|
13
|
+
import { SdkError } from "./errors.mjs";
|
|
14
|
+
|
|
15
|
+
export const FIRM_DOMAIN_NAME = "Ninsei Relay";
|
|
16
|
+
export const FIRM_DOMAIN_VERSION = "1";
|
|
17
|
+
const DOMAIN_TYPE_PARTS = ["EIP712Domain(", "string name,", "string version)"];
|
|
18
|
+
export const FIRM_DOMAIN_TYPE = DOMAIN_TYPE_PARTS.join("");
|
|
19
|
+
|
|
20
|
+
export const FIRM_ORDER_FIELDS = [
|
|
21
|
+
["chainId", "uint256"],
|
|
22
|
+
["factory", "address"],
|
|
23
|
+
["locker", "address"],
|
|
24
|
+
["claimer", "address"],
|
|
25
|
+
["amount", "uint256"],
|
|
26
|
+
["readyBy", "uint64"],
|
|
27
|
+
["t1", "uint64"],
|
|
28
|
+
["salt", "bytes32"],
|
|
29
|
+
];
|
|
30
|
+
export const FIRM_ORDER_TYPE = "FirmOrder(" + FIRM_ORDER_FIELDS.map(([name, type]) => type + " " + name).join(",") + ")";
|
|
31
|
+
|
|
32
|
+
export const FIRM_TERMS_FIELDS = [
|
|
33
|
+
["market", "string"],
|
|
34
|
+
["side", "string"],
|
|
35
|
+
["maker", "address"],
|
|
36
|
+
["registry", "address"],
|
|
37
|
+
["featuresHash", "bytes32"],
|
|
38
|
+
["xmrNetwork", "string"],
|
|
39
|
+
["order", "FirmOrder"],
|
|
40
|
+
["xmrAmount", "uint256"],
|
|
41
|
+
["claimTip", "uint256"],
|
|
42
|
+
];
|
|
43
|
+
// EIP-712 appends every referenced struct type to the primary type, sorted by name. `FirmOrder` is the only
|
|
44
|
+
// one, so the type hash covers both strings.
|
|
45
|
+
export const FIRM_TERMS_TYPE = "FirmTerms(" + FIRM_TERMS_FIELDS.map(([name, type]) => type + " " + name).join(",") + ")" + FIRM_ORDER_TYPE;
|
|
46
|
+
|
|
47
|
+
const utf8 = (value) => new TextEncoder().encode(String(value));
|
|
48
|
+
const concat = (...arrays) => {
|
|
49
|
+
const total = arrays.reduce((n, a) => n + a.length, 0);
|
|
50
|
+
const out = new Uint8Array(total);
|
|
51
|
+
let at = 0;
|
|
52
|
+
for (const array of arrays) { out.set(array, at); at += array.length; }
|
|
53
|
+
return out;
|
|
54
|
+
};
|
|
55
|
+
const hexToBytes = (value, field) => {
|
|
56
|
+
const text = String(value === undefined || value === null ? "" : value).replace(/^0x/, "");
|
|
57
|
+
if (!/^[0-9a-fA-F]*$/.test(text) || text.length % 2) throw new SdkError("bad-input", { field });
|
|
58
|
+
const out = new Uint8Array(text.length / 2);
|
|
59
|
+
for (let i = 0; i < out.length; i += 1) out[i] = parseInt(text.slice(i * 2, i * 2 + 2), 16);
|
|
60
|
+
return out;
|
|
61
|
+
};
|
|
62
|
+
const number = (value, field, bits) => {
|
|
63
|
+
let n = null;
|
|
64
|
+
try { n = BigInt(String(value === undefined || value === null ? "" : value).trim()); } catch { n = null; }
|
|
65
|
+
if (n === null || n < 0n || n >= (1n << BigInt(bits))) throw new SdkError("bad-input", { field });
|
|
66
|
+
const out = new Uint8Array(32);
|
|
67
|
+
for (let i = 31; i >= 0 && n > 0n; i -= 1) { out[i] = Number(n & 0xffn); n >>= 8n; }
|
|
68
|
+
return out;
|
|
69
|
+
};
|
|
70
|
+
const address = (value, field) => {
|
|
71
|
+
const text = String(value === undefined || value === null ? "" : value).trim();
|
|
72
|
+
if (!/^(0x)?[0-9a-fA-F]{40}$/.test(text)) throw new SdkError("bad-input", { field });
|
|
73
|
+
const out = new Uint8Array(32);
|
|
74
|
+
out.set(hexToBytes(text.replace(/^0x/, ""), field), 12);
|
|
75
|
+
return out;
|
|
76
|
+
};
|
|
77
|
+
const bytes32 = (value, field) => {
|
|
78
|
+
const bytes = hexToBytes(value, field);
|
|
79
|
+
if (bytes.length !== 32) throw new SdkError("bad-input", { field });
|
|
80
|
+
return bytes;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const encodeField = (crypto, type, value, field) => {
|
|
84
|
+
if (type === "string") return crypto.keccak256(utf8(value === undefined || value === null ? "" : value));
|
|
85
|
+
if (type === "address") return address(value, field);
|
|
86
|
+
if (type === "bytes32") return bytes32(value, field);
|
|
87
|
+
if (type === "uint64") return number(value, field, 64);
|
|
88
|
+
if (type === "uint256") return number(value, field, 256);
|
|
89
|
+
throw new SdkError("bad-input", { field });
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const structHash = (crypto, typeString, fields, value, prefix) => {
|
|
93
|
+
const parts = [crypto.keccak256(utf8(typeString))];
|
|
94
|
+
for (const [field, type] of fields) {
|
|
95
|
+
if (type === "FirmOrder") {
|
|
96
|
+
const order = value && value.order;
|
|
97
|
+
if (!order || typeof order !== "object") throw new SdkError("bad-input", { field: prefix + ".order" });
|
|
98
|
+
parts.push(firmOrderHash(crypto, order));
|
|
99
|
+
} else {
|
|
100
|
+
parts.push(encodeField(crypto, type, value ? value[field] : undefined, prefix + "." + field));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return crypto.keccak256(concat(...parts));
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export function firmOrderHash(crypto, order) {
|
|
107
|
+
return structHash(crypto, FIRM_ORDER_TYPE, FIRM_ORDER_FIELDS, order, "terms.order");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function firmTermsDomainSeparator(crypto) {
|
|
111
|
+
return crypto.keccak256(concat(
|
|
112
|
+
crypto.keccak256(utf8(FIRM_DOMAIN_TYPE)),
|
|
113
|
+
crypto.keccak256(utf8(FIRM_DOMAIN_NAME)),
|
|
114
|
+
crypto.keccak256(utf8(FIRM_DOMAIN_VERSION)),
|
|
115
|
+
));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function firmTermsStructHash({ crypto, terms } = {}) {
|
|
119
|
+
if (!crypto || typeof crypto.keccak256 !== "function") throw new SdkError("bad-input", { field: "crypto" });
|
|
120
|
+
return structHash(crypto, FIRM_TERMS_TYPE, FIRM_TERMS_FIELDS, terms, "terms");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function firmTermsDigest({ crypto, terms } = {}) {
|
|
124
|
+
if (!crypto || typeof crypto.keccak256 !== "function") throw new SdkError("bad-input", { field: "crypto" });
|
|
125
|
+
return crypto.keccak256(concat(Uint8Array.from([0x19, 0x01]), firmTermsDomainSeparator(crypto), firmTermsStructHash({ crypto, terms })));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Absent fields are named, not treated as zero. The relay client builds a request only from a complete set.
|
|
129
|
+
export function firmTermsMissingFields(terms) {
|
|
130
|
+
const missing = [];
|
|
131
|
+
if (!terms || typeof terms !== "object") return ["terms"];
|
|
132
|
+
for (const [name] of FIRM_TERMS_FIELDS) {
|
|
133
|
+
if (name === "order") {
|
|
134
|
+
if (!terms.order || typeof terms.order !== "object") missing.push("order");
|
|
135
|
+
else for (const [field] of FIRM_ORDER_FIELDS) {
|
|
136
|
+
const value = terms.order[field];
|
|
137
|
+
if (value === undefined || value === null || value === "") missing.push("order." + field);
|
|
138
|
+
}
|
|
139
|
+
} else {
|
|
140
|
+
const value = terms[name];
|
|
141
|
+
if (value === undefined || value === null || value === "") missing.push(name);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return missing;
|
|
145
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// THE VIEW MATERIAL A RELAY REQUEST CARRIES.
|
|
2
|
+
//
|
|
3
|
+
// The relay `firm/1` exchange does not send a Monero view scalar in clear text. The client chooses the view
|
|
4
|
+
// halves, keeps them in its recovery material, and encrypts only the node's share to the X25519 key in the
|
|
5
|
+
// signed public features. The profile is fixed by the relay wire spec: HPKE RFC 9180 base mode, DHKEM(X25519,
|
|
6
|
+
// HKDF-SHA256), HKDF-SHA256 and ChaCha20-Poly1305.
|
|
7
|
+
//
|
|
8
|
+
// This module owns the profile, not the request shape. It names one encryption and one decryption, both taking
|
|
9
|
+
// the application `info` as bytes, so the caller cannot accidentally use a different suite or a different
|
|
10
|
+
// interpretation of the context. The relay client builds `info` with `relayViewInfo`.
|
|
11
|
+
import { CipherSuite, DhkemX25519HkdfSha256, HkdfSha256 } from "@hpke/core";
|
|
12
|
+
import { Chacha20Poly1305 } from "@hpke/chacha20poly1305";
|
|
13
|
+
import { SdkError } from "./errors.mjs";
|
|
14
|
+
|
|
15
|
+
const utf8 = (value) => new TextEncoder().encode(String(value));
|
|
16
|
+
|
|
17
|
+
const bytesOf = (value) => {
|
|
18
|
+
if (value instanceof Uint8Array) return value;
|
|
19
|
+
if (value instanceof ArrayBuffer) return new Uint8Array(value);
|
|
20
|
+
throw new SdkError("bad-input", { field: "viewEnvelope" });
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const bytesToHex = (bytes) => {
|
|
24
|
+
let out = "0x";
|
|
25
|
+
for (const byte of bytesOf(bytes)) out += byte.toString(16).padStart(2, "0");
|
|
26
|
+
return out;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const hexToBytes = (value, width, field) => {
|
|
30
|
+
const text = String(value === undefined || value === null ? "" : value).replace(/^0x/, "");
|
|
31
|
+
if (!new RegExp("^[0-9a-fA-F]{" + (width * 2) + "}$").test(text)) {
|
|
32
|
+
throw new SdkError("bad-input", { field });
|
|
33
|
+
}
|
|
34
|
+
const out = new Uint8Array(width);
|
|
35
|
+
for (let i = 0; i < width; i += 1) out[i] = parseInt(text.slice(i * 2, i * 2 + 2), 16);
|
|
36
|
+
return out;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const suite = () => new CipherSuite({
|
|
40
|
+
kem: new DhkemX25519HkdfSha256(),
|
|
41
|
+
kdf: new HkdfSha256(),
|
|
42
|
+
aead: new Chacha20Poly1305(),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// The relay binds encryption to the exact terms, the client half and both view points. The prefix and the
|
|
46
|
+
// order of the five words are part of the wire format; a different order still decrypts, which is why it is
|
|
47
|
+
// built here once instead of at each call site.
|
|
48
|
+
export const RELAY_VIEW_INFO_PREFIX = "ninsei-relay-view-v1";
|
|
49
|
+
|
|
50
|
+
export function relayViewInfo({ termsDigest, commitHalf, edPoint, edViewPointClient, edViewPointNode } = {}) {
|
|
51
|
+
const words = [
|
|
52
|
+
["termsDigest", termsDigest],
|
|
53
|
+
["commitHalf", commitHalf],
|
|
54
|
+
["edPoint", edPoint],
|
|
55
|
+
["edViewPointClient", edViewPointClient],
|
|
56
|
+
["edViewPointNode", edViewPointNode],
|
|
57
|
+
].map(([field, value]) => hexToBytes(value, 32, field));
|
|
58
|
+
const prefix = utf8(RELAY_VIEW_INFO_PREFIX);
|
|
59
|
+
const out = new Uint8Array(prefix.length + words.reduce((n, word) => n + word.length, 0));
|
|
60
|
+
out.set(prefix, 0);
|
|
61
|
+
let at = prefix.length;
|
|
62
|
+
for (const word of words) { out.set(word, at); at += word.length; }
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// One sender context, one Seal. The relay spec forbids reusing an ephemeral key for another encryption, so
|
|
67
|
+
// the context is created here and never exposed.
|
|
68
|
+
export async function sealViewEnvelope({ hpkeKey, info, plaintext } = {}) {
|
|
69
|
+
// The two plaintext lengths are part of the wire contract: ask carries the node's view scalar, bid carries
|
|
70
|
+
// the node's scalar followed by the client's. A caller that passes another length is refused before any
|
|
71
|
+
// network or cryptographic work.
|
|
72
|
+
const publicKey = hexToBytes(hpkeKey, 32, "hpkeKey");
|
|
73
|
+
const body = bytesOf(plaintext);
|
|
74
|
+
if (body.length !== 32 && body.length !== 64) {
|
|
75
|
+
throw new SdkError("bad-input", { field: "plaintext" });
|
|
76
|
+
}
|
|
77
|
+
if (!(info instanceof Uint8Array)) throw new SdkError("bad-input", { field: "info" });
|
|
78
|
+
const ctx = suite();
|
|
79
|
+
let key = null;
|
|
80
|
+
try {
|
|
81
|
+
key = await ctx.kem.deserializePublicKey(publicKey);
|
|
82
|
+
const sender = await ctx.createSenderContext({ recipientPublicKey: key, info });
|
|
83
|
+
const ciphertext = bytesOf(await sender.seal(body));
|
|
84
|
+
return { enc: bytesToHex(sender.enc), ciphertext: bytesToHex(ciphertext) };
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (error instanceof SdkError) throw error;
|
|
87
|
+
throw new SdkError("bad-input", { field: "hpkeKey" });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The recipient side exists for checks and for a node that embeds this package. It is not needed by a client
|
|
92
|
+
// that only asks for a quote, but keeping both halves in one module makes a symmetric test possible.
|
|
93
|
+
export async function openViewEnvelope({ privateKey, enc, ciphertext, info } = {}) {
|
|
94
|
+
const secret = hexToBytes(privateKey, 32, "privateKey");
|
|
95
|
+
const encapsulated = hexToBytes(enc, 32, "enc");
|
|
96
|
+
const text = String(ciphertext === undefined || ciphertext === null ? "" : ciphertext).replace(/^0x/, "");
|
|
97
|
+
const width = text.length / 2;
|
|
98
|
+
if (width !== 48 && width !== 80) throw new SdkError("bad-input", { field: "ciphertext" });
|
|
99
|
+
const body = hexToBytes(ciphertext, width, "ciphertext");
|
|
100
|
+
if (!(info instanceof Uint8Array)) throw new SdkError("bad-input", { field: "info" });
|
|
101
|
+
const ctx = suite();
|
|
102
|
+
try {
|
|
103
|
+
const key = await ctx.kem.deserializePrivateKey(secret, true);
|
|
104
|
+
const recipient = await ctx.createRecipientContext({ recipientKey: key, enc: encapsulated, info });
|
|
105
|
+
return bytesOf(await recipient.open(body));
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (error instanceof SdkError) throw error;
|
|
108
|
+
throw new SdkError("bad-input", { field: "ciphertext" });
|
|
109
|
+
}
|
|
110
|
+
}
|