diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..73f69e0 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..03d9549 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..639900d --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..dd06403 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/multicrypt.iml b/.idea/multicrypt.iml new file mode 100644 index 0000000..d6ebd48 --- /dev/null +++ b/.idea/multicrypt.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..797acea --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index 035e255..286cac8 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ -# template-npm-typescript +# multicrypt -A template repository for NPM packages built with Typescript and PNPM. \ No newline at end of file +Multicrypt is a library for multi-key reversible encryption. diff --git a/package.json b/package.json index 8beff3f..cc0e7e5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "template-npm-typescript", + "name": "multicrypt", "version": "0.1.0", - "description": "A template for NPM packages built with TypeScript", + "description": "A library for multi-key reversible encryption", "main": "lib/index.js", "types": "lib/index.d.ts", "directories": { @@ -21,7 +21,7 @@ "postversion": "git push && git push --tags", "repository": { "type": "git", - "url": "https://code.garrettmills.dev/garrettmills/template-npm-typescript" + "url": "https://code.garrettmills.dev/garrettmills/multicrypt" }, "author": "Garrett Mills ", "license": "MIT", diff --git a/src/KeyPackage.ts b/src/KeyPackage.ts new file mode 100644 index 0000000..7aac29a --- /dev/null +++ b/src/KeyPackage.ts @@ -0,0 +1,83 @@ +import {EncodedValue, isEncodedPayload, KeyValue} from './types' +import {Cryptor, InvalidKeyError} from './crypt/Cryptor' +import {pkg} from './interface' +import {Payload} from './Payload' + +/** + * A collection of keys that are used to decode a base key. + * + * The base key is used to encode various values that can then be accessed + * by all of the other keys in the package. + */ +export class KeyPackage { + /** Create a new key package from an initial sharer's key. */ + public static async fromInitialSharer(initialSharerKey: KeyValue): Promise { + // Get the cryptor for the initial sharer + const sharerCryptor = pkg.cryptor(initialSharerKey) + + // Encode a new global key using the initial sharer's key + const envelope = new Payload(Cryptor.getNewKey()) + const envelopeKey = await sharerCryptor.encode(envelope.encode()) + + // Return a new KeyPackage with the created envelope + return new KeyPackage([envelopeKey]) + } + + /** Instantiate a key package from its serialized JSON value. */ + public static fromJSON(value: { envelopes: EncodedValue[] }): KeyPackage { + return new KeyPackage(value.envelopes) + } + + constructor( + /** The encoded user keys. */ + private readonly envelopes: EncodedValue[], + ) {} + + /** + * Get a Cryptor instance for master key given some user's key, if that key is valid. + * @param key + */ + async getCryptor(key: KeyValue): Promise { + const envelopeCryptor = pkg.cryptor(key) + + for ( const encodedKey of this.envelopes ) { + // Attempt to decode one of the envelope keys using the input key + const decodedKey = await envelopeCryptor.decode(encodedKey) + if ( isEncodedPayload(decodedKey) ) { + // We successfully decoded the envelope, which contains the global key + return pkg.cryptor(Payload.from(decodedKey).value) + } + } + + throw new InvalidKeyError() + } + + /** + * Add a user's key to this package, granting them access to the master key. + * @param sharerKey - a currently valid user's key + * @param shareeKey - the key to add to the package + */ + async addCryptor(sharerKey: KeyValue, shareeKey?: KeyValue): Promise { + // Validate the sharer's key and get the cryptor + const sharerCryptor = await this.getCryptor(sharerKey) + + // Create a new cryptor for the sharee + const determinedShareeKey = shareeKey || Cryptor.getNewKey() + const shareeCryptor = pkg.cryptor(determinedShareeKey) + + // Encode the global key using the sharee's key + const envelope = new Payload(sharerCryptor.getKey()) + const envelopeKey = await shareeCryptor.encode(envelope.encode()) + this.envelopes.push(envelopeKey) + + // Return the sharee's key that can be used to decode the global key + return determinedShareeKey + } + + /** Serialize the key package securely. */ + toJSON() { + return { + envelopes: this.envelopes, + } + } +} diff --git a/src/Payload.ts b/src/Payload.ts new file mode 100644 index 0000000..f448c77 --- /dev/null +++ b/src/Payload.ts @@ -0,0 +1,22 @@ +import {EncodedPayload} from './types' + +/** + * Wrapper class for managing values encoded in a string format. + */ +export class Payload { + /** Instantiate the payload from a given string. */ + public static from(v: EncodedPayload): Payload { + const encoded = v.slice('multicrypt:'.length) + return new Payload(JSON.parse(encoded)) + } + + /** Instantiate the payload from a given value. */ + constructor( + public readonly value: T, + ) {} + + /** Serialize the value. */ + encode(): EncodedPayload { + return `multicrypt:${JSON.stringify(this.value)}` + } +} diff --git a/src/SharedValue.ts b/src/SharedValue.ts new file mode 100644 index 0000000..a6fd355 --- /dev/null +++ b/src/SharedValue.ts @@ -0,0 +1,96 @@ +import {EncodedValue, KeyValue} from './types' +import {KeyPackage} from './KeyPackage' +import {Payload} from './Payload' + +/** Interface for a JSON-serializable shared value. */ +export interface SerializedSharedValue { + value: EncodedValue, + envelopes: EncodedValue[], +} + +/** + * A value that is shared and accessible by multiple encryption keys. + */ +export class SharedValue { + /** The package of keys allowed to access the encoded value. */ + public readonly package: KeyPackage + + /** + * Create a new shared value. + * @param initialSharerKey - an initial key, used to give access to the encoded value + * @param value - the value to be encoded and shared + */ + public static async create(initialSharerKey: KeyValue, value: T): Promise> { + const keyPackage = await KeyPackage.fromInitialSharer(initialSharerKey) + const cryptor = await keyPackage.getCryptor(initialSharerKey) + const payload = new Payload(value) + + const serialized = { + value: await cryptor.encode(payload.encode()), + ...(keyPackage.toJSON()), + } + + return new SharedValue(serialized) + } + + constructor( + /** The serialized data for this shared value. */ + private serialized: SerializedSharedValue, + public readonly validator: (w: unknown) => w is T = (w: unknown): w is T => true, + ) { + this.package = KeyPackage.fromJSON(serialized) + } + + /** + * Get a Payload instance of the decoded value. + * @param key - valid key to decode the payload + */ + async payload(key: KeyValue): Promise> { + const cryptor = await this.package.getCryptor(key) + const decodedValue = await cryptor.decode(this.serialized.value) + return Payload.from(decodedValue) + } + + /** + * Get the value of this secret. + * @param key - valid key to decode the value + */ + async get(key: KeyValue): Promise { + const payload = await this.payload(key) + const value = payload.value + if ( !this.validator(value) ) { + throw new TypeError('Invalid encoded value!') + } + + return value + } + + /** + * Set a new value for this secret. + * @param key - valid key to encode the value + * @param value - new value to encode + */ + async set(key: KeyValue, value: T): Promise { + const cryptor = await this.package.getCryptor(key) + const payload = new Payload(value) + this.serialized.value = await cryptor.encode(payload.encode()) + } + + /** + * Add a new key to the key package for this shared value. This allows + * the `shareeKey` to access this shared value. + * @param sharerKey - a currently valid key + * @param shareeKey - the new key to add + */ + async addKey(sharerKey: KeyValue, shareeKey?: KeyValue): Promise { + return this.package.addCryptor(sharerKey, shareeKey) + } + + /** Serialize the shared value securely. */ + toJSON(): SerializedSharedValue { + return { + value: this.serialized.value, + ...(this.package.toJSON()), + } + } +} diff --git a/src/crypt/AES256Cryptor.ts b/src/crypt/AES256Cryptor.ts new file mode 100644 index 0000000..6fdd5d1 --- /dev/null +++ b/src/crypt/AES256Cryptor.ts @@ -0,0 +1,35 @@ +import {Cryptor} from './Cryptor' +import {Awaitable, DecodedValue, EncodedValue} from '../types' +import * as crypto from 'crypto' +import { Buffer } from 'buffer' + +/** + * AES 256 implementation of a Cryptor. + */ +export class AES256Cryptor extends Cryptor { + encode(value: DecodedValue): Awaitable { + const hash = crypto.createHash('sha256') + hash.update(this.key) + + const vector = crypto.randomBytes(16) + const cipher = crypto.createCipheriv('aes-256-ctr', hash.digest(), vector) + const input = Buffer.from(value) + const cipherText = cipher.update(input) + + const encoded = Buffer.concat([vector, cipherText, cipher.final()]) + return encoded.toString('base64') + } + + decode(payload: EncodedValue): Awaitable { + const hash = crypto.createHash('sha256') + hash.update(this.key) + + const input = Buffer.from(payload, 'base64') + const vector = input.slice(0, 16) + const decipher = crypto.createDecipheriv('aes-256-ctr', hash.digest(), vector) + const cipherText = input.slice(16) + + const decoded = Buffer.concat([decipher.update(cipherText), decipher.final()]) + return decoded.toString() + } +} diff --git a/src/crypt/Cryptor.ts b/src/crypt/Cryptor.ts new file mode 100644 index 0000000..83286ff --- /dev/null +++ b/src/crypt/Cryptor.ts @@ -0,0 +1,62 @@ +import {Awaitable, DecodedValue, EncodedValue, Instantiable, isInstantiable, KeyValue} from '../types' +import * as uuid from 'uuid' + +/** + * Error thrown when a key fails to decode an encoded value. + */ +export class InvalidKeyError extends Error { + constructor() { + super('The provided key is invalid and cannot be used to decode the payload.') + } +} + +/** + * Base class for implementations that encode and decode values using some key. + */ +export abstract class Cryptor { + /** Function used to instantiate the Cryptor. */ + protected static factoryFunction?: (key: KeyValue) => Cryptor + + /** Generate a new, random encryption key. */ + public static getNewKey(): string { + return uuid.v4().replace(/-/g, '') + } + + /** + * Get a factory that produces an instance of this cryptor. + */ + public static getFactory(): (key: KeyValue) => Cryptor { + if ( !this.factoryFunction ) { + const ctor = this as typeof Cryptor + if ( !isInstantiable(ctor) ) { + throw new Error('Cannot create factory for abstract Cryptor.') + } + + this.factoryFunction = key => new (ctor as Instantiable)(key) + } + + return this.factoryFunction + } + + constructor( + /** The key used by this cryptor. */ + protected key: KeyValue, + ) {} + + /** + * Using the key, encode the value. + * @param value + */ + public abstract encode(value: DecodedValue): Awaitable + + /** + * Using the key, decode the value. + * @param payload + */ + public abstract decode(payload: EncodedValue): Awaitable + + /** Get the key used by this Cryptor. */ + public getKey(): KeyValue { + return this.key + } +} diff --git a/src/index.ts b/src/index.ts index acc5e3b..a151bc2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,7 @@ -export const HELLO_WORLD = 'Hello, World!' - +export * from './types' +export * from './crypt/Cryptor' +export * from './crypt/AES256Cryptor' +export * from './KeyPackage' +export * from './Payload' +export * from './SharedValue' diff --git a/src/interface.ts b/src/interface.ts new file mode 100644 index 0000000..25f4bef --- /dev/null +++ b/src/interface.ts @@ -0,0 +1,11 @@ +import {AES256Cryptor} from './crypt/AES256Cryptor' + +/** + * Set of values that can be overridden externally to modify the behavior of this package. + */ +const pkg = { + /** Factory for producing the default Cryptor instance. */ + cryptor: AES256Cryptor.getFactory(), +} + +export { pkg } diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..36f8641 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,50 @@ + +/** Alias for a value that might come in a Promise. */ +export type Awaitable = T | Promise + +/** String that has been decrypted. */ +export type DecodedValue = string + +/** String that has been encrypted. */ +export type EncodedValue = string + +/** A key used for encryption. */ +export type KeyValue = string + +/** + * Interface that designates a particular value as able to be constructed. + */ +export interface Instantiable { + new(...args: any[]): T +} + +/** + * Returns true if the given value is instantiable. + * @param what + */ +export function isInstantiable(what: unknown): what is Instantiable { + return ( + Boolean(what) + && (typeof what === 'object' || typeof what === 'function') + && (what !== null) + && 'constructor' in what + && typeof what.constructor === 'function' + ) +} + +/** + * Serialized payload of a given value. + */ +export type EncodedPayload = string + +/** + * Determines that a given item is a valid serialized payload. + * @param what + */ +export function isEncodedPayload(what: unknown): what is EncodedPayload { + if ( typeof what !== 'string' ) { + return false + } + + return what.startsWith('multicrypt:') +}