Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,22 @@ SENTINEL_MODE=advisory
# Empty = refund attempts throw at runtime (caught by circuit breaker).
SENTINEL_AUTHORITY_KEYPAIR=

# ───────────────────────────────────────────
# Server-side signature verification (Spec 3)
# ───────────────────────────────────────────
# Controls how POST /api/tool-signing/:flagId/confirm verifies client-supplied
# signatures via Solana RPC (getSignatureStatuses + getTransaction fee-payer).
# strict (default) — reject pending + 4xx VALIDATION_FAILED on verify failure;
# 503 + Retry-After on RPC unavailability
# advisory — verify and log on failure, but still resolve pending
# (used for soak-testing the verifier without UX impact)
# off — skip verification entirely (legacy behavior)
#
# Code defaults to 'strict' when unset. Ship 'advisory' here as the safe first-
# deploy default so operators can promote to 'strict' after >= 1 week of
# observation (mirrors the SENTINEL_MODE rollout posture).
SIPHER_SIG_VERIFY=advisory

# ───────────────────────────────────────────
# Phase 4 — Network configuration
# ───────────────────────────────────────────
Expand Down
63 changes: 61 additions & 2 deletions packages/agent/src/routes/tool-signing.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,38 @@
import { Router, type Request, type Response } from 'express'
import { createConnection } from '@sipher/sdk'
import {
getPendingSigning,
resolvePendingSigning,
rejectPendingSigning,
} from '../sentinel/pending-signing.js'
import { verifySignature, type VerifyResult } from '../sentinel/verify-signature.js'
import { loadNetworkConfig } from '../config/network.js'
import { sendSentinelError } from './sentinel-errors.js'

export const toolSigningRouter = Router()

type VerifyMode = 'strict' | 'advisory' | 'off'

function loadVerifyMode(): VerifyMode {
const raw = (process.env.SIPHER_SIG_VERIFY ?? 'strict').toLowerCase()
if (raw === 'strict' || raw === 'advisory' || raw === 'off') return raw
return 'strict'
}

/**
* POST /api/tool-signing/:flagId/confirm
* Body: { signature: string }
* Resolves the pending signing promise with the on-chain tx signature.
* Wallet binding: JWT wallet must equal the pending entry's wallet.
*
* Server-side verification (Spec 3 — PR #279):
* SIPHER_SIG_VERIFY=strict (default) — verify via Solana RPC; reject pending
* on failure and return 4xx VALIDATION_FAILED. RPC unavailable → 503.
* SIPHER_SIG_VERIFY=advisory — verify and log on failure, but still resolve.
* Used for soak-testing the verifier without blocking the UX.
* SIPHER_SIG_VERIFY=off — skip verification entirely (legacy behavior).
*/
toolSigningRouter.post('/:flagId/confirm', (req: Request, res: Response) => {
toolSigningRouter.post('/:flagId/confirm', async (req: Request, res: Response) => {
const flagId = req.params.flagId as string
const entry = getPendingSigning(flagId)
if (!entry) {
Expand All @@ -39,8 +57,49 @@ toolSigningRouter.post('/:flagId/confirm', (req: Request, res: Response) => {
return
}

const mode = loadVerifyMode()
let verifyResult: VerifyResult | null = null
if (mode !== 'off') {
try {
const net = loadNetworkConfig()
const connection = createConnection(net.clusterName, net.rpcUrl)
verifyResult = await verifySignature(signature, entry, { connection })
} catch (err) {
const detail = err instanceof Error ? err.message : String(err)
verifyResult = { ok: false, reason: 'rpc_error', detail }
}

if (!verifyResult.ok) {
if (mode === 'strict') {
if (verifyResult.reason === 'rpc_error' || verifyResult.reason === 'timeout') {
// RPC blip — let the client retry rather than killing the pending entry.
res.setHeader('Retry-After', '2')
sendSentinelError(
res,
'UNAVAILABLE',
`signature verification unavailable (${verifyResult.reason})`,
)
return
}
rejectPendingSigning(flagId, `verification_failed: ${verifyResult.reason}`)
sendSentinelError(
res,
'VALIDATION_FAILED',
`signature verification failed: ${verifyResult.reason}${verifyResult.detail ? ` (${verifyResult.detail})` : ''}`,
)
return
}
console.warn(
`[signing] verify failed (advisory mode): flagId=${flagId} reason=${verifyResult.reason}${verifyResult.detail ? ` detail=${verifyResult.detail}` : ''}`,
)
}
}

resolvePendingSigning(flagId, signature)
res.status(200).json({ status: 'accepted' })
res.status(200).json({
status: 'accepted',
verified: mode === 'strict' && verifyResult?.ok === true,
})
})

/**
Expand Down
136 changes: 136 additions & 0 deletions packages/agent/src/sentinel/verify-signature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import type { Connection } from '@solana/web3.js'
import type { PendingSigningFlag } from './pending-signing.js'

/**
* Result of server-side signature verification.
*
* Discriminated union so the consumer (confirm route) can switch on `reason`
* without losing type narrowing. The `assertNever` exhaustiveness guard added
* in PR #277 applies here when the consumer dispatches on this union.
*/
export type VerifyResult =
| { ok: true; slot: number }
| {
ok: false
reason: 'not_confirmed' | 'wallet_mismatch' | 'rpc_error' | 'timeout'
detail?: string
}

export interface VerifyOptions {
connection: Connection
/** Total budget for verification. Default 3000ms. */
timeoutMs?: number
}

/**
* Verify that a signature submitted to /api/tool-signing/:flagId/confirm
* corresponds to a real on-chain Solana transaction signed by the wallet
* recorded in the pending entry.
*
* Two tiers:
* - T1: getSignatureStatuses — confirmed/finalized AND no err
* - T2: getTransaction — fee payer (accountKeys[0]) === entry.wallet
*
* T3 (instruction-match against entry.serializedTx) is deferred per spec.
*
* Returns a discriminated VerifyResult — caller decides how to act based on
* the SIPHER_SIG_VERIFY mode.
*/
export async function verifySignature(
signature: string,
entry: PendingSigningFlag,
opts: VerifyOptions,
): Promise<VerifyResult> {
const timeoutMs = opts.timeoutMs ?? 3000
return Promise.race([
runVerification(signature, entry, opts.connection),
new Promise<VerifyResult>((resolve) =>
setTimeout(() => resolve({ ok: false, reason: 'timeout' }), timeoutMs),
),
])
}

async function runVerification(
signature: string,
entry: PendingSigningFlag,
connection: Connection,
): Promise<VerifyResult> {
// ── T1: existence + confirmation status ────────────────────────────────
let statuses
try {
statuses = await connection.getSignatureStatuses([signature])
} catch (err) {
return {
ok: false,
reason: 'rpc_error',
detail: err instanceof Error ? err.message : String(err),
}
}

const status = statuses?.value?.[0]
if (!status) {
return { ok: false, reason: 'not_confirmed' }
}

if (status.err) {
return {
ok: false,
reason: 'not_confirmed',
detail: typeof status.err === 'object' ? JSON.stringify(status.err) : String(status.err),
}
}

if (status.confirmationStatus !== 'confirmed' && status.confirmationStatus !== 'finalized') {
return {
ok: false,
reason: 'not_confirmed',
detail: `confirmationStatus=${status.confirmationStatus ?? 'unknown'}`,
}
}

// ── T2: fee payer must match entry.wallet ──────────────────────────────
let tx
try {
tx = await connection.getTransaction(signature, {
commitment: 'confirmed',
maxSupportedTransactionVersion: 0,
})
} catch (err) {
return {
ok: false,
reason: 'rpc_error',
detail: err instanceof Error ? err.message : String(err),
}
}

if (!tx) {
return { ok: false, reason: 'not_confirmed', detail: 'tx body not available after confirmation' }
}

// Versioned messages expose `staticAccountKeys`; legacy messages use
// `accountKeys`. Mocks provide whichever path the test cares about.
// Index 0 is the fee payer in both shapes.
const feePayer = extractFeePayer(tx.transaction?.message as unknown)
if (feePayer !== entry.wallet) {
return {
ok: false,
reason: 'wallet_mismatch',
detail: `expected ${entry.wallet}, got ${feePayer ?? '(no fee payer)'}`,
}
}

return { ok: true, slot: status.slot }
}

function extractFeePayer(message: unknown): string | null {
if (message === null || typeof message !== 'object') return null
const m = message as {
staticAccountKeys?: Array<{ toBase58?: () => string } | string>
accountKeys?: Array<{ toBase58?: () => string } | string>
}
const candidate = m.staticAccountKeys?.[0] ?? m.accountKeys?.[0]
if (!candidate) return null
if (typeof candidate === 'string') return candidate
if (typeof candidate.toBase58 === 'function') return candidate.toBase58()
return null
}
Loading
Loading