Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog
in java

chữ ký số trong java

Tung Bui Thanh (TungDaDev)9 mins read

Brown paper with silver round pendant

Làm trong mảng tài chính, bảo hiểm hay domain nào cũng vậy, đảm bảo dữ liệu đi qua hệ thống còn nguyên vẹn và đúng là chuyện bắt buộc.

Chữ ký số là thứ giải quyết đúng yêu cầu này.

Vậy muốn ký dữ liệu thì cần gì? Trước hết là một cặp khoá bất đối xứng, gồm một private-key chỉ người ký mới được giữ, và một public-key hoặc tốt hơn là một certificate. Public-key hoặc certificate thì công khai cho mọi người.

# ký bằng Java thuần

Cách đơn giản nhất để tạo chữ ký trong Java:

Signature ecdsaSignature = Signature.getInstance("SHA256withECDSA");
ecdsaSignature.initSign(eccPrivateKey);
ecdsaSignature.update(dataToSign);
byte[] signature = ecdsaSignature.sign();

Đoạn code này tạo ra một chữ ký thô (raw signature): dữ liệu được băm ra (hash) bằng SHA-256, rồi thuật toán ký dùng giá trị băm đó cùng private-key để tính ra chữ ký.

Hay có câu "ký là mã hoá hash bằng private key". Cách nói này tạm gần đúng với RSA kiểu cũ (PKCS#1 v1.5), còn với ECDSA thì không có bước mã hoá hay giải mã nào cả: chữ ký là một cặp số (r, s) tính từ hash, private key và một số ngẫu nhiên dùng một lần. Với curve P-256, sign() của Java trả về cặp số này đóng gói DER, dài khoảng 70-72 byte.

Muốn kiểm tra dữ liệu có bị sửa hay không, bên nhận băm lại dữ liệu rồi đưa hash, chữ ký và public-key vào phép kiểm tra của thuật toán. Kết quả chỉ là đúng hoặc sai. Bước này gọi là xác minh (verify) chữ ký:

Signature ecdsaSignature = Signature.getInstance("SHA256withECDSA");
ecdsaSignature.initVerify(certificate);
ecdsaSignature.update(dataToVerify);
boolean isValid = ecdsaSignature.verify(rawSignature);

# ưu điểm

  • Chữ ký nhỏ (vài chục byte với ECDSA), code ngắn và dễ hiểu, hợp khi bạn chỉ cần ký nhanh gọn.

# nhược điểm

  • Bên kiểm tra (verifier) phải biết dùng certificate nào để xác minh.
  • Bên kiểm tra cũng phải biết dùng thuật toán nào.
  • Bên ký (signer) và bên kiểm tra phải tự ghép dữ liệu với chữ ký.
  • Kiểu chữ ký này hợp nhất khi dùng gói gọn trong một hệ thống.

# cú pháp mã hoá (Cryptographic Message Syntax - CMS)

Để né mấy nhược điểm trên, nên dùng một định dạng chữ ký chuẩn. Chuẩn hay dùng là Cryptographic Message Syntax (CMS), định nghĩa trong RFC5652.

CMS mô tả nhiều chuẩn cho dữ liệu mã hoá, nhưng ở đây mình chỉ quan tâm tới định dạng Signed-data. Dữ liệu ký theo định dạng này mang theo khá nhiều thông tin, nhờ vậy xác minh chữ ký dễ hơn hẳn. Vậy tạo cấu trúc này kiểu gì?

Java có sẵn interface cho các thao tác mã hoá qua Java Cryptography Architecture (JCA) và phần mở rộng Java Cryptography Extension (JCE), và đây là cách nên dùng. Các bản implement gọi là provider. JDK cài sẵn vài provider, mỗi cái lo một mảng: SUN (hash, DSA, keystore...), SunRsaSign (RSA), SunEC (ECDSA, chính là provider chạy đoạn SHA256withECDSA ở trên), SunJCE (cipher, MAC...).

Có điều JCE không có interface cho Cryptographic Message Syntax, nên bạn phải dùng thêm thư viện khác. BouncyCastle là lựa chọn tốt: nó là một JCE provider, kèm thêm nhiều chức năng mã hoá ở mức trừu tượng cao. Code tạo chữ ký với CMS và BouncyCastle trông như sau (lấy từ JavaDoc của BouncyCastle, cần thêm bcprov và bcpkix vào project):

List certList = new ArrayList();
CMSTypedData  msg = new CMSProcessableByteArray("Hello world!".getBytes());
certList.add(signCert);
Store certs = new JcaCertStore(certList);
CMSSignedDataGenerator gen = new CMSSignedDataGenerator();
ContentSigner sha256Signer = new JcaContentSignerBuilder("SHA256withECDSA").build(signKP.getPrivate());
 
gen.addSignerInfoGenerator(
  new JcaSignerInfoGeneratorBuilder(
    new JcaDigestCalculatorProviderBuilder().build())
      .build(sha256Signer, signCert));
 
gen.addCertificates(certs);
CMSSignedData sigData = gen.generate(msg, false);

Bạn được chọn có nhét dữ liệu vào trong CMS Container cùng với chữ ký hay không. Nói cách khác, chữ ký có thể là attached (đính kèm dữ liệu) hoặc detached (tách rời). Tham số thứ hai của generate() quyết định chuyện này: false như ví dụ trên là detached. Với chữ ký detached, bên nhận phải tự ghép lại dữ liệu gốc khi đọc container, kiểu new CMSSignedData(new CMSProcessableByteArray(data), signatureBytes), rồi mới xác minh được.

CMS Container thường gồm:

  • Chữ ký.
  • Certificate dùng để xác minh.
  • Thuật toán đã dùng.
  • Có thể có luôn dữ liệu đã ký.

Ngoài ra, một dữ liệu có thể có nhiều chữ ký trong cùng một container. Tức là nhiều người ký (signers) cùng ký một dữ liệu và gửi tất cả chữ ký đi chung một container.

Còn đây là code xác minh một CMSSignedData (lấy từ JavaDoc của BouncyCastle):

Store certStore = cmsSignedData.getCertificates();
SignerInformationStore signers = cmsSignedData.getSignerInfos();
Collection c = signers.getSigners();
Iterator it = c.iterator();
 
while (it.hasNext()){
  SignerInformation signer = (SignerInformation)it.next();
  Collection certCollection = certStore.getMatches(signer.getSID());
  Iterator certIt = certCollection.iterator();
  X509CertificateHolder cert = (X509CertificateHolder)certIt.next();
  if (signer.verify(new JcaSimpleSignerInfoVerifierBuilder().build(cert))) {
    // successfully verified
  }
}

# light weight

Với các bản JDK cũ, muốn dùng hết chức năng của một bản implement JCE thì bạn phải cài "unlimited strength jurisdiction policy files" cho JVM. Không cài thì có thể gặp lỗi kiểu này:

java.lang.SecurityException: Unsupported keysize or algorithm parameters
or java.security.InvalidKeyException: Illegal key size

Nguyên nhân là các quy định hạn chế xuất khẩu công nghệ mã hoá của Mỹ, giới hạn độ dài khoá (ví dụ AES chỉ tới 128 bit). Ngày trước cài JDK mặc định xong thì các hạn chế này vẫn còn, nên bạn phải cài thêm file policy.

Tin vui là từ JDK 9 và JDK 8u161 trở đi, chính sách mặc định đã là unlimited (thuộc tính crypto.policy=unlimited trong file java.security, xem JDK-8170157). Bài này gắn tag Java 21, nên trên JDK hiện đại bạn không cần cài gì thêm. Phần dưới vẫn có ích nếu bạn phải chạy trên JDK 8 bản cũ, hoặc muốn tránh phụ thuộc vào cấu hình provider của JVM.

Với JDK cũ thì chuyện này cũng không to tát gì, cài thêm file policy cho JVM là xong.

Nhưng nếu bạn cần phân phối ứng dụng cho người khác chạy thì sao? Lúc đó hơi mệt, nhưng BouncyCastle cứu được. Nó có một phiên bản nhẹ cho các thao tác mã hoá, không dùng tới JCE provider nào cả.

Vậy là khỏi cài file policy :))). Chắc bạn đã thấy một số class của BouncyCastle bắt đầu bằng JCE (Java Cryptography Extension) hoặc JCA (Java Cryptography Architecture). Mấy class đó có dùng JCE provider. Còn các class phiên bản nhẹ bắt đầu bằng Bc (BcECContentSignerBuilder, BcDigestCalculatorProvider...) và, như đã nói, không dùng JCE provider. Code ký với phiên bản nhẹ:

X509Certificate certificate = ...;
PrivateKey privateKey = ...; // private key EC tương ứng với certificate
 
X509CertificateHolder x509CertificateHolder = new X509CertificateHolder(certificate.getEncoded());
String certAlgorithm = certificate.getPublicKey().getAlgorithm();
 
CMSTypedData message = new CMSProcessableByteArray(dataToSign);
 
AlgorithmIdentifier sigAlgId = new DefaultSignatureAlgorithmIdentifierFinder().find("SHA256WithECDSA");
 
AlgorithmIdentifier digAlgId = new DefaultDigestAlgorithmIdentifierFinder().find(sigAlgId);
AsymmetricKeyParameter privateKeyParameter = PrivateKeyFactory.createKey(
                                                      privateKey.getEncoded());
 
ContentSigner signer = new BcECContentSignerBuilder(sigAlgId, digAlgId).build(privateKeyParameter);
 
SignerInfoGeneratorBuilder signerInfoGeneratorBuilder =
                       new SignerInfoGeneratorBuilder(new BcDigestCalculatorProvider());
SignerInfoGenerator infoGenerator = signerInfoGeneratorBuilder.build(signer, x509CertificateHolder);
 
CMSSignedDataGenerator dataGenerator = new CMSSignedDataGenerator();
dataGenerator.addSignerInfoGenerator(infoGenerator);
 
dataGenerator.addCertificate(x509CertificateHolder);
 
CMSSignedData signedData = dataGenerator.generate(message, true);

Kết quả vẫn là một container CMS y như trên, mà không cần cài thêm bản vá nào. Xác minh thì dùng đoạn code sau:

Collection<SignerInformation> signers = cmsSignedData.getSignerInfos().getSigners();
List<SignerInformation> signerList = new ArrayList<>(signers);
SignerInformation signerFromCMS = signerList.get(0);
SignerId sid = signerFromCMS.getSID();
 
Store store = cmsSignedData.getCertificates();
Collection<X509CertificateHolder> certificateCollection = store.getMatches(sid);
ArrayList<X509CertificateHolder> x509CertificateHolders = new ArrayList<>(certificateCollection);
// we use the first certificate
X509CertificateHolder x509CertificateHolder = x509CertificateHolders.get(0);
 
BcECSignerInfoVerifierBuilder verifierBuilder = new BcECSignerInfoVerifierBuilder(
        new DefaultCMSSignatureAlgorithmNameGenerator(),
        new DefaultSignatureAlgorithmIdentifierFinder(),
        new DefaultDigestAlgorithmIdentifierFinder(),
        new BcDigestCalculatorProvider());
SignerInformationVerifier verifier = verifierBuilder.build(x509CertificateHolder);
boolean result = signerFromCMS.verify(verifier);

Có một điểm hay bị bỏ sót ở cả hai đoạn xác minh: chúng lấy certificate từ chính container để kiểm tra. verify() trả về true chỉ chứng minh dữ liệu khớp với certificate đi kèm, chứ chưa chứng minh certificate đó đáng tin. Ai cũng có thể tự tạo cặp khoá, tự cấp certificate, ký rồi gửi cho bạn. Trong hệ thống thật, bạn còn phải kiểm tra certificate: chuỗi chứng thực có dẫn về một CA hoặc certificate bạn tin không (CertPathValidator trong JDK), còn hạn không, có bị thu hồi không (CRL/OCSP). Nếu hai bên đã trao đổi public key trước thì so certificate trong container với certificate đã biết là đủ.

Tóm lại có hai cách tạo và xác minh chữ ký.

Cách thứ nhất là chữ ký thô. Ngắn gọn, dễ hiểu, nhưng không mang theo đủ thông tin về việc ký.

Cách thứ hai là dùng CMS container. Phức tạp hơn một chút nhưng làm việc với chữ ký thoải mái hơn nhiều. Nếu không muốn dính tới JCE provider nào thì dùng bản Light Weight của BouncyCastle.

# lời kết

Chữ ký số giờ chẳng còn lạ gì với anh em developer, nhưng làm đúng thì vẫn phải để ý vài chỗ. Nếu chuẩn bị đưa chữ ký số vào hệ thống của mình, bạn có thể dò lại theo mấy câu hỏi này:

  • Thuật toán đang dùng còn được khuyến nghị không? Với RSA thì khoá tối thiểu 2048 bit, ưu tiên SHA256withRSA hoặc RSA-PSS; muốn chữ ký ngắn, ký nhanh thì cân nhắc ECDSA hay Ed25519 (có sẵn trong JDK từ Java 15).
  • Bên xác minh có kiểm tra certificate thật không, hay chỉ gọi verify() với certificate gửi kèm như đã nói ở trên?
  • Private key đang nằm ở đâu? Trong file trên server, trong keystore có mật khẩu, hay trong HSM/KMS? Lộ private key là mọi chữ ký từ trước tới nay đều mất giá trị.

Đừng tự chế thuật toán hay định dạng riêng rồi trông vào việc giấu cách làm để được an toàn. Thuật toán chuẩn, thư viện được kiểm chứng và giữ kỹ private key mới là thứ thật sự bảo vệ bạn.

Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.

Reference: