AWSのAmplify+Vue+KMSを使ってPDFの電子署名サイトを作ってみた
現在契約書の締結等に使われるPDFの電子署名をAWSのAmplify+AWS KMS+オープンソースで作ってみました。
AWSにてサーバレス開発はしてきたので、フルスタックのサーバレスアーキテクチャ使うとどんな作り方になるのか試したく作ってみました。
以下備忘録として、まとめています。
★ちなみにシステムを作りハジめたのは2年前なので、情報は古くなっています。
Amplifyもgen1ベースで、現状のgen2とは作り方は変わっていると思われるので見られる方は参考程度に見ていただければと思います。
今回は概要のみ記載します
対象の読者
・サーバレスでフルスタック開発に興味ある方
・Amplify を使ってみたいと思っている読者
・オープンソースを利用してアプリ作りに興味がある方
構築サイト名(押印クラウド)
押印クラウド使い方説明
GITHUB
1.全体構造図
AWS Amplify配下で比較的標準のモジュールを使っています
・フロント Vue
・認証:Cognito
・DB:DynamoDB
・バックエンド:Lambda
・署名処理:KMS
・メール連携:SES,SQS

主に使っているオープンソース (ほとんどバックエンドで使用)
・node-signpdf:PDF署名処理(pdfkit版:当初組み込んだ時期から現在は大きく体系が変わったため、おそらくpdfkit版に相当する部分を使っています)
・node-forge:signpdfから呼ばれて署名計算の主部分
・pdf-editor:pdf文書にテキスト、画像、署名等を貼り付ける機能
・pdf.js:PDFをcanvas上に表示する機能
・@napi-rs/canvas:node.js内のcanvas処理(当初はnode-canvas使っていましたが、変更しています)
・freeTSA.org:タイムスタンプサイト
2.押印クラウドの処理の流れ
文書uploadから、署名文書を送信まで
アカウント作成→PDFをupload→署名ルートを作成、入力有無も含めて定義→ルート保存、入力有の対象者単位に入力属性(押印、日付、署名、テキスト入力)を定義→送信
ルート順に文書はメール送信されます。
署名時
メールを受けた人(アカウントはなくてもOK)はリンクをクリックで文書を確認して、OKであれば承認。入力内容あれば、入力して承認。NGなら却下
署名完了後
すべての署名者の署名が完了すると、作成者、署名者全員にダウンロード用のリンクがついたメールを送ります。却下の場合その旨のメールが作成者に流れます。リンクはセキュリティ上(AWSの仕様上)7日間のみ有効になっています
以下、各機能の設定でのポイントのみ記載します。設定等は、ほかの(正しい)方法もあるでしょうが、今回はこれで乗り切ったと考えていただければいいかと思います
3.DynamoDB:IDの要素とGSIの追加
DynamoDBは、no-sql key-valueのDBで、スケーラビリティ、柔軟性に優れていますが、Joinでの検索ができないので、どういうアクセスをテーブルでしていくのか検討した上で、テーブル設計する必要があります。DynamoDBへのアクセスはGraphQLで実施しています。
AmplifyのDB設計は、なるべくダイレクトアクセスをできるよう、ID(Primary Key)の値を工夫して使っています。
bunsyo→Shomei(ルート)→Contents(入力設定) それぞれhad manyと規定している(下図参照)のですが、文書読んだら、ルートを読みにいきたいので、Shomeiのidはbunsyoのid+ルートNOとしました。Contentsのidは、bunsyoのid+ルートNO+コンテンツ番号にしています。idはテーブルを通してユニークであればいいので、ユニークかつダイレクトアクセスできるように設定しています。
逆に、作成者単位で、文書を全部、またその文書のルートを持ってくるときには、GSI(GlobalServiceIndex)をテーブルに登録して一括で読み込めるようにしています。(下記Shema定義の@index)
設定して、amplify pushすると、GraphQL内に、GSIを使った処理が追加されるので、それを利用してフロント側でアクセスします。
Dynamo Table関連図

Shema定義
type bunsyo @model {
pdfId: ID!
createUser: ID!
@index(name: "createUser-index", sortKeyFields: ["createdAt"], queryField: "bunsyoByCreateUser")
・
・
createdAt: String!
shomei: [Shomei] @hasMany
}
type Shomei @model {
id: ID!
seq: Int
bunsyoShomeiId: ID!
@index(name: "gsi-bunsyo.shomei", sortKeyFields: ["seq"], queryField: "shomeiByBunsyo")
・
・
content: [Contents] @hasMany
}
type Contents @model {
pdfId: ID!
id: ID!
・
・
shomeiContentId: ID! @index(name: "gsi-Shomei.content", queryField: "contentsByShomei")
}
出来たGraphQLの例
export const bunsyoByCreateUser = /* GraphQL */ `
query BunsyoByCreateUser(
$createUser: ID!
$createdAt: ModelStringKeyConditionInput
$sortDirection: ModelSortDirection
$filter: ModelbunsyoFilterInput
$limit: Int
$nextToken: String
) {
フロントのvueでアクセス
import { bunsyoByCreateUser } from '../graphql/queries';
async function fetchBunsyoList() {
const bunsyo = await client.graphql({
query: bunsyoByCreateUser,
variables: { createUser: userId.value, sortDirection: 'DESC', limit: 200 },
});
バックエンド(Lambda)でGSIを使って読みたいときはGSI名をつけてqueryします
async function fetchShomei(request) {
const params = {
TableName: process.env.API_XXXXX_SHOMEITABLE_NAME,
IndexName: "gsi-bunsyo.shomei",
KeyConditionExpression: "#id = :id",
ExpressionAttributeNames: {
"#id": "bunsyoShomeiId",
},
ExpressionAttributeValues: {
":id": itemid,
},
};
const result = await dynamoDB.query(params);
no-SQL、key-value storeのDynamoDBではなくて、mySQLを使ったほうがいいのかとも思いましたが、idの作り方と、GSIでなんとか処理が完結することになりました。
4.Lambdaとの連携:非同期でのinvokeとAPI Gatewayの設定
PDF文書を登録して、バックエンドに署名処理を起動依頼するのですが、起動して、フロント自体は終了したいので、非同期(eventモード)での起動を行っています。
API GatewayのPOSTでLambdaに起動をかけているのですが、その際headerに
Invocation-typeを設定しています。
async function invokeLambda(functionName, payLoad) {
try {
const restOperation = post({
apiName: 'lambdaXXXApi',
path: '/' + functionName,
options: {
body: payLoad,
headers: {
'X-Amz-Invocation-Type': 'Event', // 非同期実行を指定
},
},
});
const resbody = await restOperation.response;
Headerに呼び出しタイプを設定して、発信しているので、API Gatewayの設定も変更・追加しています。メソッドリクエストと統合リクエストにX-Amz-Invocation-Typeの設定を追加しています
5.Cognito 非認証ユーザへの許可の与え方 roleと期間
今回、PDF作成者はアカウント作成を要求していますが、署名者は特にアカウント作成しなくても、署名できるようにしています。ただし、非認証ユーザに、PDFが保存されているS3のオブジェクトをオープンにすることにもなるので、Cognitoに対して、対象idプールのゲストアクセスにのみ必要な最低限のroleを設定してアクティブ化しています。またアクセスURLについては、getSignedUrlを使って署名URLを発行しています。urlの期限は現状AWSの制限で最大7日間です。
SignedURLの作成
AWSのマニュアル
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const params_geturl = {
Bucket: bucket,
Key: fileName,
};
const signedUrl = await getSignedUrl(
s3,
new GetObjectCommand(params_geturl), /*
{
expiresIn:expires /*期限を設定。現状最大7日、秒で設定 */,
}
);
6.AWS KMSの利用
AWSのKMS(Key Management store)は、署名に使うkeyを作成して、管理、当該keyをもとに署名してくれる機能です。
キーを作成するとPublicKeyはダウンロードできますが、PrivateKeyは作成者でも見ることはできません。
今回PDF文書の署名機能を、この堅牢な暗号化キーを利用して作ってみることにしました。
証明書は、基本情報をもとにnode-forgeで(なんちゃって)certを作り、そのダイジェストをもとにKMSで署名して、公開鍵証明書(X509)を作成、S3にて保存しています。
node-signpdfにて、この公開鍵証明書とPDF文書のdigest情報をもとにAWSのKMSを通して電子署名を作成、再びnode-signpdfの処理にてPDF文書に署名を埋め込んでいます
KMSの使い方
let kms_params = {
KeyId: process.env.KMS_ARN,
Message: mdarray,
MessageType: "DIGEST",
SigningAlgorithm: "RSASSA_PKCS1_V1_5_SHA_512",
};
const command = new SignCommand(kms_params);
const KMS_end = await kmsclient.send(command);
var signBuffer = Buffer.from(KMS_end.Signature);
7.Amplifyを使ってみて
サーバレスでフルスタックのシステムをサーバ、DB、ネットワークの設定なくても立ち上げられるのはかなり楽だと思いました。
Amplifyのマニュアルはコーディング事例が記載されているので、同じパターンの場合は利用、細かい設定や別のコーディングパターンにしたい場合はDynamoDBやLambda,Congnitoの説明や設定、事例をそれぞれ調べて使用しています。
ただし、amplify pushすると個別サービスで設定した設定は上書きされるのでなるべくCLIで、設定変更すべきかもしれませんが、すべての設定変更がCLIでできるようでもなさそうなので、amplify pushで環境変更するときは何を個別設定したか注意がすこし必要です。(私の場合は、ほかの設定を追加してamplify pushしたら、Cognitoの非認証ユーザが非アクティブになってました)
現状サイトはまだgen1での開発になっていますが、gen2は大規模運用向けに設計されているようなので機会とコンバージョンルートがあれば今後挑戦したいと思います
8.オープンソースの利用において:機能の前提を理解して使う
今回の作成は、AWSにAmplifyというプラットホームと、AWS KMSというサービス、node-signpdfというオープンソースを見つけて合体すると容易にシステム構築できそうではないかと”安易に”始めたプロジェクトでしたが、
AWSのKMSと合体させるというオープンソースが意図してない使い方をしたため、結局オープンソースの中身を解析しながら、さらにこれが呼び出しているサブのパッケージの解析もしながら、迂回処理を作って試すことになり、ここにかなりの時間を費やしました。
オープンソースを使う場合には機能の前提を理解した上で、”こちらの勝手な解釈で"使うべきではないかなと改めて感じました。

