Public/Write-OpenTag3DTag.ps1
|
function Write-OpenTag3DTag { <# .SYNOPSIS Writes an NTAG21x memory image to a tag using an ACR122U (or any PC/SC reader). .DESCRIPTION Takes a 180-byte (NTAG213), 540-byte (NTAG215) or 924-byte (NTAG216) image produced by Export-OpenTag3DPayload and writes its user memory to a physical tag, four bytes at a time, using the PC/SC UPDATE BINARY APDU (FF D6 00 <page> 04 <data>). Before writing, the chip on the reader is identified with GET_VERSION and checked against the image, so a mismatched image is rejected before any bytes are committed. Only user memory (page 4 onward) is written. Pages 0-2 hold the factory UID, which is read-only and is neither checked nor written - any blank tag of the right type will do. Page 3 is the one-time-programmable capability container and is written only when -FormatCC is specified and the tag's CC is still blank. The dynamic lock and configuration pages at the end of memory are never written. .PARAMETER Path Path to the .bin image. .PARAMETER Bytes A 540- or 924-byte image already in memory, instead of a file on disk. .PARAMETER ReaderName Substring of the PC/SC reader name. Defaults to the first reader matching 'ACR122'. .PARAMETER Force Write even when the spool already holds a different OpenTag3D spec version - that is, deliberately migrate the tag from one version to another. Without it a mismatch is refused and nothing is written, tag and image versions both named. .EXAMPLE Write-OpenTag3DTag -Path .\50017-FYG5-NTAG215-Extended-Ndef.bin Writes the image to whichever tag is on the first ACR122 reader. The chip type is confirmed against the image first, so a 215 image on a 213 aborts before anything is committed. .EXAMPLE Write-OpenTag3DTag -Path .\tag.bin -WhatIf Connects, reads the UID and identifies the chip, then reports what would be written without committing anything - including the one-time-programmable capability container on page 3. .EXAMPLE Get-ChildItem D:\tags\*.bin | ForEach-Object { Read-Host "Place tag for $($_.Name), then press Enter" $_ | Write-OpenTag3DTag } Walks a folder of images, pausing for a tag swap between each. -Path binds from the FullName property, so FileInfo objects pipe straight in. .EXAMPLE Write-OpenTag3DTag -Path .\tag.bin -ReaderName 'ACR122U PICC' -Verbose Targets a specific reader by name substring. Verification covers every page, so a tag holding stale data from a longer payload fails rather than being left half-updated. #> [CmdletBinding(PositionalBinding = $false, SupportsShouldProcess, ConfirmImpact = 'Medium', DefaultParameterSetName = 'Path')] param( [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName, ParameterSetName = 'Path')] [Alias('FullName','PSPath')] [ValidateNotNullOrEmpty()] [string]$Path, [Parameter(Mandatory, ParameterSetName = 'Bytes')] [ValidateNotNullOrEmpty()] [byte[]]$Bytes, [Parameter()] [string]$ReaderName, [switch]$Force ) process { if ($PSCmdlet.ParameterSetName -eq 'Path') { $file = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($Path) if (-not (Test-Path -LiteralPath $file -PathType Leaf)) { throw "Image not found: $file" } $image = [IO.File]::ReadAllBytes($file) } else { $image = $Bytes } $spec = switch ($image.Length) { 180 { @{ TagType = 'NTAG213'; UserPages = 36 } } 540 { @{ TagType = 'NTAG215'; UserPages = 126 } } 924 { @{ TagType = 'NTAG216'; UserPages = 222 } } default { throw "Image is $($image.Length) bytes; expected 180 (NTAG213), 540 (NTAG215) or 924 (NTAG216)." } } Write-Verbose "$($spec.TagType) image, $($spec.UserPages) user pages" $session = Connect-PcscCard -ReaderName $ReaderName try { Write-Verbose "Reader: $($session.Reader)" # --- identify the tag (FF CA 00 00 00) --- $r = Invoke-PcscApdu -Session $session -Apdu ([byte[]]@(0xFF,0xCA,0x00,0x00,0x00)) if (-not $r.Success) { throw "Could not read tag UID (SW=$($r.SW))." } $tagHex = ($r.Data | ForEach-Object { $_.ToString('X2') }) -join ':' Write-Verbose "Tag UID $tagHex" # --- confirm the chip matches the image before committing anything --- $actual = Get-NtagType -Session $session Write-Verbose "Detected $($actual.TagType) via $($actual.Method)" if ($actual.TagType -ne $spec.TagType) { throw "Tag on the reader is an $($actual.TagType) ($($actual.UserSize) bytes user memory) but this image is for an $($spec.TagType). Rebuild with -TagType $($actual.TagType)." } # --- refuse to change the spec version of a tag already carrying data --- # Not because the result would be misread: the payload declares its version at # 0x00, so a version-aware reader handles whichever version it finds. This is a # guard against changing the format by accident - the module's default version # moves over time, and editing one field on an existing spool should not silently # migrate the tag. A blank or non-OpenTag3D tag has nothing to disagree with and # writes normally. $imageVersion = $null try { $imagePayload = Get-OpenTag3DNdefPayload -UserMemory $image $imageVersion = Get-OpenTag3DPayloadVersion -Payload $imagePayload } catch { Write-Verbose "Image is not an NDEF OpenTag3D record; no version check." } if ($imageVersion) { # Read in 48-byte steps until the opentag3d record's payload is in view. One # step covers the TLV, record header and 21-byte type; more is needed when # another record - a URI for a product page, say - comes first. $head = [byte[]]@() $start = $null for ($chunk = 0; $chunk -lt 3; $chunk++) { $page = 4 + ($chunk * 12) $r = Invoke-PcscApdu -Session $session -Apdu ([byte[]]@(0xFF,0xB0,0x00,[byte]$page,0x30)) -ReceiveLength 64 if (-not $r.Success) { Write-Verbose "Could not read page $page for a version check (SW=$($r.SW))."; break } $head = $head + $r.Data $start = Get-OpenTag3DPayloadStart -UserMemory $head if ($null -ne $start -and $start + 2 -le $head.Length) { break } } if ($head.Length) { if ($null -ne $start -and $start + 2 -le $head.Length) { $onTag = Get-OpenTag3DPayloadVersion -Payload $head[$start..($start + 1)] $rawOnTag = ([int]$head[$start] -shl 8) -bor $head[$start + 1] $shown = if ($onTag) { $onTag } else { '{0}.{1:D3}' -f [math]::Floor($rawOnTag / 1000), ($rawOnTag % 1000) } if ($shown -ne $imageVersion) { if ($Force) { # Worded without naming -Force: the browser UI reaches this by # a confirmation click, not a parameter. Write-Warning "Migrating the spool from OpenTag3D $shown to $imageVersion." } else { # The two directions go wrong differently, so say the one that # applies rather than both. $imageRaw = (Get-OpenTag3DSpec -SpecVersion $imageVersion).Raw $tagRaw = if ($onTag) { (Get-OpenTag3DSpec -SpecVersion $onTag).Raw } else { $rawOnTag } $upgrade = $imageRaw -gt $tagRaw $why = if ($upgrade) { "readers that predate $imageVersion will refuse the tag afterwards" } else { "$shown fields that $imageVersion has no room for would be lost" } # Fields the target version cannot hold, for a caller that wants # to show what a downgrade costs. $lost = @() if (-not $upgrade -and $onTag) { $keep = @((Get-OpenTag3DFieldTable -SpecVersion $imageVersion) | ForEach-Object { $_.Id }) $lost = @((Get-OpenTag3DFieldTable -SpecVersion $onTag) | Where-Object { $_.Id -notin $keep } | ForEach-Object { $_.Name }) } # A structured error so a caller - the browser UI - can offer to # migrate deliberately rather than having to match on the text. $detail = [pscustomobject]@{ ImageVersion = $imageVersion TagVersion = $shown Upgrade = $upgrade Why = $why Lost = $lost } $message = "Spec version mismatch: this image is OpenTag3D $imageVersion, the spool on the reader holds OpenTag3D $shown. Changing a tag's spec version is a deliberate act - $why. Nothing was written. Rebuild the image as $shown, or pass -Force to rewrite the tag as $imageVersion." throw ([System.Management.Automation.ErrorRecord]::new( [System.InvalidOperationException]::new($message), 'SpecVersionMismatch', [System.Management.Automation.ErrorCategory]::InvalidData, $detail)) } } else { Write-Verbose "Spool and image agree on OpenTag3D $shown" } } else { Write-Verbose "Tag holds no readable OpenTag3D record; no version to compare." } } } # --- capability container (page 3), always written --- # Page 3 is OTP: WRITE ORs into the existing value, so rewriting the same CC is a # no-op. Read back afterwards to confirm the tag holds what the image expects. $want = $image[12..15] $wantHex = ($want | ForEach-Object { $_.ToString('X2') }) -join ' ' if ($PSCmdlet.ShouldProcess($session.Reader, "Write capability container $wantHex to page 3")) { $w = Invoke-PcscApdu -Session $session -Apdu ([byte[]]@(0xFF,0xD6,0x00,0x03,0x04) + $want) if (-not $w.Success) { throw "Failed writing CC to page 3 (SW=$($w.SW))." } $r = Invoke-PcscApdu -Session $session -Apdu ([byte[]]@(0xFF,0xB0,0x00,0x03,0x04)) -ReceiveLength 16 if (-not $r.Success) { throw "Could not read back capability container (SW=$($r.SW))." } $now = ($r.Data[0..3] | ForEach-Object { $_.ToString('X2') }) -join ' ' if ($now -ne $wantHex) { throw "CC verification failed: page 3 reads $now, expected $wantHex. Page 3 is one-time programmable, so this tag was previously formatted for a different type." } Write-Verbose "CC $wantHex confirmed" } # --- write user memory --- if (-not $PSCmdlet.ShouldProcess($session.Reader, "Write $($spec.UserPages) pages to $($spec.TagType) tag $tagHex")) { return } # Every page is written, including the all-zero ones. Skipping them would be # quicker on a factory-blank tag, but over a tag that already held a longer # payload it leaves the old bytes in place past the new terminator. $written = 0 for ($p = 0; $p -lt $spec.UserPages; $p++) { $page = 4 + $p $offset = 16 + ($p * 4) $chunk = $image[$offset..($offset + 3)] $apdu = [byte[]]@(0xFF,0xD6,0x00,[byte]$page,0x04) + $chunk $w = Invoke-PcscApdu -Session $session -Apdu $apdu if (-not $w.Success) { throw "Write failed at page $page (0x{0:X2}), SW={1}. Tag may be partially written." -f $page, $w.SW } $written++ if (($p % 16) -eq 0) { Write-Progress -Activity "Writing $($spec.TagType)" -Status "Page $page" -PercentComplete (100 * $p / $spec.UserPages) } } Write-Progress -Activity "Writing $($spec.TagType)" -Completed Write-Verbose "Wrote $written pages" # --- verify (always) --- $bad = 0 for ($p = 0; $p -lt $spec.UserPages; $p += 4) { $page = 4 + $p $count = [Math]::Min(4, $spec.UserPages - $p) $offset = 16 + ($p * 4) $r = Invoke-PcscApdu -Session $session -Apdu ([byte[]]@(0xFF,0xB0,0x00,[byte]$page,[byte]($count*4))) -ReceiveLength 32 if (-not $r.Success) { throw "Verify read failed at page $page (SW=$($r.SW))." } for ($b = 0; $b -lt ($count*4); $b++) { if ($r.Data[$b] -ne $image[$offset + $b]) { Write-Warning ("Mismatch at page {0} byte {1}: tag {2:X2}, image {3:X2}" -f ($page + [Math]::Floor($b/4)), ($b % 4), $r.Data[$b], $image[$offset + $b]) $bad++ } } } if ($bad -gt 0) { throw "Verification failed: $bad byte(s) differ." } Write-Verbose "Verified $($spec.UserPages) pages" Write-Host "Wrote and verified $written pages to $($spec.TagType) $tagHex" } finally { Disconnect-PcscCard -Session $session } } } |