|
12 | 12 | // Yang TIDAK dilakukan agen mana pun di berkas ini: menghitung status. PASHA |
13 | 13 | // dipanggil executor (server) dari artefak yang dikutip replay, sesuai Bagian 4. |
14 | 14 |
|
| 15 | +import { createHmac, timingSafeEqual } from "node:crypto"; |
| 16 | + |
15 | 17 | import { |
16 | 18 | ModelBehaviorError, |
17 | 19 | RunState, |
@@ -81,9 +83,33 @@ export const ResumeTokenSchema = z.object({ |
81 | 83 | ttl_s: z.literal(TTL_DETIK), |
82 | 84 | run_id: z.string().regex(/^[a-z0-9]{6,32}$/), |
83 | 85 | expires_at: z.iso.datetime({ offset: true }), |
| 86 | + sig: z.string().regex(/^[0-9a-f]{64}$/), |
84 | 87 | }); |
85 | 88 | export type ResumeToken = z.infer<typeof ResumeTokenSchema>; |
86 | 89 |
|
| 90 | +/** Token adalah bearer yang bolak-balik lewat klien; tanpa tanda tangan, field |
| 91 | + * mana pun (terutama state_ref) bisa diganti. HMAC atas JSON kanonis tujuh |
| 92 | + * field non-sig; rahasia dari env, fallback nilai dev yang sama konvensinya |
| 93 | + * dengan garam MMSI. */ |
| 94 | +const rahasiaToken = (): string => |
| 95 | + process.env.RESUME_TOKEN_SECRET ?? process.env.MMSI_HASH_SALT ?? "varuna-dev-salt-2026"; |
| 96 | + |
| 97 | +const tandaTanganToken = (t: Omit<ResumeToken, "sig">): string => |
| 98 | + createHmac("sha256", rahasiaToken()) |
| 99 | + .update( |
| 100 | + JSON.stringify( |
| 101 | + Object.fromEntries(Object.entries(t).sort(([a], [b]) => (a < b ? -1 : 1))), |
| 102 | + ), |
| 103 | + ) |
| 104 | + .digest("hex"); |
| 105 | + |
| 106 | +const sigCocok = (t: ResumeToken): boolean => { |
| 107 | + const { sig, ...tanpa } = t; |
| 108 | + const a = Buffer.from(tandaTanganToken(tanpa), "hex"); |
| 109 | + const b = Buffer.from(sig, "hex"); |
| 110 | + return a.length === b.length && timingSafeEqual(a, b); |
| 111 | +}; |
| 112 | + |
87 | 113 | // --------------------------------------------------------------------------- |
88 | 114 | // Peristiwa SSE (Bagian 3, event `agent_step`) |
89 | 115 | // --------------------------------------------------------------------------- |
@@ -456,17 +482,18 @@ async function jeda( |
456 | 482 | }); |
457 | 483 |
|
458 | 484 | const nama = namaTertunda(tertunda[0] as RunToolApprovalItem); |
| 485 | + const tanpaSig: Omit<ResumeToken, "sig"> = { |
| 486 | + inv_id: ktx.inv.inv_id, |
| 487 | + step_idx: berikut, |
| 488 | + state_ref: refState(kunci), |
| 489 | + seed: SEED, |
| 490 | + ttl_s: TTL_DETIK, |
| 491 | + run_id: ktx.run_id, |
| 492 | + expires_at: new Date(Date.parse(m.sekarang()) + TTL_DETIK * 1000).toISOString(), |
| 493 | + }; |
459 | 494 | return { |
460 | 495 | selesai: false, |
461 | | - resume_token: { |
462 | | - inv_id: ktx.inv.inv_id, |
463 | | - step_idx: berikut, |
464 | | - state_ref: refState(kunci), |
465 | | - seed: SEED, |
466 | | - ttl_s: TTL_DETIK, |
467 | | - run_id: ktx.run_id, |
468 | | - expires_at: new Date(Date.parse(m.sekarang()) + TTL_DETIK * 1000).toISOString(), |
469 | | - }, |
| 496 | + resume_token: { ...tanpaSig, sig: tandaTanganToken(tanpaSig) }, |
470 | 497 | agen_berikut: nama === undefined ? null : idDariNamaAlat(nama), |
471 | 498 | trace_ref: ktx.trace_ref, |
472 | 499 | run_id: ktx.run_id, |
@@ -515,6 +542,9 @@ export async function lanjutkanReplay( |
515 | 542 | if (!token.success) { |
516 | 543 | throw new GalatReplay("token", "resume_token tidak sah atau bukan milik protokol replay ini."); |
517 | 544 | } |
| 545 | + if (!sigCocok(token.data)) { |
| 546 | + throw new GalatReplay("token", "tanda tangan resume_token tidak cocok; token diubah atau berasal dari server lain."); |
| 547 | + } |
518 | 548 | if (Date.parse(token.data.expires_at) <= Date.parse(m.sekarang())) { |
519 | 549 | throw new GalatReplay( |
520 | 550 | "kedaluwarsa", |
|
0 commit comments