Version 1 · for anyone writing their own verifier
Everything needed to verify an ArtFirma tag without any of our software or servers.
An NXP NTAG 424 DNA chip on the artwork holds one NDEF URI record:
https://purl.org/artfirma/v?u=<UID>&c=<CTR>&m=<CMAC>#1.<work>.<hash>.<sig>
| Part | Written by | Meaning |
|---|---|---|
u | the chip, on every read (SUN mirror) | chip serial number, 7 bytes, 14 upper-case hex |
c | the chip, on every read | read counter, 3 bytes big-endian, 6 hex; grows by one per read |
m | the chip, on every read | SUN CMAC, 8 bytes, 16 hex (see "Chip code") |
1 | the artist, once | format version |
work | the artist, once | work number, [A-Za-z0-9-]{1,64}: usually the artwork's slug in the artist's shop |
hash | the artist, once | first 16 bytes of SHA-256 of the catalogue record file, base64url without padding (22 chars); - if the tag is not bound to a record |
sig | the artist, once | Ed25519 signature (RFC 8032), 64 bytes, base64url without padding (86 chars) |
Older tags may point to https://verify.d.123automate.it/?u=… instead of the purl address. The data is the same.
The signed message is the UTF-8 string:
NFCSIGN1|<UID>|<work>|<hash>
<UID> is the 14 upper-case hex characters from u. The signature is valid if Ed25519 verification of sig over this message succeeds with one of the artist's public keys (keys.json, 32-byte keys in hex). An entry with revoked (a date, and revoked_at in unix ms when known) is a withdrawn key: treat its signatures as invalid, unless there is independent evidence the seal existed before that moment (see Revocation).
A valid signature means: the artist declared that the chip with this serial number is this work, bound to this record. Copying the part after # to another chip fails, because the other chip reports a different u.
The record is the file r/<work>.json published next to the verification page (and in archived copies), or <registry>/r/<public key hex>/<work>.json in the online registry (records are kept per artist key: work numbers are the artist's own). SHA-256 of its exact bytes, truncated to 16 bytes and base64url-encoded, must equal hash. A file that is not JSON (e.g. a host serving its start page for a missing file) is "no record here", not a mismatch.
The record is a JSON object. Fields, in this order when present: v (1), id (= work), artist, title, year, series, artform, medium, size, notes, statement, photo (file name of the main photo, next to the record), photos (list of {file, sha256, width, height}: SHA-256 of each photo file, lowercase hex), source ({url, product_id, imported, description_html}: where the data was taken from). Show a photo only if its bytes match its sha256. Compare the fields and the photo with the physical work.
A published record also carries a detached signature by the artist, so any copy of the registry can be checked without the tag: file <work>.json.sig next to the record, one line <public key hex>.<signature base64url>. The signature is Ed25519 over the UTF-8 string
NFCSIGN-RECORD1|<work>|<SHA-256 of the record file, lowercase hex>
with one of the keys in keys.json. The record lists its photos with their SHA-256, so the signature covers them too. index.json lists the records: {"v": 1, "records": [{"artist": <public key hex>, "id": <work>, "fingerprint": <hash>}]}. A published record never changes: the registry refuses different bytes under the same artist key and work number.
Every registry file is also stored on Arweave as a separate data item, findable through any Arweave gateway's GraphQL by its tags: App-Name: ArtFirma, Type: record | record-sig | photo, Artist: <public key hex>, Work: <work>, Fingerprint: <hash> (record and its signature), Sha256: <lowercase hex of the file>, File (photos). To find a tag's record with no other service alive: query Type=record, Fingerprint=<hash from the tag>, download it from the gateway, and check its SHA-256 against hash as above; the photo is Type=photo, Sha256=<sha256 from the record>. Gateways are not trusted: only bytes matching the fingerprint count.
Quarantine and withdrawal. A record and its photos are copied to Arweave only 30 days after publishing; until then the registry index (/r/index.json) lists archive_after: "YYYY-MM-DD" instead of arweave. A record published with illegal content can be withdrawn after a complaint: it is deleted from the registry and listed at GET /takedowns.json: [{"pub", "work", "fingerprint", "removed": "YYYY-MM-DD", "arweave"?: [ids]}] (arweave only if it had already been archived; entries for media leaves also have key and sha256, and fingerprint is null). A verifier should not display a record whose fingerprint (for that artist key) is in this list, from any source; the seal's signature is checked as usual. The reference page carries a copy of the list from the time it was built.
The record never changes. Media the artist adds later go into separate signed documents, leaves. A work leaf (work-media) adds photos or video to a record, any number of them, at any time. A seal leaf (seal-media) adds a photo of one copy (for example, its hand-written signature) to one seal; there is one per seal and record. A leaf is a JSON object, serialised like a record (JSON.stringify(v, null, 2) plus a newline):
{ "v": 1, "type": "work-media" | "seal-media", "artist": <public key hex>, "work": <work>,
"record": <hash from the seal>, "ts": <unix ms, the phone's clock, informative>,
"uid": <UID, seal-media only>,
"files": [{ "file": "<sha256>.jpg" | "<sha256>.mp4", "sha256": <lowercase hex>, "bytes": <length>,
"kind": "photo" | "video", "width"?, "height"?, "duration_s"? }] }
It is signed with Ed25519 by the artist's key over the UTF-8 string
ARTFIRMA-WORK-MEDIA1|<SHA-256 of the leaf bytes, lowercase hex> (work leaf)
ARTFIRMA-SEAL-MEDIA1|<SHA-256 of the leaf bytes, lowercase hex> (seal leaf)
and the signature is stored next to the leaf as <leaf>.json.sig, one line <public key hex>.<signature base64url>. Photos are JPEG, video MP4 (H.264/AAC).
Checking a leaf against a seal. Show a leaf only if all hold: it parses, v is 1 and type is known (unknown fields are ignored); the signature line's key and artist are the key that signed the seal, and the signature verifies over the string above for its type; work and record equal work and hash from the seal; for a seal leaf, uid equals the seal's UID. A work leaf belongs to every seal signed with that record. Show a file only if its bytes have exactly the listed bytes and sha256. Several seal leaves for the same seal and record (one may be uploaded to Arweave directly): show the one the registry accepted; without the registry, the earliest by Arweave block. Registry limits (file size, number of files, storage quota) are bookkeeping, not part of authenticity: a correctly signed leaf is genuine wherever it is found.
Where they are. In the registry: /r/<pub>/<work>/media.json lists the work leaves, {"v": 1, "media": [{"id", "sha256", "received"}]} (received — unix ms when the leaf and all its files had arrived; the reference page shows this date, not ts); a work leaf is /r/<pub>/<work>/media/<id>.json (id — the first 16 hex characters of its SHA-256), a seal leaf /r/<pub>/_seals/<UID>/<record>.json, each with .json.sig; files /r/<pub>/_files/<sha256>.<ext>. On Arweave (after the same 30-day quarantine, counted from received), tags: Type: work-media with Artist, Work; Type: seal-media with Artist, Uid, Record; Type: media-sig with Artist, Leaf: <SHA-256 of the leaf>; Type: media-file; each with Sha256 of its own bytes. Reference vectors (key, leaf bytes, signed strings, signatures): vectors/media-leaves.json.
Withdrawal. A withdrawn leaf (one leaf, the leaves of a seal, or a record with all its leaves) is listed in /takedowns.json with "key" (absent for a whole record) and "sha256": hashes of the withdrawn leaves and their files. A verifier should not show a leaf or file whose SHA-256 is in any entry, from any source. A withdrawn file is withdrawn everywhere, including from other leaves that list it.
Besides keys added by hand, keys.json holds keys that artists registered themselves (entries with site). Such a key is vouched for by the artist's own website, checked once, at registration:
{"v":1,"name":…,"site":<domain>,"pub":<key hex>,"sdm":<chip-code master hex>,"ts":<unix ms>} signed with that key (Ed25519 over NFCSIGN-ARTIST1| + the JSON text);artfirma-key=<key hex> in a DNS TXT record _artfirma.<domain>, or on https://<domain>/pages/artfirma, /.well-known/artfirma.txt or /artfirma.txt.name is chosen by the artist; the domain is what was checked, so show the domain first. The check happens once, at registration (the registry keeps where and how the line was found); the artist may remove the line afterwards. Keys withdrawn later are kept in keys.json with revoked: <date>.
Only the key's owner can withdraw a registered key, in one of two ways (POST /artists/revoke on the registry):
{"v":1,"pub":<key hex>,"ts":<unix ms>} signed with that key (Ed25519 over NFCSIGN-REVOKE1| + the JSON text; ts within 10 minutes), for a key that leaked while the owner still has the words;artfirma-revoke=<key hex> where the key was confirmed (same places as artfirma-key=). This takes effect only 30 days later: a website can change hands (a lapsed domain bought by someone else), so the key stays valid meanwhile, the request is listed at GET /keys-pending-revocation.json ([{"pub", "requested", "effective", …}]) and the reference page warns about it, and the key holder can cancel it with {"v":1,"pub","ts"} signed over NFCSIGN-REVOKE-CANCEL1| + the JSON text (POST /artists/revoke/cancel). A revocation signed with the key itself is immediate.Impostors' keys are withdrawn by the operator. Withdrawn keys are listed at GET /keys-revoked.json: [{"pub", "revoked": "YYYY-MM-DD", "revoked_at": unix ms}].
A seal carries no signing date, so a verifier cannot tell offline whether a seal was signed before or after the key was withdrawn. The only evidence ArtFirma has is the optional online layer: first_seen in the /check answer: when the chip was first checked online. If it is earlier than revoked_at, the seal existed before the revocation; the reference page then shows a warning (“key revoked later”) instead of “not verified”. The archive date of the record is not evidence: an old record can be signed onto a new chip. Even first_seen is weak evidence: whoever held the leaked key could sign a new chip and check it online before the owner revoked. That is why the page shows a warning, not “verified”; after a leak, works that matter (insured, sold) should get new seals with a new key.
Copies made before a revocation do not know about it. A saved or archived copy of the page carries the keys as they were when it was built; online, the reference page also reads /keys-revoked.json. Offline, an old copy will still accept a key withdrawn after it was made.
m proves that the URL was produced by the genuine chip just now. It needs the secret tag key, so only the artist's online service can check it. A copied link keeps its old counter, so a service that remembers the last counter it saw can detect it.
The chip computes m with its key slot 1 (SDMFileRead = 1). Slots 0, 2 to 4 hold different keys that allow rewriting the tag; they are never given to the online service.
AES-CMAC(master, 0x01 || UID || "NFCSIGN1"), where master is a 16-byte AES key: for slot 1 the artist's *chip-code master*, for slots 0, 2 to 4 the artist's *admin master* (tags signed before 2026-10-06 used one master for all slots). Version N ≥ 2: AES-CMAC(master, 0x01 || UID || "NFCSIGN1" || N). The version is the chip's key version (GetKeyVersion); every re-signing of a tag moves to the next version, because re-enabling SUN resets the chip's read counter. A link made with an older key version was taken before the tag was re-signed.AES-CMAC(tagKey, 3C C3 00 01 00 80 || UID || CTR as 3 bytes little-endian).m = bytes 1, 3, 5, … 15 of AES-CMAC(sessionKey, "u=<UID>&c=<CTR>&m=").This is the NXP NTAG 424 DNA SUN MAC (NXP AN12196) over the URL fragment between u= and m=.
NXP signs each chip's UID at the factory (originality signature, ECDSA secp224r1, NXP AN12196). It is read with the chip's Read_Sig command (NFC reader app, not a browser). The NTAG 424 DNA public key is:
048A9B380AF2EE1B98DC417FECC263F8449C7625CECE82D9B916C992DA209D68422B81EC20B65A66B5102A61596AF3379200599316A00A1410
Before a seal is sold, ArtFirma issues it: the ArtFirma issuer key signs the chip's serial number and the result is written into the chip's proprietary file 3 (128 bytes), which is then locked for ever (write and settings change: never; read: free). The chip keys are not touched; the artist sets them when signing.
m/7365'/2'/0' (the artist key is m/7365'/1'/<index>').NFCSIGN-ISSUE1|<UID> (UID as 14 upper-case hex characters)."AFI1" (ASCII) ‖ issuer public key (32) ‖ signature (64). Read without authentication: ReadData 90 AD 00 00 07 03 000000 640000 00.What the page sees. A phone that opens the seal's link receives only the NDEF URL, not file 3. The page therefore asks the registry about that one chip: GET /issued/<UID> → {"uid", "issuer", "issued": "YYYY-MM-DD", "batch"} or 404, and shows the line only if issuer is one of the issuer public keys embedded in the page. Without a network answer the line is left out. The full list is GET /issued.json?after=<UID>&limit=1000 → {"issued": [...], "next"}, page by page in UID order.
Batches. UIDs are added only by an issuer key, in signed batches: POST /issued with {"payload", "sig"}, payload {"v":1,"issuer","uids":[...],"ts"} signed as NFCSIGN-ISSUED1|payload. Each batch is kept exactly as sent (GET /issued/batches/<name>.json, the batch field above) and stored on Arweave as its own file (tag Type=issued-batch, Issuer); a manifest (tag Type=issued-manifest) lists all batches by name. Anyone can rebuild the list from Arweave and check every signature. Seals signed before issuance existed (October 2026) are added in a batch too.
What it means. “Seal issued by ArtFirma” says the chip came from us; it adds nothing to the artist's signature, which stays valid either way. A seal not in the list is shown neutrally, not as a forgery. The artist app reads file 3 directly and may refuse to sign seals we did not issue.
The artist's keys are derived from a 12-word phrase, using published wallet standards only:
m/7365'/1'/<index>' (index 0 = first key);"NFCSIGN" / "tag admin key" (slots 0, 2 to 4) and "NFCSIGN" / "tag sdm key" (slot 1, chip code), first 16 bytes each; "NFCSIGN" / "tag master key" was the single master of earlier tags.The public keys in keys.json are the only thing a verifier needs (with revoked and site as described above).
A tag proves what the artist signed about a chip. It cannot prove that the chip was not moved to another object. The record (description, photo) and the paper certificate are there so a person can compare them with the work itself.