diff --git a/kms/host/src/api_server.rs b/kms/host/src/api_server.rs index 5843867e..cd695544 100644 --- a/kms/host/src/api_server.rs +++ b/kms/host/src/api_server.rs @@ -5912,6 +5912,31 @@ struct BlsGenResp { public_key: String, } +// CC-37 staked registration: BLS proof-of-possession. RFC-standard self-PoP — the TA signs the +// node's OWN pubkey under BLS_DST (the caller supplies no message), byte-identical to SDK core +// buildDvtPop. Returns the full DvtPop tuple for registerWithProof. Loopback + token. +#[derive(serde::Deserialize)] +struct PopSignReq { + /// Informational only (board singleton BLS key is addressed by KMS_BLS_KEY_ID from env). + #[allow(dead_code)] + node_id: Option, +} + +// camelCase JSON keys — DVT register-node.mjs reads `{ popPoint, popSig }` and SDK's DvtPop +// is `{ publicKey, popPoint, popSig }`. The exact keys are the cross-repo contract (CC-37). +#[derive(serde::Serialize)] +struct PopSignResp { + /// G1 pubkey, 128B EIP-2537 (registerWithProof `publicKey`). + #[serde(rename = "publicKey")] + public_key: String, + /// hashToCurve(publicKey, BLS_DST), 256B EIP-2537 G2 (registerWithProof `popPoint`). + #[serde(rename = "popPoint")] + pop_point: String, + /// sk·popPoint, 256B EIP-2537 G2 (registerWithProof `popSig`). + #[serde(rename = "popSig")] + pop_signature: String, +} + // ── CC-34: keeper/operator ECDSA(secp256k1) signer — mirrors the BLS signer on // 127.0.0.1:3100. POST /kms/sign {keeper_id, digest} → {signature(65B r||s||v), address}; // POST /kms/gen-keeper-eoa → {key_id, address, public_key}. Keeper key sealed in TA. @@ -6020,7 +6045,13 @@ fn check_signer_token_required(token: &Option) -> Result<(), warp::Rejec fn check_signer_token(token: &Option) -> Result<(), warp::Rejection> { match std::env::var("KMS_BLS_SIGNER_TOKEN") { Ok(expected) if !expected.is_empty() => { - if token.as_deref() == Some(expected.as_str()) { + // constant-time compare (mirrors check_keeper_token / _required) — no timing + // leak on the bearer token. + if token + .as_deref() + .map(|t| ct_eq(t.as_bytes(), expected.as_bytes())) + .unwrap_or(false) + { Ok(()) } else { Err(warp::reject::custom(ApiError( @@ -6140,6 +6171,39 @@ async fn bls_sign_handler( } } +/// CC-37 staked registration: return the BLS proof-of-possession tuple so a KMS-TEE key-less +/// DVT node can call the validator's registerWithProof. Same key_id-from-env + token as /sign. +/// The TA signs its OWN pubkey under BLS_DST (RFC self-PoP; caller supplies no message). +async fn pop_sign_handler( + _req: PopSignReq, + token: Option, + server: Arc, +) -> Result { + check_signer_token(&token)?; + let key_id = match std::env::var("KMS_BLS_KEY_ID") + .ok() + .and_then(|s| Uuid::parse_str(&s).ok()) + { + Some(k) => k, + None => { + return Err(warp::reject::custom(ApiError( + "KMS_BLS_KEY_ID not configured".into(), + ))) + } + }; + match server.tee.bls_pop_sign(key_id).await { + Ok((public_key, pop_point, pop_signature)) => Ok(warp::reply::json(&PopSignResp { + public_key: format!("0x{}", hex::encode(public_key)), + pop_point: format!("0x{}", hex::encode(pop_point)), + pop_signature: format!("0x{}", hex::encode(pop_signature)), + })), + Err(e) => Err(warp::reject::custom(ApiError(format!( + "BLS PoP sign failed: {}", + e + )))), + } +} + // ── CC-34 keeper/operator ECDSA handlers (loopback :3100) ── /// Provision the board's singleton keeper EOA (TEE-sealed secp256k1). Returns @@ -7194,6 +7258,16 @@ function tgl(){var d=document.documentElement.classList.toggle('dark');document. .and(warp::header::optional::("x-signer-token")) .and(warp::any().map(move || signer_server.clone())) .and_then(bls_sign_handler); + // CC-24 staked registration: BLS proof-of-possession over the operator address. + let pop_server = server.clone(); + let pop_route = warp::post() + .and(warp::path("pop")) + .and(warp::path::end()) + .and(warp::body::content_length_limit(1024)) // node_id + operator is tiny + .and(warp::body::json()) + .and(warp::header::optional::("x-signer-token")) + .and(warp::any().map(move || pop_server.clone())) + .and_then(pop_sign_handler); let gen_server = server.clone(); let bls_gen_route = warp::post() .and(warp::path("gen-key")) @@ -7232,6 +7306,7 @@ function tgl(){var d=document.documentElement.classList.toggle('dark');document. warp::reply::json(&serde_json::json!({"status": "ok", "service": "kms-bls-signer"})) }); let signer_routes = bls_sign_route + .or(pop_route) .or(bls_gen_route) .or(bls_remove_route) .or(keeper_sign_route) diff --git a/kms/host/src/ta_client.rs b/kms/host/src/ta_client.rs index dc8848e8..c8cac773 100644 --- a/kms/host/src/ta_client.rs +++ b/kms/host/src/ta_client.rs @@ -649,6 +649,30 @@ impl TeeHandle { Ok((output.signature, output.signature_compact)) } + /// BLS proof-of-possession (RFC self-PoP over the node's own pubkey) — CC-37 staked + /// registration. Returns the DvtPop tuple (publicKey 128B, popPoint 256B, popSig 256B), + /// all EIP-2537. The TA signs its own pubkey (no caller message) → not a signing oracle. + pub async fn bls_pop_sign( + &self, + key_id: uuid::Uuid, + ) -> Result<(Vec, Vec, Vec)> { + let input = bincode::serialize(&proto::BlsPopSignInput { key_id }) + .context("Failed to serialize BlsPopSignInput")?; + let out = self.call(proto::Command::BlsPopSign, input).await?; + let output: proto::BlsPopSignOutput = + bincode::deserialize(&out).context("Failed to deserialize BlsPopSignOutput")?; + anyhow::ensure!( + output.public_key.len() == 128 + && output.pop_point.len() == 256 + && output.pop_signature.len() == 256, + "BLS PoP tuple length invalid (publicKey {} want 128, popPoint {} want 256, popSig {} want 256)", + output.public_key.len(), + output.pop_point.len(), + output.pop_signature.len() + ); + Ok((output.public_key, output.pop_point, output.pop_signature)) + } + /// Return the sealed BLS key's 48B compressed G1 public key. pub async fn bls_pubkey(&self, key_id: uuid::Uuid) -> Result> { let input = bincode::serialize(&proto::BlsPubKeyInput { key_id }) diff --git a/kms/node-setup/aastar-kms-selfinit.sh b/kms/node-setup/aastar-kms-selfinit.sh index 6b8b994a..734e5811 100755 --- a/kms/node-setup/aastar-kms-selfinit.sh +++ b/kms/node-setup/aastar-kms-selfinit.sh @@ -83,12 +83,101 @@ wait_signer() { done die "signer $SIGNER not up after 30s — is kms-api running?" } +# non-fatal variant: returns 1 instead of dying (used to health-gate the de-root restart). +wait_signer_soft() { + for _ in $(seq 1 20); do + curl -sf -m2 "$SIGNER/health" >/dev/null 2>&1 && return 0 + sleep 1 + done + return 1 +} +# TA-backed probe: /health is static JSON (liveness only) — it passes even if the process +# can't open /dev/tee0 (bad DevicePolicy / missing group). A REAL BLS sign proves the TEE is +# reachable as whatever user kms-api now runs as. Used to gate the de-root (rollback if dead). +tee_reachable() { + [ -n "$BLS_KEY_ID" ] || return 0 # nothing provisioned yet → skip (nothing to prove) + local r; r="$(curl -s -m8 -X POST "$SIGNER/sign" -H 'content-type: application/json' \ + -H "x-signer-token: $TOKEN" \ + -d '{"node_id":"selfinit-probe","user_op_hash":"0x0000000000000000000000000000000000000000000000000000000000000000"}' 2>/dev/null || true)" + printf '%s' "$r" | grep -q '"signature"' +} restart_kms() { # $1 = fatal-on-fail (1) or best-effort (0) systemctl restart kms-api.service 2>/dev/null && return 0 if [ "${1:-0}" = 1 ]; then die "kms-api restart failed — refusing to finish (gate may still be live)"; fi log "WARN: kms-api restart failed" } +# ── device-auth hardening (CC-25): de-root kms-api + dvt so a compromise of either +# (especially the network-facing DVT) cannot open /dev/tee* and invoke the sealed +# keeper/BLS TA directly, bypassing the loopback X-Signer-Token. Idempotent + defensive: +# writes only systemd drop-ins + ownership (never touches DVT/KMS *code*), and the caller +# health-gates the follow-up restart with an auto-rollback so it can't brick first boot. ── +_in_group() { id -nG "$1" 2>/dev/null | tr ' ' '\n' | grep -qx "$2"; } +harden_device_auth() { + # 1. dedicated users. kms in tee/teepriv (may open /dev/tee0); dvt in NEITHER. + id kms >/dev/null 2>&1 || useradd --system --no-create-home --shell /usr/sbin/nologin kms 2>/dev/null || useradd --system --no-create-home --shell /bin/false kms + id dvt >/dev/null 2>&1 || useradd --system --no-create-home --shell /usr/sbin/nologin dvt 2>/dev/null || useradd --system --no-create-home --shell /bin/false dvt + for g in tee teepriv; do getent group "$g" >/dev/null 2>&1 && ! _in_group kms "$g" && usermod -aG "$g" kms; done + # dvt must have NO path to /dev/tee* — actively remove it from tee/teepriv if present + # (belt-and-suspenders; the dvt drop-in below ALSO sets DevicePolicy=closed). + for g in tee teepriv; do _in_group dvt "$g" && { gpasswd -d dvt "$g" 2>/dev/null || true; log "removed dvt from group $g (TEE must be unreachable from the network-facing node)"; }; done + + # 2. kms deploy must live off /root (a non-root user can't traverse 0700 /root). If the + # unit's ExecStart is under /root, COPY it out to /opt/airaccount (copy, not move, so a + # rollback that reverts to the /root unit still has its files). kms.db is the live state. + local kexec kdir; kexec="$(systemctl show -p ExecStart --value kms-api.service 2>/dev/null | grep -oE '/[^ ;]*kms-api-server' | head -1 || true)" + kdir="$(dirname "${kexec:-/opt/airaccount/kms-api-server}")" + case "$kexec" in + /root/*) + local NEW=/opt/airaccount src; src="$(dirname "$kexec")"; mkdir -p "$NEW" + systemctl stop kms-api.service 2>/dev/null || true # release kms.db WAL before copying + cp -a "$kexec" "$NEW/kms-api-server" + [ -f "$src/kms-test-page.html" ] && cp -a "$src/kms-test-page.html" "$NEW/" 2>/dev/null || true + for f in kms.db kms.db-shm kms.db-wal; do [ -e "$src/$f" ] && cp -a "$src/$f" "$NEW/$f"; done + kdir="$NEW"; kexec="$NEW/kms-api-server" + log "copied kms deploy out of /root -> $NEW (de-root prerequisite; /root copy kept for rollback)" + ;; + esac + chown -R kms:kms "$kdir" 2>/dev/null || true + + # 3. kms-api de-root drop-in: User=kms + tee groups + cgroup-v2 device lockdown. + mkdir -p /etc/systemd/system/kms-api.service.d + cat > /etc/systemd/system/kms-api.service.d/deroot.conf < /etc/systemd/system/dvt.service.d/deroot.conf + [ -d "$ddir" ] && chown -R dvt:dvt "$ddir" 2>/dev/null || true + + systemctl daemon-reload 2>/dev/null || true + log "device-auth hardening staged (kms-api=kms + DevicePolicy=closed; dvt=dvt, no TEE access)" +} +# Revert the kms-api de-root drop-in (used if the hardened restart fails — never brick). +unharden_kms() { rm -f /etc/systemd/system/kms-api.service.d/deroot.conf; systemctl daemon-reload 2>/dev/null || true; } + # ── 1. shared signer tokens (generate once, persisted) ── if [ -z "$(env_get KMS_BLS_SIGNER_TOKEN)" ]; then env_set KMS_BLS_SIGNER_TOKEN "$(openssl rand -hex 32)" @@ -209,9 +298,13 @@ htmp="$(mktemp "$CONFIG_DIR/.handoff.XXXXXX")" echo "KMS_BLS_KEY_ID=$BLS_KEY_ID" echo "KMS_BLS_PUBKEY=$BLS_PUBKEY" if [ -n "$KEEPER_ADDR" ]; then - echo "KEEPER_SIGNER_URL=$SIGNER/kms/sign" + # KEEPER_SIGNER_URL is the loopback BASE — DVT's KmsEcdsaSigner appends /kms/sign + # itself (and register-node.mjs appends /pop). Emitting the base (not …/kms/sign) + # avoids a doubled path. + echo "KEEPER_SIGNER_URL=$SIGNER" echo "KEEPER_SIGNER_TOKEN=$(env_get KMS_KEEPER_SIGNER_TOKEN)" echo "KEEPER_ADDRESS=$KEEPER_ADDR" + echo "KEEPER_ID=$(env_get KMS_KEEPER_KEY_ID)" fi } > "$htmp" chmod 600 "$htmp"; mv -f "$htmp" "$HANDOFF" @@ -224,9 +317,21 @@ env_del KMS_BLS_PROVISIONING env_del KMS_KEEPER_PROVISIONING rm -f /etc/systemd/system/kms-api.service.d/prov.conf 2>/dev/null || true systemctl daemon-reload 2>/dev/null || true -log "closed provisioning gate; restarting kms-api to load key ids" -restart_kms 1 -wait_signer + +# device-auth hardening (CC-25): de-root kms-api + dvt, then restart so kms-api comes up as +# the non-root kms user. Health-gated with auto-rollback: if it does NOT come up hardened, +# revert to the prior (root) config and continue — a running KMS beats a hardened-but-dead one. +harden_device_auth +log "closed provisioning gate; restarting kms-api (hardened) to load key ids" +systemctl restart kms-api.service 2>/dev/null || true +if wait_signer_soft && tee_reachable; then + log "kms-api up as non-root kms (device-auth hardened; TEE reachable via a live BLS sign)" +else + log "WARN: kms-api not healthy+TEE-reachable hardened — rolling back de-root, staying on prior config" + unharden_kms + restart_kms 1 + wait_signer +fi # ── 7. mark done ── : > "$MARKER"; chmod 600 "$MARKER" diff --git a/kms/proto/src/in_out.rs b/kms/proto/src/in_out.rs index 680ab1df..def6b96b 100644 --- a/kms/proto/src/in_out.rs +++ b/kms/proto/src/in_out.rs @@ -710,3 +710,22 @@ pub struct BlsRemoveOutput { /// Number of BLS keys deleted (0 or 1 given the singleton invariant). pub removed: u32, } + +// CC-37 staked registration: BLS proof-of-possession (RFC-standard self-PoP over the node's +// OWN public key). The TA derives its pubkey from the sealed key and signs it under BLS_DST — +// the caller supplies NO message, so /pop is not a signing oracle. Byte-identical to SDK +// buildDvtPop. Input is just the key_id (board singleton); output is the DvtPop tuple. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct BlsPopSignInput { + pub key_id: Uuid, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct BlsPopSignOutput { + /// G1 public key in 128-byte EIP-2537 layout (registerWithProof's `publicKey`). + pub public_key: Vec, + /// hashToCurve(publicKey, BLS_DST) as 256-byte EIP-2537 G2 (registerWithProof's `popPoint`). + pub pop_point: Vec, + /// sk · popPoint as 256-byte EIP-2537 G2 (registerWithProof's `popSig`). + pub pop_signature: Vec, +} diff --git a/kms/proto/src/lib.rs b/kms/proto/src/lib.rs index 4277d94d..c5835f01 100644 --- a/kms/proto/src/lib.rs +++ b/kms/proto/src/lib.rs @@ -88,6 +88,12 @@ pub enum Command { /// the BLS entries and deletes them; returns the count removed. Gated host-side /// behind KMS_BLS_PROVISIONING=1 + KMS_BLS_ALLOW_REMOVE=1 + token (destructive). BlsRemove = 33, + /// CC-24 staked registration: sign a proof-of-possession for the co-located DVT + /// node. The TA signs the OPERATOR address under the PoP DST (not an arbitrary + /// caller-provided point) → operator-bound, so a compromised caller can only obtain + /// a PoP for a given operator, never a forgery on a chosen message. Host loopback + /// /pop, token-gated. Output is the EIP-2537 G2 pop signature. + BlsPopSign = 34, #[default] Unknown, } @@ -146,6 +152,7 @@ mod tests { assert_eq!(u32::from(Command::KeeperSign), 31); assert_eq!(u32::from(Command::KeeperPubKey), 32); assert_eq!(u32::from(Command::BlsRemove), 33); + assert_eq!(u32::from(Command::BlsPopSign), 34); } #[test] @@ -187,7 +194,7 @@ mod tests { // 13 (JwtHmacSign) and 16 (JwtSignPayload) removed — JWT signing oracle closed (Issue #16) let valid_ids: &[u32] = &[ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14, 15, 17, 18, 19, 20, 21, 22, 23, 24, 25, - 26, 27, 28, 29, 30, 31, 32, 33, + 26, 27, 28, 29, 30, 31, 32, 33, 34, ]; for &i in valid_ids { let cmd = Command::from(i); @@ -204,7 +211,7 @@ mod tests { /// reuse of removed ids (13 = JwtHmacSign, 16 = JwtSignPayload). #[test] fn command_ids_unique_and_reserved_respected() { - let all: Vec = (0u32..=33) + let all: Vec = (0u32..=34) .filter(|&i| !matches!(Command::from(i), Command::Unknown)) .collect(); let mut dedup = all.clone(); diff --git a/kms/scripts/register-bls-node.sh b/kms/scripts/register-bls-node.sh new file mode 100755 index 00000000..0bc48bbe --- /dev/null +++ b/kms/scripts/register-bls-node.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# register-bls-node.sh — BOOTSTRAP-ONLY on-chain registration of the co-located DVT node's +# BLS G1 pubkey (CC-24). It calls validator.registerPublicKey(nodeId, pubkey), which the +# contract ONLY accepts while requireStake == false (owner-registers bootstrap mode). +# +# ⚠️ If the validator has requireStake == true (staked mode, the default for a live +# validator), this path reverts "Staking on: use registerWithProof" — you must instead use +# the STAKED path: DVT's `scripts/register-node.mjs`, which builds a BLS proof-of-possession +# and, for a key-less KMS-TEE node, gets the PoP signed by the KMS via POST :3100/pop +# {node_id, operator} → {pop_signature} (this KMS exposes /pop; the operator must also be +# staked for ROLE_DVT). This script remains handy for a bootstrap/dev validator. +# +# Needs the OPERATOR key (validator owner / authorized registrant), which by design never +# lives on the node. Run it once per node from an operator host. +# +# What it sends (matches DVT blockchain.service.ts registerNodeOnChain, byte-for-byte): +# validator.registerPublicKey(bytes32 nodeId, bytes publicKey) +# gated by a view pre-check: +# validator.isRegistered(bytes32 nodeId) -> bool +# +# Requires: foundry `cast`. +# +# ── Node identity (A-board; override via env for another node) ───────────────── +# PUBKEY is the on-chain form the validator requires: EIP-2537 UNCOMPRESSED G1 = 128 +# bytes = [16 zero ‖ 48-byte x ‖ 16 zero ‖ 48-byte y]. NOT the 48-byte compressed G1 +# from KMS gen-key / node_state.json — expand it first (DVT bls.util encodeG1Point, or +# noble: G1.ProjectivePoint.fromHex(compressed).toAffine() → pad x,y to 48 each). +NODE_ID="${NODE_ID:-0xf177545fd7889a4a0670944da3b3ae2ca12718cec89c830e59a05ebc4b6dd664}" +PUBKEY="${PUBKEY:-0x00000000000000000000000000000000149ffed8a9a5bbd153714a6752f327ff94834c636a1e35acf8af3e405af01a5a0e6f8d6b76987722262b12a9865e1436000000000000000000000000000000000063ccae09f227cd32ec77af7444effcfc46c61b4f977617df5ec8efd7141bf99f025c266ceb695ed63e45a277d23516}" +VALIDATOR="${VALIDATOR:-0x539B9681aFd5BFbCaa655Fe4c6BdcFe1fa7864bC}" +RPC_URL="${RPC_URL:-}" +set -euo pipefail + +usage() { + cat >&2 < OPERATOR_PK=<0x…> $0 + e.g. source ~/Dev/aastar/superpaymaster/.env.sepolia + RPC_URL="\$SEPOLIA_RPC_URL" OPERATOR_PK="\$OWNER_PRIVATE_KEY" $0 + (use whichever key is the validator's authorized operator/owner) + + # MAINNET — cast wallet keystore (key never on disk in plaintext): + RPC_URL= CAST_ACCOUNT= CAST_FROM=<0xoperator> $0 + +Env overrides: NODE_ID, PUBKEY, VALIDATOR (default = A-board values above). +EOF + exit 2 +} + +command -v cast >/dev/null || { echo "‼ foundry 'cast' not found (https://getfoundry.sh)"; exit 1; } +[ -n "$RPC_URL" ] || { echo "‼ RPC_URL required"; usage; } +# length-based checks (portable — avoids bash regex {n}-interval RE_DUP_MAX quirks) +{ [[ "$NODE_ID" =~ ^0x[0-9a-fA-F]+$ ]] && [ ${#NODE_ID} -eq 66 ]; } || { echo "‼ NODE_ID must be 0x+64hex (bytes32)"; exit 1; } +{ [[ "$PUBKEY" =~ ^0x[0-9a-fA-F]+$ ]] && [ ${#PUBKEY} -eq 258 ]; } || { echo "‼ PUBKEY must be 0x+256hex (128-byte EIP-2537 uncompressed G1) — expand the 48-byte compressed key first"; exit 1; } + +echo "validator : $VALIDATOR" +echo "nodeId : $NODE_ID" +echo "pubkey : $PUBKEY" + +echo "── pre-check: isRegistered(nodeId) ──" +already="$(cast call "$VALIDATOR" 'isRegistered(bytes32)(bool)' "$NODE_ID" --rpc-url "$RPC_URL")" +if [ "$already" = "true" ]; then + echo "✅ already registered — nothing to do (idempotent)." + exit 0 +fi +echo "not registered yet → sending registerPublicKey ..." + +# Build the cast-send auth flags: testnet raw key OR mainnet cast-wallet keystore. +AUTH=() +if [ -n "${OPERATOR_PK:-}" ]; then + AUTH=(--private-key "$OPERATOR_PK") +elif [ -n "${CAST_ACCOUNT:-}" ]; then + AUTH=(--account "$CAST_ACCOUNT" ${CAST_FROM:+--from "$CAST_FROM"}) +else + echo "‼ provide OPERATOR_PK (testnet) or CAST_ACCOUNT (+CAST_FROM) for mainnet cast wallet"; usage +fi + +set -x +cast send "$VALIDATOR" 'registerPublicKey(bytes32,bytes)' "$NODE_ID" "$PUBKEY" \ + --rpc-url "$RPC_URL" "${AUTH[@]}" +set +x + +echo "── post-check ──" +after="$(cast call "$VALIDATOR" 'isRegistered(bytes32)(bool)' "$NODE_ID" --rpc-url "$RPC_URL")" +[ "$after" = "true" ] && echo "✅ registered on-chain (isRegistered=true)" || { echo "❌ still not registered — check tx/operator authorization"; exit 1; } diff --git a/kms/ta/src/bls.rs b/kms/ta/src/bls.rs index 2ffe0188..86d2ab46 100644 --- a/kms/ta/src/bls.rs +++ b/kms/ta/src/bls.rs @@ -24,6 +24,57 @@ pub fn sign(sk_bytes: &[u8; 32], message: &[u8; 32]) -> Result<([u8; 256], [u8; Ok((eip2537, compact)) } +/// G1 uncompressed(96B = x[48]‖y[48] big-endian)→ EIP-2537 128B(2×64 槽,每 48B 右对齐、 +/// 前 16B 零)。G1 是 Fp(非 Fp2),无 c0/c1 交换。与 SDK core encodeG1Point + 合约 publicKey +/// 布局逐字节一致。 +fn encode_g1_eip2537(uncompressed_96: &[u8; 96]) -> [u8; 128] { + let mut out = [0u8; 128]; + out[16..64].copy_from_slice(&uncompressed_96[0..48]); // x @ slot0 + out[80..128].copy_from_slice(&uncompressed_96[48..96]); // y @ slot1 + out +} + +/// hash_to_curve(message, BLS_DST) → EIP-2537 256B G2(c0-first,复用 encode_g2_eip2537)。 +/// blst_hash_to_g2 与 noble G2.hashToCurve 同为 RFC 9380 SSWU、同 DST → 同点 → SDK popPoint +/// 逐字节一致。 +fn hash_to_g2_eip2537(message: &[u8]) -> [u8; 256] { + let mut p = blst::blst_p2::default(); + unsafe { + blst::blst_hash_to_g2( + &mut p, + message.as_ptr(), + message.len(), + BLS_DST.as_ptr(), + BLS_DST.len(), + core::ptr::null(), + 0, + ); + } + let mut aff = blst::blst_p2_affine::default(); + unsafe { blst::blst_p2_to_affine(&mut aff, &p) }; + let mut ser = [0u8; 192]; + unsafe { blst::blst_p2_affine_serialize(ser.as_mut_ptr(), &aff) }; + encode_g2_eip2537(&ser) +} + +/// CC-37 staked registration: BLS proof-of-possession. RFC-standard **self-PoP** over the node's +/// OWN 128B EIP-2537 public key with DST=BLS_DST (…_POP_) — byte-identical to SDK core +/// buildDvtPop + on-chain registerWithProof (golden-vector aligned). Returns +/// (publicKey 128B, popPoint 256B, popSig 256B) = the DvtPop tuple. +/// +/// Security: the message is the node's OWN pubkey — KMS-derived, the caller supplies NO message — +/// so /pop is not a general signing oracle. And a 128B pubkey can never equal a 32B co-sign +/// userOpHash, so a PoP can't be replayed as a co-sign signature even though the DST is shared. +pub fn sign_pop(sk_bytes: &[u8; 32]) -> Result<([u8; 128], [u8; 256], [u8; 256])> { + let sk = SecretKey::from_bytes(sk_bytes).map_err(|e| anyhow!("BLS sk from_bytes: {:?}", e))?; + let pk_uncompressed = sk.sk_to_pk().serialize(); // 96B uncompressed G1 (x‖y) + let public_key = encode_g1_eip2537(&pk_uncompressed); + let pop_point = hash_to_g2_eip2537(&public_key); + let sig: Signature = sk.sign(&public_key, BLS_DST, &[]); // popSig = sk·hashToG2(pubkey128, BLS_DST) + let pop_sig = encode_g2_eip2537(&sig.serialize()); + Ok((public_key, pop_point, pop_sig)) +} + /// 从密封私钥字节恢复 48B 压缩 G1 公钥(校验/恢复用,handler 走存储的公钥)。 #[allow(dead_code)] pub fn pubkey(sk_bytes: &[u8; 32]) -> Result<[u8; 48]> { @@ -50,3 +101,36 @@ fn encode_g2_eip2537(uncompressed_192: &[u8; 192]) -> [u8; 256] { } out } + +#[cfg(test)] +mod pop_golden { + use super::sign_pop; + + // Golden vector from SDK core buildDvtPop (packages/core/src/crypto/dvtPop.ts) for the + // BLS secret key 0x…c0ffee. sign_pop MUST be byte-identical: same publicKey (EIP-2537 G1), + // same popPoint = hashToCurve(publicKey, BLS_DST), same popSig = sk·popPoint. This proves the + // TA's blst PoP matches @noble + the on-chain registerWithProof convention (CC-37). + #[test] + fn sign_pop_matches_sdk_builddvtpop() { + let mut sk = [0u8; 32]; + sk[29] = 0xc0; + sk[30] = 0xff; + sk[31] = 0xee; + let (pk, pop_point, pop_sig) = sign_pop(&sk).expect("sign_pop"); + assert_eq!( + hex::encode(pk), + "0000000000000000000000000000000004ab31668afb74bfbb84fbc4602c783fd13fc95b20daa51cd45c0b9b82296c60217516d0e959cf91462b0068ff13e37e000000000000000000000000000000000fa6ffcfdfec5259fb7b7c46ea447b793035e023f6fe0dd5c5f9ff2204e84fbc58501257f4ea9827373a0764770438a8", + "publicKey mismatch vs SDK buildDvtPop" + ); + assert_eq!( + hex::encode(pop_point), + "000000000000000000000000000000000fd9cfdad02fa76f28f830742c9f13818cd7b2a73d7851ed3a57e679e98342be611a5bd0db54f1d9964706d16619cb090000000000000000000000000000000017af8fb2319cdf43f51d53c11f7532eddb07de1c8ed99c4514e3bb7775fc27062db7b7f4e19ff04cde3b49ba60dfb682000000000000000000000000000000000cefd3d7b3e70a87cca1e334eae75058a9b8fb1ebfa6b386ed6186f6f1924eab626f1434dc961054c97676ad77844f6b00000000000000000000000000000000073323e0018b6743e1963db45fee1d0455dbca4f929a9c968290fc16cdd0e848427ff4d26f212a28f459dadc93a86fb0", + "popPoint mismatch vs SDK buildDvtPop" + ); + assert_eq!( + hex::encode(pop_sig), + "0000000000000000000000000000000005b25aff113df19e14c5c4b6c4205863870a507ed8ca1daebb2959daa37da38ee493492245baa33650384e3dd9c3ca4d000000000000000000000000000000000cd5e829ccdf4f493281f3793f0ca111baa0b09404831b4e02f58fab87e5cf7e1c23ced4abffc03d9a5c2e99b63361830000000000000000000000000000000011ba087642fe31336872ed6be3fac5c8f89a06424e6d7fb01ee548596903370080a83c88933560aa4d6b5b81f681cc910000000000000000000000000000000012217f660bd95795146327ca4893044d5959400858710caf8f532d9b32e66e552b8ef5f005799b6f8b38a3371da569b7", + "popSig mismatch vs SDK buildDvtPop" + ); + } +} diff --git a/kms/ta/src/main.rs b/kms/ta/src/main.rs index ccf4710f..14d37ac4 100644 --- a/kms/ta/src/main.rs +++ b/kms/ta/src/main.rs @@ -1492,6 +1492,22 @@ fn bls_sign(input: &proto::BlsSignInput) -> Result { }) } +/// CC-37 staked registration: BLS proof-of-possession (RFC-standard self-PoP over the node's +/// OWN pubkey). Derives the pubkey from the sealed key and signs it under BLS_DST — the caller +/// supplies no message, so /pop is not a signing oracle. Returns the DvtPop tuple. +fn bls_pop_sign(input: &proto::BlsPopSignInput) -> Result { + let db = open_storage()?; + let k = db + .get::(&input.key_id.to_string()) + .map_err(|_| anyhow!("BLS key not found: {}", input.key_id))?; + let (public_key, pop_point, pop_signature) = bls::sign_pop(&k.private_key)?; + Ok(proto::BlsPopSignOutput { + public_key: public_key.to_vec(), + pop_point: pop_point.to_vec(), + pop_signature: pop_signature.to_vec(), + }) +} + /// 返回密封 BLS 密钥的 48B 压缩公钥。 fn bls_pubkey(input: &proto::BlsPubKeyInput) -> Result { let db = open_storage()?; @@ -2743,6 +2759,7 @@ fn handle_invoke(command: Command, serialized_input: &[u8]) -> Result> { Command::GetAttestation => process(serialized_input, attestation::get_attestation), Command::BlsGenKey => process(serialized_input, bls_gen_key), Command::BlsSign => process(serialized_input, bls_sign), + Command::BlsPopSign => process(serialized_input, bls_pop_sign), Command::BlsPubKey => process(serialized_input, bls_pubkey), Command::BlsRemove => process(serialized_input, bls_remove), Command::KeeperGenKey => process(serialized_input, keeper_gen_key),