티스토리 뷰
인터페이스를 이해하고 활용하기 위한 글. 암호학에 대해서는 간략하게 다룹니다.
EIP-7212: secp256r1 곡선을 지원하기 위한 프리컴파일
개요
메시지 해시, 서명의 r과 s 그리고 공개키의 x와 y를 매개변수로 받아 'secp256r1' 타원 곡선에서의 서명을 검증하는 프리컴파일 컨트랙트를 생성하기 위한 제안입니다.
프리컴파일 컨트랙트: 이더리움 호환 블록체인에서 특정한 기능을 더 빠르고 효율적으로 수행하기 위해 EVM이 아닌 블록체인 노드 사양에서 사전에 컴파일된 코드로 실행되는 특별한 스마트 컨트랙트입니다. 고정된 주소를 가지고 있으며, 파라미터 크기에 상관없이 고정된 비용을 요구합니다. 대표적인 예로, 'ecrecover', 'sha256', 'ripemd160' 등이 있습니다.
secp256r1과 secp256k1
- secp256r1 타원 곡선은 NIST에 의해 표준화된 곡선이며, secp256k1과 유사한 보안 수준을 가지고 있지만, 입력 매개변수가 달라서 서명 검증 과정이 서로 호환되지 않습니다.
- secp256k1 타원 곡선은 이더리움에서 ecrecover 프리컴파일 컨트랙트를 통해 저렴한 가스 비용(3,000 가스)으로 서명 검증을 수행할 수 있습니다.
- 반면, secp256r1은 별도의 스마트 컨트랙트 구현이 필요하며, 그로 인해 더 많은 가스 비용이 요구됩니다.
파라미터 비교
파라미터 | secp256r1 | secp256k1 |
곡선 공식 | y² ≡ x³ + ax + b | |
유한체의 위수 (p) | 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffff | 0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f |
곡선 계수 (a) | 0xffffffff00000001000000000000000000000000fffffffffffffffffffffffc | 0x0 |
곡선 계수 (b) | 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b | 0x7 |
생성점 (G) | (0x6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296, 0x4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5) | (0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798, 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8) |
유한순환군의 위수 (n) | 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551 | 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141 |
코팩터 (h) | 0x1 | 0x1 |
- 곡선 공식 (Curve Equation): 타원 곡선은 수학적으로 y² ≡ x³ + ax + b 형태로 표현됩니다. 이 공식은 곡선의 모양을 정의하며, 암호화 알고리즘에서 사용됩니다.
- 유한체의 위수 (Prime Field Modulus, p): 곡선이 정의된 유한체(프라임 필드)의 크기입니다. p는 타원 곡선에서 사용되는 모든 x와 y 좌표가 정수로 표현되는 범위를 제한합니다.
- 곡선 계수 (Curve Coefficients, a와 b): 타원 곡선의 수학적 특성을 정의하는 두 상수입니다. a와 b는 곡선의 기울기와 모양을 결정하는 중요한 요소로, 각 타원 곡선마다 고유한 값을 가집니다.
- 생성점 (Base Point, G): 타원 곡선 상의 특정한 점으로, 암호화 작업의 출발점으로 사용됩니다. G는 일반적으로 타원 곡선 위에서 서명 생성과 검증의 기반이 되는 점입니다.
- 유한순환군의 위수 (Order of the Subgroup, n): 생성점 G를 반복해서 더했을 때, 그 결과가 다시 처음으로 돌아오는 횟수입니다. n은 서명 알고리즘에서 중요한 역할을 하며, 곡선의 보안성을 결정하는 주요 요소입니다.
- 코팩터 (Cofactor, h): 타원 곡선의 전체 점의 개수를 서브그룹의 위수(n)로 나눈 값입니다. 코팩터는 서브그룹이 곡선 전체에 얼마나 퍼져 있는지를 나타내며, 일반적으로 1이나 2로 설정되어 보안성을 높입니다.
왜 secp256r1과 secp256k1의 파라미터가 서명 검증에 영향을 미치는가?
간단한 숫자 `x = 50`을 생각해 봅시다. `secp256r1`과 `secp256k1`이 서로 다른 소수 체계를 사용하는 것처럼, 우리가 이 숫자를 다른 소수로 나누는 나머지 연산을 수행한다고 가정해 봅시다:
소수 모듈러스 `p1 = 7`을 사용하면, `x mod p1`의 결과는 다음과 같습니다:
50 mod 7 = 1
다른 소수 모듈러스 `p2 = 11`을 사용하면, `x mod p2`의 결과는 다음과 같습니다:
50 mod 11 = 6
이와 같이, 동일한 숫자 `x`도 사용된 소수 모듈러스에 따라 서로 다른 결과를 낼 수 있습니다. 타원 곡선 암호화에서 이러한 모듈러스 차이는 곡선에서 수행되는 수학적 연산의 결과에 직접적인 영향을 미치며, 이는 서명 검증 과정에 차이를 불러옵니다.
`secp256r1`과 `secp256k1`의 경우, 서로 다른 소수 모듈러스, 곡선 계수, 그리고 생성점(Base Point)이 서명 및 검증 과정에서 각각 고유한 수학적 연산을 수행하게 만듭니다. 따라서 `secp256r1` 파라미터를 사용하여 생성된 서명은 `secp256k1` 파라미터를 사용하여 직접적으로 검증할 수 없으며, 그 반대도 마찬가지입니다.
secp256r1의 중요성
- secp256r1은 Apple의 Secure Enclave, WebAuthn, Android Keystore 등의 현대 장치에서 범용적으로 사용되는 타원 곡선입니다.
- 이 기능들은 비밀 키를 물리적으로 분리된 안전한 환경에서 보호하며, 생체 인증(Face ID, 지문 인식)과 같은 추가 보안 요소를 통해 강화된 보안을 제공합니다.
- 따라서, 이러한 장치를 활용한 비밀 키 관리가 일반적인 지갑 소프트웨어보다 더 안전합니다.
계정 추상화와 결합
- 계정 추상화(Account Abstraction)에서의 스마트 컨트랙트 계정(SCA)은 프로그래밍이 가능하므로, EOA(Externally Owned Account)와 달리 단일 서명 방식에 얽매이지 않고 다양한 서명 알고리즘을 활용할 수 있습니다.
- 이로 인해 사용자는 자신이 사용하는 전자기기의 보안 기능을 활용하여 스마트 컨트랙트 계정의 트랜잭션에 더욱 간편하고 안전하게 서명할 수 있습니다.
- 예를 들어, 스마트폰에서 dApp을 사용하기 위해 별도의 지갑 앱을 설치할 필요 없이, 생체 인식을 통해 바로 스마트 컨트랙트 계정을 생성하고 트랜잭션에 서명할 수 있습니다. 이를 통해 사용자는 이더리움 호환 네트워크를 보다 간편하고 안전하게 활용할 수 있게 됩니다.
결론
- secp256r1은 다양한 현대 장치에서 널리 사용되는 타원 곡선으로, 이더리움 생태계에 도입될 경우 더 안전하고 효율적인 서명 검증을 가능하게 할 수 있습니다.
- 프리컴파일(precompiled) 컨트랙트의 도입을 통해 secp256r1 기반 서명 검증의 가스 비용(3,450 가스)을 줄이고, 더 넓은 생태계와의 호환성을 높일 수 있습니다.
- 이러한 변화는 사용자가 일상적으로 사용하는 스마트폰과 같은 장치의 보안 기능을 활용하여, 이더리움 트랜잭션을 보다 안전하고 편리하게 서명할 수 있게 해 줄 것입니다. 이를 통해 이더리움의 접근성과 사용성이 크게 향상되며, 더 많은 사용자가 안심하고 이더리움 생태계를 이용할 수 있게 될 것입니다.
왜 사토시 나카모토와 비탈릭 부테린은 secp256r1 대신 secp256k1을 선택했을까?
1. 비트코인
- 비트코인의 철학: 사토시 나카모토는 비트코인을 설계할 때, 정부나 기관의 개입에서 독립적인 시스템을 만들고자 했습니다. secp256r1은 미국 정부 기관인 NIST에 의해 표준화된 곡선인데, 이는 일부 커뮤니티에서 신뢰성에 대한 의구심을 불러일으킬 수 있었습니다. 그 당시에는 NIST 표준에 대해 백도어(Backdoor) 가능성에 대한 우려가 있었고, 사토시는 이러한 우려를 피하고자 NIST가 아닌 다른 표준을 사용하고자 했습니다.
- 빠른 계산과 최적화: secp256k1은 특정 암호화 작업(예: 타원 곡선 디지털 서명 알고리즘, ECDSA)에 대해 더 빠르고 효율적인 계산을 제공하는 것으로 알려져 있습니다. 이는 비트코인 네트워크에서 중요한 성능 요소였습니다. 따라서, 효율성을 중시한 선택이었을 가능성이 큽니다.
2. 이더리움
- 비트코인과의 호환성: 비탈릭 부테린은 이더리움을 설계할 때, 비트코인과의 일정한 호환성을 유지하고자 했습니다. 이는 이더리움이 비트코인에서 배운 점들을 그대로 활용하면서도, 더 많은 기능성을 제공하기 위함이었습니다. 따라서, 비트코인에서 이미 검증된 secp256k1 곡선을 그대로 사용하여 호환성과 신뢰성을 확보할 수 있었습니다.
- 커뮤니티의 신뢰: 비트코인에서 secp256k1이 사용되면서, 암호화폐 커뮤니티 내에서 이 곡선의 안전성과 신뢰성이 이미 확립되었습니다. 비탈릭은 이를 바탕으로 secp256k1을 선택하여 커뮤니티의 신뢰를 유지하고, 이더리움의 초기 수용을 촉진할 수 있었습니다.
ZKsync에서의 EIP-7212 구현
EIP-7212는 RIP(Rollup Improvement Proposal)-7212라는 명칭으로 이더리움의 롤업 생태계를 개선하기 위한 제안으로 새 단장을 했습니다. 그리고 여러 롤업에서 실제로 구현이 되었는데, 그중에서도 ZKsync에서의 구현을 살펴보도록 하겠습니다.
ZKsync에서 secp256r1 곡선을 지원하는 프리컴파일은 지난 6월 초에 v24 업그레이드를 통해 도입되었습니다. 먼저 프리컴파일 컨트랙트의 코드를 살펴보고 테스트 환경을 구성하여 프리컴파일 컨트랙트와 어떻게 상호작용할 수 있는지 살펴보겠습니다.
P256Verify
프리컴파일 컨트랙트의 이름은 'P256Verify'로, 'P256'은 'secp256r1' 곡선의 표준화된 이름입니다. 컨트랙트 코드는 아래와 같습니다. Solidity가 아닌 저수준 언어 Yul로 작성되어 있습니다.
/**
* @author Matter Labs
* @custom:security-contact security@matterlabs.dev
* @notice The contract that emulates RIP-7212's P256VERIFY precompile.
* @dev It uses `precompileCall` to call the zkEVM built-in precompiles.
*/
object "P256Verify" {
code {
return(0, 0)
}
object "P256Verify_deployed" {
code {
////////////////////////////////////////////////////////////////
// CONSTANTS
////////////////////////////////////////////////////////////////
/// @dev The gas cost of processing V circuit precompile.
function P256_VERIFY_GAS_COST() -> ret {
ret := 12000
}
////////////////////////////////////////////////////////////////
// HELPER FUNCTIONS
////////////////////////////////////////////////////////////////
/// @dev Packs precompile parameters into one word.
/// Note: functions expect to work with 32/64 bits unsigned integers.
/// Caller should ensure the type matching before!
function unsafePackPrecompileParams(
uint32_inputOffsetInWords,
uint32_inputLengthInWords,
uint32_outputOffsetInWords,
uint32_outputLengthInWords,
uint64_perPrecompileInterpreted
) -> rawParams {
rawParams := uint32_inputOffsetInWords
rawParams := or(rawParams, shl(32, uint32_inputLengthInWords))
rawParams := or(rawParams, shl(64, uint32_outputOffsetInWords))
rawParams := or(rawParams, shl(96, uint32_outputLengthInWords))
rawParams := or(rawParams, shl(192, uint64_perPrecompileInterpreted))
}
/// @dev Executes the `precompileCall` opcode.
function precompileCall(precompileParams, gasToBurn) -> ret {
// Compiler simulation for calling `precompileCall` opcode
ret := verbatim_2i_1o("precompile", precompileParams, gasToBurn)
}
////////////////////////////////////////////////////////////////
// FALLBACK
////////////////////////////////////////////////////////////////
if iszero(eq(calldatasize(), 160)) {
return(0, 0)
}
// Copy first 5 32-bytes words (the signed digest, r, s, x, y) from the calldata
// to memory, from where secp256r1 circuit will read it.
// The validity of the input as it is done in the internal precompile implementation.
calldatacopy(0, 0, 160)
let precompileParams := unsafePackPrecompileParams(
0, // input offset in words
5, // input length in words (the signed digest, r, s, x, y)
0, // output offset in words
2, // output length in words (internalSuccess, isValid)
0 // No special meaning, secp256r1 circuit doesn't check this value
)
let gasToPay := P256_VERIFY_GAS_COST()
// Check whether the call is successfully handled by the secp256r1 circuit
let success := precompileCall(precompileParams, gasToPay)
let internalSuccess := mload(0)
switch and(success, internalSuccess)
case 0 {
return(0, 0)
}
default {
// The circuits might write `0` to the memory, while providing `internalSuccess` as `1`, so
// we double check here.
let isValid := mload(32)
if eq(isValid, 0) {
return(0, 0)
}
return(32, 32)
}
}
}
}
1. 서명을 검증하기 위해 특정 함수를 호출하지 않고, fallback을 통해 로직을 실행합니다. 이때 calldata의 길이는 반드시 160이어야 합니다. 그 이유는 calldata가 다음과 같이 구성되어야 하기 때문입니다.
- 다이제스트(서명된 메시지) 32바이트
- 서명의 r값 32바이트
- 서명의 s값 32바이트
- 서명자 공개키의 x값 32바이트
- 서명자 공개키의 y값 32바이트
이렇게 총길이가 160인 바이트열이 calldata로 들어와야만 다음 로직을 수행하고, 그렇지 않은 경우는 0(길이가 0인 바이트열)을 반환합니다.
////////////////////////////////////////////////////////////////
// FALLBACK
////////////////////////////////////////////////////////////////
if iszero(eq(calldatasize(), 160)) {
return(0, 0)
}
// Copy first 5 32-bytes words (the signed digest, r, s, x, y) from the calldata
// to memory, from where secp256r1 circuit will read it.
// The validity of the input as it is done in the internal precompile implementation.
calldatacopy(0, 0, 160)
2. 프리컴파일 호출을 위한 매개변수를 구성하고 사용할 가스의 양을 불러옵니다.
let precompileParams := unsafePackPrecompileParams(
0, // input offset in words
5, // input length in words (the signed digest, r, s, x, y)
0, // output offset in words
2, // output length in words (internalSuccess, isValid)
0 // No special meaning, secp256r1 circuit doesn't check this value
)
let gasToPay := P256_VERIFY_GAS_COST()
이때, 프리컴파일 호출에 필요한 가스의 양은 12,000으로, EIP-7212 표준에서 제안한 3,450 가스와 비교해 3배 이상의 가스가 필요합니다.
/// @dev The gas cost of processing V circuit precompile.
function P256_VERIFY_GAS_COST() -> ret {
ret := 12000
}
3. 프리컴파일 호출이 실패하거나 서명 검증이 실패한 경우, 0을 반환합니다. 서명 검증이 성공했지만, 서명이 유효하지 않을 수 있으므로 이를 이중으로 체크하여 서명 검증이 유효한 경우에만 두 번째 반환값인 isValid를 반환합니다. 이 경우 isValid는 1이며, 반환되는 값은 1을 byte32 타입으로 변환한 '0x0000000000000000000000000000000000000000000000000000000000000001'입니다.
// Check whether the call is successfully handled by the secp256r1 circuit
let success := precompileCall(precompileParams, gasToPay)
let internalSuccess := mload(0)
switch and(success, internalSuccess)
case 0 {
return(0, 0)
}
default {
// The circuits might write `0` to the memory, while providing `internalSuccess` as `1`, so
// we double check here.
let isValid := mload(32)
if eq(isValid, 0) {
return(0, 0)
}
return(32, 32)
}
정리
- P256Verify 컨트랙트는 fallback을 통해 호출합니다. 별도의 함수 선택자는 필요하지 않습니다.
- 이때, calldata는 다이제스트 + r + s + x + y로 구성되며, 그 길이는 반드시 160이어야 합니다.
- 서명이 유효하면 1이 반환되고, 유효하지 않다면 0이 반환됩니다.
테스트 환경 구성
1. ZKsync 프로젝트를 초기화하고 테스트 노드를 통해 시뮬레이션을 실행하기 위해서는 zksync-cli가 필요합니다. 다음 명령어를 실행하여 zksync-cli를 설치합니다. 만약 node가 설치되어 있지 않다면, 먼저 설치하셔야 합니다.
$ npm i -g zksync-cli
2. 다음 명령어를 실행하여 zksync라는 이름의 디렉터리에 하드햇 프로젝트를 생성합니다. 비밀키를 설정하고 패키지 매니저를 선택합니다. 패키지 매니저의 경우, 개인 선호에 따라 선택하시면 됩니다. 다만 이어지는 내용에서 기본적으로 yarn을 사용한다는 것을 참고해 주세요.
$ npx zksync-cli create --project contracts --template hardhat_solidity zksync
Using Hardhat + Solidity template
? Private key of the wallet responsible for deploying contracts (optional) ****************************************************************
? Package manager yarn
Setting up template in /.../zksync...
✔ Cloned template
✔ Environment variables set up
✔ Dependencies installed
🎉 All set up! 🎉
--------------------------
Navigate to your project: cd zksync
Directory Overview:
- Contracts: /contracts
- Deployment Scripts: /deploy
Commands:
- Compile your contracts: yarn compile
- Deploy your contract: yarn deploy
- Tip: You can use the --network option to specify the network to deploy to.
Further Reading:
- Check out the README file in the project location for more details: zksync/README.md
--------------------------
3. 생성된 디렉터리로 이동합니다.
$ cd zksync
4. eliptic 패키지를 설치합니다. eliptic은 타원 곡선 암호를 지원하는 패키지로, secp256r1 곡선 상에서 비밀키를 생성하기 위해 필요합니다.
yarn add -D elliptic @types/elliptic
5. test 디렉터리에 'P256Verify.test.ts' 파일을 생성하고 아래의 내용을 복사하여 붙여 넣습니다.
import { expect } from "chai";
import { getWallet, LOCAL_RICH_WALLETS } from "../deploy/utils";
import { ec } from "elliptic";
import { randomBytes } from "crypto";
import { keccak256, ZeroHash } from "ethers";
import { Wallet } from "zksync-ethers";
const P256VERIFY_CONTRACT_ADDRESS =
"0x0000000000000000000000000000000000000100";
const ONE =
"0x0000000000000000000000000000000000000000000000000000000000000001";
const P256_GROUP_ORDER =
"0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551";
const padLeft = (value: string, length: number) => {
return value.padStart(length, "0");
};
describe("P256Verify", function () {
let wallet: Wallet;
let correctDigest: string;
let correctX: string;
let correctY: string;
let correctR: string;
let correctS: string;
function compileSignature(options: {
digest?: string;
x?: string;
y?: string;
r?: string;
s?: string;
}) {
const {
digest: providedDigest,
x: providedX,
y: providedY,
r: providedR,
s: providedS,
} = options;
const digest = providedDigest || correctDigest;
const x = providedX || correctX;
const y = providedY || correctY;
const r = providedR || correctR;
const s = providedS || correctS;
// Concatenate the digest, r, s, x and y.
// Note that for r,s,x,y we need to remove the 0x prefix
return digest + r.slice(2) + s.slice(2) + x.slice(2) + y.slice(2);
}
before(async function () {
wallet = getWallet(LOCAL_RICH_WALLETS[0].privateKey);
const p256 = new ec("p256");
const keyPair = p256.genKeyPair();
const message = randomBytes(128);
correctDigest = keccak256(message);
const signature = keyPair.sign(correctDigest.slice(2));
correctR = "0x" + padLeft(signature.r.toString(16), 64);
correctS = "0x" + padLeft(signature.s.toString(16), 64);
const publicKey = keyPair.getPublic();
correctX = "0x" + padLeft(publicKey.getX().toString(16), 64);
correctY = "0x" + padLeft(publicKey.getY().toString(16), 64);
});
it("Should verify the valid signature", async function () {
const signatureHex = compileSignature({});
const result = await wallet.call({
to: P256VERIFY_CONTRACT_ADDRESS,
data: signatureHex,
});
expect(result).to.equal(ONE);
});
it("Should reject the invalid signature", async function () {
for (const param of ["r", "s"]) {
const result = await wallet.call({
to: P256VERIFY_CONTRACT_ADDRESS,
data: compileSignature({ [param]: ZeroHash }),
});
expect(result).to.equal("0x");
}
});
it("Should reject when the calldata is too short", async () => {
const result = await wallet.call({
to: P256VERIFY_CONTRACT_ADDRESS,
data: "0xdeadc0de",
});
expect(result).to.equal("0x");
});
it("Should reject when calldata is longer than 160 bytes", async () => {
const result = await wallet.call({
to: P256VERIFY_CONTRACT_ADDRESS,
// The signature is valid and yet the input is too long
data: compileSignature({}) + "00",
});
expect(result).to.eq("0x");
});
it("Should reject large s/r, which are not in the group", async () => {
for (const param of ["r", "s"]) {
const result = await wallet.call({
to: P256VERIFY_CONTRACT_ADDRESS,
data: compileSignature({ [param]: P256_GROUP_ORDER }),
});
expect(result).to.eq("0x");
}
});
it("Signature malleability is permitted", async () => {
const newS = BigInt(P256_GROUP_ORDER) - BigInt(correctS);
const newSHex = "0x" + padLeft(newS.toString(16), 64);
const result = await wallet.call({
to: P256VERIFY_CONTRACT_ADDRESS,
data: compileSignature({ s: newSHex }),
});
expect(result).to.eq(ONE);
});
it("secp256k1 signature should be rejected", async () => {
const k1 = new ec("secp256k1");
const keyPair = k1.genKeyPair();
const message = randomBytes(128);
const digest = keccak256(message);
const signature = keyPair.sign(digest.slice(2));
const r = "0x" + padLeft(signature.r.toString(16), 64);
const s = "0x" + padLeft(signature.s.toString(16), 64);
const publicKey = keyPair.getPublic();
const x = "0x" + padLeft(publicKey.getX().toString(16), 64);
const y = "0x" + padLeft(publicKey.getY().toString(16), 64);
const signatureHex = compileSignature({ digest, x, y, r, s });
const result = await wallet.call({
to: P256VERIFY_CONTRACT_ADDRESS,
data: signatureHex,
});
expect(result).to.equal("0x");
});
});
테스트 코드 살펴보기
상수
const P256VERIFY_CONTRACT_ADDRESS =
"0x0000000000000000000000000000000000000100";
const ONE =
"0x0000000000000000000000000000000000000000000000000000000000000001";
const P256_GROUP_ORDER =
"0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551";
- P256VERIFY_CONTRACT_ADDRESS: ZKsync 네트워크에 배포된 프리컴파일 컨트랙트의 주소
- ONE: 서명이 유효한 경우 반환되는 값
- P256_GROUP_ORDER: secp256r1 곡선의 위수
테스트 초기화
let wallet: Wallet;
let correctDigest: string;
let correctX: string;
let correctY: string;
let correctR: string;
let correctS: string;
before(async function () {
wallet = getWallet(LOCAL_RICH_WALLETS[0].privateKey);
const p256 = new ec("p256");
const keyPair = p256.genKeyPair();
const message = randomBytes(128);
correctDigest = keccak256(message);
const signature = keyPair.sign(correctDigest.slice(2));
correctR = "0x" + padLeft(signature.r.toString(16), 64);
correctS = "0x" + padLeft(signature.s.toString(16), 64);
const publicKey = keyPair.getPublic();
correctX = "0x" + padLeft(publicKey.getX().toString(16), 64);
correctY = "0x" + padLeft(publicKey.getY().toString(16), 64);
});
1. 프리컴파일 컨트랙트를 호출하기 위해 필요한 지갑을 초기화합니다. 다음으로 p256 타원 곡선을 선택하고 키페어를 생성합니다.
wallet = getWallet(LOCAL_RICH_WALLETS[0].privateKey);
const p256 = new ec("p256");
const keyPair = p256.genKeyPair();
2. 길이가 128인 랜덤한 메시지를 생성합니다. 메시지를 keccack256 해시 함수의 입력으로 사용하여 다이제스트를 생성합니다.
const message = randomBytes(128);
correctDigest = keccak256(message);
3. 앞서 생성한 p256 키페어를 사용해 다이제스트에 대한 서명을 생성합니다. 서명의 r과 s를 각각 correctR과 correctS에 저장합니다.
const signature = keyPair.sign(correctDigest.slice(2));
correctR = "0x" + padLeft(signature.r.toString(16), 64);
correctS = "0x" + padLeft(signature.s.toString(16), 64);
4. 키페어로부터 공개키를 가져옵니다. 공개키의 x, y값을 각각 correctX와 correctY에 저장합니다.
const publicKey = keyPair.getPublic();
correctX = "0x" + padLeft(publicKey.getX().toString(16), 64);
correctY = "0x" + padLeft(publicKey.getY().toString(16), 64);
도우미 함수
paddLeft 함수는 r, s, x, y를 16진수 문자열로 변환할 때 최상위 바이트가 0이 되는 경우에 올바른 형식을 맞추기 위해 16진수 문자열의 왼쪽에 패딩을 추가하는 함수입니다.
const padLeft = (value: string, length: number) => {
return value.padStart(length, "0");
};
compileSignature 함수는 프리컴파일 컨트랙트를 호출하기 위한 calldata를 구성하는 함수입니다. 테스트를 초기화할 때 저장한 digest, r, s, x, y 순서로 길이가 160바이트인 유효한 calldata를 생성하여 반환합니다. 매개변수를 통해 선택적으로 값을 받아 올바르지 않은 calldata를 구성할 수도 있습니다.
function compileSignature(options: {
digest?: string;
x?: string;
y?: string;
r?: string;
s?: string;
}) {
const {
digest: providedDigest,
x: providedX,
y: providedY,
r: providedR,
s: providedS,
} = options;
const digest = providedDigest || correctDigest;
const x = providedX || correctX;
const y = providedY || correctY;
const r = providedR || correctR;
const s = providedS || correctS;
// Concatenate the digest, r, s, x and y.
// Note that for r,s,x,y we need to remove the 0x prefix
return digest + r.slice(2) + s.slice(2) + x.slice(2) + y.slice(2);
}
테스트 케이스 1: 유효한 서명
지갑 인스턴스의 call 메서드를 통해 테스트 노드 상에서 트랜잭션을 시뮬레이션합니다. 프리컴파일 컨트랙트의 서명 검증 로직은 fallback을 통해 실행되므로, 길이가 160인 calldata만을 전달하여 실행할 수 있습니다. 유효한 calldata를 구성하여 프리컴파일 컨트랙트를 호출하면 'ONE'이 반환됩니다.
it("Should verify the valid signature", async function () {
const signatureHex = compileSignature({});
const result = await wallet.call({
to: P256VERIFY_CONTRACT_ADDRESS,
data: signatureHex,
});
expect(result).to.equal(ONE);
});
테스트 케이스 2: 유효하지 않은 서명
r 또는 s가 0인 경우, 서명이 유효하지 않으므로 길이가 0인 바이트열이 반환됩니다.
it("Should reject the invalid signature", async function () {
for (const param of ["r", "s"]) {
const result = await wallet.call({
to: P256VERIFY_CONTRACT_ADDRESS,
data: compileSignature({ [param]: ZeroHash }),
});
expect(result).to.equal("0x");
}
});
테스트 케이스 3: calldata의 길이가 160 미만
길이가 160 미만인 calldata를 사용해 프리컴파일 컨트랙트를 호출하면 길이가 0인 바이트열이 반환됩니다.
it("Should reject when the calldata is too short", async () => {
const result = await wallet.call({
to: P256VERIFY_CONTRACT_ADDRESS,
data: "0xdeadc0de",
});
expect(result).to.equal("0x");
});
if iszero(eq(calldatasize(), 160)) {
return(0, 0)
}
테스트 케이스 4: calldata의 길이가 160 초과
calldata 길이가 160을 초과하면 길이가 160 미만인 경우와 마찬가지로 길이가 0인 바이트열이 반환됩니다.
it("Should reject when calldata is longer than 160 bytes", async () => {
const result = await wallet.call({
to: P256VERIFY_CONTRACT_ADDRESS,
// The signature is valid and yet the input is too long
data: compileSignature({}) + "00",
});
expect(result).to.eq("0x");
});
테스트 케이스 5: 유한순환군의 위수보다 크거나 같은 값
서명의 r, s는 항상 유한순환군의 위수보다 작은 값이어야 합니다. 따라서 r 또는 s가 유한순환군의 위수와 크거나 같은 경우, 서명은 유효하지 않으며 길이가 0인 바이트열이 반환됩니다.
it("Should reject large s/r, which are not in the group", async () => {
for (const param of ["r", "s"]) {
const result = await wallet.call({
to: P256VERIFY_CONTRACT_ADDRESS,
data: compileSignature({ [param]: P256_GROUP_ORDER }),
});
expect(result).to.eq("0x");
}
});
테스트 케이스 6: 서명 가변성
유한순환군의 위수 n에서 서명의 s를 뺀 값, newS를 사용해 유효한 서명을 생성할 수 있습니다. 따라서 s 대신 newS를 사용하더라도 서명은 유효하며, 반환되는 값은 'ONE'이 됩니다.
it("Signature malleability is permitted", async () => {
const newS = BigInt(P256_GROUP_ORDER) - BigInt(correctS);
const newSHex = "0x" + padLeft(newS.toString(16), 64);
const result = await wallet.call({
to: P256VERIFY_CONTRACT_ADDRESS,
data: compileSignature({ s: newSHex }),
});
expect(result).to.eq(ONE);
});
서명 가변성: 타원 곡선 디지털 서명 알고리즘(ECDSA)과 같은 암호화 기법에서 발생할 수 있는 현상으로, 동일한 메시지와 동일한 비밀 키를 사용하더라도 여러 개의 유효한 서명이 생성될 수 있는 성질을 의미합니다.
왜 서명 가변성이 발생하는가?
- 서명의 수학적 특성:
- 타원 곡선 서명에서 s 값은 서명을 생성할 때 사용되는 수학적 연산의 결과로 나옵니다. ECDSA에서는 이 s 값이 다음과 같은 방식으로 계산됩니다:
- s = k-1 × (z + r × d) mod n
- 여기서 k는 랜덤 값, z는 메시지 해시, r은 타원 곡선 점의 x 좌표, d는 개인 키, n은 서브그룹의 위수입니다.
- 서명 가변성은 주로 다음과 같은 방법으로 발생합니다: 서명 값 s는 n의 보조값(즉, s' = n - s)을 이용해 동일한 서명 값 r을 가지면서도 다른 s 값을 가지는 서명이 생성될 수 있습니다. 이는 s와 n - s 모두 유효한 서명으로 간주될 수 있기 때문입니다.
- 타원 곡선 서명에서 s 값은 서명을 생성할 때 사용되는 수학적 연산의 결과로 나옵니다. ECDSA에서는 이 s 값이 다음과 같은 방식으로 계산됩니다:
- ECDSA의 설계 특성:
- ECDSA는 기본적으로 s 값이 n보다 작은 범위 내에 있는지를 확인합니다. 그러나 s 값을 n - s로 바꿀 수 있다는 점 때문에 가변성이 생기며, 이것이 ECDSA의 내재적인 특성입니다.
서명 가변성이 보안상 취약점을 초래할 수 있는가?
서명 가변성은 몇 가지 보안상의 문제를 일으킬 수 있습니다:
- 트랜잭션 재생 공격(Transaction Replay Attack):
- 블록체인에서는 특정 서명을 가진 트랜잭션이 한 번만 처리되도록 설계됩니다. 그러나 가변 서명을 이용해 동일한 트랜잭션에 대해 다른 서명을 생성할 수 있다면, 이 트랜잭션을 재생 공격에 사용될 수 있습니다.
- 예를 들어, 악의적인 사용자가 동일한 메시지(또는 트랜잭션)로부터 여러 유효한 서명을 생성해 같은 트랜잭션을 네트워크에 여러 번 제출할 수 있습니다. 이로 인해 트랜잭션이 중복 처리되거나, 네트워크의 무결성이 손상될 수 있습니다.
- 스마트 계약에서의 혼란:
- 서명이 가변적일 경우, 스마트 계약에서 서명의 유효성을 확인하는 과정에서 혼란을 야기할 수 있습니다. 예를 들어, 서명의 고유성을 이용해 어떤 상태를 추적하거나 관리하는 스마트 계약에서, 가변 서명이 다른 서명으로 인식될 수 있습니다.
보안 강화 방법
서명 가변성 문제를 해결하거나 완화하기 위해 몇 가지 방법이 사용될 수 있습니다:
- Canonicalization(정규화):
- 서명 생성 시, 항상 s 값이 서브그룹의 위수 n의 절반 이하로 유지되도록 강제하는 방식입니다. 이렇게 하면 가변 서명의 생성 가능성이 제거됩니다.
- 트랜잭션 해시 강화:
- 블록체인 트랜잭션에서, 트랜잭션의 해시에 서명을 포함하는 방법을 통해 재생 공격을 방지할 수 있습니다. 이 방법은 같은 트랜잭션을 서로 다른 서명으로 제출할 수 없도록 합니다.
테스트 케이스 7: secp256k1 서명 검증
secp256k1 곡선에서 생성된 키페어를 사용해 서명을 생성하고 이를 검증하고자 하는 경우, 서명 검증이 실패하고 길이가 0인 바이트열이 반환됩니다.
it("secp256k1 signature should be rejected", async () => {
const k1 = new ec("secp256k1");
const keyPair = k1.genKeyPair();
const message = randomBytes(128);
const digest = keccak256(message);
const signature = keyPair.sign(digest.slice(2));
const r = "0x" + padLeft(signature.r.toString(16), 64);
const s = "0x" + padLeft(signature.s.toString(16), 64);
const publicKey = keyPair.getPublic();
const x = "0x" + padLeft(publicKey.getX().toString(16), 64);
const y = "0x" + padLeft(publicKey.getY().toString(16), 64);
const signatureHex = compileSignature({ digest, x, y, r, s });
const result = await wallet.call({
to: P256VERIFY_CONTRACT_ADDRESS,
data: signatureHex,
});
expect(result).to.equal("0x");
});
P256Verify 테스트 실행
test 명령어를 실행하면 ZKsync 테스트 노드 상에서 테스트가 실행됩니다.
$ yarn test --grep P256Verify
yarn run v1.22.22
$ hardhat test --network hardhat --grep P256Verify
P256Verify
✔ Should verify the valid signature
✔ Should reject the invalid signature
✔ Should reject when the calldata is too short
✔ Should reject when calldata is longer than 160 bytes
✔ Should reject large s/r, which are not in the group
✔ Signature malleability is permitted
✔ secp256k1 signature should be rejected
7 passing (322ms)
Done in 3.25s.
정리
- ZKsync에서의 secp256r1 지원: ZKsync의 v24 업그레이드를 통해 secp256r1 곡선을 지원하는 프리컴파일 컨트랙트(P256Verify)가 도입되었습니다. 이 컨트랙트는 Yul 언어로 작성되어 있으며, 서명을 검증하는 기능을 제공합니다.
- P256Verify 컨트랙트 작동 방식: P256Verify 컨트랙트는 fallback 함수를 통해 서명 검증을 수행하며, 올바른 서명 데이터를 입력받으면 서명의 유효성을 확인하고 1을 반환합니다. 입력 데이터는 다이제스트, r, s, x, y 값으로 구성되며, 총 160바이트 길이를 가져야 합니다.
- 테스트 시나리오: 테스트는 유효한 서명 검증, 잘못된 서명 거부, 서명 가변성 검증, secp256k1 서명의 거부 등 다양한 케이스를 다룹니다. 각 테스트는 P256Verify 컨트랙트의 정확성과 보안성을 검증하는 데 초점을 맞춥니다.
전체 코드
참고
'블록체인 > ZKsync' 카테고리의 다른 글
[ZKsync] Privy와 ZKsync Sepolia 네트워크 연동하기 (0) | 2024.06.11 |
---|