88 lines
2.7 KiB
TypeScript
88 lines
2.7 KiB
TypeScript
import {
|
|
createHash,
|
|
createPrivateKey,
|
|
createPublicKey,
|
|
generateKeyPairSync,
|
|
privateDecrypt,
|
|
type KeyObject,
|
|
constants,
|
|
} from 'node:crypto';
|
|
|
|
export interface PasswordEnvelopeInput {
|
|
keyId: string;
|
|
ciphertext: string;
|
|
}
|
|
|
|
export interface PasswordPublicKey {
|
|
keyId: string;
|
|
algorithm: 'RSA-OAEP-256';
|
|
publicKeyPem: string;
|
|
}
|
|
|
|
export interface PasswordEnvelopeService {
|
|
getPublicKey(): PasswordPublicKey;
|
|
open(input: PasswordEnvelopeInput): string;
|
|
}
|
|
|
|
const MAX_PASSWORD_BYTES = 128;
|
|
|
|
const buildKeyId = (publicKey: KeyObject): string =>
|
|
createHash('sha256').update(publicKey.export({ format: 'der', type: 'spki' })).digest('base64url').slice(0, 22);
|
|
|
|
const decodeUtf8 = (value: Buffer): string => {
|
|
if (value.length === 0 || value.length > MAX_PASSWORD_BYTES) {
|
|
throw new Error('Password envelope has an invalid length.');
|
|
}
|
|
const decoded = value.toString('utf8');
|
|
if (!Buffer.from(decoded, 'utf8').equals(value)) {
|
|
throw new Error('Password envelope is not valid UTF-8.');
|
|
}
|
|
return decoded;
|
|
};
|
|
|
|
export const createPasswordEnvelopeService = (privateKeyPem?: string): PasswordEnvelopeService => {
|
|
const privateKey = privateKeyPem
|
|
? createPrivateKey(privateKeyPem)
|
|
: generateKeyPairSync('rsa', {
|
|
modulusLength: 3072,
|
|
publicExponent: 0x10001,
|
|
}).privateKey;
|
|
const publicKey = createPublicKey(privateKey.export({ format: 'pem', type: 'pkcs8' }));
|
|
const keyId = buildKeyId(publicKey);
|
|
const publicKeyPem = publicKey.export({ format: 'pem', type: 'spki' }).toString();
|
|
|
|
return {
|
|
getPublicKey: () => ({
|
|
keyId,
|
|
algorithm: 'RSA-OAEP-256',
|
|
publicKeyPem,
|
|
}),
|
|
open: (input) => {
|
|
if (input.keyId !== keyId) {
|
|
throw new Error('Password encryption key has changed.');
|
|
}
|
|
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(input.ciphertext)) {
|
|
throw new Error('Password envelope is not valid base64.');
|
|
}
|
|
let ciphertext: Buffer;
|
|
try {
|
|
ciphertext = Buffer.from(input.ciphertext, 'base64');
|
|
} catch {
|
|
throw new Error('Password envelope is not valid base64.');
|
|
}
|
|
if (!ciphertext.length) {
|
|
throw new Error('Password envelope is empty.');
|
|
}
|
|
const plaintext = privateDecrypt(
|
|
{
|
|
key: privateKey,
|
|
padding: constants.RSA_PKCS1_OAEP_PADDING,
|
|
oaepHash: 'sha256',
|
|
},
|
|
ciphertext
|
|
);
|
|
return decodeUtf8(plaintext);
|
|
},
|
|
};
|
|
};
|