지갑(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가 프라이빗 키를 직접 받으면 두 가지 문제가 생긴다.
@core/did가@core/wallet에 의존하게 된다- 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단계:
- 소유자 서명 — VP를 제시한 사람이 실제 소유자인지
- 발급자 서명 — VC를 발급한 기관이 맞는지
- 앵커링 — 블록체인에 기록된 해시와 일치하는지
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-resolver가 did-resolver@5를 요구하는데 did-jwt-vc는 @4에 의존한다. 타입이 호환되지 않아서 @ts-expect-error로 우회했다. 런타임에서는 정상 동작한다.
(c) Background Service Worker에서 document 참조 에러
did-jwt-vc의 createVerifiablePresentationJwt가 내부적으로 document를 참조한다. Background Service Worker에는 DOM이 없어서 document is not defined 에러가 발생했다.
해결: did-jwt-vc 대신 did-jwt의 createJWT로 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개 통과
| 모듈 | 테스트 수 | 내용 |
|---|---|---|
| createDID | 5 | 주소 검증, chainId 분기, 에러 |
| resolveDID | 3 | DID Document 조회, notFound 폴백 |
| issueVC | 4 | JWT 생성, 클레임, 만료일, 에러 |
| verifyVC | 3 | 서명 검증, 변조 감지, 에러 |
| createVP | 5 | VP 생성, 복수 VC, 에러 |
| verifyVP | 3 | 이중 서명, 변조 감지, 에러 |
회고
발급자 서버가 죽어도 VC + 블록체인 앵커링으로 독립적으로 증명 가능하다 — 이게 SSI의 핵심 가치다. 중앙 서버 의존을 끊는 것.
세 가지로 정리하면.
- 서명 함수 주입은 SDK 설계의 핵심 — PK를 직접 받으면 SDK가 보안 위험이 되고 의존성이 생긴다. 함수를 주입하면 SDK는 키를 모르면서도 서명을 사용할 수 있다.
- did:ethr의 notFound는 에러가 아니다 — on-chain 미등록이어도 주소 자체가 DID다. 라이브러리의 에러를 그대로 올리지 말고 도메인 맥락에 맞게 폴백해야 한다.
- 브라우저 SDK + Background Service Worker = DOM 의존성 주의 —
did-jwt-vc처럼 내부적으로document를 참조하는 라이브러리는 Service Worker에서 터진다. 저수준 라이브러리(did-jwt)로 직접 조립하는 게 안전하다.
다음 편: 보안 — PK는 어디에 있어야 하는가