Skip to content
Go back

Wallet SDK 고도화 #3 — DID Auth 챌린지-응답 인증

2편에서 메타 트랜잭션으로 가스비 대납을 다뤘다. 이번엔 인증이다. 풀려는 문제는 — 비밀번호 없이 “이 지갑의 소유자”임을 증명하는 것.

전통적인 인증은 ID + 비밀번호다. 비밀번호를 서버에 저장하고, 로그인할 때마다 비교한다. 문제는 서버가 털리면 비밀번호도 같이 유출된다는 것이다. DID Auth는 접근이 다르다 — 비밀번호를 보내지 않고, 개인키로 서명한 증거만 보낸다. 서버는 비밀을 저장하지 않는다.

DID란

DID(Decentralized Identifier)는 탈중앙 식별자다. did:ethr 방식에서는 이더리움 주소 자체가 DID가 된다.

// did/create-did.ts (요약)
export function createDID(address: string, chainId?: number): string {
  if (chainId !== undefined && chainId !== 1) {
    const chainRef = '0x' + chainId.toString(16);
    return `did:ethr:${chainRef}:${address}`;  // did:ethr:0x540:0xABC...
  }
  return `did:ethr:${address}`;                 // did:ethr:0xABC...
}

메인넷(chainId=1)이면 chainId를 생략하고, 그 외 체인에서는 hex로 변환해서 포함한다. 별도 등록 없이 주소만 있으면 DID가 성립한다.

챌린지-응답 흐름

DID Auth는 세 단계다:

sequenceDiagram
    participant S as 서버 (검증자)
    participant C as 클라이언트 (지갑)

    S->>C: 1. 챌린지 전달 (랜덤 nonce + 만료시간)
    C->>C: 2. 개인키로 챌린지 서명 (EIP-191)
    C->>S: 3. 서명값 전달
    S->>S: 4. 서명에서 주소 복원 → DID 주소와 비교
    S-->>C: 인증 성공/실패

비밀번호가 네트워크를 타지 않는다. 개인키도 전송되지 않는다. 오직 서명값만 오간다. 서버는 서명에서 주소를 복원해서 DID의 주소와 일치하는지만 확인한다.

1단계 — 챌린지 생성

// auth/did-auth.ts (요약)
export function createChallenge(expiresInMs = 60_000, domain?: string): DIDChallenge {
  const nonce = Array.from(crypto.getRandomValues(new Uint8Array(16)))
    .map((b) => b.toString(16).padStart(2, '0'))
    .join('');
  const issuedAt = Date.now();
  const domainPart = domain ? `:${domain}` : '';

  return {
    challenge: `did-auth:${nonce}:${issuedAt}${domainPart}`,
    issuedAt,
    expiresAt: issuedAt + expiresInMs,
  };
}

챌린지 포맷은 did-auth:{nonce}:{timestamp}:{domain} 이다. 세 가지 요소가 각각 역할을 한다:

2단계 — 서명

export async function signChallenge(
  challenge: string,
  privateKey: `0x${string}`,
): Promise<string> {
  const account = privateKeyToAccount(privateKey);
  return account.signMessage({ message: challenge });
}

EIP-191 personal_sign으로 서명한다. 내부적으로 "\x19Ethereum Signed Message:\n" + length + message를 해싱하고 ECDSA로 서명한다. TX 서명과 구분되기 때문에, 챌린지 서명이 TX로 악용될 수 없다.

3단계 — 검증

// auth/did-auth.ts (요약)
export async function verifyChallenge(
  challenge: string,
  signature: `0x${string}`,
  did: string,
  expiresAt?: number,
): Promise<DIDAuthResult> {
  // 만료 확인
  if (expiresAt && Date.now() > expiresAt) {
    return { verified: false, address: '', error: '챌린지가 만료되었습니다' };
  }
  // 리플레이 방지 — 같은 챌린지 재사용 차단
  if (usedChallenges.has(challenge)) {
    return { verified: false, address: '', error: '이미 사용된 챌린지입니다' };
  }

  const didAddress = did.split(':').pop()?.toLowerCase() as `0x${string}`;

  const valid = await verifyMessage({ address: didAddress, message: challenge, signature });
  if (valid) usedChallenges.add(challenge);  // 사용 완료 마킹

  return { verified: valid, address: didAddress };
}

검증 로직은 세 개의 방어선이 있다:

  1. 만료 확인 — 60초 지난 챌린지는 거부
  2. 리플레이 방지usedChallenges Set으로 한 번 쓴 챌린지는 재사용 불가
  3. 서명 검증verifyMessage가 서명에서 주소를 복원하고, DID의 주소와 비교

세 개 모두 통과해야 인증 성공이다.

블록체인을 안 쓴다

여기서 중요한 점은 — 이 과정에서 블록체인 네트워크에 접속하지 않는다. TX도 없고, 가스비도 없다. 순수하게 암호학(ECDSA 서명 + 주소 복원)만으로 동작한다.

did:ethr 방식이 이를 가능하게 한다. 이더리움 주소 자체가 공개키에서 파생되기 때문에, 서명을 검증하면 자동으로 “이 주소의 소유자인지”가 증명된다. 별도의 DID Document 조회나 온체인 레지스트리 접근이 필요 없다.

퍼블릭 체인이든 프라이빗 체인이든 상관없다. DID 포맷에 chainId가 포함되어 있어서 체인별 식별은 가능하지만, 인증 자체는 오프체인이다.

전체 사용 예시

import { createDID, createChallenge, signChallenge, verifyChallenge } from '@trust-core/wallet';

// 서버: 챌린지 생성
const challenge = createChallenge(60000, 'battery-wallet.example.com');

// 클라이언트: 서명
const did = createDID(address, 1344);
const signature = await signChallenge(challenge.challenge, privateKey);

// 서버: 검증
const result = await verifyChallenge(challenge.challenge, signature, did, challenge.expiresAt);
// → { verified: true, address: '0x...' }

서버는 비밀번호를 저장하지 않는다. DB에 DID(주소)만 저장하고, 로그인할 때마다 챌린지-응답으로 소유권을 확인한다.

회고

인증의 본질은 “비밀을 공유하는 것”이 아니라 “비밀을 가지고 있음을 증명하는 것”이었다.

DID Auth를 구현하면서 인증에 대한 관점이 바뀌었다. 비밀번호 기반 인증은 서버와 사용자가 같은 비밀을 공유한다. DID Auth는 개인키라는 비밀을 절대 공유하지 않으면서 소유를 증명한다. 서버가 털려도 유출될 비밀이 없다는 구조적 안전성이 가장 큰 장점이었다.


이전 편: Wallet SDK 고도화 #2 — 메타 트랜잭션으로 가스비 대납 다음 편: Wallet SDK 고도화 #4 — Nonce·Gas·TxQueue로 TX 안정성 확보


Share this post on:

Comments


Previous Post
Wallet SDK 고도화 #4 — Nonce·Gas·TxQueue로 TX 안정성 확보
Next Post
Wallet SDK 고도화 #2 — 메타 트랜잭션으로 가스비 대납