프라이버시와 ZK
검증 결과를 공개 원장에 올리면서 개인정보를 지키는 방법입니다. 온체인에는 커밋먼트 루트 32바이트만 올라가고, 원본 값은 사용자 브라우저에 남습니다. 그 구조를 제3자가 우리 코드 없이 직접 확인하는 방법까지 정리했습니다.
무엇이 어디에 있나
| 위치 | 저장되는 것 | 저장되지 않는 것 |
|---|---|---|
| 온체인 | 종류·등급·증적 해시·claimsRoot·auditRef (모두 32바이트 이하 판정값) | 이름, 생년월일, 신분증, 연락처 — 어떤 형태로도 올리지 않습니다 |
| 사용자 브라우저 | 크리덴셜 — 입력한 값과 필드별 salt | 발급 서버로 전송되지 않습니다 |
| 발급 서버 | 심사 결과, 증적 레코드, 커밋먼트 루트 | 신원 원본 값 (브라우저에서 이미 커밋먼트로 바뀐 뒤 전달됩니다) |
| 검증 벤더 | 신원 검증 원본 (연동 이후) | 호패는 참조 ID와 판정만 받습니다 |
그래서 어테스테이션이 공개하는 사실은 “이 지갑은 어느 등급으로 검증됐고 언제 만료된다”까지입니다. 그 이상은 커밋먼트 뒤에 있고, 열지 말지는 사용자가 정합니다.
다음 단계가 술어 증명입니다. 값을 열지 않고 조건만 증명합니다. 생년월일을 보내지 않고 성년임을 증명하는 age_over 회로가 그 첫 사례이며, 온체인 검증까지 동작합니다.
검증 가능한 주장 4가지
| 주장 | 내용 |
|---|---|
| 결속 | 온체인 claimsRoot는 검증 필드들의 Poseidon2 커밋먼트 루트다. pre-image에서 재계산하면 비트 단위로 일치한다. |
| 은닉 | 온체인에는 루트 32바이트뿐. 필드별 128-bit 랜덤 salt 때문에 루트에서 값을 역산할 수 없다. |
| 폐기 전이 | 어테스테이션이 폐기·만료되면 claimsRootOf()가 0을 반환한다. |
| 술어 증명 | age_over 회로가 커밋된 생년월일을 공개하지 않고 성년임을 증명하고, 온체인 검증자(UltraHonk + HopaeZkGate)가 이를 검증했다 (아래 시연 tx). |
현재 한계: 기준일(cutoff)은 검증자가 제공하고, 회로는 외부 감사 전이며, 브라우저 안에서 증명을 생성하는 UI는 로드맵입니다. 지금 동작하는 것은 회로 → 로컬 증명 → 온체인 검증의 전 구간입니다.
방법 1 — 이 사이트에서 (크리덴셜 검증 탭)
- 발급 체험에서 입력한 신원 정보로 브라우저가 크리덴셜(값 + salt)을 만들고, 이 브라우저에만 저장합니다.
- 크리덴셜 검증 탭에서 “내 크리덴셜 검증”을 누르면 브라우저가 루트를 재계산하고 레지스트리의
claimsRootOf()를 읽어 대조합니다. - 필드 하나를 골라 선택 공개를 검증합니다. 나머지 필드는 비공개로 남습니다.
- 위변조 테스트는 값을 1 바꿔 재검증합니다. 실패해야 정상입니다.
- 콘솔 탭에서 폐기하면 같은 검증이 즉시 실패로 바뀝니다.
방법 2 — 독립 스크립트
저장소에서 — 공개된 시연 크리덴셜로 재현sh
pnpm install # 최초 1회
node sdk/scripts/verify-credential.mjs \
docs/deployment/evidence/v2/issue-A.credential.json이 스크립트는 SDK를 import하지 않습니다. 커밋먼트 수학을 자체 재구현하고, 온체인 조회도 raw eth_call로 합니다. 시연 지갑 A의 크리덴셜은 시뮬레이션 데이터라 재현 검증용으로 공개되어 있습니다. 실사용자 크리덴셜은 본인 브라우저에만 존재합니다.
방법 3 — cast만으로
스키마 UID 재계산 · 온체인 루트 조회sh
RPC=https://sepolia-rpc.giwa.io
REG=0x3C05Bc238b74d99ad9d8c0345d0f64BD249234d2
A=0x2C98FAf3900E04Eb4eB37d5141619F426A120aB9
# 스키마 UID가 정말 v2 스키마 문자열에서 나온 값인지
cast keccak "$(cast concat-hex "$(cast from-utf8 \
'uint16 kind,uint8 level,bytes32 evidenceHash,bytes32 claimsRoot,bytes32 auditRef')" \
0x0000000000000000000000000000000000000000 0x01)"
cast call $REG 'schemaUID()(bytes32)' -r $RPC # 위 값과 일치해야 함
# 유효성 결합 뷰 — 폐기·만료 시 0
cast call $REG 'claimsRootOf(address,uint16)(bytes32)' $A 1 -r $RPC전체 절차(어테스테이션 5필드 디코드·증적 해시 대조 포함)는 저장소 docs/zk-verify.md에 있습니다.
온체인 시연 트랜잭션
| 장면 | tx |
|---|---|
| 성년 ZK 증명 온체인 검증 — 생년월일은 어디에도 없음 | 0x666f2135ac3b… ↗ |
| 폐기된 지갑의 유효한 증명 → 게이트 revert (의도된 실패) | 0xd0cdc3750c2f… ↗ |
시연 tx 전체와 증명 아티팩트는 저장소 docs/deployment.md와 docs/deployment/evidence/v2/에 있습니다.
커밋먼트 스펙 (제3자 재구현용)
hopae-credential/2text
리프: leaf_i = Poseidon2(keyId, value, salt) // BN254 스칼라 필드
트리: 깊이 4 고정(슬롯 16), 빈 슬롯 리프 = 0, 부모 = Poseidon2(L, R)
salt: 필드별 독립 128-bit CSPRNG — 발급 시 생성, 사용자에게만 전달
keyId: 1=DOB(YYYYMMDD) · 2=NATIONALITY · 3=RESIDENCY('KR'→0x4B52)
· 4=SCREENED_AT(unix초) · 5=RISK_BAND(1~5)- ·해시는 Poseidon2 (BN254)입니다. Noir 회로 쪽 구현과 동일 다이제스트임을 고정 벡터로 검증합니다. 온체인은 해시를 재계산하지 않고 저장된 bytes32만 비교합니다.
- ·회로:
age_over(Noir) — 커밋 개봉 + 머클 포함 + 생년월일 비교 + 지갑 바인딩. 재현 절차는 저장소circuits/README.md에 있습니다.