[0.3.0] - 2026-04-30
Changed — Real-card support: GPO inline TLV + Track 2 fallbacks + TTQ default (#59)
Architectural fix for Visa qVSDC kernel-3 cards (Chase Credit + Debit observed). Captured the real-card APDU traffic via diagnostic logging and corrected the model of what EmvParser parses.
Breaking changes (pre-1.0):
- Removed
GpoError.MissingAfl. Format-2 GPO responses (template77) without an AFL tag are now accepted — the card may be in MSD-only mode and deliver application data inline.Gpo.afl.entriesis empty in this case. EMV Book 3 §10 / EMVCo Book C-3 (Visa kernel-3) §5.4.3 treat AFL as optional when the application data is inline. GpoexposesinlineTlv: List<Tlv>carrying the format-2 application-data children (everything except AIP and AFL). Format-1 (80) returnsemptyList(). Additive to the public API.- New
EmvParser.parse(aid: Aid, tlvNodes: List<Tlv>): EmvCardResultoverload (@JvmName("parseTlv")to disambiguate from theList<ByteArray>overload at the JVM level) — the canonical TLV-native entry point.parseOrThrow(aid, tlvNodes)mirror also added. The existingparse(aid, apduResponses: List<ByteArray>)overload still works (decodes then delegates).
Reader behavior changes:
ContactlessReader(Android) now decodes each AFL READ RECORD body to TLV nodes once, concatenates withgpo.inlineTlv, and calls the newEmvParser.parse(aid, nodes)overload. Cards that ship Track 2 / cardholder name / dates inline in the GPO body — common on Visa qVSDC kernel-3 — now succeed even when their AFL records are empty or carry only supplementary tags (like5F 28Issuer Country,9F 07Application Usage Control).EmvParsernow falls back to Track 2's embedded PAN when standalone tag5Ais absent (canonical per IDTECH KB and EMV Book 3 record layout). Symmetrically, falls back to Track 2's embedded expiry when standalone tag5F 24is absent (canonical per ISO 7813 and EMV Book 3). Track 2 is pre-parsed once per call so required-field resolution and optional-field exposure see byte-identical data. New private sealed typesPanOutcome,ExpiryOutcome,Track2Outcomekeep the dispatch flag-free per CLAUDE.md §3.2.TerminalConfig.default()TTQ default lowered from36 00 80 00to36 00 00 00. The "Online cryptogram required" bit (byte 2,0x80) is now cleared so issuer kernels emit the GPO response shape that read-only readers need. iOSTerminalConfig.defaultmirrors the new value. Override viaTerminalConfig.default().copy(terminalTransactionQualifiers = ...)if a specific issuer kernel needs the legacy bit set.
Real-card regression coverage:
- Sanitized
:android:readerintegration tests for the Visa Credit Chase MSD-only flow (no AFL, all data inline) and the Visa Debit Chase split flow (inline57+ supplementary AFL record with no PAN-bearing tags). - Post-review remediation:
Gpo.inlineTlvfilter curated to exclude single-use cryptographic tags (9F26ARQC,9F27CID,9F10IAD,9F36ATC,9F4BSDAD) perdocs/threat-model.md§19. Real-card transcript fixtures sanitized to drop captured ARQC + IAD bytes. iOSEmvReadermirrored the Android union-of-TLV-sources refactor (parity per CLAUDE.md §8). Test discipline:pan.unmasked()assertions converted toPanvalue-class equality so failure rendering masks;parse(aid, tlvNodes)overload gained property/fuzz coverage (1000 random-input rounds);5A-precedence test rewritten with divergent canonical Visa test PANs. Doc hygiene: stale class/method KDocs refreshed acrossGpo,SelectAidFci,ContactlessReader.9F11Issuer Code Table Index deferral documented inextractApplicationLabel+ follow-up issue #61 filed. DRY: sharedfirstChildByTaghelper extracted toextract/internal/TlvSearch.kt.
Added — PDOL-aware GPO + EmvParser AID injection (#57)
- Reader now reads tag
9F38from the SELECT AID FCI response, parses the DOL format, builds a structurally-valid PDOL response from terminal defaults (TTQ, country, currency, date, type, UN), wraps in83 [Lc] [response], and sends it as the GPO command body. Cards return records instead of6A 80/69 85. - New 2-arg overload
EmvParser.parse(aid: Aid, records: List<ByteArray>)(and the matchingparseOrThrow). Real cards put4F(AID) in PPSE / SELECT AID FCI but NOT in READ RECORD records — the reader passes the PPSE-extracted AID directly. Existing 1-argparse(records)keeps its behavior for v0.1.x synthetic fixtures. - New
:shared:commonMainpublic types:Pdol+PdolEntry+PdolError+PdolResult,SelectAidFci+SelectAidFciError+SelectAidFciResult,TerminalConfig(withCompanion.default()),PdolResponseBuilder. All follow the establishedparse/parseOrThrowmirror pattern. EmvTagsdictionary extended with9F38,9F66,9F1A,95,9A,9C,9F33,9F35,9F40,9F09entries.:android:readernew public API:ContactlessReader.read(config: TerminalConfig)overload (the no-argread()delegates toread(TerminalConfig.default())). Newinternal ApduCommands.gpoCommand(pdolResponse)builder. Two newReaderErrorvariants:SelectAidFciRejected(cause)andPdolRejected(cause).ios/Sources/EmvReadermirrors the Android refactor: parallel SwiftTerminalConfigstruct withstatic let default, newMappingbridges (parsePdol,parseSelectAidFci,buildPdolResponse,parseEmvCard(aid:records:)), refactoredEmvReader.read(config:)overload, two newReaderErrorcases (selectAidFciRejected,pdolRejected).composeAppErrorPanelupdated with friendly messages for the two new variants.- TTQ default
36 00 80 00per javaemvreader / nfc-frog reference implementations. Country / currency US/USD per ISO 3166-1 / 4217 numeric0840. Amounts zero (read-only flow does not commit a transaction). Transaction date and unpredictable number computed at READ time so a long-livedTerminalConfigdoes not ship stale values. - ABI gate regenerated for the additive surface (4 new public types + 1 new
EmvParser.parseoverload + tag dict additions). Determinism verified. - Test coverage delta:
:shared:allTests+49 (PdolTest, SelectAidFciTest, TerminalConfigTest, PdolResponseBuilderTest, EmvTagsTest, plus 5 new EmvParserTest cases).:android:reader:check+6 (PDOL-flow Visa, AID-injection records-without-4F, SelectAidFci/PDOL rejection, custom TTQ override, Mastercard back-compat).EmvReaderTests+5 (Swift mirror). - Module boundaries preserved per CLAUDE.md §7: only
NFCISO7816TagTransport.swiftimports CoreNFC; new bridges live inMapping.swift. Out-of-scope per CLAUDE.md §1: ARQC, DDA/CDA, online auth, GENERATE AC, kernel certification.
Added — iOS contactless reader (#50)
- New
ios/Sources/EmvReaderSwift Package providingEmvReader().read() -> AsyncStream<ReaderState>. Wraps CoreNFC'sNFCTagReaderSession+NFCISO7816Tagand orchestrates the full EMV contactless read flow per Book 1 §11–12: PPSE → SELECT AID → GPO → READ RECORD →EmvParser.parse. Mirrors the v0.2.0 Android reader (#48), but as Swift-idiomatic enums. ReaderStateandReaderErrordefined as parallel Swift enums (decision: NOT promoted tocommonMain). Manual mapping at the Kotlin/Swift boundary lives in a singleMapping.swift. KeptcommonMainplatform-neutral and gives iOS consumers idiomaticswitchexhaustiveness on Swift-native types.IoReasonenum mirrors the Android reader's:tagLost/timeout/generic. CoreNFC'sNFCReaderErrorcodes are mapped to these categories so consumers don't depend on CoreNFC error semantics.- New
XCFramework("Shared")Gradle wiring inshared/build.gradle.ktsproducesShared.xcframeworkfor the Swift package'sbinaryTargetto consume. New tasks:assembleSharedReleaseXCFramework,assembleSharedDebugXCFramework,assembleSharedXCFramework. - CI:
iosjob now builds the XCFramework and runsxcodebuild testfor the EmvReader Swift package on the iPhone simulator before the sample-app xcodebuild. - Module boundary verified per CLAUDE.md §7:
EmvReaderdepends only onShared(XCFramework) andCoreNFC. The ONLY file importingCoreNFCisNFCISO7816TagTransport.swift(mirrors:android:reader's "onlyIsoDepTransport.ktimportsandroid.nfc.*" rule). Does NOT depend on UIKit, SwiftUI, oriosApp/. - Sample-app integration is out of scope for this PR —
iosApp/is untouched. Sample integration tracked as a separate scope per CLAUDE.md §2 architecture (ios/Sources/EmvToolkitUI). - Test coverage: 14 integration tests in
EmvReaderTests(Visa / Mastercard / Amex happy paths plus everyReaderErrorvariant includingioFailure(.generic), multi-AID lowest-priority selection, silent-skip on non-9000 READ RECORD, and realTaskcancellation). Tests run against aFakeIso7816Transporttest double; iOS Simulator does NOT support CoreNFC, so the productionNFCISO7816TagTransportis exercised only on real-device manual QA. - Public ABI surface change:
:shared:checkKotlinAbiUNCHANGED — this PR consumes:shared's public surface, does not modify it. - Bridge note: Kotlin
@JvmInline value classtypes (Aid,Pan) cannot expose methods through the ObjC interop bridge — they appear as boxedAnyto Swift. Theirdescription(KotlintoString()) is the only accessible method. Swift consumers obtain the AID hex form viaString(describing: card.aid); raw bytes are reconstructed by hex-decoding the description inMapping.aidBytes(_:). Documented in CONTRIBUTING.md. - Post-review fixes from PR #51 multi-agent review:
- Concurrency hardening:
NFCISO7816TagTransportnow holdslockacross ALLsessionandconnectedTagaccesses (was missing thesessionwrite from the main-queue dispatch and theconnectedTagread intransceive). Two data races closed.transceivewith a closed session now throwsTransportError.io(.generic)instead of.timeout(the previous mapping was misleading — session was torn down externally, not a CoreNFC timeout). - API correctness: Removed dead
ReaderError.noApplicationSelectedvariant — unreachable given currentPpse.parsebehavior (returnsErr(NoApplicationsFound)before constructing an empty Ppse).ApduCommands.selectAidandreadRecordboundary checks converted fromprecondition()tothrows ApduCommandErrorso they survive release-mode optimization (CLAUDE.md §3.1); call sites usetry!with// why:comments because AFL/PPSE parsers already validate the inputs. - Test discipline: Direct unit tests for
ApduCommandsboundary conditions (mirrors Android #49'sApduCommandsTest). AddedIoFailure(.tagLost)andIoFailure(.timeout)terminal-state tests. Cancellation test now uses deterministic continuation-basedwaitForClose()instead ofTask.sleep(200ms). Added Swift PCI-safety regression tests pinning thatString(describing:)ANDdump()ofReaderState.done(card)do not leak raw PAN. All three brand happy-paths now asserttransport.closed. - Doc + lint: Amex AID corrected to
A000000025010701(ExpressPay) in README. CI workflow'sxcodebuild | xcprettypipe now usesset -o pipefailso xcodebuild failure propagates. Cancellation-during-connect limitation documented inEmvReader.read()DocC.// why:comments added scopingaidBytes/aidHextoAidonly (forbidding pattern reuse forPan/Track2).
- Concurrency hardening:
Added — Android contactless reader (#48)
- New
:android:readerGradle module providingContactlessReader.fromTag(tag).read()returning aFlow<ReaderState>. Wrapsandroid.nfc.tech.IsoDepand orchestrates the full EMV contactless read flow (PPSE → SELECT AID → GPO → READ RECORD →EmvParser.parse) per EMV Book 1 §11–12. ReaderStatesealed catalogue:TagDetected,SelectingPpse,SelectingAid(aid),ReadingRecords,Done(card),Failed(error).ReaderErrorsealed catalogue:TagLost,IoFailure(cause),PpseUnsupported,NoApplicationSelected,ApduStatusError(sw1, sw2),PpseRejected(cause),GpoRejected(cause),AflRejected(cause),ParseFailed(cause).- Three new pure-fn parsers in
:shared:commonMainconsumed by the reader (and the future iOS reader):Ppse.parse,Gpo.parse(handles tag-80format-1 and tag-77format-2),Afl.parse. All follow the establishedparse/parseOrThrowmirror pattern with sealed*Resultand*Errortypes. - New
Aid.toBytes()helper on the existing value class so reader code can buildSELECTAPDUs without re-parsing the hex form. - Module boundary verified per CLAUDE.md §7:
:android:readerdepends on:shared+kotlinx-coroutines-core 1.10.2only. Does NOT depend on Compose, sample apps, or iOS code. The only file in the module importingandroid.nfc.*isIsoDepTransport.kt. - Cancellation: collector cancellation closes the
IsoDepchannel viaonCompletion. APDU exchanges run onDispatchers.IO; collector can run on Main. - Testability:
ContactlessReader's production constructor is internal;fromTag(Tag)factory builds the production reader, while tests inject aFakeApduTransportagainst an internalApduTransportinterface. - Test coverage: 12 integration tests in
ContactlessReaderTest(Visa / Mastercard / Amex happy paths plus everyReaderErrorvariant plus cancellation), 38 unit tests across the three new:sharedparsers (12 each + property fuzz × 1000 random inputs each). - Public ABI surface change:
:shared:checkKotlinAbiregenerated (+662 lines acrossshared/api/android/shared.apiandshared/api/shared.klib.api). ABI gate now pins the additive surface. - Post-review fixes from PR #49 multi-agent review:
- Spec correctness:
Ppsenow decodes the Application Priority Indicator's low nibble per EMV Book 1 §12.2.3 (was decoding the full byte; cards sending0x81for priority 1 with cardholder-confirmation flag were ranked LAST instead of FIRST).ApduCommands.readRecordnow rejects record number 255 (RFU per ISO/IEC 7816-4 §7.3.3); valid range tightened to 1..254. - API correctness:
ReaderError.AflRejectedremoved (dead code — AFL failures wrap asGpoError.AflRejected → ReaderError.GpoRejected).ReaderError.IoFailure(Throwable)narrowed toIoFailure(reason: IoReason)enum (TagLost/Timeout/Generic) sotoStringcannot leak whatever the underlying exception's message carries;ReaderError.TagLostfolded intoIoFailure(IoReason.TagLost).Gpo.applicationInterchangeProfilereturns a fresh defensive copy per access (was reference-mutable).Gpois no longer adata class(hand-rolledequals/hashCode/toString; mirrors theTlv.Primitivepattern).ApduCommands.PPSE_SELECTandGPO_DEFAULTare now defensive-copyget()accessors (mirrorsFixtures.kt). - Lint enforcement:
detekt.yml'sForbiddenMethodCall.includesnow covers**/android/reader/src/main/**, so the project'sprintln/Log.*/Thread.sleep/runBlockingban applies to the new reader module. - Test discipline: direct happy + error-path tests for
Aid.toBytes()(4 new tests). Terminal-state tests forReaderError.PpseRejected(malformed FCI + no-applications),ReaderError.GpoRejected, plus aSocketTimeoutException → IoReason.Timeoutmapping.read silently skips a non-9000 READ RECORD and continues with the nextpins the AFL silent-skip contract. Cancellation test now usescancelAndJoin()against a structured-coroutine path (wastake(2).toList()which only triggers normal terminal completion). Property-fuzz tests inAflTest/GpoTest/PpseTestnow assert structural invariants on theOkbranch (SFI range, AIP byte-count, priority nibble bounds) instead of the tautological sealed-type check. Multi-AID test renamed to clarify "lowest priority value wins". Three new:android:readertests forApduCommands.readRecordboundary + defensive-copy. - ABI gate:
:shared:checkKotlinAbiregenerated for theGposhape change (componentN/copy removed; hand-rolled equals/hashCode/toString preserved). Determinism verified.
- Spec correctness:
Added — Dokka API site (#12)
- Dokka 2.0.0 Gradle plugin applied to
:shared. Output atdocs/api/kotlin/(gitignored — regenerated by CI on each release tag). - New
.github/workflows/dokka.ymlpublishes to GitHub Pages on everyv*tag push and on manualworkflow_dispatch. Source: "GitHub Actions" (the modern Pages mode; notgh-pagesbranch). - Site URL:
https://a7asoft.github.io/nfc-emv-toolkit/. - README "API Docs" section links to the published site and documents local generation (
./gradlew :shared:dokkaGenerate). - Implementation note: Dokka 2 ships in V1-compatibility mode by default; opted into the V2 DSL via
org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabledingradle.propertiesso the moderndokka { dokkaPublications.html { ... } }extension is available. composeApp is intentionally excluded — sample app, not a public library surface. Multi-module aggregation can be added later if needed. - Manual settings to flip post-merge (NOT in this PR — UI-only):
- Settings → Pages → Source: GitHub Actions (the modern artifact-based mode).
- First deploy: trigger via Actions tab → Dokka workflow → "Run workflow" (since no
v*tags exist yet).
Added — APDU replay fixtures (#9)
extract/fixtures/Fixtures.kt(test-only): three sanitized synthetic APDUREAD RECORDresponse fixtures —VISA_CLASSIC,MASTERCARD_PAYPASS,AMEX_EXPRESSPAY. Each carries the six contactless tags (4F,5A,5F24,5F20,50,57) wrapped in a70template per EMV Book 3 §10.5.4. Test PANs from public test ranges (Stripe / Adyen / industry-standard) — Luhn-valid but not real accounts. ARQC, IAD, and other cryptographic fields are absent.extract/fixtures/FixtureExpectation.kt:data classcapturing expected per-fixture parse outcome (PAN, brand, AID, expiry, cardholder, label, Track 2 components).extract/fixtures/EmvParserFixturesTest.kt: 3 integration-pin tests, one per brand, each driving the fixture throughEmvParser.parseand asserting every field of the resultingEmvCard(PAN, expiry, cardholder, brand, application label, AID + Track 2 PAN/expiry/service code) in a single integration pin. Mirrors the pre-existing per-fixture integration pattern inEmvParserTest.- Implementation note: chose constants-in-source over
.apdutext files (the issue's literal request) to avoid KMPcommonTestresource-loading friction (no new dep, single source of truth, matches existing project test pattern). Rich KDoc on each constant carries the human-legible content an.apdufile would have provided. - Amex AID note: the fixture targets
A000000025010701(Amex ExpressPay, the registered EMVCo contactless application — 8 bytes), not the 6-byteA00000002501stub originally listed in the plan. The 6-byte stub was explicitly dropped fromAidDirectoryin PR #28; ExpressPay is the contactless EMVCo-registered AID and the right brand-detection path for this corpus.
Added — Security disclosure path + Dependabot (#36)
SECURITY.mdat repo root documents the responsible-disclosure path: GitHub Private Vulnerability Reporting primary,a7asoft@gmail.comfallback. Supported versions table covers the latest minor onmain. SLO: first-touch in 5 business days, triage in 10, fix-and-advisory within 90 days..github/dependabot.ymlconfigures weekly version updates ongradle(root, picks uplibs.versions.toml,shared, andcomposeApp) andgithub-actionsecosystems. Minor + patch grouped per ecosystem; majors open as individual PRs. Security updates grouped separately. Maintainer auto-assigned per CODEOWNERS.CONTRIBUTING.mdcross-referencesSECURITY.mdso contributors know where to route a sensitive finding.README.mdadds a "Security" section linking toSECURITY.mdanddocs/threat-model.md, and lists the CI gates that protect PCI-sensitive surfaces (ABI gate,ForbiddenMethodCall, PCI-safety regressions).- Repository settings to flip post-merge (NOT in this PR — UI-only):
- Settings → Code security → Private vulnerability reporting → Enable.
- Settings → Code security → Dependabot alerts → Enable.
- Settings → Code security → Dependabot security updates → Enable.
- Settings → Code security → Code scanning is intentionally deferred to issue #37.
Added — ABI validation gate (#11)
- Kotlin's built-in
AbiValidationExtension(since Kotlin 2.1) is enabled on:shared. Reference dumps live undershared/api/android/shared.api(Android target) andshared/api/shared.klib.api(KLIB ABI; the dump tool unifies Native targets when their ABIs match — per-target files appear undershared/api/klib/<target>/if they diverge). - CI runs
./gradlew :shared:checkKotlinAbion every PR via the macOSkmpjob. Public-API drift fails the build. CONTRIBUTING.mddocuments the workflow: any PR touching public symbols oncommonMain,androidMain, oriosMainmust run./gradlew :shared:updateKotlinAbiand commit the regenerated dump.- Implementation note: chose the built-in
AbiValidationExtensionover the standaloneorg.jetbrains.kotlinx.binary-compatibility-validatornamed in issue #11 because the built-in handles KMP KLIB ABI per target without experimental flags and is already on the Kotlin Gradle plugin classpath. Functionally equivalent for the issue's intent.
Added — EmvCard model + EmvParser entry point (#8)
EmvCarddata class composing every parser shipped so far:pan: Pan,expiry: YearMonth(kotlinx.datetime),cardholderName: String?,brand: EmvBrand,applicationLabel: String?,track2: Track2?,aid: Aid.EmvParser.parse(apduResponses: List<ByteArray>): EmvCardResult(sealedOk/Err(EmvCardError)) andEmvParser.parseOrThrowfor the throw form. MirrorsPan.parse/parseOrThrowandTrack2.parse/parseOrThrow.EmvCardErrorsealed catalogue:EmptyInput,TlvDecodeFailed(cause: TlvError),MissingRequiredTag(tagHex),PanRejected(cause: PanError),Track2Rejected(cause: Track2Error),InvalidExpiryFormat(nibbleCount),InvalidExpiryMonth(month),InvalidAid(byteCount). Errors carry only structural metadata; raw bytes never appear.- Required tags (
4F,5A,5F24) and optional tags (5F20,50,57) are extracted via per-format helpers inextract/internal/; PAN segment delegates toPan.parse, Track 2 delegates toTrack2.parse, brand resolution delegates toBrandResolver.resolveBrand. TLV decoding usesStrictness.Lenientso cards that emit non-minimal length encodings still parse. EmvCard.toStringoverrides the data class default to mask sensitive fields: PAN throughPan.toString, Track 2 throughTrack2.toString, and cardholder name as a length-only placeholder (<N chars>). DirectcardholderNameaccessor still returns the raw String — caller MUST NOT log the EmvCard whole. A futureCardholderNamevalue-class wrapper is tracked separately.- Tag
5APAN BCD validation: malformed nibbles (0xA..0xEin any position;0xFin a non-trailing position) surface as the typedEmvCardError.MalformedPanNibble(offset)BEFORE reachingPan.parse, so the diagnostic reports the actual offending nibble offset rather than a derived character index. - Tags
5F20and50decode as ISO-8859-1 (was UTF-8); cardholder names likeMÜLLER(Latin-10xDC) round-trip correctly. extractAidvalidates byte length BEFORE copying the value, eliminating the wasted defensive copy on the error path.- 1,000-iteration deterministic property fuzz pinning the parser invariant: every
List<ByteArray>resolves to a typedEmvCardResult, with no other exception escaping (mirrorsTlvDecoderFuzzTestandTrackEncoderFuzzTest). - Multi-APDU integration test:
EmvParser.parsecorrectly merges fields across multipleByteArrayresponses. CENTURY_OFFSET = 2000two-digit-year mapping is documented as a deliberate v0.1.x deviation from EMV (which leaves century interpretation to the kernel) and pinned by regression tests at YY=00 and YY=99.EmvParserKDoc explicitly enumerates out-of-scope behaviors: PSE/PPSE flow, multi-AID with87priority,9F6BMastercard Track 2 fallback, PAN agreement between5Aand57, caller-overridableStrictness. Each is tracked separately for v0.2.x or later.
Added — BER-TLV decoder (#1)
Tagvalue class (@JvmInline) backed by a packedLong. Supports 1–4 byte tags per ISO/IEC 8825-1.TagClassenum (universal / application / context-specific / private).Tlvsealed type withPrimitiveandConstructedvariants (regular classes, not data classes — auto-generated members cannot accidentally expose value bytes). Hand-rolledequals/hashCode/toString.toStringomits value bytes (tag + length / child count only) for PCI-safe logging.TlvOptionswithStrictness(Strict / Lenient sealed) andPaddingPolicy(Tolerated / Rejected sealed) instead of boolean flags, plusmaxTagBytesandmaxDepthbounds.TlvErrorsealed catalogue:UnexpectedEof,IndefiniteLengthForbidden,InvalidLengthOctet,IncompleteTag,TagTooLong,NonMinimalTagEncoding,NonMinimalLengthEncoding,ChildrenLengthMismatch,MaxDepthExceeded. Every variant carries an offset; none embed value bytes.TlvParseResult(sealedOk/Err) andTlvParseExceptionfor the two API styles.TlvDecoder.parse(returns sealed result) andTlvDecoder.parseOrThrow(throws on first violation). Both honor the same option set.- 116 tests on
commonMain: happy paths for primitive / constructed / nested, every error variant, EMV padding behavior, documented X.690 deviation cases (e.g.9F02,BF0C), 10,000-iteration deterministic fuzz, OOM-resistance regression with pinnedUnexpectedEof, PCI-safety regressions for tags5A/57/9F26with exact-formtoStringassertions.
Added — BER-TLV encoder (#2)
TlvEncoder.encode(node: Tlv): ByteArrayandTlvEncoder.encode(nodes: List<Tlv>): ByteArray— the only public surface, mirrorsTlvDecoder.- DER-canonical output: definite length, minimal length octets. No options.
- Two-pass exact-allocation strategy: one pass computes the size, second pass fills a single pre-allocated
ByteArray. No intermediate buffers. - Tag bytes are preserved verbatim from the source
Tlvtree, so EMV deviations like9F02andBF0Cround-trip byte-for-byte at the tag level. - Defense in depth: hardcoded
MAX_DEPTH = 64guard mirrorsTlvOptions.maxDepthupper bound; trees deeper than that surface asIllegalStateException. - Round-trip invariant: for every input accepted by the decoder,
decode(encode(parsed.tlvs)) == parsed.tlvs. Pinned by deterministic 5,000-iteration fuzz and a fixture suite (FCI Visa, Track2, ARQC, nested depth 3, multi-primitive).
Removed before release
- An earlier draft included a
rejectTrailingBytesoption intended to catch APDU responses passed in with SW1 SW2 still attached. Removed because at the BER-TLV layer90 00decodes as a valid empty primitive, not as trailing bytes — SW detection belongs to the transport layer. - Wizard-generated scaffold (
Greeting.kt,Platform.kt,SharedCommonTest.example) cleaned up.
Added — Luhn validation (#7)
String.isValidLuhn()extension incommonMainpackageio.github.a7asoft.nfcemv.validationper ISO/IEC 7812-1 Annex B.- Predicate semantics: empty input, non-digit characters, embedded whitespace, and any non-
'0'..'9'codepoint all returnfalse. Length-agnostic (PAN length bounds belong toPanper #5). - Property-tested against a textbook reference implementation across 1,000 random digit strings.
Added — Pan value class (#5)
@JvmInline value class PanincommonMainpackageio.github.a7asoft.nfcemv.extract.- Construction goes through typed factories:
Pan.parse(raw)returns a sealedPanResult(Ok(Pan)orErr(PanError)), andPan.parseOrThrow(raw)throwsIllegalArgumentException— mirroring theTlvDecoder.parse/parseOrThrowpattern. The primary constructor isinternal, so external consumers cannot bypass validation. PanErrorsealed catalogue:LengthOutOfRange(length),NonDigitCharacters(no position reported, to avoid leaking corrupted-PAN structure),LuhnCheckFailed. Errors carry only structural metadata; raw digits never appear.- Validation order: length (12–19, ISO/IEC 7812-1 modern range) → ASCII-digits-only → Luhn / mod-10 (#7).
toStringreturns the PCI DSS Req 3.4 masked formBBBBBB*…NNNN(first 6 BIN + middle stars + last 4) on every length 12 through 19. Confirmed by exact-form snapshot tests at lengths 12, 13, 15, 16, 19, plus an explicit "raw never embedded" regression and an all-zeros sweep over 12..19.unmasked()is the only path back to the raw digit string and is documented as the PCI scope boundary.equals/hashCodeare auto-generated by the value class (Kotlin reserves overrides), use the raw form internally, and never expose it via stringification.
Added — Track2 parser (#6)
Track2regular class incommonMainpackageio.github.a7asoft.nfcemv.extract. Decodes EMV tag 57 / ISO 7813 Track 2 Equivalent Data from a BCD-packedByteArray.- Construction goes through
Track2.parse(raw): Track2Result(sealedOk/Err(Track2Error)) orTrack2.parseOrThrow(raw): Track2. MirrorsPan.parse/parseOrThrowandTlvDecoder.parse/parseOrThrow. Track2Errorsealed catalogue:EmptyInput,MissingSeparator,PanRejected(cause: PanError),ExpiryTooShort,InvalidExpiryMonth,ServiceCodeTooShort,MalformedBcdNibble(offset),MalformedFPadding. Errors carry only structural metadata; raw nibbles never appear.MalformedBcdNibblereports the offset of the actual offending nibble (after a single full-input pre-validation pass), andMalformedFPaddingfires whenever anFnibble appears anywhere other than the single trailing position.- 5,000-iteration deterministic property fuzz pinning the parser invariant: every input resolves to a typed
Track2Result, with no other exception escaping (mirrorsTlvDecoderFuzzTest). - Fields:
pan: Pan,expiry: YearMonth(2-digit year interpreted as 21st century),serviceCode: ServiceCode, plus a private discretionary segment exposed only viaunmaskedDiscretionary(): StringanddiscretionaryLength: Int. toStringmasks the PAN (viaPan.toString) and the discretionary (size only).equals/hashCodeare hand-rolled (NOT adata class) so auto-generatedcomponentN/copycannot leak the discretionary by destructuring.ServiceCodeis a@JvmInline value classvalidating exactly three ASCII digits; service codes are categorical metadata, not PCI data, sotoStringreturns the raw form.- New dependency:
org.jetbrains.kotlinx:kotlinx-datetime(commonMain).
Added — AID directory + brand resolution (#4)
- New package
io.github.a7asoft.nfcemv.brandwithAidvalue type,EmvBrandenum (10 variants — Visa, Mastercard, Maestro, American Express, Discover, Diners Club, JCB, UnionPay, Interac, Unknown),AidDirectorystatic lookup, internalBinMatchersealed type (Prefix/DigitRange), internalBIN_TABLE, and the publicBrandResolver. Aid.fromHex(...)andAid.fromBytes(...)factories validate length (5..16 bytes per ISO/IEC 7816-5) and hex content; case is normalised to uppercase. AIDs are public application metadata, not PCI data.AidDirectoryregisters 23 EMVCo-published AIDs across 9 brands, paraphrased; no third-party listing copied verbatim. O(1) lookup via a precomputedMap<Aid, EmvBrand>.BinMatcher.Prefix(prefix)matches by leading-digit prefix;BinMatcher.DigitRange(length, lo, hi)matches by numeric range over the leadinglengthdigits (covers Mastercard's 2221..2720 second series, Discover's 622126..622925, JCB's 3528..3589, Diners' 300..305, etc.).BIN_TABLEis order-sensitive: more specific matchers come first so Discover's 622126..622925 sub-range resolves to Discover before UnionPay's broader62prefix would catch it.BrandResolver.resolveBrand(aid: Aid?, pan: Pan?): EmvBrandis the public layered entry point: AID lookup first, then BIN fallback against the PAN's raw digits (viaPan.unmasked()), thenEmvBrand.UNKNOWN.- Out of scope for this milestone: country-specific debit AIDs, BIN-database issuer-name resolution, kernel-scoped AID disambiguation, and exhaustive Maestro overlap coverage.
Added — EMV tag dictionary (#3)
- New package
io.github.a7asoft.nfcemv.emvwithEmvTagFormat,EmvTagLength(sealedFixed/Variable),TagSensitivity(PCI/PUBLIC),EmvTagInfo, and theEmvTagslookup object. - 27 entries covering EMV Book 3 / Book 4 staples plus contactless-kernel additions: AID, App Label, Track 2, PAN, Cardholder Name, Expiration / Effective Date, Country / Currency / Language, PAN Sequence, AIP, DF Name, CDOL1 / CDOL2, AFL, Amount, IAD, Preferred Name, ARQC, CID, ATC, Unpredictable Number, Signed Dynamic, Track 2 (Mastercard), CTQ, FCI Issuer Discretionary Data.
- Each entry carries human-readable name, EMV format code (
N/AN/B/CN),Fixed(n)orVariable(maxBytes)length, and a binaryPCI/PUBLICsensitivity flag. Names are paraphrased from EMV specs; no third-party listing is copied verbatim. - O(1) lookup via
EmvTags.lookup(tag)(returnsnullfor unknown tags);EmvTags.allreturns entries in source order.
Added — engineering setup
CLAUDE.mdengineering rules (architecture, SOLID, code style, testing discipline §6.1)..claude/agents/project-scoped reviewers:emv-nfc-expert,pci-security-reviewer..claude/skills/slash commands:/review,/review-pr,/review-emv,/review-arch.- KMP scaffold from kmp.jetbrains.com wizard with Android (Compose sample) and iOS (SwiftUI sample) targets.
Documentation
- Top-level
README.mdwith terminal-style header, quickstart for both platforms, threat model summary. docs/threat-model.mdcovering scope, defaults, what the lib does not protect against.docs/recipes/parse-tlv.mdworked example.shared/README.mdAPI reference for the KMP core module.CONTRIBUTING.mdcovering scope guarantees, branching, commit format, PR checklist.