Pkcs11EcdsaSignatureEncoder.java
package io.mersel.dss.signer.api.services.keystore.iaik;
import org.bouncycastle.asn1.ASN1EncodableVector;
import org.bouncycastle.asn1.ASN1Encoding;
import org.bouncycastle.asn1.ASN1Integer;
import org.bouncycastle.asn1.ASN1Primitive;
import org.bouncycastle.asn1.ASN1Sequence;
import org.bouncycastle.asn1.DERSequence;
import java.io.IOException;
import java.math.BigInteger;
import java.util.Arrays;
/**
* PKCS#11 ECDSA/DSA mekanizmaları imza çıktısını JCA/CMS uyumlu formata
* normalize eder.
*
* <h2>Neden gerekli?</h2>
* <p>PKCS#11 v2.40/v3.0 §2.3.1 (ECDSA) ve §2.3.5 (DSA) spec'i diyor ki:</p>
* <blockquote>
* "For these mechanisms, signatures shall be represented as the
* concatenation of (r, s), where each integer is encoded as an octet
* string of equal length, padded to the left with zeros if necessary."
* </blockquote>
* <p>Yani HSM bize <b>raw r||s</b> verir (sabit uzunluk, byte concat).</p>
*
* <p>Buna karşılık <b>JCA, CMS, PAdES, XAdES, DSS doğrulayıcıları</b> hep
* ASN.1 DER format bekler:</p>
* <pre>
* ECDSA-Sig-Value ::= SEQUENCE {
* r INTEGER,
* s INTEGER
* }
* </pre>
*
* <p>İki format uyumsuzdur. Eğer raw byte'ları doğrudan CMS akışına
* verirsek imza <b>doğrulamadan geçemez</b> — Adobe Reader, GİB e-Fatura
* doğrulayıcısı, DSS validator, hepsi reddeder. Bu sınıf bu dönüşümü
* gerçekleştirir.</p>
*
* <p>NOT: PFX yolunda (JCA {@link java.security.Signature#sign}) bu işlem
* platform tarafından otomatik yapılır; HSM yolunda <b>biz</b> yapmak
* zorundayız.</p>
*
* <h2>RSA için neden gerekmez?</h2>
* <p>PKCS#11 RSA mekanizmaları (CKM_*_RSA_PKCS, CKM_*_RSA_PKCS_PSS)
* RSASSA-PKCS1-v1_5 ya da RSASSA-PSS standardına göre encoded imza
* döndürür — bu format JCA çıktısıyla birebir aynı. Dönüşüm gerekmez.</p>
*/
public final class Pkcs11EcdsaSignatureEncoder {
private Pkcs11EcdsaSignatureEncoder() {
throw new UnsupportedOperationException("static utility");
}
/**
* HSM'den gelen raw r||s byte dizisini ASN.1 DER SEQUENCE { r, s }'a sarar.
*
* @param rawConcat r ve s'in sabit uzunlukta birleştirilmiş hali (HSM çıktısı)
* @return DER-encoded {@code SEQUENCE { INTEGER r, INTEGER s }}
* @throws IllegalArgumentException null, boş, ya da tek-sayılı uzunluk
*/
public static byte[] toDer(byte[] rawConcat) {
if (rawConcat == null) {
throw new IllegalArgumentException("rawConcat null olamaz");
}
if (rawConcat.length == 0 || (rawConcat.length & 1) != 0) {
throw new IllegalArgumentException(
"ECDSA/DSA raw imzası çift uzunlukta olmalı (r||s eşit uzunluk); "
+ "bulunan uzunluk: " + rawConcat.length);
}
int half = rawConcat.length / 2;
byte[] rBytes = Arrays.copyOfRange(rawConcat, 0, half);
byte[] sBytes = Arrays.copyOfRange(rawConcat, half, rawConcat.length);
// BigInteger(1, ...) → unsigned interpretation; sıfır-leading byte'lar
// kabul edilir, INTEGER negatif olmaz (high-bit set olsa bile).
BigInteger r = new BigInteger(1, rBytes);
BigInteger s = new BigInteger(1, sBytes);
ASN1EncodableVector vec = new ASN1EncodableVector();
vec.add(new ASN1Integer(r));
vec.add(new ASN1Integer(s));
try {
return new DERSequence(vec).getEncoded("DER");
} catch (IOException e) {
// DER encoding'in IO hatası vermesi pratik olarak imkansızdır
// (memory stream); programatik bug işareti.
throw new IllegalStateException("DER encode başarısız", e);
}
}
/**
* Strict DER algılaması: imzayı tam ASN.1 olarak parse etmeyi dener ve
* <em>kanonik DER roundtrip</em> kontrolü yapar.
*
* <p><b>Neden bu kadar katı?</b> PKCS#11 spec'i EC/DSA mekanizmalarında
* <b>raw r||s</b> üretir; ama 32 byte'lık (P-256) bir raw imza tesadüfen
* {@code 30 3E 02 ...} gibi DER benzeri bir prefix taşıyabilir
* (olasılık 1/2²⁴ civarı, hiç de imkansız değil). Yüzeysel "tag 0x30 +
* length match" heuristiği bu durumda raw'ı DER sanıp dönüştürmeden
* geçirir → imza sporadik şekilde doğrulanamaz.</p>
*
* <p>Bu metot şu üç koşulu birden sağlarsa DER kabul eder:</p>
* <ol>
* <li>Byte dizisi geçerli ASN.1 SEQUENCE olarak <b>tam tüketimle</b>
* parse ediliyor (artakalan byte yok).</li>
* <li>SEQUENCE içinde tam <b>iki</b> öğe var ve ikisi de
* {@link ASN1Integer}.</li>
* <li>Aynı yapı tekrar DER encode edilince <b>byte-bit aynı</b>
* imzaya geri dönüyor (kanonik DER deterministiktir; raw r||s
* bu deterministik forma rastlamış olamaz).</li>
* </ol>
*
* <p>Roundtrip + tam tüketim, yanlış-pozitif olasılığını pratik olarak
* sıfıra indirir.</p>
*/
public static boolean looksLikeDer(byte[] signature) {
if (signature == null || signature.length < 8) {
return false;
}
try {
ASN1Primitive parsed = ASN1Primitive.fromByteArray(signature);
if (!(parsed instanceof ASN1Sequence)) {
return false;
}
ASN1Sequence seq = (ASN1Sequence) parsed;
if (seq.size() != 2) {
return false;
}
if (!(seq.getObjectAt(0) instanceof ASN1Integer)
|| !(seq.getObjectAt(1) instanceof ASN1Integer)) {
return false;
}
// Kanonik DER roundtrip — raw r||s burayı geçemez çünkü
// BigInteger leading-zero handling, length encoding ve INTEGER
// padding kuralları deterministik bir output üretir.
byte[] reencoded = seq.getEncoded(ASN1Encoding.DER);
return Arrays.equals(signature, reencoded);
} catch (IOException | IllegalStateException | ClassCastException e) {
return false;
}
}
/**
* Eğer giriş zaten DER ise olduğu gibi döndürür, raw r||s ise DER'e sarar.
* Çağıran tarafın "iki defa sarmama" yükünü kaldırır. DER algılaması
* {@link #looksLikeDer} ile strict yapıldığı için yanlış-pozitif
* olasılığı pratik sıfırdır.
*/
public static byte[] normalizeToDer(byte[] signature) {
if (looksLikeDer(signature)) {
return signature;
}
return toDer(signature);
}
/**
* DER-encoded ECDSA imzayı (CMS / PKCS#7 / PAdES / CAdES formatı) <b>raw
* r||s</b> formatına çevirir (XMLDsig / RFC 4051 formatı).
*
* <h3>Neden gerekli?</h3>
* <p>Aynı ECDSA imzası için iki farklı standart format vardır:</p>
* <ul>
* <li><b>CMS / CAdES / PAdES</b> (RFC 5652) →
* {@code DER SEQUENCE { INTEGER r, INTEGER s }}</li>
* <li><b>XMLDsig / WS-Security</b> (RFC 4051 §3.4.1) →
* {@code concat(r, s)} — sabit uzunluklu, leading-zero ile padded</li>
* </ul>
*
* <p>{@link #toDer} bu yönde tek yönlü dönüşüm yapıyor; XML imza akışında
* tersini (DER → raw) yapmak gerekir. Bu metot {@code BigInteger.toByteArray()}'ın
* leading-zero handling'i ile dikkatli çalışır.</p>
*
* @param der DER-encoded ECDSA imzası
* @param fieldSizeBytes EC curve field uzunluğu byte cinsinden
* (P-256 = 32, P-384 = 48, P-521 = 66)
* @return raw {@code r || s} (uzunluk: {@code 2 * fieldSizeBytes})
* @throws IllegalArgumentException girdi DER değil ya da r veya s
* {@code fieldSizeBytes}'tan büyük
*/
public static byte[] derToRaw(byte[] der, int fieldSizeBytes) {
if (fieldSizeBytes <= 0) {
throw new IllegalArgumentException("fieldSizeBytes pozitif olmalı: " + fieldSizeBytes);
}
if (!looksLikeDer(der)) {
throw new IllegalArgumentException(
"Girdi DER ECDSA-Sig-Value değil; derToRaw çağrısı geçersiz.");
}
try {
ASN1Sequence seq = (ASN1Sequence) ASN1Primitive.fromByteArray(der);
BigInteger r = ((ASN1Integer) seq.getObjectAt(0)).getValue();
BigInteger s = ((ASN1Integer) seq.getObjectAt(1)).getValue();
if (r.signum() < 0 || s.signum() < 0) {
throw new IllegalArgumentException(
"ECDSA r/s pozitif tam sayı olmalı (negatif değer yorumlanamaz).");
}
byte[] raw = new byte[fieldSizeBytes * 2];
writeUnsignedFixedLength(raw, 0, r, fieldSizeBytes);
writeUnsignedFixedLength(raw, fieldSizeBytes, s, fieldSizeBytes);
return raw;
} catch (IOException e) {
throw new IllegalArgumentException("DER parse hatası", e);
}
}
private static void writeUnsignedFixedLength(byte[] target, int offset,
BigInteger value, int fieldSizeBytes) {
byte[] bytes = value.toByteArray();
// BigInteger.toByteArray() iki şey yapabilir:
// (a) pozitif değer + high-bit set → 0x00 leading byte ekler
// (örn. 0xFF... için [0x00, 0xFF, ...])
// (b) pozitif değer + high-bit clear → minimal length
// Her iki durumda da fieldSize'a sığacak şekilde sağa hizalanır.
//
// GÜVENLİK: Eğer kaynak gerçekten fieldSize'tan büyük (ham olarak
// anlamlı byte içeriyor) ise sessizce kırpmak imzanın matematiksel
// değerini değiştirir → encoder farklı bir (r,s) ürettir. Bu nedenle
// önce "kırpılacak ön byte'lar TAMAMEN sıfır mı?" kontrolü yapıyoruz.
// Sadece BigInteger'in eklediği sign-padding 0x00'ları atıyoruz; anlamlı
// veri kayboluyorsa fail-fast.
if (bytes.length > fieldSizeBytes) {
int excess = bytes.length - fieldSizeBytes;
for (int i = 0; i < excess; i++) {
if (bytes[i] != 0x00) {
throw new IllegalArgumentException(
"r veya s curve fieldSize'tan büyük: actual="
+ bytes.length + " byte, expected<=" + fieldSizeBytes
+ " (anlamlı üst byte=0x"
+ String.format("%02X", bytes[i] & 0xFF)
+ " offset=" + i + ")");
}
}
}
int start = bytes.length > fieldSizeBytes ? bytes.length - fieldSizeBytes : 0;
int length = bytes.length - start;
int dstOffset = offset + (fieldSizeBytes - length);
System.arraycopy(bytes, start, target, dstOffset, length);
// Soldaki padding bytes zaten 0 (new byte[] default).
}
}