← 프로젝트 목록
완료

Off-chase

에이전트 결제 결정의 독립 검증.

AI 에이전트의 결제 요청과 기관의 거절 판단을 서명된 레코드로 남기고 Sepolia에 고정해, 기관 DB 없이도 제3자가 검증할 수 있게 만든 시스템입니다.

기간
2026.09.10 – 09.20
출처
TRUST404 Web3 보안 해커톤 · Track 3 · 특별상
내 역할
백엔드 · 블록체인
팀
2명 (프론트·배포는 팀원)
이 페이지의 목차

문제

에이전트의 결제 요청이 기관 정책에 의해 거절되면 자금 이동 트랜잭션이 생기지 않습니다. 그래서 요청과 판단의 근거가 기관 내부 DB에만 남고, 나중에 그 기록을 고치거나 지워도 외부인은 기관이 지금 보여주는 로그만으로 원본을 확인하기 어렵습니다.

서명된 결정 한 건만 보관하는 방식으로는 "요청을 받고도 결정을 남기지 않은" 경우를 구분할 수 없습니다. 그래서 요청이 존재했다는 사실을 판단보다 먼저 외부에 고정해야 했습니다.

해결 개요

기업 정책, 에이전트 요청, Gateway 접수 영수증, 기관 결정을 각각 서명된 JSON 레코드로 연결했습니다. Gateway가 요청·정책 해시를 먼저 Sepolia에 기록하고, 기관 결정이 나오면 결정 해시를 추가로 기록합니다. 제3자는 원본 레코드가 담긴 Evidence Bundle, 별도로 신뢰하는 공개 주소 등록부, 체인 읽기 권한만으로 서명·참조·앵커·기한을 확인합니다. 기관 DB는 과거 판단의 최종 근거가 아닙니다.

  1. 01서명된 정책 · 요청기업이 정책을, 에이전트가 결제 요청을 각자의 키로 서명한다.
  2. 02Request AnchorGateway가 요청·정책 해시를 판단보다 먼저 Sepolia에 기록한다.
  3. 03접수 영수증 · 기관 판단체인 시각과 기한이 담긴 영수증을 받은 요청만 기관이 판단한다.
  4. 04Decision Anchor · 증거 번들서명된 결정의 해시를 기한 안에 기록하고, 원본 레코드를 번들로 묶는다.
  5. 05독립 검증번들, 공개 주소 등록부, 체인 조회만으로 서명·참조·기한을 확인한다.

구조

구성 요소역할
레코드·암호 처리엄격한 JSON 스키마, 정규화, 해시, 서명 검증
Enterprise · Agent정책과 결제 요청 생성·서명 (모의 역할)
Mandatory Gateway서명·정책 검사, 앵커 순서 제어, 접수 영수증과 번들 생성
Mock InstitutionGateway 영수증이 있는 요청만 판단하고 결정 서명
DecisionAnchor요청·정책·결정 해시, 블록 시각, 30초 기한 저장 (Solidity)
체인 어댑터컨트랙트 쓰기·읽기, 트랜잭션 이벤트 검사와 복구
영속성레코드·앵커 참조·번들 저장 (Supabase)
독립 검증기DB 없이 구조·서명·체인·정책·기한 검사 (CLI)
HTTP · 화면사건 생성·조회·다운로드·업로드 검증과 결과 표시

원본 정책·요청·결정·서명은 오프체인 JSON과 DB에 있고, 체인에는 해시와 시각만 남습니다. 브라우저는 개인키나 DB 서비스 키를 갖지 않습니다. 검증기는 번들의 원본 JSON에서 해시를 다시 계산해 체인 값과 비교하며, 저장소에 의존하지 않습니다.

내가 맡은 부분

오프체인 판단을 서명 레코드로 모델링하고, 요청을 먼저 체인에 고정한 뒤, 기관 DB 없이 증거를 검증하는 백엔드 경로와 검증 도구를 구현했습니다. 화면·배포·최종 통합은 팀원이 맡았습니다.

결제 판단을 서명 가능한 레코드로 정의

만든 것

Policy, Request, Verification Receipt, Decision, Evidence Bundle의 Zod 스키마와 공통 해시·서명 함수를 구현했습니다. Policy는 기업 키, Request는 에이전트 키, Receipt는 Gateway 키, Decision은 기관 키로 서명하고, 체인에 기록하는 Anchor Writer는 별도 키입니다. 예를 들어 한도 4,000 USDC에 4,500 USDC 요청은 REJECT / LIMIT_EXCEEDED로 기록되고, 금액은 6자리 최소 단위 정수 문자열과 BigInt로 비교합니다.

설계 결정

JSON 문자열을 그대로 해시하지 않고 canonicalize한 UTF-8 바이트를 Keccak-256에 넣어, 키 순서나 공백 때문에 같은 레코드의 해시가 달라지지 않게 했습니다. 각 레코드의 서명 필드는 해시에서 제외하고, 32바이트 해시를 EVM 메시지 서명(EIP-191)으로 서명합니다.

export function hashRecord(record: object, signatureField: string): Hex {
  const payload: Record<string, unknown> = { ...record };
  delete payload[signatureField];
  const canonical = canonicalize(payload);
  if (canonical === undefined) throw new Error('INVALID_RECORD');
  return keccak256(toBytes(canonical));
}

export async function signRecord<T extends object>(
  record: T,
  signatureField: string,
  account: PrivateKeyAccount,
): Promise<T & Record<string, Hex>> {
  const hash = hashRecord(record, signatureField);
  const signature = await account.signMessage({ message: { raw: hash } });
  return { ...record, [signatureField]: signature };
}
어려웠던 점

같은 정책 ID 아래 내용이 바뀌어도 기존 요청의 의미가 바뀌지 않도록 Request와 Decision에 policy_hash를 넣었습니다. 공개 주소는 번들 안에서 바꿔치기할 수 없게 key_id → address 등록부를 별도 입력으로 받습니다.

검증

고정 입력·서명·예상 해시를 담은 golden 테스트로, 정규화·해시 규칙이 바뀌면 바로 실패하게 했습니다.

기관보다 먼저 요청을 고정하는 Mandatory Gateway

만든 것

요청 검증부터 결정 앵커까지의 순서를 submitRequest와 submitDecision으로 구현했습니다. Gateway를 통과했다는 서명 영수증이 있어야 기관이 요청을 처리합니다.

설계 결정

결정만 체인에 올리지 않고 Request Anchor를 먼저 만들었습니다. 결정이 끝내 생성되지 않아도 요청이 존재했다는 외부 기준이 남습니다. 기관을 호출하기 전에 decision: null인 번들을 먼저 저장해, 이후 실패에도 요청 단계 증거가 남게 했습니다.

    const requestAnchor = await this.anchor.anchorRequest(request.request_id, requestHash, request.policy_hash as `0x${string}`);
    const receipt = await createReceipt(this.verificationAccount, request, requestAnchor);
    const bundle = EvidenceBundleSchema.parse({
      schema_version: 1, policy, request, verification_receipt: receipt, decision: null,
      anchors: {
        chain_id: this.anchor.chainId, contract_address: this.anchor.address, request_tx: requestAnchor.requestTx,
      },
    });
    await this.store.saveBundle(bundle);
    if (options.omitDecision) return bundle;
    const decision = await decideRequest(this.institutionAccount, request, policy, receipt, this.registry);
    return this.submitDecision(decision);
  }
어려웠던 점

같은 요청 ID를 다시 보낼 때 체인 쓰기가 반복되면 비용과 상태 충돌이 생깁니다. 저장된 요청과 해시가 같으면 기존 번들을 돌려주고, 다르면 REQUEST_ID_CONFLICT로 거절했습니다. 결정도 요청·정책 해시와 정책 ID가 모두 맞아야 하고, 같은 결정의 재제출과 다른 결정의 덮어쓰기를 구분합니다.

Sepolia 앵커 컨트랙트와 체인 어댑터

만든 것

DecisionAnchor.sol, 배포 스크립트, viem 기반 체인 어댑터를 구현했습니다.

체인에 올리는 것

머클 루트가 아니라 요청·정책·결정 각각의 Keccak-256 해시입니다. keccak256(request_id)를 키로, 요청·정책 해시와 접수 시각, 결정 기한, 결정 해시와 기록 시각을 저장합니다. 금액·수취인·사유·서명 원문은 올리지 않습니다.

설계 결정

Ownable로 공식 Gateway의 Anchor Writer만 기록하고, 요청과 결정을 각각 한 번만 기록합니다. 누락 판정에는 서버 시계 대신 block.timestamp를 쓰고, 요청 앵커 후 30초까지만 결정 앵커를 허용합니다.

    function anchorDecision(bytes32 requestKey, bytes32 decisionHash) external onlyOwner {
        require(decisionHash != bytes32(0), "INVALID_ANCHOR_HASH");
        RequestAnchorData storage record = records[requestKey];
        require(record.requestHash != bytes32(0), "UNVERIFIED_REQUEST");
        require(record.decisionHash == bytes32(0), "DECISION_ALREADY_ANCHORED");
        require(block.timestamp <= record.decisionDeadline, "DEADLINE_EXPIRED");
        record.decisionHash = decisionHash;
        record.decisionAnchoredAt = uint64(block.timestamp);
        emit DecisionAnchored(requestKey, decisionHash, record.decisionAnchoredAt);
    }
어려웠던 점

Sepolia에서 결정 트랜잭션이 요청 앵커 36초 뒤 블록에 들어가 기한 초과로 revert된 적이 있습니다. 이를 성공으로 처리하거나 기한을 늘리지 않고, 남은 요청 전용 번들을 독립 검증해 MISSING으로 분류했습니다. 네트워크 지연을 정상적인 검증 결과 안에서 드러낸 사례입니다.

기관 DB 없이 실행하는 독립 검증기

만든 것

verifyEvidence와 파일 기반 읽기 전용 CLI를 구현했습니다. 입력은 번들, 공개 주소 등록부, 체인 리더뿐이고, 기관 DB나 개인키가 필요 없습니다.

설계 결정

번들이 조작될 수 있다는 전제로 모든 필드를 다시 검증합니다. 체인 해시만 비교하지 않고 번들에 적힌 트랜잭션의 이벤트·블록·시각까지 확인해, 무관한 tx 해시를 붙이는 경우를 막습니다.

  const signatures: Array<{ record: object; signature: string; key: string; error: string }> = [
    { record: policy, signature: 'enterprise_signature', key: policy.enterprise_key_id, error: 'INVALID_ENTERPRISE_SIGNATURE' },
    { record: request, signature: 'agent_signature', key: request.agent_key_id, error: 'INVALID_AGENT_SIGNATURE' },
    { record: receipt, signature: 'verification_signature', key: receipt.verification_key_id, error: 'INVALID_VERIFICATION_SIGNATURE' },
  ];
  if (decision) signatures.push({
    record: decision, signature: 'institution_signature', key: decision.institution_key_id, error: 'INVALID_INSTITUTION_SIGNATURE',
  });
  for (const { record, signature, key, error } of signatures) {
    const address = registry[key];
    if (!address || !await verifyRecordSignature(record, signature, address)) errors.push(error);
  }
제3자 검증 절차
  1. 원본 Evidence Bundle과, 그와 별개로 신뢰하는 역할별 공개 주소 등록부·체인 ID·컨트랙트 주소를 확보한다.
  2. 스키마로 버전·필수 필드·해시·서명·정수 형식을 검사한다.
  3. 각 key_id를 등록부 주소로 바꿔 Policy, Request, Receipt, Decision의 서명을 검사한다.
  4. 서명 필드를 뺀 레코드를 다시 정규화·해시하고, 정책·요청·영수증·결정의 참조가 이어지는지 확인한다.
  5. 체인에서 요청 기록을 읽어 해시·접수 시각·기한을 비교하고, 요청·결정 트랜잭션의 이벤트를 검사한다.
  6. 금액 정책을 다시 계산한다.
  7. 체인 해시가 다르면 TAMPERED, 구조·서명 오류면 INVALID, 결정이 없고 기한 전이면 PROCESSING, 기한이 지났으면 MISSING, 모두 맞으면 VERIFIED.

Express API와 Supabase 저장 계층

만든 것

초기 Express 앱과 런타임 설정, 시작 검증, 그리고 저장소 인터페이스를 구현한 Supabase 어댑터와 여섯 테이블 migration을 작성했습니다. 이후 사건 API와 화면 연결, 배포용 기능은 팀원이 추가했습니다.

설계 결정

HTTP 경계, Gateway, 저장 계층, 검증 함수를 분리했습니다. 검증기는 DB를 쓰지 않고, 사건 조회·다운로드에만 DB를 씁니다. DB에서 읽은 JSON도 스키마로 다시 검사하고, 설정이 없거나 DB 연산이 실패하면 성공처럼 처리하지 않고 오류를 올립니다.

체인 성공과 DB 실패 사이의 복구

만든 것

결정 앵커 후 DB 저장이 실패했을 때, 다시 앵커하지 않고 기존 체인 기록을 재사용해 번들을 완성하는 흐름을 추가했습니다.

설계 결정

체인 트랜잭션과 DB 쓰기는 하나의 원자적 트랜잭션으로 묶을 수 없습니다. 그래서 "체인은 성공, 번들 저장은 실패"인 상태를 따로 다룹니다. 같은 결정 해시가 이미 체인에 있으면 원래 이벤트의 tx를 찾아 번들을 완성하고, 찾지 못하면 다시 앵커하지 않고 멈춥니다.

    const recovered = record.decisionHash === decisionHash;
    if (recovered && (record.decisionAnchoredAt === null || record.decisionAnchoredAt > record.decisionDeadline)) {
      throw new Error('DEADLINE_EXPIRED');
    }
    if (!recovered && await this.anchor.chainTime() > record.decisionDeadline) throw new Error('DEADLINE_EXPIRED');
    let result: { decisionTx: `0x${string}` };
    if (recovered) {
      const recoveredTx = await this.anchor.findDecisionTx(
        decision.request_id,
        decisionHash,
        BigInt(bundle.verification_receipt.request_anchor_block),
      );
      if (!recoveredTx) throw new Error('DECISION_ANCHOR_RECOVERY_FAILED');
      result = { decisionTx: recoveredTx };
    } else {
      result = await this.anchor.anchorDecision(decision.request_id, decisionHash);
    }

초기 Python 보안 POC

만든 것

최종 TypeScript 구조 이전에, Python으로 서명된 접수·결정 로그와 독립 검증기, 별도 witness 서비스, 공격 재현 보고서와 CI를 만들었습니다. Ed25519·SHA-256 해시 체인과 서명 체크포인트로, 운영자가 빈 로그로 다시 서명하는 공격까지 다뤘습니다.

배운 것

로그 삭제를 탐지하려면 검증자가 비교할 외부 증거를 미리 가지고 있어야 한다는 점을 여기서 확인했고, 이것이 최종 버전의 "요청 먼저 앵커" 설계로 이어졌습니다. 당시 51개 테스트 통과와 다섯 가지 공격 탐지 기록이 남아 있습니다.

결과

증거내용
공개 Sepolia 번들4,500 / 4,000 → REJECT / LIMIT_EXCEEDED, 별도 CLI에서 VERIFIED 번들 ↗
Request Anchor블록 11739924 0xc6cee…8f973 ↗
Decision Anchor같은 요청의 결정 0xefb7…efe94 ↗
기한 초과 실패36초 후 결정 tx revert, 요청 전용 번들 MISSING 판정 0x6946…d6a3b ↗
팀 통합 검증정상 거절·정책 변조·위험 사유·기한 경과 사례, 재시작 후 재검증 통합 문서 ↗
초기 POC CI테스트·공격 보고서·HTTP 왕복 성공 Actions ↗

Track 3의 요구사항인 신뢰 가능한 거절 기록, 해시·서명 스키마, 기관 DB 없는 독립 검증 도구, 사후 조작·삭제·누락 탐지를 각각 Decision 서명과 앵커, 정규화 해시 스키마, 파일 CLI, 해시 재계산 비교, Request-first 앵커와 기한으로 대응했습니다.

한계와 다음 단계

  • 완전성의 범위: Gateway를 거치지 않았거나 앵커 자체가 없는 요청은 증명하지 못합니다. 모든 사건이 공개됐다는 전역 완전성도 보장하지 않습니다.
  • 판단의 타당성: VERIFIED는 기관 사유의 무결성을 뜻할 뿐, 위험 판단이 옳았다는 뜻은 아닙니다.
  • 30초 고정 기한: 테스트넷 블록 지연만으로도 실패할 수 있어, 실제 SLA·늦은 결정·finality 정책이 필요합니다.
  • 비용: 요청·결정마다 개별 앵커라 체인 쓰기가 많습니다. 다음 단계는 배치와 Merkle root·inclusion proof입니다.
  • 키와 신원: 정적 등록부와 서버 환경변수 키를 씁니다. HSM/KMS, 키 회전, 기관 신원 등록이 필요합니다.
  • 제품 범위: 실제 LLM·외부 금융기관·결제 정산 연동은 없습니다.

배운 점

  1. 서명과 완전성 검증은 다른 문제다. 서명은 제시된 기록의 출처를 확인하지만, 아예 빠진 결정을 찾으려면 먼저 고정된 요청과 외부 기준점이 필요했다.
  2. 체인 성공과 서비스 성공은 일치하지 않는다. 앵커 후 DB 저장이 실패할 수 있어, 재시도는 새 트랜잭션이 아니라 기존 체인 이벤트 복구부터 시작해야 했다.
  3. 검증 결과가 보장하는 범위를 좁고 정확하게 정의해야 한다. 정상 거절도 VERIFIED이고, 실패한 테스트넷 실행도 숨기지 않고 MISSING으로 남기는 편이 보안 보장을 설명하는 데 도움이 됐다.

기여 범위

구분나팀원 (ik-s)
기획·명세프로토콜·위협 모델·검증 문서초기 개요·개발·디자인 기준 문서
Python POC서명 로그·witness·공격 실험·CI—
TypeScript 핵심스키마·암호 함수·Gateway·컨트랙트·CLI·검증기최종 사유 처리·상세 검증 결과·통합 수정
API · DB초기 Express·Supabase 어댑터·결정 앵커 복구사건 API·프론트 연결·요청 앵커 복구·후속 migration
Sepolia배포·스모크 스크립트·공개 번들·검증 문서통합 계약으로 UI·DB·체인 전체 검증
UI · 배포—React 화면·Vercel 배포

커밋 작성자 기준으로 나눴습니다. 공동 논의와 리뷰는 커밋만으로 나눌 수 없어 포함하지 않았습니다.

개발 기록

아직 개발 기록이 없습니다.