Skip to content

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 legacy dist_javaInclus20200203.zip is never needed. (That zip remains the manual path: extract dist/ and ignore the bundled 32-bit JRE 6 — a modern JVM, proven on Oracle Java 26 / macOS arm64, runs -v/-p fine.) A minimal working dist is exactly: DUKIntegrator.jar at the root, lib/ with the three core jars + the three third-party jars + the per-form jar pairs, and config/.
  • Update feed: http://static.anaf.ro/static/10/Anaf/update5/versiuni.xml (also served over https — anafpy pins https and the static.anaf.ro host; the feed itself is unsigned) lists the current core jars and per-form jars (e.g. D300 at D300Validator.jar + D300Pdf.jar, version J12.0.1/P9.0.0). Per-form jars go into dist/lib/. The GUI mode auto-updates; the CLI mode does not — staleness must be surfaced (anafpy's declaratie_duk_status / DukIntegrator.feed_versions compare installed against the feed; declaratie_duk_install / anafpy duk update refresh).

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 + iJarslib/ (core + the third-party iText/BouncyCastle jars), cFisiereconfig/, 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 to validator.log, stdout only says cod eroare=-5 with an empty err file). Fix: update DUKIntegrator.jar (feed path zz9/) and lib/DecValidation.jar (feed path ss8/) to the feed's current versions.
  • A dist without its config/ folder makes the updated DUKIntegrator.jar exit silently (no output, exit 0) — carry config/ over when assembling a fresh dist.
  • D406 is in the update feed (D406_35/, J2.2.18); D406T is not — its jars (D406TValidator.jar, D406TPdf.jar) ship only inside the dedicated duk_SAFT distribution (duk_SAFT_20230216.zip is the newest observed; jars dated 2023, J2.0.6), dropped into lib/ like any per-form pair. Consequence: the staleness comparison (feed_versions) will never show D406T.
  • Form names and namespaces: -v D406 expects mfp:anaf:dgti:d406:declaratie:v1, -v D406T expects mfp: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) AnalysisTypeTable the current D406 no longer asks for.
  • The SAF-T validators write log files besides the err file (observed 2026-07-26): a verbose <xml>.log section trace next to the input XML on every run, and validator.log in the working directory on crashes (the NoClassDefFoundError mode 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 besides E:/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: MovementTypeTable and MovementOfGoods must be present but empty (their children answer maxOccurs=0), Products/Owners/Assets may be empty, SalesInvoices/PurchaseInvoices/Payments may be omitted (a present SalesInvoices demands a full Invoice).
  • Structure gotchas (from converging the minimal file, committed at tests/fixtures/declaratii/d406t-minimal.xml): partner identifiers (CustomerID/SupplierID/partner RegistrationNumber) need the 2-char ID-type prefix from the SAF-T nomenclature (00 + CUI for Romanian companies, check-digit-verified; 080000000000000 is the generic no-ID person code); Transaction and each TransactionLine require both CustomerID and SupplierID; every AmountStructure requires Amount + CurrencyCode + CurrencyAmount; each line requires TaxInformation; BaseRate is a fraction (1.00, not 100.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 its Line naming is the newer schema's — the v1 wire element is TransactionLine.

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 literally ok; otherwise, lines prefixed E: (errors) / W: (warnings) / A: (atentionare — informational notices) — the SAF-T validators (D406/D406T) also emit F: (structure/fatal) — each followed by indented detail lines (eroare regula: R25: …, eroare atribut: …). Judgment: E:/F: findings are blocking; a run whose only findings are W:/A: is a pass — some forms never write a bare ok (D700 always emits an A: "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 as Validare fara erori fisier: <path>.
  • The exit code is 0 either way — judge success by the err-file content, never by the exit code, and never parse stdout except as debug info.
  • valOption defaults to 0; it is form-specific.
  • zipFile0 when the form has no attachment (true for D300).
  • -p writes the official multi-page PDF with the XML as an embedded file (/EmbeddedFiles present). Proven: a 4-page, ~25 KB D300.
  • -c expects the config directory (live-probed 2026-07-26): -c dist/config works — as does any existing directory, even one with no config.properties in it — while pointing -c at the config.properties file, or at a nonexistent path, makes DUK exit silently (exit 0, no output, no err file; the same shape as the update-check silent exit).
  • The working directory is free (live-probed 2026-07-26): -v runs correctly from any cwd — lib/ resolves relative to the jar, not the cwd (proven for D300 and the SAF-T D406T validator), and no validator.log is written on clean or findings-level runs (it appears only in crash modes, in the cwd).
  • -s is not used on macOS (see §5); on Windows it is the candidate signer with algorithm=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). Root declaratie300, namespace mfp:anaf:dgti:d300:declaratie:v12; everything is an attribute on the one element, no children. Per-form pages under https://static.anaf.ro/static/10/Anaf/Declaratii_R/<nnn>.html publish 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 via security list-smartcards). There is no PKCS#11 dylib, so DUK's sunpkcs11 path can never work on macOS, and mscapi is Windows-only. The key is reachable only through Security.framework, and CPython's ssl cannot present a non-exportable platform-store key.
  • Working pipeline (proven end-to-end; signature validated intact=True, valid=True, coverage ENTIRE_FILE, /EmbeddedFiles preserved): DUK -p renders → pyHanko embeds a standard adbe.pkcs7.detached CMS as an incremental update, where the raw RSA PKCS#1 v1.5 SHA-256 operation is SecKeyCreateSignature on 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 -s on macOS? The community setup (nokeect/duk-integrator-macos) does sign through DUK, by wiring safeNet.cfg's library= 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 removed sun.security.pkcs11 internals, 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 -p renders, pyHanko embeds the adbe.pkcs7.detached CMS — with the raw operation delegated to Cert:\CurrentUser\My, the store anafpy.spv.certs already 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 under powershell.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 no ncrypt.dll. The SignData(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 -s with mscapi? It would route the PIN through DUK's process, which the strand's first invariant forbids. (Its sunpkcs11 sibling 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)

  • SimpleCertificateStore lives in pyhanko_certvalidator.registry (not pyhanko.sign.general).
  • A custom signer subclasses pyhanko.sign.signers.Signer and implements async_sign_raw(data, digest_algorithm, dry_run); on dry_run=True return zero-bytes of the signature size (RSA key size / 8 — read it from the cert's public key, do not hardcode 512).
  • validate_pdf_signature calls asyncio.run internally; use async_validate_pdf_signature from inside an event loop.