DUKIntegrator (declaration validation, rendering, signing)¶
DUKIntegrator is ANAF's Java desktop tool for tax declarations. Its per-form
validator jars are ANAF's own validation code — anafpy runs them and never
re-implements a rule. anafpy drives it headlessly ("mode B") for two operations,
-v (validate) and -p (render the official PDF); the qualified signature is
added separately (see §5) because DUK's signing path cannot reach a macOS
CryptoTokenKit key.
Everything below was live-proven on 2026-07-15 (macOS 26 / Oracle Java 26 / certSIGN Paperless vToken).
1. Distribution and update feed¶
- Distribution — the feed is self-sufficient (established 2026-07-26): the
update feed lists every file a working dist contains, so anafpy's installer
(
anafpy duk install/DukInstaller, managed dist at~/.anafpy/duk-dist) assembles the dist file by file from the feed and the legacydist_javaInclus20200203.zipis never needed. (That zip remains the manual path: extractdist/and ignore the bundled 32-bit JRE 6 — a modern JVM, proven on Oracle Java 26 / macOS arm64, runs-v/-pfine.) A minimal working dist is exactly:DUKIntegrator.jarat the root,lib/with the three core jars + the three third-party jars + the per-form jar pairs, andconfig/. - Update feed:
http://static.anaf.ro/static/10/Anaf/update5/versiuni.xml(also served over https — anafpy pins https and thestatic.anaf.rohost; the feed itself is unsigned) lists the current core jars and per-form jars (e.g. D300 atD300Validator.jar+D300Pdf.jar, versionJ12.0.1/P9.0.0). Per-form jars go intodist/lib/. The GUI mode auto-updates; the CLI mode does not — staleness must be surfaced (anafpy'sdeclaratie_duk_status/DukIntegrator.feed_versionscompare installed against the feed;declaratie_duk_install/anafpy duk updaterefresh).
Feed shape (live-fetched 2026-07-17, wrapper + integrator lists confirmed
2026-07-26; no XML namespace): one <integrator> element for the core, then
one container element per form, named after the form (D-series and
S-series — 237 containers, 173 distinct validator jars), inside a
<declaratii> wrapper:
<versiuni>
<integrator>
<versiune>1.4.18.3.3</versiune>
<iJars>…iText-5.0.4.jar, bcmail-jdk15-145.jar, bcprov-jdk15-145.jar…</iJars>
<sJars>…ss8/DecValidation.jar, DecPdf.jar, Validator.jar…</sJars>
<zJars>…zz9/DUKIntegrator.jar, ajutor.chm…</zJars>
<dJars>…dd5/Download.jar…</dJars>
<cFisiere>…cc2/config.properties + the smart-card vendor .cfg files…</cFisiere>
</integrator>
<declaratii>
<D300>
<versiuneJ>J12.0.1</versiuneJ>
<versiuneP>P9.0.0</versiuneP>
<JURL>http://static.anaf.ro/static/10/Anaf/update5/D300_27/D300Validator.jar</JURL>
<PURL>http://static.anaf.ro/static/10/Anaf/update5/D300_27/D300Pdf.jar</PURL>
<DURL>http://static.anaf.ro/static/10/Anaf/update5/D300_27/D300IstoriaVersiunilor.txt</DURL>
</D300>
...
</declaratii>
</versiuni>
The integrator lists map onto the dist: zJars → the root (DUKIntegrator.jar;
ajutor.chm is the GUI help file), sJars + iJars → lib/ (core + the
third-party iText/BouncyCastle jars), cFisiere → config/, and dJars
(the GUI updater) is not needed for CLI use. versiuneJ is the validator
jar's version and versiuneP the PDF jar's — the feed is the authority for
installed-vs-current comparison (anafpy records versiuneJ per fetched file
in its install manifest).
- <form>IstoriaVersiunilor.txt (what DURL points at) is chronological,
oldest first, and its opening lines are free text — verified across the 12
preinstall forms 2026-07-26. The head is a document title (Istoria
versiunilor pentru D406), a bare date (21-Mar-2019 for D212, 3-Jan-2012:
for D390), or the first release ever published (D100's 19-Oct-2011
publicat versiunea de test J1.0.0). The last J<major>.<minor>… token
in the file is the current version — it matched versiuneJ for all 12 forms.
Reading the first line instead reports every form as permanently stale
(issue #8). Lines also carry the PDF jar's P… versions, so a version scrape
must match the J prefix specifically.
The SAF-T module (D406/D406T) — jar sourcing and compatibility¶
Live-proven 2026-07-17 (macOS, Oracle Java 26); this is the module behind the D406T no-effect test filing (portal-upload reference §5).
- The 2018/2020-era core jars cannot run the D406 validators: they fail
with
NoClassDefFoundError: dec/DECTagStruct(written tovalidator.log, stdout only sayscod eroare=-5with an empty err file). Fix: updateDUKIntegrator.jar(feed pathzz9/) andlib/DecValidation.jar(feed pathss8/) to the feed's current versions. - A dist without its
config/folder makes the updatedDUKIntegrator.jarexit silently (no output, exit 0) — carryconfig/over when assembling a fresh dist. D406is in the update feed (D406_35/,J2.2.18);D406Tis not — its jars (D406TValidator.jar,D406TPdf.jar) ship only inside the dedicatedduk_SAFTdistribution (duk_SAFT_20230216.zipis the newest observed; jars dated 2023,J2.0.6), dropped intolib/like any per-form pair. Consequence: the staleness comparison (feed_versions) will never show D406T.- Form names and namespaces:
-v D406expectsmfp:anaf:dgti:d406:declaratie:v1,-v D406Texpectsmfp:anaf:dgti:d406t:declaratie:v1(the T validator errors with the exact expected namespace). The same document content validates under both — only the namespace differs; the 2023 T-validator additionally requires an (empty)AnalysisTypeTablethe current D406 no longer asks for. - The SAF-T validators write log files besides the err file (observed
2026-07-26): a verbose
<xml>.logsection trace next to the input XML on every run, andvalidator.login the working directory on crashes (theNoClassDefFoundErrormode above). anafpy always validates a temp copy from a per-run temp cwd, so both land in the run's own directory — cleaned up with it, and folded into the diagnostics of a no-findings failure. - SAF-T validators emit
F:(structure/fatal) finding lines besidesE:/W:; parameters are period-versioned inside the jar (version table 2019-01 → 2023-01 → 2024-01 → 2025-07), so structure rules shift with the reporting period, and several sections are vestigial in the current version:MovementTypeTableandMovementOfGoodsmust be present but empty (their children answermaxOccurs=0),Products/Owners/Assetsmay be empty,SalesInvoices/PurchaseInvoices/Paymentsmay be omitted (a presentSalesInvoicesdemands a fullInvoice). - Structure gotchas (from converging the minimal file, committed at
tests/fixtures/declaratii/d406t-minimal.xml): partner identifiers (CustomerID/SupplierID/partnerRegistrationNumber) need the 2-char ID-type prefix from the SAF-T nomenclature (00+ CUI for Romanian companies, check-digit-verified;080000000000000is the generic no-ID person code);Transactionand eachTransactionLinerequire bothCustomerIDandSupplierID; everyAmountStructurerequiresAmount+CurrencyCode+CurrencyAmount; each line requiresTaxInformation;BaseRateis a fraction (1.00, not100.00). A misplaced-but-known element is reported as "ar fi trebuit sa apara de minimum 1 ori" — check ordering before existence. The schema-definition workbook (RO_SAFT_SchemaDefCod_*.xlsx) is the current element table, but itsLinenaming is the newer schema's — the v1 wire element isTransactionLine.
Silent-exit-on-update escape hatch (offLine=Y)¶
DUK's startup update check uses hardcoded Windows paths, and on a non-Windows
host it can make the app silently exit (exit 0, no err file, no PDF). The
fix, from the community macOS setup (nokeect/duk-integrator-macos), is to
disable the check by setting offLine=Y in a config/config.properties and
pointing DUK at it with the CLI's -c flag (see §2 — -c takes the config
directory, and pointing it at the properties file reproduces the same
silent exit). Since 2026-07-26 anafpy preempts this on every run: it assembles a
per-run config directory (the dist's config/config.properties with any
offLine line replaced by offLine=Y) and always passes -c. If a manual CLI
run ever exits cleanly but produces nothing, offLine=Y — and the -c-path
shape — are the first things to check.
2. CLI contract¶
Positional arguments; $ means "use the default"; missing arguments produce the
single stdout message linie comanda incompleta.
java -jar DUKIntegrator.jar [-c configPath] -v <tip> <xml> [errFile] [valOption]
java -jar DUKIntegrator.jar [-c configPath] -p <tip> <xml> [errFile] [valOption] [zipFile] [pdfFile]
java -jar DUKIntegrator.jar [-c configPath] -s <tip> <xml> [errFile] [valOption] [zipFile] [pdfFile] <pin> <smartCard> [certSelector]
tip— the form name exactly as the validator jar prefix (D300,D112, …).errFile— a clean run contains literallyok; otherwise, lines prefixedE:(errors) /W:(warnings) /A:(atentionare — informational notices) — the SAF-T validators (D406/D406T) also emitF:(structure/fatal) — each followed by indented detail lines (eroare regula: R25: …,eroare atribut: …). Judgment:E:/F:findings are blocking; a run whose only findings areW:/A:is a pass — some forms never write a bareok(D700 always emits anA:"prelucrat la organul fiscal competent" notice on a valid document), so warnings-only must not be read as failure. An empty or unrecognized err file is a failure (a broken/mis-versioned dist leaves one behind) — never infer success from output you cannot parse. A clean-run success is also printed to stdout asValidare fara erori fisier: <path>.- The exit code is
0either way — judge success by the err-file content, never by the exit code, and never parse stdout except as debug info. valOptiondefaults to0; it is form-specific.zipFile—0when the form has no attachment (true for D300).-pwrites the official multi-page PDF with the XML as an embedded file (/EmbeddedFilespresent). Proven: a 4-page, ~25 KB D300.-cexpects the config directory (live-probed 2026-07-26):-c dist/configworks — as does any existing directory, even one with noconfig.propertiesin it — while pointing-cat theconfig.propertiesfile, or at a nonexistent path, makes DUK exit silently (exit0, no output, no err file; the same shape as the update-check silent exit).- The working directory is free (live-probed 2026-07-26):
-vruns correctly from any cwd —lib/resolves relative to the jar, not the cwd (proven for D300 and the SAF-T D406T validator), and novalidator.logis written on clean or findings-level runs (it appears only in crash modes, in the cwd). -sis not used on macOS (see §5); on Windows it is the candidate signer withalgorithm=mscapi(deferred).
anafpy always fills every positional explicitly (it passes temp paths, so no $
is needed), prefixed with a per-run -c: -c <run-config-dir> -v <form> <xml>
<err> <option> and -c <run-config-dir> -p <form> <xml> <err> <option> 0
<pdf>, where the run config dir carries the dist's settings with offLine=Y
forced (§1.1). Every run executes from its own temp directory (concurrent runs
share no writable state; a crash-mode validator.log lands there and is folded
into the diagnostics) with -Dfile.encoding=UTF-8, so the err file's encoding
is deterministic; the -p PDF is staged in the temp dir and moved to the
caller's path only on success.
3. D300 wire format and nr_evid¶
- XSD:
https://static.anaf.ro/static/10/Anaf/Declaratii_R/AplicatiiDec/d300_v12_11022026.xml(despite the extension it is the XSD). Rootdeclaratie300, namespacemfp:anaf:dgti:d300:declaratie:v12; everything is an attribute on the one element, no children. Per-form pages underhttps://static.anaf.ro/static/10/Anaf/Declaratii_R/<nnn>.htmlpublish the XSD and the validation annex PDF. - The XSD is the authoring template. DUKIntegrator does not generate templates or skeleton XML (verified against the jar, 2026-07-15; its surface is validate / render / sign only). Author the XML from the XSD.
nr_evid layout¶
nr_evid ("numărul de evidență a plății") is a required 23-character field,
decoded from the validator bytecode (D300Validator.jar v10 = XSD v12) and
confirmed against the annex example 10301010111250211000020 and a live -v
acceptance (2026-07-15):
| positions | content |
|---|---|
[0:2] |
fixed 10 |
[2:5] |
cod_imp, correlated with tip_decont: 301=L (monthly), 302=T (quarterly), 303=S, 304=A |
[5:7] |
fixed 01 |
[7:11] |
reporting period MMYY (zero-padded luna + last 2 of an) |
[11:17] |
payment due date 25 + MM (luna+1, wrapping into the next year) + YY |
[17:21] |
fixed 0000 |
[21:23] |
check: the two-digit sum of the first 21 digits |
Example: 06/2026 monthly → 10 301 01 0626 250726 0000 42. anafpy
computes this as a pure function (declaratii.payment_evidence_number); the model
never computes the check digit. Sums here are always < 100 (no modulo).
4. Iterating a document¶
Iterating a nil D300 with -v from scratch took five rounds in the spike; the
validator messages are precise enough (rule ids, attribute names) to converge a
model without any other documentation. A validated nil D300 (06/2026):
<?xml version="1.0" encoding="UTF-8"?>
<declaratie300 xmlns="mfp:anaf:dgti:d300:declaratie:v12"
luna="6" an="2026" depusReprezentant="0" bifa_interne="1" temei="0"
nume_declar="Popescu" prenume_declar="Ion" functie_declar="Administrator"
cui="12345674" den="TEST SPIKE SRL" adresa="Str. Exemplu nr. 1, Bucuresti"
banca="-" cont="-" caen="6201" tip_decont="L" pro_rata="100.0"
bifa_cereale="N" bifa_mob="N" bifa_disp="N" bifa_cons="N"
solicit_ramb="N" nr_evid="10301010626250726000042" totalPlata_A="0"/>
Notes: Str_listaDaNuSType admits 1|D|N; tip_decont admits 1|L|T|S|A; the
Romanian CUI check digit uses key 753217532 (sum of digit-products, ×10 mod 11,
10→0). The CUI comes from the user and DUK validates it, so anafpy needs no CUI
helper.
5. Signing (platform findings)¶
5.1 macOS — the CryptoTokenKit finding¶
- certSIGN Paperless vToken on macOS is a CryptoTokenKit extension
(
ro.certsign.vtoken.ctke; visible viasecurity list-smartcards). There is no PKCS#11 dylib, so DUK'ssunpkcs11path can never work on macOS, andmscapiis Windows-only. The key is reachable only through Security.framework, and CPython'ssslcannot present a non-exportable platform-store key. - Working pipeline (proven end-to-end; signature validated
intact=True, valid=True, coverageENTIRE_FILE,/EmbeddedFilespreserved): DUK-prenders → pyHanko embeds a standardadbe.pkcs7.detachedCMS as an incremental update, where the raw RSA PKCS#1 v1.5 SHA-256 operation isSecKeyCreateSignatureon the Keychain identity. Each raw signature fires the vToken phone approval — that is the human gate; no PIN or secret passes through anafpy's code. - Certificate chain: leaf via
SecIdentityCopyCertificate; the intermediate via the leaf's AIA URL (live:http://crl.certsign.ro/certsign-qualifiedca2023rsa.crt). - The portal accepts this signature (confirmed 2026-07-17): a D406T signed through this exact pipeline was filed on the WAS6DUS upload portal and answered with the success page + upload index — see the portal-upload reference §4/§5. (Acceptance of a leaf-only CMS — the AIA-fetch-failed fallback — remains unverified.)
- Why not DUK's own
-son macOS? The community setup (nokeect/duk-integrator-macos) does sign through DUK, by wiringsafeNet.cfg'slibrary=at a SafeNet PKCS#11 dylib (/usr/local/lib/libeTPkcs11.dylib). anafpy deliberately does not: that path is SafeNet-only (it needs a vendor.dylib— the certSIGN vToken above ships none), pins Java 8 (DUK's signing leans on removedsun.security.pkcs11internals, so Java 9+ breaks it — the reason that project pins Zulu 8), and would route the PIN through DUK's process. Our Security.framework path avoids all three and works for a CryptoTokenKit token that has no PKCS#11 module at all.
anafpy ports the raw-signing semantics to ctypes against Security.framework
(no build step, no new dependency) in anafpy.declaratii.signing. The proven
Swift reference program is preserved below as the semantic spec.
5.2 Windows — the certificate store¶
Code landed 2026-07-26; live verification on a real Windows box is pending, so everything here is the reasoned design, not a confirmed wire fact. Mark it confirmed only after a signature from an actual token.
- Same pipeline as macOS — DUK
-prenders, pyHanko embeds theadbe.pkcs7.detachedCMS — with the raw operation delegated toCert:\CurrentUser\My, the storeanafpy.spv.certsalready enumerates for SPV. The selector is the certificate's SHA-1 thumbprint (Schannel's cert-store syntax uses it too, so one selector serves both signing and the portal login). - The raw signature is
[Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($cert)followed by.SignData($bytes, SHA256, Pkcs1), run underpowershell.exe -NoProfile -NonInteractive. That one call covers both key kinds — a CNG/KSP key (RSACng) and a legacy CSP key (RSACryptoServiceProvider) — which is why anafpy binds noncrypt.dll. TheSignData(byte[], HashAlgorithmName, RSASignaturePadding)overload needs .NET Framework 4.6+, i.e. Windows PowerShell 5.1 as shipped. - The key stays non-exportable: signing happens inside the middleware, and
its PIN dialog is raised on the user's desktop — so this, like
spv login, is a host-side interactive step and cannot run headless. - Why not DUK
-swithmscapi? It would route the PIN through DUK's process, which the strand's first invariant forbids. (Itssunpkcs11sibling is separately dead on macOS — §5.1.) - Open questions for the live run: whether certSIGN's Windows vToken packaging registers a CSP/KSP that surfaces the key here at all; and whether the middleware's approval fits inside the 110 s signing budget.
Appendix A — proven raw-signer semantics (Swift reference)¶
Compiled and validated 2026-07-15 (swiftc -O; sign produced a 512-byte
RSA-4096 PKCS#1 v1.5 SHA-256 signature that openssl dgst -verify accepted; the
vToken phone approval fired per call).
// keychain-sign: raw RSA PKCS#1 v1.5 signing via a macOS Keychain/CTK identity.
// keychain-sign list
// keychain-sign cert <label> -> DER certificate to stdout
// keychain-sign sign <label> <sha256|sha1> < data-on-stdin -> raw signature
import Foundation
import Security
func fail(_ msg: String) -> Never {
FileHandle.standardError.write((msg + "\n").data(using: .utf8)!)
exit(1)
}
func findIdentity(label: String) -> SecIdentity {
let query: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecMatchLimit as String: kSecMatchLimitAll,
kSecReturnRef as String: true,
kSecReturnAttributes as String: true,
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let items = result as? [[String: Any]] else {
fail("identity query failed: \(status)")
}
for item in items {
let lbl = item[kSecAttrLabel as String] as? String ?? ""
if lbl == label {
return item[kSecValueRef as String] as! SecIdentity
}
}
fail("no identity labeled '\(label)'")
}
let args = CommandLine.arguments
guard args.count >= 2 else { fail("usage: keychain-sign list|cert|sign ...") }
switch args[1] {
case "cert":
let identity = findIdentity(label: args[2])
var certRef: SecCertificate?
guard SecIdentityCopyCertificate(identity, &certRef) == errSecSuccess,
let cert = certRef else { fail("cannot copy certificate") }
FileHandle.standardOutput.write(SecCertificateCopyData(cert) as Data)
case "sign":
let identity = findIdentity(label: args[2])
var keyRef: SecKey?
guard SecIdentityCopyPrivateKey(identity, &keyRef) == errSecSuccess,
let key = keyRef else { fail("cannot copy private key") }
let algorithm: SecKeyAlgorithm = args[3] == "sha1"
? .rsaSignatureMessagePKCS1v15SHA1 : .rsaSignatureMessagePKCS1v15SHA256
let data = FileHandle.standardInput.readDataToEndOfFile()
var error: Unmanaged<CFError>?
guard let sig = SecKeyCreateSignature(key, algorithm, data as CFData, &error)
else { fail("sign failed: \(error!.takeRetainedValue())") }
FileHandle.standardOutput.write(sig as Data)
default:
fail("unknown command \(args[1])")
}
pyHanko API notes (0.35.x)¶
SimpleCertificateStorelives inpyhanko_certvalidator.registry(notpyhanko.sign.general).- A custom signer subclasses
pyhanko.sign.signers.Signerand implementsasync_sign_raw(data, digest_algorithm, dry_run); ondry_run=Truereturn zero-bytes of the signature size (RSA key size / 8 — read it from the cert's public key, do not hardcode 512). validate_pdf_signaturecallsasyncio.runinternally; useasync_validate_pdf_signaturefrom inside an event loop.