nebula-ai-gateway 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/README.md +23 -0
- package/bin/nebula-gateway +2 -0
- package/bin/nebula-gateway-local +2 -0
- package/package.json +54 -0
- package/src/approval-relay.ts +91 -0
- package/src/auth.ts +334 -0
- package/src/bootstrap.ts +352 -0
- package/src/build-runtime.ts +1087 -0
- package/src/entrypoint.ts +121 -0
- package/src/events.ts +121 -0
- package/src/heartbeat.ts +97 -0
- package/src/index.ts +98 -0
- package/src/local-entrypoint.ts +356 -0
- package/src/real-runtime.ts +319 -0
- package/src/relaunch-script.ts +152 -0
- package/src/runtime.ts +169 -0
- package/src/secrets.ts +38 -0
- package/src/server.ts +598 -0
- package/src/state.ts +103 -0
- package/src/stub-runtime.ts +61 -0
- package/src/upgrade-script.ts +237 -0
package/README.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# nebula-ai-gateway
|
|
2
|
+
|
|
3
|
+
The always-on **gateway daemon** for **nebula**. Keeps the agent online when the
|
|
4
|
+
TUI is closed: runs the Telegram listener, routes inline-keyboard approvals, and
|
|
5
|
+
serves a local control plane. Runs locally on your machine (no remote sandbox);
|
|
6
|
+
started with `nebula gateway start`.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
bun add nebula-ai-gateway
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Requires [bun](https://bun.sh).
|
|
15
|
+
|
|
16
|
+
## Use
|
|
17
|
+
|
|
18
|
+
You don't usually run this directly — `nebula gateway start` (from
|
|
19
|
+
[`nebula-treasury`](https://www.npmjs.com/package/nebula-treasury)) spawns it with
|
|
20
|
+
Touch ID + a cached operator session, decrypts the local keystore, and brings the
|
|
21
|
+
listeners online. Documented here for transparency.
|
|
22
|
+
|
|
23
|
+
See the [root README](https://github.com/rstfulzz/nebula#readme).
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nebula-ai-gateway",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Nebula gateway daemon: the always-on local brain runtime + listeners (Telegram, approvals) for the nebula agent",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://github.com/rstfulzz/nebula",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/rstfulzz/nebula.git",
|
|
11
|
+
"directory": "packages/gateway"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/rstfulzz/nebula/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"nebula",
|
|
18
|
+
"ai",
|
|
19
|
+
"agent",
|
|
20
|
+
"gateway",
|
|
21
|
+
"daemon",
|
|
22
|
+
"mantle"
|
|
23
|
+
],
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"bun": ">=1.1"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"src",
|
|
32
|
+
"!src/**/*.test.ts",
|
|
33
|
+
"bin",
|
|
34
|
+
"README.md"
|
|
35
|
+
],
|
|
36
|
+
"main": "./src/index.ts",
|
|
37
|
+
"types": "./src/index.ts",
|
|
38
|
+
"bin": {
|
|
39
|
+
"nebula-gateway": "bin/nebula-gateway",
|
|
40
|
+
"nebula-gateway-local": "bin/nebula-gateway-local"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "tsc -b",
|
|
44
|
+
"test": "bun test"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"nebula-ai-core": "0.1.0",
|
|
48
|
+
"nebula-ai-plugin-onchain": "0.1.0",
|
|
49
|
+
"nebula-ai-plugin-system": "0.1.0",
|
|
50
|
+
"nebula-ai-plugin-telegram": "0.1.0",
|
|
51
|
+
"viem": "^2.21.55",
|
|
52
|
+
"zod": "^3.24.1"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { EventHub } from './events'
|
|
2
|
+
|
|
3
|
+
export interface ApprovalRequestPayload {
|
|
4
|
+
/** Tool kind (chain.send, chain.swap, shell.run, etc). */
|
|
5
|
+
kind: string
|
|
6
|
+
command?: string
|
|
7
|
+
path?: string
|
|
8
|
+
amount?: string
|
|
9
|
+
recipient?: string
|
|
10
|
+
token?: string
|
|
11
|
+
/** Free-form reason for the human. */
|
|
12
|
+
reason?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type ApprovalDecision = 'allow' | 'allow-session' | 'deny' | 'expired'
|
|
16
|
+
|
|
17
|
+
export interface PendingApproval {
|
|
18
|
+
id: string
|
|
19
|
+
payload: ApprovalRequestPayload
|
|
20
|
+
createdAt: number
|
|
21
|
+
expiresAt: number
|
|
22
|
+
resolve: (decision: Exclude<ApprovalDecision, 'expired'>) => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class ApprovalRelay {
|
|
26
|
+
#pending = new Map<string, PendingApproval>()
|
|
27
|
+
#events: EventHub
|
|
28
|
+
#ttlMs: number
|
|
29
|
+
#idSeq = 0
|
|
30
|
+
#sweepTimer: ReturnType<typeof setInterval> | null = null
|
|
31
|
+
|
|
32
|
+
constructor(events: EventHub, opts: { ttlMs?: number; sweepIntervalMs?: number } = {}) {
|
|
33
|
+
this.#events = events
|
|
34
|
+
this.#ttlMs = opts.ttlMs ?? 5 * 60 * 1000
|
|
35
|
+
const sweepMs = opts.sweepIntervalMs ?? 5_000
|
|
36
|
+
this.#sweepTimer = setInterval(() => this.#sweepExpired(), sweepMs)
|
|
37
|
+
this.#sweepTimer.unref?.()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Create a pending approval, broadcast event, return a promise resolved by /approval/:id/respond. */
|
|
41
|
+
request(payload: ApprovalRequestPayload): { id: string; promise: Promise<ApprovalDecision> } {
|
|
42
|
+
this.#idSeq += 1
|
|
43
|
+
const id = `apv-${Date.now()}-${this.#idSeq}`
|
|
44
|
+
const createdAt = Date.now()
|
|
45
|
+
const expiresAt = createdAt + this.#ttlMs
|
|
46
|
+
|
|
47
|
+
const promise = new Promise<ApprovalDecision>(resolve => {
|
|
48
|
+
this.#pending.set(id, { id, payload, createdAt, expiresAt, resolve })
|
|
49
|
+
})
|
|
50
|
+
this.#events.publish('approval-needed', { id, payload, expiresAt })
|
|
51
|
+
return { id, promise }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Operator's signed decision arrived. Returns false if id unknown / already resolved. */
|
|
55
|
+
resolve(id: string, decision: Exclude<ApprovalDecision, 'expired'>): boolean {
|
|
56
|
+
const p = this.#pending.get(id)
|
|
57
|
+
if (!p) return false
|
|
58
|
+
this.#pending.delete(id)
|
|
59
|
+
p.resolve(decision)
|
|
60
|
+
this.#events.publish('approval-resolved', { id, decision })
|
|
61
|
+
return true
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
pendingCount(): number {
|
|
65
|
+
return this.#pending.size
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
has(id: string): boolean {
|
|
69
|
+
return this.#pending.has(id)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
stop(): void {
|
|
73
|
+
if (this.#sweepTimer) clearInterval(this.#sweepTimer)
|
|
74
|
+
this.#sweepTimer = null
|
|
75
|
+
for (const p of this.#pending.values()) {
|
|
76
|
+
p.resolve('deny')
|
|
77
|
+
}
|
|
78
|
+
this.#pending.clear()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#sweepExpired(): void {
|
|
82
|
+
const now = Date.now()
|
|
83
|
+
for (const [id, p] of this.#pending.entries()) {
|
|
84
|
+
if (now >= p.expiresAt) {
|
|
85
|
+
this.#pending.delete(id)
|
|
86
|
+
p.resolve('deny')
|
|
87
|
+
this.#events.publish('approval-expired', { id, expiredAt: now })
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Address,
|
|
3
|
+
type Hex,
|
|
4
|
+
encodeAbiParameters,
|
|
5
|
+
isAddressEqual,
|
|
6
|
+
keccak256,
|
|
7
|
+
recoverMessageAddress,
|
|
8
|
+
} from 'viem'
|
|
9
|
+
import type { RuntimeConfig } from './runtime'
|
|
10
|
+
|
|
11
|
+
/** Agent reference carried in a (legacy remote) provision request. */
|
|
12
|
+
interface ProvisionAgentRef {
|
|
13
|
+
contract: Address
|
|
14
|
+
tokenId: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ProvisionEnvelope {
|
|
18
|
+
ephPubkeyHex: Hex
|
|
19
|
+
ivHex: Hex
|
|
20
|
+
tagHex: Hex
|
|
21
|
+
ciphertextHex: Hex
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ProvisionRequest {
|
|
25
|
+
envelope: ProvisionEnvelope
|
|
26
|
+
/**
|
|
27
|
+
* Optional second ECIES envelope sealing the harness secrets JSON
|
|
28
|
+
* (telegram bot token + allowlist, etc.). Sealed to the same bootstrap
|
|
29
|
+
* pubkey. The operator's signature covers both envelopes so a stolen
|
|
30
|
+
* secrets envelope can't be replayed against a different harness.
|
|
31
|
+
*/
|
|
32
|
+
secretsEnvelope?: ProvisionEnvelope
|
|
33
|
+
operatorAddress: Address
|
|
34
|
+
iNFTRef: ProvisionAgentRef
|
|
35
|
+
config: RuntimeConfig
|
|
36
|
+
ts: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function envelopeHash(env: ProvisionEnvelope): Hex {
|
|
40
|
+
return keccak256(
|
|
41
|
+
encodeAbiParameters(
|
|
42
|
+
[
|
|
43
|
+
{ type: 'bytes', name: 'eph' },
|
|
44
|
+
{ type: 'bytes', name: 'iv' },
|
|
45
|
+
{ type: 'bytes', name: 'tag' },
|
|
46
|
+
{ type: 'bytes', name: 'ct' },
|
|
47
|
+
],
|
|
48
|
+
[env.ephPubkeyHex, env.ivHex, env.tagHex, env.ciphertextHex],
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function stableStringify(value: unknown): string {
|
|
54
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value)
|
|
55
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
|
|
56
|
+
// Skip undefined-valued keys to match `JSON.stringify` semantics. Critical
|
|
57
|
+
// because the wire path is `JSON.stringify` → JSON.parse, which silently
|
|
58
|
+
// drops undefined object values. If we hashed them as the literal text
|
|
59
|
+
// `undefined`, the CLI's pre-wire hash and the harness's post-wire hash
|
|
60
|
+
// would diverge for any optional field the caller leaves unset (e.g.
|
|
61
|
+
// `RuntimeConfig.promptAppend`), surfacing as `provision-rejected: sig-mismatch`.
|
|
62
|
+
const v = value as Record<string, unknown>
|
|
63
|
+
const keys = Object.keys(v)
|
|
64
|
+
.filter(k => v[k] !== undefined)
|
|
65
|
+
.sort()
|
|
66
|
+
const props = keys.map(k => `${JSON.stringify(k)}:${stableStringify(v[k])}`)
|
|
67
|
+
return `{${props.join(',')}}`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function configHash(config: RuntimeConfig): Hex {
|
|
71
|
+
// Stable JSON via recursive key-sorted stringify; harness + client must agree.
|
|
72
|
+
const stable = stableStringify(config)
|
|
73
|
+
return keccak256(`0x${Buffer.from(stable, 'utf8').toString('hex')}` as Hex)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Build the deterministic digest the operator signs over. Anchored to the
|
|
78
|
+
* harness bootstrap pubkey + config hash so a stolen envelope cannot be replayed
|
|
79
|
+
* against a different harness or a different runtime config.
|
|
80
|
+
*/
|
|
81
|
+
export function provisionMessageHash(req: ProvisionRequest, bootstrapPubkey: Hex): Hex {
|
|
82
|
+
// v0.18+ extends the digest with a secretsEnvelopeHash so a second envelope
|
|
83
|
+
// can ship telegram secrets etc. alongside the agent privkey. Zero-hash
|
|
84
|
+
// sentinel preserves the v0.17 digest when no secrets envelope is sent.
|
|
85
|
+
const secretsHash: Hex = req.secretsEnvelope
|
|
86
|
+
? envelopeHash(req.secretsEnvelope)
|
|
87
|
+
: ('0x0000000000000000000000000000000000000000000000000000000000000000' as Hex)
|
|
88
|
+
const encoded = encodeAbiParameters(
|
|
89
|
+
[
|
|
90
|
+
{ type: 'bytes32', name: 'envelopeHash' },
|
|
91
|
+
{ type: 'bytes32', name: 'secretsEnvelopeHash' },
|
|
92
|
+
{ type: 'bytes32', name: 'configHash' },
|
|
93
|
+
{ type: 'address', name: 'operator' },
|
|
94
|
+
{ type: 'address', name: 'inftContract' },
|
|
95
|
+
{ type: 'uint256', name: 'tokenId' },
|
|
96
|
+
{ type: 'uint64', name: 'ts' },
|
|
97
|
+
{ type: 'bytes', name: 'bootstrapPubkey' },
|
|
98
|
+
],
|
|
99
|
+
[
|
|
100
|
+
envelopeHash(req.envelope),
|
|
101
|
+
secretsHash,
|
|
102
|
+
configHash(req.config),
|
|
103
|
+
req.operatorAddress,
|
|
104
|
+
req.iNFTRef.contract,
|
|
105
|
+
BigInt(req.iNFTRef.tokenId),
|
|
106
|
+
BigInt(req.ts),
|
|
107
|
+
bootstrapPubkey,
|
|
108
|
+
],
|
|
109
|
+
)
|
|
110
|
+
return keccak256(encoded)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface VerifyOpts {
|
|
114
|
+
request: ProvisionRequest
|
|
115
|
+
signature: Hex
|
|
116
|
+
bootstrapPubkey: Hex
|
|
117
|
+
expectedOperator: Address
|
|
118
|
+
/** Reject ts older than this (default 5min). */
|
|
119
|
+
maxAgeMs?: number
|
|
120
|
+
/** Reject ts further into the future than this (default 1min for clock skew). */
|
|
121
|
+
maxFutureMs?: number
|
|
122
|
+
now?: number
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export type VerifyResult = { ok: true } | { ok: false; reason: string }
|
|
126
|
+
|
|
127
|
+
export async function verifyProvisionSig(opts: VerifyOpts): Promise<VerifyResult> {
|
|
128
|
+
const now = opts.now ?? Date.now()
|
|
129
|
+
const maxAge = opts.maxAgeMs ?? 5 * 60 * 1000
|
|
130
|
+
const maxFuture = opts.maxFutureMs ?? 60 * 1000
|
|
131
|
+
|
|
132
|
+
if (!isAddressEqual(opts.request.operatorAddress, opts.expectedOperator)) {
|
|
133
|
+
return { ok: false, reason: 'operator-mismatch' }
|
|
134
|
+
}
|
|
135
|
+
if (opts.request.ts > now + maxFuture) {
|
|
136
|
+
return { ok: false, reason: 'ts-future' }
|
|
137
|
+
}
|
|
138
|
+
if (opts.request.ts < now - maxAge) {
|
|
139
|
+
return { ok: false, reason: 'ts-stale' }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const hash = provisionMessageHash(opts.request, opts.bootstrapPubkey)
|
|
143
|
+
let recovered: Address
|
|
144
|
+
try {
|
|
145
|
+
recovered = await recoverMessageAddress({ message: { raw: hash }, signature: opts.signature })
|
|
146
|
+
} catch (e) {
|
|
147
|
+
return { ok: false, reason: `sig-decode: ${(e as Error).message}` }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (!isAddressEqual(recovered, opts.expectedOperator)) {
|
|
151
|
+
return { ok: false, reason: 'sig-mismatch' }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { ok: true }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Hash the operator signs to authenticate a chat message turn. Anchored to
|
|
159
|
+
* sandboxId so a chat sig cannot be replayed against a different sandbox
|
|
160
|
+
* harness running on the same operator.
|
|
161
|
+
*/
|
|
162
|
+
export function chatMessageHash(message: string, ts: number, sandboxId: string): Hex {
|
|
163
|
+
return keccak256(
|
|
164
|
+
encodeAbiParameters(
|
|
165
|
+
[
|
|
166
|
+
{ type: 'string', name: 'message' },
|
|
167
|
+
{ type: 'uint64', name: 'ts' },
|
|
168
|
+
{ type: 'string', name: 'sandboxId' },
|
|
169
|
+
],
|
|
170
|
+
[message, BigInt(ts), sandboxId],
|
|
171
|
+
),
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface VerifyChatOpts {
|
|
176
|
+
message: string
|
|
177
|
+
ts: number
|
|
178
|
+
sandboxId: string
|
|
179
|
+
signature: Hex
|
|
180
|
+
expectedOperator: Address
|
|
181
|
+
maxAgeMs?: number
|
|
182
|
+
maxFutureMs?: number
|
|
183
|
+
now?: number
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function verifyChatSig(opts: VerifyChatOpts): Promise<VerifyResult> {
|
|
187
|
+
const now = opts.now ?? Date.now()
|
|
188
|
+
const maxAge = opts.maxAgeMs ?? 5 * 60 * 1000
|
|
189
|
+
const maxFuture = opts.maxFutureMs ?? 60 * 1000
|
|
190
|
+
if (opts.ts > now + maxFuture) return { ok: false, reason: 'ts-future' }
|
|
191
|
+
if (opts.ts < now - maxAge) return { ok: false, reason: 'ts-stale' }
|
|
192
|
+
|
|
193
|
+
const hash = chatMessageHash(opts.message, opts.ts, opts.sandboxId)
|
|
194
|
+
let recovered: Address
|
|
195
|
+
try {
|
|
196
|
+
recovered = await recoverMessageAddress({ message: { raw: hash }, signature: opts.signature })
|
|
197
|
+
} catch (e) {
|
|
198
|
+
return { ok: false, reason: `sig-decode: ${(e as Error).message}` }
|
|
199
|
+
}
|
|
200
|
+
if (!isAddressEqual(recovered, opts.expectedOperator)) {
|
|
201
|
+
return { ok: false, reason: 'sig-mismatch' }
|
|
202
|
+
}
|
|
203
|
+
return { ok: true }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* v0.21.9: hash the operator signs to authenticate an admin tick (e.g.
|
|
208
|
+
* `POST /admin/autotopup/tick`) against the sandbox endpoint. Anchored to
|
|
209
|
+
* `action` + `sandboxId` so a sig for one admin endpoint can't be replayed
|
|
210
|
+
* against another, and the `chat`/`approval` sig spaces stay isolated from
|
|
211
|
+
* admin operations. Pattern mirrors `chatMessageHash` / `approvalResponseHash`.
|
|
212
|
+
*
|
|
213
|
+
* v0.24.4: `AdminAction` is a documentation-only union of actions currently
|
|
214
|
+
* accepted by sandbox endpoints. The hash + verifier accept arbitrary
|
|
215
|
+
* strings (so cross-action replay tests can sign non-existent actions); the
|
|
216
|
+
* allowlist is enforced at the route layer in `server.ts`. Add new admin
|
|
217
|
+
* endpoints here so call-site authors can grep for the canonical name.
|
|
218
|
+
*
|
|
219
|
+
* - 'autotopup-tick' → POST /admin/autotopup/tick
|
|
220
|
+
* - 'profile-key' → POST /admin/profile-key
|
|
221
|
+
* - 'pairing-approve' → POST /admin/pairing/approve
|
|
222
|
+
*/
|
|
223
|
+
export type AdminAction = 'autotopup-tick' | 'profile-key' | 'pairing-approve'
|
|
224
|
+
|
|
225
|
+
export function adminTickHash(opts: {
|
|
226
|
+
action: AdminAction | string
|
|
227
|
+
ts: number
|
|
228
|
+
sandboxId: string
|
|
229
|
+
}): Hex {
|
|
230
|
+
return keccak256(
|
|
231
|
+
encodeAbiParameters(
|
|
232
|
+
[
|
|
233
|
+
{ type: 'string', name: 'action' },
|
|
234
|
+
{ type: 'uint64', name: 'ts' },
|
|
235
|
+
{ type: 'string', name: 'sandboxId' },
|
|
236
|
+
],
|
|
237
|
+
[opts.action, BigInt(opts.ts), opts.sandboxId],
|
|
238
|
+
),
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export interface VerifyAdminTickOpts {
|
|
243
|
+
action: AdminAction | string
|
|
244
|
+
ts: number
|
|
245
|
+
sandboxId: string
|
|
246
|
+
signature: Hex
|
|
247
|
+
expectedOperator: Address
|
|
248
|
+
maxAgeMs?: number
|
|
249
|
+
maxFutureMs?: number
|
|
250
|
+
now?: number
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export async function verifyAdminTickSig(opts: VerifyAdminTickOpts): Promise<VerifyResult> {
|
|
254
|
+
const now = opts.now ?? Date.now()
|
|
255
|
+
const maxAge = opts.maxAgeMs ?? 5 * 60 * 1000
|
|
256
|
+
const maxFuture = opts.maxFutureMs ?? 60 * 1000
|
|
257
|
+
if (opts.ts > now + maxFuture) return { ok: false, reason: 'ts-future' }
|
|
258
|
+
if (opts.ts < now - maxAge) return { ok: false, reason: 'ts-stale' }
|
|
259
|
+
|
|
260
|
+
const hash = adminTickHash({
|
|
261
|
+
action: opts.action,
|
|
262
|
+
ts: opts.ts,
|
|
263
|
+
sandboxId: opts.sandboxId,
|
|
264
|
+
})
|
|
265
|
+
let recovered: Address
|
|
266
|
+
try {
|
|
267
|
+
recovered = await recoverMessageAddress({ message: { raw: hash }, signature: opts.signature })
|
|
268
|
+
} catch (e) {
|
|
269
|
+
return { ok: false, reason: `sig-decode: ${(e as Error).message}` }
|
|
270
|
+
}
|
|
271
|
+
if (!isAddressEqual(recovered, opts.expectedOperator)) {
|
|
272
|
+
return { ok: false, reason: 'sig-mismatch' }
|
|
273
|
+
}
|
|
274
|
+
return { ok: true }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Hash the operator signs for an approval response.
|
|
279
|
+
*/
|
|
280
|
+
export function approvalResponseHash(opts: {
|
|
281
|
+
approvalId: string
|
|
282
|
+
decision: 'allow' | 'allow-session' | 'deny'
|
|
283
|
+
ts: number
|
|
284
|
+
sandboxId: string
|
|
285
|
+
}): Hex {
|
|
286
|
+
return keccak256(
|
|
287
|
+
encodeAbiParameters(
|
|
288
|
+
[
|
|
289
|
+
{ type: 'string', name: 'approvalId' },
|
|
290
|
+
{ type: 'string', name: 'decision' },
|
|
291
|
+
{ type: 'uint64', name: 'ts' },
|
|
292
|
+
{ type: 'string', name: 'sandboxId' },
|
|
293
|
+
],
|
|
294
|
+
[opts.approvalId, opts.decision, BigInt(opts.ts), opts.sandboxId],
|
|
295
|
+
),
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export interface VerifyApprovalOpts {
|
|
300
|
+
approvalId: string
|
|
301
|
+
decision: 'allow' | 'allow-session' | 'deny'
|
|
302
|
+
ts: number
|
|
303
|
+
sandboxId: string
|
|
304
|
+
signature: Hex
|
|
305
|
+
expectedOperator: Address
|
|
306
|
+
maxAgeMs?: number
|
|
307
|
+
maxFutureMs?: number
|
|
308
|
+
now?: number
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export async function verifyApprovalSig(opts: VerifyApprovalOpts): Promise<VerifyResult> {
|
|
312
|
+
const now = opts.now ?? Date.now()
|
|
313
|
+
const maxAge = opts.maxAgeMs ?? 5 * 60 * 1000
|
|
314
|
+
const maxFuture = opts.maxFutureMs ?? 60 * 1000
|
|
315
|
+
if (opts.ts > now + maxFuture) return { ok: false, reason: 'ts-future' }
|
|
316
|
+
if (opts.ts < now - maxAge) return { ok: false, reason: 'ts-stale' }
|
|
317
|
+
|
|
318
|
+
const hash = approvalResponseHash({
|
|
319
|
+
approvalId: opts.approvalId,
|
|
320
|
+
decision: opts.decision,
|
|
321
|
+
ts: opts.ts,
|
|
322
|
+
sandboxId: opts.sandboxId,
|
|
323
|
+
})
|
|
324
|
+
let recovered: Address
|
|
325
|
+
try {
|
|
326
|
+
recovered = await recoverMessageAddress({ message: { raw: hash }, signature: opts.signature })
|
|
327
|
+
} catch (e) {
|
|
328
|
+
return { ok: false, reason: `sig-decode: ${(e as Error).message}` }
|
|
329
|
+
}
|
|
330
|
+
if (!isAddressEqual(recovered, opts.expectedOperator)) {
|
|
331
|
+
return { ok: false, reason: 'sig-mismatch' }
|
|
332
|
+
}
|
|
333
|
+
return { ok: true }
|
|
334
|
+
}
|