Skip to content

Commit

Permalink
Add PBKDF2 to crypto package
Browse files Browse the repository at this point in the history
Signed-off-by: Frank Hinek <[email protected]>
  • Loading branch information
frankhinek committed Nov 5, 2023
1 parent 80c8d71 commit 25f6e78
Show file tree
Hide file tree
Showing 12 changed files with 262 additions and 7 deletions.
4 changes: 2 additions & 2 deletions packages/agent/src/types/managed-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export type DeriveBitsOptions = {
/**
* An object defining the derivation algorithm to use and its parameters.
*/
algorithm: Web5Crypto.AlgorithmIdentifier | Web5Crypto.EcdhDeriveKeyOptions;
algorithm: Web5Crypto.AlgorithmIdentifier | Web5Crypto.EcdhDeriveKeyOptions | Web5Crypto.Pbkdf2Options;

/**
* An identifier of the ManagedKey that will be the input to the
Expand Down Expand Up @@ -228,7 +228,7 @@ export interface ManagedKey {
* An object detailing the algorithm for which the key can be used along
* with additional algorithm-specific parameters.
*/
algorithm: Web5Crypto.GenerateKeyOptions;
algorithm: Web5Crypto.KeyAlgorithm | Web5Crypto.GenerateKeyOptions;

/**
* An alternate identifier used to identify the key in a KMS.
Expand Down
4 changes: 2 additions & 2 deletions packages/crypto/src/algorithms-api/crypto-key.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import type { Web5Crypto } from '../types/web5-crypto.js';

export class CryptoKey implements Web5Crypto.CryptoKey {
public algorithm: Web5Crypto.GenerateKeyOptions;
public algorithm: Web5Crypto.KeyAlgorithm | Web5Crypto.GenerateKeyOptions;
public extractable: boolean;
public material: Uint8Array;
public type: Web5Crypto.KeyType;
public usages: Web5Crypto.KeyUsage[];

constructor (algorithm: Web5Crypto.GenerateKeyOptions, extractable: boolean, material: Uint8Array, type: Web5Crypto.KeyType, usages: Web5Crypto.KeyUsage[]) {
constructor (algorithm: Web5Crypto.Algorithm | Web5Crypto.GenerateKeyOptions, extractable: boolean, material: Uint8Array, type: Web5Crypto.KeyType, usages: Web5Crypto.KeyUsage[]) {
this.algorithm = algorithm;
this.extractable = extractable;
this.material = material;
Expand Down
1 change: 1 addition & 0 deletions packages/crypto/src/algorithms-api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ export * from './errors.js';
export * from './ec/index.js';
export * from './aes/index.js';
export * from './crypto-key.js';
export * from './pbkdf/index.js';
export * from './crypto-algorithm.js';
1 change: 1 addition & 0 deletions packages/crypto/src/algorithms-api/pbkdf/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './pbkdf2.js';
94 changes: 94 additions & 0 deletions packages/crypto/src/algorithms-api/pbkdf/pbkdf2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import type { Web5Crypto } from '../../types/web5-crypto.js';

import { InvalidAccessError, OperationError } from '../errors.js';
import { CryptoAlgorithm } from '../crypto-algorithm.js';
import { checkRequiredProperty, checkValidProperty } from '../../utils.js';
import { universalTypeOf } from '@web5/common';

export abstract class BasePbkdf2Algorithm extends CryptoAlgorithm {

public readonly name: string = 'PBKDF2';

public readonly abstract hashAlgorithms: string[];

public keyUsages: Web5Crypto.KeyPairUsage = {
privateKey : ['deriveBits', 'deriveKey'],
publicKey : ['deriveBits', 'deriveKey'],
};

public checkAlgorithmOptions(options: {
algorithm: Web5Crypto.Pbkdf2Options,
baseKey: Web5Crypto.CryptoKey
}): void {
const { algorithm, baseKey } = options;
// Algorithm specified in the operation must match the algorithm implementation processing the operation.
this.checkAlgorithmName({ algorithmName: algorithm.name });
// The algorithm object must contain a hash property.
checkRequiredProperty({ property: 'hash', inObject: algorithm });
// The hash algorithm specified must be supported by the algorithm implementation processing the operation.
checkValidProperty({ property: algorithm.hash, allowedProperties: this.hashAlgorithms });
// The algorithm object must contain a iterations property.
checkRequiredProperty({ property: 'iterations', inObject: algorithm });
// The iterations value must a number.
if (!(universalTypeOf(algorithm.salt) === 'Number')) {
throw new TypeError(`Algorithm 'salt' is not of type: Number.`);
}
// The iterations value must be greater than 0.
if (algorithm.iterations < 1) {
throw new OperationError(`Algorithm 'iterations' must be > 0.`);
}
// The algorithm object must contain a salt property.
checkRequiredProperty({ property: 'salt', inObject: algorithm });
// The salt must a Uint8Array.
if (!(universalTypeOf(algorithm.salt) === 'Uint8Array')) {
throw new TypeError(`Algorithm 'salt' is not of type: Uint8Array.`);
}
// The options object must contain a baseKey property.
checkRequiredProperty({ property: 'baseKey', inObject: options });
// The baseKey object must be a CryptoKey.
this.checkCryptoKey({ key: baseKey });
// The baseKey algorithm must match the algorithm implementation processing the operation.
this.checkKeyAlgorithm({ keyAlgorithmName: baseKey.algorithm.name });
}

public checkImportKey(options: {
algorithm: Web5Crypto.Algorithm,
format: Web5Crypto.KeyFormat,
extractable: boolean,
keyUsages: Web5Crypto.KeyUsage[]
}): void {
const { algorithm, format, extractable, keyUsages } = options;
// Algorithm specified in the operation must match the algorithm implementation processing the operation.
this.checkAlgorithmName({ algorithmName: algorithm.name });
// The format specified must be 'raw'.
if (format !== 'raw') {
throw new SyntaxError(`Format '${format}' not supported. Only 'raw' is supported.`);
}
// The extractable value specified must be false.
if (extractable !== false) {
throw new SyntaxError(`Extractable '${extractable}' not supported. Only 'false' is supported.`);
}
// The key usages specified must be permitted by the algorithm implementation processing the operation.
this.checkKeyUsages({ keyUsages, allowedKeyUsages: this.keyUsages });
}

public override async decrypt(): Promise<Uint8Array> {
throw new InvalidAccessError(`Requested operation 'decrypt' is not valid for ${this.name} keys.`);
}

public override async encrypt(): Promise<Uint8Array> {
throw new InvalidAccessError(`Requested operation 'encrypt' is not valid for ${this.name} keys.`);
}

public override async generateKey(): Promise<Web5Crypto.CryptoKey> {
throw new InvalidAccessError(`Requested operation 'generateKey' is not valid for ${this.name} keys.`);
}

public override async sign(): Promise<Uint8Array> {
throw new InvalidAccessError(`Requested operation 'sign' is not valid for ${this.name} keys.`);
}

public override async verify(): Promise<boolean> {
throw new InvalidAccessError(`Requested operation 'verify' is not valid for ${this.name} keys.`);
}
}
1 change: 1 addition & 0 deletions packages/crypto/src/crypto-algorithms/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './ecdh.js';
export * from './ecdsa.js';
export * from './eddsa.js';
export * from './pbkdf2.js';
export * from './aes-ctr.js';
50 changes: 50 additions & 0 deletions packages/crypto/src/crypto-algorithms/pbkdf2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { Web5Crypto } from '../types/web5-crypto.js';

import { BasePbkdf2Algorithm, CryptoKey, OperationError } from '../algorithms-api/index.js';
import { Pbkdf2 } from '../crypto-primitives/pbkdf2.js';

export class Pbkdf2Algorithm extends BasePbkdf2Algorithm {
public readonly hashAlgorithms = ['SHA-256', 'SHA-384', 'SHA-512'];

public async deriveBits(options: {
algorithm: Web5Crypto.Pbkdf2Options,
baseKey: Web5Crypto.CryptoKey,
length: number
}): Promise<Uint8Array> {
const { algorithm, baseKey, length } = options;

this.checkAlgorithmOptions({ algorithm, baseKey });
// The base key must be allowed to be used for deriveBits operations.
this.checkKeyUsages({ keyUsages: ['deriveBits'], allowedKeyUsages: baseKey.usages });
// If the length is not a multiple of 8, throw.
if (length && length % 8 !== 0) {
throw new OperationError(`To be compatible with all browsers, 'length' must be a multiple of 8.`);
}

const derivedBits = Pbkdf2.deriveKey({
hash : algorithm.hash as 'SHA-256' | 'SHA-384' | 'SHA-512',
iterations : algorithm.iterations,
length : length,
password : baseKey.material,
salt : algorithm.salt
});

return derivedBits;
}

public async importKey(options: {
format: Web5Crypto.KeyFormat,
keyData: Uint8Array,
algorithm: Web5Crypto.Algorithm,
extractable: boolean,
keyUsages: Web5Crypto.KeyUsage[]
}): Promise<Web5Crypto.CryptoKey> {
const { format, keyData, algorithm, extractable, keyUsages } = options;

this.checkImportKey({ algorithm, format, extractable, keyUsages });

const cryptoKey = new CryptoKey(algorithm, extractable, keyData, 'secret', keyUsages);

return cryptoKey;
}
}
1 change: 1 addition & 0 deletions packages/crypto/src/crypto-primitives/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './pbkdf2.js';
export * from './x25519.js';
export * from './aes-ctr.js';
export * from './aes-gcm.js';
Expand Down
80 changes: 80 additions & 0 deletions packages/crypto/src/crypto-primitives/pbkdf2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { crypto } from '@noble/hashes/crypto';

import { isWebCryptoSupported } from '../utils.js';

type DeriveKeyOptions = {
hash: 'SHA-256' | 'SHA-384' | 'SHA-512',
password: Uint8Array,
salt: Uint8Array,
iterations: number,
length: number
};

export class Pbkdf2 {
public static async deriveKey(options: DeriveKeyOptions): Promise<Uint8Array> {
if (isWebCryptoSupported()) {
return Pbkdf2.deriveKeyWithWebCrypto(options);
} else {
return Pbkdf2.deriveKeyWithNodeCrypto(options);
}
}

private static async deriveKeyWithNodeCrypto(options: DeriveKeyOptions): Promise<Uint8Array> {
const { password, salt, iterations } = options;

// Map the hash string to the node:crypto equivalent.
const hashToNodeCryptoMap = {
'SHA-256' : 'sha256',
'SHA-384' : 'sha384',
'SHA-512' : 'sha512'
};
const hash = hashToNodeCryptoMap[options.hash];

// Convert length from bits to bytes.
const length = options.length / 8;

// Dynamically import node:crypto.
const { pbkdf2 } = await import('node:crypto');

This comment has been minimized.

Copy link
@shamilovtim

shamilovtim Nov 5, 2023

Contributor

could this just be crypto @frankhinek?

I could be off but I'm pretty sure this strange import syntax node:crypto bites us where we polyfill globalThis.crypto or crypto which is not necessarily node:crypto.

This comment has been minimized.

Copy link
@frankhinek

frankhinek Nov 6, 2023

Author Contributor

@shamilovtim We likely do need to change the dynamic import to import('crypto') to support React Native. RN lacks support for the WebCrypto API (aka Subtle crypto) that is available in all modern web browsers and the Node.js versions Web5 JS support (18+). The use of crypto and not node:crypto will make it possible to override with a polyfill or a module from node_modules.


Reference info on the node:crypto syntax:

The import('crypto') syntax attempts to import the crypto module, which could be either a built-in Node.js module or a user-installed module from node_modules (or a module resolved by a custom resolver).

The import('node:crypto') syntax is more explicit; it tells Node.js to import the built-in crypto module directly, ignoring node_modules or any custom resolvers. This was introduced in Node.js to avoid name conflicts with npm packages and to make it clear that the imported module is a built-in module.

See https://nodejs.org/dist/latest-v20.x/docs/api/crypto.html#crypto for more details.


return new Promise((resolve, reject) => {
pbkdf2(
password,
salt,
iterations,
length,
hash,
(err, derivedKey) => {
if (err) {
reject(err);
} else {
resolve(new Uint8Array(derivedKey));
}
}
);
});
}

private static async deriveKeyWithWebCrypto(options: DeriveKeyOptions): Promise<Uint8Array> {
const { hash, password, salt, iterations, length } = options;

// Import the password as a raw key for use with the Web Crypto API.
const webCryptoKey = await crypto.subtle.importKey(
'raw',
password,
{ name: 'PBKDF2' },
false,
['deriveBits']
);

const derivedKeyBuffer = await crypto.subtle.deriveBits(

This comment has been minimized.

Copy link
@shamilovtim

shamilovtim Nov 5, 2023

Contributor

curious why crypto in this case is coming from noble (import { crypto } from '@noble/hashes/crypto';) rather than the native crypto library

This comment has been minimized.

Copy link
@shamilovtim

shamilovtim Nov 5, 2023

Contributor

answering my own question. took a peak under the hood and seems like this just does some checks under the hood and returns undefined if real crypto doesn't exist. strange naming on the author's part, would expect something like cryptoIfExists or maybeCrypto.

This comment has been minimized.

Copy link
@frankhinek

frankhinek Nov 6, 2023

Author Contributor

It does more than it appears on the surface and is a clever way of returning the right crypto module depending on whether the runtime environment is a web browser or Node.js.

From utils.ts:

// We use WebCrypto aka globalThis.crypto, which exists in browsers and node.js 16+.
// node.js versions earlier than v19 don't declare it in global scope.
// For node.js, package.json#exports field mapping rewrites import
// from crypto to cryptoNode, which imports native module.
// Makes the utils un-importable in browsers without a bundler.
// Once node.js 18 is deprecated, we can just drop the import.

See:

The reason it is named crypto is because it returns a reference to the crypto package and if crypto does not exist then it is undefined -- which mirrors what crypto would be if not available.

{ name: 'PBKDF2', hash, salt, iterations },
webCryptoKey,
length
);

// Convert from ArrayBuffer to Uint8Array.
const derivedKey = new Uint8Array(derivedKeyBuffer);

return derivedKey;
}
}
4 changes: 2 additions & 2 deletions packages/crypto/src/jose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -880,7 +880,7 @@ export class Jose {
}

public static webCryptoToJose(options:
Web5Crypto.GenerateKeyOptions
Web5Crypto.Algorithm | Web5Crypto.GenerateKeyOptions
): Partial<JsonWebKey> {
const params: string[] = [];

Expand All @@ -900,7 +900,7 @@ export class Jose {
* All symmetric encryption (AES) WebCrypto algorithms
* set a value for the "length" parameter.
*/
} else if (options.length !== undefined) {
} else if ('length' in options && options.length !== undefined) {
params.push(options.length.toString());

/**
Expand Down
10 changes: 9 additions & 1 deletion packages/crypto/src/types/web5-crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export namespace Web5Crypto {
export type AlgorithmIdentifier = Algorithm;

export interface CryptoKey {
algorithm: Web5Crypto.GenerateKeyOptions;
algorithm: Web5Crypto.Algorithm;
extractable: boolean;
material: Uint8Array;
type: KeyType;
Expand Down Expand Up @@ -64,6 +64,8 @@ export namespace Web5Crypto {
name: string;
}

export type KeyFormat = 'jwk' | 'pkcs8' | 'raw' | 'spki';

export interface KeyPairUsage {
privateKey: KeyUsage[];
publicKey: KeyUsage[];
Expand Down Expand Up @@ -106,5 +108,11 @@ export namespace Web5Crypto {

export type NamedCurve = string;

export interface Pbkdf2Options extends Algorithm {
hash: string;
iterations: number;
salt: Uint8Array;
}

export type PrivateKeyType = 'private' | 'secret';
}
19 changes: 19 additions & 0 deletions packages/crypto/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,25 @@ export function keyToMultibaseId(options: {
return multibaseKeyId;
}

export function isWebCryptoSupported(): boolean {
if (typeof window !== 'undefined' && window.crypto && window.crypto.subtle) {
// Web browser environment.
return true;
} else if (typeof global !== 'undefined' && global.crypto && global.crypto.subtle) {
// Node.js environment.
return true;
} else if (typeof self !== 'undefined' && self.crypto && self.crypto.subtle) {

This comment has been minimized.

Copy link
@shamilovtim

shamilovtim Nov 5, 2023

Contributor

no self.crypto.subtle in RN

// React Native environment.
return true;
} else if (typeof crypto !== 'undefined' && crypto.subtle) {
// Other environment (e.g. Web Worker).
return true;
} else {
// Web Crypto API is not supported.
return false;
}
}

export function multibaseIdToKey(options: {
multibaseKeyId: string
}): { key: Uint8Array, multicodecCode: number, multicodecName: string } {
Expand Down

0 comments on commit 25f6e78

Please sign in to comment.