@agentauth/core
Version:
Core identity and cryptographic primitives for AgentAuth
177 lines (176 loc) • 7.29 kB
JavaScript
/*
* Copyright (c) 2025 AgentAuth
* SPDX-License-Identifier: MIT
*/
import * as secp from '@noble/secp256k1';
import { keccak_256 } from '@noble/hashes/sha3';
import { hmac } from '@noble/hashes/hmac';
import { sha256 } from '@noble/hashes/sha2';
import { v5 as uuidv5 } from 'uuid';
// Set up HMAC for secp256k1 (required by the noble library)
secp.etc.hmacSha256Sync = (k, ...m) => hmac(sha256, k, secp.etc.concatBytes(...m));
/**
* Converts a hex string to Uint8Array
* @param hex The hex string (with or without 0x prefix)
* @returns Uint8Array of bytes
*/
function hexToBytes(hex) {
const cleanHex = hex.replace(/^0x/, '');
if (cleanHex.length % 2 !== 0) {
throw new Error('Hex string must have even length');
}
const bytes = new Uint8Array(cleanHex.length / 2);
for (let i = 0; i < bytes.length; i++) {
const byte = parseInt(cleanHex.substring(i * 2, i * 2 + 2), 16);
if (isNaN(byte)) {
throw new Error('Invalid hex string');
}
bytes[i] = byte;
}
return bytes;
}
/**
* Converts a Uint8Array to hex string
* @param bytes The byte array
* @returns Hex string (without 0x prefix)
*/
function bytesToHex(bytes) {
return Array.from(bytes)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
/**
* DO NOT CHANGE THIS UUID - EVER.
*
* This UUID was generated once using `uuid.v4()` and is now a fixed constant.
* It is a constant namespace for generating AgentAuth UUIDs.
* This ensures that the same agentauth_key will always produce the same agentauth_id.
* If you change this UUID, you will break all existing AgentAuth IDs.
*
* This is a security-critical constant and should NEVER be changed.
*/
const AGENTAUTH_NAMESPACE = '2f5a5c48-c283-4231-8975-9271fe11e86c';
/**
* Parses a private key from any supported format (aa-, 0x, or raw hex).
* @param token The private key in any format.
* @returns The clean 32-byte hex private key (without prefix).
*/
export function parsePrivateKey(token) {
// Remove any prefix (aa-, 0x, or none)
const cleanHex = token.replace(/^(aa-|0x)/, '');
// Validate 32-byte hex format
if (!/^[0-9a-fA-F]{64}$/.test(cleanHex)) {
throw new Error('Invalid private key format: must be 32-byte hex string');
}
return cleanHex;
}
/**
* Derives an EVM-compatible address from a private key.
* @param privateKey The private key in any format (aa-, 0x, or raw hex).
* @returns The EVM-compatible address (0x-prefixed, 20 bytes).
*/
export function deriveAddress(privateKey) {
try {
// Parse and validate private key
const cleanPrivateKey = parsePrivateKey(privateKey);
const privateKeyBytes = hexToBytes(cleanPrivateKey);
// Get uncompressed public key (65 bytes: 0x04 + 32 + 32)
const publicKeyBytes = secp.getPublicKey(privateKeyBytes, false);
// Remove the 0x04 prefix to get the 64-byte coordinate pair
const publicKeyCoords = publicKeyBytes.slice(1);
// Hash with keccak256 and take last 20 bytes for address
const addressBytes = keccak_256(publicKeyCoords).slice(-20);
return '0x' + bytesToHex(addressBytes);
}
catch (error) {
throw new Error('Failed to derive address: The provided private key is invalid.');
}
}
/**
* Generates a stable, deterministic UUIDv5 for a given address.
* @param address The EVM-compatible address (0x-prefixed format).
* @returns The UUIDv5 string representing the stable `agentauth_id`.
*/
export function generateId(address) {
return uuidv5(address, AGENTAUTH_NAMESPACE);
}
/**
* Generates a complete AgentAuth identity with address-based components.
* @param algorithm The algorithm to use. Currently only 'secp256k1' is supported.
* @returns Complete identity with token, address, and ID.
*/
export function generateIdentity(algorithm = 'secp256k1') {
if (algorithm !== 'secp256k1') {
throw new Error(`Unsupported algorithm: ${algorithm}`);
}
// Generate private key
const privateKeyBytes = secp.utils.randomPrivateKey();
const agentauth_token = `aa-${bytesToHex(privateKeyBytes)}`;
// Derive address and ID
const agentauth_address = deriveAddress(agentauth_token);
const agentauth_id = generateId(agentauth_address);
return { agentauth_token, agentauth_address, agentauth_id };
}
/**
* Signs a payload using a private key.
* @param payload The JSON payload to sign.
* @param privateKey The private key in any format (aa-, 0x, or raw hex).
* @returns The hex-encoded signature with 0x prefix (EVM-standard).
*/
export function signPayload(payload, privateKey) {
try {
const messageString = JSON.stringify(payload);
const messageHash = keccak_256(messageString);
// Parse private key (handles any format)
const cleanPrivateKey = parsePrivateKey(privateKey);
const privateKeyBytes = hexToBytes(cleanPrivateKey);
const signature = secp.sign(messageHash, privateKeyBytes);
// EVM-standard signature format: 0x + 64 bytes (r + s) + 1 byte (v/recovery)
const signatureHex = signature.toCompactHex() + signature.recovery.toString(16).padStart(2, '0');
return `0x${signatureHex}`;
}
catch (error) {
throw new Error('Failed to sign payload: The provided private key is invalid.');
}
}
/**
* Verifies a signature against a payload and expected address.
* @param signature The hex-encoded signature with 0x prefix.
* @param payload The JSON payload to verify.
* @param expectedAddress The expected EVM-compatible address (0x-prefixed).
* @returns True if the signature is valid and recovers to the expected address, false otherwise.
*/
export function verifySignature(signature, payload, expectedAddress) {
try {
// Validate expected address format
if (!/^0x[0-9a-fA-F]{40}$/.test(expectedAddress)) {
return false;
}
// Parse signature
if (!signature.startsWith('0x') || signature.length !== 132) {
return false;
}
const signatureHex = signature.slice(2);
const r = signatureHex.slice(0, 64);
const s = signatureHex.slice(64, 128);
const recoveryHex = signatureHex.slice(128, 130);
const recovery = parseInt(recoveryHex, 16);
// Hash the payload
const messageString = JSON.stringify(payload);
const messageHash = keccak_256(messageString);
// Recover public key from signature
const sig = new secp.Signature(BigInt('0x' + r), BigInt('0x' + s)).addRecoveryBit(recovery);
const recoveredPublicKey = sig.recoverPublicKey(messageHash);
// Convert recovered public key to address
const publicKeyBytes = recoveredPublicKey.toRawBytes(false); // uncompressed
const publicKeyCoords = publicKeyBytes.slice(1); // Remove 0x04 prefix
const addressBytes = keccak_256(publicKeyCoords).slice(-20);
const recoveredAddress = '0x' + bytesToHex(addressBytes);
// Compare addresses (case-insensitive)
return recoveredAddress.toLowerCase() === expectedAddress.toLowerCase();
}
catch (error) {
// If any recovery or verification fails, the signature is invalid
return false;
}
}