Skip to main content

X.509

Overview

We present a minimal example canister smart contract for showcasing two use cases of X.509. 1) How to create an X.509 certification authority (CA) certificate, where the CA certificate's private key is a threshold signing key, which is never revealed in cleartext, and cannot be revealed due to the properties of the threshold signature protocols on the Internet Computer. This means that only the canister can sign child certificates. 2) How to create a child certificate from a certificate signing request (CSR) provided by an external party. The CSR is generated externally, i.e., with a private key generated by the caller.

More specifically, the sample canister:

  • Takes a threshold Ed25519 or ECDSA with curve secp256k1 key name upon initialization, e.g., (variant { Ed25519 = variant { TestKey1 } }).
  • Generates a CA certificate via an update call to the root_ca_certificate function. The CA certificate is generated only once and afterwards stored in the smart contract.
  • Generates a child certificate with a CSR provided in the PEM format in an update call to the child_certificate function.
  • Note that the derivation path of the key that is used to sign the root certificate is hard-coded to be empty.

Currently this canister only produces and accepts certificates with Ed25519 keys or ECDSA keys using curve secp256k1.

This tutorial gives a complete overview of the development, starting with downloading dfx, up to the deployment and trying out the code on the mainnet.

Prerequisites

  • Install the IC SDK v0.23.0 or newer.
  • Clone the example dapp project: git clone https://github.com/dfinity/examples

Deploy and test the canister locally

Sample code for x509-example is provided in the examples repository, under /rust sub-directory.

cd examples/rust/x509
dfx start --background
npm install
dfx deploy

What this does

  • dfx start --background starts a local instance of the IC via the IC SDK

If successful, you should see something like this:

Deployed canisters.
URLs:
Backend canister via Candid interface:
x509_example: http://127.0.0.1:4943/?canisterId=t6rzw-2iaaa-aaaaa-aaama-cai&id=st75y-vaaaa-aaaaa-aaalq-cai

If you open the URL in a web browser, you will see a web UI that shows the public methods the canister exposes. Since the canister exposes root_ca_certificate and child_certificate methods, those are rendered in the web UI.

Deploying the canister on the mainnet

Before deploying this canister to the mainnet, you must do the following:

  • Acquire cycles (equivalent of "gas" in other blockchains). This is necessary for all canisters.
  • Update the sample source code to have the right key ID. This is unique to this canister.

Step 1: Acquire cycles to deploy

Deploying to the Internet Computer requires cycles (the equivalent of "gas" on other blockchains).

Step 2: Update source code with the right key ID

To deploy the sample code, the canister needs the right key name for the right environment. Specifically, one needs to initialize the canister with the key name. Here, dfx deploy will give a choice of key names.

There are three options that are supported:

  • dfx_test_key: a default key ID that is used in deploying to a local version of IC (via IC SDK).
  • test_key_1: a master test key ID that is used in mainnet.
  • key_1: a master production key ID that is used in mainnet.

Note that dfx deploy formats those name in PascalCase instead of snake_case due to the formatting of types in rust.

Step 3: Deploy to the mainnet using the IC SDK

To deploy via the mainnet, run the following commands:

npm install
dfx deploy --network ic

If successful, you should see something like this:

Deployed canisters.
URLs:
Backend canister via Candid interface:
schnorr_example_rust: https://a3gq9-oaaaa-aaaab-qaa4q-cai.raw.icp0.io/?id=736w4-cyaaa-aaaal-qb3wq-cai

In the example above, x509_example_rust has the URL https://a3gq9-oaaaa-aaaab-qaa4q-cai.raw.icp0.io/?id=736w4-cyaaa-aaaal-qb3wq-cai and serves up the Candid web UI for this particular canister deployed on mainnet.

Obtaining the root CA certificate

Using the Candid Web UI

If you deployed your canister locally or to the mainnet, you should have a URL to the Candid web UI where you can access the public methods. We can call the root_ca_certificate method.

In the example below, the method returns

-----BEGIN CERTIFICATE-----
MIIBxzCCAXmgAwIBAgIBADAFBgMrZXAwazELMAkGA1UEBhMCVVMxKTAnBgNVBAoM
IFdlYjMgY2VyaXRpZmNhdGlvbiBhdXRob3JpdHkgSW5jMTEwLwYDVQQDDChXZWIz
IGNlcnRpZmljYXRpb24gYXV0aG9yaXR5IGNvcnBvcmF0aW9uMB4XDTI0MDkyMDE2
MzY0MloXDTM0MDkxODE2MzY0MlowazELMAkGA1UEBhMCVVMxKTAnBgNVBAoMIFdl
YjMgY2VyaXRpZmNhdGlvbiBhdXRob3JpdHkgSW5jMTEwLwYDVQQDDChXZWIzIGNl
cnRpZmljYXRpb24gYXV0aG9yaXR5IGNvcnBvcmF0aW9uMCowBQYDK2VwAyEA8rDQ
aKVQyUr1vKqf+PzXNjg+mw3t7RPVPB9ctenyQISjQjBAMB0GA1UdDgQWBBTULvca
cvvKz89izqKDzwWZ6gwdKTAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIB
BjAFBgMrZXADQQBlqWQ+F757rKxPXDccuhQEtrfnLoWf4rHhok/2dLioJ1+ZQda5
DNH8/kcXoPOm0jyqlVaV1ZhQm63AMwK3gSwC
-----END CERTIFICATE-----

as the root CA certificate.

(
variant {
Ok = record {
x509_certificate_string = "-----BEGIN CERTIFICATE-----\nMIIBxzCCAXmgAwIBAgIBADAFBgMrZXAwazELMAkGA1UEBhMCVVMxKTAnBgNVBAoM\nIFdlYjMgY2VyaXRpZmNhdGlvbiBhdXRob3JpdHkgSW5jMTEwLwYDVQQDDChXZWIz\nIGNlcnRpZmljYXRpb24gYXV0aG9yaXR5IGNvcnBvcmF0aW9uMB4XDTI0MDkyMDE2\nMzY0MloXDTM0MDkxODE2MzY0MlowazELMAkGA1UEBhMCVVMxKTAnBgNVBAoMIFdl\nYjMgY2VyaXRpZmNhdGlvbiBhdXRob3JpdHkgSW5jMTEwLwYDVQQDDChXZWIzIGNl\ncnRpZmljYXRpb24gYXV0aG9yaXR5IGNvcnBvcmF0aW9uMCowBQYDK2VwAyEA8rDQ\naKVQyUr1vKqf+PzXNjg+mw3t7RPVPB9ctenyQISjQjBAMB0GA1UdDgQWBBTULvca\ncvvKz89izqKDzwWZ6gwdKTAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIB\nBjAFBgMrZXADQQBlqWQ+F757rKxPXDccuhQEtrfnLoWf4rHhok/2dLioJ1+ZQda5\nDNH8/kcXoPOm0jyqlVaV1ZhQm63AMwK3gSwC\n-----END CERTIFICATE-----\n";
}
},
)

Code walkthrough

Open the file wasm_only.rs, which will show the following Rust code that demonstrates how to obtain a root CA certificate.

Obtaining root CA certificate

#[update]
async fn root_ca_certificate() -> Result<X509CertificateString, String> {
// if the certificate is already cached, return it

if let Some(certificate) = ROOT_CA_CERTIFICATE_PEM.with(|inner| inner.get().map(|v| v.clone()))
{
return Ok(X509CertificateString {
x509_certificate_string: certificate,
});
}

// if the certificate is not cached, create it and try to cache it

let serial_number = SerialNumber::from(0u32);

let subject = Name::from_str(
"CN=DEMO Web3 certification authority corporation,O=DEMO Web3 ceritifcation authority Inc,C=US",
)
.unwrap();

let newly_constructed_x509_certificate_string = match CA_KEY_INFORMATION
.with(|value| *value.borrow())
{
CaKeyInformation::Ed25519(_) => {
let signer = Ed25519Signer::new()
.await
.map_err(|e| format!("failed to create Ed25519 signer: {e:?}"))?;

let subject_public_key =
der::asn1::BitString::new(0, root_ca_public_key_bytes().await?)
.map_err(|e| format!("source: {:?}", e.source()))?;

let pub_key = SubjectPublicKeyInfoOwned {
algorithm: signer.signature_algorithm_identifier().unwrap(),
subject_public_key,
};

pem_certificate_signed_by_root_ca(
Profile::Root,
serial_number,
validity(),
subject,
pub_key,
signer,
)
.await
.map_err(|e| format!("failed to create root certificate: {e:?}"))?
}
CaKeyInformation::EcdsaSecp256k1(_) => {
let signer = EcdsaSecp256k1Signer::new()
.await
.map_err(|e| format!("failed to create ECDSA secp256k1 signer: {e:?}"))?;

let public_key_bytes_compressed = root_ca_public_key_bytes().await?;
let public_key_bytes_uncompressed =
k256::ecdsa::VerifyingKey::from_sec1_bytes(public_key_bytes_compressed.as_slice())
.map_err(|e| format!("malformed public key: {e:?}"))?
.to_encoded_point(false);

let subject_public_key =
der::asn1::BitString::new(0, public_key_bytes_uncompressed.as_bytes())
.map_err(|e| format!("source: {:?}", e.source()))?;

let pub_key = SubjectPublicKeyInfoOwned {
algorithm: AlgorithmIdentifier {
// Public Key Algorithm: id-ecPublicKey (1.2.840.10045.2.1)
oid: pkcs8::ObjectIdentifier::new_unwrap("1.2.840.10045.2.1"),
parameters: Some(der::Any::from(k256::Secp256k1::OID)),
},
subject_public_key,
};

pem_certificate_signed_by_root_ca(
Profile::Root,
serial_number,
validity(),
subject,
pub_key,
signer,
)
.await
.map_err(|e| format!("failed to create root certificate: {e:?}"))?
}
};

let x509_certificate_string = ROOT_CA_CERTIFICATE_PEM.with(move |inner| {
inner
.get_or_init(|| newly_constructed_x509_certificate_string)
.clone()
});

Ok(X509CertificateString {
x509_certificate_string,
})
}

async fn pem_certificate_signed_by_root_ca<Signer>(
profile: Profile,
serial_number: SerialNumber,
validity: Validity,
subject: Name,
subject_public_key_info: SubjectPublicKeyInfoOwned,
signer: Signer,
) -> Result<String, String>
where
Signer: Sign,
Signer: Keypair + DynSignatureAlgorithmIdentifier,
Signer::VerifyingKey: EncodePublicKey,
{
let mut builder = CertificateBuilder::new(
profile,
serial_number,
validity,
subject,
subject_public_key_info,
&signer,
)
.expect("Create certificate");

let blob = builder
.finalize()
.map_err(|e| format!("failed to finalize certificate builder: {e:?}"))?;

let signature = BitString::from_bytes(&signer.sign(&blob).await?)
.map_err(|e| format!("wrong signature length: {e:?}"))?;

let certificate = builder
.assemble(signature)
.map_err(|e| format!("failed to assemble certificate: {e:?}"))?;

certificate
.to_pem(LineEnding::LF)
.map_err(|e| format!("failed to encode certificate: {e:?}"))
}

In the code above, the canister calls root_ca_public_key_bytes which calls schnorr_public_key method or ecdsa_public_key method of the IC management canister (aaaaa-aa). Then, inside pem_certificated_signed_by_root_ca, the canister calls sign_with_schnorr method or sign_with_ecdsa method of the IC management canister inside Ed25519Signer::sign or EcdsaSecp256k1Signer::sign, respectively, in order to produce a certificate signature. More details about Ed25519Signer and EcdsaSecp256k1Signer can be found in wasm_only/signer.rs.

The IC management canister is just a facade; it does not exist as a canister (with isolated state, Wasm code, etc.). It is an ergonomic way for canisters to call the system API of the IC (as if it were a single canister). In the code below, we use the management canister to create a Schnorr public key. Canister ID "aaaaa-aa" declares the IC management canister in the canister code.

Creating a certificate signing request (CSR) and obtaining the signed child certificate

#[update]
async fn child_certificate(
certificate_request_info: PemCertificateRequest,
) -> Result<X509CertificateString, String> {
let cert_req =
CertReq::from_pem(certificate_request_info.pem_certificate_request.as_bytes())
.map_err(|e| format!("failed to parse PEM certificate signing request: {e:?}"))?;

verify_certificate_request_signature(&cert_req)?;

if !cert_req.info.attributes.is_empty() {
return Err("Attributes are currently not supported in this example".to_string());
}

prove_ownership(&cert_req, ic_cdk::api::caller() /*, ... */)?;

let root_certificate_pem = root_ca_certificate().await?;
let root_certificate =
x509_cert::Certificate::from_pem(root_certificate_pem.x509_certificate_string.as_str())
.map_err(|e| format!("failed to parse PEM root CA certificate: {e:?}"))?;

let profile = Profile::Leaf {
issuer: root_certificate.tbs_certificate.subject.clone(),
enable_key_agreement: false,
enable_key_encipherment: false,
};

let serial_number = SerialNumber::from(next_child_certificate_serial_number());

// For simplicity of this example, let's just use the same validity
// period as the root certificate. In a real application, the validity
// would normally not start in the past and might end well before the root
// certificate validity ends. Also, the validity of the child
// ceritifcate should always be in the time frame of the root
// certificate's validity.
let validity = root_certificate.tbs_certificate.validity.clone();

let x509_certificate_string = {
match CA_KEY_INFORMATION.with(|value| *value.borrow()) {
CaKeyInformation::Ed25519(_) => {
let signer = Ed25519Signer::new()
.await
.map_err(|e| format!("failed to create Ed25519 signer: {e:?}"))?;
pem_certificate_signed_by_root_ca(
profile,
serial_number,
validity,
cert_req.info.subject.clone(),
cert_req.info.public_key.clone(),
signer,
)
.await
.map_err(|e| format!("failed to create child certificate: {e:?}"))?
}
CaKeyInformation::EcdsaSecp256k1(_) => {
let signer = EcdsaSecp256k1Signer::new()
.await
.map_err(|e| format!("failed to create Ed25519 signer: {e:?}"))?;
pem_certificate_signed_by_root_ca(
profile,
serial_number,
validity,
cert_req.info.subject.clone(),
cert_req.info.public_key.clone(),
signer,
)
.await
.map_err(|e| format!("failed to create child certificate: {e:?}"))?
}
}
};

Ok(X509CertificateString {
x509_certificate_string,
})
}

Similarly to the root CA certificate, child certificates are signed via the pem_certificate_signed_by_root_ca function. The major difference here is that the meta-information about the certificate such as subject and public key information are provided via a CSR in the PKCS#10 format, which is the most widely-used CSR format and can be generate e.g. using OpenSSL.

The child_certificate API allows for an external user to generate a key pair locally, create a CSR, send it in the PEM format to the smart contract, and get a X.509 certificate for the CSR, signed with the key of the CA certificate.

Note that, similarly to CA certificates, currently only Ed25519 and ECDSA with curve secp256k1 are supported for child certificates, i.e., also for CSRs.

Certificate verification

Certificates obtained from the smart contract can be validated e.g. using OpenSSL.

To validate the root CA certificate from a locally deployed smart contract, the following command can be used.

dfx canister call x509_example_rust root_ca_certificate --output json | jq '.["Ok"].["x509_certificate_string"]' | sed -e 's/\\n/\n/g' -e 's/\"//g' > root_ca_cert.pem && openssl verify -CAfile root_ca_cert.pem root_ca_cert.pem

To create a CSR, the following script could be used.

openssl genpkey -algorithm Ed25519 -out key.pem
openssl req -new -key key.pem -out request.csr -subj "/CN=Test corporation/O=Test Inc/C=US"

To validate a child certificate, the following command could be used.

CSR=$(cat request.csr)
dfx canister call x509_example_rust child_certificate "(record { pem_certificate_request = \"$CSR\"; } )" --output json | jq '.["Ok"].["x509_certificate_string"]' | sed -e 's/\\n/\n/g' -e 's/\"//g' > child_cert.pem &&
openssl verify -CAfile root_ca_cert.pem child_cert.pem

Conclusion

In this walkthrough, we deployed a sample smart contract that:

  • Generated a root CA certificated self-signed with private Ed25519 keys even though canisters do not hold Schnorr keys themselves.
  • Requested child certificate signed with the private Ed25519 key of the root CA certificate.