zero-knowledge
아키텍처적 무지로서의 Zero-Knowledge (2/5)
대부분의 시크릿 관리자는 getSecret(name)을 노출하고, 평문은 AI의 트랜스크립트에 그대로 남는다. lodos 스키마는 이 교환 자체를 구조적으로 불가능하게 만든다, AI는 시크릿의 값을 볼 수 없고, 빌드가 이를 강제한다.
당신의 AI 어시스턴트가 방금 Stripe live key를 보여달라고 요청했다. 대부분의 시크릿 관리자는 이를 허용할 것이다, API가 getSecret(name)을 노출하고, 에이전트가 이를 호출하고, 평문이 트랜스크립트에 남고, 거기서부터 에이전트가 대화하는 어떤 파이프라인으로든 흘러간다. 흥미로운 질문은 어떤 종류의 스키마가 이 교환을 구조적으로 불가능하게 만드는가이다.
lodos를 위해 나는 광고하기는 쉽지만 엔지니어링하기는 어려운 입장을 택했다: AI는 vault에 있는 어떤 시크릿의 값도 볼 수 없다. "안 하는" 게 아니라, 할 수 없다. 평문 시크릿을 호출하는 AI에게 반환하는 코드 경로가 존재하지 않는다. Prisma 스키마에는 AI가 읽을 수 있는 컬럼이 없고, IPC 레이어에는 복호화된 필드를 반환하는 메서드가 없으며, MCP 툴 레지스트리에는 계약이 값을 달라인 툴이 없다. 미래의 내가 그런 걸 추가하려 하면, 빌드 스크립트가 머지 전에 이를 잡아낸다.
이 글은 스키마 워크스루다, 정책적 무지 대신 아키텍처적 무지를 설계한다는 것이 실제로 어떤 모습인지, 그리고 그 대가로 무엇을 포기하는지에 대한 이야기다. Vault는 내가 가장 자랑스러워하는 레이어인데, 부분적으로는 이 엔지니어링이 하지 않는 것에 대해 정직하기 때문이다.
이중 저장소 모델
순진한 설계는 컬럼 하나다: encryptedJson이 섹션 페이로드를 담고, 읽을 때 복호화하고, 복호화된 blob에서 필드를 제공한다. 이것도 작동은 하지만, 하나의 버그 클래스를 갖는다, 어떤 읽기 경로든 결국 복호화하게 된다는 것이다. account_id(공개 정보)를 원했던 조회가 api_key(민감 정보)도 함께 복호화해 버린다. 낭비된 복호화는 감사 로그가 잡아낼 수 있을지 몰라도, LLM 트랜스크립트로의 유출은 아마 잡아내지 못할 것이다.
그래서 vault 섹션은 나란히 놓인 두 개의 저장소를 갖는다:
model SecretSection {
id String @id @default(cuid())
name String // "stripe", "aws", "github"
encryptedJson Bytes // libsodium secretbox(sensitive-fields, sectionKey)
nonSensitive Json // { account_id, region, username, ... } - plain
fieldSchema Json // [{key, type, sensitive, required}, ...]
keyWrapped Bytes // sectionKey, wrapped by masterDEK
// ...
}

모든 필드는 자신이 sensitive: true인지 sensitive: false인지를 알고 있다. 라우팅은 쓰기 시점에 단 한 번 일어난다. 민감한 필드는 libsodium을 거치고, 민감하지 않은 필드는 평문 JSON 컬럼에 들어간다. 민감하지 않은 필드를 읽는 것은 SQLite 읽기다, 복호화도, DEK 접근도, 감사 로그 쓰기도 없다. 민감한 필드를 읽으려면 마스터 비밀번호가 필요하다.
잃는 것: 어떤 필드가 어떤 것인지에 대해 스스로에게 거짓말할 능력. 읽기 시점에 결정할 수 없다. 스키마가 그 판단을 미리 강제한다.
얻는 것: 삭제된 버그 클래스. 채팅 MCP 툴 secrets_field_metadata는 민감하지 않은 필드에는 실제 길이를, 민감한 필드에는 0을 반환한다, 길이를 숨겨서가 아니라, 메타데이터 경로가 애초에 encryptedJson을 건드리지 않기 때문이다. 이 규율은 주석 수준이 아니라 타입 수준에서 유지된다.
암호화 파이프라인
민감한 쪽은 Argon2id와 libsodium 위에서 동작한다. 특별할 것은 없다:
// On unlock
const masterDEK = await argon2id(masterPassword, {
memLimit: 64 * 1024 * 1024, // 64MB
opsLimit: 3,
salt: vault.argon2Salt,
});
// HMAC check against vault.masterKeyCheck - fail-fast on wrong password
// On read of a sensitive field
const sectionKey = unwrap(section.keyWrapped, masterDEK);
const plaintext = sodium.crypto_secretbox_open(
section.encryptedJson, section.nonce, sectionKey,
);
const value = JSON.parse(plaintext)[field];
sectionKey.fill(0); // zero-fill before GC

무지(blindness) 주장에서 중요한 속성은 세 가지다. 첫째, 마스터 DEK는 오직 메인 프로세스 메모리에만 존재한다, 렌더러에도, 채팅 엔진 서브프로세스에도, IPC 페이로드에도 결코 존재하지 않는다. 둘째, 단일 섹션이 손상되었다고 해서 vault 전체가 손상되는 것은 아니다, 각 섹션은 마스터 DEK로 래핑된 자신만의 무작위 sectionKey를 갖는다. 셋째, 잠금 해제 상태는 5분의 유휴 타임아웃과 30분의 절대 상한을 갖는다, 그 이후에는 모든 섹션이 다시 읽을 수 없는 상태가 되기 직전, 한 번의 재입력만 남는다.
이 지점에서 보안에 민감한 독자들은 "그래, 근데" 하며 메모리 덤프, 스왑 파일, GPU 상주 여부에 대해 묻기 시작할 것이다. 타당한 질문들이고, 대부분 이 글의 범위를 벗어난다. 여기서 중요한 지점은 AI가 이 파이프라인에 손댈 수 없다는 것이다. Agent SDK는 vault 상태에 대한 접근 권한이 전혀 없는 서브프로세스에서 실행된다. 그것은 IPC를 통해 메인 프로세스와 통신하며, IPC 표면에는 평문을 반환하는 메서드가 없다.
패턴 A, B, D: 절대 C는 안 된다
이 시스템에서 시크릿이 사용될 수 있는 방법은 정확히 네 가지다:
- A: vault를 절대 떠나지 않음. 시크릿은 워크플로우에서 이름으로 참조되고, 메인 프로세스 측에서 복호화되어 서브프로세스 환경 변수에 주입되며, AI에게는 결코 반환되지 않는다. AI는 YAML에서
{{ secrets.stripe.live_key }}를 보고, 응답으로 HTTP 200을 본다. - B: 좁은 egress 필터.
postgres://user:pass@host/db같은 값은 메인 프로세스 측에서 복호화되고, 필터가host만 추출하며, 그것이allowedEgress값이 된다. 시크릿 값 자체는 결코 IPC 경계를 넘지 않는다: 파생된 프로젝션만 넘는다. - C: 복호화해서 AI에게 반환. ❌ 존재하지 않는다. 등록된 MCP 툴도 없다. IPC 메서드도 없다. 코드 경로도 없다. 코드베이스에서
_value_get$에 매칭되는 툴 이름을 grep 해보면, 결과는 구조적으로 비어 있다. 빌드 스크립트가 이를 강제한다. - D: 주입하고 실행. 서브프로세스가 시크릿을 환경 변수에 담아 생성된다. 시크릿과 어떤 AI 표면 사이에도 6개의 레이어가 있다: env-only(argv 아님), stdout/stderr에 대한 인코딩 리댁터, no-shell-string-build, 출력에 대한 콘텐츠 방화벽, 불투명한 경로를 가진 HMAC 감사 로그 항목, 호출별 하드코딩된 egress 허용목록.

이것들을 패턴으로 명시하는 이유는 C가 대부분의 제품이 출시하는 방식이기 때문이다. 그들의 시크릿 관리자 툴은 평문을 에이전트 루프에 반환하고, 그것을 통합이라고 부른다. A부터 D까지도 통합이다, 다만 시크릿을 트랜스크립트에 남기지 않을 뿐이다.
이 트레이드오프는 정직하다. 어떤 워크플로우는 패턴 C를 쓰면 더 쉬워진다. 하지만 패턴 C가 필수인 워크플로우는 없다. 그래서 우리는 A, B, D를 출시하고, 빌드 타임 grep으로 C를 배제한다.
불투명한 감사 체인
감사 로그는 설계하면서 나를 가장 놀라게 한 부분이다.
vault 감사 로우는 "섹션 X, 필드 Y가 시각 T에 액터 Z에 의해 주입되었고, 서명은 S, 이전 서명은 P"라고 말한다. 자연스러운 스키마라면 fieldPath를 평문으로 저장할 것이다. 그것은 유출이다, 감사 테이블에 대한 읽기 권한이 있는 사람은 누구나 AI 에이전트가 뭔가를 주입했다는 것뿐 아니라, stripe.live_key를 주입했다는 사실까지 보게 된다. 규제 대상 구매자가 컴플라이언스 검토를 할 때, 이 감사 테이블 자체가 시크릿 자료가 되어버린다.
그래서 스키마는 경로를 저장하지 않는다. 해시를 저장한다:
fieldPathHash: sha256(sectionName + '.' + fieldKey).slice(0, 16)
16자짜리 불투명한 토큰이다. 오류 응답조차 이것을 사용한다: 주입이 실패하면, 오류는 { pathHash: "a3f2…", sectionExists: true }이지, { field: "stripe.live_key", error: "not found" }가 아니다. 사용자 자신의 컨텍스트에서 메인 프로세스에서 실행되는 감사 리더가 역방향 조회를 수행한다. AI는 결코 평문 경로를 보지 않으며, 유출된 감사 테이블도 어떤 시크릿이 저장되어 있는지에 대해 아무것도 드러내지 않는다.
체인은 HMAC으로 연결되어 있다, 모든 로우는 마스터 DEK에서 파생된 서브키로 (payload + prev-signature)에 서명한다. verifyAuditChain()은 시작 시 실행된다. 조작되거나 삭제된 로우는 체인을 깨뜨리고 UI에 포렌식 경고로 나타난다. 완전 삭제는 금지되어 있으며, 보관(archive)은 로우를 그대로 남겨둔 소프트 삭제다.
감사 테이블을 스크린샷으로 찍어 SOC2 감사관에게 보여줄 수 있다. 감사관은 액터 식별자, 섹션, 불투명한 필드 해시, 타임스탬프, HMAC 체인을 본다. 실제 시크릿 이름은 유출되지 않는다. 바로 그것이 컴플라이언스 구매자들이 돈을 지불하는 대상이다.
사용자가 아키텍처와 버그를 구분하지 못했을 때
첫 실제 도그푸딩 도중, 한 사용자가 secret_inject_and_run을 통해 echo $API_KEY를 실행했다. 호출은 MCP_LETHAL_TRIFECTA_GATE로 실패했다. 사용자는 이를 AI 어시스턴트에게 가져가 진단을 요청했고, AI는 약 3천 토큰을 들여 이 명령이 "정적 텍스트"이기 때문에 거부되었다고 설명하며 curl 호출로 전환할 것을 권했다. 틀렸다. 게이트 조건은 allowedEgress.length === 0 && riskTier === 'novel'이었다, 그 호출에는 egress 허용목록이 없었고, 그래서 선언된 목적지 없이 잠재적으로 유출될 수 있다고 판단되어 거부된 것이었다.
이 버그 티켓은 moat-win으로 재분류되었다. 아키텍처는 올바르게 작동했고, AI의 혼란 자체가 증거였다, 시스템은 명령 내용이 아니라 egress 형태를 근거로 거부했으며, AI의 잘못된 진단은 깨진 거부 로직 때문이 아니라 도움이 되지 않는 툴 설명 때문이었다. 우리는 설명을 수정했고, 게이트는 그대로 유지했다.
내가 계속 되돌아가게 되는 교훈은, AI가 이해하지 못하는 거부도 여전히 거부라는 것이다. 아키텍처적 무지는 LLM이 그것에 동의할 것을 요구하지 않는다.
이것이 상업적으로 갖는 가치
이런 종류의 작업을 고민하는 창업자들에게 내가 하는 설명은 간단하다. SOC2와 ISO 구매자는 "약속드립니다"를 정책으로 받아들이지 않는다. 그들은 "할 수 없습니다"는 받아들인다. 당신의 live key를 볼 수 있지만 그러지 않겠다고 약속하는 벤더는, 로깅 스택에 이따금 CVE가 발생하는 부류의 벤더다. 스키마 자체가 그 값을 볼 수 없는 벤더는 전혀 다른 위험 범주이며, 그 범주는 가격이 다르게 매겨진다.
내가 처음부터 열어둔 Phase-3 경로는 수신자별 래핑이다. 섹션 키는 이미 하나의 간접 계층이다, 오늘은 마스터 DEK로 한 번 래핑된다. 팀 환경에서는 N번 래핑된다, 수신자 공개키마다 한 번씩, 그리고 서버는 여전히 복호화할 수 없는데, 어떤 개인키도 보유하고 있지 않기 때문이다. 이 래핑은 재작성이 아니라 추가일 뿐이다. 왜냐하면 섹션별 간접 계층을 첫 커밋부터 범용적으로 유지해 왔기 때문이다. 그 문은 지금 그것을 위한 어떤 기능도 출시하지 않은 채로 계속 열려 있다.
다음 레이어
스키마 설계는 저장 시점과 주입 시점의 시크릿을 보호한다. 하지만 스키마도 코드이고, 코드는 수정될 수 있다. 다음 글, 32 Build-Time Invariants That Fail My Build If I Regress, 에서는 Prisma 스키마가 퇴행하지 않도록, 워크플로우 엔진에 eval이 자라나지 않도록, MCP 툴 레지스트리에 secret_value_get이 돋아나지 않도록 지키는 스크립트를 살펴본다. 거부는 grep이 되고, 그 grep은 자신의 이빨이 여전히 살아있음을 증명하기 위해 가짜 위반 사례를 함께 출시한다.