@oracle-agent/oracle 0.3.1 → 0.3.3

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.
@@ -1,20 +1,28 @@
1
1
  // Canonical prepare envelope shared by Oracle prepare helpers and the local operator.
2
- // The versioned checksum binds payload and freshness metadata. It detects mutation;
3
- // signer-owned policy, not this public checksum, authorizes execution.
2
+ // The versioned checksum binds payload and freshness metadata. It detects mutation.
3
+ // Optional HMAC (ORACLE_STAMP_HMAC_SECRET) adds keyed integrity when configured.
4
+ // Signer-owned policy, not this public checksum alone, authorizes execution.
4
5
 
5
- import { createHash } from "node:crypto";
6
+ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
6
7
 
7
8
  export const PREPARE_VERSION = 2;
8
9
  /** Default max age for a prepared envelope at sign time (ms). */
9
10
  export const PREPARE_MAX_AGE_MS = 5 * 60 * 1000;
10
- /** Hard ceiling for authenticated envelope lifetime (ms). */
11
+ /**
12
+ * Hard ceiling for maxAgeMs. Caller/agent cannot widen past this.
13
+ * Override upward only via signer-process env ORACLE_PREPARE_HARD_MAX_AGE_MS.
14
+ */
11
15
  export const PREPARE_HARD_MAX_AGE_MS = (() => {
12
16
  const n = Number(process.env.ORACLE_PREPARE_HARD_MAX_AGE_MS);
13
- return Number.isFinite(n) && n > 0 ? n : 30 * 60 * 1000;
17
+ return Number.isFinite(n) && n > 0 ? n : 30 * 60 * 1000; // 30 minutes
14
18
  })();
15
19
 
20
+ /** Resolve max age: default 5m, never above hard max (even if caller passes MAX_SAFE_INTEGER). */
16
21
  export function resolvePrepareMaxAgeMs(requested) {
17
- const base = requested == null || requested === "" ? PREPARE_MAX_AGE_MS : Number(requested);
22
+ const base =
23
+ requested == null || requested === ""
24
+ ? PREPARE_MAX_AGE_MS
25
+ : Number(requested);
18
26
  if (!Number.isFinite(base) || base < 0) return PREPARE_MAX_AGE_MS;
19
27
  return Math.min(base, PREPARE_HARD_MAX_AGE_MS);
20
28
  }
@@ -26,11 +34,21 @@ function stableStringify(value) {
26
34
  return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(",")}}`;
27
35
  }
28
36
 
37
+ function stampSecret(env = process.env) {
38
+ const s = String(env.ORACLE_STAMP_HMAC_SECRET || "").trim();
39
+ return s.length >= 16 ? s : "";
40
+ }
41
+
42
+ function requireMac(env = process.env) {
43
+ return String(env.ORACLE_STAMP_REQUIRE_MAC || "").trim() === "1";
44
+ }
45
+
29
46
  /** Fields that bind the envelope (must not include the stamp fields themselves). */
30
47
  export function prepareBodyForHash(prepared) {
31
48
  const {
32
49
  oraclePrepared,
33
50
  prepareHash,
51
+ prepareMac,
34
52
  note,
35
53
  ...body
36
54
  } = prepared || {};
@@ -42,11 +60,27 @@ export function computePrepareHash(prepared) {
42
60
  return createHash("sha256").update(stableStringify(body)).digest("hex");
43
61
  }
44
62
 
63
+ export function computePrepareMac(prepareHash, secret = stampSecret()) {
64
+ if (!secret) return "";
65
+ return createHmac("sha256", secret).update(String(prepareHash)).digest("hex");
66
+ }
67
+
68
+ function safeEqualHex(a, b) {
69
+ try {
70
+ const ba = Buffer.from(String(a), "hex");
71
+ const bb = Buffer.from(String(b), "hex");
72
+ if (ba.length === 0 || ba.length !== bb.length) return false;
73
+ return timingSafeEqual(ba, bb);
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+
45
79
  /**
46
80
  * Stamp a prepare result. Call at the end of every prepare helper.
47
81
  * @param {object} payload provider-specific prepare fields (action/tx/psbt/...)
48
82
  */
49
- export function stampPrepared(payload, { provider, kind } = {}) {
83
+ export function stampPrepared(payload, { provider, kind, env = process.env } = {}) {
50
84
  if (!payload || typeof payload !== "object") throw new Error("stampPrepared: payload object required");
51
85
  const base = {
52
86
  ...payload,
@@ -64,11 +98,15 @@ export function stampPrepared(payload, { provider, kind } = {}) {
64
98
  const prepareVersion = PREPARE_VERSION;
65
99
  const withMeta = { ...base, preparedAt, expiresAt, prepareVersion };
66
100
  const prepareHash = computePrepareHash(withMeta);
67
- return {
101
+ const secret = stampSecret(env);
102
+ const prepareMac = secret ? computePrepareMac(prepareHash, secret) : undefined;
103
+ const out = {
68
104
  ...withMeta,
69
105
  oraclePrepared: true,
70
106
  prepareHash,
71
107
  };
108
+ if (prepareMac) out.prepareMac = prepareMac;
109
+ return out;
72
110
  }
73
111
 
74
112
  /**
@@ -81,13 +119,14 @@ export function assertPreparedEnvelope(prepared, {
81
119
  providers = null,
82
120
  kinds = null,
83
121
  label = "operator",
122
+ env = process.env,
84
123
  } = {}) {
85
124
  if (!prepared || typeof prepared !== "object") {
86
125
  throw new Error(`${label}: prepared envelope object required`);
87
126
  }
88
127
  if (prepared.oraclePrepared !== true) {
89
128
  throw new Error(
90
- `${label}: prepared integrity envelope missing — use an @oracle-agent/oracle prepare result`
129
+ `${label}: prepared integrity envelope missing — use an @oracle-agent/oracle prepare result`,
91
130
  );
92
131
  }
93
132
  if (Number(prepared.prepareVersion) !== PREPARE_VERSION) {
@@ -97,6 +136,21 @@ export function assertPreparedEnvelope(prepared, {
97
136
  if (String(prepared.prepareHash || "") !== expected) {
98
137
  throw new Error(`${label}: prepareHash mismatch — payload was altered after prepare`);
99
138
  }
139
+
140
+ const secret = stampSecret(env);
141
+ const mac = String(prepared.prepareMac || "");
142
+ if (requireMac(env) || mac) {
143
+ if (!secret) {
144
+ throw new Error(
145
+ `${label}: prepareMac present or ORACLE_STAMP_REQUIRE_MAC=1 but ORACLE_STAMP_HMAC_SECRET missing in signer`,
146
+ );
147
+ }
148
+ const expectedMac = computePrepareMac(expected, secret);
149
+ if (!mac || !safeEqualHex(mac, expectedMac)) {
150
+ throw new Error(`${label}: prepareMac mismatch — stamp not from trusted preparer`);
151
+ }
152
+ }
153
+
100
154
  const preparedAt = Number(prepared.preparedAt);
101
155
  const expiresAt = Number(prepared.expiresAt);
102
156
  const effectiveMax = resolvePrepareMaxAgeMs(maxAgeMs);
@@ -112,7 +166,9 @@ export function assertPreparedEnvelope(prepared, {
112
166
  age > effectiveMax ||
113
167
  nowMs > expiresAt
114
168
  ) {
115
- throw new Error(`${label}: prepare envelope expired or clock-skewed (ageMs=${age}, max=${effectiveMax})`);
169
+ throw new Error(
170
+ `${label}: prepare envelope expired or clock-skewed (ageMs=${age}, max=${effectiveMax})`,
171
+ );
116
172
  }
117
173
  if (providers) {
118
174
  const allow = new Set(providers);
@@ -129,6 +129,7 @@ export function assertVaultAttestation(attestation, tx = {}, { chainId, nowMs =
129
129
  export function assertVaultApprovalAttestation(attestation, tx = {}, guard = {}, { chainId, nowMs = Date.now(), secret } = {}) {
130
130
  if (!attestation || attestation.mode !== "vault-attestation") throw new Error("vault attestation required");
131
131
  if (String(attestation.action) !== "deposit") throw new Error("vault approval requires deposit attestation");
132
+ assertFreshWindow(attestation, nowMs, "vault approval attestation");
132
133
  if (Number(attestation.expiresAtMs) <= Number(nowMs)) throw new Error("vault attestation expired");
133
134
  const expected = hmac(attestationSecret(secret), canonicalJson(unsigned(attestation)));
134
135
  if (!sameSignature(attestation.signature, expected)) throw new Error("vault attestation signature mismatch");