Closed korywka closed 1 year 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.
I mean publishing it on deno.land/x: https://deno.land/manual@main/publishing
Buffer
is node.js - only type as far as I know. I think all Buffer
- specific logic needs to be rewrite to native ArrayBuffer
.
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...
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.
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
?
import { webcrypto } from 'crypto';
import { crypto } from "https://deno.land/std@0.159.0/crypto/mod.ts";
crypto
globalSo 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.
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
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.
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:
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.
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.
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
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):
Once I figured that out RSA support using SubtleCrypto followed pretty quickly as it didn't involve anything unique like this 🎉
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;' becomes
import { } from '.iso/index.js';`.) Fingers crossed 🤞
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
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()
🤯
Still plenty of work ahead of me, but I'm finally seeing results 😌
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:
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:
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.
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.
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 👍