Handle digiid:// in your wallet

Last verified against DigiByte Core v9.26.5

Handle digiid:// in your wallet

A Digi-ID login is one signed message. The wallet parses a digiid:// URI, shows the user which site is asking, signs the exact URI with an identity key, and POSTs the signature to the site's callback. This guide walks through the DigiByte Android wallet's implementation, which is open source, so you can add the same capability to your own wallet.

Who this is for. You maintain a DigiByte wallet, mobile or desktop, and want your users to sign in to Digi-ID sites. You need a URI parser, a key-derivation choice, a Bitcoin-style message signer with DigiByte's prefix, and an https POST.

What you will build. A digiid:// handler: parse, confirm, choose a key, sign, post, and handle the reply.

Anatomy of a digiid:// URI Scheme digiid, then the callback host and path, then the x parameter carrying the 32-hex nonce, and optionally u=1 meaning the callback is plain http, which the DigiByte Android wallet refuses. digiid:// your-app.example /api/auth/digiid/callback ?x=a1b2…f0 [&u=1] scheme — the OS routes it to a wallet host — the domain the wallet shows the user; the callback must be this host or a subdomain path — the wallet POSTs here over https x — the nonce: opaque, single-use, the only thing that changes per login (DigiScope's are 32 lowercase hex) u=1 — "callback is plain http": a developer-only flag; the DigiByte Android wallet rejects it outright
Everything the wallet needs is in the URI. The nonce is the only part that changes per login.

Step 1 — Register the scheme and parse the URI

Register digiid as a URL scheme your app opens, and accept the same string from a QR scanner. Parsing is deliberately simple: strip the scheme, split host-and-path from the query, read x (the nonce) and u (plain-http flag), and derive the callback URL and the domain to show the user.

from core/src/main/java/io/digibyte/core/model/DigiIdRequest.kt

        fun parse(uri: String): DigiIdRequest? {
            if (!uri.startsWith("digiid://")) return null
            val withoutScheme = uri.removePrefix("digiid://")
            val parts = withoutScheme.split("?", limit = 2)
            val callbackPath = parts[0]
            val params = if (parts.size > 1) {
                parts[1].split("&").associate {
                    val kv = it.split("=", limit = 2)
                    kv[0] to (kv.getOrNull(1) ?: "")
                }
            } else emptyMap()

            val nonce = params["x"] ?: return null
            val unsecure = params["u"] == "1"
            val scheme = if (unsecure) "http" else "https"
            val callbackUrl = "$scheme://$callbackPath"
            val domain = callbackPath.split("/")[0].split(":")[0]

            return DigiIdRequest(uri.trim(), callbackUrl, nonce, unsecure, domain)
        }

Keep the original URI string. You will sign it and echo it back exactly; the server compares it byte for byte against what it issued. The Android wallet trims the raw scan before parsing (a scanner can add stray whitespace, and servers never emit any) and parse trims again when it stores the URI it will sign; the startsWith("digiid://") check itself runs on the string it is handed, so trim before you parse.

Step 2 — Confirm with the user, and refuse the unsafe cases

Show the domain. Then check two things before any key is touched: the callback host must be the shown domain or a subdomain of it, and the callback must be https.

from core/src/main/java/io/digibyte/core/digiid/DigiIdManager.kt

            if (callbackHost != request.domain && !callbackHost.endsWith(".${request.domain}")) {
                Log.e(TAG, "Callback host mismatch: $callbackHost vs ${request.domain}")
                return@withContext DigiIdResult.Error(1, "Callback domain doesn't match — possible phishing")
            }

            // HIGH-3: Block plaintext HTTP callbacks — signatures must never travel in cleartext
            if (request.isUnsecure) {
                Log.w(TAG, "Rejecting insecure (HTTP) Digi-ID callback to ${request.domain}")
                return@withContext DigiIdResult.Error(1, "Insecure (HTTP) authentication not allowed")
            }

Security: Require the user to unlock (PIN or biometric) before signing, and never log the URI or the nonce. The nonce is a bearer token for the site's status endpoint: whoever reads it out of your logs can poll that endpoint and claim the browser session the moment your signature lands.

Step 3 — Choose the identity key

Digi-ID does not say which key to sign with. There are two reasonable answers, and the Android wallet uses both.

Which key signs a Digi-ID login From the wallet seed, the legacy shared identity is m/0'/0/0 and yields one address for every site. The per-site identity follows SLIP-0013: hash the little-endian index and the canonical callback URL with SHA-256, read the first sixteen bytes as four little-endian 32-bit integers, and derive m/13'/A'/B'/C'/D' with every step hardened. wallet seed legacy identity m/0'/0/0 one address for every site used for DigiScope's own domains and any site you already logged into this way linkable across sites — and to the wallet's first receive address per-site identity (SLIP-0013) h = SHA256( LE32(i) ‖ callbackUrl ) A,B,C,D = h[0..15] as LE uint32 m/13'/A'/B'/C'/D' every step hardened; stable per site unlinkable across sites — the default for new domains
One shared identity, or one identity per site. The per-site scheme is SLIP-0013, derived from the callback URL.

The legacy identity is m/0'/0/0 (BIP32 account 0', external chain, index 0: the first receive key of the bread-wallet derivation this app inherited), one address for every site. It is what the Android wallet's signMessage hardcoded before per-site identities existed, and some sites, DigiScope's Hub among them, have already bound accounts to that address. Because it is also the wallet's first receive address, every legacy login publishes an address that may carry on-chain history and ties the login identity to it. The wallet keeps it only for accounts already bound to it; a new wallet should not adopt it. The per-site identity follows SLIP-0013: hash the little-endian account index and the canonical callback URL, read the first sixteen bytes of the digest as four little-endian 32-bit integers, and derive m/13'/A'/B'/C'/D' with every step hardened. It is stable for a given site and unlinkable across sites.

from native/src/main/jni/bridge/slip13.c

int slip13_indexes(uint32_t out[4], const char *uri, size_t uriLen, uint32_t index) {
// …
    buf[0] = (uint8_t)(index & 0xff);
    buf[1] = (uint8_t)((index >> 8) & 0xff);
    buf[2] = (uint8_t)((index >> 16) & 0xff);
    buf[3] = (uint8_t)((index >> 24) & 0xff);
    memcpy(buf + 4, uri, uriLen);

    uint8_t h[32];
    BRSHA256(h, buf, totalLen);
    free(buf);

    for (int i = 0; i < 4; i++) {
        out[i] = ((uint32_t)h[4 * i]) |
                 ((uint32_t)h[4 * i + 1] << 8) |
                 ((uint32_t)h[4 * i + 2] << 16) |
                 ((uint32_t)h[4 * i + 3] << 24);
    }

The policy that picks between them is a pure function so it can be unit-tested: DigiScope's own domains stay on the legacy key because the wallet's Hub account is bound to that address, any domain the user already logged into with the legacy key stays on it so they are not locked out, and everything else gets a per-site identity.

from core/src/main/java/io/digibyte/core/digiid/IdentityKeyPolicy.kt

    fun choose(domain: String, hasSuccessfulLegacyHistory: Boolean): IdentityKeyKind =
        if (isDigiScopeDomain(domain) || hasSuccessfulLegacyHistory) IdentityKeyKind.LEGACY
        else IdentityKeyKind.PER_SITE

Trap: The per-site derivation input is the canonical callback URL, without the query string, so the nonce never changes the key. If you hash the whole URI you get a different address on every login and no site can ever recognise the user.

Step 4 — Sign the URI

Sign the URI string exactly as scanned, using the Bitcoin-style signed-message scheme with DigiByte's prefix, hashed with double SHA-256, producing a 65-byte compact recoverable signature. Sign with a compressed key so the address the server derives from the recovered public key matches the one you report.

from native/src/main/jni/bridge/jni_wallet_sign.c

static jstring sign_with_key(JNIEnv *env, BRKey *key, const char *msgChars,
                             size_t msgLen, jint addressFormat, const char *tag) {
    key->compressed = 1;
// …
    static const char header[] = "\x19" "DigiByte Signed Message:\n";
    size_t headerLen = sizeof(header) - 1; /* exclude null terminator */

    uint8_t varint[9];
    size_t varintLen = write_varint(varint, msgLen);

    size_t totalLen = headerLen + varintLen + msgLen;
    uint8_t *payload = malloc(totalLen);
// …
    memcpy(payload, header, headerLen);
    memcpy(payload + headerLen, varint, varintLen);
    memcpy(payload + headerLen + varintLen, msgChars, msgLen);

    /* Double SHA256 */
    UInt256 md;
    BRSHA256_2(&md, payload, totalLen);
    free(payload);

    /* Compact recoverable signature (65 bytes: 1 recovery + 32 r + 32 s) */
    uint8_t compactSig[65];
    size_t sigLen = BRKeyCompactSign(key, compactSig, sizeof(compactSig), md);
The 65-byte compact signature Byte zero is the header: 27 plus the recovery id, plus 4 when the public key is compressed, so 31 to 34 for a compressed key. Bytes 1 to 32 are r and bytes 33 to 64 are s. Any other header byte fails verification. header byte 0 r bytes 1–32 s bytes 33–64 header = 27 + recid (0..3) + 4 if the public key is compressed compressed key → 31, 32, 33 or 34. The recovery id lets the verifier recompute the public key without being sent it. Base64 of 65 bytes is 88 characters. The verifier throws on any other header, and the DigiByte wallet always signs with a compressed key.
The header byte carries the recovery id and the compressed flag. Verifiers reject anything outside 27 to 34.

The address you report must be the legacy P2PKH form of the signing key (D…). Today's servers, DigiScope included, recover a legacy address from the signature and compare strings; a bech32 address fails even with a valid signature.

Step 5 — POST the callback

Send JSON with uri, address, and signature to the callback URL over https. The protocol's answer is the status code: treat any 2xx as success and anything else as a rejection. Some servers, DigiScope among them, also return a sessionToken in the body; the Android wallet's generic path ignores the body entirely, and you should never log it, since it may contain tokens or attacker-controlled content.

from core/src/main/java/io/digibyte/core/digiid/DigiIdManager.kt

            val json = JSONObject().apply {
                put("uri", request.rawUri)
                put("address", address)
                put("signature", signature)
            }

            val body = json.toString().toRequestBody("application/json".toMediaType())
            val httpRequest = Request.Builder()
                .url(request.callbackUrl)
                .post(body)
                .build()

            val response = httpClient.newCall(httpRequest).execute()
            val responseBody = response.body?.string()
            val success = response.isSuccessful

            // MEDIUM-2: Don't log response body — could contain tokens or attacker content
            Log.d(TAG, "Digi-ID response: ${response.code}")

Record the attempt (domain, address, success, which derivation) so the key policy in Step 3 can honour existing accounts.

Known limitation: After a successful callback on a DigiScope domain, the Android wallet's QR path posts the same signed URI a second time to obtain a Hub token. The server's replay guard rejects that second post with 401, by design, so the Hub token is never obtained this way. The correct pattern is the one-tap flow below: keep the sessionToken from the first callback response. A wallet-side fix is tracked.

The one-tap variant: your app is both browser and wallet

A native app does not need a QR code at all. Request a challenge from the server, sign the returned URI locally, post the callback, and keep the session token from the response as your API token. No polling, no second request.

from core/src/main/java/io/digibyte/core/digiscope/DigiScopeClient.kt

            val challengeJson = JSONObject(challengeResp.body?.string() ?: return@withContext false)
            val uri = challengeJson.optString("uri", null) ?: return@withContext false
            android.util.Log.i("DigiScope", "quickLogin: got challenge, signing")

            // 2. Sign with wallet key
            val signResult = io.digibyte.core.bridge.NativeBridge.signMessage(uri, 0)
// …
            val address = parts[0]
            val signature = parts[1]
            android.util.Log.i("DigiScope", "quickLogin: signed, submitting callback")

            // 3. Submit to callback
            val token = login(address, signature, uri)

Verify it

Two checks, neither needs a server:

  1. Parse. Use the Android wallet's own vectors from core/src/test/java/io/digibyte/core/model/DigiIdRequestTest.kt: digiid://example.com/callback?x=abc123nonce → callback https://example.com/callback, domain example.com, nonce abc123nonce, not unsecure. digiid://example.com/auth?x=nonce42&u=1 → unsecure, callback http://example.com/auth. digiid://secure.example.com/login?x=mynonce&u=0 → not unsecure, callback https://secure.example.com/login. digiid://localhost:8080/auth?x=nonce&u=1 → domain localhost (the port is stripped from the domain and kept in the callback http://localhost:8080/auth). digiid://example.com/callback and digiid://example.com/callback?u=1 (no x) → rejected, as is any URI whose scheme is not digiid. Treat the nonce as opaque when parsing: DigiScope's are always 32 lowercase hex characters, but other servers differ, and the server, not the wallet, enforces nonce shape.
  2. Header byte. Sign any string with a known compressed key and decode the base64 signature: 65 bytes, and byte 0 must be 31, 32, 33 or 34. If you see 27 to 30 your key is uncompressed and servers will derive the wrong address.

Check: Sign the same URI twice. A deterministic signer (RFC 6979, which is what the Android wallet's BRKeyCompactSign uses via libsecp256k1) produces byte-identical signatures; a random-nonce signer produces two different ones. Either is fine as long as both verify. If only one verifies, your hashing or message-prefix handling is wrong.

Traps

Previous: the server side, in Add Digi-ID login to your app.