@ixo/oracle-runtime-workers 0.3.0 → 0.4.1

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/README.md CHANGED
@@ -67,7 +67,9 @@ graph LR
67
67
  (`MATRIX_ORACLE_ADMIN_PASSWORD`, `MATRIX_RECOVERY_PHRASE`,
68
68
  `MATRIX_VALUE_PIN`).
69
69
  3. Deploy the oracle script with its secrets (`OPEN_ROUTER_API_KEY`, the
70
- same Matrix secrets, `ORACLE_SIGNING_MNEMONIC`).
70
+ same Matrix secrets, and `ORACLE_SIGNING_MNEMONIC` unless the account
71
+ room already holds the mnemonic the Node runtime or the CLI provisioned —
72
+ then the runtime reads it from there).
71
73
  4. `POST /matrix/start`, then `GET /matrix/status` until `running` and
72
74
  `cryptoReady` are true.
73
75
  5. Run the feature matrix against it (see [testing](docs/testing.md)).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ixo/oracle-runtime-workers",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "QiForge oracle runtime for Cloudflare Workers — Durable Object per user (SQLite in WASM over DO storage), Matrix E2EE gateway Durable Object, Hono HTTP/SSE shell.",
5
5
  "private": false,
6
6
  "publishConfig": {
@@ -52,7 +52,7 @@
52
52
  "matrix-js-sdk": "^42.2.0",
53
53
  "superjson": "1.13.3",
54
54
  "wa-sqlite": "1.0.0",
55
- "yjs": "^13.6.27",
55
+ "yjs": "13.6.32",
56
56
  "zod": "^4.4.3",
57
57
  "@ixo/ucan": "2.2.0"
58
58
  },
@@ -502,6 +502,16 @@ export interface MatrixGatewayObject extends Rpc.DurableObjectBranded {
502
502
  * objects fetch it once at boot and seat it into their secrets service.
503
503
  */
504
504
  getOracleSecretsKey(): Promise<JsonString | null>;
505
+ /**
506
+ * The oracle's Ed25519 UCAN signing mnemonic, read the way the Node
507
+ * runtime stores it: the account room's `ixo.room.state.secure` /
508
+ * `encrypted_mnemonic_ed_signing` state event, whose `encrypted_mnemonic`
509
+ * is AES-256-CBC encrypted with `MATRIX_VALUE_PIN`. Read-only (the Node
510
+ * runtime provisions it). Null when the env vars are unset, the event is
511
+ * missing or the PIN does not decrypt it — each logged, never thrown.
512
+ * User objects call this at boot when `ORACLE_SIGNING_MNEMONIC` is unset.
513
+ */
514
+ getOracleSigningMnemonic(): Promise<string | null>;
505
515
  }
506
516
 
507
517
  export interface BotCredentials {
@@ -566,6 +566,32 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
566
566
  if (delegationReplaced) await this.onDelegationReplaced();
567
567
  }
568
568
 
569
+ /**
570
+ * The mnemonic the oracle signs downstream UCAN invocations with:
571
+ * `ORACLE_SIGNING_MNEMONIC` when set, otherwise the one the Node runtime
572
+ * keeps in the Matrix account room (read and decrypted by the gateway,
573
+ * memoised there). Undefined leaves the object without a signing key —
574
+ * plugins that need one degrade with their usual "no signing key" result
575
+ * — and never fails the boot.
576
+ */
577
+ private async resolveSigningMnemonic(): Promise<string | undefined> {
578
+ const fromEnv = this.env.ORACLE_SIGNING_MNEMONIC;
579
+ if (typeof fromEnv === 'string' && fromEnv.trim().length > 0)
580
+ return fromEnv;
581
+ try {
582
+ const fromRoom = await this.gateway.getOracleSigningMnemonic();
583
+ if (fromRoom) return fromRoom;
584
+ } catch (err) {
585
+ console.warn(
586
+ `[user-do] signing mnemonic unavailable from the account room: ${err instanceof Error ? err.message : String(err)}`,
587
+ );
588
+ }
589
+ console.warn(
590
+ '[user-do] no UCAN signing key: ORACLE_SIGNING_MNEMONIC is unset and the account room holds no readable mnemonic — downstream minting is off',
591
+ );
592
+ return undefined;
593
+ }
594
+
569
595
  /**
570
596
  * The object holds a delegation it had not seen before (a header on this
571
597
  * request, or a deposit through the shell). A fresh delegation is the one
@@ -654,22 +680,20 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
654
680
  this.userDid = userDid;
655
681
 
656
682
  const env = this.env;
657
- this.ucan = new WorkersUcanService({
658
- oracleDid: env.ORACLE_DID,
659
- signingMnemonic:
660
- typeof env.ORACLE_SIGNING_MNEMONIC === 'string'
661
- ? env.ORACLE_SIGNING_MNEMONIC
662
- : undefined,
663
- logger: console,
664
- });
665
- this.ownerStore = await this.createOwnerStore(userDid);
666
683
  // The gateway lazily starts on first use; make sure it is up before the
667
- // owner store or room lookups need it (idempotent, cheap once running).
684
+ // signing mnemonic, the owner store or room lookups need it (idempotent,
685
+ // cheap once running).
668
686
  await this.gateway.ensureStarted().catch((err) => {
669
687
  console.warn(
670
688
  `[user-do] matrix gateway not available yet: ${err instanceof Error ? err.message : String(err)}`,
671
689
  );
672
690
  });
691
+ this.ucan = new WorkersUcanService({
692
+ oracleDid: env.ORACLE_DID,
693
+ signingMnemonic: await this.resolveSigningMnemonic(),
694
+ logger: console,
695
+ });
696
+ this.ownerStore = await this.createOwnerStore(userDid);
673
697
 
674
698
  // Per-room JWE secrets: seat the oracle's P-256 key (fetched once from
675
699
  // the gateway, which reads the Matrix account room). Without a key the
@@ -44,6 +44,12 @@ import {
44
44
  type UserOracleObject,
45
45
  } from '../do/contracts';
46
46
  import { decryptWithPin } from '../secrets/pin-cipher';
47
+ import {
48
+ encryptedMnemonicOf,
49
+ parseSigningMnemonic,
50
+ SIGNING_MNEMONIC_STATE_KEY,
51
+ SIGNING_MNEMONIC_STATE_TYPE,
52
+ } from '../secrets/signing-mnemonic';
47
53
  import {
48
54
  IngestPipeline,
49
55
  type InboundAttachment,
@@ -202,6 +208,7 @@ export class MatrixGatewayDO
202
208
  private pluginDevice: BotCredentials | null = null;
203
209
  /** Memoised `getOracleSecretsKey` result (private JWK JSON). */
204
210
  private oracleSecretsKeyJson: string | null = null;
211
+ private oracleSigningMnemonic: string | null = null;
205
212
 
206
213
  // -------------------------------------------------------------------------
207
214
  // Configuration
@@ -644,6 +651,75 @@ export class MatrixGatewayDO
644
651
  return jwkJson;
645
652
  }
646
653
 
654
+ /**
655
+ * The oracle's UCAN signing mnemonic from the account room — the Node
656
+ * runtime's storage (`setup-claim-signing-mnemonics.ts`): state event
657
+ * `ixo.room.state.secure` / `encrypted_mnemonic_ed_signing`, content
658
+ * `{ encrypted_mnemonic: 'ivHex:cipherHex' }`, AES-256-CBC with
659
+ * `MATRIX_VALUE_PIN` (the same cipher as the secrets key above). Read-only:
660
+ * provisioning (generate, store, publish the verification method on chain)
661
+ * stays with the Node runtime and the CLI. Memoised for the life of this
662
+ * object. Every failure is logged and yields null — a user object then
663
+ * runs without a signing key, exactly as with no `ORACLE_SIGNING_MNEMONIC`.
664
+ */
665
+ async getOracleSigningMnemonic(): Promise<string | null> {
666
+ if (this.oracleSigningMnemonic !== null) return this.oracleSigningMnemonic;
667
+ const roomId = this.env.MATRIX_ACCOUNT_ROOM_ID;
668
+ const pin = this.env.MATRIX_VALUE_PIN;
669
+ if (!roomId || !pin) {
670
+ this.log(
671
+ 'warn',
672
+ 'signing mnemonic unavailable: ORACLE_SIGNING_MNEMONIC is unset and MATRIX_ACCOUNT_ROOM_ID / MATRIX_VALUE_PIN are not set',
673
+ );
674
+ return null;
675
+ }
676
+ await this.ensureStarted();
677
+ const stateJson = await this.getRoomStateEvent(
678
+ roomId,
679
+ SIGNING_MNEMONIC_STATE_TYPE,
680
+ SIGNING_MNEMONIC_STATE_KEY,
681
+ );
682
+ if (!stateJson) {
683
+ this.log(
684
+ 'warn',
685
+ `no ${SIGNING_MNEMONIC_STATE_TYPE}/${SIGNING_MNEMONIC_STATE_KEY} in the account room — set ORACLE_SIGNING_MNEMONIC or provision the mnemonic with the Node runtime / CLI; downstream UCAN minting stays off`,
686
+ );
687
+ return null;
688
+ }
689
+ const encrypted = encryptedMnemonicOf(stateJson);
690
+ if (!encrypted) {
691
+ this.log(
692
+ 'warn',
693
+ `${SIGNING_MNEMONIC_STATE_KEY} in the account room carries no encrypted_mnemonic — downstream UCAN minting stays off`,
694
+ );
695
+ return null;
696
+ }
697
+ let plain: string;
698
+ try {
699
+ plain = await decryptWithPin(encrypted, pin);
700
+ } catch (err) {
701
+ this.log(
702
+ 'error',
703
+ `could not decrypt ${SIGNING_MNEMONIC_STATE_KEY} with MATRIX_VALUE_PIN (wrong PIN?): ${err instanceof Error ? err.message : String(err)} — downstream UCAN minting stays off`,
704
+ );
705
+ return null;
706
+ }
707
+ const mnemonic = parseSigningMnemonic(plain);
708
+ if (!mnemonic) {
709
+ this.log(
710
+ 'error',
711
+ `${SIGNING_MNEMONIC_STATE_KEY} decrypted to something that is not a BIP-39 mnemonic (wrong PIN?) — downstream UCAN minting stays off`,
712
+ );
713
+ return null;
714
+ }
715
+ this.oracleSigningMnemonic = mnemonic;
716
+ this.log(
717
+ 'info',
718
+ `oracle signing mnemonic loaded from the account room (${mnemonic.split(' ').length} words)`,
719
+ );
720
+ return mnemonic;
721
+ }
722
+
647
723
  // -------------------------------------------------------------------------
648
724
  // User snapshots (SQLite files as room media) and media downloads
649
725
  // -------------------------------------------------------------------------
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Where the Node runtime keeps the oracle's UCAN signing mnemonic, and how
3
+ * to read it (`oracles-chain-client`'s `setup-claim-signing-mnemonics.ts`):
4
+ * the account room's state event `ixo.room.state.secure` /
5
+ * `encrypted_mnemonic_ed_signing` with content
6
+ * `{ encrypted_mnemonic: 'ivHex:cipherHex' }`, AES-256-CBC under
7
+ * `MATRIX_VALUE_PIN` (`decryptWithPin`). The Workers runtime only reads it —
8
+ * provisioning stays with the Node runtime and the CLI.
9
+ */
10
+
11
+ export const SIGNING_MNEMONIC_STATE_TYPE = 'ixo.room.state.secure';
12
+ export const SIGNING_MNEMONIC_STATE_KEY = 'encrypted_mnemonic_ed_signing';
13
+
14
+ /** The `encrypted_mnemonic` of a state-event content JSON, or null. */
15
+ export function encryptedMnemonicOf(contentJson: string): string | null {
16
+ let content: unknown;
17
+ try {
18
+ content = JSON.parse(contentJson);
19
+ } catch {
20
+ return null;
21
+ }
22
+ if (typeof content !== 'object' || content === null) return null;
23
+ const value = (content as { encrypted_mnemonic?: unknown })
24
+ .encrypted_mnemonic;
25
+ return typeof value === 'string' && /^[0-9a-f]+:[0-9a-f]+$/i.test(value)
26
+ ? value
27
+ : null;
28
+ }
29
+
30
+ const WORD_COUNTS = new Set([12, 15, 18, 21, 24]);
31
+
32
+ /**
33
+ * A decrypted value that looks like a BIP-39 mnemonic (12–24 lowercase
34
+ * words), normalised to single spaces — or null. A wrong PIN decrypts to
35
+ * bytes that never pass this, which keeps garbage out of the signer.
36
+ */
37
+ export function parseSigningMnemonic(plain: string): string | null {
38
+ const words = plain.trim().split(/\s+/);
39
+ if (!WORD_COUNTS.has(words.length)) return null;
40
+ if (!words.every((w) => /^[a-z]+$/.test(w))) return null;
41
+ return words.join(' ');
42
+ }