@ixo/oracle-runtime-workers 0.3.0 → 0.4.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/README.md +3 -1
- package/package.json +1 -1
- package/src/do/contracts.ts +10 -0
- package/src/do/user-oracle-do.ts +34 -10
- package/src/matrix/gateway-do.ts +76 -0
- package/src/secrets/signing-mnemonic.ts +42 -0
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
|
+
"version": "0.4.0",
|
|
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": {
|
package/src/do/contracts.ts
CHANGED
|
@@ -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 {
|
package/src/do/user-oracle-do.ts
CHANGED
|
@@ -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,
|
|
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
|
package/src/matrix/gateway-do.ts
CHANGED
|
@@ -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
|
+
}
|