@dcxp/root
Version:
DCX: Decentralized Credential Exchange. DWN protocol for verifiable credential exchange.
398 lines (334 loc) • 14.1 kB
text/typescript
import { generateMnemonic, mnemonicToSeed, validateMnemonic } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english';
import {
AgentCryptoApi,
IdentityVault,
IdentityVaultBackup,
IdentityVaultBackupData,
IdentityVaultParams,
IdentityVaultStatus,
LocalKeyManager,
} from '@web5/agent';
import { Convert, KeyValueStore, MemoryStore } from '@web5/common';
import { Jwk } from '@web5/crypto';
import { BearerDid, DidDht, DidDhtCreateOptions } from '@web5/dids';
import { HDKey } from 'ed25519-keygen/hdkey';
import { CompactJwe } from './prototyping/crypto/jose/jwe-compact.js';
import { DeterministicKeyGenerator } from './prototyping/crypto/utils.js';
import {
isEmptyString,
isIdentityVaultBackup,
isIdentityVaultStatus,
isPortableDid,
Logger
} from './index.js';
export type DcxIdentityVaultInitializeParams = {
/**
* The password used to secure the vault.
*
* The password selected should be strong and securely managed to prevent unauthorized access.
*/
password: string;
/**
* An optional recovery phrase used to derive the cryptographic keys for the vault.
*
* Providing a recovery phrase can be used to recover the vault's content or establish a
* deterministic key generation scheme. If not provided, a new recovery phrase will be generated
* during the initialization process.
*/
recoveryPhrase?: string;
/**
* An array of Decentralized Web Node (DWN) endpoints associated with the DID.
*
* The DWN endpoints are used to resolve the DID and interact with the DID Document.
*/
dwnEndpoints: string[];
};
export class DcxIdentityVault implements IdentityVault<{ InitializeResult: string }> {
/** Provides cryptographic functions needed for secure storage and management of the vault. */
public crypto = new AgentCryptoApi();
/** Determines the computational intensity of the key derivation process. */
private _keyDerivationWorkFactor: number;
/** The underlying key-value store for the vault's encrypted content. */
private _store: KeyValueStore<string, string>;
/** The cryptographic key used to encrypt and decrypt the vault's content securely. */
private _contentEncryptionKey: Jwk | undefined;
constructor({ keyDerivationWorkFactor, store }: IdentityVaultParams = {}) {
this._keyDerivationWorkFactor = keyDerivationWorkFactor ?? 210_000;
this._store = store ?? new MemoryStore<string, string>();
}
public static create(): DcxIdentityVault {
return new DcxIdentityVault();
}
public changePassword(params: { oldPassword: string; newPassword: string; }): Promise<void> {
Logger.debug('DcxIdentityVault: Changing password...', params);
throw new Error('Method not implemented.');
}
public async getStatus(): Promise<IdentityVaultStatus> {
const storedStatus = await this._store.get('vaultStatus');
if (!storedStatus) {
return {
initialized : false,
lastBackup : null,
lastRestore : null,
};
}
const vaultStatus = Convert.string(storedStatus).toObject();
if (!isIdentityVaultStatus(vaultStatus)) {
throw new Error('DcxIdentityVault: Invalid IdentityVaultStatus object in store');
}
return vaultStatus;
}
private async getStoredDid(): Promise<string> {
const didJwe = await this._store.get('did');
if (!didJwe) {
throw new Error(
'DcxIdentityVault: Unable to retrieve the DID record from the vault. Please check the ' +
'vault status and if the problem persists consider re-initializing the vault and ' +
'restoring the contents from a previous backup.',
);
}
return didJwe;
}
public async getDid(): Promise<BearerDid> {
if (this.isLocked()) {
throw new Error(`DcxIdentityVault: Vault has not been initialized and unlocked.`);
}
const didJwe = await this.getStoredDid();
const { plaintext: portableDidBytes } = await CompactJwe.decrypt({
jwe : didJwe,
key : this._contentEncryptionKey!,
crypto : this.crypto,
keyManager : new LocalKeyManager(),
});
const portableDid = Convert.uint8Array(portableDidBytes).toObject();
if (!isPortableDid(portableDid)) {
throw new Error('DcxIdentityVault: Unable to decode malformed DID in identity vault');
}
return await BearerDid.import({ portableDid });
}
public async isInitialized(): Promise<boolean> {
return this.getStatus().then(({ initialized }) => initialized);
}
public isLocked(): boolean {
return !this._contentEncryptionKey;
}
public async lock(): Promise<void> {
if ((await this.isInitialized()) === false) {
throw new Error(`DcxIdentityVault: Lock operation failed. Vault has not been initialized.`);
}
if (this._contentEncryptionKey) this._contentEncryptionKey.k = '';
this._contentEncryptionKey = undefined;
}
public async restore({
backup,
password,
}: {
backup: IdentityVaultBackup;
password: string;
}): Promise<void> {
// Validate the backup object.
if (!isIdentityVaultBackup(backup)) {
throw new Error(`DcxIdentityVault: Restore operation failed due to invalid backup object.`);
}
// Temporarily save the status and contents of the data store while attempting to restore the
// backup so that they are not lost in case the restore operation fails.
let previousStatus: IdentityVaultStatus;
let previousContentEncryptionKey: string;
let previousDid: string;
try {
previousDid = await this.getStoredDid();
previousContentEncryptionKey = await this.getStoredContentEncryptionKey();
previousStatus = await this.getStatus();
} catch {
throw new Error(
'DcxIdentityVault: The restore operation cannot proceed because the existing vault ' +
'contents are missing or inaccessible. If the problem persists consider re-initializing ' +
'the vault and retrying the restore.',
);
}
try {
// Convert the backup data to a JSON object.
const backupData = Convert.base64Url(backup.data).toObject() as IdentityVaultBackupData;
// Restore the backup to the data store.
await this._store.set('did', backupData.did);
await this._store.set('contentEncryptionKey', backupData.contentEncryptionKey);
await this.setStatus(backupData.status);
// Attempt to unlock the vault with the given `password`.
await this.unlock({ password });
} catch (error: any) {
// If the restore operation fails, revert the data store to the status and contents that were
// saved before the restore operation was attempted.
await this.setStatus(previousStatus);
await this._store.set('contentEncryptionKey', previousContentEncryptionKey);
await this._store.set('did', previousDid);
throw new Error(
'DcxIdentityVault: Restore operation failed due to invalid backup data or an incorrect ' +
'password. Please verify the password is correct for the provided backup and try again.',
);
}
// Update the last restore timestamp in the data store.
await this.setStatus({ lastRestore: new Date().toISOString() });
}
public async unlock({ password }: { password: string }): Promise<void> {
// Lock the vault.
await this.lock();
// Retrieve the content encryption key (CEK) record as a compact JWE from the data store.
const cekJwe = await this.getStoredContentEncryptionKey();
// Decrypt the compact JWE.
try {
const { plaintext: contentEncryptionKeyBytes } = await CompactJwe.decrypt({
jwe : cekJwe,
key : Convert.string(password).toUint8Array(),
crypto : this.crypto,
keyManager : new LocalKeyManager(),
});
const contentEncryptionKey = Convert.uint8Array(contentEncryptionKeyBytes).toObject() as Jwk;
// Save the content encryption key in memory, thereby unlocking the vault.
this._contentEncryptionKey = contentEncryptionKey;
} catch (error: any) {
throw new Error(`DcxIdentityVault: Unable to unlock the vault due to an incorrect password.`);
}
}
async backup(): Promise<IdentityVaultBackup> {
throw new Error('Method not implemented.');
}
public async initialize({
password,
recoveryPhrase,
dwnEndpoints,
}: DcxIdentityVaultInitializeParams): Promise<string> {
if (await this.isInitialized()) {
throw new Error(`DcxIdentityVault: Vault has already been initialized.`);
}
if (isEmptyString(password)) {
throw new Error(
`DcxIdentityVault: The password is required and cannot be blank. Please provide a ' +
'valid, non-empty password.`,
);
}
if (recoveryPhrase && isEmptyString(recoveryPhrase)) {
throw new Error(
`DcxIdentityVault: The password is required and cannot be blank. Please provide a ' +
'valid, non-empty password.`,
);
}
recoveryPhrase ??= generateMnemonic(wordlist, 128);
if (!validateMnemonic(recoveryPhrase, wordlist)) {
throw new Error(
'DcxIdentityVault: The provided recovery phrase is invalid. Please ensure that the ' +
'recovery phrase is a correctly formatted series of 12 words.',
);
}
const rootSeed = await mnemonicToSeed(recoveryPhrase);
const rootHdKey = HDKey.fromMasterSeed(rootSeed);
const vaultHdKey = rootHdKey.derive(`m/44'/0'/0'/0'/0'`);
const contentEncryptionKey = await this.crypto.deriveKey({
algorithm : 'HKDF-512', // key derivation function
baseKeyBytes : vaultHdKey.privateKey, // input keying material
salt : '', // empty salt because private key is sufficiently random
info : 'vault_cek', // non-secret application specific information
derivedKeyAlgorithm : 'A256GCM', // derived key algorithm
});
const saltInput = await this.crypto.deriveKeyBytes({
algorithm : 'HKDF-512', // key derivation function
baseKeyBytes : vaultHdKey.publicKey, // input keying material
salt : '', // empty salt because public key is sufficiently random
info : 'vault_unlock_salt', // non-secret application specific information
length : 256, // derived key length, in bits
});
const cekJwe = await CompactJwe.encrypt({
key : Convert.string(password).toUint8Array(),
protectedHeader : {
alg : 'PBES2-HS512+A256KW',
enc : 'A256GCM',
cty : 'text/plain',
p2c : this._keyDerivationWorkFactor,
p2s : Convert.uint8Array(saltInput).toBase64Url(),
},
plaintext : Convert.object(contentEncryptionKey).toUint8Array(),
crypto : this.crypto,
keyManager : new LocalKeyManager(),
});
await this._store.set('contentEncryptionKey', cekJwe);
const identityHdKey = rootHdKey.derive(`m/44'/0'/1708523827'/0'/0'`);
const identityPrivateKey = await this.crypto.bytesToPrivateKey({
algorithm : 'Ed25519',
privateKeyBytes : identityHdKey.privateKey,
});
const signingHdKey = rootHdKey.derive(`m/44'/0'/1708523827'/0'/1'`);
const signingPrivateKey = await this.crypto.bytesToPrivateKey({
algorithm : 'Ed25519',
privateKeyBytes : signingHdKey.privateKey,
});
const deterministicKeyGenerator = new DeterministicKeyGenerator();
await deterministicKeyGenerator.addPredefinedKeys({
privateKeys: [identityPrivateKey, signingPrivateKey],
});
const options = {
verificationMethods: [
{
algorithm : 'Ed25519',
id : 'sig',
purposes : ['assertionMethod', 'authentication']
},
]
} as DidDhtCreateOptions<DeterministicKeyGenerator>;
if(dwnEndpoints && !!dwnEndpoints.length) {
options.services = [
{
id : 'dwn',
type : 'DecentralizedWebNode',
serviceEndpoint : dwnEndpoints,
enc : '#enc',
sig : '#sig',
}
];
}
const did = await DidDht.create({ keyManager: deterministicKeyGenerator, options });
const portableDid = await did.export();
const didJwe = await CompactJwe.encrypt({
key : contentEncryptionKey,
plaintext : Convert.object(portableDid).toUint8Array(),
protectedHeader : {
alg : 'dir',
enc : 'A256GCM',
cty : 'json',
},
crypto : this.crypto,
keyManager : new LocalKeyManager(),
});
// Store the compact JWE in the data store.
await this._store.set('did', didJwe);
this._contentEncryptionKey = contentEncryptionKey;
await this.setStatus({ initialized: true });
return recoveryPhrase;
}
private async setStatus({
initialized,
lastBackup,
lastRestore,
}: Partial<IdentityVaultStatus>): Promise<boolean> {
// Get the current status values from the store, if any.
const vaultStatus = await this.getStatus();
// Update the status properties with new values specified, if any.
vaultStatus.initialized = initialized ?? vaultStatus.initialized;
vaultStatus.lastBackup = lastBackup ?? vaultStatus.lastBackup;
vaultStatus.lastRestore = lastRestore ?? vaultStatus.lastRestore;
// Write the changes to the store.
await this._store.set('vaultStatus', JSON.stringify(vaultStatus));
return true;
}
private async getStoredContentEncryptionKey(): Promise<string> {
// Retrieve the content encryption key (CEK) record as a compact JWE from the data store.
const cekJwe = await this._store.get('contentEncryptionKey');
if (!cekJwe) {
throw new Error(
'DcxIdentityVault: Unable to retrieve the Content Encryption Key record from the vault. ' +
'Please check the vault status and if the problem persists consider re-initializing the ' +
'vault and restoring the contents from a previous backup.',
);
}
return cekJwe;
}
}