tech.guitarrapc.cóm

Technical updates

パスワード管理をTeamsId から Bitwarden に移行した

今のパスワード管理に小さな不満があるので長年次のパスワード管理をさがしていたのですが、Bitwardenが今ある全ての望みをかなえてくれました。

bitwarden.com

今回、TeamsId から Bitwarden に全面移行したのでその移行についてメモをしておきます。

目次

今まで使ってきたパスワード管理

個人のパスワード管理は、2015年3月まではMeldiumを使ってましたが Discontinueが発表されてからはTeamsId を使っていました。

www.teamsid.com

TeamsId を見返す

TeamsId (運営はSplashData) の良いと感じてる点は次の通りで多くの面で満足していました価格は$3user/month です。

  • Web上で管理される
  • 少人数でのパスワード共有も管理が容易
  • Chrome拡張があり入力が容易
  • iPhoneアプリもある
  • Googleログインで統制できる
  • G Suite の9 squareに表示できる

一方で、3年つかっていると細かい不満がたまってきました。

  • Web管理画面で描画が遅い
  • 反映までの3-5secの間に他のレコードを触ると意図と異なったレコードを編集することがある
  • 利用者の声が他に全然おらずサービス終わることありそうという気配
  • 日本語サービス名にすると全レコードが表示されなくなる
  • サポートの対応が技術的に掘り下げ弱い

特に日本語サービス名にした時に表示されなくなるのはかなり焦るものがあり、半年前に3度目が発生し改善の見込みが見えないので乗り換えを考えていました。

機能数よりは、少人数での共有のしやすさ、使い勝手の良さを評価しています。

www.itqlick.com

f:id:guitarrapc_tech:20190116095015p:plain

検討したサービス

いくつか検討し、仕事でも使ったりすることで探ってきました。

1Password、LastPass、KeePass、Zohoいずれも使い勝手と価格と少人数での共有の面からあまり満足できるものはなく悩みが多かったです。ちょうど1Password Teams が発表されたタイミングもあり触っていたのですが結局TeamsId でいいやとなっていました。

1password.com

www.lastpass.com

ここ1年は、Dashlane がワンチャンかと思っていましたが、決定的な理由がないのでペンディングしていました。

https://www.dashlane.com/ja/plans/premium

やりたいこととのバランス、使い勝手はそこまで変わらないなら乗り換えないというのは自分の行動を見ていてもそういうものかなぁと思います。個人的には、クラウド管理でもいいと思っており、ローカル/自前クラウド管理は避けたいところです。(必要ならやりますが必然性は感じていない)

Bitwarden

Bitwardenはたびたび見ていましたが、あまり興味がでなかったものの2018年末に改めて良いという話を聞いて試しました。

bitwarden.com

オープンソースなのは結構良いと思っているのですが、妙な処理があっても全コードは見てられないので気付けるかなぁと思いつつ。しかし、最悪コード読めるのはいいと感じています。

2FA して2人で共有できればいいので、PersonalもOrganizationも無料で十分満たせそうです。

f:id:guitarrapc_tech:20190116094545p:plain

YubiKeyに移行も進めているので、Personal だけ Premium もあり得ます。

ブラウザ拡張、スマホアプリ共に十分使い勝手は良く感じます。

f:id:guitarrapc_tech:20190116094721p:plain

Bitwardenの使い方

これは多くの記事があるのであえて書くことはないと感じるのでそちらをどうぞ。

tips4life.me

excesssecurity.com

TeamsIdからの移行

ぱっと使ってみた感じはよさそうなので、今のTeamsId のレコードを移行します。残念ながら TeamsId はありません、同一会社のSpashIdがあるのに。

f:id:guitarrapc_tech:20190116095447p:plain

試しにTeamsIdのエキスポートcsvをそのまま取り込んでみると、すさまじいことになったので推奨できません。Bitwarden のフォルダがまともに消せなくていやになりそうになったのは内緒です。

仕方ないので、Bitwarden の Generic Importフォーマットで取り込みを試みます。ドキュメントではcsv とあります。

f:id:guitarrapc_tech:20190116095654p:plain

しかし、Fields は入れ子のレコードでExportしてみると大変なことになるのが分かります。

folder,favorite,type,name,notes,fields,login_uri,login_username,login_password,login_totp
Social,1,login,Twitter,,,twitter.com,me@example.com,password123,
,,login,My Bank,Bank PIN is 1234,"PIN: 1234
Question 1: Blue",https://www.wellsfargo.com/home.jhtml,john.smith,password123456,
,,login,EVGA,,,https://www.evga.com/support/login.asp,hello@bitwarden.com,fakepassword,TOTPSEED123
,,note,My Note,"This is a secure note.

Notes can span multiple lines.",,,,,

Csvは行を跨ぐ、入れ子データの取り扱いが面倒なので避けたいところです。 ドキュメントにはありませんが、jsonでインポートできます。

{
  "folders": [],
  "items": [
    {
      "id": "2fb7acc2-41dc-4659-9dc3-a9d301283339",
      "organizationId": null,
      "folderId": null,
      "type": 3,
      "name": "card sample",
      "notes": "note line 1",
      "favorite": false,
      "card": {
        "cardholderName": "Jane Doe",
        "brand": "Visa",
        "number": "123456778",
        "expMonth": "1",
        "expYear": "2021",
        "code": "1234"
      },
      "collectionIds": null
    },
    {
      "id": "5a9eb019-e092-43ae-ac30-a9d301279b2a",
      "organizationId": null,
      "folderId": null,
      "type": 1,
      "name": "LoginSample",
      "notes": "note line 1\nnote line 2",
      "favorite": false,
      "login": {
        "uris": [
          {
            "match": null,
            "uri": "https://example.com"
          }
        ],
        "username": "username",
        "password": "password",
        "totp": "authenticator key"
      },
      "collectionIds": null
    },
    {
      "id": "254a958e-dc04-4330-aa05-a9d3012801c0",
      "organizationId": null,
      "folderId": null,
      "type": 1,
      "name": "LoginSample2",
      "notes": null,
      "favorite": false,
      "fields": [
        {
          "name": "custom",
          "value": "value",
          "type": 0
        },
        {
          "name": "custom hiden",
          "value": "value",
          "type": 1
        },
        {
          "name": "custom boolean",
          "value": "true",
          "type": 2
        }
      ],
      "login": {
        "uris": [
          {
            "match": null,
            "uri": "https://example.com"
          }
        ],
        "username": "username",
        "password": "password",
        "totp": "authenticaticator key"
      },
      "collectionIds": null
    }
  ]
}

こちらは素直なフォーマットで型変換も容易です。

ということで、TeamsId Csv -> Bitwarden Json への変換を書きましょう。

下準備

TeamsId は、Export結果にタグは含みません。あくまでもフィールドとしてすべて出てくるので、Bitwardenに取り込みたい項目はFieldに割り当てておきます。

私の場合は、Bitwarden Personalに取り込む場合は、TeamsId でGroupフィールドを作って割り当てたいグループ名をいれました。

実装

パーサーライブラリとして公開しました。*1

github.com

TeamsId のPersonalデータ -> Bitwarden の Personal データ変換、TeamsId の Organizationデータ -> Bitwarden の Organizationデータの両方に対応しています。

Dockerfile、.NET Coreも対応済みです。使い方はSampleを用意したのでどうぞ。

github.com

これに限らずですが、作業はLinqPad でやっていたので C# です。こういうパーサーを書くとき、LinqPadは非常に便利です。

f:id:guitarrapc_tech:20190116100311p:plain

変換結果を見ながら書き換えできます。*2

f:id:guitarrapc_tech:20190116100432p:plain

実装のポイントだけメモしておきます。

TeamsId Csv の解析

C# で Csv パーサーを探していくつか試しましたが、結果 CsvHealper が最も使いやすかったです。AutoMapperもあり、改行レコードがあっても素直に処理してくれます。.NETStandard も対応しています。

github.com

joshclose.github.io

TeamsId はカスタムフィールドがあるとどんどんExport時のカラムが増えるので、40フィールドのマッピングをするのはつらいのでAutoMapperはほしいところです。さて、利用に際してはStreamで読み込みが必要なので軽いラッパーだけ用意しました。

public class CsvParser
{
    private readonly string path;

    public CsvParser(string path)
    {
        this.path = path;
    }

    public T[] Parse<T>() where T : class
    {
        using (var reader = new StreamReader(path))
        using (var csv = new CsvReader(reader))
        {
            csv.Configuration.MissingFieldFound = null;
            var records = csv.GetRecords<T>();
            return records.ToArray();
        }
    }
}

TeamsId のフィールド解析、値取得

TeamsId のExport Csv は、次のフォーマットになります。

description,note,Field0,Type0,Value0,Field1,Type1,Value1,Field2,Type2,Value2,Field3,Type3,Value3,Field4,Type4,Value4,Field5,Type5,Value5,Field6,Type6,Value6,Field7,Type7,Value7,Field8,Type8,Value8,Field9,Type9,Value9,Field10,Type10,Value10,Field11,Type11,Value11,Field12,Type12,Value12,Field13,Type13,Value13,Field14,Type14,Value14,Field15,Type15,Value15

description、note以外は、レコード1つあたり FieldN, TypeN, ValueN の連続です。そのため、今回はリフレクションしてフィールドの値から型にマッピングします。

C# 7 から使えるswitch でのwhen 句を用いることで、強力に条件分岐ができるので、フィールドを特定のプロパティに割り当てたい時に便利でした。

    private FieldRecord ParseFieldRecord(PropertyInfo[] props, Type t, TeamsIdDefinition source)
    {
        var fieldRecord = new FieldRecord();
        // get FieldXX properties via reflection
        var fieldRecords = props
            .Where(x => Match(x.Name, @"Field\d+"))
            .Where(x => GetPropertyValue(source, t, x.Name) != "")
            .Select(x => (key: GetPropertyValue(source, t, x.Name), value: GetPropertyValue(source, t, x.Name.Replace("Field", "Value"))))
            .ToArray();

        // get field's value and categolize each via field name regex pattern
        var secureMemoList = new List<(string, string)>();
        var memoList = new List<(string, string)>();
        foreach (var record in fieldRecords)
        {
            switch (record.key)
            {
                case var _ when Match(record.key, "url") && fieldRecord.Url == null:
                    fieldRecord.Url = record.value;
                    break;
                case var _ when Match(record.key, "email|e-mail") && fieldRecord.Email == null:
                    fieldRecord.Email = record.value;
                    break;
                case var _ when Match(record.key, "username") && fieldRecord.UserName == null:
                    fieldRecord.UserName = record.value;
                    break;
                case var _ when Match(record.key, "password") && fieldRecord.Password == null:
                    fieldRecord.Password = record.value;
                    break;
                case var _ when Match(record.key, "pass|access|secret|pin|token"):
                    secureMemoList.Add((record.key, record.value));
                    break;
                case var _ when Match(record.key, "group"):
                    fieldRecord.Group = record.value;
                    break;
                default:
                    memoList.Add((record.key, record.value));
                    break;
            }
        }
        fieldRecord.Fields = memoList.ToArray();
        fieldRecord.SecureFields = secureMemoList.ToArray();
        return fieldRecord;
    }

    private bool Match(string text, string pattern)
    {
        return Regex.IsMatch(text, pattern, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
    }

    private string GetPropertyValue(TeamsIdDefinition record, Type t, string propertyField)
    {
        return (string)t.GetProperty(propertyField).GetValue(record);
    }

手元の300レコードは素直にインポートできたので、だいたいのケースではつかえそうです。

変換時の注意

Bitwarden Personal では FolderId が GUIDで定義、マッピングしておかないと死にます。(実装は対応済みで、Sampleに定義例があります。)

Bitwarden Organizationでは、OrganizationIdとCollectionIds を定義してマッピングしましょう。(実装は対応済みで、Sampleに定義例があります。)

インポート

変換した結果はJsonで出力されるのでBitwarden で取り込めば完了です!

f:id:guitarrapc_tech:20190116101329p:plain

まとめ

幸せな感じなので少し様子を見てみましょう。

*1:TeamsIdの利用者数からして需要はなさそう

*2:黒字で塗りつぶしたらさっぱりわからない