@totemsdk/omnia-host 0.9.0 → 0.11.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.
@@ -0,0 +1,231 @@
1
+ "use strict";
2
+ /**
3
+ * RFC-062 — local node/extension pairing MVP.
4
+ *
5
+ * A loopback HTTP service issues one-time pairing codes, verifies the extension
6
+ * origin, binds a device key and returns a scoped bearer token plus the node
7
+ * identity/fingerprint and advertised capabilities (chain RPC, P2P hub, lease).
8
+ */
9
+ var __importDefault = (this && this.__importDefault) || function (mod) {
10
+ return (mod && mod.__esModule) ? mod : { "default": mod };
11
+ };
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.createPairingService = createPairingService;
14
+ exports.createPairingHttpServer = createPairingHttpServer;
15
+ const node_crypto_1 = __importDefault(require("node:crypto"));
16
+ const node_fs_1 = __importDefault(require("node:fs"));
17
+ const node_http_1 = __importDefault(require("node:http"));
18
+ const node_path_1 = __importDefault(require("node:path"));
19
+ function sha256(value) {
20
+ return node_crypto_1.default.createHash('sha256').update(value).digest('hex');
21
+ }
22
+ function loadStore(storePath) {
23
+ try {
24
+ const parsed = JSON.parse(node_fs_1.default.readFileSync(storePath, 'utf8'));
25
+ if (parsed.identity?.publicKey && parsed.identity?.privateKey) {
26
+ return { identity: parsed.identity, clients: parsed.clients ?? {} };
27
+ }
28
+ }
29
+ catch {
30
+ // Create a new identity below.
31
+ }
32
+ const pair = node_crypto_1.default.generateKeyPairSync('ed25519');
33
+ const identity = {
34
+ publicKey: pair.publicKey.export({ type: 'spki', format: 'der' }).toString('base64'),
35
+ privateKey: pair.privateKey.export({ type: 'pkcs8', format: 'der' }).toString('base64'),
36
+ createdAt: Date.now(),
37
+ };
38
+ const store = { identity, clients: {} };
39
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(storePath), { recursive: true });
40
+ node_fs_1.default.writeFileSync(storePath, JSON.stringify(store, null, 2), { mode: 0o600 });
41
+ return store;
42
+ }
43
+ function createPairingService(options) {
44
+ const now = options.now ?? (() => Date.now());
45
+ const ttlMs = options.ttlMs ?? 5 * 60_000;
46
+ const endpoint = options.endpoint ?? 'http://127.0.0.1:7789';
47
+ const network = options.network ?? 'minima';
48
+ const scopes = options.scopes ?? ['chain.read', 'chain.submit', 'p2p.pubsub'];
49
+ const capabilities = options.capabilities ?? {};
50
+ const store = loadStore(options.storePath);
51
+ const sessions = new Map();
52
+ const publicKeyBytes = Buffer.from(store.identity.publicKey, 'base64');
53
+ const fingerprint = sha256(publicKeyBytes);
54
+ const nodeId = fingerprint.slice(0, 16);
55
+ const persist = () => {
56
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(options.storePath), { recursive: true });
57
+ node_fs_1.default.writeFileSync(options.storePath, JSON.stringify(store, null, 2), { mode: 0o600 });
58
+ };
59
+ const allowsOrigin = (origin) => {
60
+ if (!origin)
61
+ return true;
62
+ if (!options.allowedOrigins || options.allowedOrigins.length === 0)
63
+ return true;
64
+ return options.allowedOrigins.includes(origin);
65
+ };
66
+ const baseInfo = () => ({
67
+ nodeId,
68
+ fingerprint,
69
+ network,
70
+ scopes,
71
+ capabilities,
72
+ });
73
+ return {
74
+ nodeId,
75
+ fingerprint,
76
+ network,
77
+ scopes,
78
+ capabilities,
79
+ createInvitation() {
80
+ const code = node_crypto_1.default.randomBytes(18).toString('base64url');
81
+ const hash = sha256(code);
82
+ const expiresAt = now() + ttlMs;
83
+ sessions.set(hash, { hash, expiresAt, attempts: 0 });
84
+ // Opportunistic cleanup; expired sessions cannot be completed.
85
+ for (const [key, session] of sessions) {
86
+ if (session.expiresAt <= now())
87
+ sessions.delete(key);
88
+ }
89
+ return { endpoint, code, ...baseInfo(), expiresAt };
90
+ },
91
+ info(code) {
92
+ const session = sessions.get(sha256(code));
93
+ if (!session || session.expiresAt <= now())
94
+ throw new Error('Invalid or expired pairing code');
95
+ return { ...baseInfo(), expiresAt: session.expiresAt };
96
+ },
97
+ complete({ code, devicePublicKey, origin, scopes: requested }) {
98
+ const hash = sha256(code);
99
+ const session = sessions.get(hash);
100
+ if (!session || session.expiresAt <= now())
101
+ throw new Error('Invalid or expired pairing code');
102
+ if (!allowsOrigin(origin))
103
+ throw new Error('Origin not allowed for pairing');
104
+ const granted = requested ?? scopes;
105
+ if (granted.some((scope) => !scopes.includes(scope)))
106
+ throw new Error('Requested scope is not offered');
107
+ const token = node_crypto_1.default.randomBytes(32).toString('base64url');
108
+ const clientId = `pair-${node_crypto_1.default.randomBytes(8).toString('hex')}`;
109
+ store.clients[clientId] = {
110
+ clientId,
111
+ origin,
112
+ devicePublicKey,
113
+ scopes: granted,
114
+ tokenHash: sha256(token),
115
+ createdAt: now(),
116
+ lastUsedAt: now(),
117
+ };
118
+ persist();
119
+ sessions.delete(hash);
120
+ return {
121
+ token,
122
+ clientId,
123
+ node: { nodeId, fingerprint, network },
124
+ capabilities,
125
+ };
126
+ },
127
+ identity() {
128
+ return {
129
+ nodeId,
130
+ fingerprint,
131
+ publicKey: store.identity.publicKey,
132
+ network,
133
+ capabilities,
134
+ };
135
+ },
136
+ listClients() {
137
+ return Object.values(store.clients);
138
+ },
139
+ revoke(clientId) {
140
+ const client = store.clients[clientId];
141
+ if (!client)
142
+ return false;
143
+ client.revokedAt = now();
144
+ persist();
145
+ return true;
146
+ },
147
+ allowsOrigin,
148
+ };
149
+ }
150
+ function createPairingHttpServer(service, options) {
151
+ const maxBodyBytes = options.maxBodyBytes ?? 16 * 1024;
152
+ const server = node_http_1.default.createServer(async (req, res) => {
153
+ const origin = typeof req.headers.origin === 'string' ? req.headers.origin : undefined;
154
+ const cors = () => {
155
+ if (origin && service.allowsOrigin(origin)) {
156
+ res.setHeader('access-control-allow-origin', origin);
157
+ res.setHeader('vary', 'Origin');
158
+ }
159
+ res.setHeader('access-control-allow-methods', 'GET,POST,OPTIONS');
160
+ res.setHeader('access-control-allow-headers', 'content-type');
161
+ };
162
+ cors();
163
+ if (req.method === 'OPTIONS') {
164
+ res.writeHead(204).end();
165
+ return;
166
+ }
167
+ const send = (status, body) => {
168
+ const payload = JSON.stringify(body);
169
+ res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) });
170
+ res.end(payload);
171
+ };
172
+ const url = new URL(req.url ?? '/', 'http://localhost');
173
+ try {
174
+ if (req.method === 'GET' && url.pathname === '/healthz')
175
+ return send(200, { ok: true });
176
+ if (req.method === 'GET' && url.pathname === '/node/v1/identity')
177
+ return send(200, service.identity());
178
+ if (req.method === 'GET' && url.pathname === '/pair/v1/info') {
179
+ const code = url.searchParams.get('code');
180
+ if (!code)
181
+ return send(400, { error: 'code is required' });
182
+ if (origin && !service.allowsOrigin(origin))
183
+ return send(403, { error: 'Origin not allowed' });
184
+ return send(200, service.info(code));
185
+ }
186
+ if (req.method === 'POST' && url.pathname === '/pair/v1/complete') {
187
+ const body = await readBody(req, maxBodyBytes);
188
+ const parsed = JSON.parse(body);
189
+ if (!parsed.code || !parsed.devicePublicKey)
190
+ return send(400, { error: 'code and devicePublicKey are required' });
191
+ return send(200, service.complete({
192
+ code: parsed.code,
193
+ devicePublicKey: parsed.devicePublicKey,
194
+ origin: parsed.origin ?? origin ?? '',
195
+ scopes: parsed.scopes,
196
+ }));
197
+ }
198
+ send(404, { error: 'Not found' });
199
+ }
200
+ catch (error) {
201
+ send(400, { error: error instanceof Error ? error.message : String(error) });
202
+ }
203
+ });
204
+ let address;
205
+ return {
206
+ get address() { return address; },
207
+ listen: () => new Promise((resolve, reject) => {
208
+ server.once('error', reject);
209
+ server.listen(options.port, options.host ?? '127.0.0.1', () => {
210
+ server.off('error', reject);
211
+ address = server.address();
212
+ resolve({ host: address.address, port: address.port });
213
+ });
214
+ }),
215
+ close: () => new Promise((resolve, reject) => {
216
+ server.close((error) => error ? reject(error) : resolve());
217
+ }),
218
+ };
219
+ }
220
+ async function readBody(req, limit) {
221
+ const chunks = [];
222
+ let total = 0;
223
+ for await (const chunk of req) {
224
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
225
+ total += buffer.length;
226
+ if (total > limit)
227
+ throw new Error('Request body too large');
228
+ chunks.push(buffer);
229
+ }
230
+ return Buffer.concat(chunks).toString('utf8');
231
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Idempotent request/reply support for peer proposals.
3
+ *
4
+ * A requester resends a proposal when the transport reconnects (the proposal or
5
+ * its reply may have been lost). The responder must then:
6
+ * - never sign/allocate a second time for the same logical request, and
7
+ * - return the exact same reply so the requester sees a consistent result.
8
+ *
9
+ * Keyed by `(type, kind, channelId, nonce)`; concurrent duplicates await the
10
+ * in-flight execution instead of starting a second one.
11
+ */
12
+ export declare class ReplyCache {
13
+ private readonly _maxEntries;
14
+ private readonly _ttlMs;
15
+ private readonly _replies;
16
+ private readonly _inFlight;
17
+ constructor(_maxEntries?: number, _ttlMs?: number);
18
+ key(type: string, channelId: string, nonce: number | undefined, kind?: string): string;
19
+ run<T>(key: string, fn: () => Promise<T | null>): Promise<T | null>;
20
+ private _evict;
21
+ }
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ReplyCache = void 0;
4
+ /**
5
+ * Idempotent request/reply support for peer proposals.
6
+ *
7
+ * A requester resends a proposal when the transport reconnects (the proposal or
8
+ * its reply may have been lost). The responder must then:
9
+ * - never sign/allocate a second time for the same logical request, and
10
+ * - return the exact same reply so the requester sees a consistent result.
11
+ *
12
+ * Keyed by `(type, kind, channelId, nonce)`; concurrent duplicates await the
13
+ * in-flight execution instead of starting a second one.
14
+ */
15
+ class ReplyCache {
16
+ _maxEntries;
17
+ _ttlMs;
18
+ _replies = new Map();
19
+ _inFlight = new Map();
20
+ constructor(_maxEntries = 512, _ttlMs = 10 * 60_000) {
21
+ this._maxEntries = _maxEntries;
22
+ this._ttlMs = _ttlMs;
23
+ }
24
+ key(type, channelId, nonce, kind) {
25
+ return [type, kind ?? '', channelId, nonce ?? ''].join(':');
26
+ }
27
+ async run(key, fn) {
28
+ const cached = this._replies.get(key);
29
+ if (cached) {
30
+ if (Date.now() - cached.at < this._ttlMs)
31
+ return cached.reply;
32
+ this._replies.delete(key);
33
+ }
34
+ const inFlight = this._inFlight.get(key);
35
+ if (inFlight)
36
+ return inFlight;
37
+ const promise = (async () => {
38
+ try {
39
+ const reply = await fn();
40
+ if (reply !== null) {
41
+ this._replies.set(key, { reply, at: Date.now() });
42
+ this._evict();
43
+ }
44
+ return reply;
45
+ }
46
+ finally {
47
+ this._inFlight.delete(key);
48
+ }
49
+ })();
50
+ this._inFlight.set(key, promise);
51
+ return promise;
52
+ }
53
+ _evict() {
54
+ while (this._replies.size > this._maxEntries) {
55
+ const oldest = this._replies.keys().next().value;
56
+ if (oldest === undefined)
57
+ break;
58
+ this._replies.delete(oldest);
59
+ }
60
+ }
61
+ }
62
+ exports.ReplyCache = ReplyCache;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Local signer port (RFC-058 P1).
3
+ *
4
+ * Adapts the host's unified TreeKey signer + WOTS lease to the SDK `SignerPort`
5
+ * contract. A hosted deployment supplies its own `SignerPort` (KMS/HSM/MPC +
6
+ * policy) instead; the host code is agnostic.
7
+ */
8
+ import { type CustodyMode, type SignDomain, type SignRequest, type SignerPort } from '@totemsdk/omnia';
9
+ import type { HostSigning } from './signing.js';
10
+ export declare function createLocalSignerPort(signing: HostSigning): SignerPort;
11
+ /** Hosted/KMS policy checks applied before delegating to the underlying port. */
12
+ export interface SignerPolicy {
13
+ allowedDomains?: SignDomain[];
14
+ allowedModes?: CustodyMode[];
15
+ /** Reject requests whose declared amount exceeds this value (base units). */
16
+ maxAmount?: bigint;
17
+ /** Require a disclosure hash/version on the custody context. */
18
+ requireDisclosure?: boolean;
19
+ /** Additional operator approval hook (KMS/HSM/MPC or quorum). */
20
+ authorize?: (request: SignRequest) => void | Promise<void>;
21
+ }
22
+ /**
23
+ * Wrap a signer port with policy checks. The underlying port may be a local
24
+ * signer, KMS/HSM/MPC adapter, or hosted policy engine; this wrapper only
25
+ * enforces the deployment policy and disclosure requirements.
26
+ */
27
+ export declare function createPolicySignerPort(base: SignerPort, policy?: SignerPolicy): SignerPort;
@@ -0,0 +1,79 @@
1
+ "use strict";
2
+ /**
3
+ * Local signer port (RFC-058 P1).
4
+ *
5
+ * Adapts the host's unified TreeKey signer + WOTS lease to the SDK `SignerPort`
6
+ * contract. A hosted deployment supplies its own `SignerPort` (KMS/HSM/MPC +
7
+ * policy) instead; the host code is agnostic.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.createLocalSignerPort = createLocalSignerPort;
11
+ exports.createPolicySignerPort = createPolicySignerPort;
12
+ const core_1 = require("@totemsdk/core");
13
+ const omnia_1 = require("@totemsdk/omnia");
14
+ function createLocalSignerPort(signing) {
15
+ return {
16
+ publicKeyDigest: signing.publicKeyDigest,
17
+ async sign(request) {
18
+ (0, omnia_1.assertSignRequest)(request);
19
+ if (!request.signers.includes(signing.publicKeyDigest)) {
20
+ throw new Error(`local signer ${signing.publicKeyDigest} is not a required signer for ${request.requestId}`);
21
+ }
22
+ const payload = (0, omnia_1.signRequestPayload)(request);
23
+ if (request.signingIndices) {
24
+ // Caller owns the lease (channel/factory tx signing); sign the exact leaf.
25
+ return signing.signer.sign(payload, request.signingIndices);
26
+ }
27
+ const reservation = await signing.leaseProvider.reserveKeyUse({
28
+ treeId: 'omnia-sign-request',
29
+ purpose: `sign-request:${request.domain}`,
30
+ payloadHash: (0, core_1.bytesToHex)(payload),
31
+ });
32
+ try {
33
+ const signature = await signing.signer.sign(payload, reservation.indices);
34
+ await signing.leaseProvider.commitKeyUse(reservation.reservationId, `sign-request:${request.domain}`);
35
+ return signature;
36
+ }
37
+ catch (err) {
38
+ await signing.leaseProvider
39
+ .burnReservation(reservation.reservationId, 'sign-request-failed')
40
+ .catch(() => { });
41
+ throw err;
42
+ }
43
+ },
44
+ };
45
+ }
46
+ /**
47
+ * Wrap a signer port with policy checks. The underlying port may be a local
48
+ * signer, KMS/HSM/MPC adapter, or hosted policy engine; this wrapper only
49
+ * enforces the deployment policy and disclosure requirements.
50
+ */
51
+ function createPolicySignerPort(base, policy = {}) {
52
+ return {
53
+ publicKeyDigest: base.publicKeyDigest,
54
+ async sign(request) {
55
+ (0, omnia_1.assertSignRequest)(request);
56
+ if (!request.signers.includes(base.publicKeyDigest)) {
57
+ throw new Error(`signer ${base.publicKeyDigest} is not a required signer for ${request.requestId}`);
58
+ }
59
+ if (policy.allowedDomains && !policy.allowedDomains.includes(request.domain)) {
60
+ throw new Error(`domain ${request.domain} is not allowed by policy`);
61
+ }
62
+ if (policy.allowedModes && !policy.allowedModes.includes(request.custody.mode)) {
63
+ throw new Error(`custody mode ${request.custody.mode} is not allowed by policy`);
64
+ }
65
+ if (policy.requireDisclosure && (!request.custody.disclosureHash || !request.custody.disclosureVersion)) {
66
+ throw new Error('policy requires a disclosure hash and version');
67
+ }
68
+ const amount = request.context.amount;
69
+ if (policy.maxAmount !== undefined && amount !== undefined) {
70
+ const parsed = BigInt(amount);
71
+ if (parsed > policy.maxAmount) {
72
+ throw new Error(`request amount ${parsed} exceeds policy limit ${policy.maxAmount}`);
73
+ }
74
+ }
75
+ await policy.authorize?.(request);
76
+ return base.sign(request);
77
+ },
78
+ };
79
+ }
package/dist/signing.d.ts CHANGED
@@ -24,6 +24,17 @@ import type { OmniaHostConfig } from './config.js';
24
24
  export interface HostSigning {
25
25
  signer: ChannelSigner;
26
26
  leaseProvider: WotsLeaseProvider;
27
+ /**
28
+ * Stable ed25519 keypair used as the native Hyperswarm transport identity.
29
+ * Derived deterministically from the host seed. The transport is classical
30
+ * (noise); its public key is bound to the PQ identity by the signed manifest.
31
+ */
32
+ transportKeyPair: {
33
+ publicKey: Uint8Array;
34
+ secretKey: Uint8Array;
35
+ };
36
+ /** `0x`-prefixed hex transport public key (published in the signed manifest). */
37
+ transportPubkey: string;
27
38
  /** Minima address for the unified child TreeKey (identity + channels). */
28
39
  address: string;
29
40
  /** Channel participant key: the unified child TreeKey root pubkey (RFC-037). */
package/dist/signing.js CHANGED
@@ -79,6 +79,29 @@ function encryptSeedForKeyfile(seed, passphrase) {
79
79
  authTag: cipher.getAuthTag().toString('hex'),
80
80
  };
81
81
  }
82
+ /**
83
+ * Derive the stable ed25519 transport keypair from the host seed.
84
+ *
85
+ * The transport is Hyperswarm's classical noise channel; this key is *not* the
86
+ * quantum-resistant identity. It is published in the identity-signed manifest
87
+ * so peers can bind it to the host's PQ identity.
88
+ */
89
+ function deriveTransportKeyPair(baseSeed) {
90
+ const seed = (0, node_crypto_1.createHash)('sha256')
91
+ .update(Buffer.concat([Buffer.from(baseSeed), Buffer.from('omnia-transport-v1')]))
92
+ .digest();
93
+ // Ed25519 PKCS#8 DER prefix + 32-byte seed
94
+ const pkcs8 = Buffer.concat([
95
+ Buffer.from('302e020100300506032b657004220420', 'hex'),
96
+ seed,
97
+ ]);
98
+ const privateKey = (0, node_crypto_1.createPrivateKey)({ key: pkcs8, format: 'der', type: 'pkcs8' });
99
+ const spki = (0, node_crypto_1.createPublicKey)(privateKey).export({ format: 'der', type: 'spki' });
100
+ const publicKey = spki.subarray(spki.length - 32);
101
+ // sodium/ed25519 64-byte secret key = seed || publicKey
102
+ const secretKey = Buffer.concat([seed, publicKey]);
103
+ return { publicKey: new Uint8Array(publicKey), secretKey: new Uint8Array(secretKey) };
104
+ }
82
105
  function resolveBaseSeed(config) {
83
106
  if (config.seed) {
84
107
  const trimmed = config.seed.trim();
@@ -194,11 +217,15 @@ function createHostSigning(config) {
194
217
  return (0, core_1.serializeTreeSignature)(treeKey.sign(payload));
195
218
  },
196
219
  };
220
+ const transportKeyPair = deriveTransportKeyPair(baseSeed);
221
+ const transportPubkey = `0x${(0, core_1.bytesToHex)(transportKeyPair.publicKey)}`;
197
222
  return {
198
223
  signer,
199
224
  leaseProvider,
200
225
  address,
201
226
  publicKeyDigest,
227
+ transportKeyPair,
228
+ transportPubkey,
202
229
  baseSeed,
203
230
  treeKey,
204
231
  addressIndex,
@@ -9,10 +9,11 @@ export interface OperationRecord {
9
9
  result?: unknown;
10
10
  error?: string;
11
11
  }
12
- /** Durable operation journal with atomic status transitions. */
12
+ /** Durable operation journal with atomic status transitions, via storage SDK. */
13
13
  export declare class OperationStore {
14
- private readonly db;
14
+ private readonly kv;
15
15
  constructor(dbPath: string);
16
+ private migrateLegacy;
16
17
  get(operationId: string): OperationRecord | undefined;
17
18
  create(operationId: string, request?: unknown, now?: number): OperationRecord;
18
19
  verifyRequest(operationId: string, request: unknown): boolean;