Skip to content

Commit

Permalink
feat: Add key/import api [DEV-3486] (#448)
Browse files Browse the repository at this point in the history
* feat: Add key/import api

* Update routes

* Update description

* Update auth middleware

* Add salt and fix issues

* Add jsonld validator and fix tests

---------

Co-authored-by: Andrew Nikitin <[email protected]>
  • Loading branch information
DaevMithran and Andrew Nikitin committed Dec 26, 2023
1 parent 71c0ae9 commit e6fcece
Show file tree
Hide file tree
Showing 24 changed files with 448 additions and 18 deletions.
1 change: 1 addition & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ class App {

// Keys API
app.post('/key/create', new KeyController().createKey);
app.post('/key/import', new KeyController().importKey);
app.get('/key/read/:kid', new KeyController().getKey);

// DIDs API
Expand Down
1 change: 1 addition & 0 deletions src/controllers/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export class CredentialController {
check('credential')
.exists()
.withMessage('W3c verifiable credential was not provided')
.bail()
.isW3CCheqdCredential()
.bail(),
query('publish').optional().isBoolean().withMessage('publish should be a boolean value').bail(),
Expand Down
71 changes: 71 additions & 0 deletions src/controllers/key.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { Request, Response } from 'express';
import { StatusCodes } from 'http-status-codes';
import { IdentityServiceStrategySetup } from '../services/identity/index.js';
import { decryptPrivateKey } from '../helpers/helpers.js';
import { toString } from 'uint8arrays';

export class KeyController {
/**
Expand Down Expand Up @@ -50,6 +52,75 @@ export class KeyController {
}
}

/**
* @openapi
*
* /key/import:
* post:
* tags: [ Key ]
* summary: Import an identity key pair.
* description: This endpoint imports an identity key pair associated with the user's account for custodian-mode clients.
* requestBody:
* content:
* application/x-www-form-urlencoded:
* schema:
* $ref: '#/components/schemas/KeyImportRequest'
* application/json:
* schema:
* $ref: '#/components/schemas/KeyImportRequest'
* responses:
* 200:
* description: The request was successful.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/KeyResult'
* 400:
* description: A problem with the input fields has occurred. Additional state information plus metadata may be available in the response body.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/InvalidRequest'
* example:
* error: InvalidRequest
* 401:
* $ref: '#/components/schemas/UnauthorizedError'
* 500:
* description: An internal error has occurred. Additional state information plus metadata may be available in the response body.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/InvalidRequest'
* example:
* error: Internal Error
*/
public async importKey(request: Request, response: Response) {
try {
const { type, encrypted, ivHex, salt, alias } = request.body;
let { privateKeyHex } = request.body;
if (encrypted) {
if (ivHex && salt) {
privateKeyHex = toString(await decryptPrivateKey(privateKeyHex, ivHex, salt), 'hex');
} else {
return response.status(StatusCodes.BAD_REQUEST).json({
error: `Invalid request: Property ivHex, salt is required when encrypted is set to true`,
});
}
}
const key = await new IdentityServiceStrategySetup(response.locals.customer.customerId).agent.importKey(
type || 'Ed25519',
privateKeyHex,
response.locals.customer,
alias
);
return response.status(StatusCodes.OK).json(key);
} catch (error) {
return response.status(StatusCodes.INTERNAL_SERVER_ERROR).json({
error: `Internal error: ${(error as Error)?.message || error}`,
});
}
}

/**
* @openapi
*
Expand Down
5 changes: 3 additions & 2 deletions src/controllers/validator/credential.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { CheqdW3CVerifiableCredential, ICheqdCredential } from '../../services/w
import type { CheqdCredentialStatus } from '../../types/shared.js';
import { CredentialStatusValidator } from './credential-status.js';
import { CheqdDIDValidator, KeyDIDValidator } from './did.js';
import { JsonLDProofValidator } from './jsonld-proof.js';
import { JWTProofValidator } from './jwt-proof.js';
import type { IValidationResult, IValidator, Validatable } from './validator.js';
import { InvalidTokenError, jwtDecode } from 'jwt-decode';
Expand All @@ -17,7 +18,7 @@ export class CheqdW3CVerifiableCredentialValidator implements IValidator {
credentialStatusValidator?: IValidator
) {
if (!proofValidators) {
proofValidators = [new JWTProofValidator()];
proofValidators = [new JWTProofValidator(), new JsonLDProofValidator()];
}
if (!issuerValidators) {
issuerValidators = [new KeyDIDValidator(), new CheqdDIDValidator()];
Expand Down Expand Up @@ -79,7 +80,7 @@ export class CheqdW3CVerifiableCredentialValidator implements IValidator {
}
const proof = cheqdCredential.proof as Validatable;
const results = this.proofValidators.map((v) => v.validate(proof));
if (results.some((r) => !r.valid)) {
if (results.every((r) => !r.valid)) {
return {
valid: false,
error: `credential.proof has validation errors: ${results.map((r) => r.error).join(', ')}`,
Expand Down
52 changes: 52 additions & 0 deletions src/controllers/validator/jsonld-proof.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { JSONLD_PROOF_TYPES } from '../../types/constants.js';
import type { JSONLDProofType } from '../../types/shared.js';
import type { IValidationResult, IValidator, Validatable } from './validator.js';

export class JsonLDProofValidator implements IValidator {
validate(proof: Validatable): IValidationResult {
proof = proof as JSONLDProofType;
if (!proof) {
return {
valid: false,
error: 'Proof is required',
};
}
if (!proof.type) {
return {
valid: false,
error: 'Proof.type is required',
};
}
if (JSONLD_PROOF_TYPES.includes(proof.type) === false) {
return {
valid: false,
error: `Only ${JSONLD_PROOF_TYPES.join(', ')} proof types are supported`,
};
}
if (!proof.created) {
return {
valid: false,
error: 'Proof.created is required',
};
}
if (!proof.proofPurpose) {
return {
valid: false,
error: 'Proof.proofPurpose is required',
};
}
if (!proof.verificationMethod) {
return {
valid: false,
error: 'Proof.verificationMethod is required',
};
}
if (!proof.jws) {
return {
valid: false,
error: 'Proof.jws is required',
};
}
return { valid: true };
}
}
3 changes: 2 additions & 1 deletion src/controllers/validator/validator.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { W3CVerifiableCredential, W3CVerifiablePresentation } from '@veramo/core';
import type { DIDDocument, VerificationMethod, Service } from 'did-resolver';
import type { IHelpers } from './helpers.js';
import type { CreateDIDService, JwtProof2020, CheqdCredentialStatus } from '../../types/shared.js';
import type { CreateDIDService, JwtProof2020, CheqdCredentialStatus, JSONLDProofType } from '../../types/shared.js';
import type { ICheqdCredential } from '../../services/w3c-credential.js';
import type { ICheqdPresentation } from '../../services/w3c-presentation.js';
import type { AlternativeUri } from '@cheqd/ts-proto/cheqd/resource/v2/resource.js';
Expand All @@ -20,6 +20,7 @@ export type Validatable =
| ICheqdCredential
| ICheqdPresentation
| JwtProof2020
| JSONLDProofType
| CheqdCredentialStatus;

export interface IValidator {
Expand Down
94 changes: 91 additions & 3 deletions src/helpers/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import type { DIDDocument } from 'did-resolver';
import type { ParsedQs } from 'qs';
import type { SpecValidationResult } from '../types/shared.js';
import type { Coin } from '@cosmjs/amino';
import {
MethodSpecificIdAlgo,
CheqdNetwork,
Expand All @@ -10,11 +13,13 @@ import {
createDidPayload,
} from '@cheqd/sdk';
import { createHmac } from 'node:crypto';
import type { ParsedQs } from 'qs';
import type { SpecValidationResult } from '../types/shared.js';
import { DEFAULT_DENOM_EXPONENT, MINIMAL_DENOM } from '../types/constants.js';
import { LitCompatibleCosmosChains, type DkgOptions, LitNetworks } from '@cheqd/did-provider-cheqd';
import type { Coin } from '@cosmjs/amino';
import { fromString } from 'uint8arrays';

import { config } from 'dotenv';

config();

export interface IDidDocOptions {
verificationMethod: VerificationMethods;
Expand Down Expand Up @@ -149,3 +154,86 @@ export function getQueryParams(queryParams: ParsedQs) {

return queryParamsText.length == 0 ? queryParamsText : '?' + queryParamsText;
}

export async function generateSaltFromConstantInput(constant: string): Promise<Uint8Array> {
const derivedSource = await crypto.subtle.importKey(
'raw',
Buffer.from(constant),
{ name: 'PBKDF2', hash: 'SHA-256' },
false,
['deriveBits', 'deriveKey']
);

const salt = await crypto.subtle.deriveBits(
{
name: 'PBKDF2',
salt: Buffer.from(constant),
iterations: 100_000,
hash: 'SHA-256',
},
derivedSource,
256
);

return new Uint8Array(salt);
}

export async function deriveSymmetricKeyFromSecret(
encryptionKey: string,
constant: string,
iterations = 100_000
): Promise<CryptoKey> {
// generate salt from constant input
const salt = await generateSaltFromConstantInput(constant);

// import as key
const key = await crypto.subtle.importKey('raw', fromString(encryptionKey), { name: 'PBKDF2' }, false, [
'deriveBits',
'deriveKey',
]);

// derive key from encryption secret
const derivedKey = await crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt,
iterations, // 10x iterations since outcome is exposed in client storage, around 1s
hash: 'SHA-256',
},
key,
{
name: 'AES-GCM',
length: 256,
},
false,
['encrypt', 'decrypt']
);

return derivedKey;
}

export async function decryptPrivateKey(encryptedPrivateKeyHex: string, ivHex: string, salt: string) {
if (!process.env.ENCRYPTION_SECRET) {
throw new Error('Missing encryption secret');
}
// derive key from passphrase
const derivedKey = await deriveSymmetricKeyFromSecret(salt, process.env.ENCRYPTION_SECRET);

// unwrap encrypted key with iv
const encryptedKey = Buffer.from(encryptedPrivateKeyHex, 'hex');
const iv = Buffer.from(ivHex, 'hex');

// decrypt private key with derived key
const decrypted = await crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv,
},
derivedKey,
encryptedKey
);

const secretKey = new Uint8Array(decrypted);

return secretKey;
}
1 change: 1 addition & 0 deletions src/middleware/auth/routes/key-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export class KeyAuthHandler extends BaseAuthHandler {
constructor() {
super();
this.registerRoute('/key/create', 'POST', 'create:key', { skipNamespace: true });
this.registerRoute('/key/import', 'POST', 'import:key', { skipNamespace: true });
this.registerRoute('/key/read/(.*)', 'GET', 'read:key', { skipNamespace: true });
this.registerRoute('/key/list', 'GET', 'list:key', { skipNamespace: true });
}
Expand Down
9 changes: 9 additions & 0 deletions src/services/identity/abstract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,15 @@ export abstract class AbstractIdentityService implements IIdentityService {
throw new Error(`Not supported`);
}

importKey(
type: 'Ed25519' | 'Secp256k1',
privateKeyHex: string,
customer?: CustomerEntity,
keyAlias?: string
): Promise<KeyEntity> {
throw new Error(`Not supported`);
}

createDid(network: string, didDocument: DIDDocument, customer: CustomerEntity): Promise<IIdentifier> {
throw new Error(`Not supported`);
}
Expand Down
9 changes: 9 additions & 0 deletions src/services/identity/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,15 @@ export class Veramo {
});
}

async importKey(agent: TAgent<IKeyManager>, type: 'Ed25519' | 'Secp256k1' = 'Ed25519', privateKeyHex: string) {
const [kms] = await agent.keyManagerGetKeyManagementSystems();
return await agent.keyManagerImport({
type: type || 'Ed25519',
kms,
privateKeyHex,
});
}

async getKey(agent: TAgent<IKeyManager>, kid: string) {
return await agent.keyManagerGet({ kid });
}
Expand Down
6 changes: 6 additions & 0 deletions src/services/identity/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ export interface IIdentityService {
initAgent(): TAgent<any>;
createAgent?(customer: CustomerEntity): Promise<VeramoAgent>;
createKey(type: 'Ed25519' | 'Secp256k1', customer?: CustomerEntity, keyAlias?: string): Promise<KeyEntity>;
importKey(
type: 'Ed25519' | 'Secp256k1',
privateKeyHex: string,
customer?: CustomerEntity,
keyAlias?: string
): Promise<KeyEntity>;
getKey(kid: string, customer?: CustomerEntity): Promise<ManagedKeyInfo | null>;
createDid(network: string, didDocument: DIDDocument, customer: CustomerEntity): Promise<IIdentifier>;
updateDid(didDocument: DIDDocument, customer: CustomerEntity): Promise<IIdentifier>;
Expand Down
12 changes: 12 additions & 0 deletions src/services/identity/postgres.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,18 @@ export class PostgresIdentityService extends DefaultIdentityService {
return await KeyService.instance.update(key.kid, customer, keyAlias, new Date());
}

async importKey(
type: 'Ed25519' | 'Secp256k1' = 'Ed25519',
privateKeyHex: string,
customer?: CustomerEntity,
keyAlias?: string
) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const key = await Veramo.instance.importKey(this.agent!, type, privateKeyHex);
// Update our specific key columns
return await KeyService.instance.update(key.kid, customer, keyAlias, new Date());
}

async getKey(kid: string, customer: CustomerEntity) {
const keys = await KeyService.instance.find({ kid: kid, customer: customer });
if (!keys || keys.length == 0) {
Expand Down
Loading

0 comments on commit e6fcece

Please sign in to comment.