@yankikucuk/e-imza
v1.8.0
Published
XAdES elektronik imza için sıfır bağımlılıklı TypeScript kütüphanesi — kanonikleştirme, PKCS#12, imzalama, doğrulama
Maintainers
Keywords
Readme
@yankikucuk/e-imza
Durum: 1.7.1 — kararlı. Üç imza biçimi ve bir konteyner, hepsi arşiv seviyesine kadar: XAdES (BES, EPES, T, LT, LTA), CAdES (BES, EPES, T, LT, LTA), PAdES (B-B, B-T, B-LT, B-LTA) ve ASiC (S ve E). Yanında RFC 3161 zaman damgası, RFC 6960 OCSP, RFC 5652 CMS, kanonikleştirme, PKCS#12 kap okuma, ayrık imzalama ve paralel imza. Public API kararlıdır; kırıcı değişiklik ana sürüm yükseltir.
Elektronik imza için sıfır bağımlılıklı bir TypeScript kütüphanesi. UBL-TR e-Fatura ve e-İrsaliye belgelerini mali mühürle imzalar, imzalı belgeleri doğrular. XML için XAdES, ikili veri için CAdES, PDF için PAdES, hepsini tek dosyada taşımak için ASiC.
Çalışma zamanı bağımlılığı yoktur. Gereken her şey — kanonikleştirme, ASN.1, PKCS#12, PDF ve ZIP okuma, hatta artık Node'un kriptografisinde bulunmayan RC2 ve RC4 — paketin içindedir.
Neden bu paket
Türkiye'de e-imza akışının üç yerinde iş tıkanıyor. Paket bu üç noktayı çözmek için yazıldı ve üçü de kalıcı testlere bağlandı.
1. İmza, UBL uzantısının derinliğinde doğru hesaplanır
GİB e-Fatura imzasını belgenin köküne değil,
ext:UBLExtensions/ext:UBLExtension/ext:ExtensionContent içine bekler.
XMLDSig'in enveloped-signature dönüşümü ise birçok uygulamada yalnızca
imza kök öğenin doğrudan çocuğuyken çalışır. İmza daha derine
gömüldüğünde dönüşüm sessizce hiçbir şey yapmaz — hata vermez, uyarmaz —
ve imzanın kendisi de özete karışır. Ortaya çıkan imza yapı olarak
kusursuz görünür ama karşı tarafta tutmaz.
Doğru sonucu bağımsız bir tanık belirliyor. Belgeden imza çıkarıldıktan sonra kalan içeriği libxml2 kanonikleştiriyor ve özetini alıyoruz:
| | özet |
| ----------------------------------- | ---------------------- |
| libxml2'nin ürettiği referans sonuç | wXIZvLWEe1Qf9haSO… |
| bu paketin imzada beyan ettiği | wXIZvLWEe1Qf9haSO… ✓ |
Bu paket dönüşümü tanımına uygun uyguluyor: yalnızca kendi imzasını kapsam dışında bırakıyor, nerede durduğuna bakmaksızın.
2. Eski .p12 kapları açılır
Eski Java ve Windows araçları — ve macOS'un sistem TLS kütüphanesi —
PKCS#12 kabının sertifika bölümünü pbeWithSHAAnd40BitRC2-CBC ile
şifreler. RC2, Node'un OpenSSL 3'ünde varsayılan sağlayıcıdan çıkarıldı ve
WebCrypto'da hiç bulunmuyor. Sonuç: elinizdeki mühür kabı modern
araçlarla açılmıyor.
| | eski kaplar (RC2-40) | modern kaplar (PBES2/AES-256) | | ---------------------- | -------------------- | ----------------------------- | | @yankikucuk/e-imza | ✓ | ✓ |
RC2 ve RC4 bu pakette saf JavaScript olarak var; RFC 2268'in sekiz test vektörüyle iki yönde ve bağımsız bir TLS kütüphanesinin ürettiği şifreli metinlerle doğrulanıyor. Yalnızca çözme yönü dışa açık — yeni bir kabı zayıf bir şifreyle yazmanın gerekçesi yok.
3. Anahtara erişilemediğinde de imzalanır
Mali mühür bir donanım modülünde, nitelikli sertifika bir akıllı kartta olabilir. O zaman kütüphaneye "anahtarı al, imzala" denemez; "şu baytları imzala ve sonucu getir" denmesi gerekir.
prepare() / complete() ayrımı tam bu iş için var ve kartların
isteyebileceği üç biçimin üçünü de veriyor: ham veri, DER DigestInfo
ve çıplak özet. Aynı deyim CAdES ve PAdES tarafında da geçerli.
Kurulum
npm install @yankikucuk/e-imzaNode 20 veya üstü.
Hızlı başlangıç
import { loadPkcs12, sign, verify } from '@yankikucuk/e-imza'
import { readFileSync } from 'node:fs'
// Mali mühür kabını aç. Parolayı koda gömmeyin.
const { privateKey, certificate, chain } = loadPkcs12(
new Uint8Array(readFileSync('mali-muhur.p12')),
process.env.MUHUR_SIFRESI ?? '',
)
// e-Fatura'yı imzala. İmza ext:ExtensionContent içine yerleşir.
const imzali = sign({
xml: faturaXml,
signer: { certificate, chain },
privateKey,
commitmentType: 'proof-of-origin',
productionPlace: { city: 'İstanbul', country: 'TR' },
})
// Doğrula.
const sonuc = verify(imzali)
if (sonuc.valid) {
console.log(sonuc.level) // 'BES'
console.log(sonuc.signer.subjectName) // 'CN=…,O=…,C=TR'
console.log(sonuc.signer.subjectSerialNumber) // VKN
}@yankikucuk/ubl-tr ile birlikte
İki paket birbirini import etmez. Bu bilinçli: imza katmanı belge
katmanını bilmemeli, belge katmanı da imza katmanını. Aralarındaki bağ tek
bir yapısal sözleşme — UBL-TR belgesindeki boş ext:ExtensionContent.
ubl-tr onu boş bırakır, e-imza doldurur.
Sonuç: ubl-tr kullanmayanlar e-imza'yı kendi XML'leriyle kullanabilir,
e-imza kullanmayanlar ubl-tr faturasını başka bir araçla imzalayabilir.
npm install @yankikucuk/ubl-tr @yankikucuk/e-imzaUçtan uca: üret → imzala → doğrula → oku
import {
buildInvoiceXml,
InvoiceProfile,
InvoiceType,
parseDocument,
parseInvoice,
validateInvoiceRules,
} from '@yankikucuk/ubl-tr'
import { loadPkcs12, sign, verify } from '@yankikucuk/e-imza'
import { readFileSync } from 'node:fs'
// ── 1. Belge: ubl-tr üretir ───────────────────────────────────────────
// Toplamları kütüphane hesaplar; imza zarfı (boş ext:ExtensionContent)
// varsayılan olarak yazılır.
const fatura = buildInvoiceXml({
id: 'ABC2026000000001',
uuid: '1a2b3c4d-0001-4000-8001-000000000001',
issueDate: '2026-09-07',
profile: InvoiceProfile.TEMEL,
type: InvoiceType.SATIS,
supplier: {
taxNumber: '1234567890',
name: 'ÖRNEK SATICI A.Ş.',
taxOffice: 'Kadıköy',
address: { district: 'Kadıköy', city: 'İstanbul' },
},
customer: {
taxNumber: '9876543210',
name: 'Örnek Alıcı Ltd. Şti.',
address: { district: 'Çankaya', city: 'Ankara' },
},
lines: [{ name: 'Danışmanlık hizmeti', quantity: 10, unitPrice: 100, vatRate: 20 }],
})
// ── 2. İmza: e-imza mali mühürle imzalar ──────────────────────────────
const { privateKey, certificate, chain } = loadPkcs12(
new Uint8Array(readFileSync('mali-muhur.p12')),
process.env.MUHUR_SIFRESI ?? '',
)
const imzali = sign({
xml: fatura,
signer: { certificate, chain },
privateKey,
commitmentType: 'proof-of-origin',
productionPlace: { city: 'İstanbul', country: 'TR' },
})
// ── 3. Doğrulama ──────────────────────────────────────────────────────
const imza = verify(imzali)
if (!imza.valid) throw new Error(`İmza geçersiz: ${imza.reason}`)
// İmzalayanın beklediğiniz mükellef olduğunu ayrıca doğrulayın.
// Türk sertifikalarında VKN/TCKN konudaki serialNumber alanında taşınır.
if (imza.signer.subjectSerialNumber !== '1234567890') {
throw new Error('İmza başka bir mükellefe ait')
}
// ── 4. Okuma: ubl-tr imzalı belgeyi hâlâ okur ─────────────────────────
// İmza belgeyi bozmaz; ubl-tr onu ayrıştırır ve iş kurallarını denetler.
const { root } = parseDocument(imzali)
const okunan = parseInvoice(root)
const kurallar = validateInvoiceRules(root)
console.log(okunan.id) // 'ABC2026000000001'
console.log(okunan.supplier?.name) // 'ÖRNEK SATICI A.Ş.'
console.log(kurallar.valid) // true
parseInvoicevevalidateInvoiceRulesXML dizesi değil, ayrıştırılmış kök öğe alır — önceparseDocumentçağırın. Doğrudan dize verirseniz TypeScript uyarır, ama düz JavaScript'te sessizce boş sonuç dönersiniz.
Neden ayrı paketler
| | @yankikucuk/ubl-tr | @yankikucuk/e-imza |
| -------------------- | ---------------------------------- | ------------------------------------- |
| Sorumluluk | belge üretimi, okuma, iş kuralları | kanonikleştirme, imza, doğrulama |
| Bilmediği | XAdES, sertifika, kanonik biçim | UBL, KDV, tevkifat, fatura profilleri |
| Bağımlılık | sıfır | sıfır |
| Birbirine bağımlılık | yok | yok |
İmzasız kullanım da anlamlıdır: e-Arşiv portal akışında belge GİB tarafında imzalanır, siz yalnızca üretirsiniz. İmzayı ayrı tutmak o senaryoyu zorunlu bir bağımlılıkla ağırlaştırmıyor.
Sırayı bozmayın
İmza belgenin son adımıdır. İmzaladıktan sonra XML'e dokunmak — bir boşluk eklemek bile — imzayı geçersiz kılar; kanonikleştirme boşluğu "temizlemez", çünkü temizleseydi imza kapsamı belirsizleşirdi.
const imzali = sign({ xml: fatura, signer, privateKey })
const bozuk = imzali.replace('118.00', '119.00')
verify(bozuk).valid // false — tek bir rakam yettie-İrsaliye
Aynı akış buildDespatchAdviceXml ile de çalışır; e-İrsaliye de aynı
ext:UBLExtensions yapısını taşır ve placement varsayılanı değişmez.
import { buildDespatchAdviceXml } from '@yankikucuk/ubl-tr'
const imzali = sign({
xml: buildDespatchAdviceXml(irsaliye),
signer: { certificate, chain },
privateKey,
})Akıllı kart, HSM ve uzak imza
Özel anahtar sürece hiç girmez:
import { prepare, complete } from '@yankikucuk/e-imza'
const bekleyen = prepare({
xml: faturaXml,
signer: { certificate }, // yalnızca sertifika; anahtar yok
})
// Karta/HSM'e ne vereceğiniz kullandığınız mekanizmaya bağlı:
//
// CKM_SHA256_RSA_PKCS → bekleyen.dataToSign (kart kendi özetler)
// CKM_RSA_PKCS → bekleyen.digestInfo (DER DigestInfo)
// CKM_ECDSA → bekleyen.digest (ham özet)
//
const imza = await kart.imzala(bekleyen.digestInfo ?? bekleyen.dataToSign)
const imzali = complete(bekleyen, imza)digestInfo alanı EC anahtarlarda undefined olur. ECDSA imzası ham
r‖s biçiminde beklenir, ASN.1 DER değil — XMLDSig bunu şart koşar.
PKCS#11 sürücüsüne doğrudan konuşan katman ayrı bir pakete taşınıyor:
@yankikucuk/e-imza-pkcs11. Böylece bu paket sıfır bağımlılık kalıyor ve yalnızca ihtiyacı olan onu kuruyor. Yukarıdakiprepare()/complete()deyimi zaten bugün de kendi PKCS#11 katmanınızı bağlamanıza yetiyor.
Paralel imza
Aynı belgeyi birden çok kişinin bağımsız imzalaması. Varsayılan davranış
bunu desteklemez ve bu doğrudur: enveloped-signature dönüşümü tanımı
gereği yalnızca kendi imzasını kapsam dışında bırakır, dolayısıyla ikinci
imza eklendiğinde birincinin kapsadığı içerik değişir ve birinci imza
geçersiz olur.
parallel: true verildiğinde XPath Filter 2.0 ile bütün imzalar kapsam
dışında bırakılır; imzacılar aynı içeriği imzalar ve imzalar birbirinden
bağımsız olur:
const birinci = sign({ xml, signer: a, privateKey: ka, parallel: true })
const ikinci = sign({ xml: birinci, signer: b, privateKey: kb, parallel: true })
verifyAll(ikinci).every((sonuc) => sonuc.valid) // trueUBL-TR e-Fatura tek imza bekler; orada varsayılan doğrudur.
Zaman damgası (XAdES-T)
Zaman damgası, imzanın belirli bir andan önce atıldığını üçüncü bir tarafa kanıtlatır. Sertifikanız sonradan iptal edilse ya da süresi dolsa bile damga, o ana kadar geçerli olduğunu gösterir.
Kütüphane TSA'ya bağlanmaz. İstek baytlarını üretir, jetonu yerleştirir; aradaki HTTP çağrısı sizin. Bir imza kütüphanesinin ne zaman ve nereye bağlandığı çağıranın kararı olmalı — hem güvenlik açısından, hem de bu akış çoğu zaman kuyruk ve yeniden deneme mantığı gerektirdiği için.
import { timestampRequest, upgrade, parseTimestampResponse, verify } from '@yankikucuk/e-imza'
// 1. İsteği üret. Damgalanan şey, kanonikleştirilmiş ds:SignatureValue ÖĞESİDİR.
const istek = timestampRequest({ xml: imzali })
// 2. TSA'ya gönder — Kamu SM: http://tzd.kamusm.gov.tr
const yanit = await fetch('http://tzd.kamusm.gov.tr', {
method: 'POST',
headers: { 'content-type': 'application/timestamp-query' },
body: istek,
})
const jeton = parseTimestampResponse(new Uint8Array(await yanit.arrayBuffer()))
// 3. Yerleştir. Jetonun BU imzayı damgaladığı doğrulanır; tutmuyorsa hata verir.
const damgali = upgrade({ xml: imzali, to: 'T', token: jeton })
verify(damgali).level // 'T'Yükseltme imzayı neden bozmuyor
Damga xades:UnsignedProperties altına yazılır ve o alt ağaç hiçbir
ds:Reference tarafından kapsanmaz. Adı da bunu söylüyor: imzalanmamış
özellikler. SignedPropertiese bir şey eklemek imzayı anında geçersiz
kılardı.
Aynı imzaya birden çok damga eklenebilir; hepsi aynı ds:SignatureValueyu
damgalar ve verify() her birini ayrı raporlar.
Seviye, iddiaya değil kanıta bakar
verify() bulduğu her damgayı gerçekten doğrular: jeton kriptografik olarak
geçerli mi, ve bu imzayı mı damgalıyor. Doğrulanmayan bir damga seviyeyi
yükseltmez.
const sonuc = verify(supheliBelge)
sonuc.level // 'BES' — belge <xades:SignatureTimeStamp> içerse bile
sonuc.timestamps[0].valid // false
sonuc.timestamps[0].reason // 'Jeton başka bir veriyi damgalamış — özet eşleşmiyor.'
sonuc.warnings // [{ code: 'timestamp-invalid', … }]Yapıya bakıp "T" demek damganın var oluş amacını ortadan kaldırırdı:
<xades:SignatureTimeStamp> etiketini belgeye herkes yazabilir.
RFC 3161 katmanı ayrıca kullanılabilir
Protokol XAdES'ten bağımsız olarak da çalışır — herhangi bir veriyi damgalamak ve doğrulamak için:
import { buildTimestampRequest, verifyTimestampToken } from '@yankikucuk/e-imza'
const istek = buildTimestampRequest({ messageImprint: ozet, nonce: rastgele })
// … TSA'ya gönder …
const sonuc = verifyTimestampToken(jeton, { data: veri, nonce: rastgele })
sonuc.valid && sonuc.info.genTimedata vermezseniz jeton kriptografik olarak doğrulanır ama neyi
damgaladığı bilinmez; nonce vermezseniz yanıt tekrar oynatmaya açık
kalır. İkisi de isteğe bağlı, ama ikisini birden atlamak damgayı büyük
ölçüde anlamsızlaştırır.
Uzun dönem: LT ve LTA
Zaman damgası imzanın ne zaman atıldığını kanıtlar; LT ve LTA imzanın yıllar sonra da doğrulanabilir kalmasını sağlar.
Sorun şu: bugün geçerli olan sertifikanın beş yıl sonra süresi dolmuş olacak ve "imza atıldığı anda bu sertifika iptal edilmiş miydi?" sorusunun cevabı hiçbir yerde bulunamayacak — OCSP yanıtlayıcıları geçmişi saklamaz. LT o cevabı imzanın içine gömer. LTA ise gömülen kanıtın da üstüne bir arşiv damgası atar, çünkü OCSP yanıtını imzalayan sertifikanın da bir gün süresi dolar.
LT — zincir ve iptal kanıtı
import {
buildOcspRequest,
ocspResponderUrls,
parseOcspResponse,
upgrade,
verifyOcspResponse,
} from '@yankikucuk/e-imza'
// 1. İptal kanıtını al. Yanıtlayıcının adresi sertifikanın içinde yazılı.
const [adres] = ocspResponderUrls(certificate)
const istek = buildOcspRequest({ certificate, issuer: araCa, nonce })
const ham = await fetch(adres!, {
method: 'POST',
headers: { 'content-type': 'application/ocsp-request' },
body: istek,
})
const yanit = parseOcspResponse(new Uint8Array(await ham.arrayBuffer()))
// 2. Gömmeden ÖNCE doğrula: yanıt gerçekten bu sertifikaya mı ait?
const durum = verifyOcspResponse(yanit, { certificate, issuer: araCa, nonce })
if (!durum.valid) throw new Error(durum.reason)
if (durum.certificateStatus.status !== 'good') throw new Error('Sertifika iptal edilmiş')
// 3. Zinciri ve kanıtı imzaya göm.
const lt = upgrade({
xml: damgali,
to: 'LT',
certificates: [araCa, kokCa],
ocspResponses: [yanit.der],
})
verify(lt).level // 'LT'Yalnızca zincir gömmek LT sayılmaz: iptal kanıtı olmadan imza yine
doğrulanamaz. upgrade() bu yüzden en az bir OCSP yanıtı ya da CRL ister ve
yoksa açık hata verir — sessizce kabul etmek, kullanıcıya sahte bir
uzun-dönem güvencesi vermek olurdu.
LTA — arşiv damgası
import { archiveTimestampRequest, upgrade } from '@yankikucuk/e-imza'
const istek = archiveTimestampRequest({ xml: lt })
// … TSA'ya gönder, jetonu al …
const lta = upgrade({ xml: lt, to: 'LTA', token: jeton })
verify(lta).level // 'LTA'Arşiv damgası imzanın ve o ana kadarki bütün imzalanmamış özelliklerin tamamını kapsar — LT verisi dâhil. Gömülen OCSP yanıtının tek bir baytı değişse arşiv damgası tutmaz. Damga periyodik olarak yenilenebilir; her yeni damga bir öncekini de kapsar.
const sonuc = verify(lta)
sonuc.timestamps.map((d) => [d.kind, d.valid])
// [['signature', true], ['archive', true]]Hangi tanım — ve sınırı
Arşiv damgasının girdi hesabı ETSI TS 101 903 v1.4.2 §8.2.1 uyarınca yapılıyor. EN 319 132 farklı bir girdi tanımlar; ikisi uyumlu değildir ve bu paket TS 101 903'ü uygular.
Açık olmak gerekirse: zaman damgasının kendisi OpenSSL ile iki yönde sınandı, ama arşiv damgasının girdi hesabı bağımsız bir uygulamayla çapraz doğrulanamadı. Riski karşılamak için girdinin bileşimi doğrudan teste bağlandı — hangi parçaların hangi sırayla girdiğini sabitleyen ayrı bir iddia var. Yine de LTA imzalarınızı üretime almadan önce karşı tarafın doğrulayıcısıyla denemenizi öneririm.
CAdES — ikili veri imzası
XML olmayan her şey için: PDF, ZIP, e-reçete, ham veri. Aynı imza politikaları, aynı taahhüt türleri, aynı seviye merdiveni; farkı taşıyıcı — XML yerine CMS (RFC 5652).
import { cadesSign, cadesVerify, loadPkcs12 } from '@yankikucuk/e-imza'
import { readFileSync } from 'node:fs'
const { privateKey, certificate, chain } = loadPkcs12(p12, sifre)
const imza = cadesSign({
data: readFileSync('belge.pdf'),
signer: { certificate, chain },
privateKey,
commitmentType: 'proof-of-origin',
signerLocation: { country: 'TR', locality: 'İstanbul' },
})
const sonuc = cadesVerify(imza)
sonuc.valid && sonuc.level // 'BES'Gömülü ve ayrık
Varsayılan gömülü: veri imzanın içinde taşınır, imza tek başına doğrulanabilir. Büyük dosyalarda ayrık tercih edilir:
const imza = cadesSign({ data, signer, privateKey, attached: false })
// Doğrularken veri ayrıca verilmeli — yoksa açık hata.
cadesVerify(imza, { content: data })Ayrık imzada içerik verilmezse cadesVerify geçersiz döner, sessizce
"geçerli" demez: veri olmadan imzanın neyi kapsadığı bilinemez.
CAdES-BES'i düz CMS'ten ayıran şey
signingCertificateV2 özniteliği (RFC 5035). İmzalayan sertifikanın özetini
imzaya bağlar; olmadan imzayı doğrulayan sertifika yapının içinde
değiştirilebilir.
cadesVerify bu bağı denetler ve tutmazsa uyarır:
sonuc.level // 'CMS' — öznitelik hiç yoksa
sonuc.warnings // [{ code: 'no-signing-certificate-attribute', … }]
// ya da [{ code: 'signing-certificate-digest-mismatch', … }]Seviye yükseltme
XAdES'teki desenin aynısı; ağ isteği yine kütüphanede değil:
import { cadesTimestampRequest, cadesUpgrade } from '@yankikucuk/e-imza'
const istek = cadesTimestampRequest({ cms: imza })
// … TSA'ya gönder …
const t = cadesUpgrade({ cms: imza, to: 'T', token: jeton })
const lt = cadesUpgrade({
cms: t,
to: 'LT',
certificates: [araCa, kokCa],
ocspResponses: [yanit.der],
})Yükseltme imzayı bozmaz: eklenen her şey unsignedAttrs altına gider ve o
alan imzaya dâhil değildir.
LTA — arşiv damgası (archive-time-stamp-v3)
Kaynak: ETSI TS 101 733 V2.2.1 §6.4.2 ve §6.4.3.
import { cadesArchiveTimestamp, parseTimestampResponse } from '@yankikucuk/e-imza'
const bekleyen = cadesArchiveTimestamp({ cms: lt })
const yanit = await fetch(tsaUrl, {
method: 'POST',
headers: { 'content-type': 'application/timestamp-query' },
body: bekleyen.request,
})
const lta = bekleyen.finish(parseTimestampResponse(new Uint8Array(await yanit.arrayBuffer())))Damgalanan girdi, §6.4.3'ün dört maddeli listesi — bu sırayla:
| # | bileşen |
| --- | -------------------------------------------------------------------------------------------------------------- |
| 1 | SignedData.encapContentInfo.eContentType |
| 2 | İmzalanan verinin özeti — verinin kendisi değil |
| 3 | SignerInfonun version, sid, digestAlgorithm, signedAttrs, signatureAlgorithm, signature alanları |
| 4 | Tek bir ATSHashIndex |
İki nokta bu tasarımın tamamını açıklıyor:
unsignedAttrs girdiye girmiyor. Girseydi damga, eklendiği anda kendi
girdisini değiştirir ve kendi kendini geçersiz kılardı. İkinci bir arşiv
damgası da aynı sebeple mümkün.
unsignedAttrs yine de korumasız kalmıyor. ATSHashIndex, o an mevcut
her sertifikanın, her iptal kaydının ve her imzalanmamış özniteliğin özetini
tutuyor ve kendisi girdinin dördüncü bileşeni. Doğrulamada bu indeks belgeyle
karşılaştırılıyor; damgadan sonra eklenen bir bileşen
coversAllComponents: false ve archive-timestamp-partial-coverage
uyarısıyla raporlanıyor.
ats-hash-index, §6.4.3'ün şart koştuğu gibi jetonun kendi
unsignedAttrsına yazılıyor; jetonun imzası signedAttrs üzerinde olduğu
için bu onu bozmuyor.
Arşiv damgasında doğrulanan ve doğrulanamayan
Bu paketin genel kuralı, kendi kendini doğrulayan koda güvenmemek. Arşiv damgasında o kural en çok zorlanıyor, çünkü girdi hesabını sınayacak bağımsız bir uygulama bulunamadı — bu formatı doğrulayan olgun açık kaynaklı uygulama Java ekosisteminde ve bu paketin test zinciri Node ile sınırlı.
Bu yüzden bağımsızlık üç ayrı yerden geliyor:
| ne | bağımsız tanık |
| --------------------------------------------------------------------- | ------------------------------- |
| Jetonun imzası ve messageImprintin girdimizin özeti oluşu | openssl ts -verify -token_in |
| SignerInfo alanlarının ham dilimleri, ATSHashIndex kodlaması, OID | openssl asn1parse |
| İndeksteki her özet, sertifika sayısı | openssl dgst, openssl pkcs7 |
Kalan boşluk, açıkça: bileşenlerin sırası standardın metninden alındı ve bağımsız bir uygulamayla karşılaştırılamadı. Sıra, testte §6.4.3'ün maddeleriyle birlikte yazılı ve elle kurulmuş bir beklentiyle sabitlendi — ama üretime almadan önce karşı tarafın doğrulayıcısıyla denemenizi öneririm.
ATSv2 (1.2.840.113549.1.9.16.2.48) okunuyor ama üretilmiyor ve
doğrulanmıyor: girdi hesabı ATSv3'ten farklı. Belgede varsa
archive-timestamp-v2-unverified uyarısı çıkıyor ve seviye yükselmiyor.
XAdES ile arasındaki tek ince fark
ECDSA imzasının biçimi. XMLDSig ham r‖s ister, CMS ise DER
SEQUENCE { r, s }. Kütüphane bunu kendi hallediyor, ama prepare() /
complete() ile dışarıda imzalıyorsanız kartınızın hangi biçimi ürettiğine
dikkat edin — aynı kartın çıktısı iki yerde farklı sarılır.
Doğrulama
CAdES çıktısı OpenSSL ile çapraz doğrulandı: openssl cms -verify
ürettiğimiz imzaları kabul ediyor — gömülü, ayrık, SHA-384/512, EC anahtar,
EPES, T'ye ve LT'ye yükseltilmiş hâlleriyle. Bu, signedAttrs kodlamasının,
SET etiketi dönüşümünün, SignerInfo alan sırasının ve messageDigest
bağının hepsinin doğru olduğunu birlikte gösteriyor.
PAdES — PDF imzası
PDF'e gömülen şey ayrık bir CAdES imzasıdır; bu yüzden PAdES kendi
kriptografisini getirmiyor, CAdES katmanının üstüne oturuyor. Getirdiği şey
PDF'e özgü olan kısım: artımlı güncelleme, imza sözlüğü ve /ByteRange.
import { padesSign, padesVerify, loadPkcs12 } from '@yankikucuk/e-imza'
import { readFileSync } from 'node:fs'
const { privateKey, certificate, chain } = loadPkcs12(p12, sifre)
const imzali = padesSign({
pdf: readFileSync('belge.pdf'),
signer: { certificate, chain },
privateKey,
reason: 'Fatura onayı',
location: 'İstanbul',
name: 'Örnek İmzacı',
})
const sonuc = padesVerify(imzali)
sonuc.valid // true
sonuc.signatures[0].coversWholeDocument // trueÖzgün baytlara dokunulmaz
İmza dosyanın sonuna eklenir, eski çapraz başvuru /Prev ile zincire
bağlanır. Daha önce atılmış imzalar bu yüzden bozulmaz — ve aynı belgeye üst
üste imza atılabilmesinin nedeni budur.
Kapsam raporlanır
İkinci bir imza eklendiğinde birincinin kapsamı daralır. Bu bir saldırı değil, PDF'in normal davranışı — ama bilinmesi gerekiyor:
const sonuc = padesVerify(ikiImzali)
sonuc.signatures[0].coversWholeDocument // false
sonuc.signatures[0].warnings // [{ code: 'partial-coverage', … }]
sonuc.signatures[1].coversWholeDocument // trueİmza için yer ayırma
PDF'te imzanın boyutu imza atılmadan önce ayrılmak zorunda: yer
ayrılmadan /ByteRange hesaplanamaz, /ByteRange olmadan imzalanacak
baytlar belli olmaz. Varsayılan 8 KB; zincir ve zaman damgası gömülecekse
signatureSpace ile artırın. Sığmayan imza sessizce kırpılmaz, açık hata
verir.
PAdES-LT — belgeye gömülen doğrulama malzemesi
Uzun dönem geçerlilik PDF'te /DSS (Document Security Store) ile kurulur:
doğrulayanın ihtiyaç duyacağı her şey — zincir ve iptal kanıtı —
belgenin içine, artımlı bir güncellemeyle konur. XAdES'teki
CertificateValues + RevocationValues ikilisinin PDF karşılığı.
import { padesUpgrade, readDocumentSecurityStore } from '@yankikucuk/e-imza'
const lt = padesUpgrade({
pdf: imzali, // B-T seviyesindeki PDF
to: 'LT',
certificates: [araCa, kokCa],
ocspResponses: [ocspYaniti],
})
readDocumentSecurityStore(lt)?.certificates.length // 2Var olan bir /DSS korunur ve genişletilir: içindeki nesnelere yapılan
başvurular olduğu gibi taşınır, yenileri eklenir. Üzerine yazmak, daha önce
eklenmiş iptal kanıtını silmek olurdu.
PAdES-LTA — belge zaman damgası
Arşiv damgası, belgenin tamamını — imzayı ve /DSSi birlikte —
damgalar. Gerekçesi şu: /DSSe gömdüğünüz OCSP yanıtını imzalayan
sertifikanın da bir gün süresi dolar; damga o zinciri kırılmadan uzatır.
PDF açısından damga, imzalayanı olmayan bir imzadır: /Contents içinde
ham bir RFC 3161 jetonu durur ve /SubFilter /ETSI.RFC3161 bunu söyler.
İmzada olduğu gibi burada da yer önce ayrılmak zorunda, bu yüzden akış aynı
prepare/finish deyimini izliyor:
import { padesDocumentTimestamp, parseTimestampResponse } from '@yankikucuk/e-imza'
const bekleyen = padesDocumentTimestamp({ pdf: lt })
const yanit = await fetch(tsaUrl, {
method: 'POST',
headers: { 'content-type': 'application/timestamp-query' },
body: bekleyen.request,
})
const lta = bekleyen.finish(parseTimestampResponse(new Uint8Array(await yanit.arrayBuffer())))finish, jetonun gerçekten bu baytları damgaladığını gömmeden önce
denetler. Damga yenilenebilir: ikinci bir padesDocumentTimestamp üsttekini
ekler, eskisi kendi kapsadığı baytlar değişmediği için tutmaya devam eder.
Seviye, burada da kanıta bakar
padesVerify her imza için bir level bildiriyor ve seviye yalnızca
doğrulanan kanıtla yükseliyor:
| Seviye | Koşul |
| --------- | ----------------------------------------------------------- |
| B-B | İmza geçerli |
| B-T | + gömülü imza zaman damgası doğrulandı |
| B-LT | + /DSSte sertifika ya da OCSP yanıtı var |
| B-LTA | + imzadan sonra atılmış, doğrulanan bir /DocTimeStamp |
Basamaklar atlanmıyor: /DSS varken imza zaman damgası yoksa seviye B-B
kalır. ETSI, B-LT'nin B-T üzerine kurulmasını şart koşuyor ve gerekçesi
pratik — imza zamanı kanıtlanmamışsa, iptal kanıtının "imza anında" geçerli
olduğunu söylemek bir şey ifade etmez.
/VRI anahtarı ve neden dolgulu baytlar
/DSS içindeki /VRI sözlüğü, hangi malzemenin hangi imzaya ait olduğunu
gösterir ve anahtarı ISO 32000-2 uyarınca imzanın SHA-1 özetinin büyük
harfli onaltılık yazımıdır. "İmza" burada /Contents dizesinin dosyada
durduğu hâlidir — yani DER'in ardındaki sıfır dolgusu da dahil.
Bu, kelimesi kelimesine tek okunuş değil: DER'i kırpıp yalnız CMS'i
özetlemek de savunulabilir ve spesifikasyon bunu netleştirmiyor. Dolgulu hâl
seçildi çünkü yaygın PDF imza araçları /Contents bayt dizesini olduğu gibi
özetliyor ve /VRInin tek işlevi başka bir doğrulayıcıyla eşleşmek.
Kendi okuyucumuzla tutarlı olmak yetmez.
Testte anahtar OpenSSL'e hesaplatılıyor: /Contents onaltılığı dosyadan
doğrudan okunuyor, çözülüyor ve openssl dgst -sha1 sonucuyla
karşılaştırılıyor.
Doğrulama
PAdES çıktısı poppler'ın pdfsigi ile çapraz doğrulandı: bağımsız bir
PDF imza doğrulayıcısı imzalarımızı Signature is Valid ve Total document
signed diye raporluyor. Bu tek sonuç artımlı güncellemenin, /ByteRange
hesabının, imza sözlüğünün ve gömülü CAdES'in hepsinin doğru olduğunu
birlikte gösteriyor.
Belge damgası da bağımsız tanığını buluyor: pdfsig onu ayrı bir imza alanı
olarak görüyor, /ByteRangeını kendi hesaplayıp Total document signed
diyor. Damga alanının yerleşimini yanlış hesaplasaydık bu satır çıkmazdı.
Okuma tarafında üç çapraz başvuru biçimi de destekleniyor: klasik xref
tablosu, çapraz başvuru akışı, ve PNG öngörücülü akış — sonuncusu modern
üreticilerin varsayılanı ve geri alınmazsa tablo sessizce yanlış okunur.
ASiC — imzalı konteyner
Belgeyi ve imzasını tek dosyada taşımak için. ASiC imza üretmez, paketler: içine konan imza XAdES de olabilir CAdES de. Standardın kendi ayrımı bu — ASiC bir imza biçimi değil, taşıma biçimidir.
import { cadesSign, createAsic, readAsic, cadesVerify } from '@yankikucuk/e-imza'
// Belgeyi ayrık CAdES ile imzala.
const imza = cadesSign({ data: fatura, signer, privateKey, attached: false })
// İkisini tek konteynerde paketle.
const konteyner = createAsic({
type: 'asic-s',
dataFiles: [{ name: 'fatura.xml', data: fatura }],
signatures: [{ format: 'cades', data: imza }],
})
// Karşı taraf açar ve doğrular.
const okunan = readAsic(konteyner)
cadesVerify(okunan.signatures[0]!.data, { content: okunan.dataFiles[0]!.data })ASiC-S ve ASiC-E
ASiC-S tek veri dosyası ve tek imza taşır — bir faturayı imzasıyla
birlikte göndermek için. ASiC-E birden çoğunu; hangi imzanın hangi
dosyaları kapsadığı ASiCManifest ile bildirilir.
createAsic({
type: 'asic-e',
dataFiles: [
{ name: 'fatura.xml', data: fatura },
{ name: 'ek.pdf', data: ek },
],
signatures: [
{ format: 'cades', data: imzaA }, // hepsini kapsar
{ format: 'cades', data: imzaB, covers: ['ek.pdf'] }, // yalnızca eki
],
})ASiC-S kısıtları esnetilmiyor: birden çok dosya ya da imza vermek açık hata verir. Esnetmek, konteyneri okuyan diğer uygulamaların reddetmesine yol açardı.
Manifest okunurken doğrulanıyor
readAsic bir ASiCManifest bulduğunda referans edilen dosyaların
özetlerini yeniden hesaplayıp karşılaştırır:
const manifest = okunan.signatures[0]?.manifest
manifest?.references
// [{ uri: 'fatura.xml', present: true, digestMatches: true }, …]Manifesti okuyup özetleri doğrulamamak, imzanın kapsadığını iddia ettiği dosyanın gerçekten o dosya olduğunu varsaymak olurdu.
mimetype neden ilk ve sıkıştırılmamış
Standardın şartı, ama sebebi pratik: konteynerin türü ZIP açılmadan,
dosyanın ilk baytlarına bakılarak anlaşılabiliyor. peekFirstEntry tam
olarak bunu yapıyor ve readAsic türü oradan belirliyor.
Doğrulama
ZIP katmanı Info-ZIP (unzip) ile çapraz doğrulandı: ürettiğimiz
arşivleri unzip -t sağlam buluyor, unzip -l listeliyor, unzip -p
içeriği doğru açıyor. Yerel başlıklar, merkezî dizin ve CRC-32 değerlerinin
hepsinin doğru olduğunu birlikte gösteriyor.
Okuma tarafında merkezî dizin yetkili sayılıyor ama verinin konumu yerel başlıktan okunuyor: ikisi çelişebilir ve merkezî uzunluklara güvenen bir okuyucu yanlış konumdan okur — hata vermeden, sessizce.
Kanonikleştirme
Kanonikleştirici ayrıca kullanılabilir. Canonical XML 1.0 ve Exclusive C14N, yorumlu ve yorumsuz dört varyantla:
import { canonicalize, parseXml } from '@yankikucuk/e-imza'
canonicalize(parseXml(xml), { algorithm: 'exc-c14n' })
// Belgenin ortasındaki bir alt ağaç — ata bağlamı belgeden okunur.
canonicalize(doc, { algorithm: 'c14n10', subset: hedefOge })
// Bir alt ağacı çıkararak (enveloped-signature dönüşümünün anlamı).
canonicalize(doc, { algorithm: 'exc-c14n', omit: new Set([imzaOgesi]) })Doğrulaması iki bağımsız kaynağa dayanıyor:
- W3C
REC-xml-c14n-20010315§3.1–3.6 uygunluk vektörleri. Beklenen çıktılar spesifikasyondan birebir alındı. - libxml2 ile fark testi. Dokuz belge, iki algoritma, bayt bayt aynı sonuç. Kendi testlerimiz kendi yorumumuzu paylaşabilir; libxml2 paylaşmaz.
Alt küme kanonikleştirmesi — yani imza yolunun tam ortası — bu işin en
sık sessizce yanlış yapılan adımıdır. Kapsayıcı biçimde ata ad alanı
bildirimleri tepe öğeye taşınmalı ve ata xml:* öznitelikleri miras
alınmalıdır (C14N 1.0 §2.4). SignedProperties referansı her zaman
belge ortasında bir alt kümedir; bu paket dört alt küme vektörünün dördünü
de her iki algoritmada geçiyor.
Kapsam
Bu sürümde var
| | |
| ------------------- | ---------------------------------------------------------- |
| XAdES | BES, EPES, T, LT, LTA — beş seviye |
| CAdES | BES, EPES, T, LT, LTA — archive-time-stamp-v3 |
| PAdES | B-B, B-T, B-LT, B-LTA — /DSS ve /DocTimeStamp |
| ASiC | ASiC-S ve ASiC-E, ASiCManifest üretimi ve doğrulaması |
| Zaman damgası | RFC 3161 — istek üretme, jeton doğrulama, seviye yükseltme |
| İptal denetimi | RFC 6960 OCSP — istek üretme, yanıt doğrulama |
| CMS | RFC 5652 SignedData okuma, üretme ve doğrulama |
| Yerleşim | ubl-extension (UBL-TR), enveloped |
| Kanonikleştirme | Canonical XML 1.0, Exclusive C14N, ±yorumlar |
| Özet | SHA-256, SHA-384, SHA-512 |
| İmza | RSA-PKCS1, RSA-PSS, ECDSA |
| Anahtar | PKCS#12 — PBES2/AES, 3DES, RC2-40/128, RC4-40/128 |
| Ayrık imzalama | prepare() / complete() — kart, HSM, uzak servis |
| Paralel imza | XPath Filter 2.0 ile |
Bu sürümde yok
PKCS#11 sürücüsüne doğrudan erişim — ayrı bir pakete taşınıyor:
@yankikucuk/e-imza-pkcs11. Yerleşik destek yerel bir eklenti gerektirdiği
için bu paketin sıfır bağımlılık ilkesini kırardı; ayrı paket olarak
isteyen kurar, istemeyen etkilenmez. Bugün de kullanılabilir:
prepare() / complete() ile kendi PKCS#11 katmanınızı bağlayın.
CAdES ATSv2 arşiv damgası — ATSv3 üretiliyor ve doğrulanıyor; ATSv2'nin girdi hesabı farklı (TS 101 733 v1.8.3 §6.4.1) ve bağımsız doğrulama olmadan yazılmadı. Belgede varsa uyarı çıkıyor, seviye yükseltmiyor.
PAdES /VRI başına ayrı malzeme — /VRI yazılıyor ama belgedeki bütün
malzeme her imzaya bağlanıyor. Hangi sertifikanın hangi imzaya ait olduğunu
çağıran bilir, kütüphane bilmez; yanlış eşleştirmektense hepsini göstermek
seçildi. Tek imzalı belgelerde — pratikte e-Fatura'nın tamamı — fark yok.
CRL ayrıştırma — CRL'ler LT seviyesine ve /DSSe gömülebiliyor ama
içerikleri çözümlenmediği için doğrulama tarafı onları iptal kanıtı olarak
değerlendirmiyor; seviye yalnızca OCSP yoluyla yükseliyor.
ASiC-E'de XAdES manifesti — ASiC-E + CAdES için ASiCManifest üretiliyor
ve okunurken özetleri doğrulanıyor. XAdES tarafında imza dosyaların kendisine
referans verdiği için ayrı bir manifest gerekmiyor, ama ODF tarzı
META-INF/manifest.xml üretilmiyor.
ZIP64 ve şifreli ZIP — ASiC konteynerleri bunları kullanmaz. 4 GiB üstü konteyner ya da parola korumalı arşiv desteklenmiyor.
Şifreli PDF — imza eklemek belgeyi çözmeyi gerektirir; açıkça reddediliyor.
Genel XPath — ve eklenmesi planlanmıyor. İmza kapsamını belirleyen bir ifadeyi yaklaşık değerlendirmek, imzanın kapsamadığı içeriği kapsıyormuş gibi göstermektir. Tek bir iyi tanımlı deyim destekleniyor (bütün imzaları çıkarma), gerisi açıkça reddediliyor.
Sertifika zinciri ve iptal denetimi — bilinçli olarak kapsam dışı. Gerekçesi aşağıda.
valid: true ne demek, ne demek değil
Demek olan üç şey:
- Her
ds:Referenceözeti yeniden hesaplandı ve tuttu, ds:SignedInfokanonikleştirildi veds:SignatureValuebu baytlar üzerindeds:KeyInfo'daki sertifikayla kriptografik olarak doğrulandı,xades:SigningCertificatevarsa, özeti kullanılan sertifikayla tutarlı.
Demek olmayan şeyler: sertifikanın güvenilir bir köke bağlandığı, iptal edilmediği, imza anında geçerli olduğu ya da imzanın hukuken bağlayıcı olduğu.
Bu ayrımı bulanıklaştırmak, imza kütüphanelerinde en sık görülen sahte
güvenlik kaynağıdır. Sertifikanın geçerlilik aralığıyla ilgili gözlemler
sessizce yutulmaz, warnings altında ayrıca raporlanır:
const sonuc = verify(imzali)
if (sonuc.valid && sonuc.warnings.length > 0) {
for (const uyari of sonuc.warnings) console.warn(uyari.code, uyari.message)
}Bir XAdES imzasının hukuki geçerliliği, kullanılan sertifikanın niteliğiyle (nitelikli elektronik sertifika) ve imza politikasına uygunlukla ilgilidir; kütüphanenin yapısal geçerlik denetimiyle karıştırılmamalıdır.
Tasarım kararları
SHA-1 yok. Ne özet ne imza algoritması olarak. Çakışma üretmek 2017'den beri pratikte mümkün. "Eski sistemlerle uyum" gerekçesiyle açık bırakmak, kullanıcıyı zayıf bir imzaya bir seçenek kadar yakın tutmak olurdu.
DTD tümden reddediliyor. Varlık genişletme, harici varlık (XXE) ve karesel şişme saldırılarının tamamı DTD üzerinden gelir. Dahası bir varlık, imzalanan baytlarla doğrulanan baytların ayrışmasına yol açabilir — imzanın anlamını kaybettiği durum tam olarak budur.
Belirsiz kimlik reddediliyor. URI="#x" ile aranan kimliği birden çok
öğe taşıyorsa undefined dönülür. "İlkini al" demek, saldırganın araya
kendi öğesini koyup imzanın kapsamını kaydırmasına izin vermektir — imza
sarma (signature wrapping) saldırısının klasik biçimi.
Okurken hoşgörülü, yazarken katı. Parçalara bölünmüş OCTET STRING
DER'de geçersizdir ama bazı araçların ürettiği kaplarda bulunur; okunuyor.
Yazarken her zaman ilkel biçim üretiliyor.
Ata işaretçisi yok. Kanonikleştirici belgeyi kökten dolaşıp ad alanı bağlamını yanında taşır; alt ağaç kanonikleştirilirken bağlam elle kopyalanmadığı için kaybolamaz da.
Mimari
sign / verify / upgrade (tepe; her şeyi görür)
│
┌────────┬───────┴───────┬────────┐
xades cades pades asic ← KARDEŞ
│ │ │ │ │
│ └──────┬──────┘ pdf zip
│ │ │ │
c14n pki │ │ ← KARDEŞ
│ │ │ │
xml asn1 │ │
└───────┬───────┴──────────┴───────┘
core (yaprak)Kardeş izolasyonu ESLint ile uygulanıyor ve doğrudan doğrulanabilirlik
kazandırıyor: c14n hiçbir kriptografi görmediği için W3C'nin kendi test
vektörleriyle tek başına sınanabiliyor, pki ise hiç XML görmediği için
PKCS#12 çözümü bir imza akışı kurmadan sınanabiliyor, zip hiç imza
görmediği için unzip ile tek başına sınanabiliyor.
pades, pdf ve cadesin üstünde durur: PDF'e gömülen şey ayrık bir CAdES
imzasıdır ve PAdES kendi kriptografisini getirmez.
Geliştirme
npm install
npm test # 511 test
npm run test:coverage
npm run typecheck
npm run lint
npm run knipTestler dört bağımsız referans uygulamayla karşılaştırma yapar:
| ne | araç |
| --------------------------------------- | ----------------------- |
| kanonikleştirme | libxml2 (xmllint) |
| ASN.1, CMS, zaman damgası, OCSP, /VRI | OpenSSL |
| PDF imzası ve belge damgası kapsamı | poppler (pdfsig) |
| ASiC konteyneri | Info-ZIP (unzip) |
Zaman damgası ve OCSP çevrimdışı sunucularla sınanıyor (openssl ts -reply,
openssl ocsp -index) — testler hiçbir zaman ağa çıkmaz. Araç yoksa ilgili
testler atlanır; CI'da dördü de kurulu ve varlıkları ayrıca iddia ediliyor.
Anahtar malzemesi depoda tutulmaz, her koşuda geçici dizinde üretilir. Eski biçim kapları macOS'un sistem TLS kütüphanesiyle, modern olanlar OpenSSL 3 ile yazılır — bir kütüphanenin ikisini birden açabildiği ancak ikisini birden üreterek gösterilebilir.
Her sürümde mutasyon denemesi yapılıyor: koda kasıtlı hatalar konup testlerin onları yakalayıp yakalamadığı ölçülüyor. Tekrar eden tuzak şu — üretici ile doğrulayıcı aynı bizsek, ikisi aynı yanlışı yaptığında testler geçer. Bu yüzden kritik yerlerde beklenen değer bağımsız bir araca hesaplatılıyor.
Güvenlik
Güvenlik açıklarını herkese açık issue ile değil, özel güvenlik kanalından bildirin.
Özel anahtarınızı hiçbir koşulda paylaşmayın; bir hata bildirimi için gerekmez. Kütüphane özel anahtarı diske yazmaz, ağa göndermez ve günlüklemez.
Sorumluluk reddi
Bu proje bağımsızdır; Gelir İdaresi Başkanlığı, TÜBİTAK ya da herhangi bir entegratörle ilişkili değildir. ETSI ve W3C standartları ile kamuya açık Türkçe belgeler üzerine kurulmuştur. Uygunluk değerlendirmesi gereken senaryolarda denetimi kullanıcı yürütür.
Lisans
MIT
