원문 캡처 · github.com
원문 캡처 · github.com

mstamp는 AI 에이전트가 외부 API를 호출할 때 요청과 응답을 기록하고, 서명된 영수증을 제삼자가 오프라인에서 검증하도록 설계된 오픈소스 프로젝트다. 다만 현재 호스팅 서비스는 없고 서명 백엔드도 배포되지 않았다. 설치 가능한 @mstamp/protocol 패키지는 로컬 감사 기록과 검증 기능을 제공하지만, 제삼자가 서명한 영수증을 만들 수는 없다.

mstamp의 역할과 현재 상태

mstamp는 호출자의 프로세스 안에서 fetch를 감싸 요청과 응답을 기록한다. 호출자가 선언한 규칙에 따라 응답을 검증할 수도 있으며, 관찰 정보를 백엔드에 보내면 백엔드가 Ed25519로 서명한다. 결과는 JSON 영수증이다. 영수증과 서명자의 공개키만 있으면 제삼자가 오프라인으로 검증할 수 있다.

mstamp는 프록시가 아니다. 트래픽을 중계하거나 바꾸지 않으며, 호출자가 보낸 요청은 그대로 API 제공자에게 전달된다. API가 좋은지 판단하거나 제공자 순위를 매기지도 않는다. 호출자가 정한 규칙이 통과했는지를 기록할 뿐이다. 첫 적용 분야는 웹 스크래핑이다. 이때 API가 200 OK를 반환해도 본문이 비었거나 오래됐거나 구조가 잘못된 경우가 있는데, 상태 코드만으로는 드러나지 않는 실패를 영수증에 남기는 것이 목표다.

프로젝트는 초기 사전 릴리스 단계지만 설치할 수 있다. @mstamp/protocol은 공개됐고 다음 명령으로 설치한다.

text
npm install @mstamp/protocol

@mstamp/backend는 라이브러리가 아니라 서비스이므로 공개 배포되지 않았으며 저장소에서 private: true로 관리된다. 전송 형식은 아직 확정되지 않았다. schemaVersion은 "1"이고 필드 집합은 규범적으로 문서화됐지만, 첫 정식 릴리스 전에는 호환성이 깨지는 변경이 있을 수 있다. 서명 필드 집합을 바꾸는 일은 단순한 리팩터링이 아니라 프로토콜 버전 결정으로 다뤄야 한다. 문서에 고정한 해시는 이런 변경이 조용히 지나가지 않도록 하기 위한 장치다.

현재 라이브러리에는 RFC 8785 정규화, SHA-256 해싱, 민감 정보 제거, 응답 검증, 원본을 바꾸지 않는 fetch 가로채기, 실패를 허용하는 큐, 로컬 SQLite 영수증 저장소, 클라우드 전송 기능이 있다. 백엔드에는 Hono 앱, 수신 처리, Ed25519 서명, 공개키 게시, 테넌트별 격리를 적용한 PostgreSQL 저장소, 공개 검증 기능, 페이로드 보존 기간 작업이 있다. 그러나 확정된 전송 형식과 정규화·서명 경로에 대한 외부 암호학 검토는 아직 없다. 양쪽 모두 진행 중인 작업으로 봐야 한다.

현재 호스팅된 mstamp 서비스는 없으므로 mode: 'cloud'에 지정할 실제 엔드포인트도 없다. @mstamp/backend는 저장소 안의 소스 코드이며 실제 PostgreSQL을 대상으로 테스트했지만 배포된 적은 없다. API 키도 발급된 적이 없다.

서명 서비스를 운영하려면 낯선 사람도 신뢰할 Ed25519 키와 영수증 해시를 보존 기간에 맞춰 관리할 데이터베이스를 맡아야 한다. 서명 키가 유출되면 게시된 공개키로 검증되는 위조 영수증을 만들 수 있으므로 키 관리는 장기적인 보안 책임이다. 영수증을 이용할 사람이 아직 없는 상황에서 키를 맡는 것은 기능이 아니라 책임이 될 수 있다는 것이 프로젝트의 설명이다.

따라서 npm 패키지만으로 얻는 것은 로컬 감사 기록과 정규화·해싱·정보 제거·검증 기능이다. 제삼자의 증명이 포함되지는 않는다. 서명된 영수증에는 호출자와 다른 서명자가 필요하지만, 현재 실행 중인 서명자는 없다. 직접 호스팅한 백엔드도 호출자가 키를 소유하므로 로컬 영수증 이상의 제삼자 증명이 되지 않는다.

백엔드는 배포할 수 있으며 배포 안내서에는 데이터베이스 마이그레이션, API 키 발급, 서비스 시작, 보존 기간 작업 실행 절차가 담겨 있다. 하지만 배포 가능하다는 말이 실제 배포를 뜻하지는 않는다. 서명 서비스를 원한다면 이슈를 열어 필요한 사항을 설명할 수 있다.

mstamp는 개인정보 보호를 강화해 주는 도구라고 주장하지 않는다. 해싱 전에 정보를 제거하지만, 프로세스 밖으로 전송되는 내용은 각 호출의 registration 설정에 따라 달라진다. 자세한 절충점은 아래 한계에 설명돼 있다. 프로젝트의 설계 원칙은 mstamp가 누구에게도 신뢰를 요구하지 않는 중립적인 기록자라는 것이다.

설치와 사용

필요한 환경은 Node.js 22 이상이다. 선택한 RFC 8785 구현이 이를 요구하며 로컬 영수증 저장소는 node:sqlite를 사용한다. 패키지는 ESM 전용이며 "type": "module"로 설정돼 있다. .mjs 파일이나 "type": "module"인 프로젝트에서 가져와야 한다. Node.js 22.12 이상에서는 require()도 사용할 수 있다. 저장소 작업에는 pnpm이 필요하다. pnpm-workspace.yaml의 minimumReleaseAge: 1440은 패키지 버전이 공개된 뒤 24시간 동안 기다리는 조건이다. 강력한 통제라기보다 최소 대기 시간이며, 프로젝트는 이를 늘릴 계획이라고 설명한다. 라이브러리 설치에는 npm만 있으면 된다.

저장소에서 사용하는 명령은 다음과 같다.

text
pnpm install # install the workspace
pnpm test # @mstamp/protocol suite, then @mstamp/backend, then the root suite
pnpm run typecheck # tsc across the root scripts and every package
pnpm run build # compile every package to dist/

# per-package
pnpm --filter @mstamp/protocol test
pnpm --filter @mstamp/backend test
pnpm --filter @mstamp/protocol run typecheck
pnpm --filter @mstamp/protocol run test:watch

# the canonicalization bake-off on its own
pnpm --filter @mstamp/protocol exec vitest run test/bakeoff.test.ts

로컬 모드에는 서버가 필요 없다. 영수증은 로컬 SQLite 파일에 저장되며 프로세스 밖으로 전송되지 않는다.

text
npm install @mstamp/protocol

import { Telemetry } from '@mstamp/protocol'

const telemetry = new Telemetry({
 mode: 'local',
 defaultRegistration: 'hash-only',
 sdkVersion: '0.1.0',
})

// A fetch that records every call it makes. The caller's request and response are
// untouched: the same Response object comes back, still fully readable.
const observedFetch = telemetry.instrumentFetch({ provider: 'scraper-x', operation: 'extract' })

const response = await observedFetch('https://your-api.example/items?page=1')
const items = await response.json()

예시 URL은 실제로 호출할 수 있는 주소로 바꿔야 한다. 영수증은 .mstamp/receipts.db에 저장된다. 응답 본문을 읽은 뒤 백그라운드 작업에서 영수증을 기록하므로 한 가지 타이밍에 주의해야 한다. close()는 동기식이며 쓰기가 끝날 때까지 기다리지 않는다. 응답 직후 같은 틱에서 호출하면 영수증이 조용히 버려질 수 있다. 프로세스를 잠시 유휴 상태로 두거나 요청마다 닫지 말고 종료 시점에 닫아야 한다.

로컬 영수증은 서명되지 않으며 attestation: "self"로 표시된다. 서명된 영수증을 받으려면 같은 코드를 실행 중인 백엔드에 연결해야 한다.

text
const telemetry = new Telemetry({
 mode: 'cloud',
 apiKey: process.env.MSTAMP_API_KEY, // required in cloud mode
 endpoint: 'https://ingest.example.com', // required in cloud mode, no default
 defaultRegistration: 'hash-only', // 'full' also sends redacted previews
 registrationPolicy: { 'scraper-x': 'full' }, // per-provider override
 sdkVersion: '0.1.0',
})

// ...the same instrumentFetch call as above...

await telemetry.flush() // await the cloud POST; never rejects

호출 하나만 기록하고 싶다면 래퍼를 만들지 않고 호출별 규칙을 지정할 수도 있다.

text
await telemetry.wrap(fetch, url, init, {
 provider: 'scraper-x',
 operation: 'extract',
 rules: ['min-length:items:1'],
})

Telemetry가 결정하는 것은 두 가지뿐이다. 호출별 설정, 제공자별 정책, 기본 설정 순으로 적용할 registration을 고르고 클라우드 POST를 큐에 넣을지 정한다. 서명은 하지 않는다. 로컬 모드의 영수증은 .mstamp/receipts.db에 서명 없이 저장된다. 이때 attestation: "self"이며 keyId, serverSignature, signedAt은 모두 null이다. 백엔드가 서명해야 "platform" 증명이 붙는다. 이 필드들을 직접 확인하기보다 isSignedReceipt()를 사용해야 한다. 서명 시각이 없는 서명은 유효한 증명이 아니기 때문이다.

설정 오류는 요청 시점이 아니라 객체를 만들 때 발생한다. 클라우드 모드에서 API 키나 endpoint가 없거나 sdkVersion이 누락되면 즉시 실패한다. 가로채기 자체는 실패를 허용하는 방식으로 동작한다. 기록을 완료하지 못해도 호출자의 요청은 영향을 받지 않는다. 기본 엔드포인트는 의도적으로 없다. 텔레메트리를 어디로 보낼지는 호출자가 결정해야 하기 때문이다. endpoint는 클라우드 모드에서 필수이며, 로컬 모드에서는 사용되지 않고 아무것도 전송되지 않는다.

영수증과 검증

영수증에는 schemaVersion, receiptId, tenantId, provider, operation, requestHash, responseHash, statusCode, latencyMs, timestamp, registration, attestation, validation, sdkVersion, keyId, serverSignature, signedAt이 들어간다. 이후 스키마를 위해 예약된 anomaly와 buyerOverride 필드도 있으며, schemaVersion이 "1"일 때는 항상 null이다.

서명 대상은 serverSignature와 signedAt을 null로 바꾼 영수증이 아니라, 두 필드를 완전히 제거한 영수증을 RFC 8785로 정규화한 결과다. 서명자와 검증자는 모두 @mstamp/protocol의 receiptSigningPayload()를 사용해야 한다. 필드 하나라도 다른 방식으로 구현하면 모든 영수증의 검증이 실패할 수 있다. 전체 필드 집합, 해시 형식, 예시는 영수증 스키마 문서에 규범적으로 정리돼 있다.

요청 해시와 응답 해시는 서로 다른 대상을 다룬다.

항목requestHashresponseHash
입력정보 제거 후 RFC 8785 정규화를 거친 요청 본문수신한 그대로의 응답 본문
전송 바이트와 일치 여부아니오예

요청은 해시 전에 정보를 제거하므로 비밀값은 저장되거나 전송되지 않는다. 해시는 <redacted> 자리표시자를 포함한 내용을 대상으로 하므로 제거된 위치에 실제로 무엇이 있었는지는 증명할 수 없다. 응답 해시는 본문을 파싱하기 전에 원시 바이트를 대상으로 한다. 그래야 mstamp가 포착하려는 잘못된 형식, 잘린 응답, JSON이 아닌 응답도 다룰 수 있다. 응답 바이트를 가진 사람은 responseHash를 다시 계산할 수 있다. 반면 제삼자는 원래 요청의 비공개 값을 갖지 않으며 정규화 과정에서 키 순서와 숫자 표기도 바뀌므로 requestHash를 다시 계산할 수 없다.

충실한 해시를 만들 수 없는 경우에는 임의의 다이제스트를 만들지 않는다. 해시 필드에 센티널을 넣고 영수증에는 대응하는 unvalidated: 규칙을 기록한다.

상황해시 필드규칙validation.passed
요청 본문이 FormData, Blob, ReadableStream인 경우sentinel:unsupported-body-typeunvalidated:unsupported-body-typenull
응답 본문이 maxResponseBytes를 넘는 경우sentinel:oversizeunvalidated:oversizenull

sentinel: 접두사는 중요하다. sha256:unsupported-body-type처럼 쓰면 잘못된 다이제스트로 오해해 데이터 손상으로 처리할 수 있지만, sentinel:은 형식 검사에서 다이제스트와 구별되며 isHash(sentinel)은 false를 반환한다. 센티널도 다른 값처럼 서명 대상에 포함되므로 서명 자체를 무효화하지는 않는다. 다만 해당 바이트가 영수증의 증거 범위에 포함되지 않는다는 뜻이다.

기본 maxResponseBytes는 1MB다. 한도를 넘으면 본문 전체를 버퍼에 담지 않고 읽기를 중단한다. 호출자는 여전히 모든 바이트를 받지만, 한도를 넘은 응답은 영수증으로 덮을 수 없다. 이 경우 sentinel:oversize가 기록되며, 필요한 제공자에 대해서는 한도를 높일 수 있다.

검증 규칙은 내장 문자열로 지정하며, Zod 방식의 safeParse를 가진 스키마도 함께 전달할 수 있다. 두 방식이 모두 있으면 모두 통과해야 validation.passed가 true가 된다. 내장 규칙은 non-empty, has-field, min-length, freshness, numeric-range, no-duplicates의 여섯 가지이며 각각 점으로 구분한 경로를 받는다. 규칙을 잘못 입력하면 아무 일도 없던 것처럼 넘어가지 않고 오류를 낸다. 검증 누락을 성공으로 기록하는 것보다 오류를 드러내는 편이 낫다는 설계다.

영수증의 passed: null은 통과가 아니라 검증하지 않았다는 뜻이다. 규칙과 스키마가 모두 없으면 검사한 내용이 없으므로 통과를 주장할 수 없다. 통과율을 계산하는 소비자는 null을 제외해야 한다. JSON이 아닌 본문은 해시되지만 unvalidated:non-json으로 표시되고 passed: null이 된다. 이는 오류가 아니라 구조적으로 검증할 수 없다는 의미다. 검증은 SDK에서 수행하며 서버는 결과를 저장할 뿐 직접 판정하지 않는다.

영수증 검증자는 영수증 JSON과 keyId가 가리키는 Ed25519 공개키만 있으면 된다. mstamp를 신뢰하거나 계정을 보유할 필요는 없다. 공개키를 가져오는 경우를 제외하면 온라인 연결도 필요하지 않다.

  1. schemaVersion을 읽고 필드 집합을 알고 있는지 확인한다.
  2. keyId를 읽는다. 값이 null이면 서명되지 않은 영수증이며, 뒷받침하는 주장은 attestation: "self"뿐이다. 여기서 검증을 멈춘다.
  3. serverSignature와 signedAt을 제거한다.
  4. 나머지를 RFC 8785에 따라 정규화한다. UTF-8, 정렬된 키, 공백 없음, ECMAScript 숫자 형식, 유니코드 정규화 없음이 적용된다.
  5. keyId가 지정한 키로 해당 바이트에 대한 서명을 검증한다.

독립 구현에서 오류가 나기 쉬운 부분은 네 번째 단계다. 제삼자 구현을 신뢰하기 전에 대조할 수 있도록 IETF 적합성 테스트 벡터가 packages/protocol/test/vectors/rfc8785.ts에 포함돼 있다. 두 다이제스트도 변경 감지용으로 고정돼 있다.

  • docs/receipt-schema.md의 예시에서 서명 페이로드 해시는 sha256:5c5390bb0a90ad90d7e5a9226edc398d3a2cdc84df2e28b8885ee1a5c04070c7이다. 이 값이 달라지면 서명 필드 집합이 바뀌어 기존 영수증을 검증할 수 없게 된 것이다.
  • RFC 8785 ES6 숫자 직렬화 적합성 파일의 해시는 be18b62b6f69cdab33a7e0dae0d9cfa869fda80ddc712221570f9f40a5878687이다. IETF 프로젝트가 공개한 체크섬이며 숫자 형식의 경계 사례를 생성한 1,000줄을 다룬다. 영수증 스키마를 편집해도 이 값은 바뀌지 않으므로 정규화 구현 자체가 변하지 않았는지 확인하는 데 쓰인다.

백엔드는 지금까지 사용한 모든 공개키를 GET /v1/keys에서 제공하며 키를 삭제하지 않는다. 서명 키를 교체해도 과거 기록을 검증할 수 있도록 하기 위해서다. 폐기한 키를 제거하면 그 키로 서명한 모든 영수증을 영구적으로 검증할 수 없게 된다. GET /v1/keys/current는 메모리에서 응답하므로 데이터베이스가 중단돼도 이미 보유한 영수증의 검증을 막지 않는다. scripts/test-canonicalization.ts는 @mstamp/protocol에 접근하지 않는 제삼자처럼 원시 정규화기와 직접 구현한 필드 제거 방식으로 서명 바이트를 다시 만들어 이 경로를 시험한다.

한계와 백엔드 운영

정보 제거는 값이 아니라 필드 이름을 기준으로 한다. description 필드에 붙여 넣은 키는 자동으로 제거되지 않는다. RedactionPolicy.bodyKeys에 제공자별 필드 이름을 추가해야 한다. URL 경로는 제거 대상이 아니다. 사용자 정보 영역의 자격 증명과 민감한 쿼리 매개변수는 제거하지만, 경로 안에 포함된 토큰은 남는다.

일반 객체가 아닌 값은 다른 형태로 바꾸지 않고 거부한다. 정규화기에 Date, Map, 클래스 인스턴스가 들어오면 {}로 조용히 바뀌는 대신 오류가 발생한다. 유니코드도 그대로 보존한다. A와 U+030A의 조합은 U+00C5와 서로 다른 입력이므로 해시도 다르다. RFC 8785는 정규화를 금지하므로 NFC 정규화를 적용한 구현은 mstamp와 결과가 달라져 공식 벡터 검증에 실패한다.

가로채기는 최대 maxResponseBytes까지만 버퍼링한다. 한도를 넘은 응답은 영수증이 다루지 않으며 sentinel:oversize로 표시된다. 실제 fetch가 거부되면 상태 코드와 응답이 없으므로 호출을 기록하지 않는다. 제공자 장애를 증명하려면 스키마 변경이 필요하다. JSON이 아닌 본문은 full 모드에서도 미리보기를 저장하지 않는다. 구조적 정보 제거를 적용할 수 없고 원문 미리보기가 자격 증명을 그대로 노출할 수 있기 때문이다. 해시에는 영향이 없다.

클라우드 모드의 로컬 초안은 별도 설정이 없으면 tenantId: "tnt_local"을 기록한다. 실제 테넌트는 백엔드가 API 키로 확인하며 로컬 행은 초안이다. 타임스탬프는 입력 때 밀리초 정밀도로 정규화된다. 영수증의 timestamp는 서명 페이로드에 포함되고 PostgreSQL의 timestamptz 열을 거쳐 되돌아오므로, 더 높은 정밀도를 쓰면 검증 시 문자열이 달라질 수 있다. 외부 암호학 검토도 아직 이뤄지지 않았다.

백엔드는 PostgreSQL을 사용하는 Hono 앱이다. 영수증에 서명하지만 검증 판정은 계산하지 않는다. 설정은 환경 변수로 받는다.

변수의미
DATABASE_URL서비스가 mstamp_app 역할로 연결하는 주소. 필수.
MIGRATION_DATABASE_URL마이그레이션과 키 발급이 데이터베이스 소유자 역할로 연결하는 주소. 해당 작업에 필수이며 실행 중인 서비스에서는 사용하지 않는다.
MSTAMP_APP_PASSWORD마이그레이션 단계에서 적용하는 mstamp_app 비밀번호. 생략하면 기존 비밀번호를 유지한다.
MSTAMP_SIGNING_KEY32바이트 Ed25519 시드의 Base64 값. 필수.
MSTAMP_SIGNING_KEY_ID공개키와 함께 게시할 키 ID. 기본값은 k_0001이다.
MSTAMP_MASTER_KEY32바이트 페이로드 암호화 키의 Base64 값. 모든 모드에서 필수.
PORT수신 포트. 기본값은 3000이다.
HOST바인딩할 인터페이스. 기본값은 0.0.0.0이다. 루프백에 바인딩하면 서비스는 정상 시작해도 응답하지 않는다.

필수 설정이 없으면 서비스는 시작하지 않으며 대체 키도 없다. 개발 환경에서도 예외가 아니다. 키 없이 개발 서버를 시작한 뒤 모든 영수증을 거부하는 동작은 서비스 작동 방식을 잘못 보여준다는 이유다. 이미 다른 공개키와 함께 게시된 키 ID를 다시 사용해도 시작을 거부한다. 기존 키 ID를 덮어쓰면 이미 서명한 영수증의 검증이 깨지고, 그대로 두면 이후 영수증이 깨지므로 어느 쪽도 복구할 수 없다는 설명이다.

데이터베이스 연결 문자열 두 개의 차이는 보안 경계다. 테이블 소유자와 슈퍼유저는 행 수준 보안(RLS)을 우회할 수 있으므로 서비스와 마이그레이션은 다른 역할을 써야 한다. DATABASE_URL은 앱 역할, MIGRATION_DATABASE_URL은 소유자 역할이어야 한다. 연결 문자열 하나만 설정한 배포는 겉으로는 정상 작동하고 테스트도 통과할 수 있지만 테넌트 격리는 적용되지 않는 잘못된 설정이다. 전체 절차는 배포 안내서에 있다.

배포 진입점은 packages/backend/src/serve.ts다. pnpm run build 뒤에는 packages/backend/dist/serve.js에서 실행한다. 설정을 검증하고 공개키를 게시한 다음 포트를 연다. 따라서 검증자가 공개키를 조회할 수 없는 키로 영수증이 먼저 서명되는 상황을 막는다. 테스트용 서명 키는 packages/backend/test/에 있고 src/에는 없다. 배포 코드에 하드코딩한 시드를 넣으면 알려진 개인키가 되며, 로컬에서 작동했다는 이유로 배포하면 심각한 보안 실패가 될 수 있다. 테스트는 키를 명시적으로 주입하므로 src/에 테스트 모드가 필요하지 않다.

마이그레이션은 packages/backend/migrations/의 번호가 붙은 .sql 파일을 파일명 순으로 적용한다. ORM은 없으며 스키마는 SQL로 관리돼 변경 내용을 검토할 수 있다. 각 마이그레이션은 별도 트랜잭션에서 실행된다. PostgreSQL의 DDL은 트랜잭션을 지원하므로 실패하면 일부 스키마나 실행 기록이 남지 않는다. 이미 적용된 마이그레이션은 체크섬으로 확인하며 파일을 편집하면 거부한다. 그렇지 않으면 배포 데이터베이스에는 변경이 적용되지 않아 새 데이터베이스와 조용히 달라질 수 있기 때문이다. 마이그레이션은 추가만 가능하다.

저장소 루트에서 빌드한 뒤 실행하는 명령은 다음과 같다.

text
# from the repository root, after `pnpm run build`
node packages/backend/dist/bin/migrate.js # owner: schema + app-role password
node packages/backend/dist/bin/issue-key.js --name "Acme" # owner: prints a new tenant's key once
node packages/backend/dist/serve.js # the service
node packages/backend/dist/bin/purge.js # app role: expired previews

migrate는 소유자 역할(MIGRATION_DATABASE_URL)로 연결해 대기 중인 파일을 적용한 뒤 MSTAMP_APP_PASSWORD로 앱 역할 비밀번호를 설정한다. issue-key는 출력한 키 자체가 아니라 sha256: 해시만 저장하므로 키를 나중에 복구할 수 없다. 발급 직후 복사해야 한다. purge는 앱 역할로 연결해 SECURITY DEFINER 함수를 통해 만료된 페이로드를 삭제한다. 앱 역할로 테넌트를 설정하지 않은 채 일반 DELETE를 실행하면 일치하는 행이 없어 삭제되지 않으면서 성공으로 보고될 수 있기 때문이다. 필요한 환경 변수가 없으면 각 명령은 구체적인 메시지와 함께 0이 아닌 종료 코드를 반환한다. 배포 안내서에는 호스트별 환경 변수 설정도 설명돼 있다.

RLS는 receipts와 payloads에 활성화돼 있으며 tenant_isolation 정책을 적용한다. 애플리케이션은 슈퍼유저가 아닌 mstamp_app으로 연결한다. 테이블 소유자와 슈퍼유저는 RLS를 우회하므로 마이그레이션 사용자로 격리 테스트를 하면 실제 보호가 없어도 테스트가 통과할 수 있다.

  • 테넌트는 세션 범위의 SET이 아니라 SET LOCAL로 설정한다. 연결 풀에서 세션 범위 설정을 쓰면 요청이 끝난 뒤에도 남아 다음 요청에 다른 테넌트의 접근 권한을 줄 수 있다. withTenant는 트랜잭션 안에서 set_config('app.tenant_id', $1, true)를 사용한다. 세 번째 인수 true는 SET LOCAL에 해당한다. 바인드 매개변수를 받을 수 없는 SET LOCAL 문에 값을 문자열로 끼워 넣으면 SQL 삽입 위험이 생기므로 함수 형식을 사용한다.
  • 정책은 읽기를 필터링하는 USING뿐 아니라 WITH CHECK도 적용한다. WITH CHECK가 없으면 테넌트가 다른 테넌트를 소유자로 표시한 행을 삽입할 수 있다.
  • app.tenant_id가 설정되지 않으면 비교 결과가 NULL이 되어 모든 행을 보지 못하도록 닫힌 상태로 실패한다.

tenants와 signing_keys에는 의도적으로 RLS를 적용하지 않는다. API 키 미들웨어는 테넌트를 확인하기 전에 테넌트를 조회해야 하고, 검증자는 테넌트 정보 없이 공개키를 읽어야 하기 때문이다. 그 결과 앱 역할은 모든 api_key_hash를 읽을 수 있다. 이는 필요한 권한이지만 알아둬야 할 사항이다.

테넌트 경계를 넘는 작업 두 가지는 정책을 느슨하게 하는 대신 마이그레이션 004에서 범위를 좁힌 SECURITY DEFINER 함수로 처리한다. mstamp_get_receipt_for_verification(text)는 자격 증명과 테넌트 정보가 없는 공개 검증 엔드포인트에서 사용하며, 영수증 ID가 접근 권한 역할을 한다. mstamp_purge_expired_payloads()는 전체 만료 데이터를 지운다. 테넌트가 설정되지 않은 앱 역할로 DELETE FROM payloads WHERE expires_at < now()를 실행하면 행이 삭제되지 않으면서 성공으로 보고돼 보존 기간 보장이 무력화될 수 있다.

mstamp_app에 BYPASSRLS 권한을 주는 대안은 전체 테넌트 격리를 포기하는 것이다. 각 함수는 search_path = public, pg_temp로 고정한다. 경로를 고정하지 않은 SECURITY DEFINER 함수는 호출자가 경로 앞쪽에 같은 이름의 객체를 만들어 가로챌 수 있다. 실행 권한은 PUBLIC에서 회수하고 mstamp_app에만 부여한다.

호출자 데이터를 거의 그대로 보존하는 것은 페이로드 미리보기뿐이다. full 등록에서만 저장하며, 테넌트별 키를 MSTAMP_MASTER_KEY에서 HKDF-SHA256으로 파생해 AES-256-GCM으로 암호화한다. info 매개변수는 키를 테넌트와 용도에 묶는다. 미리보기는 7일 뒤 만료된다. 키가 테넌트별로 파생되므로 다른 테넌트의 데이터를 읽으려 하면 GCM 인증 태그 검증이 실패한다. 이는 RLS에 더한 방어선이다. 영수증 자체는 삭제하지 않는다. 만료 작업은 스케줄러가 호출할 함수만 제공하며 스케줄링은 범위에서 제외한다.

POST /v1/ingest/batch는 최대 100개 이벤트를 받는다. receiptId를 기준으로 멱등성을 보장하며 최초 기록을 우선한다. 서명된 영수증은 재전송으로 내용을 바꿀 수 없어야 하기 때문이다. 각 이벤트는 별도 트랜잭션에 저장되므로 형식이 잘못된 이벤트 하나만 거부하고 나머지는 처리한다. 응답에는 이벤트별 accepted와 rejected 수가 들어가며 100개를 넘는 배치는 413을 반환한다.

200은 배치를 처리했다는 뜻이며 이벤트별 결과는 본문에 담긴다. 503은 배치를 전혀 처리하지 않았다는 뜻이므로 호출자는 재시도해야 한다. 처리되지 않은 배치에 200을 반환하면 SDK 큐가 성공으로 판단해 재시도하지 않고 텔레메트리를 조용히 잃을 수 있다.

인증이 필요 없는 공개 엔드포인트는 GET /v1/health, GET /v1/keys, GET /v1/keys/current, GET /v1/receipts/:id/verify다. 검증 엔드포인트는 { valid, receiptId, signedAt, attestation, keyId }를 반환하며 영수증 내용은 공개하지 않는다. 검증 실패도 valid: false를 담은 200이며, 영수증이 없을 때만 404를 반환한다.

API 키가 필요한 엔드포인트는 GET /v1/whoami, POST /v1/ingest/batch, GET /v1/receipts/:id다. 영수증 조회는 RLS로 테넌트를 제한한다. 애플리케이션 쿼리에 WHERE tenant_id = ...를 중복해 넣지 않는다. 그렇게 하면 RLS 오류를 가리고 두 필터를 영원히 일치시켜야 하기 때문이다. 없거나 다른 테넌트 소유인 영수증은 403이 아니라 404를 반환한다. 403은 해당 ID가 존재한다는 사실을 확인해 줄 수 있다.

서명 키와 페이로드 마스터 키는 환경 변수로 전달하며 저장소에 커밋하지 않는다. .gitignore는 .env, *.pem, *.key를 제외하고 코드에는 기본 비밀값이 없다. 취약점을 발견했다면 공개 이슈 대신 저장소의 GitHub 비공개 취약점 신고 기능으로 알리도록 안내한다. 라이선스는 MIT이며 LICENSE에서 확인할 수 있다.

저장소와 관련 문서는 mstamp 저장소, 영수증 스키마, 정규화기 선택 기록, 공개 설계 결정, 배포 안내서에서 볼 수 있다.