@virtonetwork/authenticators-webauthn
Version:
An Authenticator compatible with KreivoPassSigner that uses the WebAuthn standard
146 lines (145 loc) • 6.26 kB
JavaScript
import { Binary, Blake2256 } from "@polkadot-api/substrate-bindings";
import { Assertion } from "./types.js";
import { InMemoryCredentialsHandler } from "./in-memory-credentials-handler.js";
export { InMemoryCredentialsHandler };
/** Fixed authority id for Kreivo pass‑key attestors. */
export const KREIVO_AUTHORITY_ID = Binary.fromText("kreivo_p".padEnd(32, "\0"));
/**
* Browser‑side Authenticator that wraps the WebAuthn API.
*
* The generic type parameter `<number>` indicates that the **context**
* carried inside attestations and assertions is the block number that
* originated the challenge.
*
* @implements {Authenticator<number>}
*/
export class WebAuthn {
userId;
getChallenge;
/**
* SHA‑256 hash of {@link userId}. Filled once by {@link setup} and reused
* for all WebAuthn operations.
*/
hashedUserId = new Uint8Array(32);
credentialId;
getPublicKeyCreateOptions;
getPublicKeyRequestOptions;
onCreatedCredentials;
/**
* Creates a new WebAuthn helper.
*
* @param userId - Logical user identifier (e‑mail, DID, etc.).
* @param [credentialId] - Raw credential id obtained from a previous
* registration flow; omit it if the user must enrol a new pass‑key.
*/
constructor(userId, getChallenge, { publicKeyCreateOptions, publicKeyRequestOptions, onCreatedCredentials, } = new InMemoryCredentialsHandler()) {
this.userId = userId;
this.getChallenge = getChallenge;
this.getPublicKeyCreateOptions = publicKeyCreateOptions;
this.getPublicKeyRequestOptions = publicKeyRequestOptions;
this.onCreatedCredentials = onCreatedCredentials;
}
/**
* Pre‑computes {@link hashedUserId}.
*
* Must be awaited **once** before any other interaction.
* Returns `this` for fluent chaining.
*/
async setup() {
this.hashedUserId = await WebAuthn.getHashedUserId(this.userId);
return this;
}
static async getHashedUserId(userId) {
return new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(userId)));
}
/**
* Deterministic identifier of the hardware/software authenticator
* (`deviceId = Blake2‑256(credentialId)`).
*
* @returns DeviceId suitable for on‑chain storage.
* @throws Error If this instance does not yet know a credential id.
*/
async getDeviceId() {
if (!this.credentialId) {
throw new Error("credentialId unknown – call register() first or inject it via constructor/setCredentialId()");
}
return Binary.fromBytes(Blake2256(this.credentialId));
}
/**
* Registers a **new** resident credential (pass‑key) with the user’s
* authenticator and returns a SCALE‑ready attestation.
*
* @param blockNumber - The number of the block whose hash seeds the challenge.
* @param blockHash - The block hash used to derive a deterministic challenge.
* @param [displayName=this.userId] - Friendly name shown by the authenticator.
*
* @throws Error If this instance already has a credential id.
* @returns {Promise<TAttestation<number>>} SCALE‑encoded attestation object.
*/
async register(blockNumber, displayName = this.userId) {
if (this.credentialId) {
throw new Error("Already have a credentialId; no need to register");
}
const challenge = await this.getChallenge(blockNumber, new Uint8Array([]));
const credentials = (await navigator.credentials.create({
publicKey: await this.getPublicKeyCreateOptions(challenge, {
id: this.hashedUserId,
name: this.userId,
displayName,
}),
}));
const response = credentials.response;
const { attestationObject, clientDataJSON } = response;
// Save raw credential id for future auth calls
this.credentialId = new Uint8Array(credentials.rawId);
// Ensure publicKey is obtained in the registration process.
const publicKey = response.getPublicKey();
if (!publicKey) {
throw new Error("The credentials don't expose a public key. Please use another authenticator device.");
}
await this.onCreatedCredentials(this.userId, credentials);
return {
meta: {
authority_id: KREIVO_AUTHORITY_ID,
device_id: await this.getDeviceId(),
context: blockNumber,
},
authenticator_data: Binary.fromBytes(new Uint8Array(attestationObject)),
client_data: Binary.fromBytes(new Uint8Array(clientDataJSON)),
public_key: Binary.fromBytes(new Uint8Array(publicKey)),
};
}
/**
* Signs an arbitrary challenge with the pass‑key and produces a
* {@link TPassAuthenticate} payload understood by `PassSigner`.
*
* @param challenge - 32‑byte buffer supplied by the runtime.
* @param context - Block number (or any numeric context expected by the pallet).
*
* @returns SCALE‑encoded authentication payload.
* @throws Error If no credential id is available.
*/
async authenticate(context, xtc) {
const challenge = await this.getChallenge(context, xtc);
const cred = (await navigator.credentials.get({
publicKey: await this.getPublicKeyRequestOptions(this.userId, challenge),
}));
const { authenticatorData, clientDataJSON, signature } = cred.response;
return {
deviceId: await this.getDeviceId(),
credentials: {
tag: "WebAuthn",
value: Assertion.enc({
meta: {
authority_id: KREIVO_AUTHORITY_ID,
user_id: Binary.fromBytes(this.hashedUserId),
context,
},
authenticator_data: Binary.fromBytes(new Uint8Array(authenticatorData)),
client_data: Binary.fromBytes(new Uint8Array(clientDataJSON)),
signature: Binary.fromBytes(new Uint8Array(signature)),
}),
},
};
}
}