2011/12/30

Javaでできるだけ安全にデータを直列化するためのてきとう備忘録

現在絶賛放置中の Web サイトを今後の発信基地として再開発したいと思い立った事がきっかけで、最近は Web サイトの簡易更新支援ツールの開発を考えている。

簡単な HTML ジェネレータと FTP クライアントを一緒にしたようなものを作りたい。

特に、FTP クライアントを作る際には、ユーザビリティのためにアカウント情報(ユーザ名とかパスワードとか)をローカルに保存しておき、できるだけワンタッチでサーバにアクセスできる仕様にしたいなと考えている。

データのセーブとロードは至るところで使いそうだし、今日はそこらへんの実装をステップバイステップで書き残しておこうと思う。

あ、この記事のサンプルコードには FTP 接続の実装例は含まれていないので、そういう内容を期待している方にはごめんなさい。あくまでデータのアーカイブに関する話題に絞ってもそれなりの分量になってしまったので……。



【備忘録その1: 直列化】

データのアーカイブに関する最も簡単な方法は、以下のようにしてユーザ名とパスワードをそのまま直列化してしまう事であろう。以下に Java のコードを示す。エラー処理が不十分なのはご容赦
package com.tercel_tech.sample;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;

public class Main {
  private static final String FILENAME = "Account.txt";  // ファイル名

  public static void main(String[] args) {  
    File file = new File(FILENAME);
    
    if(!file.exists()) {
      // 事前に登録したアカウント情報がない場合
      System.out.println("ユーザアカウントが存在しません");
      createUserAccount();
    } else {
      // 事前に登録したアカウント情報がある場合
      System.out.println("ユーザアカウントが存在します");
      loadUserAccount();
    }
  }

  // ==============================
  // ユーザアカウントを作ってファイルに保存するメソッド
  // ==============================
  private static void createUserAccount() {
    System.out.print("ユーザ名を入力して下さい: ");

    // 入力を適当に受け付ける
    String name;
    try {
      name = new BufferedReader(
               new InputStreamReader(
                 System.in)).readLine();
    } catch (IOException ex) {
      name = "名無しさん";
    }

    System.out.print("パスワードを入力して下さい: ");

    // 入力を適当に受け付ける
    String passwd;
    try {
      passwd = new BufferedReader(
                 new InputStreamReader(
                   System.in)).readLine();
    } catch (IOException ex) {
      passwd = "aaaa";
    }

    // ユーザ名とパスワードを基に、アカウント情報を作る
    UserData userData = new UserData(name, passwd);

    // アカウント情報をファイルに保存する
    try {
      new ObjectOutputStream(
        new FileOutputStream(
          FILENAME)).writeObject(userData);
      System.out.println("セーブに成功しました");
    } catch (IOException ex) {
      // 書き込みにミスった
    }
  }

  // ==============================
  // 既に存在するユーザアカウント情報を
  //ファイルからロードするメソッド
  // ==============================
  private static UserData loadUserAccount() {
    try {
      UserData data = (UserData) new ObjectInputStream(
        new FileInputStream(FILENAME)).readObject();

      System.out.println("ロードに成功しました");
      System.out.println("ユーザ名: "   + data.getName());
      System.out.println("パスワード: " + data.getPass());
      return data;
    } catch (Exception ex) {
      return null;
    }
  }
}


// ==============================
// ユーザのログイン情報クラス
// ==============================
class UserData implements Serializable {
  private String name;    // ユーザ名
  private String passwd;  // パスワード

  // コンストラクタ
  // --------------------
  // 引数1: ユーザ名
  // 引数2: パスワード
  public UserData(String name, String passwd) {
    this.name   = name;
    this.passwd = passwd;
  }

  // ゲッタ
  public String getName() { return name;   }
  public String getPass() { return passwd; }
}

まずは、ユーザアカウントが存在しない状態でプログラムを走らせる。

【実行例1】
ユーザアカウントが存在しません
ユーザ名を入力して下さい: tercel
パスワードを入力して下さい: hoge
セーブに成功しました
なお、ユーザ名とパスワードは任意で入力する事ができる。

この後、プログラムを再起動させると、既に存在するアカウント情報を自動的にロードして、その内容を表示してくれる。

【実行例2】
ユーザアカウントが存在します
ロードに成功しました
ユーザ名: tercel
パスワード: hoge

一見するとこれでも充分そうだが、自動生成された Account.txt をメモ帳で開くととんでもない事が分かる。
ユーザ名とパスワードが平文のまま保存されていて、明らかにまずい。常識的に考えて何らかの暗号化を施すべきであろう。



【備忘録その2: 秘密鍵暗号】

そこで、アカウント情報を秘密鍵暗号方式で暗号化する(アルゴリズムは Blowfish)。ちなみに、暗号化に使用した秘密鍵は復号するまで破棄してはいけない。

秘密鍵暗号方式を実装するために、UserData クラスを以下のように書き換える(ただし javax.crypto.Cipherjavax.crypto.spec.SecretKeySpecimport する事)。

この時点で UserData クラスの互換性が無くなっているので、もし Account.txt が残っている場合は削除する。

// ==============================
// ユーザのログイン情報クラス
// ==============================
class UserData implements Serializable {
  private final String SECRET_KEY = "HOGEHOGE_TERCEL";  // 秘密鍵
  private byte[] name;    // ユーザ名
  private byte[] passwd;  // パスワード

  // コンストラクタ
  // --------------------
  // 引数1: ユーザ名
  // 引数2: パスワード
  public UserData(String name, String passwd) {
    try {
      this.name   = encrypt(SECRET_KEY, name);
      this.passwd = encrypt(SECRET_KEY, passwd);
    } catch (Exception ex) {
      System.out.println("暗号化に失敗しました");
      this.name   = null;
      this.passwd = null;
    }
  }

  // ゲッタ
  public String getName() {
    String ret;
    try {
      ret = decrypt(SECRET_KEY, name);
    } catch (Exception ex) {
      System.out.println("復号化に失敗しました");
      ret = null;
    }
    return ret;
  }
  public String getPass() {
    String ret;
    try {
      ret = decrypt(SECRET_KEY, passwd);
    } catch (Exception ex) {
      System.out.println("復号化に失敗しました");
      ret = null;
    }
    return ret;
  }

  // ==============================
  // 暗号化
  // ==============================
  private byte[] encrypt(String key, String text) throws Exception {

    SecretKeySpec sksSpec = new SecretKeySpec(key.getBytes(), "Blowfish");

    Cipher cipher = Cipher.getInstance("Blowfish");

    cipher.init(Cipher.ENCRYPT_MODE, sksSpec);
    byte[] encrypted = cipher.doFinal(text.getBytes());

    return encrypted;
  }

  // ==============================
  // 復号化
  // ==============================
  private String decrypt(String key, byte[] encrypted) throws Exception {
    
    SecretKeySpec sksSpec = new SecretKeySpec(key.getBytes(), "Blowfish");
    Cipher cipher = Cipher.getInstance("Blowfish");

    cipher.init(Cipher.DECRYPT_MODE, sksSpec);
    byte[] decrypted = cipher.doFinal(encrypted);

    return new String(decrypted);
  }
}

実行結果は先程と同じだが、保存されるファイルの内容が異なる。アカウント名とパスワードが傍目には判読できなくなっているのだ。
だが、今度は秘密鍵が平文で保存されている。これでは結局、家の鍵を玄関先に放置しておくようなものだ。

これに関しては、秘密鍵をクラスの外で持てばとりあえずはよさそうだが、どのみちプログラム中で秘密鍵を埋め込む事になるので気持ちが悪い。逆コンパイルされたらイチコロだ。

それよりも、アプリケーションを起動した瞬間にログイン情報の復号化を試みる現在の実装では、無関係な第三者がサーバに繋げてしまう事の方が遙かに問題のような気がする。



【備忘録その3: メッセージダイジェスト】

上記の問題に対処するため、さらに次の方針を採る事にする。

まず、事前にユーザが入力した秘密鍵は、適当なハッシュ関数(ここでは MD5)でメッセージダイジェスト化したハッシュ値として保存しておく。ハッシュ値から元の文字列を復元する事は不可能とされているため、たとえ漏れてもそれほど心配する必要はない。

アカウント情報を復元する際には、ユーザに対して改めて秘密鍵の入力を要求する。鍵を知らない第三者を弾くためである。

システムは入力された鍵をメッセージダイジェスト化し、保存してある鍵のハッシュ値との突き合わせを行う事で同一性のチェックを行う。もし両者が等しければ、入力された鍵の平文を用いてアカウント情報を復号化できる。

この方法だと、プログラムを起動するたびに秘密鍵の入力(認証)を要求する事になるので、少々操作性が悪くなる。しかし、手間の割に守れる情報量は多いので、それなりに割に合っているとは思う。

とりあえず、これを実装した結果を以下に示す。前回の Account.txt が残っている場合は削除する。

まずは UserData クラスから。特に hash メソッドがチャームポイントである。

// ==============================
// ユーザのログイン情報クラス
// ==============================
class UserData implements Serializable {
  //private final String SECRET_KEY = "HOGEHOGE_TERCEL";  // 秘密鍵
  private byte[] secretKey; // 秘密鍵(のハッシュ値)
  private byte[] name;      // ユーザ名
  private byte[] passwd;    // パスワード

  // コンストラクタ
  // --------------------
  // 引数1: 秘密鍵(マスターパスワード)
  // 引数1: ユーザ名
  // 引数2: ログインパスワード
  public UserData(String secretKey, String name, String passwd) {
    try {
      this.secretKey = hash(secretKey);
      this.name      = encrypt(secretKey, name);
      this.passwd    = encrypt(secretKey, passwd);
    } catch (Exception ex) {
      System.err.println("暗号化に失敗しました");
      this.secretKey = null;
      this.name      = null;
      this.passwd    = null;
      System.exit(1);
    }
  }

  // ==============================================
  // 入力された秘密鍵が保存されている秘密鍵と等しいかを
  // ハッシュで比較するよ!
  // ==============================================
  public boolean auth(String secretKey) {
    try {
      byte[] md = hash(secretKey);
      if(md.length != this.secretKey.length) return false;
      for(int i = 0; i < md.length; ++ i) {
        if(md[i] != this.secretKey[i]) return false;
      }
      return true;
    } catch (Exception ex) {
      return false;
    }
  }

  // ===========================================
  // 秘密鍵の平文を渡して名前とパスワードを取得する
  // ===========================================
  public String getName(String key) {
    // 最初に認証
    if(!auth(key)) {
      System.err.println("秘密鍵が正しくありません");
      System.err.println("復号化を行いませんでした");
      System.exit(1);
    }

    String ret;
    try {
      ret = decrypt(key, name);
    } catch (Exception ex) {
      System.err.println("復号化に失敗しました");
      ret = null;
    }
    return ret;
  }

  public String getPass(String key) {
    // 最初に認証
    if(!auth(key)) {
      System.err.println("秘密鍵が正しくありません");
      System.err.println("復号化を行いませんでした");
      System.exit(1);
    }

    String ret;
    try {
      ret = decrypt(key, passwd);
    } catch (Exception ex) {
      System.err.println("復号化に失敗しました");
      ret = null;
    }
    return ret;
  }

  // ==============================
  // メッセージダイジェスト化
  // ==============================
  private byte[] hash(String text) throws Exception {
    MessageDigest md = MessageDigest.getInstance("MD5");
    md.update(text.getBytes());
    return md.digest();
  }

  // ==============================
  // 暗号化
  // ==============================
  private byte[] encrypt(String key, String text) throws Exception {

    SecretKeySpec sksSpec = new SecretKeySpec(key.getBytes(), "Blowfish");

    Cipher cipher = Cipher.getInstance("Blowfish");

    cipher.init(Cipher.ENCRYPT_MODE, sksSpec);
    byte[] encrypted = cipher.doFinal(text.getBytes());

    return encrypted;
  }

  // ==============================
  // 復号化
  // ==============================
  private String decrypt(String key, byte[] encrypted) throws Exception {
    
    SecretKeySpec sksSpec = new SecretKeySpec(key.getBytes(), "Blowfish");
    Cipher cipher = Cipher.getInstance("Blowfish");

    cipher.init(Cipher.DECRYPT_MODE, sksSpec);
    byte[] decrypted = cipher.doFinal(encrypted);

    return new String(decrypted);
  }
}

また、これを使用する Main クラスも以下のように書き換える。

public class Main {
  private static final String FILENAME = "Account.txt";  // ファイル名

  private static String secretKey;

  public static void main(String[] args) {  
    File file = new File(FILENAME);
    
    // 秘密鍵を入力させる
    System.out.print("秘密鍵を入力して下さい: ");
    try {
      secretKey = new BufferedReader(
                    new InputStreamReader(
                      System.in)).readLine();
    } catch (IOException ex) {
      System.err.println("秘密鍵の取得に失敗しました");
      System.exit(1);
    }

    if(!file.exists()) {
      // 事前に登録したアカウント情報がない場合
      System.out.println("ユーザアカウントが存在しません");
      createUserAccount();
    } else {
      // 事前に登録したアカウント情報がある場合
      System.out.println("ユーザアカウントが存在します");
      loadUserAccount();
    }
  }

  // ==============================
  // ユーザアカウントを作ってファイルに保存するメソッド
  // ==============================
  private static void createUserAccount() {
    System.out.print("ユーザ名を入力して下さい: ");

    // 入力を適当に受け付ける
    String name;
    try {
      name = new BufferedReader(
               new InputStreamReader(
                 System.in)).readLine();
    } catch (IOException ex) {
      name = "名無しさん";
    }

    System.out.print("パスワードを入力して下さい: ");

    // 入力を適当に受け付ける
    String passwd;
    try {
      passwd = new BufferedReader(
                 new InputStreamReader(
                   System.in)).readLine();
    } catch (IOException ex) {
      passwd = "aaaa";
    }

    // ユーザ名とパスワードを基に、アカウント情報を作る
    UserData userData = new UserData(secretKey, name, passwd);

    // アカウント情報をファイルに保存する
    try {
      new ObjectOutputStream(
        new FileOutputStream(
          FILENAME)).writeObject(userData);
      System.out.println("セーブに成功しました");
    } catch (IOException ex) {
      // 書き込みにミスった
      System.err.println("セーブに失敗しました");
    }
  }

  // ==============================
  // 既に存在するユーザアカウント情報を
  //ファイルからロードするメソッド
  // ==============================
  private static UserData loadUserAccount() {
    try {
      UserData data = (UserData) new ObjectInputStream(
        new FileInputStream(FILENAME)).readObject();

      System.out.println("ロードに成功しました");
      System.out.println("ユーザ名: "   + data.getName(secretKey));
      System.out.println("パスワード: " + data.getPass(secretKey));
      return data;
    } catch (Exception ex) {
      return null;
    }
  }
}

というか、面倒な人(というか僕)のためにだだ書きしたソースも丸ごと置いておこう。コピペすれば動くと思う。

package com.tercel_tech.sample;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.security.MessageDigest;
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;

public class Main {
  private static final String FILENAME = "Account.txt";  // ファイル名

  private static String secretKey;

  public static void main(String[] args) {  
    File file = new File(FILENAME);
    
    // 秘密鍵を入力させる
    System.out.print("秘密鍵を入力して下さい: ");
    try {
      secretKey = new BufferedReader(
                    new InputStreamReader(
                      System.in)).readLine();
    } catch (IOException ex) {
      System.err.println("秘密鍵の取得に失敗しました");
      System.exit(1);
    }

    if(!file.exists()) {
      // 事前に登録したアカウント情報がない場合
      System.out.println("ユーザアカウントが存在しません");
      createUserAccount();
    } else {
      // 事前に登録したアカウント情報がある場合
      System.out.println("ユーザアカウントが存在します");
      loadUserAccount();
    }
  }

  // ==============================
  // ユーザアカウントを作ってファイルに保存するメソッド
  // ==============================
  private static void createUserAccount() {
    System.out.print("ユーザ名を入力して下さい: ");

    // 入力を適当に受け付ける
    String name;
    try {
      name = new BufferedReader(
               new InputStreamReader(
                 System.in)).readLine();
    } catch (IOException ex) {
      name = "名無しさん";
    }

    System.out.print("パスワードを入力して下さい: ");

    // 入力を適当に受け付ける
    String passwd;
    try {
      passwd = new BufferedReader(
                 new InputStreamReader(
                   System.in)).readLine();
    } catch (IOException ex) {
      passwd = "aaaa";
    }

    // ユーザ名とパスワードを基に、アカウント情報を作る
    UserData userData = new UserData(secretKey, name, passwd);

    // アカウント情報をファイルに保存する
    try {
      new ObjectOutputStream(
        new FileOutputStream(
          FILENAME)).writeObject(userData);
      System.out.println("セーブに成功しました");
    } catch (IOException ex) {
      // 書き込みにミスった
      System.err.println("セーブに失敗しました");
    }
  }

  // ==============================
  // 既に存在するユーザアカウント情報を
  //ファイルからロードするメソッド
  // ==============================
  private static UserData loadUserAccount() {
    try {
      UserData data = (UserData) new ObjectInputStream(
        new FileInputStream(FILENAME)).readObject();

      System.out.println("ロードに成功しました");
      System.out.println("ユーザ名: "   + data.getName(secretKey));
      System.out.println("パスワード: " + data.getPass(secretKey));
      return data;
    } catch (Exception ex) {
      return null;
    }
  }
}


// ==============================
// ユーザのログイン情報クラス
// ==============================
class UserData implements Serializable {
  //private final String SECRET_KEY = "HOGEHOGE_TERCEL";  // 秘密鍵
  private byte[] secretKey; // 秘密鍵(のハッシュ値)
  private byte[] name;      // ユーザ名
  private byte[] passwd;    // パスワード

  // コンストラクタ
  // --------------------
  // 引数1: 秘密鍵(マスターパスワード)
  // 引数1: ユーザ名
  // 引数2: ログインパスワード
  public UserData(String secretKey, String name, String passwd) {
    try {
      this.secretKey = hash(secretKey);
      this.name      = encrypt(secretKey, name);
      this.passwd    = encrypt(secretKey, passwd);
    } catch (Exception ex) {
      System.err.println("暗号化に失敗しました");
      this.secretKey = null;
      this.name      = null;
      this.passwd    = null;
      System.exit(1);
    }
  }

  // ==============================================
  // 入力された秘密鍵が保存されている秘密鍵と等しいかを
  // ハッシュで比較するよ!
  // ==============================================
  public boolean auth(String secretKey) {
    try {
      byte[] md = hash(secretKey);
      if(md.length != this.secretKey.length) return false;
      for(int i = 0; i < md.length; ++ i) {
        if(md[i] != this.secretKey[i]) return false;
      }
      return true;
    } catch (Exception ex) {
      return false;
    }
  }

  // ===========================================
  // 秘密鍵の平文を渡して名前とパスワードを取得する
  // ===========================================
  public String getName(String key) {
    // 最初に認証
    if(!auth(key)) {
      System.err.println("秘密鍵が正しくありません");
      System.err.println("復号化を行いませんでした");
      System.exit(1);
    }

    String ret;
    try {
      ret = decrypt(key, name);
    } catch (Exception ex) {
      System.err.println("復号化に失敗しました");
      ret = null;
    }
    return ret;
  }

  public String getPass(String key) {
    // 最初に認証
    if(!auth(key)) {
      System.err.println("秘密鍵が正しくありません");
      System.err.println("復号化を行いませんでした");
      System.exit(1);
    }

    String ret;
    try {
      ret = decrypt(key, passwd);
    } catch (Exception ex) {
      System.err.println("復号化に失敗しました");
      ret = null;
    }
    return ret;
  }

  // ==============================
  // メッセージダイジェスト化
  // ==============================
  private byte[] hash(String text) throws Exception {
    MessageDigest md = MessageDigest.getInstance("MD5");
    md.update(text.getBytes());
    return md.digest();
  }

  // ==============================
  // 暗号化
  // ==============================
  private byte[] encrypt(String key, String text) throws Exception {

    SecretKeySpec sksSpec = new SecretKeySpec(key.getBytes(), "Blowfish");

    Cipher cipher = Cipher.getInstance("Blowfish");

    cipher.init(Cipher.ENCRYPT_MODE, sksSpec);
    byte[] encrypted = cipher.doFinal(text.getBytes());

    return encrypted;
  }

  // ==============================
  // 復号化
  // ==============================
  private String decrypt(String key, byte[] encrypted) throws Exception {
    
    SecretKeySpec sksSpec = new SecretKeySpec(key.getBytes(), "Blowfish");
    Cipher cipher = Cipher.getInstance("Blowfish");

    cipher.init(Cipher.DECRYPT_MODE, sksSpec);
    byte[] decrypted = cipher.doFinal(encrypted);

    return new String(decrypted);
  }
}

これを実行してみよう。まずユーザアカウント情報が存在しない状態から。

【実行例1】
秘密鍵を入力して下さい: HOGEHOGE_TERCEL
ユーザアカウントが存在しません
ユーザ名を入力して下さい: tercel
パスワードを入力して下さい: hoge
セーブに成功しました
次に、この状態でアプリケーションを再起動してみる。

【実行例2】
秘密鍵を入力して下さい: HOGEHOGE_TERCEL
ユーザアカウントが存在します
ロードに成功しました
ユーザ名: tercel
パスワード: hoge
秘密鍵が正しいと、ちゃんと復号に成功する。

ふたたび再起動し、今度は秘密鍵を間違えてみる。

【実行例3】
秘密鍵を入力して下さい: hogehoge_tercel
ユーザアカウントが存在します
ロードに成功しました
秘密鍵が正しくありません
復号化を行いませんでした
Java Result: 1

どのみちロードまではしてしまうわけだが(というかロードしないと秘密鍵の突き合わせが行えないので)、異なる秘密鍵の入力をミスると復号化できないので、セキュリティ的にはそこそこ良さげな気がする。

ちなみに、Account.txt をメモ帳で開くとこんな感じになっている。かろうじて判読できるのはクラス名とフィールドだけだ。
これだけでは解読は困難だろう。

また、プログラムに秘密鍵を直接埋め込んでいるわけでもないので逆コンパイルされてもそれほど痛くもかゆくもない。

というわけで、ひとまずこれでログイン情報を外部に保存する最低限の仕組みが一応できたっぽい。

ふぅ…。



【参考サイト】

0 件のコメント:

コメントを投稿

ひとことどうぞφ(・ω・,,)