본문 바로가기
Backend2026년 9월 27일16분 읽기

오프라인에서도 검증되는 라이선스 키 — Ed25519 서명으로 설계한 LinkOn Pro

YS
김영삼
조회 15
오프라인에서도 검증되는 라이선스 키 — Ed25519 서명으로 설계한 LinkOn Pro

오프라인에서도 검증되는 라이선스 키는 "서버가 비밀키로 서명하고, 프로그램은 공개키로 검증하는" 구조로 만든다. Ed25519 서명을 키 안에 넣으면 프로그램은 인터넷 없이도 그 키가 내가 발급한 것인지 확인할 수 있다.

내가 만든 PC·안드로이드 연결 도구 LinkOn의 Pro 키를 이렇게 설계했다. 이 글은 키 형식, 발급 서버의 안전장치, 나중에 추가한 가벼운 온라인 확인, 그리고 끝까지 지킨 원칙 하나 — 이미 확인된 PC에서는 서버가 죽어도 Pro가 꺼지지 않는다 — 에 대한 기록이다.

3줄 요약
  • 키 = 접두사 + 페이로드 + Ed25519 서명. 프로그램 안에는 공개키만 있어서, 키를 위조하려면 서버의 비밀키가 있어야 한다.
  • 발급 서버는 시작할 때 비밀키에서 공개키를 계산해 프로그램에 박힌 공개키와 다르면 발급을 거부한다. 키 파일을 잘못 놓는 사고를 막는 장치다.
  • 온라인 확인은 새 PC 등록과 하루 1회 확인에만 쓰고, 이미 확인된 PC에서는 오프라인이든 서버 장애든 Pro를 끄지 않는다.

PART 01LinkOn Pro는 무엇을 잠그는가

설계 이야기 전에 Pro가 뭘 주는지부터 밝혀 둔다. LinkOn Pro는 광고만 없어진다. 속도도 기능도 무료판과 똑같다. 그리고 돈을 받지 않는다. 블로그에 LinkOn 소개 글을 쓰고 신청하면, 서버가 글을 열어 링크가 들어 있는지 등을 자동으로 확인한 뒤 바로 키를 발급한다.

이 전제가 설계 전체를 정했다. 잠그는 게 광고 하나뿐이라면, 크랙을 막으려고 정직한 사용자를 불편하게 할 이유가 거의 없다. 누가 키를 위조해서 광고를 끈다고 내가 잃는 건 미미하다. 반대로 정상적으로 키를 받은 사람이 어느 날 갑자기 "라이선스 확인 실패"를 보면, 그 사람은 소개 글까지 써 준 사람이다. 손해의 크기가 비교가 안 된다.

영삼
내 생각
라이선스 시스템의 첫 번째 목표는 크랙 방지가 아니라 정직한 사용자를 불편하게 하지 않는 것이라고 본다. 특히 1인 개발자의 작은 프로그램에서는, 막겠다고 넣은 장치가 훨씬 많은 정상 사용자를 괴롭히는 경우가 대부분이다. 나는 "위조는 어렵게, 정상 사용은 마찰 없이"를 기준으로 삼았다.

PART 02키 형식: 접두사 + 페이로드 + Ed25519 서명

키는 세 부분으로 이뤄진다. 사람이 봐도 어떤 키인지 알 수 있는 짧은 접두사, 발급 정보가 담긴 페이로드, 그리고 그 페이로드에 대한 Ed25519 서명이다. 페이로드와 서명은 base64url로 인코딩해서 점으로 이어 붙인다. 복사·붙여넣기 할 때 깨지지 않고, URL이나 파일 이름에 써도 문제가 없다.

왜 Ed25519인가. 공개키가 32바이트, 서명이 64바이트로 짧아서 키 문자열이 길어지지 않는다. 같은 메시지를 같은 키로 서명하면 항상 같은 서명이 나오는 결정적 서명이라, 서명할 때 난수 생성기 품질에 기대지 않아도 된다. 그리고 Node.js의 기본 crypto 모듈과 Go 표준 라이브러리의 crypto/ed25519에 모두 들어 있어서, 서버와 클라이언트 언어가 달라도 별도 라이브러리가 필요 없다. 명세는 RFC 8032에 정리되어 있다.

아래는 원리를 보여 주는 일반적인 예시다. 실제 키 형식이나 필드 이름과는 다르고, 키도 매번 새로 만든다.

Node.js로 Ed25519 라이선스 키 서명·검증하기 (일반 예시)
const crypto = require('crypto')

// 1) 키쌍 생성 (실제로는 한 번만 만들어 비밀키는 서버에만 둔다)
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519')

// 2) 발급: 페이로드를 JSON으로 만들고 서명한다 (Ed25519는 알고리즘 인자에 null)
const payload = Buffer.from(JSON.stringify({ id: 'L-0001', plan: 'pro', iat: '2026-09-27' }))
const sig = crypto.sign(null, payload, privateKey)            // 64바이트
const key = 'DEMO-' + payload.toString('base64url') + '.' + sig.toString('base64url')

// 3) 검증: 프로그램에는 공개키만 들어 있다 (raw 32바이트로 내장하는 경우가 많다)
function verifyKey(str, pub) {
  if (!str.startsWith('DEMO-')) return null
  const [p, s] = str.slice(5).split('.')
  if (!p || !s) return null
  const data = Buffer.from(p, 'base64url')
  const ok = crypto.verify(null, data, pub, Buffer.from(s, 'base64url'))
  return ok ? JSON.parse(data.toString()) : null
}

console.log(verifyKey(key, publicKey))                          // { id, plan, iat }
// 4) 조작 시도: 페이로드의 plan을 바꾸고 서명은 그대로 둔다
const forged = Buffer.from(JSON.stringify({ id: 'L-0001', plan: 'max', iat: '2026-09-27' }))
const fakeKey = 'DEMO-' + forged.toString('base64url') + '.' + sig.toString('base64url')
console.log(verifyKey(fakeKey, publicKey))                      // null (서명 불일치)

핵심은 3번이다. 검증에는 공개키만 필요하고, 공개키는 공개되어도 상관없다. 누군가 프로그램을 뜯어 공개키를 꺼내도, 그걸로 새 키를 만들 수는 없다. 페이로드를 한 글자라도 바꾸면 서명 검증이 실패한다. 그래서 프로그램은 인터넷 연결 없이도 "이 키는 내 서버가 발급한 것이 맞다"를 판단할 수 있다.

Go 클라이언트라면. Go에서는 ed25519.Verify(pub, message, sig) 한 줄이면 된다. 공개키를 32바이트 바이트 배열로 바이너리에 넣어 두고, 키 문자열을 나눠 base64url로 디코딩한 뒤 넘기면 된다. Node에서 서명한 것을 Go에서 그대로 검증할 수 있다.

PART 03발급 서버의 안전장치: 공개키 대조와 대기열

서명 방식은 수학적으로 깔끔한데, 실제로 사고가 나는 곳은 대개 운영이다. 내가 가장 걱정한 건 비밀키 파일을 잘못 놓는 일이었다. 테스트용 키를 운영 서버에 올리거나, 키를 새로 만들어 덮어쓰면 어떻게 될까. 서버는 아무 문제 없이 키를 발급하고, 그 키는 사용자 프로그램에서 전부 검증에 실패한다. 사용자는 받은 키가 안 된다고 하고, 나는 원인을 찾느라 한참 헤맨다.

그래서 발급 서버는 시작할 때 자기가 가진 비밀키에서 공개키를 계산해서, 프로그램에 박혀 있는 공개키와 같은지 대조한다. 다르면 발급을 아예 거부한다. Ed25519는 비밀키에서 공개키가 결정적으로 나오니 이 비교는 한 줄이면 된다. 잘못된 키로 키를 쏟아내는 것보다 발급이 멈추는 게 훨씬 낫다.

1
시작 시 비밀키 로드
비밀키는 서버의 별도 파일에 두고, 웹앱을 돌리는 계정만 읽을 수 있게 권한을 좁혔다. 코드 저장소나 환경 설정 파일에는 넣지 않는다.
2
공개키 계산 후 대조
비밀키에서 공개키를 계산해 프로그램에 내장된 공개키와 비교한다. 다르면 발급 기능을 끄고 로그를 남긴다.
3
키 파일이 없으면 대기(pending)
파일이 없거나 대조에 실패하면 신청은 거절하지 않고 대기 상태로 쌓인다. 문제를 고친 뒤 처리하면 신청자가 다시 할 일이 없다.
4
블로그 글 확인 후 즉시 발급
정상 상태라면 신청한 글 2개를 열어 링크가 들어 있는지 등을 확인하고, 통과하면 바로 서명해 키를 내준다.

블로그 글을 여는 부분에도 사정이 있다. 웹앱 서버는 외부로 나가는 연결을 막아 두었다. 그래서 글을 가져오는 일은 별도의 작은 서비스가 대신 한다. 사용자가 입력한 URL을 서버가 대신 여는 구조라 SSRF(서버 측 요청 위조)에 그대로 노출되기 쉬운데, 이 서비스는 사설·내부 IP로 향하는 요청을 막고, 80·443 포트만 허용하고, 리디렉션이 일어날 때마다 목적지를 다시 검사한다. 처음 주소는 멀쩡해도 리디렉션으로 내부 주소를 노리는 경우가 있어서, 마지막 검사가 특히 중요했다.

삽질 기록 · 0.2.0에서 온라인 확인을 붙인 이유
처음 버전은 순수하게 오프라인 서명 검증만 했다. 그런데 키는 문자열이라 한 번 퍼지면 회수할 방법이 없다. 그래서 0.2.0부터 가벼운 서버 확인을 추가했다. 새 PC를 등록할 때는 온라인이 필수이고, 키 하나는 기기 3대까지 쓸 수 있고, 등록된 PC는 하루 1회 확인한다. 서버는 active / revoked / unknown / invalid 중 하나로 답한다. 서명이 맞더라도 서버 DB에 발급 상태로 기록되어 있어야 active다. 그런데 이걸 붙이면서 가장 오래 고민한 건 기능이 아니라 "서버가 응답하지 않으면 어떻게 하느냐"였다. 결론은 사용자 요구를 받아 분명하게 정했다. 이미 확인된 PC에서는 오프라인이거나 서버가 죽어도 Pro가 절대 꺼지지 않는다.

PART 04검증을 어디서 할 것인가: 세 가지 방식

라이선스 검증을 설계할 때 결국 고르는 건 "누가, 언제 판단하느냐"다. 내가 비교해 본 세 가지 방식을 정리하면 아래와 같다.

라이선스 검증 위치별 장단점
방식장점단점어울리는 경우
오프라인 서명만서버 없이 영구 동작, 개인정보 전송 없음, 서버 장애와 무관유출된 키를 회수할 수 없음, 기기 수 제한 불가잠그는 기능이 가볍고 배포 규모가 작을 때
온라인 활성화만키 회수·기기 제한·사용 현황 파악이 쉬움서버가 죽으면 모든 사용자가 잠김, 오프라인 환경에서 못 씀구독형 서비스, 서버가 원래 필수인 앱
혼합(서명 + 가벼운 확인)오프라인에서도 동작, 신규 등록·회수는 서버가 통제구현할 게 조금 늘고, 장애 시 정책을 따로 정해야 함1인 개발자의 데스크톱 프로그램

나는 세 번째를 택했다. 진위 판단은 서명이 하고, 서버는 "새 기기를 들여도 되는가"와 "이 키가 취소되었는가"만 본다. 이렇게 역할을 나누면 서버가 할 일이 작아지고, 서버가 없을 때 무너지는 범위도 작아진다.

영삼
내 생각
1인 개발자에게 현실적인 답은 "오프라인 서명 검증 + 가벼운 온라인 확인" 혼합이라고 본다. 서버를 24시간 완벽하게 지킬 수 있는 팀이 아니라면, 온라인 활성화만으로 만든 라이선스는 내 서버 장애를 그대로 사용자 장애로 바꾸는 장치가 된다. 나는 그 책임을 질 자신이 없다.

PART 05fail-open: 이미 확인된 PC는 끄지 않는다

보안 쪽에서는 보통 "실패하면 닫아라(fail-closed)"가 정석이다. 확인을 못 하면 거부하는 게 안전하다는 논리다. 그런데 라이선스에서는 이 정석이 정직한 사용자를 겨눈다. 서버가 점검 중이거나, 회사 방화벽이 요청을 막거나, 노트북을 들고 비행기를 탔을 때 Pro가 꺼진다면, 그건 보안이 아니라 그냥 고장이다.

그래서 LinkOn은 상태를 나눠서 처리한다. 응답이 없거나, 네트워크 오류가 나거나, 서버가 이상한 값을 돌려줘도 이미 확인된 PC는 마지막으로 확인된 상태를 그대로 유지한다. 키를 중지하는 건 기계가 아니라 사람이 판단할 때뿐이고, 중지된 키는 재설치나 새 PC 등록이 막힌다. 새 PC 등록만은 온라인이 필수인데, 이건 아직 확인된 적 없는 기기이기 때문이다.

이미 확인된 PCfail-open
  • 오프라인이어도 Pro 유지
  • 서버 무응답·오류 시 마지막 상태 유지
  • 하루 1회 확인은 조용히 재시도
  • 키 중지는 사람이 판단할 때만
처음 등록하는 PC온라인 필수
  • 서버 확인이 성공해야 등록
  • 기기 3대 한도를 서버가 판단
  • 서명이 맞아도 서버 기록이 없으면 등록 불가
  • 실패 시 나중에 다시 시도하라고 안내

같은 이유로 자동 중지 기능도 넣지 않았다. Pro 키는 블로그 글을 조건으로 발급되니, 글이 사라지면 자동으로 키를 끊자는 생각도 해 봤다. 하지만 블로그 글은 생각보다 자주 "사라진 것처럼" 보인다. 플랫폼 점검, 일시적인 접속 오류, 주소 변경, 비공개 전환 같은 이유로 말이다. 그래서 재확인에서 글을 찾지 못하면 그 신청을 "중지 후보"로 표시만 하고, 실제로 취소할지는 내가 직접 보고 판단한다.

영삼
내 생각
자동 중지는 오탐 비용이 너무 크다. 진짜로 글을 지운 사람을 놓쳐서 생기는 손해는 광고 하나다. 반대로 멀쩡한 사람의 Pro를 자동으로 끊으면, 소개 글까지 써 준 사람에게 모욕을 주는 셈이다. 사람이 한 번 보는 수고가 그보다 훨씬 싸다.
서명이 맞는다고 active는 아니다. 서명 검증은 "내가 발급한 형식이 맞다"까지만 증명한다. 서버 확인에서는 DB에 발급 기록이 있고 취소되지 않은 키만 active로 답한다. 혹시 비밀키 관리에 사고가 나더라도, 서버 기록이 없는 키는 새 기기에 등록되지 않도록 한 겹을 더 둔 것이다.
LinkOn Pro 키를 만들며 정한 것
  1. 진위는 Ed25519 서명이 판단한다. 프로그램에는 공개키만 두고, 비밀키는 서버의 별도 파일에 최소 권한으로 둔다.
  2. 발급 서버는 시작할 때 비밀키에서 계산한 공개키를 프로그램의 공개키와 대조하고, 다르면 발급을 멈추고 신청을 대기열에 쌓는다.
  3. 온라인 확인은 새 기기 등록, 기기 3대 제한, 하루 1회 상태 확인에만 쓴다.
  4. 이미 확인된 PC에서는 오프라인·서버 장애에도 Pro를 끄지 않는다. 명시적 취소만 반영한다.
  5. 자동 중지는 하지 않는다. 의심스러운 건 표시만 하고 사람이 판단한다.

자주 묻는 질문

라이선스 키를 인터넷 없이 검증하려면 어떻게 하나요?

서버가 비밀키로 키 내용에 디지털 서명을 하고, 프로그램에는 공개키만 넣어 서명을 검증하게 하면 된다. Ed25519 같은 서명 알고리즘을 쓰면 공개키가 노출되어도 새 키를 만들 수 없다. 이렇게 하면 서버에 접속하지 않고도 키가 정식 발급된 것인지 확인할 수 있다.

라이선스 키에 왜 Ed25519를 쓰나요?

Ed25519는 공개키 32바이트, 서명 64바이트로 짧아서 키 문자열을 간결하게 만들 수 있다. 결정적 서명이라 서명할 때 난수 품질에 의존하지 않고, Node.js crypto와 Go 표준 라이브러리 등 주요 언어에 기본으로 들어 있다. 명세는 RFC 8032에 있다.

Node.js에서 Ed25519로 서명하고 검증하는 방법은?

crypto.generateKeyPairSync("ed25519")로 키쌍을 만들고, crypto.sign(null, data, privateKey)로 서명, crypto.verify(null, data, publicKey, signature)로 검증한다. Ed25519는 해시 알고리즘을 따로 지정하지 않으므로 첫 인자에 null을 넘긴다. 서명은 base64url로 인코딩하면 키 문자열에 넣기 편하다.

라이선스 서버가 다운되면 사용자 프로그램은 어떻게 되나요?

설계에 따라 다르다. 온라인 활성화만 쓰면 서버 장애가 곧 사용자 장애가 된다. 나는 이미 확인된 기기에서는 서버가 응답하지 않아도 마지막으로 확인된 상태를 유지하고, 명시적인 취소 응답이 올 때만 기능을 내리도록 만들었다.

오프라인 라이선스 키는 크랙을 막을 수 있나요?

서명 덕분에 키 위조는 비밀키 없이는 사실상 불가능하지만, 프로그램 자체를 수정해 검증을 건너뛰는 것까지 막지는 못한다. 그래서 크랙을 완벽히 막기보다는 정상 사용자의 불편을 줄이는 쪽에 무게를 두는 게 현실적이다. 유출된 키는 가벼운 온라인 확인으로 신규 등록을 막는 식으로 대응할 수 있다.

참고 자료

댓글 0

아직 댓글이 없습니다.
Ctrl+Enter로 등록