From fefab8467f55ba7be04f6ef999142622ab1a29a5 Mon Sep 17 00:00:00 2001 From: jhfnetboy Date: Sat, 11 Jul 2026 00:05:05 +0700 Subject: [PATCH 1/4] feat(kms): self-init device-auth de-root + BLS PoP /pop for staked registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CC-24/CC-25 follow-ups: 1) self-init encodes the device-auth hardening so B-board/community nodes de-root automatically (not just the hand-hardened A-board): creates dedicated kms (in tee/teepriv) + dvt (no TEE) users, migrates the kms deploy off /root, writes both de-root drop-ins (kms: User=kms + DevicePolicy=closed + DeviceAllow=/dev/tee*; dvt: User=dvt), health-gated with auto-rollback so it can never brick first boot. Also fixes the handoff KEEPER_SIGNER_URL (emit the loopback BASE, not …/kms/sign — DVT appends the path) and adds KEEPER_ID. 2) BLS proof-of-possession endpoint POST /pop {node_id, operator} -> {pop_signature} for the staked validator path (registerWithProof). The TA signs the OPERATOR address under POP_DST (AASTAR_DVT_POP_…) — an operator-bound message the KMS chooses, NEVER a caller-supplied point, so /pop can't be a forgery oracle. New proto BlsPopSign=34 + TA bls::sign_pop + ta_client + host route (loopback+token). Builds: proto 43 tests, TA aarch64-unknown-optee, CA aarch64-linux-gnu all green. Activating /pop on the A-board needs a TA reflash + DVT register-node.mjs sending the operator (not pop_point) — coordinated rollout, not in this commit. --- kms/host/src/api_server.rs | 82 ++++++++++++++++++++++ kms/host/src/ta_client.rs | 22 ++++++ kms/node-setup/aastar-kms-selfinit.sh | 98 +++++++++++++++++++++++++-- kms/proto/src/in_out.rs | 19 ++++++ kms/proto/src/lib.rs | 11 ++- kms/scripts/register-bls-node.sh | 77 +++++++++++++++++++++ kms/ta/src/bls.rs | 17 +++++ kms/ta/src/main.rs | 16 +++++ 8 files changed, 336 insertions(+), 6 deletions(-) create mode 100755 kms/scripts/register-bls-node.sh diff --git a/kms/host/src/api_server.rs b/kms/host/src/api_server.rs index 5843867e..2fcc7d7e 100644 --- a/kms/host/src/api_server.rs +++ b/kms/host/src/api_server.rs @@ -5912,6 +5912,26 @@ struct BlsGenResp { public_key: String, } +// CC-24 staked registration: BLS proof-of-possession. DVT's register-node.mjs POSTs the +// OPERATOR address (not a caller-chosen point); the TA signs sk·hashToG2(operator, POP_DST), +// so /pop is operator-bound and can never be used as a signing oracle. Loopback + token. +#[derive(serde::Deserialize)] +struct PopSignReq { + #[allow(dead_code)] + node_id: String, + /// 20-byte operator EOA (hex, 0x-optional) — msg.sender of registerWithProof. + operator: String, +} + +#[derive(serde::Serialize)] +struct PopSignResp { + /// EIP-2537 uncompressed G2 PoP signature (256B) — feeds registerWithProof's popSig. + pop_signature: String, + /// Compressed G2 (96B). + pop_signature_compact: String, + public_key: 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. @@ -6140,6 +6160,57 @@ async fn bls_sign_handler( } } +/// CC-24 staked registration: sign a BLS proof-of-possession over the operator address 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 the operator under POP_DST (never a caller-supplied point). +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(), + ))) + } + }; + let pk_hex = match std::env::var("KMS_BLS_PUBKEY") { + Ok(p) if !p.is_empty() => p, + _ => { + return Err(warp::reject::custom(ApiError( + "KMS_BLS_PUBKEY not configured".into(), + ))) + } + }; + let ob = match hex::decode(req.operator.trim_start_matches("0x")) { + Ok(b) if b.len() == 20 => b, + _ => { + return Err(warp::reject::custom(ApiError( + "operator must be a 20-byte address hex".into(), + ))) + } + }; + let mut operator = [0u8; 20]; + operator.copy_from_slice(&ob); + match server.tee.bls_pop_sign(key_id, operator).await { + Ok((sig, compact)) => Ok(warp::reply::json(&PopSignResp { + pop_signature: format!("0x{}", hex::encode(sig)), + pop_signature_compact: hex::encode(compact), + public_key: pk_hex, + })), + 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 +7265,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 +7313,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..1f952645 100644 --- a/kms/host/src/ta_client.rs +++ b/kms/host/src/ta_client.rs @@ -649,6 +649,28 @@ impl TeeHandle { Ok((output.signature, output.signature_compact)) } + /// BLS proof-of-possession over the 20-byte operator address (POP_DST) — CC-24 staked + /// registration. Returns (EIP-2537 G2 256B, compact G2 96B). The TA signs the operator, + /// never a caller-supplied point, so this is not a general signing oracle. + pub async fn bls_pop_sign( + &self, + key_id: uuid::Uuid, + operator: [u8; 20], + ) -> Result<(Vec, Vec)> { + let input = bincode::serialize(&proto::BlsPopSignInput { key_id, operator }) + .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.signature.len() == 256 && output.signature_compact.len() == 96, + "BLS PoP signature length invalid (EIP-2537 {} want 256, compact {} want 96)", + output.signature.len(), + output.signature_compact.len() + ); + Ok((output.signature, output.signature_compact)) + } + /// 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..e04259f4 100755 --- a/kms/node-setup/aastar-kms-selfinit.sh +++ b/kms/node-setup/aastar-kms-selfinit.sh @@ -83,12 +83,86 @@ 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 +} 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 + { _in_group dvt tee || _in_group dvt teepriv; } && log "WARN: dvt user is in a tee group — defeats the hardening; review manually" + + # 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)" + 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 +283,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 +302,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; then + log "kms-api up as non-root kms (device-auth hardened)" +else + log "WARN: kms-api did not come up 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..2f0f5573 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-24 staked registration: BLS proof-of-possession over the OPERATOR address. +// The TA signs `operator` under the PoP DST (AASTAR_DVT_POP_…) — an operator-bound +// message the KMS chooses, NOT an arbitrary caller-provided point, so /pop cannot be +// used as a signing oracle to forge signatures on chosen messages. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct BlsPopSignInput { + pub key_id: Uuid, + /// 20-byte operator EOA the node is bound to (msg.sender of registerWithProof). + pub operator: [u8; 20], +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct BlsPopSignOutput { + /// EIP-2537 uncompressed G2 PoP signature (256 bytes) = sk · hashToG2(operator, POP_DST). + pub signature: Vec, + /// Compressed G2 (96 bytes). + pub signature_compact: 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..6c2deebc --- /dev/null +++ b/kms/scripts/register-bls-node.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# register-bls-node.sh — register the co-located DVT node's BLS G1 pubkey on the +# validator contract (CC-24/CC-34). This is the one on-chain step "power-on self-run" +# can't do itself: it 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..e6d05f3e 100644 --- a/kms/ta/src/bls.rs +++ b/kms/ta/src/bls.rs @@ -24,6 +24,23 @@ pub fn sign(sk_bytes: &[u8; 32], message: &[u8; 32]) -> Result<([u8; 256], [u8; Ok((eip2537, compact)) } +/// PoP domain — MUST equal DVT register-node.mjs POP_DST (AASTAR_DVT_POP_…). Distinct from +/// BLS_DST above: the on-chain _verifyPoP checks the signature under THIS domain, and using a +/// separate domain stops a PoP from ever being mistaken for (or reused as) a co-sign signature. +pub const POP_DST: &[u8] = b"AASTAR_DVT_POP_BLS12381G2_XMD:SHA-256_SSWU_RO_"; + +/// CC-24 staked registration: proof-of-possession. Sign the 20-byte OPERATOR address under +/// POP_DST (= sk · hashToG2(operator, POP_DST)). The message is the operator (not a +/// caller-supplied point), so this can never be used as a general signing oracle. Returns +/// (EIP-2537 uncompressed G2 256B, compressed G2 96B) — byte-identical to DVT/@noble. +pub fn sign_pop(sk_bytes: &[u8; 32], operator: &[u8; 20]) -> Result<([u8; 256], [u8; 96])> { + let sk = SecretKey::from_bytes(sk_bytes).map_err(|e| anyhow!("BLS sk from_bytes: {:?}", e))?; + let sig: Signature = sk.sign(operator, POP_DST, &[]); + let compact = sig.compress(); + let eip2537 = encode_g2_eip2537(&sig.serialize()); + Ok((eip2537, compact)) +} + /// 从密封私钥字节恢复 48B 压缩 G1 公钥(校验/恢复用,handler 走存储的公钥)。 #[allow(dead_code)] pub fn pubkey(sk_bytes: &[u8; 32]) -> Result<[u8; 48]> { diff --git a/kms/ta/src/main.rs b/kms/ta/src/main.rs index ccf4710f..accf26ae 100644 --- a/kms/ta/src/main.rs +++ b/kms/ta/src/main.rs @@ -1492,6 +1492,21 @@ fn bls_sign(input: &proto::BlsSignInput) -> Result { }) } +/// CC-24 staked registration: BLS proof-of-possession over the operator address (POP_DST). +/// Signs the caller-supplied 20-byte OPERATOR (an operator-bound message the KMS chooses), +/// never an arbitrary point — so /pop cannot forge signatures on chosen messages. +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 (eip2537, compact) = bls::sign_pop(&k.private_key, &input.operator)?; + Ok(proto::BlsPopSignOutput { + signature: eip2537.to_vec(), + signature_compact: compact.to_vec(), + }) +} + /// 返回密封 BLS 密钥的 48B 压缩公钥。 fn bls_pubkey(input: &proto::BlsPubKeyInput) -> Result { let db = open_storage()?; @@ -2743,6 +2758,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), From 5099de452007685b97f43907780bfbd775768762 Mon Sep 17 00:00:00 2001 From: jhfnetboy Date: Sat, 11 Jul 2026 00:15:20 +0700 Subject: [PATCH 2/4] review(codex): TA-backed de-root health gate + dvt cgroup lockdown + ct_eq token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex adversarial review fixes: - H1: the de-root health gate only checked :3100/health (static JSON liveness) — it passed even if kms-api couldn't open /dev/tee0. Now gated on a real BLS sign (tee_reachable) so a broken de-root rolls back instead of marking a dead signer done. - H2: dvt-in-tee-group is now actively removed (gpasswd -d), and the dvt drop-in gets DevicePolicy=closed (no DeviceAllow) so the network-facing node can never reach /dev/tee* even on a group leak. - M4: || true on the ExecStart extraction so a no-match can't abort harden under set -e. - L5: check_signer_token now uses ct_eq (constant-time), matching keeper/remove paths. - L6: register-bls-node.sh documented BOOTSTRAP-ONLY; staked path points to DVT register-node.mjs + the new /pop. CA rebuilds green; self-init syntax OK. --- kms/host/src/api_server.rs | 8 +++++++- kms/node-setup/aastar-kms-selfinit.sh | 27 +++++++++++++++++++++------ kms/scripts/register-bls-node.sh | 17 +++++++++++++---- 3 files changed, 41 insertions(+), 11 deletions(-) diff --git a/kms/host/src/api_server.rs b/kms/host/src/api_server.rs index 2fcc7d7e..dcb2cd3b 100644 --- a/kms/host/src/api_server.rs +++ b/kms/host/src/api_server.rs @@ -6040,7 +6040,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( diff --git a/kms/node-setup/aastar-kms-selfinit.sh b/kms/node-setup/aastar-kms-selfinit.sh index e04259f4..734e5811 100755 --- a/kms/node-setup/aastar-kms-selfinit.sh +++ b/kms/node-setup/aastar-kms-selfinit.sh @@ -91,6 +91,16 @@ wait_signer_soft() { 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 @@ -108,12 +118,14 @@ harden_device_auth() { 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 - { _in_group dvt tee || _in_group dvt teepriv; } && log "WARN: dvt user is in a tee group — defeats the hardening; review manually" + # 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)" + 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/*) @@ -154,7 +166,10 @@ EOF local ddir; ddir="$(systemctl show -p WorkingDirectory --value dvt.service 2>/dev/null)" { [ -z "$ddir" ] || [ "$ddir" = "[not set]" ]; } && ddir=/opt/dvt-build mkdir -p /etc/systemd/system/dvt.service.d - printf '[Service]\nUser=dvt\nGroup=dvt\n' > /etc/systemd/system/dvt.service.d/deroot.conf + # DevicePolicy=closed with NO DeviceAllow → dvt's cgroup reaches only systemd's default + # devices (/dev/null,zero,random,urandom,…) and NEVER /dev/tee* — even if a group leak + # ever put dvt back in tee. Node is pure JS; it needs no special device. + printf '[Service]\nUser=dvt\nGroup=dvt\nDevicePolicy=closed\n' > /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 @@ -309,10 +324,10 @@ systemctl daemon-reload 2>/dev/null || true 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; then - log "kms-api up as non-root kms (device-auth hardened)" +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 did not come up hardened — rolling back de-root, staying on prior config" + log "WARN: kms-api not healthy+TEE-reachable hardened — rolling back de-root, staying on prior config" unharden_kms restart_kms 1 wait_signer diff --git a/kms/scripts/register-bls-node.sh b/kms/scripts/register-bls-node.sh index 6c2deebc..0bc48bbe 100755 --- a/kms/scripts/register-bls-node.sh +++ b/kms/scripts/register-bls-node.sh @@ -1,8 +1,17 @@ #!/usr/bin/env bash -# register-bls-node.sh — register the co-located DVT node's BLS G1 pubkey on the -# validator contract (CC-24/CC-34). This is the one on-chain step "power-on self-run" -# can't do itself: it 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. +# 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) From ee62aeb1725faee0940e57b0f6edeab24e2e3c44 Mon Sep 17 00:00:00 2001 From: jhfnetboy Date: Sat, 11 Jul 2026 00:47:39 +0700 Subject: [PATCH 3/4] fix(kms): /pop to SDK canonical PoP convention (self-PoP over pubkey) + golden test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewing CC-37 (SDK) against ground truth showed my first /pop cut followed DVT's register-node.mjs convention (operator + AASTAR_DVT_POP_ DST), which diverges from the authoritative, on-chain-verified SDK buildDvtPop (live tx 0xa641534f). The contract _verifyPoP is message-agnostic so both pass mathematically, but the ecosystem standard is SDK's RFC self-PoP. Reworked to match it: - TA bls::sign_pop now signs the node's OWN 128B EIP-2537 pubkey under BLS_DST (…_POP_), returning the full DvtPop tuple (publicKey, popPoint, popSig). New encode_g1_eip2537 + hash_to_g2_eip2537 (blst_hash_to_g2, same RFC 9380 SSWU as @noble). - Safer than the operator variant: the message is the node's own pubkey (caller supplies NONE), so /pop is not a signing oracle; and a 128B pubkey can't collide with a 32B co-sign userOpHash, so a PoP can't be replayed as a co-sign sig despite the shared DST. - proto BlsPopSignInput drops operator (just key_id); output is the tuple. Host POST /pop {node_id?} -> {publicKey, popPoint, popSig}. - GOLDEN: standalone blst vs SDK buildDvtPop(0x…c0ffee) => publicKey + popPoint + popSig byte-identical. Locked as bls.rs #[test] sign_pop_matches_sdk_builddvtpop. proto 43 tests + TA aarch64 + CA aarch64 all green. --- kms/host/src/api_server.rs | 56 ++++++++--------------- kms/host/src/ta_client.rs | 24 +++++----- kms/proto/src/in_out.rs | 20 ++++---- kms/ta/src/bls.rs | 93 ++++++++++++++++++++++++++++++++------ kms/ta/src/main.rs | 13 +++--- 5 files changed, 129 insertions(+), 77 deletions(-) diff --git a/kms/host/src/api_server.rs b/kms/host/src/api_server.rs index dcb2cd3b..a4408a7b 100644 --- a/kms/host/src/api_server.rs +++ b/kms/host/src/api_server.rs @@ -5912,24 +5912,24 @@ struct BlsGenResp { public_key: String, } -// CC-24 staked registration: BLS proof-of-possession. DVT's register-node.mjs POSTs the -// OPERATOR address (not a caller-chosen point); the TA signs sk·hashToG2(operator, POP_DST), -// so /pop is operator-bound and can never be used as a signing oracle. Loopback + token. +// 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: String, - /// 20-byte operator EOA (hex, 0x-optional) — msg.sender of registerWithProof. - operator: String, + node_id: Option, } #[derive(serde::Serialize)] struct PopSignResp { - /// EIP-2537 uncompressed G2 PoP signature (256B) — feeds registerWithProof's popSig. - pop_signature: String, - /// Compressed G2 (96B). - pop_signature_compact: String, + /// G1 pubkey, 128B EIP-2537 (registerWithProof `publicKey`). public_key: String, + /// hashToCurve(publicKey, BLS_DST), 256B EIP-2537 G2 (registerWithProof `popPoint`). + pop_point: String, + /// sk·popPoint, 256B EIP-2537 G2 (registerWithProof `popSig`). + pop_signature: String, } // ── CC-34: keeper/operator ECDSA(secp256k1) signer — mirrors the BLS signer on @@ -6166,11 +6166,11 @@ async fn bls_sign_handler( } } -/// CC-24 staked registration: sign a BLS proof-of-possession over the operator address 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 the operator under POP_DST (never a caller-supplied point). +/// 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, + _req: PopSignReq, token: Option, server: Arc, ) -> Result { @@ -6186,29 +6186,11 @@ async fn pop_sign_handler( ))) } }; - let pk_hex = match std::env::var("KMS_BLS_PUBKEY") { - Ok(p) if !p.is_empty() => p, - _ => { - return Err(warp::reject::custom(ApiError( - "KMS_BLS_PUBKEY not configured".into(), - ))) - } - }; - let ob = match hex::decode(req.operator.trim_start_matches("0x")) { - Ok(b) if b.len() == 20 => b, - _ => { - return Err(warp::reject::custom(ApiError( - "operator must be a 20-byte address hex".into(), - ))) - } - }; - let mut operator = [0u8; 20]; - operator.copy_from_slice(&ob); - match server.tee.bls_pop_sign(key_id, operator).await { - Ok((sig, compact)) => Ok(warp::reply::json(&PopSignResp { - pop_signature: format!("0x{}", hex::encode(sig)), - pop_signature_compact: hex::encode(compact), - public_key: pk_hex, + 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: {}", diff --git a/kms/host/src/ta_client.rs b/kms/host/src/ta_client.rs index 1f952645..c8cac773 100644 --- a/kms/host/src/ta_client.rs +++ b/kms/host/src/ta_client.rs @@ -649,26 +649,28 @@ impl TeeHandle { Ok((output.signature, output.signature_compact)) } - /// BLS proof-of-possession over the 20-byte operator address (POP_DST) — CC-24 staked - /// registration. Returns (EIP-2537 G2 256B, compact G2 96B). The TA signs the operator, - /// never a caller-supplied point, so this is not a general signing oracle. + /// 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, - operator: [u8; 20], - ) -> Result<(Vec, Vec)> { - let input = bincode::serialize(&proto::BlsPopSignInput { key_id, operator }) + ) -> 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.signature.len() == 256 && output.signature_compact.len() == 96, - "BLS PoP signature length invalid (EIP-2537 {} want 256, compact {} want 96)", - output.signature.len(), - output.signature_compact.len() + 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.signature, output.signature_compact)) + Ok((output.public_key, output.pop_point, output.pop_signature)) } /// Return the sealed BLS key's 48B compressed G1 public key. diff --git a/kms/proto/src/in_out.rs b/kms/proto/src/in_out.rs index 2f0f5573..def6b96b 100644 --- a/kms/proto/src/in_out.rs +++ b/kms/proto/src/in_out.rs @@ -711,21 +711,21 @@ pub struct BlsRemoveOutput { pub removed: u32, } -// CC-24 staked registration: BLS proof-of-possession over the OPERATOR address. -// The TA signs `operator` under the PoP DST (AASTAR_DVT_POP_…) — an operator-bound -// message the KMS chooses, NOT an arbitrary caller-provided point, so /pop cannot be -// used as a signing oracle to forge signatures on chosen messages. +// 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, - /// 20-byte operator EOA the node is bound to (msg.sender of registerWithProof). - pub operator: [u8; 20], } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct BlsPopSignOutput { - /// EIP-2537 uncompressed G2 PoP signature (256 bytes) = sk · hashToG2(operator, POP_DST). - pub signature: Vec, - /// Compressed G2 (96 bytes). - pub signature_compact: Vec, + /// 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/ta/src/bls.rs b/kms/ta/src/bls.rs index e6d05f3e..86d2ab46 100644 --- a/kms/ta/src/bls.rs +++ b/kms/ta/src/bls.rs @@ -24,21 +24,55 @@ pub fn sign(sk_bytes: &[u8; 32], message: &[u8; 32]) -> Result<([u8; 256], [u8; Ok((eip2537, compact)) } -/// PoP domain — MUST equal DVT register-node.mjs POP_DST (AASTAR_DVT_POP_…). Distinct from -/// BLS_DST above: the on-chain _verifyPoP checks the signature under THIS domain, and using a -/// separate domain stops a PoP from ever being mistaken for (or reused as) a co-sign signature. -pub const POP_DST: &[u8] = b"AASTAR_DVT_POP_BLS12381G2_XMD:SHA-256_SSWU_RO_"; +/// 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-24 staked registration: proof-of-possession. Sign the 20-byte OPERATOR address under -/// POP_DST (= sk · hashToG2(operator, POP_DST)). The message is the operator (not a -/// caller-supplied point), so this can never be used as a general signing oracle. Returns -/// (EIP-2537 uncompressed G2 256B, compressed G2 96B) — byte-identical to DVT/@noble. -pub fn sign_pop(sk_bytes: &[u8; 32], operator: &[u8; 20]) -> Result<([u8; 256], [u8; 96])> { +/// 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 sig: Signature = sk.sign(operator, POP_DST, &[]); - let compact = sig.compress(); - let eip2537 = encode_g2_eip2537(&sig.serialize()); - Ok((eip2537, compact)) + 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 走存储的公钥)。 @@ -67,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 accf26ae..14d37ac4 100644 --- a/kms/ta/src/main.rs +++ b/kms/ta/src/main.rs @@ -1492,18 +1492,19 @@ fn bls_sign(input: &proto::BlsSignInput) -> Result { }) } -/// CC-24 staked registration: BLS proof-of-possession over the operator address (POP_DST). -/// Signs the caller-supplied 20-byte OPERATOR (an operator-bound message the KMS chooses), -/// never an arbitrary point — so /pop cannot forge signatures on chosen messages. +/// 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 (eip2537, compact) = bls::sign_pop(&k.private_key, &input.operator)?; + let (public_key, pop_point, pop_signature) = bls::sign_pop(&k.private_key)?; Ok(proto::BlsPopSignOutput { - signature: eip2537.to_vec(), - signature_compact: compact.to_vec(), + public_key: public_key.to_vec(), + pop_point: pop_point.to_vec(), + pop_signature: pop_signature.to_vec(), }) } From c61afd8a7faee8a7f760f4c262a21972a0887945 Mon Sep 17 00:00:00 2001 From: jhfnetboy Date: Sat, 11 Jul 2026 00:58:34 +0700 Subject: [PATCH 4/4] fix(kms): /pop response keys to camelCase (publicKey/popPoint/popSig) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-repo contract fix: DVT register-node.mjs (#214) reads { popPoint, popSig } and SDK's DvtPop is { publicKey, popPoint, popSig } (camelCase). The response was emitting snake_case (pop_point/pop_signature) → consumers would read undefined and fail. serde rename to the exact camelCase keys the consumers parse. (The earlier REQUEST-CHANGES review's DST/message/input points were against the pre-ee62aeb operator version; those were already reworked to SDK's pubkey-self-PoP — this field-naming was the real residual.) --- kms/host/src/api_server.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/kms/host/src/api_server.rs b/kms/host/src/api_server.rs index a4408a7b..cd695544 100644 --- a/kms/host/src/api_server.rs +++ b/kms/host/src/api_server.rs @@ -5922,13 +5922,18 @@ struct PopSignReq { 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, }