Skip to content

Commit

Permalink
Merge pull request #11 from commenthol/feat-esm
Browse files Browse the repository at this point in the history
Feat esm
  • Loading branch information
commenthol authored Aug 30, 2024
2 parents a9f74c7 + 8b3f22e commit b5753ea
Show file tree
Hide file tree
Showing 17 changed files with 552 additions and 105 deletions.
12 changes: 0 additions & 12 deletions .eslintrc.yml

This file was deleted.

6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
# 1.3.0-0 (2024-07-27)

- feat: add binascii functions for zero dependencies (#633af73)
- feat: change to ESM (#0316412)
- docs: fix CI status badge (#a9f74c7)

# 1.2.0 (2024-07-27)

- feat: fix pkcs7 padding for encryption to match compatibility with ansible-vault (#5dc1426)
Expand Down
1 change: 1 addition & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
The MIT License (MIT)

Copyright (c) 2019- commenthol
Copyright (c) 2014 Michał Budzyński (@michalbe)

Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
encrypt

```js
const { Vault } = require('ansible-vault')
import { Vault } from 'ansible-vault'

const v = new Vault({ password: 'pa$$w0rd' })
v.encrypt('superSecret123').then(console.log)
Expand All @@ -28,7 +28,7 @@ const vault = v.encryptSync('superSecret123')
decrypt

```js
const { Vault } = require('ansible-vault')
import { Vault } from 'ansible-vault'

const vault = `$ANSIBLE_VAULT;1.1;AES256
33383239333036363833303565653032383832663162356533343630623030613133623032636566
Expand Down
17 changes: 17 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import globals from 'globals'
import pluginPrettier from 'eslint-plugin-prettier/recommended'

export default [
{ languageOptions: { globals: globals.node } },
pluginPrettier,
{
rules: {
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }]
},
ignores: [
'coverage',
'lib',
'tmp'
]
}
]
329 changes: 329 additions & 0 deletions lib/index.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,329 @@
'use strict';

var util = require('util');
var crypto = require('crypto');

/**
* @license MIT
* @copyright Copyright (c) 2014 Michał Budzyński (@michalbe)
* @see https://github.com/michalbe/binascii
* @see https://docs.python.org/2/library/binascii.html
*/

/**
* @param {string} str
* @returns {string}
*/
function hexlify(str) {
let result = '';
for (let i = 0, l = str.length; i < l; i++) {
const digit = str.charCodeAt(i).toString(16);
const padded = ('00' + digit).slice(-2);
result += padded;
}
return result
}

/**
* @param {string} str
* @returns {string}
*/
function unhexlify(str) {
let result = '';
for (var i = 0, l = str.length; i < l; i += 2) {
result += String.fromCharCode(parseInt(str.slice(i, i + 2), 16));
}
return result
}

/**
* pkcs7 pad
* @param {number} messageLength
* @param {number} blocksize
* @returns {Buffer}
*/
function pad(messageLength, blocksize) {
if (blocksize > 256) throw new Error("can't pad blocks larger 256 bytes")
const padLength = blocksize - (messageLength % blocksize);
return Buffer.alloc(padLength, Buffer.from([padLength]))
}

/**
* pkcs7 unpad
* @param {Buffer} padded
* @param {number} blocksize
* @returns {Buffer}
*/
function unpad(padded, blocksize) {
let len = padded.length;
const byte = padded[len - 1];
if (byte > blocksize) return padded
for (let i = len - byte; i < len; i++) {
if (padded[i] !== byte) {
return padded
}
}
return padded.subarray(0, len - byte)
}

var pkcs7 = /*#__PURE__*/Object.freeze({
__proto__: null,
pad: pad,
unpad: unpad
});

const pbkdf2 = util.promisify(crypto.pbkdf2);

const HEADER = '$ANSIBLE_VAULT';
const AES256 = 'AES256';
const CIPHER = 'aes-256-ctr';
const DIGEST = 'sha256';

const PASSWORD = Symbol();

/**
* @typedef DerivedKey
* @property {Buffer} key
* @property {Buffer} hmacKey
* @property {Buffer} iv
*/

/**
* @typedef Unpacked
* @property {Buffer} salt
* @property {Buffer} hmac
* @property {Buffer} ciphertext
*/

class Vault {
/**
* @param {object} param0
* @param {string} param0.password vault password
*/
constructor({ password }) {
this[PASSWORD] = password;
}

/**
* @private
* @param {string} header
* @returns {boolean|string} for 1.2 "id" and for 1.1 `true` if header is ok, otherwise false
*/
_checkHeader(header) {
if (!header) {
return false
}
const [_header, version, cipher, id = true] = header.split(';');

if (_header === HEADER && /^1\.[12]$/.test(version) && cipher === AES256) {
return id
}
return false
}

/**
* @private
* @param {Buffer} key
* @param {Buffer} ciphertext
* @returns {Buffer}
*/
_hmac(key, ciphertext) {
const hmac = crypto.createHmac(DIGEST, key);
hmac.update(ciphertext);
return hmac.digest()
}

/**
* @private
* @param {Buffer} salt
* @returns {Promise<DerivedKey>}
*/
async _derivedKey(salt) {
if (!this[PASSWORD]) throw new Error('No password')

const derivedKey = await pbkdf2(this[PASSWORD], salt, 10000, 80, DIGEST);
return this._deriveKey(derivedKey)
}

/**
* @private
* @param {Buffer} salt
* @returns {DerivedKey}
*/
_derivedKeySync(salt) {
if (!this[PASSWORD]) throw new Error('No password')

const derivedKey = crypto.pbkdf2Sync(
this[PASSWORD],
salt,
10000,
80,
DIGEST
);
return this._deriveKey(derivedKey)
}

/**
* @private
* @param {Buffer} derivedKey
* @returns {DerivedKey}
*/
_deriveKey(derivedKey) {
const key = derivedKey.subarray(0, 32);
const hmacKey = derivedKey.subarray(32, 64);
const iv = derivedKey.subarray(64, 80);
return {
key,
hmacKey,
iv
}
}

/**
* Encrypt `secret` text
* @param {string} secret
* @param {string} id
* @returns {Promise<string>} encrypted string
*/
async encrypt(secret, id) {
const salt = crypto.randomBytes(32);
const derivedKey = await this._derivedKey(salt);
return this._cipher(secret, id, salt, derivedKey)
}

/**
* Synchronously encrypt `secret` text
* @param {string} secret
* @param {string} id
* @returns {string} encrypted string
*/
encryptSync(secret, id) {
const salt = crypto.randomBytes(32);
const derivedKey = this._derivedKeySync(salt);
return this._cipher(secret, id, salt, derivedKey)
}

/**
* @private
* @param {string} secret
* @param {string} id
* @param {Buffer} salt
* @param {DerivedKey} derivedKey
* @returns
*/
_cipher(secret, id, salt, derivedKey) {
const { key, hmacKey, iv } = derivedKey;
const cipherF = crypto.createCipheriv(CIPHER, key, iv);
const padded = Buffer.concat([
Buffer.from(secret, 'utf-8'),
pad(Buffer.from(secret, 'utf-8').length, 16)
]);
const ciphertext = Buffer.concat([cipherF.update(padded), cipherF.final()]);

const hmac = this._hmac(hmacKey, ciphertext);
const hex = [salt, hmac, ciphertext]
.map((buf) => buf.toString('hex'))
.join('\n');
return this._pack(id, hex)
}

/**
* @private
* @param {Unpacked} unpacked
* @param {DerivedKey} derivedKey
* @returns
*/
_decipher(unpacked, derivedKey) {
const { hmac, ciphertext } = unpacked;
const { key, hmacKey, iv } = derivedKey;
const hmacComp = this._hmac(hmacKey, ciphertext);

if (Buffer.compare(hmacComp, hmac) !== 0)
throw new Error('Integrity check failed')

const cipherF = crypto.createDecipheriv(CIPHER, key, iv);
const buffer = unpad(
Buffer.concat([cipherF.update(ciphertext), cipherF.final()]),
16
);

return buffer.toString()
}

/**
* @private
* @param {string|undefined} id optional id
* @param {string} hex hex encoded
* @returns {string} ansible encoded secret
*/
_pack(id, hex) {
const header = id
? `${HEADER};1.2;${AES256};${id}\n`
: `${HEADER};1.1;${AES256}\n`;

return (
header +
// @ts-expect-error
hexlify(hex)
.match(/.{1,80}/g)
.join('\n')
)
}

/**
* @private
* @param {string} vault
* @param {string|undefined} id optional id
* @returns {Unpacked|undefined}
*/
_unpack(vault, id) {
const [header, ...hexValues] = vault.split(/\r?\n/);

const _id = this._checkHeader(header);
if (!_id) throw new Error('Bad vault header')
if (id && id !== _id) return // only decrypt if `id` is matching id in header

const [salt, hmac, ciphertext] = unhexlify(hexValues.join(''))
.split(/\r?\n/)
.map((hex) => Buffer.from(hex, 'hex'));

if (!salt || !hmac || !ciphertext) throw new Error('Invalid vault')

return { salt, hmac, ciphertext }
}

/**
* Decrypt vault
* @param {string} vault
* @param {string|undefined} id optional id
* @returns {Promise<string|undefined>}
*/
async decrypt(vault, id) {
const unpacked = this._unpack(vault, id);
if (!unpacked) return
const { salt } = unpacked;

const derivedKey = await this._derivedKey(salt);
return this._decipher(unpacked, derivedKey)
}

/**
* Synchronously decrypt vault
* @param {string} vault
* @param {string|undefined} id optional id
* @returns {string|undefined}
*/
decryptSync(vault, id) {
const unpacked = this._unpack(vault, id);
if (!unpacked) return
const { salt } = unpacked;

const derivedKey = this._derivedKeySync(salt);
return this._decipher(unpacked, derivedKey)
}
}

exports.Vault = Vault;
exports.hexlify = hexlify;
exports.pkcs7 = pkcs7;
exports.unhexlify = unhexlify;
Loading

0 comments on commit b5753ea

Please sign in to comment.