URL-based Authorization (NFC Tag)
When a card is tapped on a mobile device without the app being open, the OS reads the NDEF record. For United Network cards, this is a URL containing the card's public keys.
The project supports two primary ways to authorize a user:
- Direct Card Interaction (NFC/FIDO): Tapping the physical card against the device.
- URL-based Authorization: Accessing the application via a unique URL stored on the card's NFC tag (NDEF).
URL Structure
https://united.network/<HEX_DATA>
The <HEX_DATA> is a 134-symbol hash representing the serialized public keys.
Example: https://united.network/02f1f2f3f4f5f6f7f8f91011121314151617181920212223242526272829303132e1e2e3e4e5e6e7e8e91011121314151617181920212223242526272829303132
Hex Data Decoding
The <HEX_DATA> is a serialized list of public keys. The format is:
- Key Count (1 byte): Number of keys included.
- Keys (33 bytes each): Each key starts with a type/prefix byte.
In the example provided:
02: 2 keys are present.- Key 1 (33 bytes in stream, 66 symbols parsed):
f1f2f3f4f5f6f7f8f9101112131415161718192021222324252627282930313233(secp256k1) - Key 2 (33 bytes in stream, 64 symbols parsed):
e1e2e3e4e5e6e7e8e91011121314151617181920212223242526272829303132(ed25519)
Total stream length: $1 + 33 + 33 = 67$ bytes $\rightarrow$ 134 hex characters in the URL.
3. The ioPublicKey and Secure Communication
The smart card uses three types of keys:
- Type 0 (
ioPublicKey): A NIST P-256 public key used for secure communication (66 symbols). - Type 1 (
secp256k1): Used for Bitcoin, Ethereum, and other EVM chains (66 symbols). - Type 2 (
ed25519): Used for Solana, TON, and other high-performance chains (64 symbols).
Why ioPublicKey is missing from the URL
The URL written on the NFC tag typically only includes the wallet keys (secp256k1 and ed25519) to allow the dApp to resolve the wallet address and show balances immediately. The ioPublicKey is omitted to save space on the NDEF record and because it is primarily used for active, secure sessions with the card hardware.
4. Authorization vs. Recovery
There is a critical difference between simply authorizing a view and performing sensitive operations like Card Recovery.
The Recovery Requirement
Card Recovery (CmdInit) involves transferring a new seed phrase to the card's secure element. This process MUST be encrypted to prevent eavesdropping on the NFC communication.
- Authorization via URL: Provides only wallet keys. It is sufficient to view the wallet but insufficient to talk to the card securely.
- Authorization via Tap: By tapping the card, the app executes
CmdGetInfo, which returns the full set of keys, including theioPublicKey.
Why you must Authorize (Tap) before Recovery
Even if a user enters the app via a URL (and is thus "Authorized" to see their wallet), they do not have the ioPublicKey in local state.
Because CmdInit (the command to write the seed) requires ioPublicKey to establish an encrypted channel (AES-CTR), the app forces a "Validation Tap" at the start of the recovery process. This tap ensures:
- The app fetches the latest
ioPublicKeydirectly from the card. - The app confirms the physical presence of the card intended for recovery.
5. Implementation
Parsing logic (card-io.ts)
export function parsePublicKeyHexList(src: string): CardKeys | undefined {
try {
const reader = new BufferReader(Buffer.from(src, "hex"));
const keys = readPublicKeys(reader); // Reads keyCount then N * 33 bytes
reader.failIfNonEmpty();
return keys;
} catch(_) {
return undefined;
}
}Authorization Handler (Authorization.vue)
The page checks the URL parameters on mount. If a publicKey is present, it parses it and proceeds to authorization without requiring an initial tap, but the resulting state will lack ioPublicKey.
onMounted(() => {
if (route.params.publicKey) {
publicKeyCard.value = parsePublicKeyHexList(route.params.publicKey);
authorizationHandler();
}
});Recovery Flow (RestoreSeedPhrase.vue)
The recovery page enforces a tap to ensure the ioPublicKey is available in the store before allowing the writeSeed operation.
const authorizeCard = async() => {
const resultPrepare = await CmdGetInfo.buildRequest();
const resultAuth = await nfcHandlerWindow.value?.nfcProgressHandler(resultPrepare, CmdGetInfo, false);
// This fetches the ioPublicKey required for encryption
publicKeyCard.value = resultAuth.value?.keys;
store.dispatch('user/setPinPublicKey', publicKeyCard.value?.ioPublicKey);
}