Skip to content
Go back

Web3 신뢰 인프라 #3 — DID/VC/VP, 탈중앙 신원 증명을 만들어보다

지갑(09)과 앵커링(07) 위에 마지막 레이어인 **SSI1 (Self-Sovereign Identity)**를 쌓았다. 핵심 질문은 “중앙 기관 없이 디지털 증명서를 발급하고, 독립적으로 검증할 수 있는가?”다.

왜 SSI인가

현재 디지털 신원 체계의 문제를 폐배터리 도메인으로 바꿔보면 명확하다.

기존:
  수거업체 서버에 "수거 완료" 기록 → 서버 죽으면 증명 불가
  → 수거업체를 믿어야 함 → 중앙 의존

SSI:
  수거업체가 VC(디지털 증명서) 발급 → 수거기사 지갑에 저장
  → 서명 검증으로 위조 불가 → 서버 없어도 독립 검증
  → 앵커링으로 존재 시점까지 증명

프로젝트 구조

packages/@core/did/
├── src/
│   ├── did/        ← createDID (did:ethr)
│   ├── resolver/   ← resolveDID (DID Document)
│   ├── vc/         ← issueVC, verifyVC (JWT)
│   ├── vp/         ← createVP, verifyVP (JWT)
│   ├── types/      ← SignerFn, IssueVCParams
│   └── errors/     ← DIDError, VCError, VPError

5 Phase로 나눠서 구현했다. Phase 1(DID) → 2(VC) → 3(VP) → 4(Extension UI) → 5(앵커링 연동). SDK 테스트 23개가 전부 통과하는 것을 확인하면서 진행했다.

핵심 설계 — 서명 함수 주입

SDK가 프라이빗 키를 직접 받으면 두 가지 문제가 생긴다.

  1. @core/did@core/wallet에 의존하게 된다
  2. SDK에 보안 취약점이 있으면 PK가 유출될 수 있다

대신 서명 함수를 외부에서 주입한다.

// SDK는 PK를 모른다 — "이 데이터에 서명해줘" 요청만
const vc = await issueVC(
  { issuer: 'did:ethr:0x수거업체', subject: 'did:ethr:0x수거기사', ... },
  (data) => signMessage(data, privateKey),  // 외부에서 주입
);

같은 SDK를 세 가지 환경에서 다르게 사용한다.

// Extension — Background Service Worker가 서명
(data) => sendToBackground({ type: 'SIGN_VC', data, password })

// 데모 페이지 — 브라우저 메모리에서 직접
(data) => ES256KSigner(hexToBytes(pk))(data)

// 테스트 — mock
() => 'fake-signature'

SDK 코드는 변경 없이 환경만 바꾸면 된다. 이 패턴이 나중에 Extension/데모/CLI 세 곳에서 같은 SDK를 쓸 수 있게 해준 핵심이다.

SSI 삼각 관계와 검증 흐름

발급자 (수거업체)           소유자 (수거기사)           검증자 (환경부)
  │                          │                          │
  ├─VC 발급 (서명)──────────▶│                          │
  ├─anchor(vcHash)───────────┼─────────────────────────▶│ 앵커링 검증
  │                          ├─VP 생성 (서명)───────────▶│
  │                          │                          ├─ 소유자 서명 ✅
  │                          │                          ├─ 발급자 서명 ✅
  │                          │                          └─ 앵커링 ✅

VP 검증 시 3단계:

  1. 소유자 서명 — VP를 제시한 사람이 실제 소유자인지
  2. 발급자 서명 — VC를 발급한 기관이 맞는지
  3. 앵커링 — 블록체인에 기록된 해시와 일치하는지

DID — did:ethr

이더리움 주소를 그대로 DID로 사용한다. 별도 등록 과정이 없다.

createDID('0xf1e003...', 11155111)
// → 'did:ethr:0xaa36a7:0xf1e003...'
//    method  chainId    주소

DID를 조회하면 DID Document2가 나온다. “이 DID의 주인은 이 공개키로 확인할 수 있다”는 정보가 담겨있다.

VC — JWT 형식

VC는 JWT3 형식이다. Header.Payload.Signature 세 부분으로 나뉜다.

{
  "iss": "did:ethr:0xaa36a7:0x수거업체",
  "sub": "did:ethr:0xaa36a7:0x수거기사",
  "vc": {
    "type": ["VerifiableCredential", "BatteryCollectionCredential"],
    "credentialSubject": {
      "batteryId": "BAT-001",
      "status": "수거 완료",
      "capacity": "72%"
    }
  }
}

검증 과정: JWT 분리 → 발급자 DID 추출 → DID Document 조회 → 공개키 획득 → 서명 검증. 발급 기관 서버가 없어도 공개키만 있으면 검증 가능하다.

VP — 이중 서명

VC만 보내면 “증명서는 진짜인데 제시한 사람이 본인인지 모른다”. VP는 VC를 포장하면서 소유자 서명을 추가한다.

const vpJwt = await createVP(
  { holder: 'did:ethr:0x수거기사', vcJwtList: [vcJwt1, vcJwt2] },
  holderSignerFn,
);

VP 안에 VC가 여러 개 들어갈 수 있다. 폐배터리 체험에서는 “수거 완료” + “검사 완료” 2개 VC를 하나의 VP로 묶어서 제시한다.

트러블 슈팅

(a) ethr-did-resolver notFound

ethr-did-resolver가 on-chain에 등록되지 않은 DID에 대해 notFound 에러를 반환했다. 하지만 did:ethr은 주소 자체가 DID라서 등록 없이도 유효하다. notFound일 때 주소 기반 기본 DID Document를 생성하는 폴백 resolver를 만들었다.

if (result.didResolutionMetadata.error === 'notFound') {
  const address = did.split(':').pop()!;
  return {
    didDocument: {
      id: did,
      verificationMethod: [{
        id: `${did}#controller`,
        type: 'EcdsaSecp256k1RecoveryMethod2020',
        controller: did,
        blockchainAccountId: `eip155:${chainId}:${address}`,
      }],
      authentication: [`${did}#controller`],
    },
    // ...
  };
}

이 폴백은 verifyVC에서도 필요했다. did-jwt-vc의 내부 resolver에는 우리 폴백이 없으니까, 커스텀 resolver wrapper를 만들어서 verifyCredential에 주입했다.

(b) did-resolver 버전 충돌

ethr-did-resolverdid-resolver@5를 요구하는데 did-jwt-vc@4에 의존한다. 타입이 호환되지 않아서 @ts-expect-error로 우회했다. 런타임에서는 정상 동작한다.

(c) Background Service Worker에서 document 참조 에러

did-jwt-vccreateVerifiablePresentationJwt가 내부적으로 document를 참조한다. Background Service Worker에는 DOM이 없어서 document is not defined 에러가 발생했다.

해결: did-jwt-vc 대신 did-jwtcreateJWT로 VP를 직접 조립했다.

// ❌ did-jwt-vc — document 참조
await createVerifiablePresentationJwt(payload, holder);

// ✅ did-jwt — DOM 의존성 없음
await createJWT(
  { vp: { '@context': [...], type: [...], verifiableCredential: vcJwtList } },
  { issuer: holderDid, signer },
);

(d) JWT UTF-8 인코딩

atob()이 Latin1만 처리해서 한국어 클레임(“수거 완료”)이 ìê±° ìë£로 깨졌다. TextDecoder로 UTF-8 안전 디코딩 유틸을 만들었다.

export function decodeJwtPayload(jwt: string): Record<string, unknown> {
  const base64 = jwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
  const bytes = Uint8Array.from(atob(base64), c => c.charCodeAt(0));
  return JSON.parse(new TextDecoder().decode(bytes));
}

Extension DID/VC 탭

Extension에 DID/VC 탭을 추가했다.

기능설명
내 DID지갑 주소 기반 did:ethr 표시 + 복사
VC 목록저장된 VC 상세 보기 + 삭제
VC 추가외부에서 받은 JWT 붙여넣기
테스트 VC 발급랜덤 배터리 VC 발급 (앵커링 선택)
VP 제시VC 선택 → VP 서명 → QR 코드

테스트 도구도 만들었다.

# CLI로 VC 발급 (발급자 역할)
pnpm tsx tools/vc-issuer/issue.ts 0x수거기사주소 --anchor

# CLI로 VP 검증 (검증자 역할)
pnpm tsx tools/vc-verifier/verify.ts <VP_JWT>
# → 소유자 서명 ✅ + 발급자 서명 ✅ + 앵커링 ✅

테스트 결과 — 23개 통과

모듈테스트 수내용
createDID5주소 검증, chainId 분기, 에러
resolveDID3DID Document 조회, notFound 폴백
issueVC4JWT 생성, 클레임, 만료일, 에러
verifyVC3서명 검증, 변조 감지, 에러
createVP5VP 생성, 복수 VC, 에러
verifyVP3이중 서명, 변조 감지, 에러

회고

발급자 서버가 죽어도 VC + 블록체인 앵커링으로 독립적으로 증명 가능하다 — 이게 SSI의 핵심 가치다. 중앙 서버 의존을 끊는 것.

세 가지로 정리하면.

  1. 서명 함수 주입은 SDK 설계의 핵심 — PK를 직접 받으면 SDK가 보안 위험이 되고 의존성이 생긴다. 함수를 주입하면 SDK는 키를 모르면서도 서명을 사용할 수 있다.
  2. did:ethr의 notFound는 에러가 아니다 — on-chain 미등록이어도 주소 자체가 DID다. 라이브러리의 에러를 그대로 올리지 말고 도메인 맥락에 맞게 폴백해야 한다.
  3. 브라우저 SDK + Background Service Worker = DOM 의존성 주의did-jwt-vc처럼 내부적으로 document를 참조하는 라이브러리는 Service Worker에서 터진다. 저수준 라이브러리(did-jwt)로 직접 조립하는 게 안전하다.

다음 편: 보안 — PK는 어디에 있어야 하는가

Footnotes

  1. SSI (Self-Sovereign Identity) — 자기주권 신원. 중앙 기관 없이 사용자가 자신의 신원 정보를 직접 소유하고 선택적으로 제시하는 체계.

  2. DID Document — DID에 연결된 메타데이터. 공개키, 인증 방법, 서비스 엔드포인트 등이 포함된다.

  3. JWT (JSON Web Token)Header.Payload.Signature 구조의 토큰 형식. VC/VP를 JWT로 인코딩하면 단일 문자열로 전달할 수 있다.


Share this post on:

Comments


Previous Post
Web3 신뢰 인프라 #4 — 보안, PK는 어디에 있어야 하는가
Next Post
Web3 신뢰 인프라 #2 — 앵커링, Solidity 스마트 컨트랙트부터 Etherscan까지