inherence-verifier 0.1.0

Reference verifier for Inherence receipts (verification protocol v1).
Documentation
//! SPEC §6.1 — EIP-712 principal-signature recovery.
//!
//! Recovers the signer address from a 65-byte `0x…` Ethereum-style
//! signature over the contract digest (32 bytes), and compares to the
//! address embedded in `did:ethr:0x…`.

use k256::ecdsa::{RecoveryId, Signature, VerifyingKey};
use tiny_keccak::{Hasher, Keccak};

use crate::error::VerifyError;

/// Recover the 0x-prefixed Ethereum address that produced `sig_hex`
/// over the EIP-712 digest `contract_hash_hex`. Both are 0x-prefixed
/// hex; the signature is 65 bytes (r || s || v).
pub fn recover_address(contract_hash_hex: &str, sig_hex: &str) -> Result<String, VerifyError> {
    let digest = hex32(contract_hash_hex)?;
    let sig_bytes = decode_hex(sig_hex)?;
    if sig_bytes.len() != 65 {
        return Err(VerifyError::SchemaViolation(
            format!("expected 65-byte signature, got {}", sig_bytes.len())));
    }
    let mut v = sig_bytes[64];
    // Ethereum-style v is 27/28; k256's RecoveryId wants 0/1.
    if v >= 27 { v -= 27; }
    let recid = RecoveryId::try_from(v).map_err(|e|
        VerifyError::SchemaViolation(format!("bad recovery id: {e}")))?;
    let sig = Signature::from_slice(&sig_bytes[..64]).map_err(|e|
        VerifyError::SchemaViolation(format!("bad signature: {e}")))?;
    let recovered = VerifyingKey::recover_from_prehash(&digest, &sig, recid)
        .map_err(|_| VerifyError::PrincipalSignatureInvalid("principal"))?;
    let encoded = recovered.to_encoded_point(false);
    let pubkey_bytes = encoded.as_bytes();
    // pubkey_bytes[0] == 0x04, then 64 bytes (x||y) of the point
    if pubkey_bytes.len() != 65 || pubkey_bytes[0] != 0x04 {
        return Err(VerifyError::PrincipalSignatureInvalid("principal"));
    }
    let mut hasher = Keccak::v256();
    let mut hash = [0u8; 32];
    hasher.update(&pubkey_bytes[1..]);
    hasher.finalize(&mut hash);
    Ok(format!("0x{}", hex::encode(&hash[12..])))
}

/// Extract the 20-byte address from `did:ethr:0xADDRESS`.
pub fn address_from_did_ethr(did: &str) -> String {
    let prefix = "did:ethr:";
    if let Some(rest) = did.strip_prefix(prefix) {
        return rest.to_string();
    }
    did.to_string()
}

fn hex32(s: &str) -> Result<[u8; 32], VerifyError> {
    let stripped = s.trim_start_matches("0x");
    let bytes = hex::decode(stripped).map_err(|_|
        VerifyError::SchemaViolation(format!("not hex: {s:?}")))?;
    if bytes.len() != 32 {
        return Err(VerifyError::SchemaViolation(
            format!("expected 32 bytes, got {}", bytes.len())));
    }
    let mut arr = [0u8; 32];
    arr.copy_from_slice(&bytes);
    Ok(arr)
}

fn decode_hex(s: &str) -> Result<Vec<u8>, VerifyError> {
    let stripped = s.trim_start_matches("0x");
    hex::decode(stripped).map_err(|_|
        VerifyError::SchemaViolation(format!("not hex: {s:?}")))
}