@totemsdk/omnia-host 0.2.1 → 0.2.2

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.
@@ -24,6 +24,18 @@ export interface ControlServerOptions {
24
24
  isReady?: () => boolean;
25
25
  methods?: Map<string, JsonRpcHandler>;
26
26
  onConnection?: (socket: WebSocket) => void;
27
+ /**
28
+ * Browser Origin allowlist (AUD-009). When set, browser requests whose
29
+ * `Origin` header is not listed are rejected. Requests with no Origin
30
+ * (native clients, curl) are always allowed.
31
+ */
32
+ allowedOrigins?: string[];
33
+ /**
34
+ * When set, control-plane requests must present this bearer token (AUD-009):
35
+ * `Authorization: Bearer <token>` over HTTP, or a query param / subprotocol
36
+ * over the WebSocket handshake.
37
+ */
38
+ authToken?: string;
27
39
  }
28
40
  export interface ControlServer {
29
41
  listen(): Promise<void>;
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.createControlServer = createControlServer;
7
+ const node_crypto_1 = __importDefault(require("node:crypto"));
7
8
  const node_http_1 = __importDefault(require("node:http"));
8
9
  const ws_1 = require("ws");
9
10
  function response(id, result) {
@@ -44,23 +45,95 @@ function sendJson(res, status, body) {
44
45
  res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) });
45
46
  res.end(payload);
46
47
  }
48
+ function pathname(url) {
49
+ if (!url)
50
+ return '/';
51
+ const query = url.indexOf('?');
52
+ return query === -1 ? url : url.slice(0, query);
53
+ }
54
+ function isOriginAllowed(req, allowedOrigins) {
55
+ const origin = req.headers.origin;
56
+ // Native clients (no browser Origin header) are always allowed.
57
+ if (!origin)
58
+ return true;
59
+ // RFC-020 H1: browser requests require an explicit allowlist (default deny).
60
+ if (!allowedOrigins || allowedOrigins.length === 0)
61
+ return false;
62
+ return allowedOrigins.includes(origin);
63
+ }
64
+ function bearerToken(req) {
65
+ const header = req.headers.authorization;
66
+ if (typeof header === 'string' && header.startsWith('Bearer '))
67
+ return header.slice('Bearer '.length);
68
+ return undefined;
69
+ }
70
+ function handshakeToken(req) {
71
+ const url = new URL(req.url ?? '/', 'http://localhost');
72
+ const queryToken = url.searchParams.get('token');
73
+ if (queryToken)
74
+ return queryToken;
75
+ const header = req.headers['sec-websocket-protocol'];
76
+ const raw = Array.isArray(header) ? header.join(',') : header;
77
+ if (typeof raw === 'string') {
78
+ for (const part of raw.split(',')) {
79
+ const protocol = part.trim();
80
+ if (protocol.startsWith('bearer.'))
81
+ return protocol.slice('bearer.'.length);
82
+ if (protocol)
83
+ return protocol;
84
+ }
85
+ }
86
+ return undefined;
87
+ }
88
+ function safeTokenEqual(provided, expected) {
89
+ if (provided === undefined)
90
+ return false;
91
+ const a = Buffer.from(provided);
92
+ const b = Buffer.from(expected);
93
+ if (a.length !== b.length)
94
+ return false;
95
+ return node_crypto_1.default.timingSafeEqual(a, b);
96
+ }
47
97
  function createControlServer(options) {
98
+ // RFC-020 H1: a non-loopback bind must be authenticated. Refuse to start a
99
+ // network-exposed control plane without a token.
100
+ const isLoopback = options.host === '127.0.0.1' || options.host === '::1' || options.host === 'localhost';
101
+ if (!isLoopback && (options.authToken === undefined || options.authToken.length === 0)) {
102
+ throw new Error(`Refusing to bind the control plane to ${options.host} without a control token (OMNIA_CONTROL_TOKEN)`);
103
+ }
48
104
  const methods = options.methods ?? new Map();
49
105
  const wsPath = options.wsPath ?? '/rpc';
106
+ const allowedOrigins = options.allowedOrigins;
107
+ const authToken = options.authToken;
50
108
  const server = node_http_1.default.createServer(async (req, res) => {
51
- if (req.method === 'GET' && req.url === '/healthz') {
109
+ if (req.method === 'GET' && pathname(req.url) === '/healthz') {
52
110
  sendJson(res, 200, { ok: true });
53
111
  return;
54
112
  }
55
- if (req.method === 'GET' && req.url === '/readyz') {
113
+ if (req.method === 'GET' && pathname(req.url) === '/readyz') {
56
114
  const ready = options.isReady?.() ?? false;
57
115
  sendJson(res, ready ? 200 : 503, { ok: ready });
58
116
  return;
59
117
  }
60
- if (req.method !== 'POST' || req.url !== wsPath) {
118
+ if (req.method !== 'POST' || pathname(req.url) !== wsPath) {
61
119
  sendJson(res, 404, { error: 'Not found' });
62
120
  return;
63
121
  }
122
+ if (!isOriginAllowed(req, allowedOrigins)) {
123
+ sendJson(res, 403, { error: 'Forbidden' });
124
+ return;
125
+ }
126
+ // RFC-020 H1: require application/json — a browser cross-origin `text/plain`
127
+ // POST (a classic CSRF vector that skips CORS preflight) must be rejected.
128
+ const contentType = String(req.headers['content-type'] ?? '').toLowerCase();
129
+ if (!contentType.startsWith('application/json')) {
130
+ sendJson(res, 415, { error: 'Unsupported Media Type: application/json required' });
131
+ return;
132
+ }
133
+ if (authToken !== undefined && !safeTokenEqual(bearerToken(req), authToken)) {
134
+ sendJson(res, 403, { error: 'Forbidden' });
135
+ return;
136
+ }
64
137
  try {
65
138
  const request = JSON.parse(await readBody(req));
66
139
  sendJson(res, 200, await dispatch(request, methods));
@@ -72,7 +145,15 @@ function createControlServer(options) {
72
145
  });
73
146
  const webSockets = new ws_1.WebSocketServer({ noServer: true });
74
147
  server.on('upgrade', (req, socket, head) => {
75
- if (req.url !== wsPath) {
148
+ if (pathname(req.url) !== wsPath) {
149
+ socket.destroy();
150
+ return;
151
+ }
152
+ if (!isOriginAllowed(req, allowedOrigins)) {
153
+ socket.destroy();
154
+ return;
155
+ }
156
+ if (authToken !== undefined && !safeTokenEqual(handshakeToken(req), authToken)) {
76
157
  socket.destroy();
77
158
  return;
78
159
  }
package/dist/config.d.ts CHANGED
@@ -26,6 +26,16 @@ export interface OmniaHostConfig {
26
26
  identityFile?: string;
27
27
  /** EdgeServiceManifest serviceType (default "omnia-router"). */
28
28
  serviceType: string;
29
+ /**
30
+ * Browser Origin allowlist for the control plane (OMNIA_ALLOWED_ORIGINS,
31
+ * comma-separated). Undefined/empty allows any Origin.
32
+ */
33
+ allowedOrigins?: string[];
34
+ /**
35
+ * Bearer token required on control-plane requests (OMNIA_CONTROL_TOKEN).
36
+ * Undefined disables token auth.
37
+ */
38
+ controlToken?: string;
29
39
  }
30
40
  /** Load the daemon configuration from the documented OMNIA_* environment. */
31
41
  export declare function loadConfigFromEnv(env?: NodeJS.ProcessEnv): OmniaHostConfig;
package/dist/config.js CHANGED
@@ -28,6 +28,13 @@ function optionalInteger(value) {
28
28
  throw new Error(`OMNIA_LOCAL_ADDRESS_INDEX must be a non-negative integer; received ${value}`);
29
29
  return parsed;
30
30
  }
31
+ function parseOrigins(value) {
32
+ const parts = value
33
+ ?.split(',')
34
+ .map((part) => part.trim())
35
+ .filter((part) => part.length > 0);
36
+ return parts && parts.length > 0 ? parts : undefined;
37
+ }
31
38
  function parseSeed(value) {
32
39
  const seed = optional(value);
33
40
  if (!seed)
@@ -76,5 +83,7 @@ function loadConfigFromEnv(env = process.env) {
76
83
  readOnly: env.OMNIA_HOST_READ_ONLY?.trim() === '1',
77
84
  identityFile: optional(env.OMNIA_HOST_IDENTITY_FILE),
78
85
  serviceType: optional(env.OMNIA_HOST_SERVICE_TYPE) ?? 'omnia-router',
86
+ allowedOrigins: parseOrigins(env.OMNIA_ALLOWED_ORIGINS),
87
+ controlToken: optional(env.OMNIA_CONTROL_TOKEN),
79
88
  };
80
89
  }
package/dist/lifecycle.js CHANGED
@@ -86,6 +86,8 @@ function createOmniaHost(config, dependencies = {}) {
86
86
  port: config.port,
87
87
  wsPath: config.wsPath,
88
88
  isReady: () => started,
89
+ allowedOrigins: config.allowedOrigins,
90
+ authToken: config.controlToken,
89
91
  methods: dependencies.methods ?? (0, methods_js_1.createHostMethods)({
90
92
  channels: channelStore,
91
93
  routing: routingProvider,
package/dist/signing.d.ts CHANGED
@@ -43,13 +43,15 @@ interface KeyfilePayload {
43
43
  export declare function encryptSeedForKeyfile(seed: Uint8Array, passphrase: string): KeyfilePayload;
44
44
  /**
45
45
  * JSON-file-backed StorageAdapter rooted at a directory next to the channel DB.
46
- * Each key is stored as `<key>.json`; the directory is created on first write.
46
+ *
47
+ * Delegates to the hardened shared `FileStore` from `@totemsdk/storage/fs`
48
+ * (RFC-007): one versioned codec record per key written temp+rename with an
49
+ * fsync; corrupt records surface as `StorageError` (`corrupt`) rather than
50
+ * silently returning JSON `null`. The directory is created on first write.
47
51
  */
48
52
  export declare class JsonFileStorageAdapter implements StorageAdapter {
49
- private readonly dir;
53
+ private readonly store;
50
54
  constructor(dir: string);
51
- private fileFor;
52
- private ensureDir;
53
55
  get<T>(key: string): Promise<T | null>;
54
56
  set<T>(key: string, value: T): Promise<void>;
55
57
  remove(key: string): Promise<boolean>;
package/dist/signing.js CHANGED
@@ -30,6 +30,7 @@ const node_fs_1 = __importDefault(require("node:fs"));
30
30
  const node_path_1 = __importDefault(require("node:path"));
31
31
  const node_crypto_1 = require("node:crypto");
32
32
  const core_1 = require("@totemsdk/core");
33
+ const fs_1 = require("@totemsdk/storage/fs");
33
34
  const wots_lease_1 = require("@totemsdk/wots-lease");
34
35
  const wots_lease_2 = require("@totemsdk/wots-lease");
35
36
  const KEYFILE_SCRYPT_N = 16384;
@@ -115,58 +116,34 @@ function resolveBaseSeed(config) {
115
116
  }
116
117
  /**
117
118
  * JSON-file-backed StorageAdapter rooted at a directory next to the channel DB.
118
- * Each key is stored as `<key>.json`; the directory is created on first write.
119
+ *
120
+ * Delegates to the hardened shared `FileStore` from `@totemsdk/storage/fs`
121
+ * (RFC-007): one versioned codec record per key written temp+rename with an
122
+ * fsync; corrupt records surface as `StorageError` (`corrupt`) rather than
123
+ * silently returning JSON `null`. The directory is created on first write.
119
124
  */
120
125
  class JsonFileStorageAdapter {
121
- dir;
126
+ store;
122
127
  constructor(dir) {
123
- this.dir = dir;
124
- }
125
- fileFor(key) {
126
- const safe = key.replace(/[^a-zA-Z0-9_-]/g, '_');
127
- return node_path_1.default.join(this.dir, `${safe}.json`);
128
- }
129
- ensureDir() {
130
- node_fs_1.default.mkdirSync(this.dir, { recursive: true });
128
+ this.store = new fs_1.FileStore(dir);
131
129
  }
132
130
  async get(key) {
133
- const file = this.fileFor(key);
134
- if (!node_fs_1.default.existsSync(file))
135
- return null;
136
- try {
137
- return JSON.parse(node_fs_1.default.readFileSync(file, 'utf8'));
138
- }
139
- catch {
140
- return null;
141
- }
131
+ return this.store.get(key);
142
132
  }
143
133
  async set(key, value) {
144
- this.ensureDir();
145
- node_fs_1.default.writeFileSync(this.fileFor(key), JSON.stringify(value));
134
+ return this.store.set(key, value);
146
135
  }
147
136
  async remove(key) {
148
- const file = this.fileFor(key);
149
- if (!node_fs_1.default.existsSync(file))
150
- return false;
151
- node_fs_1.default.unlinkSync(file);
152
- return true;
137
+ return this.store.remove(key);
153
138
  }
154
139
  async clear() {
155
- if (!node_fs_1.default.existsSync(this.dir))
156
- return;
157
- for (const entry of node_fs_1.default.readdirSync(this.dir)) {
158
- node_fs_1.default.unlinkSync(node_path_1.default.join(this.dir, entry));
159
- }
140
+ return this.store.clear();
160
141
  }
161
142
  async keys() {
162
- if (!node_fs_1.default.existsSync(this.dir))
163
- return [];
164
- return node_fs_1.default.readdirSync(this.dir)
165
- .filter((name) => name.endsWith('.json'))
166
- .map((name) => name.slice(0, -'.json'.length));
143
+ return this.store.keys();
167
144
  }
168
145
  async has(key) {
169
- return node_fs_1.default.existsSync(this.fileFor(key));
146
+ return this.store.has(key);
170
147
  }
171
148
  }
172
149
  exports.JsonFileStorageAdapter = JsonFileStorageAdapter;
@@ -200,14 +177,22 @@ function createHostSigning(config) {
200
177
  const kp = (0, core_1.wotsKeypairFromSeed)(perAddressSeed, 0);
201
178
  const publicKeyDigest = `0x${(0, core_1.bytesToHex)(kp.pk)}`;
202
179
  const address = (0, core_1.wotsAddressFromKeypair)(kp);
180
+ const storage = new JsonFileStorageAdapter(leaseStorageDir(config.dbPath));
181
+ const leaseProvider = new wots_lease_1.LocalLeaseProvider(storage, undefined, deviceId);
203
182
  const signer = {
204
183
  publicKeyDigest,
205
- async sign(payload, _indices) {
206
- return (0, core_1.wotsSign)(perAddressSeed, 0, payload);
184
+ async sign(payload, indices) {
185
+ // RFC-020 C1: honor the reserved leaf instead of hardcoding index 0. The
186
+ // reservation must belong to this signer's address; the leaf within the
187
+ // per-address seed is (l1*64 + l2). Callers must supply the pk digest for
188
+ // the leaf they reserved (the default reservation is leaf (0,0)).
189
+ if (indices.addressIndex !== addressIndex) {
190
+ throw new Error(`omnia-host signer address ${addressIndex} cannot sign reserved address ${indices.addressIndex}`);
191
+ }
192
+ const leafIndex = indices.l1 * 64 + indices.l2;
193
+ return (0, core_1.wotsSign)(perAddressSeed, leafIndex, payload);
207
194
  },
208
195
  };
209
- const storage = new JsonFileStorageAdapter(leaseStorageDir(config.dbPath));
210
- const leaseProvider = new wots_lease_1.LocalLeaseProvider(storage, undefined, deviceId);
211
196
  return {
212
197
  signer,
213
198
  leaseProvider,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@totemsdk/omnia-host",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Durable Omnia node daemon for channel lifecycle, routing, and control APIs",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -22,17 +22,18 @@
22
22
  "dependencies": {
23
23
  "better-sqlite3": "^12.2.0",
24
24
  "ws": "^8.21.1",
25
- "@totemsdk/core": "^1.2.9",
26
- "@totemsdk/identity": "^1.0.0",
25
+ "@totemsdk/chain-provider": "^0.2.1",
26
+ "@totemsdk/core": "^1.2.11",
27
+ "@totemsdk/identity": "^1.0.1",
27
28
  "@totemsdk/manifest": "^1.0.0",
28
- "@totemsdk/chain-provider": "^0.2.0",
29
- "@totemsdk/omnia": "^1.0.1",
30
- "@totemsdk/omnia-factory": "^0.2.0",
31
- "@totemsdk/omnia-router": "^0.2.0",
32
- "@totemsdk/omnia-splice": "^0.2.0",
33
- "@totemsdk/minima-rpc": "^0.2.0",
34
- "@totemsdk/omnia-vtxo": "^0.2.0",
35
- "@totemsdk/wots-lease": "^1.0.0"
29
+ "@totemsdk/omnia": "^1.0.2",
30
+ "@totemsdk/omnia-factory": "^0.2.1",
31
+ "@totemsdk/omnia-splice": "^0.2.1",
32
+ "@totemsdk/omnia-vtxo": "^0.2.1",
33
+ "@totemsdk/minima-rpc": "^0.2.2",
34
+ "@totemsdk/storage": "^0.1.0",
35
+ "@totemsdk/wots-lease": "^1.0.1",
36
+ "@totemsdk/omnia-router": "^0.2.1"
36
37
  },
37
38
  "devDependencies": {
38
39
  "@types/jest": "^30.0.0",
@@ -42,7 +43,7 @@
42
43
  "jest": "^30.4.2",
43
44
  "ts-jest": "^29.4.11",
44
45
  "typescript": "5.9.3",
45
- "@totemsdk/txpow": "^1.0.0"
46
+ "@totemsdk/txpow": "^1.0.2"
46
47
  },
47
48
  "engines": {
48
49
  "node": ">=20.0.0"