@instun/sm2-multikey
Version:
A JavaScript library for generating and working with SM2Multikey key pairs and digital signatures. Compatible with both Node.js and fibjs runtimes.
722 lines (666 loc) • 25.5 kB
JavaScript
/*!
* Copyright (c) 2024 Instun, Inc. All rights reserved.
*/
/**
* @fileoverview Implementation of SM2 Multikey functionality.
*
* This module provides a comprehensive implementation of the SM2 cryptographic algorithm
* with multi-key support. It includes functionality for key pair generation, import/export
* in various formats (JWK, Multibase), signing, and verification.
*
* Key Features:
* - SM2 key pair generation and management
* - Support for multiple key formats (JWK, Multibase)
* - Digital signature creation and verification
* - Key compression and encoding utilities
* - Platform-agnostic implementation with pluggable crypto backend
*
* Security Considerations:
* - Private keys are never exposed in plaintext
* - All key operations are performed in memory
* - Proper key format validation and error handling
* - Support for key compression to reduce storage size
*
* Performance Considerations:
* - Lazy initialization of crypto implementation
* - Efficient key compression and encoding
* - Minimal memory footprint
*
* Usage Example:
* ```javascript
* // Generate a new key pair
* const keyPair = SM2Multikey.generate({
* id: 'key-1',
* controller: 'did:example:123'
* });
*
* // Export public key
* const exported = keyPair.export({
* publicKey: true,
* includeContext: true
* });
*
* // Create a signer
* const signer = keyPair.signer();
* const signature = await signer.sign(message);
*
* // Create a verifier
* const verifier = keyPair.verifier();
* const isValid = await verifier.verify(message, signature);
* ```
*
* Standards and Specifications:
* - SM2 Digital Signature Algorithm (GM/T 0003-2012)
* - JWK (RFC 7517)
* - Multicodec and Multibase
*
* @module SM2Multikey
*/
import { base58btc } from 'multiformats/bases/base58';
import { toBase64Url, fromBase64Url } from '../formats/base64.js';
import { compressPublicKey, uncompressPublicKey } from '../utils/key-compression.js';
import { validatePublicKeyCoordinates } from '../utils/key-validator.js';
import {
KeyError,
FormatError,
SM2Error,
ErrorCodes,
ArgumentError,
createError
} from './errors.js';
import {
fromJwk,
toJwk,
jwkToPublicKeyBytes as _jwkToPublicKeyBytes,
jwkToSecretKeyBytes as _jwkToSecretKeyBytes
} from '../formats/jwk.js';
import {
MULTIBASE_BASE58BTC_HEADER,
MULTICODEC_SM2_PUB_HEADER,
MULTICODEC_SM2_PRIV_HEADER,
encodeKey,
decodeKey
} from '../formats/codec.js';
import {
exportKeyPair as _exportKeyPair,
importKeyPair as _importKeyPair
} from '../utils/key-pair.js';
// multibase/multicodec constants
const MULTIKEY_CONTEXT_V1_URL = 'https://w3id.org/security/multikey/v1';
const ALGORITHM = 'SM2';
/**
* Default implementation for crypto functions.
* Throws an error indicating that no crypto implementation has been set.
* This ensures that the crypto implementation must be explicitly set before use.
*
* @private
* @throws {Error} Always throws an error indicating no implementation is set
*/
function no_implementation() {
throw new Error('No crypto implementation set');
}
/**
* Default cryptographic implementation object.
* All methods will throw errors until a proper implementation is set.
* This design allows for platform-specific implementations to be injected.
*
* @private
* @type {Object}
*/
let cryptoImpl = {
generateKey: no_implementation,
createSigner: no_implementation,
createVerifier: no_implementation,
digest: no_implementation
};
/**
* SM2 Key Pair Class
*
* This class implements the SM2 cryptographic algorithm with multi-key support.
* It provides functionality for key generation, import/export, signing, and verification.
* The implementation follows the SM2 standard and supports various key formats.
*
* Security Features:
* - Private key protection
* - Key format validation
* - Secure key generation
* - Proper error handling
*
* @class
*/
class SM2Multikey {
/**
* Creates a new instance of SM2Multikey.
* Initializes an empty key pair with no keys or identifiers.
*
* @constructor
* @property {Buffer} publicKey - Public key buffer
* @property {Buffer} secretKey - Private key buffer (sensitive)
* @property {string} id - Optional key identifier
* @property {string} controller - Optional controller identifier
*/
constructor() {
this.publicKey = null;
this.secretKey = null;
this.id = null;
this.controller = null;
}
/**
* Sets the cryptographic implementation to be used by this class.
* This allows for platform-specific implementations while maintaining
* a consistent API across different environments (Node.js, Browser).
*
* Required Implementation Methods:
* - generateKey(): Generates a new SM2 key pair
* - createSigner(): Creates a signing function
* - createVerifier(): Creates a verification function
* - digest(): Creates a message digest
*
* @static
* @param {Object} impl - Crypto implementation object
* @param {Function} impl.generateKey - Generates a new key pair
* @param {Function} impl.createSigner - Creates a signing function
* @param {Function} impl.createVerifier - Creates a verification function
* @param {Function} impl.digest - Creates a message digest
* @throws {ArgumentError} If the implementation object is invalid or missing required methods
*/
static setCryptoImpl(impl) {
if (!impl || typeof impl !== 'object') {
throw new ArgumentError('Invalid crypto implementation', { code: ErrorCodes.ERR_ARGUMENT_INVALID });
}
cryptoImpl = impl;
}
/**
* Generates a new SM2 key pair with optional identifiers.
* The generated key pair includes both public and private keys
* and can be associated with an ID and controller.
*
* Key Generation Process:
* 1. Generate raw key pair using crypto implementation
* 2. Create new SM2Multikey instance
* 3. Set public and private keys
* 4. Generate compressed public key
* 5. Encode keys in multibase format
* 6. Set optional identifiers
*
* @static
* @param {Object} [options={}] - Generation options
* @param {string} [options.id] - Key identifier
* @param {string} [options.controller] - Controller identifier
* @returns {SM2Multikey} New key pair instance
* @throws {ArgumentError} If provided options are invalid
* @throws {KeyError} If key generation fails
* @throws {FormatError} If key encoding fails
*/
static generate({ id, controller } = {}) {
// Validate arguments
if (id && typeof id !== 'string') {
throw new ArgumentError('ID must be a string', { code: ErrorCodes.ERR_ARGUMENT_INVALID });
}
if (controller && typeof controller !== 'string') {
throw new ArgumentError('Controller must be a string', { code: ErrorCodes.ERR_ARGUMENT_INVALID });
}
// Generate key pair
const keyPair = cryptoImpl.generateKey();
if (!keyPair || !keyPair.publicKey || !keyPair.secretKey) {
throw new KeyError('Failed to generate key pair', { code: ErrorCodes.ERR_KEY_GENERATION });
}
// Create new instance
const instance = new SM2Multikey();
instance.publicKey = keyPair.publicKey;
instance.secretKey = keyPair.secretKey;
// Export public key in compressed format
try {
const compressedPublicKey = compressPublicKey(instance.publicKey);
instance.publicKeyMultibase = encodeKey(
MULTICODEC_SM2_PUB_HEADER,
compressedPublicKey
);
} catch (error) {
throw new FormatError('Failed to encode public key', {
code: ErrorCodes.ERR_FORMAT_ENCODE,
cause: error
});
}
// Export private key in multibase format
try {
instance.secretKeyMultibase = encodeKey(
MULTICODEC_SM2_PRIV_HEADER,
instance.secretKey
);
} catch (error) {
throw new FormatError('Failed to encode private key', {
code: ErrorCodes.ERR_FORMAT_ENCODE,
cause: error
});
}
// Set ID and controller
if (controller && !id) {
// If controller is provided but ID is not, generate ID from controller and public key
id = `${controller}#${instance.publicKeyMultibase}`;
}
instance.id = id;
instance.controller = controller;
return instance;
}
/**
* Exports the key pair in a specified format.
* Supports various export options for different use cases.
*
* Export Options:
* - publicKey: Export public key (default: true)
* - secretKey: Export private key (default: false)
* - includeContext: Include @context field (default: false)
* - raw: Export in raw format (default: false)
* - canonicalize: Sort properties alphabetically (default: false)
*
* Security Note:
* - Private key export is optional and should be used with caution
* - Raw format should only be used in trusted environments
*
* @param {Object} options - Export options
* @param {boolean} [options.publicKey=true] - Whether to export public key
* @param {boolean} [options.secretKey=false] - Whether to export private key
* @param {boolean} [options.includeContext=false] - Whether to include context
* @param {boolean} [options.raw=false] - Whether to export in raw format
* @param {boolean} [options.canonicalize=false] - Whether to canonicalize output
* @returns {Object} Exported key object
* @throws {ArgumentError} If arguments are invalid
* @throws {KeyError} If no key is available for export
*/
export({
publicKey = true,
secretKey = false,
includeContext = false,
raw = false,
canonicalize = false
} = {}) {
// Argument validation
if (typeof publicKey !== 'boolean') {
throw new ArgumentError('publicKey must be a boolean', { code: ErrorCodes.ERR_ARGUMENT_INVALID });
}
if (typeof secretKey !== 'boolean') {
throw new ArgumentError('secretKey must be a boolean', { code: ErrorCodes.ERR_ARGUMENT_INVALID });
}
if (typeof includeContext !== 'boolean') {
throw new ArgumentError('includeContext must be a boolean', { code: ErrorCodes.ERR_ARGUMENT_INVALID });
}
if (typeof raw !== 'boolean') {
throw new ArgumentError('raw must be a boolean', { code: ErrorCodes.ERR_ARGUMENT_INVALID });
}
if (typeof canonicalize !== 'boolean') {
throw new ArgumentError('canonicalize must be a boolean', { code: ErrorCodes.ERR_ARGUMENT_INVALID });
}
if (!this.publicKey) {
throw new KeyError('No key to export', { code: ErrorCodes.ERR_KEY_NOT_FOUND });
}
const exported = {
type: 'Multikey'
};
// If includeContext or id/controller is specified, add context
if (includeContext || this.id || this.controller) {
exported['@context'] = MULTIKEY_CONTEXT_V1_URL;
}
if (this.id) {
exported.id = this.id;
}
if (this.controller) {
exported.controller = this.controller;
}
if (publicKey) {
if (raw) {
// Export public key in raw format
exported.publicKey = this.publicKey;
} else {
// Export public key in compressed format
const compressedPublicKey = compressPublicKey(this.publicKey);
exported.publicKeyMultibase = encodeKey(
MULTICODEC_SM2_PUB_HEADER,
compressedPublicKey
);
}
}
if (secretKey && this.secretKey) {
if (raw) {
// Export private key in raw format
exported.secretKey = this.secretKey;
} else {
// Export private key in multibase format
exported.secretKeyMultibase = encodeKey(
MULTICODEC_SM2_PRIV_HEADER,
this.secretKey
);
}
}
// If canonicalize is true, sort properties alphabetically
if (canonicalize) {
const sortedKeys = Object.keys(exported).sort();
const canonicalized = {};
for (const key of sortedKeys) {
canonicalized[key] = exported[key];
}
return canonicalized;
}
return exported;
}
/**
* Imports a key pair from an exported key object.
* Supports multiple import formats and performs thorough validation.
*
* Import Process:
* 1. Validate input arguments
* 2. Handle different key formats (Multikey, JWK)
* 3. Set default values and identifiers
* 4. Validate Multikey format
* 5. Import public key
* 6. Import private key (if present)
*
* Supported Formats:
* - Multikey format
* - JWK format (via publicKeyJwk)
*
* @static
* @param {Object} key - Exported key object
* @returns {SM2Multikey} Imported key pair instance
* @throws {ArgumentError} If exported key object is invalid
* @throws {FormatError} If key format is invalid
* @throws {KeyError} If key import fails
*/
static from(key) {
// 1. Argument validation
if (!key || typeof key !== 'object') {
throw new ArgumentError('Key must be an object', { code: ErrorCodes.ERR_ARGUMENT_INVALID });
}
let multikey = { ...key };
// 2. Handle different key formats
if (multikey.type !== 'Multikey') {
// Try loading from JWK if publicKeyJwk is present
if (multikey.publicKeyJwk) {
return SM2Multikey.fromJwk({ jwk: multikey.publicKeyJwk, secretKey: false });
}
}
// 3. Set default values
if (!multikey.type) {
multikey.type = 'Multikey';
}
if (!multikey['@context']) {
multikey['@context'] = MULTIKEY_CONTEXT_V1_URL;
}
if (multikey.controller && !multikey.id) {
multikey.id = `${multikey.controller}#${multikey.publicKeyMultibase}`;
}
// 4. Validate SM2Multikey format
try {
SM2Multikey._assertMultikey(multikey);
} catch (error) {
throw new ArgumentError('Invalid SM2Multikey format', {
code: ErrorCodes.ERR_FORMAT_MULTIKEY,
cause: error
});
}
// 5. Create new instance
const instance = new SM2Multikey();
instance.id = multikey.id;
instance.controller = multikey.controller;
// 6. Import public key
if (multikey.publicKeyMultibase) {
try {
// Check multibase format
if (!multikey.publicKeyMultibase.startsWith(MULTIBASE_BASE58BTC_HEADER)) {
throw new FormatError('Invalid multibase format', { code: ErrorCodes.ERR_FORMAT_MULTIBASE });
}
// Decode and validate key
const { key: compressedPublicKey, prefix: publicKeyPrefix } = decodeKey(multikey.publicKeyMultibase);
// Validate key prefix
if (!publicKeyPrefix.equals(MULTICODEC_SM2_PUB_HEADER)) {
throw new FormatError('Invalid public key format', { code: ErrorCodes.ERR_KEY_FORMAT });
}
// Uncompress public key
instance.publicKey = uncompressPublicKey(compressedPublicKey);
instance.publicKeyMultibase = multikey.publicKeyMultibase;
} catch (error) {
if (error instanceof SM2Error) {
throw error;
}
throw new FormatError('Failed to decode public key', {
code: ErrorCodes.ERR_FORMAT_MULTIBASE,
cause: error
});
}
} else if (multikey.publicKey) {
// Import raw public key
try {
// Validate raw public key format
if (!Buffer.isBuffer(multikey.publicKey)) {
throw new FormatError('Public key must be a Buffer', { code: ErrorCodes.ERR_FORMAT_INPUT });
}
if (multikey.publicKey.length !== 64) {
throw new FormatError('Public key must be 64 bytes', { code: ErrorCodes.ERR_FORMAT_LENGTH });
}
const x = multikey.publicKey.subarray(0, 32);
const y = multikey.publicKey.subarray(32, 64);
validatePublicKeyCoordinates(x, y);
instance.publicKey = multikey.publicKey;
// Export public key in compressed format
const compressedPublicKey = compressPublicKey(instance.publicKey);
instance.publicKeyMultibase = encodeKey(
MULTICODEC_SM2_PUB_HEADER,
compressedPublicKey
);
} catch (error) {
if (error instanceof SM2Error) {
throw error;
}
throw new FormatError('Invalid public key format', {
code: ErrorCodes.ERR_FORMAT_INPUT,
cause: error
});
}
} else {
throw new KeyError('No public key found', { code: ErrorCodes.ERR_KEY_NOT_FOUND });
}
// 7. Import private key if present
if (multikey.secretKeyMultibase) {
try {
// Check multibase format
if (!multikey.secretKeyMultibase.startsWith(MULTIBASE_BASE58BTC_HEADER)) {
throw new FormatError('Invalid multibase format', { code: ErrorCodes.ERR_FORMAT_MULTIBASE });
}
// Decode and validate key
const { key: secretKey, prefix: secretKeyPrefix } = decodeKey(multikey.secretKeyMultibase);
// Validate key prefix
if (!secretKeyPrefix.equals(MULTICODEC_SM2_PRIV_HEADER)) {
throw new FormatError('Invalid private key format', { code: ErrorCodes.ERR_KEY_FORMAT });
}
instance.secretKey = secretKey;
instance.secretKeyMultibase = multikey.secretKeyMultibase;
} catch (error) {
if (error instanceof SM2Error) {
throw error;
}
throw new FormatError('Failed to decode private key', {
code: ErrorCodes.ERR_FORMAT_MULTIBASE,
cause: error
});
}
} else if (multikey.secretKey) {
// Import raw private key
instance.secretKey = multikey.secretKey;
// Export private key in multibase format
instance.secretKeyMultibase = encodeKey(
MULTICODEC_SM2_PRIV_HEADER,
instance.secretKey
);
}
return instance;
}
/**
* Verify if the key pair conforms to SM2Multikey format
* Performs thorough validation of the key object structure.
*
* Validation Checks:
* 1. Key is an object
* 2. Context is correct
* 3. Required fields are present
* 4. Field types are correct
*
* @param {Object} key - The key object to verify
* @throws {TypeError} If the key object format is incorrect
* @private
*/
static _assertMultikey(key) {
if (!(key && typeof key === 'object')) {
throw new TypeError('"key" must be an object.');
}
if (!(key['@context'] === MULTIKEY_CONTEXT_V1_URL ||
(Array.isArray(key['@context']) &&
key['@context'].includes(MULTIKEY_CONTEXT_V1_URL)))) {
throw new TypeError(
'"key" must be a SM2Multikey with context ' +
`"${MULTIKEY_CONTEXT_V1_URL}".`);
}
}
/**
* Imports a key pair from a JWK object.
* Supports both public and private key import.
*
* Import Process:
* 1. Convert from JWK format
* 2. Create new instance
* 3. Import public key
* 4. Import private key (if requested)
*
* JWK Requirements:
* - Must contain valid kty field
* - Must contain required key parameters
* - Must use correct algorithm
*
* @static
* @param {Object} options - Import options
* @param {Object} options.jwk - JWK key object
* @param {boolean} [options.secretKey=false] - Whether to import private key
* @param {string} [options.id] - Key identifier
* @param {string} [options.controller] - Controller identifier
* @returns {SM2Multikey} Imported key pair instance
* @throws {ArgumentError} If JWK is invalid
* @throws {FormatError} If JWK format is incorrect
*/
static fromJwk({ jwk, secretKey = false, id, controller } = {}) {
// 1. Argument validation
if (!jwk || typeof jwk !== 'object') {
throw new ArgumentError('Invalid JWK object', { code: ErrorCodes.ERR_ARGUMENT_INVALID });
}
// 2. Convert JWK format
const keyPair = fromJwk({ jwk, secretKey, id, controller });
// 3. Create instance
const instance = new SM2Multikey();
instance.publicKey = keyPair.publicKey;
instance.secretKey = keyPair.secretKey;
instance.id = keyPair.id;
instance.controller = keyPair.controller;
return instance;
}
/**
* Converts a key pair to a JWK object.
* Supports both public and private key export.
*
* Export Process:
* 1. Validate arguments
* 2. Check key availability
* 3. Convert to JWK format
*
* JWK Format:
* - kty: Key type (EC)
* - crv: Curve name (SM2)
* - x, y: Public key coordinates
* - d: Private key (if requested)
*
* @static
* @param {Object} options - Options
* @param {SM2Multikey} options.keyPair - Key pair object
* @param {boolean} [options.secretKey=false] - Whether to include private key
* @returns {object} JWK key object
* @throws {ArgumentError} If arguments are invalid
* @throws {KeyError} If no key is available for export
*/
static toJwk({ keyPair, secretKey = false } = {}) {
// 1. Argument validation
if (!keyPair || !(keyPair instanceof SM2Multikey)) {
throw new ArgumentError('Invalid key pair', { code: ErrorCodes.ERR_ARGUMENT_INVALID });
}
// 2. Check key availability
if (!keyPair.publicKey) {
throw new KeyError('No public key available', { code: ErrorCodes.ERR_KEY_NOT_FOUND });
}
// 3. Convert to JWK format
return toJwk({
keyPair: {
publicKey: keyPair.publicKey,
secretKey: keyPair.secretKey
},
secretKey
});
}
/**
* Creates a signer function for this key pair.
* The signer function is used to create digital signatures.
*
* Signer Object:
* - algorithm: Signature algorithm (SM2)
* - id: Key identifier
* - sign: Signing function
*
* Security Note:
* - Private key must be available
* - Signing operation is performed in memory
*
* @param {Object} [options={}] - Options
* @param {string} [options.id] - Key identifier
* @returns {Object} Signer object with sign function
* @throws {KeyError} If no private key is available
*/
signer() {
if (!this.secretKey) {
throw new KeyError('No private key available', { code: ErrorCodes.ERR_KEY_NOT_FOUND });
}
return {
algorithm: ALGORITHM,
id: this.id,
sign: cryptoImpl.createSigner({
publicKey: this.publicKey,
secretKey: this.secretKey
})
};
}
/**
* Creates a verifier function for this key pair.
* The verifier function is used to verify digital signatures.
*
* Verifier Object:
* - algorithm: Signature algorithm (SM2)
* - id: Key identifier
* - verify: Verification function
*
* Security Note:
* - Only requires public key
* - Safe to use in untrusted environments
*
* @param {Object} [options={}] - Options
* @param {string} [options.id] - Key identifier
* @returns {Object} Verifier object with verify function
* @throws {KeyError} If no public key is available
*/
verifier() {
if (!this.publicKey) {
throw new KeyError('No public key available', { code: ErrorCodes.ERR_KEY_NOT_FOUND });
}
return {
algorithm: ALGORITHM,
id: this.id,
verify: cryptoImpl.createVerifier({
publicKey: this.publicKey
})
};
}
}
export { SM2Multikey };