コマンドラインで動作する簡易パスワードマネージャ「Neo's Password Manager」を作った

Node.js でコマンドラインツールを作る勉強として、簡易的なパスワードマネージャを作ってみた。その名も「Neo's Password Manager」。パッケージ名は @neos21/npm。Neo's Password Manager の頭文字を取って NPM と称しているが、コマンド名は np とした。

特徴と仕組み

このツールは Node.js で作成している。npm でグローバルインストールすると np というコマンドで動作するようになっている。ID やパスワード情報を単一の JSON ファイルに書き込み、それを参照する、というコマンドだ。

ID やパスワード情報を記録する DB となる JSON ファイルは、ユーザホームディレクトリ直下に neos-password-manager-db.json というファイル名で自動作成し保存する。

コマンドを使う際は、シェルに NEOS_MASTER_PASS という環境変数を定義しておいてもらう。この環境変数で示した文字列をマスターパスワードとして利用し、パスワード情報を暗号化して JSON ファイルに書き込んでいる。コレにより、JSON ファイルを持ち運んでもマスターパスワードを知らなければ復号できないので、多少セキュアになるかな、という狙い。

…ただ、実際のところ、シェルの環境変数って ~/.bash_profile とかで設定するし、それを Dotfiles として GitHub に上げたりすることも多いから、うっかり JSON ファイルをセットで GitHub とかに上げちゃうと、マスターパスワードと一緒に情報が漏れることになるよなぁ…というところが、イマイチ使い勝手の悪いところ。環境変数も JSON ファイルも、いずれも GitHub のようなオープンな場所で管理しないように注意。所詮は個人で作ったお遊びツールなので、ご利用は計画的に。

…念のため断り書きを入れておくが、コード全量を見てもらえば分かるとおり、入力された情報は一切外部に送信したりしていないので、その点はご安心を。このツールを誰がどんな風に使っているか追跡する方法は何もないし、暗号化には AES を利用しているので、開発者であっても復号したりする術はないのだ。

インストール方法と使い方

ツールのインストール方法と使い方は README.md にちゃんと書いたので、GitHub を参照のこと。

np add で JSON ファイルにデータを追加 or 更新、np get で JSON ファイルからデータ検索してコンソール出力、というのが主に使うコマンドかな。

実装について

モチベーション

今回のモチベーション、やりたかったことは、

というのが主なところ。

それ以外は単一のファイルを読み書きする程度なので、依存パッケージは commander の他、暗号化と復号に使用する crypto-js と、コンソール出力を整形するための columnify だけ。

commander 面白い

commander は色々柔軟にコマンドが定義できた。GitHub リポジトリでいうと index.js でコマンドを定義しているので、ココを見てもらえば想像つくかな。

OS 問わずユーザホームディレクトリを得るには

DB として利用する JSON ファイルをどこに置くかは迷った。ユーザホームディレクトリの直下とかに適当に置こうかな、と思ったのだが、Windows でどうなるかよく分からなかった。

そこで調べてみると、Node.js 組み込みの os モジュールにある、os.homedir() という関数がユーザホームディレクトリへのパスを返してくれることが分かったので、コレを利用した。

const os   = require('os');
const path = require('path');

const dbFilePath = path.resolve(os.homedir(), 'db-file.json');

コレで、OS を問わずに ~/db-file.json 相当のパスが用意できた。

ファイルの読み書きを Promise 化 → asyncawait 化する

Node.js でファイルの読み書きをするには、fs.readFilefs.writeFile を使う。コレはコールバックスタイルの関数なのだが、Node.js 組み込みの util モジュールにある、util.promisify() という関数を使うと、コールバックスタイルの関数を Promise 化してくれる。

const fs   = require('fs');
const util = require('util');

// Promisify で Promise 化する
const fsReadFile  = util.promisify(fs.readFile);
const fsWriteFile = util.promisify(fs.writeFile);

fsReadFile('./file.txt', 'utf-8')
  .then((text) => {
    console.log(text);
    
    return fsWriteFile('./file.txt', 'New File Text!', 'utf-8');
  })
  .then(() => {
    console.log('Success');
  })
  .catch((error) => {
    console.error(error);
  });

Promisify は、コールバック関数の第1引数に error を返す作りになっている関数なら何でも Promise 化できる。Node.js のほとんどの API ではコールバック関数の第1引数に error が返されるので、このような変換が可能になっている。

Promisify で Promise 化できたら、asyncawait の利用は簡単。以下のように書き換えられる。

const fs   = require('fs');
const util = require('util');

// Promisify で Promise 化する
const fsReadFile  = util.promisify(fs.readFile);
const fsWriteFile = util.promisify(fs.writeFile);

// 内部で await を使うので、関数宣言に async を付与する
async function example() {
  let text;
  try {
    // Promise な関数を await で待つ
    text = await fsReadFile('./file.txt', 'utf-8');
  }
  catch(error) {
    console.error(error);
    return;  // ココでは、後続のファイル書き出しは行わず中断する。コレがやりやすい
  }
  
  console.log(text);
  
  try {
    await fsWriteFile('./file.txt', 'New File Text!', 'utf-8');
    console.log('Success');
  }
  catch(error) {
    console.error(error);
  }
}

この程度なら fs.readFileSyncfs.writeFileSync で書くのと大差ない気がするが、とりあえずこんな感じ。

Promise と違うのは、「非同期処理が途中で例外を吐いた時に、その時点で関数を終了する」というエラーハンドリングがやりやすいところ。Promise を繋いで書いていると、fs.readFile が失敗した時に、後続の fs.writeFile を実行しないようにする、というハンドリングが面倒くさいのだ。

await は Promise な関数を待つだけの構文なので、例外を .catch() で処理しておいて上手く繋げる、という書き方もできたりする。

async function example() {
  // ファイルを読み込む
  // ファイル読み込みに失敗したら「Read Error」というテキストを返し、エラーを握り潰す
  const text1 = await fsReadFile('./file1.txt', 'utf-8').catch(_error => 'Read Error');
  console.log(text1);
  
  // 上は1行にしたが、改行したり `then()` を繋いだりしても問題ない
  const text2 = await fsReadFile('./file2.txt', 'utf-8')
    .then((text) => {
      console.log('ファイル読み込み成功・行頭に固定文言を付与して返す');
      return 'Success : ' + text;
    })
    .catch((error) => {
      console.warn('ファイル読み込み失敗・固定文言で続行', error);
      return 'Read Error';
    });
  console.log(text2);
}

こうした柔軟なハンドリングがしやすいので、例外発生時の中断が必要な場合は特に、asyncawait が有効だろう。

async を付与しないと実行できない問題

ところで、await を使いたい関数は、その宣言時に async を使う必要がある。そして async が付いている関数を呼び出すには await を使う必要があり、そうなると呼び出し元の関数にも async を付与する必要がある。コレではいつまで経っても asyncawait を解決して関数を実行できないので、大元の呼び出し元では、以下のように無名関数に async を付与して、即時関数として実行してやる。

// example コマンドを実行した時に、前述の async な example() 関数を呼び出す例
commander
  .command('example')
  .action(() => {
    // 無名関数に `async` を付与。`await` で `example()` 関数の実行を待ち、それを即時関数として実行する
    (async () => {
      await example();
    })();
  });

アロー関数だと分かりづらいかもしれないが、(async function() { await example(); })(); という構成だ。(function() { })(); で即時実行する要領で、async を付与して解決すれば良い。

その他

その他、JSON ファイルへの追記、検索、削除など、一連の処理はゴリゴリ実装した。何かライブラリを使えば検索も容易になったかもしれないが、今回は自力でやってみた。

パスワード情報を暗号化する際は crypto-js を使い、AES で暗号化した。結果表示の整形は columnify を使った程度。あとはほとんどゴリゴリ自力で実装。なかなかイマイチな気がする。w

以上

最初は、職場の PC で使用する、社内システムのログイン情報を一箇所に集約したいがために、このような作りにした次第。GitHub などオープンなところと繋げることはないし、JSON ファイルを持ち出してよそで使う要件もない。個人の端末に閉じた利用しか考えていなかったのでコレで十分かな、と。

commander がなかなか面白いので、Node.js でコマンドラインツール作成はこれからも続けていきたい。