This article is aimed at Mina developers who wish to continue the work on zkNotary following our efforts in Mina Navigators Season 1. It is assumed that readers have a general knowledge of Rust and its key programming paradigms, such as enums and traits, as well as a basic understanding of the TLSNotary project. For an introduction to zkNotary, refer to the documentation available here. While an advanced cryptography background is not necessary, familiarity with basic concepts like digital signatures, hashing algorithms, Mina smart contracts, and the o1js library is expected.
The original zkNotary project was born during Mina zkIgnite's second cohort, that took place between October and December 2023. zkNotary aimed to create a developer-friendly layer for the TLSNotary project.
The idea was to abstract the complexities of TLSNotary away from zkApp developers, especially those related to the Rust programming environment. However, the original zkNotary project didn't offer the possibility of verifying the validity of a TLSNotary transcript from within a Mina smart contract using o1js.
In February 2024, we decided to extend the original scope of zkNotary to include the verification of TLSNotary transcripts, in provable code, using o1js. This meant solving two different challenges:
The signatures TLSNotary uses to sign its notarizations are ECDSA signatures on the SECP256R1 curve (aka P256).
So we had to choices:
Given that the we already had the Rust code for Mina Schnorr digital signatures from the mina-signer
crate, we decided to go with option 2. So we forked the original TLSNotary repository to our own repo and began refactoring it to meet our needs.
Note: We forked the original repo on February 14th 2024, so our forked version and the original one diverge exactly at commit
309c37fdecd719d8837ec617646a2d394ac3386f
. From now on, every time we link to any TLSNotary code it will be from our forked version, not the original one.
Most of the work was adding support for the Mina Schnorr signature from mina-signer
into TLSNotary's various Rust crates, namely:
notary-server
: executes the multi-party computation and generates the TLS session transcript.tlsn-core
: contains the core functionality for TLSNotary, including the signing mechanism.tlsn-verifier
: provides the means to verify the proof of correct notarization generated by notary-server.One of TLS Notary's most interesting and useful features is the possibility of redacting parts of the data from the notarization, also known as "selective disclosure". This means sensitive information (i.e. authentication tokens) can be excluded from the notarization and be kept private. TLSNotary does this by having the notary server sign commitments to the redacted data instead of the data itself.
Currently, the commitments are computed using the ChaCha algorithm, for which no JavaScript implementation is available. However, the team behind TLSNotary is actively working on redesigning the tlsn-core
crate to support alternative algorithms such as Poseidon hashes, among many other things.
Note: There's already a PR on TLSNotary's Github for this redesign: https://github.com/tlsnotary/tlsn/pull/463
Once this PR is merged into the main project, it will be possible to verify the redactions using o1js, as Poseidon is Mina's native hashing algorithm.
It is our recommendation to wait until this PR is merged before attempting to add the redaction feature to zkNotary.
The Notary Server (the crate notary-server
) signs every notarization using its private key. In TLSNotary's original implementation, only the SECP256R1 ECDSA signature scheme (aka P256) is supported.
In order to make it compatible with Mina and o1js, we decided to refactor TLSNotary's codebase to support Mina-style Schnorr signatures as well.
Following are the main modifications we implemented:
tlsn/tlsn-core/src/signature.rs
We import the mina-hasher
, mina-signer
and o1-utils
crates from O1 Labs' Proof Systems Github repository
use mina_hasher::{Hashable, ROInput};
use mina_signer::{BaseField, Keypair, NetworkId, PubKey, ScalarField, SecKey, Signer as MinaSigner};
use o1_utils::FieldHelpers;
We modify the struct called NotaryPublicKey
to include the Mina Schnorr public key as well.
#[derive(Debug, Clone)]
pub enum NotaryPublicKey {
/// A Mina-compatible public key.
MinaSchnorr(PubKey),
/// A NIST P-256 public key.
P256(p256::PublicKey),
}
We define a new struct called MinaSchnorrSignature
which is basically a wrapper around the mina_signer::Signature
. This is necessary because otherwise we could not implement the Serialize and Deserialize traits for the Mina Signature, as this struct is defined outside of the tlsn-core
crate.
#[derive(Debug, Clone)]
pub struct MinaSchnorrSignature(pub mina_signer::Signature);
We then manually implement the Serialize and Deserialize traits.
Note: We use the
bitcoin
crate to perform the Base58Check encoding, that Mina uses to encode the data for serialization.
use serde::de::{self, MapAccess, Visitor};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use bitcoin;
impl Serialize for MinaSchnorrSignature {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut bytes = Vec::with_capacity(BaseField::size_in_bytes() * 2);
let rx_bytes = self.0.rx.to_bytes();
let s_bytes = self.0.s.to_bytes();
bytes.extend_from_slice(&rx_bytes);
bytes.extend_from_slice(&s_bytes);
let versioned_data = [vec![154], vec![1], bytes.to_vec()].concat();
let b58_str = bitcoin::base58::encode_check(&versioned_data);
serializer.serialize_str(&b58_str)
}
}
impl<'de> serde::Deserialize<'de> for MinaSchnorrSignature {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct MinaSchnorrSignatureVisitor;
impl<'de> Visitor<'de> for MinaSchnorrSignatureVisitor {
type Value = MinaSchnorrSignature;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a string encoded in Base58")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
let data = bitcoin::base58::decode_check(v).map_err(E::custom)?;
// Validate version byte
if data[0] != 154 {
return Err(E::custom("Invalid version byte"));
}
let bytes = &data[2..];
let (rx_bytes, s_bytes) = bytes.split_at(32);
if let Ok(rx) = BaseField::from_bytes(rx_bytes) {
if let Ok(s) = ScalarField::from_bytes(s_bytes) {
println!("rx: {:?}, s: {:?}", rx.to_biguint(), s.to_biguint());
let sig = mina_signer::Signature { rx, s };
return Ok(MinaSchnorrSignature(sig));
} else {
return Err(E::custom("Invalid MinaSchnorr signature s bytes"));
}
} else {
return Err(E::custom("Invalid MinaSchnorr signature rx bytes"));
}
}
}
deserializer.deserialize_str(MinaSchnorrSignatureVisitor)
}
}
We rename the original Signature
data type to TLSNSignature
to include the Mina Schnorr signature.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum TLSNSignature {
/// A Mina-Schnorr signature.
MinaSchnorr(MinaSchnorrSignature),
/// A secp256r1 signature.
P256(p256::ecdsa::Signature),
}
We then implement the verify
method for the TLSNSignature
data type. We use the mina_signer::create_kimchi
method to create a context using TESTNET
as the NetworkId
.
impl TLSNSignature {
pub fn verify(
&self,
msg: &Data,
notary_public_key: impl Into<NotaryPublicKey>,
) -> Result<(), SignatureVerifyError> {
match (self, notary_public_key.into()) {
(Self::MinaSchnorr(MinaSchnorrSignature(sig)), NotaryPublicKey::MinaSchnorr(key)) => {
let mut ctx = mina_signer::create_kimchi(NetworkId::TESTNET);
if ctx.verify(&sig, &key, msg) {
Ok(())
} else {
Err(SignatureVerifyError(
"Signature verification failed".to_string(),
))
}
}
(Self::P256(sig), NotaryPublicKey::P256(key)) => {
println!(" P256 - msg: {:?}", msg);
VerifyingKey::from(key)
.verify(msg.to_array(), sig)
.map_err(|e| SignatureVerifyError(e.to_string()))
}
(Self::MinaSchnorr(_), NotaryPublicKey::P256(_)) => Err(SignatureVerifyError(
"Invalid public key type for Mina-Schnorr signature".to_string(),
)),
(Self::P256(_), NotaryPublicKey::MinaSchnorr(_)) => Err(SignatureVerifyError(
"Invalid public key type for P-256 signature".to_string(),
)),
}
}
}
The original implementation defines the data that will be signed as a simple [u8]
array. However, the mina_signer::Signature
requires the data to be of type mina_signer::BaseField
, so we define a new data type called Data
that supports both the u8
type as well as the BaseField
type.
#[derive(Clone, Debug)]
pub enum Data {
/// Mina data
Mina(Vec<BaseField>),
/// P256 data
P256(Vec<u8>),
}
impl Data {
/// Converts a byte array to a Mina data variant.
pub fn to_base_field(data: &[u8]) -> Self {
let mina_data: Vec<BaseField> = data.iter().map(|&byte| BaseField::from(byte)).collect();
Data::Mina(mina_data)
}
}
The Data
type needs to implement the mina_hasher::Hashable
trait, so we implement it.
Note: The
domain_string()
method implementation for theHashable
trait was one our main "aha moments" as it wasn't at all obvious how it had to be implemented. It turns out that for the signature to be correctly verified by the o1js code, the string corresponding to theTESTNET
network ID has to be "CodaSignature".
impl Hashable for Data {
type D = NetworkId;
fn to_roinput(&self) -> ROInput {
match self {
Data::Mina(fields) => {
let mut ro_input = ROInput::new();
for field in fields {
ro_input = ro_input.append_field(*field);
}
ro_input
}
Data::P256(_) => panic!("to_roinput is not applicable for P256 variant"),
}
}
fn domain_string(network_id: NetworkId) -> Option<String> {
match network_id {
NetworkId::MAINNET => "MinaSignatureMainnet".to_string().into(),
NetworkId::TESTNET => "CodaSignature".to_string().into(),
}
}
}
We define a TLSNSigningKey
struct to encapsulate both the original p256::ecdsa::SigningKey
and Mina's mina_signer::SecKey
.
#[derive(Clone, Debug)]
pub enum TLSNSigningKey {
MinaSchnorr(SecKey),
P256(p256::ecdsa::SigningKey),
}
Then we implement the signature::Signer
trait for our TLSNSigningKey
so it can be used to sign messages. Note that the input message msg
is of type [u8]
, just as in the original TLSNotary implementation. However, if the signing key happens to be a Mina signature, we use the Data::to_base_field(msg)
method to convert it to a vector of mina_signer::BaseField
.
impl Signer<TLSNSignature> for TLSNSigningKey {
fn sign(&self, msg: &[u8]) -> TLSNSignature {
self.try_sign(msg).expect("signature operation failed")
}
fn try_sign(&self, msg: &[u8]) -> Result<TLSNSignature, signature::Error> {
match self {
TLSNSigningKey::MinaSchnorr(sk) => {
let mut ctx = mina_signer::create_kimchi::<Data>(NetworkId::TESTNET);
let key_pair =
Keypair::from_secret_key(sk.clone()).map_err(|_| signature::Error::new())?;
let sig = ctx.sign(&key_pair, &Data::to_base_field(msg));
Ok(TLSNSignature::MinaSchnorr(MinaSchnorrSignature(sig)))
}
TLSNSigningKey::P256(sk) => {
let sig = sk.try_sign(msg)?;
Ok(TLSNSignature::P256(sig))
}
}
}
}
notary-server/src/server.rs
We modified the load_notary_signing_key
function to include the case where the signing key is a Mina Schnorr key and not a P256 key.
async fn load_notary_signing_key(config: &NotarySigningKeyProperties) -> Result<TLSNSigningKey> {
debug!("Loading notary server's signing key");
let notary_signing_key = match config.signing_key_type_name {
TLSNSigningKeyTypeNames::MinaSchnorr => {
let path = &config.private_key_pem_path;
let signing_key_bytes = tokio::fs::read(path).await?;
let signing_key_str = std::str::from_utf8(&signing_key_bytes).unwrap();
let signing_key_schnorr = SecKey::from_base58(signing_key_str).unwrap();
TLSNSigningKey::MinaSchnorr(signing_key_schnorr)
},
TLSNSigningKeyTypeNames::P256 => {
let path = &config.private_key_pem_path;
let signing_key_bytes = tokio::fs::read(path).await?;
let signing_key_str = std::str::from_utf8(&signing_key_bytes).unwrap();
let p256_signing_key = p256::ecdsa::SigningKey::from_pkcs8_pem(signing_key_str).unwrap();
TLSNSigningKey::P256(p256_signing_key)
},
};
debug!("Successfully loaded notary server's signing key!");
Ok(notary_signing_key)
}
All our efforts have focused on achieving Challenge #1, specifically verifying the signature of a notarization produced by the Notary Server using provable code with o1js.
Regarding Challenge #2, which involves verifying the redacted parts of a notarization, no substantial work has been done yet. We have only conducted preliminary research to assess its feasibility. As mentioned earlier, since the TLSNotary team is actively working on redesigning the tlsn-core
crate to include support for Poseidon hashes, among other improvements, it is advisable to wait until this redesign is complete before tackling the redaction feature.
After the redesign, this code will most likely change, but as of today, the selective disclosure logic happens on /tlsn-core/src/proof/substrings.rs
The redaction of selected pieces of data is referred to as "substring verification" inside TLSNotary. It basically consists of a SubstringProof
data structure that contains the commitment openings to the redacted parts and a proof that the commitments are present in the Merkle tree that comes inside the proof file generated by the Notary Server.
pub struct SubstringsProof {
openings: HashMap<CommitmentId, (CommitmentInfo, CommitmentOpening)>,
inclusion_proof: MerkleProof,
}
The SubstringsProof::verify()
function is where all substring verification takes place.
Notice on line 272 where they use the SessionHeader::encoder()
method to generate the encodings (commitments) to the redacted data. This encoder is an instance of the ChaChaEncoder from the mpz_garble_core
crate.
In the future, once the redesign of tlsn-core
is done, there should be other alternatives to ChaCha, such as the Poseidon algorithm.
We hope this write up and the READMEs for both zkNotary
and our fork of tlsn
are enough to anyone interested in pushing us ever much closer to a truly trustless zkOracle. For any questions or just to say hi, you can reach out to us on X, our handles will be placed below. Thank you for your time!
Rafael Campos and Tito Thompson
X Handles
Tito - @tito_cda
Rafael - @racampos