Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 71 additions & 1 deletion kms/host/src/api_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5912,6 +5912,26 @@
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<String>,
}

#[derive(serde::Serialize)]
struct PopSignResp {
/// 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
// 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.
Expand Down Expand Up @@ -6020,7 +6040,13 @@
fn check_signer_token(token: &Option<String>) -> 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(
Expand Down Expand Up @@ -6076,7 +6102,7 @@
return Err(warp::reject::custom(ApiError(
"BLS remove disabled (destructive; set KMS_BLS_ALLOW_REMOVE=1 to enable)".into(),
)));
}

Check warning on line 6105 in kms/host/src/api_server.rs

View workflow job for this annotation

GitHub Actions / lint

Diff in /home/runner/work/AirAccount/AirAccount/kms/host/src/api_server.rs
check_signer_token_required(&token)?; // fail-closed (not the tokenless gen-key default)
match server.tee.bls_remove().await {
Ok(removed) => Ok(warp::reply::json(&serde_json::json!({ "removed": removed }))),
Expand Down Expand Up @@ -6140,6 +6166,39 @@
}
}

/// 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<String>,
server: Arc<KmsApiServer>,
) -> Result<impl warp::Reply, warp::Rejection> {
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
Expand Down Expand Up @@ -7133,7 +7192,7 @@
hex::decode(h).ok().filter(|b| b.len() == 20).map(|b| {
let mut a = [0u8; 20];
a.copy_from_slice(&b);
a

Check warning on line 7195 in kms/host/src/api_server.rs

View workflow job for this annotation

GitHub Actions / lint

Diff in /home/runner/work/AirAccount/AirAccount/kms/host/src/api_server.rs
})
});
let asserted_raw = std::env::var("KMS_KEEPER_ADDRESS").ok().filter(|s| !s.trim().is_empty());
Expand Down Expand Up @@ -7194,6 +7253,16 @@
.and(warp::header::optional::<String>("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::<String>("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"))
Expand Down Expand Up @@ -7232,10 +7301,11 @@
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)
.or(keeper_gen_route)

Check warning on line 7308 in kms/host/src/api_server.rs

View workflow job for this annotation

GitHub Actions / lint

Diff in /home/runner/work/AirAccount/AirAccount/kms/host/src/api_server.rs
.or(bls_health)
.recover(handle_rejection);
println!("🔏 Internal BLS signer (DVT) on http://127.0.0.1:3100 (localhost only, not via tunnel)");
Expand Down
24 changes: 24 additions & 0 deletions kms/host/src/ta_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -631,7 +631,7 @@
output.public_key.len()
);
Ok(output.public_key)
}

Check warning on line 634 in kms/host/src/ta_client.rs

View workflow job for this annotation

GitHub Actions / lint

Diff in /home/runner/work/AirAccount/AirAccount/kms/host/src/ta_client.rs

/// BLS-sign a 32-byte message with the sealed key. Returns (EIP-2537 G2 256B, compact G2 96B).
pub async fn bls_sign(&self, key_id: uuid::Uuid, message: [u8; 32]) -> Result<(Vec<u8>, Vec<u8>)> {
Expand All @@ -649,6 +649,30 @@
Ok((output.signature, output.signature_compact))
}

/// BLS proof-of-possession (RFC self-PoP over the node's own pubkey) — CC-37 staked

Check warning on line 652 in kms/host/src/ta_client.rs

View workflow job for this annotation

GitHub Actions / lint

Diff in /home/runner/work/AirAccount/AirAccount/kms/host/src/ta_client.rs
/// 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<u8>, Vec<u8>, Vec<u8>)> {
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<Vec<u8>> {
let input = bincode::serialize(&proto::BlsPubKeyInput { key_id })
Expand Down
113 changes: 109 additions & 4 deletions kms/node-setup/aastar-kms-selfinit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<EOF
# device-auth hardening (aastar-kms-selfinit): non-root kms user in tee/teepriv groups;
# DevicePolicy=closed restricts even this cgroup to the two OP-TEE devices.
[Service]
User=kms
Group=kms
SupplementaryGroups=tee teepriv
WorkingDirectory=$kdir
ExecStart=
ExecStart=$kexec
StandardOutput=journal
StandardError=journal
NoNewPrivileges=true
PrivateTmp=true
DevicePolicy=closed
DeviceAllow=/dev/tee0 rw
DeviceAllow=/dev/teepriv0 rw
EOF

# 4. dvt de-root drop-in: User=dvt (NO tee groups). Applied whenever dvt.service exists
# (harmless if DVT is deployed later — the drop-in waits). chown DVT's dir if present.
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
# 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
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)"
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand Down
19 changes: 19 additions & 0 deletions kms/proto/src/in_out.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8>,
/// hashToCurve(publicKey, BLS_DST) as 256-byte EIP-2537 G2 (registerWithProof's `popPoint`).
pub pop_point: Vec<u8>,
/// sk · popPoint as 256-byte EIP-2537 G2 (registerWithProof's `popSig`).
pub pop_signature: Vec<u8>,
}
11 changes: 9 additions & 2 deletions kms/proto/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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);
Expand All @@ -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<u32> = (0u32..=33)
let all: Vec<u32> = (0u32..=34)
.filter(|&i| !matches!(Command::from(i), Command::Unknown))
.collect();
let mut dedup = all.clone();
Expand Down
Loading
Loading