Building a verifier
A verifier reads a stamp, retrieves the issuer's key from the DNS and reports one of seven outcomes. This guide describes the verification procedure, the DNS lookup, the display of the result and the testing of a verifier against the test vectors.
Overview#
A verifier takes the text decoded from a QR code and produces one of seven outcomes. For the outcome
valid, it also produces the issuer's domain, the time of issue, the DNSSEC status and the fields.
The procedure is fixed by the specification, so that every conforming verifier reaches the same
outcome for the same stamp under the same DNS answers.
A verifier can be written from the specification, or built on an existing implementation such as dkds.js, which leaves only the reading of the QR code and the display to the application. The requirements in this guide apply in both cases.
Components#
| Component | Function | Section |
|---|---|---|
| QR reader | Decodes the image into the text form | 3.3 |
| Base45 decoder | Decodes the text form into the envelope | 3.1, 3.2 |
| Envelope parser | Reads and checks the header: version, domain, key ID, time of issue, lengths | 4 |
| DNS client | Retrieves the TXT records at the lookup name from two resolvers | 7.1, 9.2 |
| Key record parser | Selects and parses the v=DKDS1 record |
7.2, 7.3 |
| Signature check | Verifies the Ed25519 signature, with the additional checks of section 6.3 | 6 |
| Payload decoder | Decompresses the payload and parses the fields | 5 |
| Display | Presents the outcome, the verifier's statements and the issuer's fields | 9.1 |
| Warnings | Reports lookalike domains and changed keys | 9.3 |
Apart from the QR reader, a SHA-512 function and Ed25519 arithmetic, which are available in most languages, each component is small. In dkds.js, the components used for verification amount to about 2,000 lines of TypeScript, including documentation comments.
Order of implementation#
Each test vector names the section of the specification that it tests, in its section field. A
verifier can therefore be written and tested one component at a time:
- Base45 and the text form: vectors for sections 3.1 and 3.2.
- The envelope, the domain and the key ID: sections 4.1 to 4.4.
- The clock check and the order of the procedure: section 8.2.
- The key record, with the DNS simulated from the vectors: sections 7.1 to 7.4.
- The signature: section 6.3.
- The payload and the forbidden code points: sections 5.1, 5.2 and Appendix B.
- All vectors together, followed by the display and the warnings, which the vectors do not cover.
The verification procedure#
The verifier performs the following steps in order. The first step that fails determines the outcome. No step may be skipped or reordered (section 8.2).
| Step | Check | Outcome on failure |
|---|---|---|
| 1 | The text is DKDS: followed by valid Base45, with nothing before or after |
malformed |
| 2 | The envelope is at least one byte long; its first byte is not 0x00 or 0xFF |
malformed |
| 3 | The first byte is 0x01 |
unsupported |
| 4 | Every length is within bounds; the domain and key ID are valid; the lookup name is at most 253 characters; payload and signature are not empty | malformed |
| 5 | The time of issue is no more than 600 seconds after the verifier's clock | malformed |
| 6 | The DNS returns exactly one usable v=DKDS1 record at the lookup name |
unavailable if no answer, key-not-found otherwise |
| 7 | The p tag is not empty |
key-revoked |
| 8 | The algorithm named by k is implemented |
unsupported |
| 9 | The value of p decodes to a key of the algorithm |
key-not-found |
| 10 | The signature verifies | invalid-signature |
| 11 | The payload decompresses and parses as a valid field list | malformed |
| 12 | None; every previous step has succeeded | valid |
Two properties of the order are deliberate. No DNS query is made for a stamp that fails steps 1 to 5, so that malformed input causes no network traffic. The payload is decompressed only after the signature has been verified, so that the decompressor processes only data signed by a published key.
The procedure in outline:
verify(text, now):
if text does not start with "DKDS:" -> malformed # step 1
envelope = base45_decode_strict(text[5:]) or malformed
if envelope is empty or envelope[0] in {0x00, 0xFF}
-> malformed # step 2
if envelope[0] != 0x01 -> unsupported # step 3
header = parse_header(envelope) or malformed # step 4
if header.issued_at > now + 600 -> malformed # step 5
answer = query_txt(header.key_id + "._dkds." + header.domain)
if answer is a failure -> unavailable # step 6
record = the single record whose first tag is v=DKDS1
or key-not-found
if record.p is empty -> key-revoked # step 7
if record.k is not "ed25519" -> unsupported # step 8
key = base64_decode_canonical(record.p), 32 bytes
or key-not-found # step 9
message = "DKDS" || signed_part(envelope)
if not ed25519_verify_strict(key, message, header.signature)
-> invalid-signature # step 10
fields = parse_fields(inflate_raw(header.payload, limit = 16384))
or malformed # step 11
-> valid, with domain, issued_at, dnssec, fields # step 12Decoding#
Text form and Base45#
The text is passed to the procedure exactly as the QR library decodes it. Base45 decoding is strict (section 3.2): every character must be in the Base45 alphabet, without case folding; a group of three characters whose value exceeds 65,535 is invalid; a final group of one character is invalid; and a final group of two characters whose value exceeds 255 is invalid. Many Base45 libraries accept some of these inputs, and the vectors for section 3.2 test each rule.
Domain#
The domain is checked with purely algorithmic rules (section 4.2),
which require no Unicode tables. A label beginning with xn-- must be valid Punycode, must decode to
a string without forbidden code points, and must encode back to exactly the same characters. The
decoded form is the one displayed to the reader.
Signature#
The signature is verified as defined in RFC 8032, section 5.1.7, with additional checks made
mandatory (section 6.3). Verification fails if the signature is
not 64 bytes, if S is not smaller than the group order, if the key or R is not a canonical
encoding of a curve point, or if either is a point of small order.
Payload#
The payload is raw DEFLATE (RFC 1951), without the zlib or gzip header. The decompressor stops as soon as the output exceeds 16,384 bytes, and the stream must end exactly at the end of the payload (section 5.2). The decompressed data is then read as a field list: every name and value must be valid UTF-8 without forbidden code points, the list must end exactly at the end of the data, and no two names may be equal.
DNS#
Query#
The verifier queries the TXT records at the lookup name <key_id>._dkds.<domain>. The character
strings of each record are joined without separators. Records whose first tag is not v=DKDS1 are
ignored; of the remainder, there must be exactly one, and it must parse
(section 7.3). A name that does not exist and a name
without a usable record both lead to key-not-found. A timeout, a server failure and a DNSSEC
validation failure lead to unavailable, which means that the stamp may be checked again later.
Two resolvers#
A verifier SHOULD query two independent resolvers (section 9.2), which makes a forged answer from a single resolver detectable:
| Resolver A | Resolver B | Result |
|---|---|---|
| answers | answers the same | the answer is used |
| answers | answers differently | unavailable |
| answers | no answer | the answer is used; the reader is told that one resolver was reached |
| no answer | no answer | unavailable |
DNSSEC#
A resolver that validates DNSSEC marks a validated answer with the Authenticated Data (AD) flag. The verifier reports the answer as validated only when every answering resolver validated it. An answer from an unsigned zone is used and reported as not validated.
Browsers#
A verifier that runs in a web browser has no direct access to the DNS and uses DNS over HTTPS
(RFC 8484). The public resolvers of Cloudflare (cloudflare-dns.com) and Google (dns.google)
permit requests from web pages, report the AD flag, and are the default resolvers of dkds.js.
Displaying the result#
Valid stamps#
For the outcome valid, the verifier displays, in this order
(section 9.1):
- the outcome;
- the domain, prominently; for an internationalised domain, in its Unicode form, with the ASCII form available;
- the time of issue, with an explicit time zone;
- whether the DNS answer was validated with DNSSEC;
- any warnings;
- every field, in order, with names and values exactly as signed.
Items 1 to 5 are the verifier's own statements. The fields are statements of the issuer and are
presented separately, under a heading that identifies them as such, so that a field named, for
example, Issued by cannot be mistaken for the verifier's display of the domain.
- Supplier
- Northwind B.V.
- Invoice no.
- 2026-0917
- Amount
- 4,812.50 EUR
- Pay to IBAN
- NL91 ABNA 0417 1643 00
Values are displayed in full, without reformatting: an amount, a date or an account number appears
exactly as signed, so that the reader can compare it with the document. Each value is best placed in
its own bidirectional isolate, such as the HTML element bdi, so that right-to-left text in one
field cannot reorder the text around it.
Other outcomes#
For any outcome other than valid, the verifier MUST NOT display the fields. The domain in the
header has not been confirmed by a signature and is not presented as the issuer. Descriptions such
as the following state the outcome without speculation:
| Outcome | Description for the reader |
|---|---|
malformed |
The code is not a valid DKDS stamp. |
unsupported |
The stamp uses a version or algorithm that this verifier does not support. |
key-not-found |
No key is published for this stamp. Its origin cannot be confirmed. |
key-revoked |
The key of this stamp has been revoked by the holder of the domain. |
invalid-signature |
The signature does not match the contents of the stamp. |
unavailable |
The key could not be retrieved. The stamp can be checked again later. |
Warnings#
Warnings inform the reader and never change the outcome (section 9.3). The following checks are recommended:
| Warning | Condition | Basis |
|---|---|---|
| Internationalised domain | The domain contains xn-- labels |
4.2 |
| Mixed scripts | A label mixes scripts, for example Latin and Cyrillic | UTS #39 |
| Confusable characters | The domain contains characters that resemble others | UTS #39 |
| Similar domain | The domain differs slightly from one the reader has verified before | 9.3 |
| Key changed | The key record differs from the one seen before for the same lookup name | 9.2 |
| Key disappeared | A key record seen before is no longer published | 9.2 |
| Single resolver | Only one of the resolvers answered | 9.2 |
The last four warnings require the verifier to remember key records and domains it has seen. This memory is kept on the reader's device; it records which issuers the reader has verified, and is treated as private data.
Testing#
Test vectors#
The test vectors check the procedure mechanically. For each vector, a harness gives the verifier the text, sets its clock, answers its DNS queries from the vector and compares the outcome, the names queried and, for valid stamps, the domain, time of issue, DNSSEC status and fields. A verifier conforms to the procedure when it passes every vector.
Beyond the vectors#
The vectors start from decoded text and model the DNS as a single answer. The following are tested separately:
- reading QR codes of every version up to 27, printed and photographed under ordinary conditions;
- the display, reviewed against section 9.1 for each outcome, including long values, right-to-left text and internationalised domains;
- disagreeing, failing and slow resolvers;
- the warnings, with domains that mix scripts and domains that differ by one character.
Differential testing#
dkds.js can serve as a reference. dkds verify --json reports the outcome together with the step of
the procedure that determined it, which locates a disagreement precisely:
dkds verify --json "DKDS:360B/D5LED9DZED-TCB..."Checklist#
Before a verifier is released:
- Every test vector passes.
- QR codes up to version 27 are read reliably.
- Two independent resolvers are queried, and disagreement leads to
unavailable. - The DNSSEC status is reported for every valid stamp.
- The display follows section 9.1, and fields are never shown for an outcome other than
valid. - Internationalised domains are shown in Unicode, with the ASCII form available.
- Lookalike warnings are given and do not affect the outcome.