• 作成:

NixOSで暗号化USBメモリのマウントコマンドを半自動的に生成する

背景

GPGを導入して署名用サブキーを端末ごとに分けて運用することにしました - ncaq に書いたように、 GPGのマスターキーなど機密データを暗号化したUSBメモリにバックアップしています。

普段はオフラインで保管しているこのUSBメモリを、必要なときに手動でマウントするのですが、毎回cryptsetupのコマンドを手打ちするのは面倒ですし、別のデバイスをフォーマットしてしまうなどの事故が怖いです。

そこで、既知の暗号化USBメモリのデバイスIDをNixOSの設定に書いておいて、専用のマウントコマンドを自動生成する仕組みを作りました。

設計

要件

  • 暗号化USBメモリを差し込んだらmnt-<name>のようなコマンドでパスフレーズを入力するだけでマウントしたい
  • デバイスIDを設定に書いておくことで誤って別のデバイスを操作する事故を防ぎたい
  • なるべくNixOSの宣言的な設定として管理したい
  • パスフレーズを不意に求められたりしたくないので自動マウントはしない

アプローチの検討

いくつかのアプローチを検討しました。

udisks2を使う方法

udisksの、 udisksctlコマンドを使えばroot権限なしでマウントできますが、自由度があまり高くなくて、別にroot権限を使うことに抵抗がなかったので採用しませんでした。

systemd-cryptsetupベースのシェルスクリプトを生成する方法

systemd-cryptsetup はsystemdに統合されていて、 NixOSとの相性も良さそうでした。

マウントポイントも自由に指定できます。

TPMなどでunlockできる場合は自動的にそれを試してもくれます。

最終的にこのアプローチを採用しました。

実装

モジュールの構造

{
  config,
  lib,
  pkgs,
  ...
}:
let
  cfg = config.programs.removable-crypt;

  dmNamePattern = "[a-zA-Z0-9][a-zA-Z0-9#+=@_.:-]*";

  deviceIdType = lib.types.strMatching dmNamePattern // {
    description = "device ID under /dev/disk/by-id/ (alphanumeric, #+-.:=@_)";
  };

  deviceType = lib.types.submodule {
    options = {
      deviceId = lib.mkOption {
        type = deviceIdType;
        description = "ID of the device under `/dev/disk/by-id/`";
        example = "usb-JetFlash_Transcend_32GB_25XSK57XTBIHQODC-0:0-part1";
      };
      mountOptions = lib.mkOption {
        type = lib.types.listOf lib.types.str;
        default = [ "noatime" ];
        description = "Mount options to use for all filesystems";
        example = [
          "noatime"
          "nodiratime"
        ];
      };
      btrfsMountOptions = lib.mkOption {
        type = lib.types.listOf lib.types.str;
        default = [ "compress=zstd" ];
        description = "Additional mount options to use when the filesystem is btrfs";
        example = [ "compress=zstd" ];
      };
    };
  };

デバイスの定義には、 /dev/disk/by-id/以下のデバイスIDと、マウントオプションを指定できるようにしています。 btrfsの場合は追加のマウントオプションも指定できます。

マウントコマンドの生成

  mkMountCommand =
    name: device:
    pkgs.writeShellApplication {
      name = "mnt-${name}";
      runtimeInputs = with pkgs; [
        systemd
        util-linux
      ];
      text = ''
        if [[ ! -w /dev/mapper/control ]]; then
          echo "error: no permission to access /dev/mapper/control (use sudo)" >&2
          exit 1
        fi

        device_path="/dev/disk/by-id/${device.deviceId}"
        mapper_name="${name}"
        target_user="''${SUDO_USER:-$USER}"
        mount_point="/mnt/${name}"

        mapper_attached=0
        mounted=0

        cleanup() {
          if [[ "$mounted" -eq 1 ]]; then
            umount "$mount_point" 2>/dev/null || true
          fi
          if [[ -d "$mount_point" ]]; then
            rmdir "$mount_point" 2>/dev/null || true
          fi
          if [[ "$mapper_attached" -eq 1 ]]; then
            systemd-cryptsetup detach "$mapper_name" 2>/dev/null || true
          fi
        }
        trap cleanup EXIT

        if [[ ! -e "$device_path" ]]; then
          echo "error: device not found: $device_path" >&2
          exit 1
        fi

        systemd-cryptsetup attach "$mapper_name" "$device_path"
        mapper_attached=1

        mkdir -p "$mount_point"
        chown "$target_user:" "$mount_point"

        fs_type=$(blkid -s TYPE -o value "/dev/mapper/$mapper_name")
        if [[ "$fs_type" == "btrfs" ]]; then
          mount -o "${
            lib.concatStringsSep "," (device.mountOptions ++ device.btrfsMountOptions)
          }" "/dev/mapper/$mapper_name" "$mount_point"
        else
          mount -o "${lib.concatStringsSep "," device.mountOptions}" "/dev/mapper/$mapper_name" "$mount_point"
        fi
        mounted=1

        chown "$target_user:" "$mount_point"

        # Success - disable cleanup
        trap - EXIT
      '';
    };

ポイント

エラー時のクリーンアップ

trap cleanup EXITを使って、エラーが発生した場合でも中途半端な状態になりにくいようにしています。成功時はtrap - EXITでクリーンアップを無効化します。

SUDO_USERの活用

sudoで実行された場合、 $SUDO_USERに元のユーザー名が入っています。これを使ってマウントポイントの所有者を適切に設定しています。

ファイルシステムの自動判定

blkidでファイルシステムの種類を判定し、 btrfsの場合は追加のマウントオプション(圧縮など)を適用します。

アンマウントコマンドの生成

  mkUnmountCommand =
    name: _device:
    pkgs.writeShellApplication {
      name = "umnt-${name}";
      runtimeInputs = with pkgs; [
        systemd
        util-linux
      ];
      text = ''
        if [[ ! -w /dev/mapper/control ]]; then
          echo "error: no permission to access /dev/mapper/control (use sudo)" >&2
          exit 1
        fi

        mapper_name="${name}"
        mount_point="/mnt/${name}"
        has_error=0

        if ! umount "$mount_point"; then
          echo "warning: failed to unmount $mount_point" >&2
          has_error=1
        fi

        if [[ -d "$mount_point" ]]; then
          if ! rmdir "$mount_point"; then
            echo "warning: failed to remove $mount_point" >&2
            has_error=1
          fi
        fi

        if [[ -e "/dev/mapper/$mapper_name" ]]; then
          if ! systemd-cryptsetup detach "$mapper_name"; then
            echo "warning: failed to detach $mapper_name" >&2
            has_error=1
          fi
        fi

        exit "$has_error"
      '';
    };

アンマウント時は各ステップでエラーが発生しても続行し、最後にエラーがあったかどうかを返します。中途半端な状態でもできるだけクリーンアップをしたいからです。

コマンドのまとめ

  allCommands =
    (lib.mapAttrsToList mkMountCommand cfg.devices)
    ++ (lib.mapAttrsToList mkUnmountCommand cfg.devices);

オプション定義とアサーション

{
  options.programs.removable-crypt = {
    enable = lib.mkEnableOption "encrypted removable device management";

    devices = lib.mkOption {
      type = lib.types.attrsOf deviceType;
      default = { };
      description = "manage these removable encrypted devices";
      example = lib.literalExpression ''
        {
          two-thousand.deviceId = "usb-JetFlash_Transcend_32GB_25XSK57XTBIHQODC-0:0-part1";
        };
      '';
    };
  };

  config = lib.mkIf (cfg.enable && cfg.devices != { }) {
    assertions =
      let
        invalidNames = lib.filter (name: builtins.match dmNamePattern name == null) (
          lib.attrNames cfg.devices
        );
      in
      [
        {
          assertion = invalidNames == [ ];
          message = ''
            programs.removable-crypt.devices:
            invalid device name(s): ${lib.concatStringsSep ", " invalidNames}.
            Names must match ${dmNamePattern}.
          '';
        }
      ];
    environment.systemPackages = allCommands;
  };
}

デバイス名がdevice-mapperの命名規則に従っているかをアサーションでチェックしています。不正な名前を指定するとビルド時にエラーになります。

使い方

デバイスIDの確認

まずUSBメモリなどを接続して、デバイスIDを確認します。

ls -la /dev/disk/by-id/

パーティションを指定する場合は-part1のようなサフィックスがついたものを使います。

NixOS設定への追加

{
  imports = [ ../../lib/removable-crypt.nix ];

  programs.removable-crypt = {
    enable = true;
    devices = {
      gideon.deviceId = "usb-USB_SanDisk_3.2Gen1_03005417112725151804-0:0-part1";
      two-thousand.deviceId = "usb-JetFlash_Transcend_32GB_25XSK57XTBIHQODC-0:0-part1";
    };
  };
}

devicesの属性名がそのままコマンド名とマウントポイント名になります。上の例ではmnt-gideonumnt-gideonmnt-two-thousandumnt-two-thousandというコマンドが生成されます。

マウントとアンマウント

# マウント(パスフレーズの入力を求められる)
sudo mnt-gideon

# /mnt/gideon にマウントされる

# アンマウント
sudo umnt-gideon

まとめ

NixOSモジュールとして暗号化USBメモリのマウントコマンドを半自動的に作成できるようにしました。

  • デバイスIDを設定に書いておくことで誤操作を防止
  • mnt-<name>/umnt-<name>という直感的なコマンド名
  • エラー時のクリーンアップ処理で安全にロールバック
  • 追加のマウントオプションを自動適用

実装は、 feat: 既知の暗号化済みUSBメモリの自動マウントを実装する by ncaq · Pull Request #452 · ncaq/dotfiles で行いました。

主なソースコードは、 ncaq/dotfileslib/removable-crypt.nix にあります。