BlockchainCards protocol

Show your users' BlockchainCards in your own app. Back to the app

Building with an AI assistant?

Download guide_for_llm.md and give it to your AI coding assistant (ChatGPT, Claude, Copilot, …) with what you want to build, for example "Using this guide, show the user's BlockchainCards in my app". It holds everything on this page in one file, with ready-to-use, tested JavaScript.

Download guide_for_llm.md

How it works

Users add and edit their cards in the BlockchainCards web app. Your app only has to read them. There is no server and no API key: the cards live on the Solana blockchain, and your app reads them directly.

So there are three steps: 1. get the data key, 2. read the encrypted cards, 3. decrypt and draw them.

Program id (mainnet)8sDonRmJgkhq7wSrnwbEk488m2PVzAfHUyejmhssSRQ4
Authority address6jmedw2T7sZ8aWHY47bG9em7guA2i91psVrn9pHpkgA9
Solana RPCany mainnet server, e.g. https://api.mainnet-beta.solana.com. Reading is free.

Step 1. Get the data key

There are two ways. Pick the one that fits your app.

A. Your app has a Solana wallet: ask it to sign

Ask the user's wallet to signMessage this exact text (UTF-8). Copy it character for character, or the key will not match:

const UNLOCK_MESSAGE =
  'BlockchainCards: unlock my cards (key version 2)\n\n' +
  'Signing is free and sends nothing. This signature is the key to your encrypted ' +
  'cards: anyone who has it can read them. Only sign it in an app you trust.';
KEK     = HMAC-SHA256(key = "hwc-vault-v1", message = the 64 signature bytes)
dataKey = open the vault's wrapped key with the KEK        (see "decrypt" in step 3)

Keep the signature and the KEK in memory only: never store or send them.

B. No wallet (a watch, a TV, a kiosk): pair the device

The user approves your device once in the BlockchainCards web app, which leaves the data key on the chain, encrypted so that only your device can open it.

  1. Make a P-256 key pair and 16 random bytes (the secret). Keep both.
  2. Show the user this link as a QR code (or open it on their phone):
    https://blockchaincards.xyz/?v=1&pub=<public key>&cluster=mainnet&program=8sDonRmJgkhq7wSrnwbEk488m2PVzAfHUyejmhssSRQ4#s=<secret>
    <public key> is x and y of the key, 32 bytes each, as 128 hex characters. <secret> is the 16 bytes as 32 hex characters.
  3. The user connects their wallet on that page and taps Pair. Meanwhile your app polls its pairing record (address in step 2, with deviceId = sha256(public key bytes)) until it appears.
  4. Open the grant stored in the record:
    x       = ECDH(your private key, grant[0..64] as a P-256 public key).x
    S       = HMAC-SHA256(key = "hwc-device-v1", message = x)
    content = open grant[64..] with S                      → 48 bytes
    dataKey = content[0..32]
    check   : content[32..48] == HMAC-SHA256(key = secret, message = record owner)[0..16]
    If the check fails, the record is not for you: start again.

Keep the data key, the owner's wallet address, the key pair and the secret. The web client is a small reader that works exactly this way.

Step 2. Read the encrypted cards

Addresses

Every address is a Solana seed address (PublicKey.createWithSeed) with the authority address as the base and the program id as the owner:

seed(parts)        = first 16 bytes of sha256(parts), as 32 lowercase hex characters
vault of wallet W  : createWithSeed(authority, seed("hwc:vault"  + W),                 programId)
card N of wallet W : createWithSeed(authority, seed("hwc:card"   + W + N as 4 bytes LE), programId)
pairing record     : createWithSeed(authority, seed("hwc:device" + deviceId),          programId)

W is the 32 raw bytes of the wallet address; + joins bytes.

What is inside

Fetch accounts with getAccountInfo or getMultipleAccounts (encoding: "base64", commitment: "confirmed"). Before using one, check that its owner is the program id and that it starts with the right 8-letter tag. Numbers are little-endian.

AccountBytes
Vaulthwcvault (8) · owner (32) · revision (8) · next card id (4) · prepaid saves (4) · wrapped key: length (4) + bytes · card ids: count (4) + one 4-byte id each, in the user's order · (more data your app can ignore)
Cardhwccard1 (8) · owner (32) · card id (4) · length L (2) · written chunks (1) · encrypted card: L bytes from offset 47
Pairing recordhwcdevic (8) · owner (32) · created (8) · grant: length (4) + bytes · (owner's notes, ignore them)
  1. Read the vault of the wallet. Take its card ids.
  2. Work out each card's address and fetch them all in one getMultipleAccounts call.
  3. A card id always means the same card (an edit gets a new id), so cache cards by id. The vault's revision changes on every save: if it did not change, nothing did.

Step 3. Decrypt and draw

Decrypt (a "box")

The wrapped key, every card and the grant use the same format, made with a 32-byte secret S:

box    = 0x01 · iv (16) · AES-128-CBC ciphertext (PKCS#7 padding) · tag (32)
encKey = HMAC-SHA256(S, "hwc-enc")[0..16]
macKey = HMAC-SHA256(S, "hwc-mac")
tag    = HMAC-SHA256(macKey, 0x01 · iv · ciphertext)

To open: check the first byte is 1, check the tag, and only then decrypt with encKey and iv. A wrong tag means a wrong key.

const te = new TextEncoder();

async function hmac(key, data) {
  const k = await crypto.subtle.importKey('raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
  return new Uint8Array(await crypto.subtle.sign('HMAC', k, data));
}

// Opens a box with the 32-byte secret; returns the decrypted bytes.
async function openBox(secret, box) {
  if (box.length < 65 || box[0] !== 1) throw new Error('not a version 1 box');
  const encKey = (await hmac(secret, te.encode('hwc-enc'))).slice(0, 16);
  const macKey = await hmac(secret, te.encode('hwc-mac'));
  const head = box.slice(0, box.length - 32);          // version, iv and ciphertext
  const tag = await hmac(macKey, head);
  let diff = 0;                                         // compare every byte (no early exit)
  for (let i = 0; i < 32; i++) diff |= tag[i] ^ box[head.length + i];
  if (diff) throw new Error('wrong key');
  const aes = await crypto.subtle.importKey('raw', encKey, 'AES-CBC', false, ['decrypt']);
  const iv = head.slice(1, 17);
  return new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-CBC', iv }, aes, head.slice(17)));
}

The decrypted card

Numbers here are big-endian. QR codes and barcodes are stored ready to draw, as black and white dots.

1 byte    version (1 or 2)
1 + n     name (UTF-8, length first)
1 + n     card number (UTF-8, length first; show it under the code)
3 bytes   card colour (RGB)
1 byte    code kind: 0 none, 1 QR code, 2 barcode
2 + 2     code width and height (height is 1 for a barcode)
...       width × height dots, 8 per byte, row by row, highest bit first, 1 = black
1 + 1     logo width and height (both 0 = no logo, and the next three fields are absent)
1 + 3n    palette: number of colours n (1 to 16), then n RGB colours
1 byte    logo encoding (version 2 only; version 1 is always raw)
...       logo pixels, one palette index each:
            raw (0): two pixels per byte, high half first
            run-length (1): byte count (2), then one byte per run: (run length − 1) × 16 + index
1 + n     barcode type, e.g. "QR" or "EAN13" (may be missing on old cards)
1 + n     the code's text (same)
1 byte    brand id (optional)

Draw the code with each dot as a square (QR) or a bar (barcode), on white with a quiet margin. A card with a brand id uses a well-known logo instead of storing one; the list (id, name, colour, logo) is at /lib/brand-data.js. If you don't know an id, show the card without a logo.

Keeping the cards up to date

Rules