本地搭建和测试zkLogin零知识证明服务
zkLogin
零知识证明服务 @SUI Move开发必知必会zkLogin
是Sui
的一种原生功能,它允许人们只使用来自如Google
、Meta
等的现有Web2
网络凭证来创建Sui
地址并签名交易。为确保隐私,集成zkLogin
的应用必须创建零知识证明ZKP
(Zero Knowledge proofs
),以使凭证对应用实现隐藏,交易历史对网络服务实现隐藏。
由于生成ZKP
可能需要大量资源,在客户端本地生成可能会较慢,建议使用专用于ZKP
生成的后端服务来进行生成。
本文将在本地搭建zkLogin
证明服务端,并进行合约接口调用测试验证。
在下载 zkey
之前安装 Git Large File Storage
(用于大文件版本控制的开源 Git 扩展)
apt/deb: sudo apt-get install git-lfs
yum/rpm: sudo yum install git-lfs
下载
Groth16
证明密钥zkey
文件,稍后将其用作运行prover
的参数
wget -O - https://raw.githubusercontent.com/sui-foundation/zklogin-ceremony-contributions/main/download-main-zkey.sh | bash
wget -O - https://raw.githubusercontent.com/sui-foundation/zklogin-ceremony-contributions/main/download-test-zkey.sh | bash
要验证下载的
zkey
文件是否正确,可以使用如下命令校验文件Blake2b
哈希值:$ b2sum ${file_name}.zkey
Network | zkey file name | Hash |
---|---|---|
Mainnet<br /> Testnet | zkLogin-main.zkey |
060beb961802568ac9ac7f14de0fbcd55e373e8f5ec7cc32189e26fb65700aa4e36f5604f868022c765e634d14ea1cd58bd4d79cef8f3cf9693510696bcbcbce |
Devnet | zkLogin-test.zkey |
686e2f5fd969897b1c034d7654799ee2c3952489814e4eaaf3d7e1bb539841047ae8ee5fdcdaca5f4ddd76abb5a8e8eb77b44b693a2ba9d4be57e94292b26ce2 |
docker-compose
文件在Docker
仓库:https://hub.docker.com/repository/docker/mysten/zklogin/general, 下载镜像,也可以使用docker-compose
文件来比较方便的下载和启动。
prover-fe-stable
prover-stable
services:
backend:
image: mysten/zklogin:prover-stable
volumes:
# The ZKEY environment variable must be set to the path of the zkey file.
- ${ZKEY}:/app/binaries/zkLogin.zkey
environment:
- ZKEY=/app/binaries/zkLogin.zkey
- WITNESS_BINARIES=/app/binaries
frontend:
image: mysten/zklogin:prover-fe-stable
command: '8080'
ports:
# The PROVER_PORT environment variable must be set to the desired port.
- '${PROVER_PORT}:8080'
environment:
- PROVER_URI=http://backend:8080/input
- NODE_ENV=production
- DEBUG=zkLogin:info,jwks
# The default timeout is 15 seconds. Uncomment the following line to change it.
# - PROVER_TIMEOUT=30
根据实际情况填写
export ZKEY=/root/zklogin-ceremony-contributions/zkLogin-main.zkey
export PROVER_PORT=7788
$ docker-compose up
zkLogin:info Server is listening on port 8080
服务启动后,将会对外暴露两个接口。
/ping
:心跳测试访问
ping
返回pong
$ curl http://localhost:7788/ping
pong
/v1
:获取零知识证明请求与响应,与官方维护的版本相同,如:https://prover-dev.mystenlabs.com/v1
curl -X POST 'http://localhost:7788/v1' -H 'Content-Type: application/json' \
-d '{
"jwt": "eyJhbGciOiJSUz~~~XAiOiJKV1QifQ.eyJpc3MiO~~~~~~2EwIn0.pbZ2Z0VZD~~~Y1LzyS6v8XxIVa6ocww",
"extendedEphemeralPublicKey": "ACG7j6wBvLWx~~~~PKwwPGEr",
"maxEpoch": "401",
"jwtRandomness": "115679807962189344077088833730703216993",
"salt": "1001",
"keyClaimName": "sub"
}'
{
"proofPoints": {
"a": ["18183913133488170987244886660719107007331181614040444504674056517093026894391", "16842299504470545911087571663739457925754422998499354640417518206207142769772", "1"],
"b": [
["7555024475646145341689699627504855210714456812562052703065054137626135385647", "858749325816392910726049922089768075436189230488439583553391676001732141378"],
["6496804230514851815321010008902850198445392090959512529353333447840300687819", "7260574299287359028780871778232557568068946816911691368034443648636199939995"],
["1", "0"]
],
"c": ["12717252660902811179218400063519536450898576609509570808290830037323247140386", "18393778769176542175036000358619938954524364490058277374962691740025730049657", "1"]
},
"issBase64Details": {
"value": "yJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLC",
"indexMod4": 1
},
"headerBase64": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImMzYWJlNDEzYjIyNjhhZTk3NjQ1OGM4MmMxNTE3OTU0N2U5NzUyN2UiLCJ0eXAiOiJKV1QifQ"
}
# 谷歌OAuth认证URL
OPENID_AUTHORIZATION_ENDPOINT=https://accounts.google.com/o/oauth2/v2/auth
# 注册的谷歌OAuth客户端ID
CLIENT_ID=xxx.apps.googleusercontent.com
# 使用SUI测试网
FULLNODE_URL=https://fullnode.testnet.sui.io:443
# 随便填一个地址,用于获得登录授权后,回调的JWT
REDIRECT_URL=http://localhost:8080
# 为我们自建的证明服务器地址,而非官方提供的证明服务
PROVER_URL=http://localhost:7788/v1
执行以下代码,将会生成OAuth URL
,并将生成临时私钥、JWT
随机数、临时密钥过期时间记录到环境变量.env
中,用于后续证明文件生成、签名验签等。
import dotenv from "dotenv";
dotenv.config();
import { generateNonce, generateRandomness } from "@mysten/zklogin";
import { Ed25519Keypair, Ed25519PublicKey } from "@mysten/sui/keypairs/ed25519";
import { SuiClient } from "@mysten/sui.js/client";
import { decodeSuiPrivateKey } from "@mysten/sui/cryptography";
import * as fs from "fs";
const OPENID_AUTHORIZATION_ENDPOINT =
process.env.OPENID_AUTHORIZATION_ENDPOINT!;
const CLIENT_ID = process.env.CLIENT_ID!;
const FULLNODE_URL = process.env.FULLNODE_URL!;
const REDIRECT_URL = process.env.REDIRECT_URL!;
export const SUI_CLIENT = new SuiClient({ url: FULLNODE_URL });
// 创建临时密钥对,并将私钥记录到.env中
const ephemeralKeyPair = createEphemeralKeyPair("EPHEMERAL_PRIVATE_KEY");
// 本地生成随机数,并记录到.env中
const jwtRandomness = createJWTRandomness("JWT_RANDOMNESS");
function createJWTRandomness(tag: string): string {
const jwtRandomness = generateRandomness();
fs.appendFile(".env", `\n${tag}=${jwtRandomness}`, (err) => {
if (err) {
console.error(`append ${tag} jwtRandomness to .env failed, ${err}`);
return;
}
});
return jwtRandomness;
}
function createEphemeralKeyPair(tag: string): Ed25519Keypair {
const ekp = new Ed25519Keypair();
const skBytes = decodeSuiPrivateKey(ekp.getSecretKey()).secretKey.toString();
fs.appendFile(".env", `\n${tag}=${skBytes}`, (err) => {
if (err) {
console.error(`append ${tag} sk to .env failed, ${err}`);
return;
}
});
return ekp;
}
// 创建OAuth URL
async function getOAuthURL(
epk: Ed25519PublicKey,
jwtRandomness: string
): Promise<string> {
const { epoch } = await SUI_CLIENT.getLatestSuiSystemState();
// 设置临时密钥对的过期时间(2天后过期),并添加到环境变量
const maxEpoch = Number(epoch) + 2;
fs.appendFile(".env", `\nMAX_EPOCH=${maxEpoch}`, (err) => {
if (err) {
console.error(`append MAX_EPOCH to .env failed, ${err}`);
return;
}
});
const nonce = generateNonce(epk, maxEpoch, jwtRandomness);
const params = new URLSearchParams({
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URL,
response_type: "id_token",
scope: "openid email",
nonce: nonce,
});
return `${OPENID_AUTHORIZATION_ENDPOINT}?${params.toString()}`;
}
async function main() {
const oAuthUrl = await getOAuthURL(
ephemeralKeyPair.getPublicKey(),
jwtRandomness
);
console.log(oAuthUrl);
}
main();
https://accounts.google.com/o/oauth2/v2/auth?client_id=xxx.apps.googleusercontent.com&redirect_uri=http%3A%2F%2Flocalhost%3A8080&response_type=id_token&scope=openid+email&nonce=PRKKJyW9IU44lRMHaeoSduewgZ4
将上面生成的OAuth URL
拷贝到浏览器执行,完整谷歌账号的登录与授权,便能从回调地址中得到JWT
,例如回调地址如下,id_token
的内容即为JWT
:
http://localhost:8080/#id_token=eyJhbGciOiJSUzI1NiIs~~~~UiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJo~~~~~NlZWFkMmI4In0.HvPYeIlTcxcaraVuhbQYIiTdCxMVB2oE9CJvhIT3bxFGEjmfG35BIuPIfPdM5ibx-hAM1MD6vRkl2RTXsBX8gGTAlhFJPbIIhPDDTr4ua-WIB39eVwjnUayF~~~~Nt0qCQ&authuser=0&prompt=none
JWT
信息添加到环境变量.env
文件中JWT=eyJhbGciOiJSUzI1NiIs~~~~UiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJo~~~~~NlZWFkMmI4In0.HvPYeIlTcxcaraVuhbQYIiTdCxMVB2oE9CJvhIT3bxFGEjmfG35BIuPIfPdM5ibx-hAM1MD6vRkl2RTXsBX8gGTAlhFJPbIIhPDDTr4ua-WIB39eVwjnUayF~~~~Nt0qCQ
为了生成zkLogin
钱包地址,除了JWT
外,还需要盐值,作为测试,我们固定盐值,将盐值添加到环境变量.env
文件中
USER_SALT=1000
const JWT = process.env.JWT!;
const USER_SALT = process.env.USER_SALT!;
function getWalletAddress() {
return jwtToAddress(JWT, USER_SALT);
}
console.log("zkLogin Wallet address:", getWalletAddress());
得到zklogin钱包地址
为该地址充值,便于后续发送交易
zkLogin Wallet address: 0xb4629c0f44......45f7c4d99e9a
// ========== 获取证明文件 ==========
const PROVER_URL = process.env.PROVER_URL!;
// 从环境变量中获得私钥,并解析得到临时Keypair对象
function createEphemeralKeyPairFromEnv(): Ed25519Keypair {
const sk = process.env.EPHEMERAL_PRIVATE_KEY as string;
const skArray = sk.split(",").map(Number);
return Ed25519Keypair.fromSecretKey(new Uint8Array(skArray));
}
const ephemeralKeyPair = createEphemeralKeyPairFromEnv();
// 从环境变量获得随机数
const jwtRandomness = process.env.JWT_RANDOMNESS as string;
async function getPartialZkLoginSignature(
keyPair: Ed25519Keypair,
jwt: string,
jwtRandomness: string,
userSalt: string
): Promise<any> {
const extendedEphemeralPublicKey = getExtendedEphemeralPublicKey(
keyPair.getPublicKey()
);
const verificationPayload = {
jwt: jwt,
extendedEphemeralPublicKey,
maxEpoch: process.env.MAX_EPOCH as string,
jwtRandomness: jwtRandomness,
salt: userSalt,
keyClaimName: "sub",
};
return await verifyPartialZkLoginSignature(verificationPayload);
}
export type PartialZkLoginSignature = Omit<
Parameters<typeof getZkLoginSignature>["0"]["inputs"],
"addressSeed"
>;
async function verifyPartialZkLoginSignature(zkpRequestPayload: any) {
try {
const proofResponse = await axios.post(PROVER_URL, zkpRequestPayload, {
headers: {
"content-type": "application/json",
},
});
const partialZkLoginSignature =
proofResponse.data as PartialZkLoginSignature;
return partialZkLoginSignature;
} catch (error) {
console.log("failed to reqeust the partial sig: ", error);
return {};
}
}
async function main() {
const partialZkLoginSignature = await getPartialZkLoginSignature(
ephemeralKeyPair,
JWT,
jwtRandomness,
USER_SALT
);
console.log("partialZkLoginSignature:", partialZkLoginSignature);
}
main();
partialZkLoginSignature: {
proofPoints: {
a: [
'15495134036289421734756486075638497259476885063665323148887219006441184413426',
'5496814077189949990762154445157348379519913307613637699373164633041138209869',
'1'
],
b: [ [Array], [Array], [Array] ],
c: [
'18788824701150535476050426460267418374653743176995002590195890205726508614553',
'11951306859484876841425870106123409206262537628752160521522546504400831519926',
'1'
]
},
issBase64Details: {
value: 'yJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLC',
indexMod4: 1
},
headerBase64: 'eyJhbGciOiJSUzI1NiIsImtpZCI6ImMzYWJlNDEzYjIyNjhhZTk3NjQ1OGM4MmMxNTE3OTU0N2U5NzUyN2UiLCJ0eXAiOiJKV1QifQ'
}
证明服务日志输出
在我们搭建的证明服务器上也可以看到相关日志。
frontend_1 | UID: 241dc3dfa7fad24befd15d7261d5597b078fa33e7a682c034d8014740d22ca38
frontend_1 | The issuer https://accounts.google.com is supported
frontend_1 | Skipping aud whitelist check
frontend_1 | verifySignature: 0.963ms
frontend_1 | Input validation passed!
frontend_1 | validateInput: 3.273ms
frontend_1 | genZKLoginInputs: 36.841ms
backend_1 | SingleProver::startProve begin
backend_1 | /app/binaries/zkLogin /tmp/zklogin_9M54c8 /tmp/zklogin_hDGjvW
backend_1 | Witness generation finished in 519ms
backend_1 | SingleProver::prove begin
backend_1 | Proof generation finished in 1967ms
frontend_1 | Call rapidsnark: 2.497s
frontend_1 | Request took 2537ms
frontend_1 | Sending response
这里调用的合约接口,还是使用的《如何构建一个基于zkLogin的SUI Move dApp?》一文中的笔记合约(
notes
) 的 新增笔记(create_note
) 接口。
interface JwtPayload {
iss: string;
sub: string;
aud: string[] | string;
exp?: number;
nbf?: number;
iat?: number;
jti?: string;
}
async function generateZkLoginSignature(
userSalt: string,
jwt: string,
userSignature: string
): Promise<string> {
const partialZkLoginSignature = await getPartialZkLoginSignature(
ephemeralKeyPair,
JWT,
jwtRandomness,
USER_SALT
);
console.log("partialZkLoginSignature:", partialZkLoginSignature);
const decodedJWT = jwtDecode(jwt) as JwtPayload;
const addressSeed = genAddressSeed(
BigInt(userSalt),
"sub",
decodedJWT.sub,
decodedJWT.aud.toString()
).toString();
return getZkLoginSignature({
inputs: {
...partialZkLoginSignature,
addressSeed,
},
maxEpoch: MAX_EPOCH,
userSignature,
});
}
function getZkLoginPk(jwt: string, userSalt: string): ZkLoginPublicIdentifier {
const decodedJWT = jwtDecode(jwt) as JwtPayload;
const addressSeed = genAddressSeed(
userSalt,
"sub",
decodedJWT.sub,
decodedJWT.aud.toString()
);
return toZkLoginPublicIdentifier(addressSeed, decodedJWT.iss);
}
async function addNote(title: string, body: string) {
const txb = new Transaction();
const txData = {
target: `${PACKAGE_ID}::notes::create_note`,
arguments: [txb.pure.string(title), txb.pure.string(body)],
};
return makeMoveCall(txData, txb);
}
async function makeMoveCall(txData: any, txb: Transaction) {
const sender = getWalletAddress();
txb.setSender(sender);
txb.moveCall(txData);
const { bytes, signature: userSignature } = await txb.sign({
client: SUI_CLIENT,
signer: ephemeralKeyPair,
});
const zkLoginSignature = await generateZkLoginSignature(
USER_SALT,
JWT,
userSignature
);
return SUI_CLIENT.executeTransactionBlock({
transactionBlock: bytes,
signature: zkLoginSignature,
});
}
async function main() {
await addNote("欢迎关注微信公众号:Move中文", "开启你的Sui Move之旅!");
}
main();
欢迎关注微信公众号:Move中文,开启你的 Sui Move 之旅!
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!