shogun-core
Version:
SHOGUN SDK - Core library for Shogun SDK
451 lines (450 loc) • 16.5 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.Webauthn = void 0;
/**
* Constants for WebAuthn configuration
*/
const MIN_USERNAME_LENGTH = 3;
const MAX_USERNAME_LENGTH = 64;
const ethers_1 = require("ethers");
const errorHandler_1 = require("../../utils/errorHandler");
const eventEmitter_1 = require("../../utils/eventEmitter");
const logger_1 = require("../../utils/logger");
const types_1 = require("./types");
/**
* Constants for WebAuthn configuration
*/
const DEFAULT_CONFIG = {
rpName: "Shogun Wallet",
timeout: 60000,
userVerification: "preferred",
attestation: "none",
authenticatorAttachment: "platform",
requireResidentKey: false,
};
/**
* Main WebAuthn class for authentication management
*/
class Webauthn extends eventEmitter_1.EventEmitter {
config;
gunInstance;
credential;
abortController = null;
/**
* Creates a new WebAuthn instance
*/
constructor(gunInstance, config) {
super();
this.gunInstance = gunInstance;
this.credential = null;
// Merge default config with provided config
this.config = {
...DEFAULT_CONFIG,
...config,
rpId: config?.rpId ?? window.location.hostname.split(":")[0],
};
}
/**
* Validates a username
*/
validateUsername(username) {
if (!username || typeof username !== "string") {
throw new Error("Username must be a non-empty string");
}
if (username.length < MIN_USERNAME_LENGTH ||
username.length > MAX_USERNAME_LENGTH) {
throw new Error(`Username must be between ${MIN_USERNAME_LENGTH} and ${MAX_USERNAME_LENGTH} characters`);
}
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
throw new Error("Username can only contain letters, numbers, underscores and hyphens");
}
}
/**
* Creates a new WebAuthn account with retry logic
*/
async createAccount(username, credentials, isNewDevice = false) {
try {
this.validateUsername(username);
const maxRetries = 3;
let lastError = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const result = await this.generateCredentials(username, credentials, isNewDevice);
if (result.success) {
this.emit(types_1.WebAuthnEventType.DEVICE_REGISTERED, {
type: types_1.WebAuthnEventType.DEVICE_REGISTERED,
data: { username, deviceInfo: result.deviceInfo },
timestamp: Date.now(),
});
return result;
}
lastError = new Error(result.error ?? "Unknown error");
}
catch (error) {
lastError = error;
if (attempt < maxRetries) {
await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
continue;
}
}
}
throw lastError || new Error("Failed to create account after retries");
}
catch (error) {
this.emit(types_1.WebAuthnEventType.ERROR, {
type: types_1.WebAuthnEventType.ERROR,
data: { error: error.message },
timestamp: Date.now(),
});
throw error;
}
}
/**
* Authenticates a user with timeout and abort handling
*/
async authenticateUser(username, salt, options = {}) {
try {
this.validateUsername(username);
if (!salt) {
const error = new Error("No WebAuthn credentials found for this username");
errorHandler_1.ErrorHandler.handle(errorHandler_1.ErrorType.WEBAUTHN, "NO_CREDENTIALS", error.message, error);
return { success: false, error: error.message };
}
// Cancel any existing authentication attempt
this.abortAuthentication();
// Create new abort controller
this.abortController = new AbortController();
const timeout = options.timeout || this.config.timeout;
const timeoutId = setTimeout(() => this.abortController?.abort(), timeout);
try {
const challenge = this.generateChallenge(username);
const assertionOptions = {
challenge,
allowCredentials: [],
timeout,
userVerification: options.userVerification || this.config.userVerification,
rpId: this.config.rpId,
};
const assertion = (await navigator.credentials.get({
publicKey: assertionOptions,
signal: this.abortController.signal,
}));
if (!assertion) {
throw new Error("WebAuthn verification failed");
}
const { password } = this.generateCredentialsFromSalt(username, salt);
const deviceInfo = this.getDeviceInfo(assertion.id);
const result = {
success: true,
username,
password,
credentialId: this.bufferToBase64(assertion.rawId),
deviceInfo,
};
this.emit(types_1.WebAuthnEventType.AUTHENTICATION_SUCCESS, {
type: types_1.WebAuthnEventType.AUTHENTICATION_SUCCESS,
data: { username, deviceInfo },
timestamp: Date.now(),
});
return result;
}
finally {
clearTimeout(timeoutId);
this.abortController = null;
}
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown WebAuthn error";
this.emit(types_1.WebAuthnEventType.AUTHENTICATION_FAILED, {
type: types_1.WebAuthnEventType.AUTHENTICATION_FAILED,
data: { username, error: errorMessage },
timestamp: Date.now(),
});
errorHandler_1.ErrorHandler.handle(errorHandler_1.ErrorType.WEBAUTHN, "AUTH_ERROR", errorMessage, error);
return { success: false, error: errorMessage };
}
}
/**
* Aborts current authentication attempt
*/
abortAuthentication() {
if (this.abortController) {
this.abortController.abort();
this.abortController = null;
}
}
/**
* Gets device information
*/
getDeviceInfo(credentialId) {
const platformInfo = this.getPlatformInfo();
return {
deviceId: credentialId,
timestamp: Date.now(),
name: platformInfo.name,
platform: platformInfo.platform,
lastUsed: Date.now(),
};
}
/**
* Gets platform information
*/
getPlatformInfo() {
if (typeof navigator === "undefined") {
return { name: "unknown", platform: "unknown" };
}
const platform = navigator.platform;
const userAgent = navigator.userAgent;
if (/iPhone|iPad|iPod/.test(platform)) {
return { name: "iOS Device", platform };
}
if (/Android/.test(userAgent)) {
return { name: "Android Device", platform };
}
if (/Win/.test(platform)) {
return { name: "Windows Device", platform };
}
if (/Mac/.test(platform)) {
return { name: "Mac Device", platform };
}
if (/Linux/.test(platform)) {
return { name: "Linux Device", platform };
}
return { name: "Unknown Device", platform };
}
/**
* Generates a challenge for WebAuthn operations
*/
generateChallenge(username) {
const timestamp = Date.now().toString();
const randomBytes = this.getRandomBytes(32);
const challengeData = `${username}-${timestamp}-${this.uint8ArrayToHex(randomBytes)}`;
return new TextEncoder().encode(challengeData);
}
/**
* Gets cryptographically secure random bytes
*/
getRandomBytes(length) {
if (typeof window !== "undefined" && window.crypto) {
return window.crypto.getRandomValues(new Uint8Array(length));
}
throw new Error("No cryptographic implementation available");
}
/**
* Converts Uint8Array to hexadecimal string
*/
uint8ArrayToHex(arr) {
return Array.from(arr)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
/**
* Converts ArrayBuffer to URL-safe base64 string
*/
bufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
const binary = bytes.reduce((str, byte) => str + String.fromCharCode(byte), "");
return btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
/**
* Generates credentials from username and salt
*/
generateCredentialsFromSalt(username, salt) {
const data = ethers_1.ethers.toUtf8Bytes(username + salt);
return {
password: ethers_1.ethers.sha256(data),
};
}
/**
* Checks if WebAuthn is supported
*/
isSupported() {
return (typeof window !== "undefined" && window.PublicKeyCredential !== undefined);
}
/**
* Creates a WebAuthn credential for registration
*/
async createCredential(username) {
try {
const challenge = crypto.getRandomValues(new Uint8Array(32));
const userId = new TextEncoder().encode(username);
const publicKeyCredentialCreationOptions = {
challenge,
rp: {
name: "Shogun Wallet",
...(this.config.rpId !== "localhost" && { id: this.config.rpId }),
},
user: {
id: userId,
name: username,
displayName: username,
},
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
timeout: this.config.timeout,
attestation: this.config.attestation,
authenticatorSelection: {
authenticatorAttachment: this.config.authenticatorAttachment,
userVerification: this.config.userVerification,
requireResidentKey: this.config.requireResidentKey,
},
};
(0, logger_1.logDebug)("Attempting to create credentials with options:", publicKeyCredentialCreationOptions);
const credential = await navigator.credentials.create({
publicKey: publicKeyCredentialCreationOptions,
});
if (!credential) {
throw new Error("Credential creation failed");
}
(0, logger_1.logDebug)("Credentials created successfully:", credential);
const webAuthnCredential = credential;
// Convert to WebAuthnCredentialData
const credentialData = {
id: webAuthnCredential.id,
rawId: webAuthnCredential.rawId,
type: webAuthnCredential.type,
response: {
clientDataJSON: webAuthnCredential.response.clientDataJSON,
},
getClientExtensionResults: webAuthnCredential.getClientExtensionResults,
};
// Add additional response properties if available
if ("attestationObject" in webAuthnCredential.response) {
credentialData.response.attestationObject = webAuthnCredential.response.attestationObject;
}
this.credential = credentialData;
return credentialData;
}
catch (error) {
(0, logger_1.logError)("Detailed error in credential creation:", error);
const errorMessage = error instanceof Error ? error.message : "Unknown error";
throw new Error(`Error creating credentials: ${errorMessage}`);
}
}
/**
* Generates WebAuthn credentials
*/
async generateCredentials(username, existingCredential, isLogin = false) {
try {
if (isLogin) {
const verificationResult = await this.verifyCredential(username);
return {
success: verificationResult.success,
error: verificationResult.error,
credentialId: verificationResult.credentialId,
username: verificationResult.username,
};
}
else {
const credential = await this.createCredential(username);
const credentialId = credential.id;
let publicKey = null;
if (credential?.response?.getPublicKey) {
publicKey = credential.response.getPublicKey();
}
return {
success: true,
credentialId,
publicKey,
};
}
}
catch (error) {
(0, logger_1.logError)("Error in generateCredentials:", error);
const errorMessage = error instanceof Error
? error.message
: "Unknown error during WebAuthn operation";
return {
success: false,
error: errorMessage,
};
}
}
/**
* Verifies a credential
*/
async verifyCredential(username) {
try {
const challenge = crypto.getRandomValues(new Uint8Array(32));
const options = {
challenge,
timeout: this.config.timeout,
userVerification: this.config.userVerification,
...(this.config.rpId !== "localhost" && { rpId: this.config.rpId }),
};
if (this.credential?.rawId) {
options.allowCredentials = [
{
id: this.credential.rawId,
type: "public-key",
},
];
}
const assertion = await navigator.credentials.get({
publicKey: options,
});
if (!assertion) {
return {
success: false,
error: "Credential verification failed",
};
}
return {
success: true,
credentialId: assertion.id,
username,
};
}
catch (error) {
(0, logger_1.logError)("Error verifying credentials:", error);
const errorMessage = error instanceof Error
? error.message
: "Unknown error verifying credentials";
return {
success: false,
error: errorMessage,
};
}
}
/**
* Removes device credentials
*/
async removeDevice(username, credentialId, credentials) {
if (!credentials ||
!credentials.credentials ||
!credentials.credentials[credentialId]) {
return { success: false };
}
const updatedCreds = { ...credentials };
// Make sure credentials exists before modifying it
if (updatedCreds.credentials) {
delete updatedCreds.credentials[credentialId];
}
return {
success: true,
updatedCredentials: updatedCreds,
};
}
/**
* Signs data with the credential
*/
async sign(data) {
const signature = await navigator.credentials.get({
publicKey: {
challenge: new Uint8Array(16),
rpId: this.config.rpId,
},
});
return signature;
}
}
exports.Webauthn = Webauthn;
// Add to global scope if available
if (typeof window !== "undefined") {
window.Webauthn = Webauthn;
}
else if (typeof global !== "undefined") {
global.Webauthn = Webauthn;
}