AWS KMS의 kms:Sign과 kms:GetPublicKey 두 권한이 왜 모두 필요한지,
Ethereum 서명의 내부 동작을 따라가며 이해한 기록.
배경: 왜 AWS KMS로 서명하는가?
Ethereum 트랜잭션에 서명하려면 개인키가 필요하다. 보통은 keystore 파일이나 환경변수에 평문으로 저장하지만, 프로덕션 환경에서는 보안 위험이 크다.
AWS KMS는 개인키를 HSM(Hardware Security Module) 내부에 보관하고, 절대 외부에 노출하지 않으면서 서명만 수행해준다. IAM 정책으로 접근을 제어할 수 있고, CloudTrail로 모든 서명 요청이 기록된다.
필요한 IAM 권한은 딱 두 가지다:
1
2
3
4
5
6
| {
"Action": [
"kms:Sign",
"kms:GetPublicKey"
]
}
|
처음에는 “서명만 하면 되는데 왜 GetPublicKey까지 필요하지?“라는 의문이 들었다. 이 글은 그 의문을 따라가며 Ethereum 서명의 내부 구조를 파헤친 기록이다.
이 글에서 분석하는 코드는 go-ethereum-aws-kms-tx-signer 저장소를 기반으로 한다.
공개키 디코딩: KMS 응답에서 Ethereum 주소까지
KMS가 반환하는 공개키 포맷
AWS KMS의 GetPublicKey API는 DER-encoded X.509 SubjectPublicKeyInfo(RFC 5280) 형식으로 공개키를 반환한다.
1
2
3
4
| SubjectPublicKeyInfo ::= SEQUENCE {
algorithm AlgorithmIdentifier -- "EC 알고리즘, secp256k1 커브"
subjectPublicKey BIT STRING -- 실제 공개키 바이트
}
|
왜 이런 포맷인가? KMS는 Ethereum 전용이 아니라 TLS 인증서, JWT 서명, 코드 서명 등 범용 암호화 서비스다. raw 공개키만 반환하면 받는 쪽에서 “이게 RSA인지 EC인지, 어떤 커브인지” 알 수 없다. SubjectPublicKeyInfo에 알고리즘과 커브 OID를 함께 담아 자기 설명적(self-describing) 포맷으로 만든 것이다.
참고로 KMS에 키를 import할 때는 PKCS#8(RFC 5958)을 사용한다:
| 방향 | Import (키 넣을 때) | Export (GetPublicKey 응답) |
|---|
| 대상 | 개인키 (private key) | 공개키 (public key) |
| 포맷 | PKCS#8 (RFC 5958) | SubjectPublicKeyInfo (RFC 5280) |
둘 다 AlgorithmIdentifier를 공유하는 X.509 계열 표준이며, 안에 담기는 게 개인키냐 공개키냐만 다르다.
ASN.1 파싱으로 공개키 바이트 추출
Go에서는 asn1.Unmarshal로 DER 껍질을 벗긴다. 실제 코드에서 ASN.1 구조체 정의를 보면:
1
2
3
4
5
6
7
8
9
| type asn1EcPublicKey struct {
EcPublicKeyInfo asn1EcPublicKeyInfo
PublicKey asn1.BitString // ← 이것이 실제 공개키
}
type asn1EcPublicKeyInfo struct {
Algorithm asn1.ObjectIdentifier // OID 1.2.840.10045.2.1 (EC)
Parameters asn1.ObjectIdentifier // OID 1.3.132.0.10 (secp256k1)
}
|
KMS 응답에서 실제 공개키를 추출하는 getPublicKeyDerBytesFromKMS 함수:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| func getPublicKeyDerBytesFromKMS(ctx context.Context, svc *kms.Client, keyId string) ([]byte, error) {
getPubKeyOutput, err := svc.GetPublicKey(ctx, &kms.GetPublicKeyInput{
KeyId: aws.String(keyId),
})
if err != nil {
return nil, errors.Wrapf(err, "can not get public key from KMS for KeyId=%s", keyId)
}
// ASN.1 DER 파싱: SubjectPublicKeyInfo에서 공개키 바이트만 추출
var asn1pubk asn1EcPublicKey
_, err = asn1.Unmarshal(getPubKeyOutput.PublicKey, &asn1pubk)
if err != nil {
return nil, errors.Wrapf(err, "can not parse asn1 public key for KeyId=%s", keyId)
}
return asn1pubk.PublicKey.Bytes, nil
}
|
추출된 PublicKey.Bytes는 65바이트의 비압축(uncompressed) 공개키다:
1
| [0x04] [---- X 좌표 32 bytes ----] [---- Y 좌표 32 bytes ----]
|
0x04는 RFC 5480 Section 2.2에서 정의한 “uncompressed point” 표시자다.
secp256k1 타원곡선의 기초
X, Y는 secp256k1 타원곡선 위의 좌표다.
$$y^2 = x^3 + ax + b \pmod{p}$$secp256k1의 파라미터(모두 고정 상수):
- $a = 0$, $b = 7$ → 즉, $y^2 = x^3 + 7 \pmod{p}$
- $p = 2^{256} - 2^{32} - 977$
- $G$ (generator point): 고정된 기준점
- $n$ (order): $G$를 $n$번 점 덧셈하면 무한원점(항등원)으로 돌아감
$a$, $b$, $p$, $G$, $n$은 secp256k1 스펙에 고정된 상수이며 모든 사용자(Bitcoin, Ethereum 등)가 동일하다. 개인별로 달라지는 것은 **개인키 $k$**와 그로부터 파생되는 공개키 좌표 $(X, Y)$ 뿐이다.
공개키는 $(X, Y) = k \times G$로 계산된다. 여기서 $\times$는 일반 곱셈이 아니라 타원곡선 점 스칼라 곱셈(point scalar multiplication) — $G$를 $k$번 점 덧셈(point addition)하는 연산이다.
1
2
| 순방향: k → (X, Y) 계산 가능 (point multiplication)
역방향: (X, Y) → k 계산 불가능 (이산 로그 문제, ECDLP)
|
이것이 공개키 암호의 기반이며, KMS가 개인키 $k$를 외부에 노출하지 않고 서명만 수행하는 이유다.
공개키에서 Ethereum 주소 도출
Mastering Ethereum Ch.4에 따르면:
“The address is the last 20 bytes of the keccak256 hash of the public key”
1
2
3
4
5
6
7
8
9
10
| // 1. 0x04 prefix 제거
rawPubKey := pubKeyBytes[1:] // 64 bytes (X || Y)
// 2. keccak256 해시 (Ethereum은 NIST SHA-3가 아닌 원본 Keccak-256 사용)
hash := sha3.NewLegacyKeccak256()
hash.Write(rawPubKey)
fullHash := hash.Sum(nil) // 32 bytes
// 3. 마지막 20 bytes = Ethereum 주소
address := fullHash[12:] // 20 bytes
|
왜 keccak256인가? Ethereum 개발 당시(2014~2015) NIST가 Keccak을 SHA-3(FIPS 202)로 표준화하면서 패딩을 변경했는데, Ethereum은 표준화 이전의 원본 Keccak을 이미 채택한 상태였다.
왜 앞 12바이트를 버리는가? 특별한 암호학적 이유는 없다. Bitcoin 주소도 20바이트이고, Ethereum이 이를 따른 것으로 보인다.
서명 과정: KMS Sign → Ethereum 서명 (r, s, v)
AWS KMS의 서명을 Ethereum이 요구하는 형식으로 변환하는 과정은 4단계로 나뉜다.
Step 1. 트랜잭션 해시를 KMS에 보내 서명 요청
1
2
3
4
5
6
7
8
| txHashBytes := signer.Hash(tx).Bytes() // keccak256(RLP(tx))
signInput := &kms.SignInput{
KeyId: aws.String(keyId),
Message: txHashBytes,
SigningAlgorithm: "ECDSA_SHA_256",
MessageType: "DIGEST", // ← 반드시 지정!
}
|
MessageType: "DIGEST"를 반드시 지정해야 한다. 지정하지 않으면 KMS가 메시지를 한 번 더 해싱하여 잘못된 서명이 생성된다.
KMS 내부에서 일어나는 ECDSA 서명 알고리즘:
- 서명용 랜덤 nonce $k_{sig}$ 생성 (개인키 $k$와 다른 값, 서명마다 새로 생성)
- $R = k_{sig} \times G$ (커브 위의 점)
- $r = R_x \mod n$ (점 $R$의 x좌표)
- $s = k_{sig}^{-1} \times (txHash + r \times privateKey) \mod n$
$s$ 수식에서 privateKey가 직접 사용된다. 이것이 “이 서명은 개인키 소유자만 만들 수 있다"는 것을 보장한다. 동시에 $s$에서 개인키를 역산하는 것은 이산 로그 문제(ECDLP)로 불가능하다.
Step 2. DER-encoded 서명을 파싱하여 r, s 추출
KMS는 ANS X9.62에 정의된 DER 형식으로 서명을 반환한다:
1
2
3
4
| ECDSA-Sig-Value ::= SEQUENCE {
r INTEGER, -- 점 R의 x좌표
s INTEGER -- k_sig⁻¹ × (txHash + r × privateKey) mod n
}
|
getSignatureFromKms 함수에서 ASN.1 파싱:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| func getSignatureFromKms(
ctx context.Context, svc *kms.Client, keyId string, txHashBytes []byte,
) ([]byte, []byte, error) {
signInput := &kms.SignInput{
KeyId: aws.String(keyId),
SigningAlgorithm: awsKmsSignOperationSigningAlgorithm,
MessageType: awsKmsSignOperationMessageType,
Message: txHashBytes,
}
signOutput, err := svc.Sign(ctx, signInput)
if err != nil {
return nil, nil, err
}
// DER-encoded 서명을 ASN.1 파싱하여 r, s 정수값 추출
var sigAsn1 asn1EcSig
_, err = asn1.Unmarshal(signOutput.Signature, &sigAsn1)
if err != nil {
return nil, nil, err
}
return sigAsn1.R.Bytes, sigAsn1.S.Bytes, nil
}
|
Step 3. s 값 조정 — EIP-2와 “The Dark Side of the Curve”
타원곡선이 x축 대칭이므로, ECDSA에서는 $(r, s)$와 $(r, n-s)$ 둘 다 유효한 서명이다. 이는 트랜잭션 가변성(transaction malleability) 문제를 일으킨다 — 누군가 서명된 트랜잭션의 $s$를 $n-s$로 바꿔도 여전히 유효한 서명이 되어, 트랜잭션 해시가 바뀌고 추적이 어려워진다.
EIP-2는 이를 해결하기 위해 $s > secp256k1n/2$ 인 서명을 거부한다:
1
2
3
4
5
6
7
8
9
10
11
| // secp256k1 커브의 order (n)
var secp256k1N = crypto.S256().Params().N
// EIP-2 기준값: n / 2
var secp256k1HalfN = new(big.Int).Div(secp256k1N, big.NewInt(2))
// s > n/2 이면 "dark side"에 있는 s를 "bright side"로 뒤집기
sBigInt := new(big.Int).SetBytes(sBytes)
if sBigInt.Cmp(secp256k1HalfN) > 0 {
sBytes = new(big.Int).Sub(secp256k1N, sBigInt).Bytes()
}
|
“Dark side"라는 표현은 Lucas Henning의 글에서 유래한 것이다. EIP-2에서는 공식적으로 “non-canonical s value"라고 부른다.
모든 유효한 ECDSA 서명이 유효한 Ethereum 서명은 아니다!
go-ethereum 내부에서 실제로 체크하는 코드:
1
2
3
4
5
6
7
8
| // github.com/ethereum/go-ethereum/crypto/crypto.go
func ValidateSignatureValues(v byte, r, s *big.Int, homestead bool) bool {
// ...
if homestead && s.Cmp(secp256k1halfN) > 0 {
return false // ← s > N/2 이면 거부!
}
// ...
}
|
트랜잭션을 수신한 노드가 types.Sender() → recoverPlain() → ValidateSignatureValues()를 호출하여, $s > n/2$인 서명은 ErrInvalidSig로 거부한다.
Step 4. 올바른 v 값 찾기 (Recovery ID)
ECDSA 서명과 Ethereum 서명의 핵심 차이:
| 항목 | ECDSA 표준 (RFC 3279) | Ethereum |
|---|
| 형식 | DER-encoded (r, s) | raw 65 bytes: [r:32][s:32][v:1] |
| r | 점 R의 x좌표 | 동일 |
| s | 개인키·해시·r을 엮은 값, 0 < s < n 허용 | 동일하지만 s ≤ n/2 제약 (EIP-2) |
| v | 없음 | recovery id (0 또는 1) |
| 용도 | 서명 유효성 검증 | 서명 + 공개키 복구 (발신자 주소 도출) |
v가 필요한 이유: 타원곡선 $y^2 = x^3 + 7$에서 하나의 x값에 대해 y가 두 개 존재한다 ($y_1$, $y_2 = p - y_1$). 서명의 $r$은 점 $R$의 x좌표인데, 같은 x에 대해 $R = (x, y_1)$ 또는 $R = (x, y_2)$ 두 점이 가능하다.
1
2
| v=0 → R = (x, y₁) 사용 → 공개키 A 복구
v=1 → R = (x, y₂) 사용 → 공개키 B 복구
|
$v$는 “$y_1$을 썼냐, $y_2$를 썼냐"를 알려주는 1비트 힌트다.
AWS KMS는 $v$를 반환하지 않으므로, 두 값을 모두 시도해서 원본 공개키와 일치하는 쪽을 찾는다. getEthereumSignature 함수가 바로 이 역할을 한다:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| func getEthereumSignature(expectedPublicKeyBytes []byte, txHash []byte, r []byte, s []byte) ([]byte, error) {
rsSignature := append(adjustSignatureLength(r), adjustSignatureLength(s)...)
// v=0 시도: R = (x, y₁) 가정
signature := append(rsSignature, []byte{0}...)
recoveredPublicKeyBytes, err := crypto.Ecrecover(txHash, signature)
if err != nil {
return nil, err
}
// 복구된 공개키와 원본 공개키 비교
if hex.EncodeToString(recoveredPublicKeyBytes) != hex.EncodeToString(expectedPublicKeyBytes) {
// v=1 시도: R = (x, y₂) 가정
signature = append(rsSignature, []byte{1}...)
recoveredPublicKeyBytes, err = crypto.Ecrecover(txHash, signature)
if err != nil {
return nil, err
}
if hex.EncodeToString(recoveredPublicKeyBytes) != hex.EncodeToString(expectedPublicKeyBytes) {
return nil, errors.New("can not reconstruct public key from sig")
}
}
return signature, nil // 65 bytes: [r:32][s:32][v:1]
}
|
expectedPublicKeyBytes가 바로 GetPubKey로 가져온 공개키다. 비교 대상 없이는 v=0이 맞는지 v=1이 맞는지 판단할 방법이 없다.
$v$는 고정값이 아니다. 같은 키라도 서명할 때마다 내부 nonce($k_{sig}$)가 달라지므로 v=0일 수도, v=1일 수도 있다.
Ethereum 트랜잭션에 from은 없다
이 사실이 GetPubKey가 필요한 근본적 이유와 연결된다.
RLP 인코딩에 from이 없는 이유
EIP-1559 (Type 2) 트랜잭션의 RLP 필드:
| # | 필드 | 설명 |
|---|
| 1 | chainId | 체인 ID |
| 2 | nonce | 발신자의 트랜잭션 순번 |
| 3 | maxPriorityFeePerGas | 팁 상한 |
| 4 | maxFeePerGas | 가스 가격 상한 |
| 5 | gasLimit | 최대 가스 사용량 |
| 6 | to | 수신자 주소 |
| 7 | value | 전송 금액 |
| 8 | data | 호출 데이터 |
| 9 | accessList | 접근 목록 |
| 10 | v | ECDSA recovery id |
| 11 | r | ECDSA 서명 r |
| 12 | s | ECDSA 서명 s |
어떤 트랜잭션 타입이든 from 필드는 없다.
go-ethereum 코어 구조체
1
2
3
4
5
6
7
8
9
| type Transaction struct {
inner TxData // ← 합의(consensus) 데이터 (RLP로 인코딩되는 부분)
time time.Time
// caches (메모리에만 존재, 저장/전파 안 됨)
hash atomic.Pointer[common.Hash]
size atomic.Uint64
from atomic.Pointer[sigCache] // ← 캐시! 블록에 저장되지 않음
}
|
from은 inner TxData(합의 데이터)가 아니라 caches 섹션에 있다. sigCache라는 이름 그대로, ecrecover 결과를 메모리에 캐싱해두는 필드일 뿐이다.
JSON-RPC 응답에 from이 보이는 이유
eth_getTransactionByHash나 eth_getTransactionReceipt 응답에 from이 포함되어 있지만, 이것은 노드가 응답을 만들 때 계산해서 넣어주는 것이다:
1
2
3
4
5
| 사용자 → eth_getTransactionByHash 요청
→ 노드가 DB에서 tx 조회 (from 없음)
→ 노드가 tx의 (v, r, s)에서 Sender() 호출 → from 계산
→ JSON 응답에 from 필드 추가
→ 사용자에게 반환
|
JSON-RPC 스펙의 from은 “API 응답에 이 필드를 포함해라"라는 인터페이스 규약이지, “이 데이터가 블록체인에 저장되어 있다"는 뜻이 아니다.
발신자 주소 복구 과정 (ecrecover)
1
2
3
4
5
6
7
| 트랜잭션 수신
→ types.Sender(signer, tx) 호출
→ recoverPlain(sighash, R, S, V)
→ crypto.Ecrecover(hash, sig) // sig = r || s || v, 65 bytes
→ secp256k1.RecoverPubkey(hash, sig) // v로 두 후보 중 하나 선택
→ 공개키 (65 bytes, 0x04 || X || Y)
→ keccak256(공개키[1:])[12:] = from 주소
|
go-ethereum 코어 레벨에서 $v$가 실제로 사용되며, $v$ 없이는 발신자를 알 수 없다.
전체 흐름: NewAwsKmsTransactorWithChainIDCtx
지금까지 설명한 모든 과정이 하나의 함수 NewAwsKmsTransactorWithChainIDCtx에 통합되어 있다:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
| func NewAwsKmsTransactorWithChainIDCtx(
ctx context.Context, svc *kms.Client, keyId string, chainID *big.Int,
) (*bind.TransactOpts, error) {
// [Step 1] KMS에서 공개키 가져오기 (kms:GetPublicKey 권한 필요)
pubkey, err := GetPubKeyCtx(ctx, svc, keyId)
if err != nil {
return nil, err
}
// [Step 2] 공개키 → uncompressed 포맷 (65 bytes)
pubKeyBytes := secp256k1.S256().Marshal(pubkey.X, pubkey.Y)
// [Step 3] 공개키 → Ethereum 주소 도출
keyAddr := crypto.PubkeyToAddress(*pubkey)
if chainID == nil {
return nil, bind.ErrNoChainID
}
signer := types.LatestSignerForChainID(chainID)
// [Step 4] 트랜잭션 서명 함수 (signerFn)
signerFn := func(address common.Address, tx *types.Transaction) (*types.Transaction, error) {
if address != keyAddr {
return nil, bind.ErrNotAuthorized
}
// (a) 트랜잭션 해시
txHashBytes := signer.Hash(tx).Bytes()
// (b) KMS 서명 → (r, s) 추출
rBytes, sBytes, err := getSignatureFromKms(ctx, svc, keyId, txHashBytes)
if err != nil {
return nil, err
}
// (c) EIP-2: s > n/2 이면 뒤집기
sBigInt := new(big.Int).SetBytes(sBytes)
if sBigInt.Cmp(secp256k1HalfN) > 0 {
sBytes = new(big.Int).Sub(secp256k1N, sBigInt).Bytes()
}
// (d) v 값 결정 + 최종 서명 조립
signature, err := getEthereumSignature(pubKeyBytes, txHashBytes, rBytes, sBytes)
if err != nil {
return nil, err
}
// (e) 서명을 트랜잭션에 부착
return tx.WithSignature(signer, signature)
}
return &bind.TransactOpts{
From: keyAddr,
Signer: signerFn,
}, nil
}
|
공개키는 GetPubKeyCtx 내부에서 keyId 기준으로 캐싱되므로, 이후 서명 시에는 KMS API를 재호출하지 않는다:
1
2
3
4
| type pubKeyCache struct {
pubKeys map[string]*ecdsa.PublicKey
mutex sync.RWMutex
}
|
결론: GetPubKey가 필요한 이유
모든 것을 종합하면:
| 단계 | 필요한 권한 | 이유 |
|---|
| 1. Ethereum 주소 결정 | kms:GetPublicKey | 공개키 → keccak256 → 주소 |
| 2. KMS 서명 | kms:Sign | (r, s)만 반환, v 없음 |
| 3. v 값 결정 | kms:GetPublicKey (캐시) | v=0, v=1로 ecrecover → 원본 공개키와 비교 |
| 4. 트랜잭션 전송 | - | 노드가 (v, r, s)에서 from 복구하여 검증 |
1
2
3
4
5
6
| KMS 사용 흐름:
GetPubKey → 공개키 → Ethereum 주소 (from)
Sign(txHash) → (r, s) ← v가 없음!
v 결정: ecrecover(txHash, r, s, v=0) == 공개키? → v=0, 아니면 v=1
(v, r, s) 완성 → 트랜잭션 전송
노드: ecrecover(txHash, v, r, s) → from 복구 → 잔액 확인 → 실행
|
만약 GetPubKey가 없다면:
- Ethereum 주소를 알 수 없고
- v 값을 결정할 수 없어
- 유효한 Ethereum 서명을 만들 수 없다
kms:Sign과 kms:GetPublicKey, 두 권한 모두 필수다.
참고 자료