MasterKale / SimpleWebAuthn

WebAuthn, Simplified. A collection of TypeScript-first libraries for simpler WebAuthn integration. Supports modern browsers, Node, Deno, and more.
https://simplewebauthn.dev
MIT License
1.61k stars 135 forks source link

Deno support #268

Closed korywka closed 1 year ago

korywka commented 2 years ago

Don't take it as a request to do it here and now. But Deno support would be nice. Thanks for your qualitative webauthn JS helpers and docs 👍

MasterKale commented 2 years ago

What is currently broken about trying to use SimpleWebAuthn in a Deno project? If there's an error raised in your project then please provide a reproduction I can use on my end to recreate and begin strategizing a fix.

korywka commented 2 years ago

I mean publishing it on deno.land/x: https://deno.land/manual@main/publishing

korywka commented 2 years ago

Buffer is node.js - only type as far as I know. I think all Buffer - specific logic needs to be rewrite to native ArrayBuffer.

https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/registration/generateRegistrationOptions.ts#L19

image
tmountjr commented 2 years ago

Isn't there a native equivalent in the std io library? https://deno.land/std@0.76.0/node/buffer.ts

I had a similar issue where I was serializing a Buffer from node into JSON and it was coming out as { data: [ 1, 2, 3, ... ], type: 'Buffer' }

I basically had to write my own implementation of "serialized buffer to actual buffer" - if the underlying library used an array (which i feel like is Data Structures 101, every language should have that, right?) the actual distribution of that library for Node could have a built-in serializer/deserializer that used the Buffer, while the Deno distribution could have a serializer/deserializer for ArrayBuffer.

One other route that I see somewhere in the code is that the Node buffer got base64'd into a string, then decoded where needed. Maybe that's an option? Though I imagine that might be slower overall...

MasterKale commented 2 years ago

I wonder if there's a performance benefit to be had from dropping all the serialization for "logic that handles serialized representations." Back when I started this project I was not confident enough in using Buffer's to consider such options. But now, with all of the experience I have thus far, the answer may really be to not be afraid to work with JSON.stringify()'s representation of ArrayBuffers on the front end and back end...

A Buffer is a 'view' just like Uint8Array, in fact, Buffer is simply a class that inherits from Uint8Array with some optimizations for use cases where the buffer size is small.

Some random Quora post made this connection for me the other day, and so I think you're right (in the related Discussion) that refactoring to use a more widely available type like Uint8Array will be better in the long run than any attempt to polyfill.

MasterKale commented 2 years ago

The thought just occurred to me: import paths to standard lib functionality in Node and Deno as so completely different, how do I detect the availability of API's like WebCrypto?

So now I'm not sure what the goal should be. Would each runtime need its own package? Do I try to build and maintain helper methods to hide away where the method gets imported from? And how would TypeScript handle such a thing? 😱

Gotta think on this more.

tmountjr commented 2 years ago

Someone showed me a trick like this:

import('@simplewebauthn/browser')
  .then((imports) => {
    const { browserSupportsWebAuthn } = imports
    this.webAuthnSupported = browserSupportsWebAuthn()
    this.webAuthnSupportWait = false
  })

As long as the import() method is available across all three (which it should be? probably?) you might be able to dynamically import the correct library for the environment. That might be doing some other kind of detection, or maybe just wrapping a few try...catch statements.

Someone should write a node<->deno transpiler. :D

korywka commented 2 years ago

Deno have global crypto just like browser. So code for Deno will be 100% the same as for browser. Example of my WebAuthn code that works in both environments without any imports.

const cryptoKey = await crypto.subtle.importKey("spki", publicKey, algorithm, false, ["verify"]);
const verified = await crypto.subtle.verify(algorithm, cryptoKey, signature, concatenatedBuffer);

But I do not verify attestation, just signature. Maybe runtime crypto will be not enough for attestation verification, and you need this package.

For node it is really a problem, cause node do not have crypto in global scope. Example how I got around this: https://github.com/korywka/crypto-aes-gcm Maybe you will find more elegant solution.

MasterKale commented 2 years ago

Today's discovery: the cbor library I use is heavily dependent on the availability of Node's Buffer. I use this in a few places:

Screenshot 2022-11-05 at 10 09 22 AM

The library doesn't know how to handle Uint8Array when I substitute it in for Buffer, and so this is currently a blocker to this effort. I'll have to try and find an isomorphic replacement.

korywka commented 2 years ago

cbor-x should handle both: https://github.com/kriszyp/cbor-x/blob/master/encode.js#L8

also I think it is possible to implement passkeys without any package at all with getAuthenticatorData (already did it, can share code if it is needed). for attestation verification it can be additional package. i guess a lot of users will use passkeys, that as far as I know doesn't use attestation.

MasterKale commented 2 years ago

Is anyone around who's well-versed in SubtleCrypto? I'm in the final stages of this "isomorphic" refactor (see #299) and have managed to replace all but one use of Node's crypto. I'm trying now to use SubtleCrypto.verify() to verify a known verifiable hash, but I'm not sure what I'm doing wrong:

const { createVerify, webcrypto } = import('node:crypto');

const target = {
  hashAlgorithm: 'sha256',
  publicKeyPEM: '-----BEGIN PUBLIC KEY-----\n' +
    'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEPzMMB0nPKu9zvu6tvvyaP7MlGKJi\n' +
    '4zazYQw5kyCjGymyHxcnMCwcj4llYwRY+MedgOCQzcz/TgKeabY4yFQyrA==\n' +
    '-----END PUBLIC KEY-----',
  publicKey: 'a50102032620012158203f330c0749cf2aef73beeeadbefc9a3fb32518a262e336b3610c399320a31b29225820b21f1727302c1c8f8965630458f8c79d80e090cdccff4e029e69b638c85432ac',
  publicKeyDER: '3059301306072a8648ce3d020106082a8648ce3d030107034200043f330c0749cf2aef73beeeadbefc9a3fb32518a262e336b3610c399320a31b29b21f1727302c1c8f8965630458f8c79d80e090cdccff4e029e69b638c85432ac',
  signatureBase: '49960de5880e8c687434170f6476605b8fe4aeb9a28632c7995cf3ba831d97634561f598a8adce000235bcc60a648b0b25f1f05503004c014cb00ec3d810ea2e708dfea3e8c1d49e8ab7a400c4b31ad56c052a3d5b362e2901a91602c657fa92788007839a870bed25e797cbf351f2e47eb38621599db354618d4d436085a5c5c1a100a50102032620012158203f330c0749cf2aef73beeeadbefc9a3fb32518a262e336b3610c399320a31b29225820b21f1727302c1c8f8965630458f8c79d80e090cdccff4e029e69b638c85432ac6fd7c1a810d55544a222c0e22ea57a8687d09dda5fc5f614c5faddeb8b91cb61',
  signature: '3045022100f828cb7b3121e52f37d328f2dc322106fd8570c7c50daeac8684ab5e2eeaf34702207c96d9428a9a6c30822c87a02a328665507fc11f46d74f5c6c376ccaa02132ef'
};

(async () => {
  const { hashAlgorithm, publicKeyDER, publicKeyPEM, signature, signatureBase } = target;

  const publicKeyDERBytes = Buffer.from(publicKeyDER, 'hex');
  const signatureBytes = Buffer.from(signature, 'hex');
  const signatureBaseBytes = Buffer.from(signatureBase, 'hex');

  /**
   * Attempting to make things work with the SubtleCrypto Web API
   *
   * https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/importKey
   * https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/verify
   */
  const subtleKey = await webcrypto.subtle.importKey(
    "spki",
    publicKeyDERBytes,
    { name: 'ECDSA', namedCurve: "P-256" },
    false,
    ["verify"]
  );

  // SubtleCrypto
  console.log(
    await webcrypto.subtle.verify(
      { name: 'ECDSA', hash: { name: 'SHA-256' } },
      subtleKey,
      signatureBytes,
      signatureBaseBytes,
    )
  ); // false

  /**
   * Using Node's `crypto.createVerify()`
   *
   * https://nodejs.org/api/crypto.html#verifyverifyobject-signature-signatureencoding
   */
  console.log(
    createVerify(hashAlgorithm)
      .update(signatureBaseBytes)
      .verify(publicKeyPEM, signatureBytes)
  ); // true
})();

If someone is able to help out here I'd be grateful, there's not a lot to go off of than MDN's examples of SubtleCrypto's methods and I'm kinda stumped.

Edit: After a bit of experimenting I think the "jwk" format is the way to go. I'm still getting false though so it didn't magically solve my problem...

const subtleKey = await webcrypto.subtle.importKey(
    "jwk",
    {
      kty: 'EC',
      crv: 'P-256',
      x: 'PzMMB0nPKu9zvu6tvvyaP7MlGKJi4zazYQw5kyCjGyk',
      y: 'sh8XJzAsHI-JZWMEWPjHnYDgkM3M_04Cnmm2OMhUMqw',
      ext: true
    },
    {
      name: 'ECDSA',
      namedCurve: "P-256",
    },
    true,
    ["verify"]
  );

  // SubtleCrypto
  console.log(
    await webcrypto.subtle.verify(
      {
        name: 'ECDSA',
        hash: { name: 'SHA-256' },
      },
      subtleKey,
      signatureBytes.buffer,
      signatureBaseBytes.buffer,
    )
  ); // false
linuxwolf commented 1 year ago

Saw your toot. I believe the issue you're having is one of output encoding mismatch: Node's crypto module uses a DER-encoded sequence of R and S; WebCrypto uses a simple concatenation of (padded) R and S.

Hopefully this helps work out the encode and decode (from node-jose).

MasterKale commented 1 year ago

Hey @linuxwolf, thank you for the assist! You were right, it was the case of my needing to peel apart the DER-encoded R and S (from code I'm working on in #299):

https://github.com/MasterKale/SimpleWebAuthn/blob/2d122b488613be7a913f4589d07b9ce4b4037b69/packages/server/src/helpers/iso/isoCrypto.ts#L178

Once I figured that out RSA support using SubtleCrypto followed pretty quickly as it didn't involve anything unique like this 🎉

MasterKale commented 1 year ago

299 is in a really good place right now. The code rewrite itself is largely done, and now I want to test it out in some alternative runtimes like Deno and CloudFlare Workers.

Unfortunately, Deno has already proven to be a tough thing to even get a simple script running. I can't use 1.28's new npm: library import prefix with the local copy of the library, I can't relatively import from dist/ because none of the output from tsc has file extensions which Deno requires, and I'm currently transpiling to CommonJS which Deno won't consume either. And if I tried updating import paths to explicitly include file extensions, TypeScript complains with ts(2691)...

So at this point I'm thinking it's time to start outputting an ESM build of @simplewebauthn/server too, alongside the CommonJS one for Node compatibility. I already use Rollup for @simplewebauthn/browser so I'll start playing around with that. Perhaps there's an extension for Rollup that can take care of adding file extensions after compiling to ESM?

In the meantime, if any of you subscribed to this issue have tips on how best to build the project to appease Deno's requirements, perhaps you can point me in the right direction? There are many variables I can think of that I'll end up manipulating through trial and error until something builds that might be suitable for environments that only support ESM. If someone has expertise in this project I'd be grateful for any time you could save me on the next step of this endeavor.

For now I'll play around with Rollup to output ESM-compatible code (i.e. import/export) that automatically includes file extensions in the import paths (e.g. import { } from './iso;' becomesimport { } from '.iso/index.js';`.) Fingers crossed 🤞

MasterKale commented 1 year ago

Note to self: I wonder if something like this might work, rewrite to work in Deno and compile to Node instead: https://github.com/fromdeno/deno2node

From https://github.com/denoland/deno/discussions/9569

MasterKale commented 1 year ago

I experimented with making server "Deno-first" (explicit file extensions on relative imports, "npm:" prefixes to use 1.28's ability to install NPM packages) and got it to run a call of generateRegistrationOptions() 🤯

swan-iso-deno

Still plenty of work ahead of me, but I'm finally seeing results 😌

MasterKale commented 1 year ago

Last night I got server to run in a CloudFlare Worker without needing to make any changes - it seems CloudFlare workers support CommonJS modules? I couldn't find anything to corroborate that, but hey, it works:

Screenshot 2022-11-29 at 8 49 33 AM

And it's bordering on ridiculous, but for fun I also managed to get server running in a Vite + React project. I had to temporarily switch two packages to build to ESNext modules, though, which would negatively impacts Node support:

Screenshot 2022-11-29 at 11 19 58 PM

I'm kinda wondering what to make of this exploration into how to get server running in more ESM-first places. Ssetting aside front-end browser support, I want SimpleWebAuthn to support more ESM-first environments. However to do so it seems I might need to generate two builds in dist/ (one "module": "ESNext", another "module": "CommonJS") and then update package.json with something like this to help various environments figure out which build to import:

"main": "dist/node.js",
"types": "./dist/node.d.ts",
"exports": {
  "node": "./dist/node.js",
  "default": "./dist/esm.js"
},

Honestly I should probably punt on that part of things for now, and take the win that the code in #299 is largely completely decoupled from any Node-specific APIs. That'd set a more solid foundation for subsequent work to figure out how else I might support non-Node runtimes.

MasterKale commented 1 year ago

I think I can close this out now. I just a quick test with Deno 1.28 and 1.29 using the latest @simplewebauthn/server@7.0.0, and the code actually ran!

import { generateRegistrationOptions } from 'npm:@simplewebauthn/server@7.0.0';

(async () => {
  const rpName = 'SimpleWebAuthn (Deno)';
  const rpID = 'not.real';
  const userID = '1234';
  const userName = 'usernameHere';
  const timeout = 1;
  const attestationType = 'indirect';

  console.log('Calling generateRegistrationOptions()');

  const options = await generateRegistrationOptions({
    rpName,
    rpID,
    userID,
    userName,
    timeout,
    attestationType,
  });

  console.log(options);
})();
$> deno run --allow-read --allow-env --allow-ffi --unstable ./index.ts
Calling generateRegistrationOptions()
{
  challenge: "_SkQCFNKrbhIWQvuKDIpS45-m6fTkW1EG7BwrUbAmjk",
  rp: { name: "SimpleWebAuthn (Deno)", id: "not.real" },
  user: { id: "1234", name: "usernameHere", displayName: "usernameHere" },
  pubKeyCredParams: [
    { alg: -8, type: "public-key" },
    { alg: -7, type: "public-key" },
    { alg: -36, type: "public-key" },
    { alg: -37, type: "public-key" },
    { alg: -38, type: "public-key" },
    { alg: -39, type: "public-key" },
    { alg: -257, type: "public-key" },
    { alg: -258, type: "public-key" },
    { alg: -259, type: "public-key" }
  ],
  timeout: 1,
  attestation: "indirect",
  excludeCredentials: [],
  authenticatorSelection: {
    residentKey: "preferred",
    userVerification: "preferred",
    requireResidentKey: false
  },
  extensions: { credProps: true }
}

And @simplewebauthn/server is still a CommonJS module in this test, hence my surprise.

My ultimate goal is to output two builds, one CommonJS and one ESM, but I'd say I can now advertise "Deno support" 🚀

Edit: I just realized that, now, I can advertise "unofficial" Deno support because you have to use the --unstable CLI flag, which I'm sure many projects don't want to use in anything production-facing. My ultimate goal is to also output an ESM build of @simplewebauthn/server so that Deno can load the library like a typical Deno package. I'll make a new issue for that when I'm ready to tackle it.