zatbridge_zatca 0.2.0 copy "zatbridge_zatca: ^0.2.0" to clipboard
zatbridge_zatca: ^0.2.0 copied to clipboard

ZATCA Phase 1 QR generation and Phase 2 offline signing for simplified-subtype (B2C) documents. Produces compliant QR payloads entirely on-device.

zatbridge_zatca #

ZATCA Phase 1 QR generation and Phase 2 offline signing for simplified-subtype (B2C) documents.

This package is the client SDK for the commercial ZatBridge service. Using the package does not grant access to ZatBridge, ZATCA credentials, device enrollment, or hosted invoice services. A valid ZatBridge vendor account and one-time device enrollment are required for Phase 2 device signing only. Phase 1 QR generation is a local POS utility and requires neither enrollment nor network access.

Install #

flutter pub add zatbridge_zatca

A simplified invoice must carry a valid QR at the point of sale whether or not the till has connectivity. QR tags 6-9 are the invoice hash, the signature value, the certificate public key and the certificate signature, so producing them offline means running the whole signing chain on the device. That is what this package does; the document is then forwarded to ZatBridge for reporting when the network returns. Ordinary invoices, credit notes, debit notes, and prepayments are supported when their subtype is explicitly simplified.

Every standard-subtype document is out of scope: ZATCA must clear it before the QR exists, so standard invoices, credit notes, debit notes, and prepayments stay cloud-signed.

Status #

Implemented and verified against the shared corpus:

  • QR TLV encoding, including the per-tag codecs and UTF-8 byte lengths
  • invoice hash (base64(sha256(canonical form)))
  • W3C Canonical XML 1.1 with ZATCA's three digest exclusions
  • the XAdES SignedProperties digest encoding
  • the UBL 2.1 builder, verified by a full round trip against the Go builder's output
  • fixed-point monetary arithmetic (money is never a double here)
  • ECDSA over secp256k1, with deterministic RFC 6979 nonces
  • X.509 certificate field extraction straight from DER
  • XAdES SignedProperties, SignedInfo and the enveloped-signature UBLExtensions block
  • signInvoice, the full chain from unsigned UBL to a signed document plus its QR
  • one-time enrollment against ZatBridge with credentials sealed in platform secure storage
  • a SQLite-backed, single-writer ICV/PIH chain and durable invoice outbox
  • pre-signing structural, tax-category and fixed-point monetary validation
  • ordered forwarding to ZatBridge with idempotent retries and exponential backoff

The offline device workflow is complete. The complete invoice, credit note, debit note, and prepayment flow has been verified against ZATCA's developer portal with continuous ICV/PIH chaining. Issuing and revoking one-time enrollment codes remains an operator workflow in ZatBridge, not a responsibility of this package.

Usage #

Phase 1 QR #

Generate the Base64 TLV payload from the authoritative values on the printed invoice, then give that text to the QR widget or renderer already used by the POS application.

final qrPayload = generatePhase1QrCodeTlv(
  Phase1QrCodeInput(
    sellerName: 'Example Store',
    vatRegistration: '300000000000003',
    invoiceTimestamp: DateTime.now(),
    invoiceTotal: '115.00',
    vatTotal: '15.00',
  ),
);

The Phase 1 payload contains ZATCA QR tags 1-5 only: seller name, seller VAT registration number, issue timestamp, total including VAT, and total VAT. Amounts are decimal strings rather than double values so the QR carries the same legally rounded totals as the invoice. The timestamp is normalized to UTC and emitted with second precision. The package returns QR content, not a QR image or widget.

Phase 2 device signing #

final zatbridge = await ZatBridge.open(
  baseUrl: 'https://api.zatbridge.com',
);

// Once per device, while online.
await zatbridge.enroll(
  code: enrollmentCode,
  appFingerprint: installationFingerprint,
);

// Works with no network. The kind and subtype are independent and explicit.
final issued = await zatbridge.issueSimplified(InvoiceRequest.deviceSimplified(
  documentKind: DocumentKind.creditNote,
  billingReference: originalInvoiceNumber,
  instructionNote: 'Customer return',
  // ...the remaining invoice fields...
));
print(issued.qr);

// Optional explicit drain. A foreground timer also drains while the app runs.
final forwarded = await zatbridge.flush();
final status = await zatbridge.status();

issueSimplified accepts DocumentKind.invoice, creditNote, debitNote, and prepayment. InvoiceRequest.deviceSimplified fixes the subtype to simplified and leaves the UUID, ICV, PIH, and enrolled supplier identity for the device to assign. It validates, signs, advances ICV/PIH and inserts the signed document plus its explicit kind/subtype into the outbox in one SQLite write transaction. Credit and debit notes require a billing reference; an omitted instruction note receives the same default correction reason as the cloud UBL builder. Every standard-subtype combination is rejected before it consumes an ICV. It returns only after that transaction commits and never waits for the network. Concurrent callers are serialized by SQLite, including callers from different isolates or processes using the same database.

The forwarder submits in ICV order. Transport errors, rate limits and server failures stay queued with exponential backoff. A permanent validation or authentication rejection blocks later chain positions and is surfaced by status().permanentFailures, because skipping a rejected link would make the later PIH chain invalid.

The timer only runs while the host application is alive. The package does not claim that iOS or Android will keep arbitrary Dart work alive in the background. Durability is the guarantee: the outbox resumes on the next foreground start, and the host app can integrate flush() with its own platform-approved background task if needed.

The package fails closed when platform credentials survive an uninstall but the SQLite chain does not. It never restarts at ICV 1 or reuses the original enrollment seed. SQLite uses the native sqflite driver on Android, iOS and macOS, and the FFI driver on Windows and Linux. Web is intentionally unsupported because browser storage cannot provide the private-key custody and crash-safe chain guarantees this workflow requires.

Signing does not disturb what it signed #

The signature and the QR are added after the invoice digest is taken, and all three added subtrees are digest exclusions. Canonicalizing the signed document therefore has to reproduce the unsigned canonical form exactly, and a test asserts it. If that ever broke, every invoice the device issued would be self-inconsistent: the QR would carry a hash the document no longer has.

The SignedProperties digest is over a literal, not canonical XML #

ZATCA specifies the exact bytes, indentation included, and self-closes ds:DigestMethod, which C14N never emits. So it cannot be derived by canonicalizing the element; it is reproduced verbatim. The form embedded in the document differs from the digested form -- inside the document the ds prefix is already in scope, so it is not redeclared -- and using one where the other belongs produces a rejected document.

Signing is verified in both directions #

test/fixtures holds a throwaway secp256k1 key, a self-signed certificate, a fixed payload, and a signature over it produced by the platform's own vendored OpenSSL. The tests check that Dart verifies what OpenSSL signed, and a manual check confirms OpenSSL verifies what Dart signs. Certificate fields are pinned from signing.ExtractCertificateInfo itself via go test ./internal/signing -run TestFlutterCertificateFixture -update, so the two extractions cannot drift.

Nonces are deterministic (RFC 6979) rather than random. A till is exactly the environment where entropy is worth not depending on, a repeated nonce under ECDSA discloses the private key, and determinism makes a signature reproducible when diagnosing a rejected invoice.

The generated signatures have been accepted by ZATCA's developer portal from the production egress address for every supported simplified document kind.

The corpus is the contract #

In the source repository, test/corpus_test.dart reads the same signing corpus that the Go chain pins its values from and byte-compares against the canonical XML checked in beside it.

Reimplementing a compliance-critical signing chain in a second language is the risk offline signing accepts. This corpus is the control on it: if the two implementations ever disagree, the device would produce invoices ZATCA rejects, and without the corpus that would only surface in production.

test/ubl_builder_test.dart closes the loop: it builds each fixture from the corpus inputs, canonicalizes, and byte-compares against the canonical XML the Go builder produced from those same inputs. Element order is part of that contract, not a cosmetic detail -- the hash is a digest over these bytes, so swapping two siblings changes it.

Run both with flutter test from this directory. Both halves must stay green, and regenerating the corpus is a behaviour change that has to be justified in review.

Two things that are easy to get wrong #

The SignedProperties digest is double-encoded. It is base64(lowercase_hex(sha256(template))), not base64(sha256(template)). The decoded value is 64 ASCII characters, not 32 raw bytes.

The certificate digest hashes the base64 text, not the DER. It is base64(hex(sha256(base64(DER)))). Hashing the certificate bytes directly is the intuitive reading and is wrong.

The QR TLV codecs differ per tag. Tags 1-5 are raw UTF-8 text. Tags 6 and 7 carry the base64 characters themselves. Tag 8 is base64 decoded to bytes, and tag 9 is hex decoded to bytes. Mixing these up produces a QR that scans but fails ZATCA validation. Lengths are byte counts, not character counts, which matters for every Arabic name.

Canonicalization #

lib/src/core/ubl/c14n.dart is a real canonicalizer.

Published ZATCA packages commonly approximate this by trimming whitespace and splicing fixed indentation into the serialised string. That only yields correct hashes for XML that generator emitted at that exact indentation, and breaks as soon as the document shape shifts. The invoice hash is a digest over these bytes, so an approximation there means a document ZATCA rejects.

0
likes
150
points
18
downloads

Documentation

API reference

Publisher

verified publishersprdh.com

Weekly Downloads

ZATCA Phase 1 QR generation and Phase 2 offline signing for simplified-subtype (B2C) documents. Produces compliant QR payloads entirely on-device.

Homepage
Repository (GitHub)
View/report issues

Topics

#zatca #e-invoicing #offline #flutter #saudi-arabia

License

Apache-2.0 (license)

Dependencies

asn1lib, convert, crypto, flutter, flutter_secure_storage, http, path, pointycastle, sqflite, sqflite_common_ffi, uuid, xml

More

Packages that depend on zatbridge_zatca