How to Gain Server-Side Trust on the Client-Side? DPoP (RFC 9449)

Verifying client identity is crucial in data transmission, commonly achieved through mutual Transport Layer Security (mTLS i.e. RFC 8705), where both client and server authenticate using digital certificates. However, obtaining client TLS certificates can be complex.

The DPoP (Demonstration of Proof of Possession – RFC 9449) specification, uses cryptographic key pairs instead of TLS certificates, simplifying the process. It securely links a token to a specific HTTP request and client by including a DPoP proof (typically a JWT with a cryptographic signature) with each access token request. This method enhances security against token theft and allows dynamic key generation, operating at the application level.

RFC 9449 vs RFC 8705

RFC 9449 (DPoP) simplifies authentication by letting clients generate a private and public key pair, eliminating the need for a TLS certificate. In contrast, RFC 8705 (mTLS) requires TLS certificates for bidirectional authentication, posing challenges in certificate distribution to clients.

Challenges with DPoP:

  • Securely storing and distributing public keys: Ensuring clients can register and update their keys securely without risk of unauthorized access or manipulation.
FeatureDPoP (RFC 9449)mTLS (RFC 8705)Token Binding (RFC 8471)
AuthenticationClient-side signature using key pairsClient-side TLS certificateClient-side cryptographic token
Integration LayerApplication layerTransport layer (HTTPS)Application layer
Complexity levelSimpler key managementRequires TLS certificate infrastructureBrowser support is limited
ProtectionToken theft, replay, unauthorized usageMan-in-the-middle attacks, eavesdroppingToken theft, replay
PerformanceLess overhead than mTLSSome overhead due to TLS handshakeNegligible overhead
FlexibilityCan be used with various authentication flowsTied to TLS-based connectionsCan be used with different protocols

1. Client: Generate Key pair using Web Crypto API (https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API)

  • Private keys can be non-extractable for more security.
  • For demonstration purposes, the encryption algorithm is fixed, and the JWK format is not utilized. 
  • You can execute code snippets in the browser console to view them in action.
const payload = { "key": "abcd" };
async function generateKeyPair() {
    const keyPair = await window.crypto.subtle.generateKey(
        {
            name: "RSASSA-PKCS1-v1_5",
            modulusLength: 2048,
            publicExponent: new Uint8Array([1, 0, 1]),
            hash: { name: "SHA-256" },
        },
        true,
        ["sign", "verify"]
    );
    return keyPair;
}
const keys =  await generateKeyPair()
console.log(keys);

2. Client: Sign a request using the Private key

//gen signature
async function signData(privateKey, data) {
    const encoder = new TextEncoder();
    const encodedData = encoder.encode(data);
    const signature = await window.crypto.subtle.sign(
        "RSASSA-PKCS1-v1_5",
        privateKey,
        encodedData
    );

    return signature;
}
const signature = await signData(keys.privateKey, payload);
console.log(signature);

3. Server: Signature Verification on the server side

//verify signature
const receivedPayload = { "key": "abcd" };
async function verifySignature(publicKey, signature, data) {
  const encoder = new TextEncoder();
  const encodedData = encoder.encode(data);

  const isValid = await window.crypto.subtle.verify(
    {
      name: "RSASSA-PKCS1-v1_5",
      hash: { name: "SHA-256" },
    },
    publicKey,
    signature,
    encodedData
  );

  return isValid;
}
const isValid = await verifySignature(keys.publicKey, sigV4, receivedPayload);
console.log(isValid);

4. Server: process data and send back requested data if the signature is valid

Recursive Implementation for Asyn.series

We are going to look into a recursive solution for the series method. Note that recursive always comes with a performance dent with a deeper call stack of instruction execution in the hardware.

The idea here will start with a function1 from the tasks array and then pass a wrapped callback function on reading the result of function1, then invoking function2 and vice versa. If any error occurs in the process, the algorithom will exit by calling the final callback with error and results collection.

    fn((error, result) => {
      if (error) {
        callback(error, results);
        return;
      }
      results.push(result);
      //invoke a recursive call to next method 
    });

The full code is available in the below code block and it is ready to execute in your console for analysis or debug to see how this works in action.

const sampleTasks =  [ function (callback) { setTimeout(function () { console.log("1"); callback(null, "one"); }, 200); }, function (callback) { setTimeout(function () { console.log("2"); callback(null, "two"); }, 100); }, function (callback) { setTimeout(function () { console.log("3"); callback(null, "three"); }, 90); }, function (callback) { setTimeout(function () { console.log("4"); callback(Error("some error")); }, 10); }, function (callback) { setTimeout(function () { console.log("5"); callback(null, "five"); }, 1000); }, ];
 
function onSeriesCompleted(error, results) {
  console.log("Error stack", error);
  console.log("Results of successfully executed tasks", results);
}

function startTask(tasks, callback, counter = 0, results = []) {
  if (counter < tasks.length) {
    const fn = tasks[counter];
    const fn2 = tasks[counter + 1];
    fn((error, result) => {
      if (error) {
        callback(error, results);
        return;
      }
      results.push(result);
      startTask(tasks, callback, ++counter, results);
    });
  }
}

function series(tasks, finalCallback) {
  startTask(tasks, finalCallback);
}

series(sampleTasks, onSeriesCompleted);

Output

Polyfill for Async.series(tasks, callback)

The series method was quite a different one when we compared it to other sequential call stacks. The series method will take two arguments one is an array of functions and another one is the finalCallback which is optional to call with the first occurred error and obtained results until then.

 

async.series( 
  [fn-1(callback), fn-2(callback), ... fn-n(callback)], 
  finalCallback( error, results ){ ... }
);

Now will talk about the implementation. We can solve this problem by making Asyncify each task from the tasks array. This way, we can extract the result from each task, review for errors, and move to the next task. Let’s move to the code implementation.

Asyncify a task


function Asyncify(task) {
  return new Promise((resolve, reject) =&gt; {
    task((error, result) =&gt; (error ? reject(error) : resolve(result)));
  });
}

That’s all we have completed with the implementation, and we need to use this method to queue up the tasks in a sequential way of execution.

async function series(tasks, finalCallback) {
  const allResults = [];
  for (let i = 0; i &lt; tasks.length; i++) {
    try {
      allResults.push(await Asyncify(tasks[i]));
    } catch (error) {
      finalCallback(error, allResults);
      break;
    }
  }
  console.log(allResults);
  finalCallback(null, allResults);
}

This solution is based on Promise API, and you can execute the below code in the browser console for quick analysis. And we can also implement a recursive-based solution and will look into it in our next post.
The full spec you can read from https://caolan.github.io/async/v3/docs.html#series.

Now, add some sample tasks to test and see how this works.

const sampleTasks =  [ function (callback) { setTimeout(function () { console.log("1"); callback(null, "one"); }, 200); }, function (callback) { setTimeout(function () { console.log("2"); callback(null, "two"); }, 100); }, function (callback) { setTimeout(function () { console.log("3"); callback(null, "three"); }, 90); }, function (callback) { setTimeout(function () { console.log("4"); callback(Error("some error")); }, 10); }, function (callback) { setTimeout(function () { console.log("5"); callback(null, "five"); }, 1000); }, ];

function onSeriesCompleted(error, results) {
  console.log("Error stack", error);
  console.log("Results of successfully executed tasks", results);
}

function Asyncify(task) {
  return new Promise((resolve, reject) => {
    task((error, result) => (error ? reject(error) : resolve(result)));
  });
}

async function series(tasks = [], finalCallback) {
  const allResults = [];
  for (let i = 0; i < tasks.length; i++) {
    try {
      allResults.push(await Asyncify(tasks[i]));
    } catch (error) {
      finalCallback?.(error, allResults);
      return; //remove if you want continue even after error
    }
  }
  finalCallback?.(null, allResults);
}

series(sampleTasks, onSeriesCompleted);

Output