node-app-build 5.28.0

Mini app developer CLI: scaffold, validate, package node-app-* Debian packages
//! BIP39 seed → secp256k1 keypair + ECDSA-DER signing.
//!
//! Byte-for-byte mirror of `tests/e2e/src/harness/auth.ts:138-167`:
//!   secretKey = mnemonicToSeedSync(mnemonic).subarray(0, 32)   // NOT BIP32
//!   publicKey = secp256k1.getPublicKey(secretKey, compressed)
//!   signature = secp256k1.sign(sha256(challenge), secretKey).toDERRawBytes()
//!
//! The auth handler on the server side performs the inverse: SHA-256 the
//! challenge string and ECDSA-verify the DER signature against the
//! compressed pubkey. Any deviation here (BIP32 derivation, schnorr,
//! compact sig) will silently 401.
//!
//! No randomness, no clock — the only entropy source is `bip39::Mnemonic::generate`.

use anyhow::{anyhow, Context, Result};
use bip39::{Language, Mnemonic};
use secp256k1::{ecdsa, Message, PublicKey, Secp256k1, SecretKey};
use sha2::{Digest, Sha256};

/// Identity derived from a BIP39 mnemonic.
pub struct AgentIdentity {
    pub mnemonic: String,
    pub secret_key_hex: String,
    /// Compressed (33-byte) secp256k1 public key, hex-encoded.
    pub public_key_hex: String,
}

impl AgentIdentity {
    /// Generate a fresh 12-word BIP39 mnemonic and derive a usable secp256k1
    /// keypair from `seed[0..32]`. Retries on the (astronomically unlikely)
    /// case where the slice isn't a valid secp256k1 scalar.
    pub fn generate() -> Result<Self> {
        for _ in 0..10 {
            let mnemonic = Mnemonic::generate_in(Language::English, 12)
                .context("bip39 mnemonic generation")?;
            if let Some(identity) = Self::from_mnemonic_inner(&mnemonic)? {
                return Ok(identity);
            }
        }
        Err(anyhow!(
            "failed to derive a valid secp256k1 keypair from 10 generated mnemonics"
        ))
    }

    /// Re-derive identity from a stored mnemonic phrase (login path).
    pub fn from_phrase(phrase: &str) -> Result<Self> {
        let mnemonic = Mnemonic::parse_in(Language::English, phrase)
            .with_context(|| "parse stored BIP39 mnemonic")?;
        Self::from_mnemonic_inner(&mnemonic)?.ok_or_else(|| {
            anyhow!(
                "stored mnemonic derives an invalid secp256k1 scalar — \
                 the saved session file is corrupt; delete it to re-onboard"
            )
        })
    }

    fn from_mnemonic_inner(mnemonic: &Mnemonic) -> Result<Option<Self>> {
        let seed = mnemonic.to_seed_normalized("");
        let secret_bytes: [u8; 32] = seed[..32]
            .try_into()
            .expect("bip39 seed is always 64 bytes");
        let secret_key = match SecretKey::from_slice(&secret_bytes) {
            Ok(sk) => sk,
            Err(_) => return Ok(None),
        };
        let secp = Secp256k1::signing_only();
        let public_key = PublicKey::from_secret_key(&secp, &secret_key);
        Ok(Some(Self {
            mnemonic: mnemonic.to_string(),
            secret_key_hex: hex::encode(secret_bytes),
            public_key_hex: hex::encode(public_key.serialize()),
        }))
    }

    /// ECDSA-sign SHA-256(challenge) and return the DER-encoded signature
    /// as hex (matching `signature.toDERRawBytes()` from noble/curves).
    pub fn sign_challenge(&self, challenge: &str) -> Result<String> {
        let secret_bytes = hex::decode(&self.secret_key_hex)
            .context("decode stored secret_key_hex")?;
        let secret_key =
            SecretKey::from_slice(&secret_bytes).context("rehydrate secp256k1 SecretKey")?;

        let mut hasher = Sha256::new();
        hasher.update(challenge.as_bytes());
        let digest = hasher.finalize();
        let message = Message::from_digest_slice(&digest)
            .context("wrap SHA-256 digest as secp256k1 Message")?;

        let secp = Secp256k1::signing_only();
        let signature: ecdsa::Signature = secp.sign_ecdsa(&message, &secret_key);
        Ok(hex::encode(signature.serialize_der().as_ref()))
    }
}