Skip to content

서명 알고리즘

CyberGo JWT 는 4 종류 총 12 개의 서명 알고리즘을 지원하며, 단일 애플리케이션부터 마이크로서비스 아키텍처까지 다양한 시나리오를 다룹니다.

알고리즘 개요

타입알고리즘키 타입적용 시나리오
HMACHS256 / HS384 / HS512대칭 키단일 애플리케이션, 단순 서비스
RSARS256 / RS384 / RS512공개 키/개인 키마이크로서비스, 다중 서비스 검증
RSA-PSSPS256 / PS384 / PS512공개 키/개인 키마이크로서비스 (RSA 대체 권장)
ECDSAES256 / ES384 / ES512공개 키/개인 키고성능 마이크로서비스

HMAC (대칭 키)

HMAC 은 동일한 키로 서명하고 검증하는 가장 간단한 방식입니다.

키 요구사항

HMAC 키는 validateSigningKey의 두 가지 검사를 통과해야 합니다:

  • 길이 검사: len(SecretKey) < 32인 경우 ErrInvalidSecretKey 반환, 오류 메시지에는 실제 바이트 길이가 포함됩니다 (예: "minimum 32 bytes required, got 16")
  • 엔트로피 검사: internal.IsWeakKey로 저엔트로피 키를 감지하며, 다음 패턴이 거부됩니다:
    • 모두 동일한 문자 (예: "aaaaaaaa...")
    • 반복되는 짧은 패턴 (예: "abcabcabc...")
    • 연속 증가/감소 시퀀스 (예: "abcdefgh...")
    • 일반적인 약한 비밀번호 및 그 변형 (예: "password", "qwerty")

약한 키는 거부됩니다

"반복 문자", "연속 시퀀스", "사전 단어" 등 추측하기 쉬운 키를 사용하지 마세요. 길이가 32바이트에 도달하더라도 저엔트로피 키는 jwt.New 초기화 단계에서 거부되어 ErrInvalidSecretKey를 반환합니다.

프로덕션 환경에서는 암호학적으로 안전한 난수 소스로 키를 생성해야 합니다:

go
package main

import (
    "crypto/rand"
    "encoding/base64"
    "fmt"
    "log"

    "github.com/cybergodev/jwt"
)

func main() {
    // crypto/rand 로 32 바이트 난수 키 생성
    raw := make([]byte, 32)
    if _, err := rand.Read(raw); err != nil {
        log.Fatal(err)
    }
    // base64 인코딩으로 저장, 전달
    secret := base64.StdEncoding.EncodeToString(raw)

    cfg := jwt.DefaultConfig()
    cfg.SecretKey = secret
    processor, err := jwt.New(cfg)
    if err != nil {
        log.Fatal(err)
    }
    defer processor.Close()

    fmt.Println("HMAC 키 준비 완료, 길이(바이트):", len(secret)) // 출력: HMAC 키 준비 완료, 길이(바이트): 44
}

사용법

go
cfg := jwt.DefaultConfig()
cfg.SecretKey = "hmac-key-that-has-at-least-32-bytes!"
cfg.SigningMethod = jwt.SigningMethodHS256 // 기본값, 생략 가능

알고리즘 선택

상수알고리즘설명
SigningMethodHS256HMAC-SHA256권장, 성능과 보안의 균형
SigningMethodHS384HMAC-SHA384더 높은 보안성
SigningMethodHS512HMAC-SHA512최고 보안성

권장

대부분의 시나리오에서는 HS256을 사용하면 충분합니다. 키는 암호학적으로 안전한 난수로 생성하고 길이는 최소 32 바이트 이상으로 하는 것을 권장합니다.

RSA (비대칭 키)

RSA 는 개인 키로 서명하고 공개 키로 검증합니다. 검증 측에서 개인 키를 보유할 필요가 없는 시나리오에 적합합니다.

사용법

go
cfg := jwt.DefaultConfig()
cfg.SigningMethod = jwt.SigningMethodRS256
cfg.SigningKey = rsaPrivateKey        // *rsa.PrivateKey
cfg.VerificationKey = rsaPublicKey    // *rsa.PublicKey (선택)

검증 키

VerificationKey는 선택 사항입니다. 설정하지 않으면 라이브러리가 SigningKey를 사용하여 검증합니다 (내부적으로 개인 키에서 공개 키를 추출).

키 생성

go
// 2048 비트 RSA 키 쌍 생성 (라이브러리는 최소 2048 비트를 강제 요구, 미만이면 ErrInvalidSecretKey 반환)
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
    log.Fatal(err)
}
publicKey := &privateKey.PublicKey

알고리즘 선택

상수알고리즘설명
SigningMethodRS256RSA-SHA256권장
SigningMethodRS384RSA-SHA384더 높은 보안성
SigningMethodRS512RSA-SHA512최고 보안성

RSA-PSS 와 키 공유

RS256/RS384/RS512와 PS256/PS384/PS512는 동일한 키 타입(*rsa.PrivateKey / *rsa.PublicKey)과 동일한 검증 로직을 사용하므로 키를 교환하여 재사용할 수 있습니다. RSA에서 RSA-PSS로 마이그레이션할 때 키를 재생성할 필요가 없습니다.

RSA-PSS (비대칭 키, RSA 대체 권장)

RSA-PSS 는 RSA 의 개선된 서명 방식으로, 확률적 서명 방식 (PSS) 패딩을 사용하여 PKCS#1 v1.5 보다 보안성이 뛰어납니다. 키는 RSA 와 동일합니다.

사용법

go
cfg := jwt.DefaultConfig()
cfg.SigningMethod = jwt.SigningMethodPS256
cfg.SigningKey = rsaPrivateKey        // *rsa.PrivateKey (RSA 와 키 공유)
cfg.VerificationKey = rsaPublicKey    // *rsa.PublicKey (선택)

대체 권장

RSA-PSS 는 RSA PKCS#1 v1.5 보다 안전하므로 신규 프로젝트에서는 RSA-PSS 알고리즘을 우선 사용하는 것이 좋습니다. 키는 RSA 와 완전히 동일하므로 추가로 생성할 필요가 없습니다.

알고리즘 선택

상수알고리즘설명
SigningMethodPS256RSA-PSS-SHA256권장
SigningMethodPS384RSA-PSS-SHA384더 높은 보안성
SigningMethodPS512RSA-PSS-SHA512최고 보안성

ECDSA (타원 곡선)

ECDSA 역시 비대칭 알고리즘이지만, 키가 더 짧고 성능이 더 좋습니다.

사용법

go
cfg := jwt.DefaultConfig()
cfg.SigningMethod = jwt.SigningMethodES256
cfg.SigningKey = ecdsaPrivateKey      // *ecdsa.PrivateKey
cfg.VerificationKey = ecdsaPublicKey  // *ecdsa.PublicKey (선택)

키 생성

go
// P-256 곡선 키 쌍 생성
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
    log.Fatal(err)
}
publicKey := &privateKey.PublicKey

알고리즘 선택

상수알고리즘곡선설명
SigningMethodES256ECDSA-SHA256P-256권장
SigningMethodES384ECDSA-SHA384P-384더 높은 보안성
SigningMethodES512ECDSA-SHA512P-521최고 보안성

곡선 매칭

알고리즘과 곡선은 엄격하게 대응해야 하며, 초기화 시 강제 검증합니다 (소스 코드는 validateECDSACurve 참조):

알고리즘반드시 사용해야 하는 곡선생성 방법
ES256P-256elliptic.P256()
ES384P-384elliptic.P384()
ES512P-521elliptic.P521()

ES512 는 P-521 을 사용, P-512 가 아님

ES512에 대응하는 곡선은 P-521입니다 (512가 아닌 521에 주의). 이는 흔한 실수입니다 — 숫자 512 때문에 곡선도 P-512일 것으로 오해하기 쉽지만, Go 표준 라이브러리에는 P512가 존재하지 않으며 최상위 곡선이 elliptic.P521()입니다. 곡선이 불일치하면 ErrInvalidSecretKey를 반환합니다.

키 분리 모드

마이크로서비스 아키텍처에서는 보통 서명 능력(토큰 발급)과 검증 능력(토큰 검증)을 분리하여 최소 권한 원칙을 따릅니다:

서비스 역할보유 키책임
인증 서비스개인 키 (SigningKey)로그인 성공 후 액세스 토큰 발급
API 서비스공개 키 (VerificationKey)토큰 서명 검증, 발급에 참여하지 않음

인증 서비스가 개인 키를 보유하고 발급을 담당하며, API 서비스는 공개 키로 토큰을 검증합니다. API 서비스의 설정에 SigningKey가 기록되어 있더라도 (현재 API는 이 필드가 비어있지 않음을 요구), VerificationKey만 설정되어 있으면 검증 시 해당 공개 키를 사용합니다.

VerificationKey 우선

VerificationKey를 설정하면 검증 흐름은 SigningKey에서 추출한 공개 키가 아닌 해당 공개 키를 사용합니다. 이를 통해 API 서비스가 검증 키를 명시적으로 제어할 수 있어, 검증 키와 서명 키를 분리하여 배포하는 시나리오에 적합합니다.

인증 서비스 (토큰 발급):

go
authCfg := jwt.DefaultConfig()
authCfg.SigningMethod = jwt.SigningMethodRS256
authCfg.SigningKey = rsaPrivateKey           // *rsa.PrivateKey, 서명에 사용
authCfg.VerificationKey = &rsaPrivateKey.PublicKey

API 서비스 (검증 전용):

go
apiCfg := jwt.DefaultConfig()
apiCfg.SigningMethod = jwt.SigningMethodRS256
apiCfg.SigningKey = rsaPrivateKey            // 현재 API 는 SigningKey 가 비어있지 않음을 요구
apiCfg.VerificationKey = rsaPublicKey        // *rsa.PublicKey, 검증 시 실제 사용

주의

검증 전용 ProcessorCreate / CreateRefresh를 호출해서는 안 됩니다 (서명에는 개인 키 필요). 전체 교차 서비스 예제는 고급 예제를 참조하세요.

선택 방법

text
단일 애플리케이션 ──────────→ HMAC
마이크로서비스 (동일 신뢰 도메인) ──→ HMAC
마이크로서비스 (교차 서비스 검증)→ RSA, RSA-PSS 또는 ECDSA
보안 우선 ────────────────→ RSA-PSS (RSA 대체)
고성능 요구 ──────────────→ ECDSA
키 길이 민감 ─────────────→ ECDSA
고려 요소HMACRSARSA-PSSECDSA
서명 속도빠름느림느림빠름
검증 속도빠름빠름빠름빠름
키 길이32+ 바이트2048+ 비트2048+ 비트256+ 비트
서명 길이고정김 (~256 바이트)김 (~256 바이트)짧음 (~64 바이트)
아키텍처 결합강결합느슨한 결합느슨한 결합느슨한 결합
보안성높음높음더 높음높음

키 관리 모범 사례

환경 변수 주입

환경 변수로 키를 전달하여 소스 코드에 하드코딩하지 않습니다:

go
package main

import (
    "fmt"
    "os"

    "github.com/cybergodev/jwt"
)

func main() {
    secret := os.Getenv("JWT_SECRET_KEY")
    cfg := jwt.DefaultConfig()
    cfg.SecretKey = secret
    processor, err := jwt.New(cfg)
    if err != nil {
        fmt.Println("키가 유효하지 않음:", err)
        return
    }
    defer processor.Close()
    fmt.Println("Processor 준비 완료") // 출력: Processor 준비 완료
}

PEM 파일에서 RSA 키 로드

프로덕션 환경에서는 보통 비대칭 키를 PEM 파일로 저장하고, 시작 시 crypto/x509로 파싱하여 로드합니다:

go
package main

import (
    "crypto/x509"
    "encoding/pem"
    "fmt"
    "os"

    "github.com/cybergodev/jwt"
)

func main() {
    // 개인 키 PEM 파일 읽기
    keyData, err := os.ReadFile("private_key.pem")
    if err != nil {
        fmt.Println("개인 키 읽기 실패:", err)
        return
    }

    block, _ := pem.Decode(keyData)
    if block == nil {
        fmt.Println("PEM 디코딩 실패")
        return
    }

    privateKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
    if err != nil {
        fmt.Println("개인 키 파싱 실패:", err)
        return
    }

    cfg := jwt.DefaultConfig()
    cfg.SigningMethod = jwt.SigningMethodRS256
    cfg.SigningKey = privateKey
    processor, err := jwt.New(cfg)
    if err != nil {
        fmt.Println("초기화 실패:", err)
        return
    }
    defer processor.Close()
    fmt.Println("RSA 키가 PEM에서 로드됨") // 출력: RSA 키가 PEM에서 로드됨
}

PEM 에서 공개 키 로드

공개 키 PEM 파일은 x509.ParsePKIXPublicKey로 파싱하며, 반환값은 any이므로 *rsa.PublicKey 또는 *ecdsa.PublicKey로 타입 단언해야 합니다. 전체 예제는 고급 예제를 참조하세요.

키 교체

교체 권장 사항

  • 서명 키를 정기적으로 교체하세요 (3-6개월마다 권장)
  • 신구 키 병행 기간에는 검증 측에서 두 공개 키를 모두 수락
  • kid (Key ID) 헤더로 현재 키 버전을 식별하여 점진적 전환에 활용
  • 교체 완료 후 이전 키를 폐기하고, 블랙리스트 동기화 필요 여부 확인

보안 주의사항

금지 사항

  • 코드에 키를 하드코딩하지 마세요
  • 약한 키를 사용하지 마세요 (숫자만, 반복 문자 등)
  • none 알고리즘을 사용하지 마세요 (본 라이브러리는 자동으로 거부함)
  • HMAC 키는 32 바이트 미만으로 사용하지 마세요

모범 사례

  • 환경 변수 또는 키 관리 서비스를 사용하여 키를 저장하세요
  • 서명 키를 정기적으로 교체하세요
  • 프로덕션 환경에서는 RSA 또는 ECDSA 사용을 권장합니다
  • RSA 키는 2048 비트 이상을 권장합니다

다음 단계