JavaでCloudFrontの署名付きURL (signed URL) を生成する

Amazon_CloudFront

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

よく訓練されたアップル信者、都元です。CloudFrontでコンテンツ保護を行う場合、サーバサイドで署名付きURLを発行する必要があります。この仕組みについて詳しくは弊社佐々木のエントリーCloudFront+S3で署名付きURLでプライベートコンテンツを配信するを御覧ください。

さて、上記のエントリでは主にPerlのスクリプトを用いて、作業用のローカルマシン上で署名付きURLを生成する手順をご紹介しています。また、参照先としてご紹介したドキュメントでは、下記のように各プログラミング言語上で署名付きURLを生成する方法について説明があります。

このうちJavaの解説ではJetS3t *1というAPIラッパーライブラリを用いていますが、現在の標準APIラッパーはAWS SDK for Javaです。従って、署名を生成するためだけに、わざわざJetS3tを採用する理由は特に無い *2と思います。

また、このドキュメントのコードはBouncy Castleという、これも古くからあるJavaの暗号実装に依存しています。暗号の実装は、J2SE 5.0(要するにJava5)以降であれば Java SE Security として標準提供されていますので、本稿ではそちらを使いたいと思います。

サンプルコード

というわけで、もっとシンプルに署名付きURLを生成するコードをご紹介します。このコードは「引数として下記のような情報を受け取り、標準出力に署名付きURLを出力する」という、シンプルなコマンドラインツールとして記述しました。

一応Java7で動作確認しています。出来れば外部のライブラリに依存せずに実現したかったのですが、1箇所だけGuavaを使わせてもらいました。Java8であれば、Guavaを使わずに標準APIだけで書けます。

import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.SecureRandom;
import java.security.Signature;
import java.security.spec.PKCS8EncodedKeySpec;
import com.google.common.io.BaseEncoding;

public class SignedURLGenerator {
  
  private static final long DEFAULT_DURATION_SEC = 60 * 5; // 5 minutes
  
  private static final String POLICY_FORMAT =
      "{\"Statement\":[{\"Resource\":\"%s\",\"Condition\":{\"DateLessThan\":{\"AWS:EpochTime\":%d}}}]}";
  
  private static final String URL_FORMAT = "%s%sExpires=%d&Signature=%s&Key-Pair-Id=%s";
  
  
  public static void main(String[] args) throws Exception {
    if (args.length != 3 && args.length != 4) {
      System.out.printf("usage: java %s <baseUrl> <keyPairId> <keyPath> [durationSeconds]%n",
          SignedURLGenerator.class.getName());
      System.out.println("  baseUrl ... Resource URL to sign.  ex. http://xxx.cloudfront.net/path/to/resource");
      System.out.println("  keyPairId ... CloudFront key pair ID.  ex. APKAZZZZZZZZZZZZZZZZ");
      System.out.println("  keyPath ... Path to private key.  ex. /path/to/privateKey.der");
      System.out.println("  durationSeconds ... Expiring duration in seconds. (optional, default 300)  ex. 60");
      System.exit(1);
    }
    
    String resourceUrl = args[0];
    String keyPairId = args[1];
    Path privateKeyFile = Paths.get(args[2]);
    
    long durationSeconds = args.length == 4 ? Long.parseLong(args[3]) : DEFAULT_DURATION_SEC;
    byte[] derPrivateKey = Files.readAllBytes(privateKeyFile);
    long expires = (System.currentTimeMillis() / 1000) + durationSeconds;
    
    String signedUrl = generateSignedURL(resourceUrl, keyPairId, derPrivateKey, expires);
    System.out.println(signedUrl);
  }
  
  private static String generateSignedURL(String resourceUrl, String keyPairId, byte[] derPrivateKey, long expires) {
    String policy = String.format(POLICY_FORMAT, resourceUrl, expires);
    
    try {
      KeyFactory keyFactory = KeyFactory.getInstance("RSA");
      PKCS8EncodedKeySpec privSpec = new PKCS8EncodedKeySpec(derPrivateKey);
      PrivateKey privateKey = keyFactory.generatePrivate(privSpec);
      
      // Sign data
      Signature signature = Signature.getInstance("SHA1withRSA");
      signature.initSign(privateKey, new SecureRandom());
      signature.update(policy.getBytes("UTF-8"));
      
      byte[] signatureBytes = signature.sign();
      String signatureString = BaseEncoding.base64().encode(signatureBytes);
      
      // Convert the given data to be safe for use in signed URLs for a private distribution by
      // using specialized Base64 encoding.
      String urlSafeSignature = signatureString
        .replace('+', '-')
        .replace('=', '_')
        .replace('/', '~');
      
      return String.format(URL_FORMAT,
          resourceUrl,
          resourceUrl.contains("?") ? "&" : "?",
          expires,
          urlSafeSignature,
          keyPairId);
    } catch (Exception e) {
      throw new Error(e);
    }
  }
}

コード詳解

非常にシンプルなので一気にご紹介します。

1〜9行目
非常にシンプルなimportです。Java標準クラスと、Guavaにしか依存していないことが分かります。
13〜18行目
各種定数の定義です。デフォルト値と書式文字列ですね。
21〜42行目
mainメソッドです。ヘルプ表示や引数の解析等、本稿としてはあまり本質的ではない部分かと思います。
44行目
本稿のメインgenerateSignedURLメソッドです。
45行目
まず、このURLが指し示すリソースに対してどのようなアクセス制限が必要なのか、それを表現するJSON文字列を生成します。定数書式に従って、ですね。ここを拡張すれば、IP縛り等のアクセス制限も実装可能です。
48〜57行目
Java SE SecurityのAPIを利用して、電子署名を行っている部分です。署名はバイト列としてsignatureBytesに得られます。
58行目
Guavaを使った箇所。署名バイト列をBase64エンコードしています。Java8には標準実装がありますね。それ以前であればこのコードのようにGuavaを使うか、またはcommons-codec等をご利用ください。
60〜65行目
Base64エンコードした文字列をそのままURLに指定してしまうと、URLとして+, =, /が特殊解釈されてしまうため、これらをそれぞれ-, _, ~に置換します。この置換はドキュメントにも明記されています。
67〜72行目
ここまでで得られた署名等の情報を元に、URLを組み立てて返します。

まとめ

というわけで、JetS3tに依存しない CloudFront 署名付きURL生成方法をご紹介しました。

脚注

  1. "3"は"ε"の鏡文字、"e"と解釈します。つまりジェットセットと読むとのことです。
  2. ただし、AWS SDK for Javaは署名の生成についてサポートしていません。あくまでもAWS APIのラッパーであり、AWS APIをコールしない処理については関知しない、というスタンスなのかもしれません。