@forgezero/runtime 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +89 -0
- package/contracts/foundry.toml +9 -0
- package/contracts/src/ColdVault.sol +206 -0
- package/contracts/src/DepositFactory.sol +202 -0
- package/contracts/src/DepositProxy.sol +72 -0
- package/contracts/src/IERC20.sol +7 -0
- package/contracts/src/MockTokens.sol +32 -0
- package/contracts/src/SafeTransferLib.sol +31 -0
- package/contracts/test/Custody.t.sol +361 -0
- package/contracts/test/Vectors.t.sol +45 -0
- package/dist/audit.d.ts +265 -0
- package/dist/audit.js +291 -0
- package/dist/backup.d.ts +243 -0
- package/dist/backup.js +302 -0
- package/dist/calendar.d.ts +136 -0
- package/dist/calendar.js +129 -0
- package/dist/compliance.d.ts +172 -0
- package/dist/compliance.js +168 -0
- package/dist/finance/binance.d.ts +27 -0
- package/dist/finance/binance.js +452 -0
- package/dist/finance/chain-addresses.d.ts +130 -0
- package/dist/finance/chain-addresses.js +462 -0
- package/dist/finance/chain-deposits.d.ts +193 -0
- package/dist/finance/chain-deposits.js +596 -0
- package/dist/finance/chain-reconcile.d.ts +112 -0
- package/dist/finance/chain-reconcile.js +76 -0
- package/dist/finance/chain-withdrawals.d.ts +223 -0
- package/dist/finance/chain-withdrawals.js +631 -0
- package/dist/finance/chain.d.ts +116 -0
- package/dist/finance/chain.js +316 -0
- package/dist/finance/commission.d.ts +155 -0
- package/dist/finance/commission.js +419 -0
- package/dist/finance/custody.d.ts +68 -0
- package/dist/finance/custody.js +107 -0
- package/dist/finance/derive.d.ts +115 -0
- package/dist/finance/derive.js +116 -0
- package/dist/finance/discounts.d.ts +98 -0
- package/dist/finance/discounts.js +90 -0
- package/dist/finance/ledger.d.ts +221 -0
- package/dist/finance/ledger.js +308 -0
- package/dist/finance/market.d.ts +209 -0
- package/dist/finance/market.js +112 -0
- package/dist/finance/money.d.ts +118 -0
- package/dist/finance/money.js +176 -0
- package/dist/finance/rates.d.ts +178 -0
- package/dist/finance/rates.js +292 -0
- package/dist/finance/storage.d.ts +113 -0
- package/dist/finance/storage.js +226 -0
- package/dist/finance/tax.d.ts +132 -0
- package/dist/finance/tax.js +291 -0
- package/dist/finance/transfers.d.ts +153 -0
- package/dist/finance/transfers.js +292 -0
- package/dist/finance/venues.d.ts +190 -0
- package/dist/finance/venues.js +251 -0
- package/dist/identity.d.ts +115 -0
- package/dist/identity.js +111 -0
- package/dist/importers.d.ts +87 -0
- package/dist/importers.js +250 -0
- package/dist/jobs.d.ts +171 -0
- package/dist/jobs.js +250 -0
- package/dist/notify-templates.d.ts +11 -0
- package/dist/notify-templates.js +254 -0
- package/dist/notify.d.ts +172 -0
- package/dist/notify.js +122 -0
- package/dist/openssh.d.ts +36 -0
- package/dist/openssh.js +106 -0
- package/dist/otpauth.d.ts +57 -0
- package/dist/otpauth.js +223 -0
- package/dist/outbox.d.ts +234 -0
- package/dist/outbox.js +236 -0
- package/dist/passkey.d.ts +120 -0
- package/dist/passkey.js +105 -0
- package/dist/phrase.d.ts +87 -0
- package/dist/phrase.js +87 -0
- package/dist/pipeline.d.ts +137 -0
- package/dist/pipeline.js +121 -0
- package/dist/queue.d.ts +243 -0
- package/dist/queue.js +246 -0
- package/dist/schema-typebox.d.ts +24 -0
- package/dist/schema-typebox.js +201 -0
- package/dist/schema.d.ts +134 -0
- package/dist/schema.js +169 -0
- package/dist/serial.d.ts +54 -0
- package/dist/serial.js +40 -0
- package/dist/slip10.d.ts +37 -0
- package/dist/slip10.js +74 -0
- package/dist/snp.d.ts +115 -0
- package/dist/snp.js +109 -0
- package/dist/ssh-agent.d.ts +70 -0
- package/dist/ssh-agent.js +141 -0
- package/dist/ssh-cert.d.ts +73 -0
- package/dist/ssh-cert.js +111 -0
- package/dist/totp.d.ts +104 -0
- package/dist/totp.js +143 -0
- package/package.json +248 -0
package/dist/schema.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
2
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
3
|
+
}) : x)(function(x) {
|
|
4
|
+
if (typeof require !== "undefined")
|
|
5
|
+
return require.apply(this, arguments);
|
|
6
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
// src/schema.ts
|
|
10
|
+
class SchemaError extends Error {
|
|
11
|
+
code;
|
|
12
|
+
path;
|
|
13
|
+
constructor(code, message, path) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.code = code;
|
|
16
|
+
this.path = path;
|
|
17
|
+
this.name = "SchemaError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
var DEFAULT_RESTRICTIONS = {
|
|
21
|
+
maxDepth: 4,
|
|
22
|
+
maxFields: 100,
|
|
23
|
+
maxBytes: 64 * 1024,
|
|
24
|
+
forbidden: ["$ref", "$id", "$dynamicRef", "$dynamicAnchor", "$schema", "definitions", "$defs"]
|
|
25
|
+
};
|
|
26
|
+
function restrictJsonSchema(schema, limits = DEFAULT_RESTRICTIONS) {
|
|
27
|
+
const bytes = JSON.stringify(schema).length;
|
|
28
|
+
if (bytes > limits.maxBytes) {
|
|
29
|
+
throw new SchemaError("SCHEMA_TOO_LARGE", `Schema is ${bytes} bytes; the limit is ${limits.maxBytes}.`);
|
|
30
|
+
}
|
|
31
|
+
let fields = 0;
|
|
32
|
+
const walk = (node, depth, path) => {
|
|
33
|
+
if (node === null || typeof node !== "object")
|
|
34
|
+
return;
|
|
35
|
+
if (Array.isArray(node)) {
|
|
36
|
+
node.forEach((child, index) => walk(child, depth, `${path}[${index}]`));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const record = node;
|
|
40
|
+
for (const keyword of limits.forbidden) {
|
|
41
|
+
if (keyword in record) {
|
|
42
|
+
throw new SchemaError("SCHEMA_FORBIDDEN_KEYWORD", `"${keyword}" is not permitted: it can point validation at a document we do not control.`, path || "(root)");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (depth > limits.maxDepth) {
|
|
46
|
+
throw new SchemaError("SCHEMA_TOO_DEEP", `Nesting exceeds ${limits.maxDepth} levels.`, path || "(root)");
|
|
47
|
+
}
|
|
48
|
+
if (record.type === "object") {
|
|
49
|
+
const properties = record.properties ?? {};
|
|
50
|
+
const names = Object.keys(properties);
|
|
51
|
+
fields += names.length;
|
|
52
|
+
if (fields > limits.maxFields) {
|
|
53
|
+
throw new SchemaError("SCHEMA_TOO_WIDE", `Schema declares more than ${limits.maxFields} fields.`, path || "(root)");
|
|
54
|
+
}
|
|
55
|
+
if (record.additionalProperties !== false) {
|
|
56
|
+
throw new SchemaError("SCHEMA_OPEN_OBJECT", "Objects must set additionalProperties:false, or unvalidated fields pass through.", path || "(root)");
|
|
57
|
+
}
|
|
58
|
+
for (const name of names) {
|
|
59
|
+
walk(properties[name], depth + 1, path ? `${path}.${name}` : name);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (record.type === "array" && record.items) {
|
|
63
|
+
walk(record.items, depth + 1, `${path}[]`);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
walk(schema, 1, "");
|
|
67
|
+
return schema;
|
|
68
|
+
}
|
|
69
|
+
function deriveAnnotation(property) {
|
|
70
|
+
const raw = property["x-fz-derive"];
|
|
71
|
+
if (typeof raw !== "object" || raw === null)
|
|
72
|
+
return;
|
|
73
|
+
const spec = raw;
|
|
74
|
+
const scheme = typeof spec.scheme === "string" ? spec.scheme : undefined;
|
|
75
|
+
if (!scheme)
|
|
76
|
+
return;
|
|
77
|
+
return {
|
|
78
|
+
custody: spec.custody === "supplied" ? "supplied" : "derived",
|
|
79
|
+
scheme,
|
|
80
|
+
expose: typeof spec.expose === "string" ? spec.expose : undefined
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function describeJsonSchema(schema, prefix = "") {
|
|
84
|
+
if (schema.type !== "object")
|
|
85
|
+
return [];
|
|
86
|
+
const properties = schema.properties ?? {};
|
|
87
|
+
const required = new Set(schema.required ?? []);
|
|
88
|
+
return Object.entries(properties).map(([name, property]) => {
|
|
89
|
+
const path = prefix ? `${prefix}.${name}` : name;
|
|
90
|
+
const kind = fieldKind(property);
|
|
91
|
+
const field = {
|
|
92
|
+
path,
|
|
93
|
+
label: property.title ?? humanise(name),
|
|
94
|
+
kind,
|
|
95
|
+
required: required.has(name),
|
|
96
|
+
description: property.description,
|
|
97
|
+
writeOnly: property.writeOnly === true ? true : undefined,
|
|
98
|
+
default: property.default,
|
|
99
|
+
format: property.format,
|
|
100
|
+
minLength: property.minLength,
|
|
101
|
+
maxLength: property.maxLength,
|
|
102
|
+
minimum: property.minimum,
|
|
103
|
+
maximum: property.maximum,
|
|
104
|
+
pattern: property.pattern,
|
|
105
|
+
derive: deriveAnnotation(property)
|
|
106
|
+
};
|
|
107
|
+
if (kind === "enum") {
|
|
108
|
+
return { ...field, options: property.enum ?? property.anyOf };
|
|
109
|
+
}
|
|
110
|
+
if (kind === "object") {
|
|
111
|
+
return { ...field, items: describeJsonSchema(property, path) };
|
|
112
|
+
}
|
|
113
|
+
if (kind === "array" && property.items) {
|
|
114
|
+
return {
|
|
115
|
+
...field,
|
|
116
|
+
items: describeJsonSchema(property.items, path)
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return field;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
function fieldKind(property) {
|
|
123
|
+
if (Array.isArray(property.enum))
|
|
124
|
+
return "enum";
|
|
125
|
+
if (Array.isArray(property.anyOf) && property.anyOf.every((m) => typeof m === "object" && m !== null && ("const" in m))) {
|
|
126
|
+
return "enum";
|
|
127
|
+
}
|
|
128
|
+
switch (property.type) {
|
|
129
|
+
case "string":
|
|
130
|
+
return "string";
|
|
131
|
+
case "number":
|
|
132
|
+
return "number";
|
|
133
|
+
case "integer":
|
|
134
|
+
return "integer";
|
|
135
|
+
case "boolean":
|
|
136
|
+
return "boolean";
|
|
137
|
+
case "array":
|
|
138
|
+
return "array";
|
|
139
|
+
case "object":
|
|
140
|
+
return "object";
|
|
141
|
+
default:
|
|
142
|
+
return "unknown";
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function humanise(name) {
|
|
146
|
+
const spaced = name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").trim().toLowerCase();
|
|
147
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
148
|
+
}
|
|
149
|
+
function readableFields(fields) {
|
|
150
|
+
return fields.filter((field) => !field.writeOnly).map((field) => field.items ? { ...field, items: readableFields(field.items) } : field);
|
|
151
|
+
}
|
|
152
|
+
function writeOnlyPaths(fields) {
|
|
153
|
+
return fields.flatMap((field) => [
|
|
154
|
+
...field.writeOnly ? [field.path] : [],
|
|
155
|
+
...field.items ? writeOnlyPaths(field.items) : []
|
|
156
|
+
]);
|
|
157
|
+
}
|
|
158
|
+
var SCHEMA_VERSION = 1;
|
|
159
|
+
var VERSION = "0.1.0";
|
|
160
|
+
export {
|
|
161
|
+
writeOnlyPaths,
|
|
162
|
+
restrictJsonSchema,
|
|
163
|
+
readableFields,
|
|
164
|
+
describeJsonSchema,
|
|
165
|
+
VERSION,
|
|
166
|
+
SchemaError,
|
|
167
|
+
SCHEMA_VERSION,
|
|
168
|
+
DEFAULT_RESTRICTIONS
|
|
169
|
+
};
|
package/dist/serial.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run work for one key strictly in order, and give the caller the result back.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this is not `@forgezero/runtime/queue`
|
|
5
|
+
*
|
|
6
|
+
* The queue is for work that happens LATER: it persists a message, hands it to
|
|
7
|
+
* a handler, retries, dead-letters. It is the right answer when the caller does
|
|
8
|
+
* not need the outcome — a deposit credit, a webhook, a scan.
|
|
9
|
+
*
|
|
10
|
+
* This is for work that happens NOW and whose result the caller returns. A
|
|
11
|
+
* request writing a ledger posting has to answer with the posting; enqueuing it
|
|
12
|
+
* would mean replying "accepted" to something the caller needs to have
|
|
13
|
+
* happened. Two different problems, and using the queue for this one would mean
|
|
14
|
+
* inventing a way to wait for a message — which is a worse version of this file.
|
|
15
|
+
*
|
|
16
|
+
* It was written twice before it was written once: the audit chain needed it to
|
|
17
|
+
* keep sequence numbers gapless, and accounts needed it to stop two credits
|
|
18
|
+
* interleaving. Two copies of a concurrency primitive is two chances to get it
|
|
19
|
+
* wrong, so it lives here.
|
|
20
|
+
*
|
|
21
|
+
* ## What it does NOT give you
|
|
22
|
+
*
|
|
23
|
+
* Ordering within ONE process. Two API nodes each hold their own chain, so a
|
|
24
|
+
* durable guarantee still needs a unique index on whatever must not happen
|
|
25
|
+
* twice. This makes the common case cheap and correct; the index makes every
|
|
26
|
+
* case correct. Anything relying on this alone across a cluster is relying on
|
|
27
|
+
* there being one node, which stops being true without warning.
|
|
28
|
+
*/
|
|
29
|
+
export interface SerialOptions {
|
|
30
|
+
/**
|
|
31
|
+
* Keys to keep chains for.
|
|
32
|
+
*
|
|
33
|
+
* A chain per key is a promise per key, and a process serving a million
|
|
34
|
+
* accounts would hold a million of them forever. Idle chains are dropped once
|
|
35
|
+
* settled, so the map holds only keys with work in flight.
|
|
36
|
+
*/
|
|
37
|
+
maxKeys?: number;
|
|
38
|
+
}
|
|
39
|
+
export declare function createKeyedSerial(options?: SerialOptions): {
|
|
40
|
+
/**
|
|
41
|
+
* Run `work` after everything already queued for this key.
|
|
42
|
+
*
|
|
43
|
+
* A rejection does NOT poison the chain: the tail is always replaced with a
|
|
44
|
+
* settled promise, so one transient failure cannot take every subsequent
|
|
45
|
+
* call for that key with it — which is how a single database blip becomes a
|
|
46
|
+
* permanently stuck account.
|
|
47
|
+
*/
|
|
48
|
+
run<T>(key: string, work: () => Promise<T>): Promise<T>;
|
|
49
|
+
/** How many keys have work in flight. For a health endpoint. */
|
|
50
|
+
size: () => number;
|
|
51
|
+
/** Wait for everything currently queued. Tests, and a graceful shutdown. */
|
|
52
|
+
drain: () => Promise<void>;
|
|
53
|
+
};
|
|
54
|
+
export type KeyedSerial = ReturnType<typeof createKeyedSerial>;
|
package/dist/serial.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
2
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
3
|
+
}) : x)(function(x) {
|
|
4
|
+
if (typeof require !== "undefined")
|
|
5
|
+
return require.apply(this, arguments);
|
|
6
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
// src/serial.ts
|
|
10
|
+
function createKeyedSerial(options = {}) {
|
|
11
|
+
const maxKeys = options.maxKeys ?? 1e4;
|
|
12
|
+
const chains = new Map;
|
|
13
|
+
return {
|
|
14
|
+
run(key, work) {
|
|
15
|
+
const previous = chains.get(key) ?? Promise.resolve();
|
|
16
|
+
const next = previous.then(work, work);
|
|
17
|
+
const settled = next.then(() => {
|
|
18
|
+
return;
|
|
19
|
+
}, () => {
|
|
20
|
+
return;
|
|
21
|
+
});
|
|
22
|
+
chains.set(key, settled);
|
|
23
|
+
settled.then(() => {
|
|
24
|
+
if (chains.get(key) === settled)
|
|
25
|
+
chains.delete(key);
|
|
26
|
+
});
|
|
27
|
+
if (chains.size > maxKeys) {
|
|
28
|
+
console.warn(`[serial] ${chains.size} keys in flight, above the ${maxKeys} guideline.`);
|
|
29
|
+
}
|
|
30
|
+
return next;
|
|
31
|
+
},
|
|
32
|
+
size: () => chains.size,
|
|
33
|
+
drain: async () => {
|
|
34
|
+
await Promise.allSettled([...chains.values()]);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export {
|
|
39
|
+
createKeyedSerial
|
|
40
|
+
};
|
package/dist/slip10.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** SLIP-0010 hardens by adding 2^31. Anything below it is a public index. */
|
|
2
|
+
export declare const HARDENED_OFFSET = 2147483648;
|
|
3
|
+
export declare class Slip10Error extends Error {
|
|
4
|
+
readonly code: 'NOT_HARDENED' | 'BAD_PATH' | 'BAD_SEED';
|
|
5
|
+
constructor(code: 'NOT_HARDENED' | 'BAD_PATH' | 'BAD_SEED', message: string);
|
|
6
|
+
}
|
|
7
|
+
export interface Slip10Node {
|
|
8
|
+
/** 32 bytes. The ed25519 secret scalar seed, not an expanded key. */
|
|
9
|
+
key: Uint8Array;
|
|
10
|
+
chainCode: Uint8Array;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The master node.
|
|
14
|
+
*
|
|
15
|
+
* Seeds shorter than 16 bytes are refused. SLIP-0010 permits 128 to 512 bits
|
|
16
|
+
* and a caller passing something smaller has almost certainly passed a
|
|
17
|
+
* passphrase rather than a seed — accepting it would derive a whole key tree
|
|
18
|
+
* from guessable entropy.
|
|
19
|
+
*/
|
|
20
|
+
export declare function masterFromSeed(seed: Uint8Array): Slip10Node;
|
|
21
|
+
/**
|
|
22
|
+
* One hardened step.
|
|
23
|
+
*
|
|
24
|
+
* The data is `0x00 || key || index`, and the leading zero byte is not padding
|
|
25
|
+
* — it is what distinguishes this from BIP-32's secp256k1 construction, which
|
|
26
|
+
* puts a compressed public key there for a public step. Omitting it produces
|
|
27
|
+
* plausible keys that match nothing.
|
|
28
|
+
*/
|
|
29
|
+
export declare function deriveChild(node: Slip10Node, index: number): Slip10Node;
|
|
30
|
+
/**
|
|
31
|
+
* `m/44'/501'/0'/0'` — every component hardened.
|
|
32
|
+
*
|
|
33
|
+
* Accepts `'` or `h` as the hardening mark, because both appear in the wild and
|
|
34
|
+
* a path copied from a wallet that writes `h` should not silently derive a
|
|
35
|
+
* different key than the same path written with an apostrophe.
|
|
36
|
+
*/
|
|
37
|
+
export declare function derivePath(seed: Uint8Array, path: string): Slip10Node;
|
package/dist/slip10.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
2
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
3
|
+
}) : x)(function(x) {
|
|
4
|
+
if (typeof require !== "undefined")
|
|
5
|
+
return require.apply(this, arguments);
|
|
6
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
// src/slip10.ts
|
|
10
|
+
import { hmac } from "@noble/hashes/hmac.js";
|
|
11
|
+
import { sha512 } from "@noble/hashes/sha2.js";
|
|
12
|
+
var ED25519_SEED = new TextEncoder().encode("ed25519 seed");
|
|
13
|
+
var HARDENED_OFFSET = 2147483648;
|
|
14
|
+
|
|
15
|
+
class Slip10Error extends Error {
|
|
16
|
+
code;
|
|
17
|
+
constructor(code, message) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.code = code;
|
|
20
|
+
this.name = "Slip10Error";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function masterFromSeed(seed) {
|
|
24
|
+
if (seed.length < 16 || seed.length > 64) {
|
|
25
|
+
throw new Slip10Error("BAD_SEED", "A SLIP-0010 seed is 16 to 64 bytes.");
|
|
26
|
+
}
|
|
27
|
+
const digest = hmac(sha512, ED25519_SEED, seed);
|
|
28
|
+
return { key: digest.slice(0, 32), chainCode: digest.slice(32) };
|
|
29
|
+
}
|
|
30
|
+
function deriveChild(node, index) {
|
|
31
|
+
if (!Number.isInteger(index) || index < 0 || index > 4294967295) {
|
|
32
|
+
throw new Slip10Error("BAD_PATH", `${index} is not a derivation index.`);
|
|
33
|
+
}
|
|
34
|
+
if (index < HARDENED_OFFSET) {
|
|
35
|
+
throw new Slip10Error("NOT_HARDENED", `ed25519 has no public derivation. Index ${index} must be hardened — SLIP-0010 defines only hardened steps for this curve.`);
|
|
36
|
+
}
|
|
37
|
+
const data = new Uint8Array(1 + 32 + 4);
|
|
38
|
+
data[0] = 0;
|
|
39
|
+
data.set(node.key, 1);
|
|
40
|
+
new DataView(data.buffer).setUint32(33, index >>> 0, false);
|
|
41
|
+
const digest = hmac(sha512, node.chainCode, data);
|
|
42
|
+
data.fill(0);
|
|
43
|
+
return { key: digest.slice(0, 32), chainCode: digest.slice(32) };
|
|
44
|
+
}
|
|
45
|
+
function derivePath(seed, path) {
|
|
46
|
+
const trimmed = path.trim();
|
|
47
|
+
if (!/^m(\/\d+['h]?)*$/.test(trimmed)) {
|
|
48
|
+
throw new Slip10Error("BAD_PATH", `"${path}" is not a derivation path.`);
|
|
49
|
+
}
|
|
50
|
+
let node = masterFromSeed(seed);
|
|
51
|
+
const parts = trimmed.split("/").slice(1).filter(Boolean);
|
|
52
|
+
for (const part of parts) {
|
|
53
|
+
const hardened = part.endsWith("'") || part.endsWith("h");
|
|
54
|
+
const raw = Number.parseInt(hardened ? part.slice(0, -1) : part, 10);
|
|
55
|
+
if (!hardened) {
|
|
56
|
+
throw new Slip10Error("NOT_HARDENED", `"${path}" contains a non-hardened component (${part}). ed25519 derivation is hardened-only.`);
|
|
57
|
+
}
|
|
58
|
+
if (raw >= HARDENED_OFFSET) {
|
|
59
|
+
throw new Slip10Error("BAD_PATH", `Index ${raw} is already past the hardened offset.`);
|
|
60
|
+
}
|
|
61
|
+
const next = deriveChild(node, raw + HARDENED_OFFSET);
|
|
62
|
+
node.key.fill(0);
|
|
63
|
+
node.chainCode.fill(0);
|
|
64
|
+
node = next;
|
|
65
|
+
}
|
|
66
|
+
return node;
|
|
67
|
+
}
|
|
68
|
+
export {
|
|
69
|
+
masterFromSeed,
|
|
70
|
+
derivePath,
|
|
71
|
+
deriveChild,
|
|
72
|
+
Slip10Error,
|
|
73
|
+
HARDENED_OFFSET
|
|
74
|
+
};
|
package/dist/snp.d.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parsing an AMD SEV-SNP attestation report.
|
|
3
|
+
*
|
|
4
|
+
* The report is a fixed 1184-byte structure the PSP produces and signs. Every
|
|
5
|
+
* offset below comes from the SEV Secure Nested Paging Firmware ABI
|
|
6
|
+
* specification, table "ATTESTATION_REPORT Structure" — they are not derived
|
|
7
|
+
* from anything and must not be adjusted to make a sample parse.
|
|
8
|
+
*
|
|
9
|
+
* ## Why this is separate from verifying it
|
|
10
|
+
*
|
|
11
|
+
* Parsing is total and testable: given bytes, produce fields or refuse. Deciding
|
|
12
|
+
* whether those fields are ACCEPTABLE is policy, and it already lives in
|
|
13
|
+
* `api/src/server/compute/attestation.ts`. Keeping them apart means a parser bug
|
|
14
|
+
* cannot be mistaken for a policy decision, and a policy change does not require
|
|
15
|
+
* touching byte offsets.
|
|
16
|
+
*
|
|
17
|
+
* ## What this does NOT do
|
|
18
|
+
*
|
|
19
|
+
* It does not verify the signature. Chaining a report to AMD's root needs the
|
|
20
|
+
* VCEK certificate for that specific chip at that specific TCB, fetched from
|
|
21
|
+
* AMD's KDS — a network dependency with its own failure modes, and a
|
|
22
|
+
* hand-rolled ECDSA-P384 verification here would be worse than none because it
|
|
23
|
+
* would produce `verified: true` for a report nobody signed.
|
|
24
|
+
*
|
|
25
|
+
* So `parseSnpReport` returns the signature bytes and says nothing about them.
|
|
26
|
+
* The caller supplies a verifier, which is the same shape every other trust
|
|
27
|
+
* decision in this codebase takes.
|
|
28
|
+
*/
|
|
29
|
+
/** The structure is exactly this long. Anything else is not a report. */
|
|
30
|
+
export declare const REPORT_BYTES = 1184;
|
|
31
|
+
export declare class SnpError extends Error {
|
|
32
|
+
readonly code: 'BAD_LENGTH' | 'BAD_VERSION' | 'BAD_VMPL';
|
|
33
|
+
constructor(code: 'BAD_LENGTH' | 'BAD_VERSION' | 'BAD_VMPL', message: string);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Guest policy bits.
|
|
37
|
+
*
|
|
38
|
+
* `debug` is the one that matters most and the one an operator is most likely
|
|
39
|
+
* to leave on: a guest launched with debugging enabled can have its memory
|
|
40
|
+
* inspected by the hypervisor, which removes the entire property SEV-SNP is
|
|
41
|
+
* bought for. It is surfaced by name rather than as a bitmask so a policy check
|
|
42
|
+
* reads as a sentence.
|
|
43
|
+
*/
|
|
44
|
+
export interface GuestPolicy {
|
|
45
|
+
/** Minimum ABI the guest requires. */
|
|
46
|
+
abiMinor: number;
|
|
47
|
+
abiMajor: number;
|
|
48
|
+
/** SMT permitted on the host. */
|
|
49
|
+
smt: boolean;
|
|
50
|
+
/** Migration agent permitted — the guest's memory may leave this machine. */
|
|
51
|
+
migrateMa: boolean;
|
|
52
|
+
/** Debugging enabled. Memory is inspectable; this must be false in production. */
|
|
53
|
+
debug: boolean;
|
|
54
|
+
/** The guest must run on a single socket. */
|
|
55
|
+
singleSocket: boolean;
|
|
56
|
+
/** The raw value, so a caller can record exactly what was reported. */
|
|
57
|
+
raw: string;
|
|
58
|
+
}
|
|
59
|
+
export interface SnpReport {
|
|
60
|
+
version: number;
|
|
61
|
+
guestSvn: number;
|
|
62
|
+
policy: GuestPolicy;
|
|
63
|
+
vmpl: number;
|
|
64
|
+
/** 48 bytes of hex. What the guest actually booted. */
|
|
65
|
+
measurement: string;
|
|
66
|
+
/** 64 bytes of hex. Whatever the guest asked the PSP to bind in — our nonce. */
|
|
67
|
+
reportData: string;
|
|
68
|
+
hostData: string;
|
|
69
|
+
/** Identifies the physical chip. Two reports from one machine share it. */
|
|
70
|
+
chipId: string;
|
|
71
|
+
/** TCB the report was produced under. Lower than a floor is a downgrade. */
|
|
72
|
+
reportedTcb: TcbVersion;
|
|
73
|
+
currentTcb: TcbVersion;
|
|
74
|
+
/** 512 bytes. Returned, never interpreted here. */
|
|
75
|
+
signature: string;
|
|
76
|
+
}
|
|
77
|
+
/** The four component versions packed into a TCB_VERSION. */
|
|
78
|
+
export interface TcbVersion {
|
|
79
|
+
bootLoader: number;
|
|
80
|
+
tee: number;
|
|
81
|
+
snp: number;
|
|
82
|
+
microcode: number;
|
|
83
|
+
raw: string;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Bytes to fields.
|
|
87
|
+
*
|
|
88
|
+
* Refuses before reading anything when the length is wrong. A short buffer read
|
|
89
|
+
* with a DataView produces either an exception at an arbitrary offset or, worse,
|
|
90
|
+
* plausible values from adjacent memory — and a measurement assembled from
|
|
91
|
+
* whatever followed the buffer is the one failure that must never look like a
|
|
92
|
+
* successful parse.
|
|
93
|
+
*/
|
|
94
|
+
export declare function parseSnpReport(bytes: Uint8Array): SnpReport;
|
|
95
|
+
/**
|
|
96
|
+
* Is every TCB component at least the floor?
|
|
97
|
+
*
|
|
98
|
+
* Field by field, and ALL of them. A single packed comparison would let a
|
|
99
|
+
* microcode bump mask an SNP firmware downgrade — which is precisely the
|
|
100
|
+
* direction an attacker wants, because older firmware is where the known
|
|
101
|
+
* vulnerabilities are.
|
|
102
|
+
*/
|
|
103
|
+
export declare function tcbAtLeast(reported: TcbVersion, floor: TcbVersion): boolean;
|
|
104
|
+
/**
|
|
105
|
+
* Does the report bind OUR challenge?
|
|
106
|
+
*
|
|
107
|
+
* `REPORT_DATA` is 64 bytes the guest chooses and the PSP signs. A nonce placed
|
|
108
|
+
* there is what makes a report fresh rather than replayed — without this check a
|
|
109
|
+
* report captured once is valid for ever, and attestation degrades into "this
|
|
110
|
+
* machine was an SNP guest at some point".
|
|
111
|
+
*
|
|
112
|
+
* Compared over the whole field, including the zero padding, so a nonce shorter
|
|
113
|
+
* than 64 bytes cannot be matched by a report that merely starts with it.
|
|
114
|
+
*/
|
|
115
|
+
export declare function bindsNonce(report: SnpReport, nonce: string): boolean;
|
package/dist/snp.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
2
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
3
|
+
}) : x)(function(x) {
|
|
4
|
+
if (typeof require !== "undefined")
|
|
5
|
+
return require.apply(this, arguments);
|
|
6
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
// src/snp.ts
|
|
10
|
+
var REPORT_BYTES = 1184;
|
|
11
|
+
var OFFSET = {
|
|
12
|
+
version: 0,
|
|
13
|
+
guestSvn: 4,
|
|
14
|
+
policy: 8,
|
|
15
|
+
familyId: 16,
|
|
16
|
+
imageId: 32,
|
|
17
|
+
vmpl: 48,
|
|
18
|
+
signatureAlgo: 52,
|
|
19
|
+
currentTcb: 56,
|
|
20
|
+
platformInfo: 64,
|
|
21
|
+
reportData: 80,
|
|
22
|
+
measurement: 144,
|
|
23
|
+
hostData: 192,
|
|
24
|
+
idKeyDigest: 224,
|
|
25
|
+
authorKeyDigest: 272,
|
|
26
|
+
reportId: 320,
|
|
27
|
+
reportIdMa: 352,
|
|
28
|
+
reportedTcb: 384,
|
|
29
|
+
chipId: 416,
|
|
30
|
+
signature: 672
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
class SnpError extends Error {
|
|
34
|
+
code;
|
|
35
|
+
constructor(code, message) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.code = code;
|
|
38
|
+
this.name = "SnpError";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
var hex = (bytes) => [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
42
|
+
function readTcb(view, offset) {
|
|
43
|
+
const raw = new Uint8Array(view.buffer, view.byteOffset + offset, 8);
|
|
44
|
+
return {
|
|
45
|
+
bootLoader: raw[0],
|
|
46
|
+
tee: raw[1],
|
|
47
|
+
snp: raw[6],
|
|
48
|
+
microcode: raw[7],
|
|
49
|
+
raw: hex(raw)
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function readPolicy(view, offset) {
|
|
53
|
+
const low = view.getUint32(offset, true);
|
|
54
|
+
const high = view.getUint32(offset + 4, true);
|
|
55
|
+
const bit = (index) => (low >>> index & 1) === 1;
|
|
56
|
+
return {
|
|
57
|
+
abiMinor: low & 255,
|
|
58
|
+
abiMajor: low >>> 8 & 255,
|
|
59
|
+
smt: bit(16),
|
|
60
|
+
migrateMa: bit(18),
|
|
61
|
+
debug: bit(19),
|
|
62
|
+
singleSocket: bit(20),
|
|
63
|
+
raw: `${high.toString(16).padStart(8, "0")}${low.toString(16).padStart(8, "0")}`
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function parseSnpReport(bytes) {
|
|
67
|
+
if (bytes.length !== REPORT_BYTES) {
|
|
68
|
+
throw new SnpError("BAD_LENGTH", `An SEV-SNP attestation report is ${REPORT_BYTES} bytes; this is ${bytes.length}.`);
|
|
69
|
+
}
|
|
70
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
71
|
+
const version = view.getUint32(OFFSET.version, true);
|
|
72
|
+
if (version !== 2 && version !== 3) {
|
|
73
|
+
throw new SnpError("BAD_VERSION", `Report version ${version} is not one this parser understands.`);
|
|
74
|
+
}
|
|
75
|
+
const vmpl = view.getUint32(OFFSET.vmpl, true);
|
|
76
|
+
if (vmpl > 3) {
|
|
77
|
+
throw new SnpError("BAD_VMPL", `VMPL ${vmpl} is outside the defined range.`);
|
|
78
|
+
}
|
|
79
|
+
const slice = (offset, length) => hex(bytes.subarray(offset, offset + length));
|
|
80
|
+
return {
|
|
81
|
+
version,
|
|
82
|
+
guestSvn: view.getUint32(OFFSET.guestSvn, true),
|
|
83
|
+
policy: readPolicy(view, OFFSET.policy),
|
|
84
|
+
vmpl,
|
|
85
|
+
measurement: slice(OFFSET.measurement, 48),
|
|
86
|
+
reportData: slice(OFFSET.reportData, 64),
|
|
87
|
+
hostData: slice(OFFSET.hostData, 32),
|
|
88
|
+
chipId: slice(OFFSET.chipId, 64),
|
|
89
|
+
reportedTcb: readTcb(view, OFFSET.reportedTcb),
|
|
90
|
+
currentTcb: readTcb(view, OFFSET.currentTcb),
|
|
91
|
+
signature: slice(OFFSET.signature, 512)
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function tcbAtLeast(reported, floor) {
|
|
95
|
+
return reported.bootLoader >= floor.bootLoader && reported.tee >= floor.tee && reported.snp >= floor.snp && reported.microcode >= floor.microcode;
|
|
96
|
+
}
|
|
97
|
+
function bindsNonce(report, nonce) {
|
|
98
|
+
const bare = nonce.replace(/^0x/, "").toLowerCase();
|
|
99
|
+
if (bare.length === 0 || bare.length > 128 || bare.length % 2 !== 0)
|
|
100
|
+
return false;
|
|
101
|
+
return report.reportData === bare.padEnd(128, "0");
|
|
102
|
+
}
|
|
103
|
+
export {
|
|
104
|
+
tcbAtLeast,
|
|
105
|
+
parseSnpReport,
|
|
106
|
+
bindsNonce,
|
|
107
|
+
SnpError,
|
|
108
|
+
REPORT_BYTES
|
|
109
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSH agent as a custody factor.
|
|
3
|
+
*
|
|
4
|
+
* The ceremony seals every custodian share twice — once under a key derived from
|
|
5
|
+
* a WebAuthn PRF output, once under a key derived from a BIP-39 phrase — and
|
|
6
|
+
* either alone opens it. That works in a browser and not at all over SSH, which
|
|
7
|
+
* is where the platform is first brought up: there is no passkey before there is
|
|
8
|
+
* a platform to register one against.
|
|
9
|
+
*
|
|
10
|
+
* An SSH agent fills the same slot. Both are an agent-held key that produces a
|
|
11
|
+
* stable secret without ever exposing the key itself, so the SSH signature over
|
|
12
|
+
* a fixed challenge substitutes for the PRF output with no change to the sealing
|
|
13
|
+
* code. The custodian later registers a passkey through the UI and the same
|
|
14
|
+
* share gains a browser route.
|
|
15
|
+
*
|
|
16
|
+
* **Ed25519 only, and that is a correctness requirement rather than a
|
|
17
|
+
* preference.** The derived key must be identical on every enrolment and every
|
|
18
|
+
* unlock, so the signature has to be deterministic. Ed25519 is (RFC 8032). RSA
|
|
19
|
+
* with PKCS#1 v1.5 happens to be, but agents are free to offer RSA-PSS for the
|
|
20
|
+
* same key, and PSS is randomised — a share sealed under one PSS signature could
|
|
21
|
+
* never be opened again. Refusing everything except Ed25519 makes that
|
|
22
|
+
* impossible rather than rare.
|
|
23
|
+
*/
|
|
24
|
+
export declare class SshAgentError extends Error {
|
|
25
|
+
}
|
|
26
|
+
export interface AgentIdentity {
|
|
27
|
+
/** Raw SSH public key blob, as the agent returns it. */
|
|
28
|
+
blob: Buffer;
|
|
29
|
+
comment: string;
|
|
30
|
+
/** `ssh-ed25519`, etc. Anything else is refused — see the module note. */
|
|
31
|
+
type: string;
|
|
32
|
+
/** `SHA256:…`, matching `ssh-add -l`, so a human can confirm which key. */
|
|
33
|
+
fingerprint: string;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* How long to wait on the agent before giving up.
|
|
37
|
+
*
|
|
38
|
+
* An agent holding a forwarded key whose upstream is gone, or a confirm-on-use
|
|
39
|
+
* key with nobody at the terminal, does not refuse — it simply never answers.
|
|
40
|
+
* Without a socket-level deadline those connections are never closed, and a
|
|
41
|
+
* caller that races them against its own timer leaks one socket per attempt
|
|
42
|
+
* until the agent stops accepting connections entirely.
|
|
43
|
+
*/
|
|
44
|
+
export declare const AGENT_TIMEOUT_MS = 3000;
|
|
45
|
+
/** The keys the agent is holding. Matches `ssh-add -l`. */
|
|
46
|
+
export declare function listIdentities(socketPath?: string): Promise<AgentIdentity[]>;
|
|
47
|
+
/** Ed25519 only. See the module note — determinism is the whole mechanism. */
|
|
48
|
+
export declare function listCustodyIdentities(socketPath?: string): Promise<AgentIdentity[]>;
|
|
49
|
+
/**
|
|
50
|
+
* Derive the 32-byte custody key for an identity.
|
|
51
|
+
*
|
|
52
|
+
* Stable across processes and machines for the same key, which is what lets a
|
|
53
|
+
* custodian enrol today and unlock next month from a different laptop with the
|
|
54
|
+
* same key in their agent.
|
|
55
|
+
*
|
|
56
|
+
* HKDF over the signature rather than the signature itself: the signature is a
|
|
57
|
+
* value the agent will hand to anything that asks, so using it directly as a
|
|
58
|
+
* key would mean any process that can reach the socket holds the custody key.
|
|
59
|
+
* The salt and info bind it to this purpose.
|
|
60
|
+
*/
|
|
61
|
+
export declare function deriveCustodyKey(identity: AgentIdentity, socketPath?: string): Promise<Uint8Array>;
|
|
62
|
+
/**
|
|
63
|
+
* Prove the derivation reproduces before it is trusted with a share.
|
|
64
|
+
*
|
|
65
|
+
* Signs twice and compares. A non-deterministic agent — a smartcard doing PSS, a
|
|
66
|
+
* forwarded agent that swapped keys mid-ceremony — would otherwise seal a share
|
|
67
|
+
* under a key that can never be reproduced, and the failure would surface only
|
|
68
|
+
* at the worst possible moment: recovery.
|
|
69
|
+
*/
|
|
70
|
+
export declare function assertDeterministic(identity: AgentIdentity, socketPath?: string): Promise<Uint8Array>;
|