Paper-first Verifiable Credentials Specification using QR Codes.
© 2021 PathCheck Foundation
Authors: Justin Dossey and Vitor Pamplona
Status: DRAFT
Date: Feb 26, 2021
The PathCheck Verifiable QR Specification is an extension of the W3C Verifiable Credentials Data Model expressed as a URI for the purposes of providing a standardized format of describing Verifiable Credentials within the constraints of the QR specification. This document describes the protocol to create Verifiable Credentials directly as URIs for space-limited alphanumeric-required applications, such as QR Codes, NFC tags and SMS Messages.
For the purposes of brevity, this document refers to the following terms which are defined as follows:
All verifiable credentials follow a URI Schema that starts with CRED:
and a message with:
The URI is simply organized in a colon-separated string as:
cred:type:version:signature:keyId:payload
The type field declares the payload type (e.g. COUPON
, PASSKEY
, BADGE
or STATUS
) and the version is a ever-incrementing NUMERIC field defining the version of the type of payload. The payload block contains the information itself in a pre-defined format. The cryptographic signature is a DER signature in Base32 form, calculated using the private key of the ISSUER.
Payload specifications define the syntax and semantic meaning of the fields as well as their order in the serialization process. All payload type specifications must be submitted as pull requests to the payload folder in this repository. The general payload encoding is described in sections below.
The example below is a valid credential
CRED:COUPON:1:GBDAEIIA42QDQ5BDUUXVMSQ4VIMMA7RETIZSXB573OL24M4L67LYB24CZYVQEIIA2EZ5W2QXLR7LUSLQW
6MLAFV3N7OTT3BDAZCNCRMYBMUYC6WMXMNQ:KEYS.PATHCHECK.ORG:1/5000/SOMERVILLE%20MA%20US/1A/%3E65
Main Benefits of this protocol are
cdc.gov
, they will also trust keys.cdc.gov
Disadvantages are:
Data to be used for signing and hashes is uppercased, percent encoded and then serialized with a slash-separated (/
) string in the specified order the payload spec describes. Cryptographic signatures and hashes MUST be calculated against encoded versions of the underlying payload, as they appear in the final URI. This permits signature verification before any decoding.
Cryptographic tools must sign and verify a SHA256 hash of the UTF-8 byte array of the uppercased, percent-encoded, slash-separated payload. The resulting signature in Distinguished Encoding Rules (DER - as per ASN.1 encoding rules defined in the ITU-T X.690, 2002, specification) format must be then encoded in Base32URL, a Base32 (RFC4648) without added padding (=
). The removal of the padding is due to the fact that =
is not a supported character on both URI and alphanumeric QR codes.
Public Keys can be generated using any cryptographic method. Verifiers must implement the cryptographic protocol included in the Public Key PEM File. Any verifier must be able to download a public key of the signer and maintain an indexed local key-value store of approved public keys in PEM format.
The keyID of the public key can be:
Before validating a signature, verifiers must compute the SHA256 of the payload (as is from the URI) and decode the Base32URL signature.
When the key is placed into DNS TXT records, issuers need to convert their PEM files to remove:
\n
, as new line is not a valid character for TXT Records.Issuers should replace \n
with \\n
. Verifiers must convert back from \\n
to \n
Make sure the remaining PEM includes an Object Identifier (OID) in the base64 format.
For example, the keyId keys.pathcheck.org
needs a DNS Lookup and has: ($ dig -t txt keys.pathcheck.org
):
MFYwEAYHKoZIzj0CAQYFK4EEAAoDQgAE6DeIun4EgMBLUmbtjQw7DilMJ82YIvOR\\n2jz/IK0R/F7/zXY1z+gqvFXfDcJqR5clbAYlO9lHmvb4lsPLZHjugQ==
Verifiers must then return the content to it's original format by replacing \\n
by the new line character.
The PathCheck Foundation will keep a list of trusted issuers on this repository. References to this list use the period character (.
) to separate the id of the public key from the name of the database/folder in this order: id.folder
As an example, the keyId 1a9.pcf
refers to the database at the PCF folder and the file name in that database is 1a9.pem. Each ID must contain the raw PEM file of the issuer.
This keyId is a direct URL reference with the raw PEM file of the public key inside the issuer's website, but without the URL Schema component (https://
). Host must result the link in uppercase format. Verifiers must add https://
to the URL, download and parse the key.
For example, the keyId www.pathcheck.org/hubfs/pub
downloads a file that contains the public key of a ECDSA keypair.
The payload should be represented as a series of uppercased, percent-encoded values delimited by the slash (/
) character. The serialization order is defined in each type of payload specification and key names are omitted. Percent encoding of the upcased payload is used to address QR code character set limitations, while supporting the URI spec.
This document will use the following terms to define data types.
20200201
is 1 February, 2020.1607745600
is 2020-12-12T12:00:00+08:00
.Payload Values are encoded per the standard using Percent Encoding. Note that all characters not present in the Alphanumeric QR scheme must be percent encoded. The Alphanumeric QR Code type imposes significant limitations on the data which can be represented, but allows for the generation of a lower-resolution QR code. Smaller QR codes will be scannable with older hardware and lower-resolution scanners, and smaller data sets allow for more aggressive error correction. This promotes usability and equity.
All fields (keys as well as values) are case-insensitive in both JSON and URI format. When performing operations such as hash comparison, a case-insensitive comparison function MUST be used. Additionally, the Alphanumeric QR Code character set does not include lowercase characters, so implementations MUST encode output in uppercase only.
For clarity and ease of reading, examples in this document are given in mixed case.
Unfilled fields MUST be submitted as empty between slash (/
) characters. Only add empty delimiters if there is data after. Given fields A (required), B (optional), C (optional) the implementation MUST follow the following example:
A | B | C | Output |
---|---|---|---|
1 |
1 |
||
1 |
2 |
1/2 |
|
1 |
2 |
3 |
1/2/3 |
1 |
3 |
1//3 |
All characters not present in the Alphanumeric QR scheme must be percent encoded when presented in data fields. RFC 2936 requires percent encoding a number of characters, but some of the characters not required to be encoded are not included in the Alphanumeric QR character set. As a result, those characters MUST also be percent encoded.
The columns in the table below indicate encoding requirements for each representable character. Any non-listed characters MUST be percent-encoded. The "URI Requires" column indicates whether the URI format rules (RFC 2396) requires encoding the character. The "Alphanumeric QR Requires" column indicates whether the character is missing from the Alphanumeric QR character set (thus, requiring encoding). The "Must Encode?" column indicates whether this specification requires percent-encoding of the character. The "Output Value" column indicates the expected output from processing the listed character.
Character | URI Requires | Alphanumeric QR Requires | Must Encode? | Output Value |
---|---|---|---|---|
|
YES | NO | YES | %20 |
! |
YES | YES | YES | %21 |
" |
YES | YES | YES | %22 |
# |
YES | YES | YES | %23 |
$ |
YES | NO | YES | %24 |
% |
YES | NO | YES | %25 |
& |
YES | YES | YES | %26 |
' |
YES | YES | YES | %27 |
( |
YES | YES | YES | %28 |
) |
YES | YES | YES | %29 |
* |
YES | NO | YES | %2A |
+ |
YES | NO | YES | %2B |
, |
YES | YES | YES | %2C |
- |
YES | NO | YES | %2D |
. |
YES | NO | YES | %2E |
/ |
YES | NO | YES | %2F |
0 |
NO | NO | NO | 0 |
1 |
NO | NO | NO | 1 |
2 |
NO | NO | NO | 2 |
3 |
NO | NO | NO | 3 |
4 |
NO | NO | NO | 4 |
5 |
NO | NO | NO | 5 |
6 |
NO | NO | NO | 6 |
7 |
NO | NO | NO | 7 |
8 |
NO | NO | NO | 8 |
9 |
NO | NO | NO | 9 |
: |
YES | NO | YES | %3A |
; |
YES | YES | YES | %3B |
< |
YES | YES | YES | %3C |
= |
YES | YES | YES | %3D |
> |
YES | YES | YES | %3E |
? |
YES | YES | YES | %3F |
@ |
YES | YES | YES | %40 |
A |
NO | NO | NO | A |
B |
NO | NO | NO | B |
C |
NO | NO | NO | C |
D |
NO | NO | NO | D |
E |
NO | NO | NO | E |
F |
NO | NO | NO | F |
G |
NO | NO | NO | G |
H |
NO | NO | NO | H |
I |
NO | NO | NO | I |
J |
NO | NO | NO | J |
K |
NO | NO | NO | K |
L |
NO | NO | NO | L |
M |
NO | NO | NO | M |
N |
NO | NO | NO | N |
O |
NO | NO | NO | O |
P |
NO | NO | NO | P |
Q |
NO | NO | NO | Q |
R |
NO | NO | NO | R |
S |
NO | NO | NO | S |
T |
NO | NO | NO | T |
U |
NO | NO | NO | U |
V |
NO | NO | NO | V |
W |
NO | NO | NO | W |
X |
NO | NO | NO | X |
Y |
NO | NO | NO | Y |
Z |
NO | NO | NO | Z |
[ |
YES | YES | YES | %5B |
\ |
YES | YES | YES | %5C |
] |
YES | YES | YES | %5D |
^ |
YES | YES | YES | %5E |
_ |
NO | YES | YES | %5F |
{ |
YES | YES | YES | %7C |
} |
YES | YES | YES | %7D |
~ |
NO | YES | YES | %7E |
To sign and assemble URI:
$payload = [$number, $total, $city, $phase, $indicator];
for ($i = 0; $i < length($payload); $i += 1) do
$upcasedValue = upcase($payload[$i]);
$encodedValue = percentEncode($payload[$i]);
$payload[$i] = $encodedValue;
end
$payloadString = join('/', $payload);
$payloadHash = sha256($payloadString.to('utf-8'));
$keyId = $DNS_TXT_FQDN || $URL_TO_PEM_FILE || $REF_TO_DATABASE
$signatureDER = ecdsaSign($payloadHash);
$signature = b32toB32URL(b32encode($signatureDER))
$base = join(':', ["cred", $type, $version, $signature, $keyId]);
$upcasedBase = upcase($base);
$uri = $upcasedBase + ":" + $payloadString;
To parse and verify a URI:
[$schema, $type, $version, $signature, $keyId, $payloadString] = qr.split(':')
$payload = $payloadString.split('/')
$publicKeyPem = localDB($keyId) || download($keyId)
$payloadHash = sha256($payloadString.to('utf-8')))
$signatureDER = b32decode(b32URLtoB32($signature))
$valid = ecdsaVerify($signatureDER, $payloadHash, $publicKeyPem)
for ($i = 0; $i < length($payload); $i += 1) do
$payload[$i] = percentDecode($payload[$i]);
end
To remove padding from Base32-encoded strings do:
$base32URL = $base32.replaceAll("=", "");
To add padding back to Base32-encoded strings do:
switch ($base32URL.length % 8) {
case 2: $base32 = $base32URL + "======"; break;
case 4: $base32 = $base32URL + "===="; break;
case 5: $base32 = $base32URL + "==="; break;
case 7: $base32 = $base32URL + "="; break;
default: $base32 = $base32URL;
}
$gitHubTree = "https://api.github.com/repos/Path-Check/paper-cred/git/trees/"
$rootDir = JSON.parse(fetch($gitHubTree)).tree
$payloadsDir = $rootDir.find(element => element.path === 'payloads');
$payloadDir = JSON.parse(fetch($gitHubTree + $payloadsDir.sha)).tree
$payloadNames = $payloadDir.map(x => x.path.replaceAll(".md","").replaceAll(".",":"));
[$id, $database] = $keyId.split('.')
$gitHubTree = "https://api.github.com/repos/Path-Check/paper-cred/git/trees/"
$rootDir = JSON.parse(fetch($gitHubTree + "main")).tree
$keysDir = $rootDir.find(element => element.path === 'keys')
$databasesDir = JSON.parse(fetch($gitHubTree + $keysDir.sha)).tree
$databaseDir = $databasesDir.find(element => element.path === $database)
$pemFiles = JSON.parse(fetch($gitHubTree + $databaseDir.sha)).tree
$pemFile = $pemFiles.find(element => element.path === $id+".pem")
$publicKeyPem = fetch($gitHubTree + $pemFile.sha)
Issues and pull requests are very welcome! :)