PdfSharpでLTV対応のデジタル署名とタイムスタンプを付与してみる

こんにちは。

テックリードのTerukiです。

前回の記事でインフォームドコンセントのシステムをリリースした記事を書いたのですが、この対応でPDFにデジタル署名を付与するコードを書いたのでその紹介をしたいと思います。

https://qiita.com/omt_teruki/items/d687a9b4bee6d33660f1

PDFをあれこれするにはだいたい有償のライブラリが必要なことが多いですが、最近プレビュー版ではありますがPdfSharpという.NETのOSSでデジタル署名を付与できるようになりました。

https://docs.pdfsharp.net/

MITライセンスですが、だいたいの操作はできる優れものです。

# 用語

本題に入る前にいくつかの用語を解説しておきます。
自分なりの解釈なのでもしかしたら間違っている可能性がありますがご容赦ください:pray:

|用語|説明|
|-|-|
|デジタル署名|PDFに付与することで「**誰が**」作成したものなのかを技術的に証明できる。変更があればハッシュ値が変わり改ざんの検知もできる。|
|タイムスタンプ|PDFに付与することで「**いつ**」作成されたものなのかを技術的に証明できる。変更があればハッシュ値が変わり改ざん検知もできる。|
|LTV|Long-Term Validation。長期検証可能という意味で、LTV対応の電子署名は証明書自体の有効期限が切れても無効にならない。|
|CMS|Cryptographic Message Syntax。暗号化されたメッセージの規格で、PDFへの電子署名(CAdES)やその他暗号化されたメッセージを扱う時に利用される。|

# 署名の付与

https://docs.pdfsharp.net/PDFsharp/Topics/PDF-Features/Signatures.html

PdfSharpではIDigitalSignerというインターフェースを使ってデジタル署名やタイムスタンプを付与することができます。

これを使えば任意のデジタル証明書の秘密鍵を使って署名できます。

タイムスタンプもRFC3161に対応したサーバを用意すれば比較的簡単に付与できます。

ただし、このやり方はいわゆる自己署名証明書を使った電子署名の付与であるため、外部からも有効な署名を付与するための手順としては不十分です。また、LTVにも対応できません。

外部の電子署名を付与してくれるサービスは、当然ですが秘密鍵を共有してくれたりはしません。
そのため、PDFに電子署名を付与するには署名したいPDFファイルそのものを外部のサービスに送信するか、PDFのコンテンツのハッシュ値を外部のサービスに送信するパターンになるかと思います。

前者の場合はそもそも今回の記事の趣旨と関係なくなるので省略しますが、署名部分の処理をそのまま丸投げできるので実装は楽ですね。

後者の場合は、後述する諸々の処理が必要になります。

# 実装

長いですが一気に書いてしまいます。PdfSharpのGetSignatureAsyncの実装です。


```cs
public async Task<byte[]> GetSignatureAsync(Stream stream) {

    var array = new byte[stream.Length];
    stream.Position = 0L;
    int num = stream.Read(array, 0, array.Length);
    if (num != array.Length) {
        throw new InvalidOperationException($"Tried to read {array.Length} bytes but got {num} bytes.");
    }

    var (_, ocspInfo) = // 電子証明書の信頼チェーンのOCSP情報を取得する
    var ocsps = new List<Asn1Object>();
    var (signedPem, ocsp) = // PDFに付与する電子証明書を取得する

    foreach (var chainOcsp in ocspInfo) {
        ocsps.Add(Asn1Object.FromByteArray(chainOcsp));
    }
    ocsps.Add(Asn1Object.FromByteArray(ocsp));

    var certificatePath = // 証明書のパスを取得する

    var cert = DotNetUtilities.FromX509Certificate(X509Certificate2.CreateFromPem(signedPem));

    // CAdES構成要素
    var asn1Vector = new Asn1EncodableVector {
        new Org.BouncyCastle.Asn1.Cms.Attribute(CmsAttributes.ContentType, new DerSet(new DerObjectIdentifier("1.2.840.113549.1.7.1"))),
        new Org.BouncyCastle.Asn1.Cms.Attribute(CmsAttributes.SigningTime, new DerSet(new DerUtcTime(DateTime.UtcNow, 2100))),
        new Org.BouncyCastle.Asn1.Cms.Attribute(CmsAttributes.MessageDigest, new DerSet(new DerOctetString(SHA256.HashData(array)))),

        new Org.BouncyCastle.Asn1.Cms.Attribute(new DerObjectIdentifier("1.2.840.113549.1.9.16.2.47"), new DerSet( new SigningCertificateV2([new EssCertIDv2(SHA256.HashData(cert.GetEncoded()))]))), // signingCertificateV2
        new Org.BouncyCastle.Asn1.Cms.Attribute(new DerObjectIdentifier("1.2.840.113583.1.1.8"), new DerSet(new DerSequence(new DerTaggedObject(1, new DerSequence(ocsps.ToArray()))))), // adobeRevocationInfoArchival
        new Org.BouncyCastle.Asn1.Cms.Attribute(CmsAttributes.CmsAlgorithmProtect, new DerSet(new CmsAlgorithmProtection(DefaultDigestAlgorithmFinder.Instance.Find("SHA-256"), 1, DefaultSignatureAlgorithmFinder.Instance.Find("SHA256WITHRSA")))),
    };

    var signedAttributesTable = new AttributeTable(asn1Vector);
    var tobeSigned = new DerSet(signedAttributesTable.ToAsn1EncodableVector());

    var hashedSigned = SHA256.HashData(tobeSigned.GetEncoded("DER"));

    var signature = // PDFハッシュ値等からデジタル署名を取得する
    var timestamp = // signatureのSHA256ハッシュを使ってタイムスタンプを取得する

    var cmsSignedGenerator = new CmsSignedDataGenerator();

    cmsSignedGenerator.AddSignerInfoGenerator(new SignerInfoGeneratorBuilder()
        .WithSignedAttributeGenerator(new DefaultSignedAttributeTableGenerator(signedAttributesTable))
        .Build(new SigningFactory(signature), cert));

    cmsSignedGenerator.AddCertificate(cert);
    cmsSignedGenerator.AddCertificate(DotNetUtilities.FromX509Certificate(X509Certificate2.CreateFromPem(certificatePath)));

    // 署名にタイムスタンプを付与する
    var signedCms = new SignedCms();
    signedCms.Decode(cmsSignedGenerator.Generate(new CmsProcessableByteArray(array), false).GetEncoded());
    signedCms.SignerInfos[0].AddUnsignedAttribute(new AsnEncodedData(new Oid("1.2.840.113549.1.9.16.2.14"), timestamp));

    return signedCms.Encode();
}
```

```cs
class SigningFactory(byte[] signature) : ISignatureFactory {
    public object AlgorithmDetails { get; } = DefaultSignatureAlgorithmFinder.Instance.Find("SHA256WITHRSA");

    public IStreamCalculator<IBlockResult> CreateCalculator() {
        return new StreamCalculator(signature);
    }
}

class StreamCalculator(byte[] signature) : IStreamCalculator<IBlockResult> {

    public Stream Stream { get; set; } = new MemoryStream(0);

    public IBlockResult GetResult() {
        return new SimpleBlockResult(signature);
    }
}
```

コメントになっている部分は外部サービスのAPI呼び出しになるイメージです。

BouncyCastleと.NET公式のクラスを使い分けています。
.NET公式もAsn1Tagなどある程度はクラスが用意されていますが、EssCertIDv2など一部のクラスは用意されていないため、BouncyCastleの力を借りています。

# 解説
流れとしては以下のようなイメージです。

PdfSharpはCMSを使ってPDFに署名を付与するので実装もそれに合わせます。

1. 付与する電子証明書の取得
1. CAdES構成要素の組み立て
    1. 現在時刻
    1. PDFコンテンツのSHA256ハッシュ
    1. 付与する電子証明書のSHA256ハッシュ。signingCertificateV2の部分
    1. OCSP情報の埋め込み
1. DERエンコードしたものをさらにSHA256ハッシュ
1. 署名のハッシュ値とタイムスタンプを取得
1. CMSの形式に変換

非常に難解な処理ですが、うまく繋ぎ込めればLTV対応の電子署名を付与することができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3192685/e3b08067-a280-4b3b-9d33-bceb7ba9eae7.png)

---

JavaではOSSのPDFBoxを使えば割と簡単に実装できるそうです。

https://pdfbox.apache.org/

これのためにJavaのサーバを建てるか検討もしましたが、やはり.NET一本で行きたかったのでかなりガッツリ時間をかけて調査しました。

普通は有償ライブラリを購入して対応するところだと思いますが、面白そうだったので自前実装しました。
外部サービスの担当者に自前実装したと伝えたら非常に驚かれました。

今後も多少のことは自前実装にこだわってプロダクトをリリースしていきたいなと思っています。


# Oh my teethについて
[Oh my teeth](https://www.oh-my-teeth.com/?tags=qiita)では未来の歯科体験を創るために日々活動しています。

Techチームではより良いユーザー体験を提供するべく、Webフロントエンドからバックエンド、スマホアプリに機械学習モデルなど、さまざまなプロダクトを開発しています。

一緒に未来の歯科体験を創りませんか？興味がある方は是非こちらを確認してください。

https://oh.careers/

カジュアル面談も可能なので気軽に応募してみてください！

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3192685/0138b492-e02b-852e-61fa-fc065b53d285.png" width="450" />