Lab - Authentication with ZNP and Verifiable Credentials

Introduction

The goal of these exercises is to explore the functionalities of Verifiable Credentials and Zero Knowledge Proofs in the scope of a mock authentication process

Verifiable Credentials

This guide will demonstrate how Verifiable Credentials can be used in a standalone manner, and specifically, how a Verifiable Credential can be created, presented and verified. We will not use any blockchain to store information, and will pass the VC between components directly as files.

In order to execute this project, several steps need to be completed. The first is to install the digitalbazaar NodeJS components.

npm install @digitalbazaar/vc
npm install @digitalbazaar/did-method-key
npm install @digitalbazaar/did-method-web
npm install @digitalbazaar/ecdsa-multikey
npm install @digitalbazaar/ecdsa-rdfc-2019-cryptosuite
npm install @digitalbazaar/data-integrity
npm install @digitalbazaar/security-document-loader
npm install @digitalbazaar/data-integrity-context

Our scenario considers that there is an issuer, a holder and a verifier. The issuer creates a Verifiable Credential, which is provided to the holder. The verifier can process the Verifiable Credential provided by the holder to assert identity attributes. In a real world situation, the Verifiable Credential would be in a wallet, backed by some Ledger. In our scnenario, we will skip that part and make the Verifiable Credential directly available to the verifier.

To start the process, you will need a Credential. We will issue academic diplomas, which obey a known schema. For an example of a VC related to a diploma, check: https://w3c-ccg.github.io/vc-ed-models/ and https://credreg.net/ctdl/terms.

A base credential would be:

{
  "@context": [
    "https://www.w3.org/2018/credentials/v1",
    "https://www.w3.org/2018/credentials/examples/v1"
  ],
  "id": "urn:uuid:a63a60be-f4af-491c-87fc-2c8fd3007a58",
  "type": [
    "VerifiableCredential",
    "UniversityDegreeCredential"
  ],
  "issuer": {
    "type": [
      "Profile"
    ],
    "id": "did:key:z6MktiSzqF9kqwdU8VkdBKx56EYzXfpgnNPUAGznpicNiWfn",
    "name": "Universidade de Aveiro"
  },
  "issuanceDate": "",
  "expirationDate": "",
  "credentialSubject": {
    "id": "did:student:ebfeb1f712ebc6f1c276e12ec21",
    "degree": {
      "name": "Mestrado em Cibersegurança"
    }
  }
}

Save the content to credential.json. You can customize the content to add your identity, name, etc… Take in consideration that you must follow the schema. You need to set the issuanceDate and the expirationDate.

The next step is to have an issuer able to sign the credential. It must know the format of the objects that it is signing, and it must have a signing key. A full basic issuer would be:


import * as DidKey from '@digitalbazaar/did-method-key';
import * as DidWeb from '@digitalbazaar/did-method-web';
import * as EcdsaMultikey from '@digitalbazaar/ecdsa-multikey';
import {
  cryptosuite as ecdsaRdfc2019Cryptosuite
} from '@digitalbazaar/ecdsa-rdfc-2019-cryptosuite';

import * as vc from '@digitalbazaar/vc';
import {CachedResolver} from '@digitalbazaar/did-io';
import {DataIntegrityProof} from '@digitalbazaar/data-integrity';
import {securityLoader} from '@digitalbazaar/security-document-loader';
import {contexts as diContexts} from '@digitalbazaar/data-integrity-context';
import fs from 'fs';

// setup documentLoader with security contexts
const loader = securityLoader();
loader.addDocuments({documents: diContexts});

//Load the JSON-LD context for the credential
loader.addStatic(
  "https://www.w3.org/ns/odrl.jsonld",
  await fetch("https://www.w3.org/ns/odrl.jsonld").then(res => res.json())
);

loader.addStatic(
  "https://www.w3.org/2018/credentials/examples/v1",
  await fetch("https://www.w3.org/2018/credentials/examples/v1").then(res => res.json())
)


const resolver = new CachedResolver();
const didKeyDriverMultikey = DidKey.driver();
const didWebDriver = DidWeb.driver();

didKeyDriverMultikey.use({
  multibaseMultikeyHeader: 'zDna',
  fromMultibase: EcdsaMultikey.from
});


didWebDriver.use({
  multibaseMultikeyHeader: 'zDna',
  fromMultibase: EcdsaMultikey.from
});

// P-384
didWebDriver.use({
  multibaseMultikeyHeader: 'z82L',
  fromMultibase: EcdsaMultikey.from
});


resolver.use(didKeyDriverMultikey);
resolver.use(didWebDriver);
loader.setDidResolver(resolver);

const documentLoader = loader.build();

async function main({credential, documentLoader}) {

  // generate example keypair for VC signer
  const vcEcdsaKeyPair = await EcdsaMultikey.generate({curve: 'P-256'});

  const {
    didDocument: vcDidDocument/*, keyPairs, methodFor*/
  } = await didKeyDriverMultikey.fromKeyPair({
    verificationKeyPair: vcEcdsaKeyPair
  });

  vcEcdsaKeyPair.id = vcDidDocument.assertionMethod[0];
  vcEcdsaKeyPair.controller = vcDidDocument.id;

  // ensure issuer matches key controller
  credential.issuer = vcEcdsaKeyPair.controller;

  // setup ecdsa-rdfc-2019 signing suite
  const vcSigningSuite = new DataIntegrityProof({
    signer: vcEcdsaKeyPair.signer(),
    // date: '2023-01-01T01:01:01Z',
    cryptosuite: ecdsaRdfc2019Cryptosuite
  });

  // sign credential
  const verifiableCredential = await vc.issue({
    credential,
    suite: vcSigningSuite,
    documentLoader
  });

  console.log('SIGNED CREDENTIAL:');
  console.log(verifiableCredential);
  fs.writeFileSync('vc.json', JSON.stringify(verifiableCredential, null, 2));
}

console.log("Loading credential.json");
var credential = fs.readFileSync('credential.json', 'utf8');
credential = JSON.parse(credential);

//Adjust the expiration date to one year from now
credential["issuanceDate"] = new Date().toISOString();
credential["expirationDate"] = new Date(new Date().setFullYear(new Date().getFullYear() + 1)).toISOString();

if (credential) {
  console.log("Signing Credential: "+credential);
  await main({credential, documentLoader});  
}else{
  console.log("Credential not found");
}

Save the previous code as issuer.js and update the date of the signing suite. As you can notice, the issuer knowns which documents it will sign, as the document loader will restrict the schema of the documents to a restricted set. This makes sense, because and issuer will only sign documents of its authority, and doesn’t constitute a general entity signing any type of credential.

You can run it with:

node issuer.js

The result is a verifiable credential which is written to the console. Save it to a file named vc.json.

Notice that there is an added element to the credential, named proof, with is the signature created by the issuer.

"proof": {
"type": "DataIntegrityProof",
"created": "2025-02-28T14:38:32Z",
"verificationMethod": "did:key:zDnaeRXcigSKT4nx4mRaGLTaRY7aj7vbCH7DmWCdS4m9575KF#zDnaeRXcigSKT4nx4mRaGLTaRY7aj7vbCH7DmWCdS4m9575KF",
"cryptosuite": "ecdsa-rdfc-2019",
"proofPurpose": "assertionMethod",
"proofValue": "zRyXm45NRGeAWyxQj1YNbBYhZNgPpXkcUEaYraGxJrdjhBAjKQXwcGoXLSQnMD5yXrXVmd2KccYka14aXnBAn1aw"
}

You can create a holder by using the preamble of the issuer and adding a new main method.

// TODO: Add Initial Imports like in issuer.

//Create a VC PRESENTATION by the HOLDER to a VERIFIER
async function main({verifiableCredential, documentLoader}) {

  // setup example for did:web
  const VP_DID = 'did:web:example.org:issuer:123';
  const VP_DID_URL = 'https://example.org/issuer/123'; // The target DID URL

  // generate example keypair for VP signer (The HOLDER)
  const vpEcdsaKeyPair = await EcdsaMultikey.generate({curve: 'P-384'});
  fs.writeFileSync('vpEcdsaKeyPair.json', JSON.stringify(await vpEcdsaKeyPair.export({publicKey: true})));
  
  const {
    didDocument: vpDidDocument, methodFor: vpMethodFor
  } = await didWebDriver.fromKeyPair({
    url: VP_DID_URL,
    verificationKeyPair: vpEcdsaKeyPair
  });
  const didWebKey = vpMethodFor({purpose: 'authentication'});
  vpEcdsaKeyPair.id = didWebKey.id;
  vpEcdsaKeyPair.controller = vpDidDocument.id;
  const vpSigningSuite = new DataIntegrityProof({
    signer: vpEcdsaKeyPair.signer(),
    cryptosuite: ecdsaRdfc2019Cryptosuite
  });

  loader.addStatic(VP_DID, vpDidDocument);
  //loader.addStatic(VP_DID_DOC_URL, vpDidDocument);

  // using a static verification method result
  // In the real world, this would be fetched from the DID Document
  const vpDidVm = structuredClone(vpDidDocument.verificationMethod[0]);
  vpDidVm['@context'] = 'https://w3id.org/security/multikey/v1';
  loader.addStatic(vpDidVm.id, vpDidVm);

  // presentation holder
  const holder = 'did:web:ua.pt:holder:student:456';
  // presentation challenge - required for authentication proof purpose
  const challenge = 'abc123';
  // presentation domain - optional in this use case
  const domain = 'https://example.com/';

  const presentation = await vc.createPresentation({
    verifiableCredential,
    holder
  });

  console.log('PRESENTATION:');
  console.log(presentation);

  // sign presentation
  // note this adds the proof to the input presentation
  const vp = await vc.signPresentation({
    presentation,
    suite: vpSigningSuite,
    challenge,
    domain,
    documentLoader
  });

  console.log('SIGNED PRESENTATION:');
  console.log(vp);
}

console.log("Loading vc.json");
var credential = JSON.parse(fs.readFileSync('vc.json', 'utf8'));

if (credential) {
  console.log("Creating Signed Presentation");
  await main({credential, documentLoader});  
}else{
  console.log("VC not found");
}

This result should be a Verifiable Presentation of the Credential, that can be verified by the verifier. Write the result to vp.json. You can check that an additional file is created (vpEcdsaKeyPair.json). The purpose of the file is to pass the Public Key of the Holder to the Verifier. The loader.addStatic(VP_DID, vpDidDocument); and others related create a local handler to authenticate the host. Otherwise, a request would be made to https://example.org/issuer/123/did.json as an attempt to fetch the public key.

The code for the verifier is also similar:

// TODO: Add additional imports

//Verifies a VC PRESENTATION 
async function main({verifyablePresentation, documentLoader, challenge}) {
  // setup example for did:web
  const VP_DID = 'did:web:example.org:issuer:123';
  const VP_DID_URL = 'https://example.org/issuer/123'; // The target DID URL

  const vpEcdsaKeyPair = await EcdsaMultikey.from(JSON.parse(fs.readFileSync('vpEcdsaKeyPair.json', 'utf8')));
  
  const {
    didDocument: vpDidDocument, methodFor: vpMethodFor
  } = await didWebDriver.fromKeyPair({
    url: VP_DID_URL,
    verificationKeyPair: vpEcdsaKeyPair
  });
  const didWebKey = vpMethodFor({purpose: 'authentication'});
  vpEcdsaKeyPair.id = didWebKey.id;
  vpEcdsaKeyPair.controller = vpDidDocument.id;
  const vpSigningSuite = new DataIntegrityProof({
    cryptosuite: ecdsaRdfc2019Cryptosuite
  });

  // setup VP ecdsa-rdfc-2019 verifying suite
  const vpVerifyingSuite = new DataIntegrityProof({
    cryptosuite: ecdsaRdfc2019Cryptosuite
  });

  loader.addStatic(VP_DID, vpDidDocument);
  //loader.addStatic(VP_DID_DOC_URL, vpDidDocument);

  // using a static verification method result
  // In the real world, this would be fetched from the DID Document
  const vpDidVm = structuredClone(vpDidDocument.verificationMethod[0]);
  vpDidVm['@context'] = 'https://w3id.org/security/multikey/v1';
  loader.addStatic(vpDidVm.id, vpDidVm);

  // verify signed presentation
  const verifyPresentationResult = await vc.verify({
    presentation: verifyablePresentation,
    challenge,
    suite: vpVerifyingSuite,
    documentLoader
  });

  console.log('VERIFY PRESENTATION RESULT:');
  console.log(verifyPresentationResult);
  if(verifyPresentationResult.error) {
    console.log('VP ERROR STACK:',
      verifyPresentationResult.error.errors[0].stack);
  }
}

console.log("Loading vp.json");
var verifyablePresentation = JSON.parse(fs.readFileSync('vp.json', 'utf8'));

if (verifyablePresentation) {
  console.log("Verifying Verifiable Presentation");
  var challenge = "abc123";
  await main({verifyablePresentation, documentLoader, challenge});  
}else{
  console.log("VP not found");
}

The result should be a validated Credential. The holder could also disclose only a set of credentials, but this would require additional steps to ensure that the mechanism can operate. Please check the example at: https://github.com/digitalbazaar/vc?tab=readme-ov-file#deriving-a-selective-disclosure-verifiable-credential

Setup

This lab requires that you obtain and install noknow python package available at: https://github.com/jpbarraca/noknow-python

It will include both the noknow system, developed by https://github.com/GoodiesHQ and a client and server developed for this lab.

Obtain the repository, install the requirements and install the package, with the following commands:

sudo apt install python3-virtualenv
git clone https://github.com/jpbarraca/noknow-python.git
cd noknow-python
virtualenv venv
bash
source venv/bin/activate
pip install -r requirements
python3 setup.py install
cd examples

Zero Knowledge Proofs

zero-knowledge proof or zero-knowledge protocol is a method by which one party (the prover) can prove to another party (the verifier) that a given statement is true, while avoiding conveying to the verifier any information beyond the mere fact of the statement’s truth. The intuition underlying zero-knowledge proofs is that it is trivial to prove the possession of certain information by simply revealing it

  • the challenge is to prove this possession without revealing the information, or any aspect of it whatsoever.

In light of the fact that one should be able to generate a proof of some statement only when in possession of certain secret information connected to the statement, the verifier, even after having become convinced of the statement’s truth, should nonetheless remain unable to prove the statement to third parties. 1

An important protocol in the scope of identities is the Schnorr’s identification protocol, which consists of a public-key based challenge-response identification protocol. In the scope of ZKP, the protocol can operate both in interactive and non-interactive modes, making it very versatile.

The Verifier

In our simplified scenario we will use a Flask webserver that acts as a verifier. Therefore, it will validate the identity of provers (users) that wish to access the resources. For practical purposes, we will also support a register endpoint which allows users to register their identity.

This code is available in the server.py file below. It is a simple Flask webserver with the following properties:

  • Supports register and login methods.
  • The register method allows clients to register their keys, and corresponds to an initial provisioning.
  • During the register phase, the client will set a password which can be later used for authentication. While the password is used in this phase, the key is not disclosed to the server.
  • The login method has two actions: sending a token, which acts as a challenge.
  • The client can take the token and transform it using the password. It proves to know the password without disclosing it.
  • The server also signs their tokens to prevent forgery, and the signing key is unique per client
from flask import Flask
from flask import request
from noknow.core import ZK, ZKSignature, ZKParameters, ZKData, ZKProof
import os
import json

app = Flask(__name__)

clients = {}

@app.route("/")
def index():
    return "Howdy"

@app.route("/register", methods=["POST"])
def register():
    clientid = request.json.get('clientid', None)
    sig = request.json.get('sig', None)
    
    if sig is None or clientid is None:
        return "Invalid request"
    
    if clientid in clients:
        return "Client already registered"

    client_signature = ZKSignature.from_json(sig)
    client_zk = ZK(client_signature.params)
    clients[clientid] = {'zk': client_zk, 'sig': client_signature}

    return "Client registered"


@app.route("/login", methods=["POST"])
def login():
    clientid = request.json.get('clientid', None)
    proof = request.json.get('proof', None)


    # First request, send token to client for proof
    if clientid in clients and proof is None:
        
        # Generate a server password and ZK object for the server
        server_password = os.urandom(32)
        # Set up server component, generating a new ZK object
        server_zk = ZK.new(curve_name="secp384r1", hash_alg="sha3_512")
        # store the server zk and password for later use
        clients[clientid]['server_zk'] = server_zk
        clients[clientid]['server_password'] = server_password
        # get the client zk 
        client_zk = clients[clientid]['zk']

        # Create a signed token and send to the client
        token = server_zk.sign(server_password, client_zk.token())
        return token.to_json()

    if clientid in clients and proof is not None:
        # Get the token from the client
        zkproof = ZKData.from_json(proof)
        token = ZKData.from_json(zkproof.data)

        # Get the client zk and server password
        server_zk = clients[clientid]['server_zk']
        server_signature = server_zk.create_signature(clients[clientid]['server_password'])
        client_zk = clients[clientid]['zk']

        # check if the server signature is valid
        if not server_zk.verify(token, server_signature):
            print("Invalid server auth: ")
            return "Authentication failure"
        else:
            # Verify the proof from the client
            # uses the client proof and signature to verify the token
            result = client_zk.verify(zkproof, clients[clientid]['sig'], data=token)
            return "Authentication success" if result else "Authentication failure"
    
    # Invalid request
    return "Invalid request"

if __name__ == "__main__":
    app.run(debug=True)

The Prover

The prover corresponds to the client, and will execute two actions: register a key and then create a proof that asserts the identity. The proof is only valid for a single client, at a single server. The proof doesn’t include the key but the creation of the proof requires knowing the key. The server can verify that the proof is correct even without knowing the key.

The code for this client is present in the client.py directory and below. The client first registers itself into the server, storing its signature in the server. The signature depends on the password and on a private key which is kept.

Then the client will contact the server and request a token. This token can be used to build a proof together with the password, and is sent to the server, which replies with the result.

The client will ask for a password twice, and you can provide a different password to see what happens (it will fail to prove the identity as expected).

from getpass import getpass
import requests
from noknow.core import ZK, ZKSignature, ZKParameters, ZKData, ZKProof
import uuid


print("Registering client")
client_zk = ZK.new(curve_name="secp256k1", hash_alg="sha3_256")
clientid = uuid.uuid4().hex
password = getpass("Enter Password: ")
# Create signature and send to server
signature = client_zk.create_signature(password)
print("Client: signature ", signature.to_json())
r = requests.post("http://localhost:5000/register", json={"clientid": clientid, "sig": signature.to_json()})
print(r.status_code)

input("Press Enter to continue...")

print("Logging in")
password = getpass("Enter Password: ")
print("Getting token...")
r = requests.post("http://localhost:5000/login", json={"clientid": clientid})
print("Token: ", r.json())
token = r.text
proof = client_zk.sign(password, token).to_json()

print("Proof: ", proof)
print("Sending proof...")

r = requests.post("http://localhost:5000/login", json={"clientid": clientid, "proof": proof})
print(r.text)

TASKS:

  • Obtain the code install the dependencies
  • Execute the server in one terminal and the client on another terminal
  • Provide the same, or different passwords
  • Analyze the messages exchanged

References

Previous
Next