Bonds.tw 有備而來 · Bond for the Future 2026-08-16 EN · 中文

TWDIW Field Notes

Measured errata for Taiwan's Digital Identity Wallet (TWDIW), written while building a third-party holder. It complements denkeni's Missing Manual, which explains what the tools are and how the flows run. This page is about the things you only find out by running into them: where the documentation and the deployment disagree, where the specs are bent, and which mistakes pass your tests and fail in the field.

English, because it complements an English document and has to be sendable upstream as-is. A full 中文版 is maintained alongside it, in the same commit. All measurements taken 2026-08-16 against production endpoints, read-only.

0. How to read this page

Every claim below carries an evidence grade. This matters more than usual here: some of these are defect reports aimed at a government system, and a claim that cannot survive "did you actually test that?" damages the ones that can.

What this page does not have: we have never actually collected a credential. Nothing here required issuing one, and nothing here should be read as "we ran the whole flow and it worked". The one step that needs a form submission is the one step we have not taken.

中文摘要:這一頁是做第三方皮夾時撞到的牆——文件與實際部署的差別、規範偏差、 量到的數字,以及八件要回報上游的缺陷。每一條都標了證據等級,因為其中幾條是要送到 數發部手上的,而一條經不起「你們實測過嗎」的主張會連累其他站得住的。

1. There is no third-party wallet

Not "it is hard". The role does not exist in the documentation, the sandbox, or the data model.

RoleWhat you must do to participate
IssuerApply for a sandbox account → self-host → UAT with the TWDIW team → production
VerifierSame, with the verifier stack
Wallet (holder)Not documented. No application form, no registration, no attestation.

The account application page is addressed to "products, services or programs integrating with the sandbox", and points at issuer-sandbox and verifier-sandbox. There is no wallet-sandbox. In the Missing Manual itself, the section headed "If You're Building a Third-Party Issuer or Verifier Software" is empty, and third-party wallets do not have a heading at all.

  • CODE client_id is not a registry — it is an echo The issuer signs an ID Token whose aud is the hardcoded string "moda_dw" (twdiw-vc-handler/…/CredentialService.java). The OID4VCI handler then reads client_id back out of that aud (twdiw-oid4vci-handler/…/CredentialIssuer.java) and stores it with the pre-authorized code. At /token the wallet's client_id is matched against that stored value, and the proof JWT's iss is matched against the same value again.
    The chain checks that the string you sent equals the string you sent. There are 49 tables in the schema and none of them is a client, wallet, app or device registry. No client_secret, no client assertion, no mTLS, no Play Integrity, no App Attest, no DeviceCheck.
    Two further findings from the same read: the ID Token's signature is extracted and then never used, and AccessTokenFilter's validation is commented out with the note 「尚未啟用 AccessToken 驗證」 while /api/** is permitAll.
  • Careful What this does and does not mean It does not mean anyone can obtain a credential. The real gate on issuance is the pre-authorized code — the QR — plus an optional tx_code. The defensible statement is narrower and stronger:
    whoever can obtain the QR can complete the entire flow with any home-made wallet; the official app has no cryptographic or registration privilege.
    And a limit we cannot remove by reading: we have not sent a single request to a production issuance endpoint, so nothing here excludes a network-layer control (WAF, API gateway, mTLS, IP allowlist) that is not in the repository.

2. did:key — the spelling is not the one you have implemented

This is where a wallet fails first, and it fails as a parse error two layers away from the cause.

TWDIW uses multicodec 0xEB51 (jwk_jcs-pub), whose payload is the JWK itself as JSON text. Not p256-pub (0x1200), which carries a 33-byte compressed point and is what most did:key implementations emit for P-256. Decoded byte by byte from a real trust-list entry:

129 bytes total
first 3 bytes  = D1 D6 03      # LEB128 varint for 0xEB51
remaining 126  = {"crv":"P-256","kty":"EC","x":"kY6ina…","y":"c5je-…"}

The server side strips it with a hardcoded three-byte slice, checking neither the multibase character nor the multicodec:

String did_hex_prefix = CryptoHelper.convertBinaryToHexString(Base58.decode(did.substring(1)));
String did_hex = did_hex_prefix.substring(6);   // 去掉prefix D1D603
String did_jwk = new String(CryptoHelper.hexStringToByteArray(did_hex));

So a did:key:zDn… handed to TWDIW loses three bytes and is then parsed as JSON. You get a JSON error, and the actual cause — the wrong multicodec — is not mentioned anywhere.

  • MEASURED Every production issuer DID is JCS-canonical All 43 entries in the production trust list: multicodec 0xEB51 ×43, JWK member order crv,kty,x,y ×43, self-signature verifies ×43, embedded DID document id matches ×43, header jwk matches the DID's key ×43. The issuer side is clean.
  • n=1 The official wallet's own holder DID is not canonical jwk_jcs-pub means the JWK is canonicalised per RFC 8785, which orders members crv < kty < x < y. The one holder DID we have decoded is crv, x, y, ktykty last.
    ⚠️ One sample, from documentation rather than from a wallet we ran, with no app version recorded. Enough to make a decision about how to write a parser; not enough to file as a defect. We would need several DIDs from a stated build first.
  • Rule Emit strictly, accept loosely — and never compare raw DID strings Canonicalise everything you emit. Do not require canonicality of what you accept: a strict resolver rejects the only Taiwanese wallet in existence, and the key bytes are unambiguous either way.
    That reopens a real problem, because everything downstream compares DIDs as strings — a JWS kid against an issuer, a presenter's DID against credentialSubject.id. Close it by comparing the canonical form, computed from the key, rather than the string as it arrived. Two DIDs name one key exactly when their canonical forms match, and no spelling can hide behind another.

Test vectors. Both are real, from production:

# Ministry of Digital Affairs (canonical — must round-trip to itself)
did:key:z2dmzD81cgPx8Vki7JbuuMmFYrWPgYoytykUZ3eyqht1j9Kbrzifm9txeerMVc9oLUg2nBJJnUtgYcAYd35rw1rCLq8y3bLDDBUPH5yTYB7ocY7oPESPBXqubuwMcRzw9evbeHHyFkwsmDc43myibDChGhDk8zrgZDB4KNyXPiQvkktUwn

# A wallet holder DID (NOT canonical — must still decode)
did:key:z2dmzD81cgPx8Vki7JbuuMmFYrWPodrZSqMbCy9Ndu4UgUGy3RNkhH479eLPpbfAhVSNu7B4oJvUwLzyxiP4Jt5k9cqqmChanxAazTGxJMvGxYDApNkXeDW5MPZgZRkjRgD1yaig5KCEgAaVbg8zrvYjMTi1BzqdDpPpkeSFmJwiej9YNY

3. The credential is an SD-JWT, and nothing tells you so

This is the single most expensive thing to get wrong, because the wrong implementation passes every test you would think to write.

  • MEASURED The metadata says jwt_vc_json, for everything GET https://issuer-oid4vci.wallet.gov.tw/api/issuer/00000000/.well-known/openid-credential-issuer → 701,000 bytes, 882 credential configurations.
    format distribution: {jwt_vc_json: 882}. Occurrences of the string sd-jwt in the whole document: 0. Of _sd: 0. Of selective or disclosure: 0.
    What actually comes back is typ: vc+sd-jwt with ~-separated disclosures. A conformant OID4VCI client has no field it can read to discover this. The ~ is the only signal there is.
    (Operational aside: 701 KB of metadata to obtain one configuration. Fetch it once.)
  • MEASURED The digest is over the base64url string, not the decoded JSON Both readings are plausible and only one is right. Getting it wrong yields a wallet that finds no match for any honest disclosure and reports every real card as a forgery.
    Verified against production values rather than against a reading of the spec: six real disclosures from a driving licence issued 2025-10-07, and two published _sd entries from the same credential.
    base64url(SHA-256(<the disclosure's own base64url string, ASCII>)) reproduces both. Hashing the decoded bytes reproduces neither.
    Run it yourself:
python3 - <<'EOF'
import hashlib, base64
b64u_e = lambda b: base64.urlsafe_b64encode(b).decode().rstrip('=')
D = "WyJwbHowWFN6LW9CSEUwZTUzTFVBeWNBIiwiaWRfbnVtYmVyIiwiQTIzNDU2Nzg5MCJd"
print(b64u_e(hashlib.sha256(D.encode('ascii')).digest()))
# ApkeYAR85EzxAHS1ojnNHhG7wnCDyTt4_iCIX2VKxaw  ← the published _sd entry
EOF
  • CODE It is a hybrid: SD-JWT inside a VCDM 1.1 vc claim _sd and _sd_alg live under vc.credentialSubject, not at the top level. The type is vc.type[1], not vct. So a reader written strictly to SD-JWT VC looks in the wrong place, and one written strictly to VCDM never splits the disclosures. Both parse; both are wrong.
    Issuance and server-side verification both use com.authlete:sd-jwt, which is why the digest semantics are correct even though the metadata does not admit the format exists.
  • MEASURED statusListIndex is a string "statusListIndex": "35", not 35. A reader that accepts only numbers drops the whole credentialStatus object — and a credential with no status entry is a credential that is never checked for revocation. Silent, and in the safe-looking direction.

4. The trust list

Smaller than you would guess, anchored better than you would guess, and labelled in a way you must not show to a user.

  • MEASURED 43 entries in production, and size is not honoured GET https://frontend.wallet.gov.tw/api/did?size=20&page=0&orgType=1&status=1
    20 entries at orgType=1, 23 at orgType=2, 0 at 3 and 4. Enumerated twice at two different page sizes (20 and 5), paging until empty; both give the same totals with no duplicate identifiers.
    ⚠️ The page size is clamped to 20 but the offset appears to be computed from the requested size, so size=100&page=1 returns nothing at all. Page until you get an empty result; do not compute offsets from size.
    43 entries fit in an app bundle. That changes what an offline wallet can do.
  • MEASURED Do not display orgGroupDetail.name All 43 entries are labelled 「政府部門」 (government department). That set includes FamilyMart, President Chain Store (7-Eleven), Chunghwa Telecom, Taiwan Mobile, Far EasTone and a number of private companies.
    Any UI that renders this field literally will tell its user that FamilyMart is a government department. orgType 1/2 appears to be the issuer/verifier distinction instead — the Highway Bureau appears in both, convenience stores only in 2, and convenience stores are the verifier in the telecom-VC parcel-pickup scenario.
  • MEASURED The on-chain anchor stores the full document, not a hash Arbitrum One, contract 0x84172caf8dd126c76f1fa8a2733ca3233264d31f. The anchoring transaction for the Ministry of Digital Affairs carries 2,596 bytes of calldata containing the complete DID string and the complete self-signed DID document JWS in plaintext. sha256(DID) does not appear.
    This is stronger than the "carved in stone" framing suggests: the data itself is on chain, so a client could in principle verify the list without trusting frontend.wallet.gov.tw.
    ⚠️ We have only verified one transaction, and its hash came from the API we would be trying not to trust. Demonstrating an actually trustless path means enumerating all 43 from the contract address alone. We have not done that.
  • n=1 41 of 43 are anchored — the exceptions are worth knowing China Medical University appears twice (once under each orgType) with an empty onChainHistory, x509_type: XCA, and — although it is registered under the issuer orgTypeissuerMetadataBaseURL: null.
    ⚠️ This is the API's own self-report. We did not search the chain for them, so "not anchored" here means "the API says so", which is circular given what the anchor is for.

5. Revocation has no offline root of trust

The credential path is anchored on a public chain. The revocation path is a URL.

  • MEASURED The status list is signed by a key that is not in the issuer's DID Fetch a status list and verify it two ways:
    • with the key embedded in its own iss did:keyfails
    • with key-2 from the jku URL → verifies
    GET https://issuer-vc.wallet.gov.tw/api/keys returns two keys. key-1 is the one embedded in the issuer's DID and signs credentials. key-2 signs status lists and appears in no DID document, no trust list entry, and no on-chain record. The DID document has exactly one verification method.
    So: checking whether a TWDIW credential is revoked requires trusting a key distributed only over TLS. Caching the list does not help — you cannot verify it offline either.
  • MEASURED Validity is 24 hours from last signing, not from fetch nbfexp is exactly 86,400 seconds. But nbf is when the list was last signed, not when you fetched it.
    A status list you just downloaded has somewhere between 24 hours and almost no validity left. Show the exp the list carries; never tell a user "valid for 24 hours".
    The list itself is cheap: 76 characters compressed, 16,384 bytes decompressed, 131,072 bits of capacity, 84 bits set at the time of measurement. Fetching often is viable; the problem is only the offline stretch.
    ⚠️ n=2 on the re-signing rhythm (we have one 2025 sample re-signed at issuance and one 2026 sample on a daily boundary). Do not build a prefetch schedule on the daily boundary; the conclusion about remaining validity holds regardless, because it needs only that exp is an absolute time.

6. Age verification leaks the date of birth, and the card leaks itself

Two complete driving-licence credentials, verified end to end. What separates "over 18" from "under 18" is one disclosure — and there is no way to send it without sending the birthday.

  • MEASURED Both credentials verify, and they differ in exactly one field Two complete SD-JWTs were recovered and checked: all six disclosures reproduce their _sd digests (6/6, both credentials), and both ES256 signatures verify against key-1 fetched live from the issuer's JWKS.
    The two are structurally identical. name, id_number, type, controlnumber and gDate carry the same values in both; only roc_birthday differs — 1040605 (ROC 104 → 2015-06-05, under 18) against 0570605 (ROC 057 → 1968-06-05, over 18). Different salts, so different digests, but the same document otherwise.
  • MEASURED There is no age predicate — proving 18 means disclosing the birthday No range proof, no age_over_18 boolean, nothing derived. The only way to answer 「are you over 18」 with this credential is to disclose roc_birthday in full, to the day.
    That is the ordinary convenience-store case, and it is the case selective disclosure is usually sold on. The mechanism is present and correct — SD-JWT, salted, per-claim — and it still cannot answer this question without handing over a date that identifies its holder far more narrowly than the question required.
  • MEASURED How many fields you withheld is visible, and which ones are guessable _sd carries exactly six digests, and the credential configuration that defines those six fields is published in the issuer metadata for anybody to read. So a verifier who receives four disclosures knows two were withheld, and knows the six-item menu they were drawn from.
    Withholding is still worth doing — the values stay hidden — but it is not invisible, and a wallet that presents it as "they will not know" is overstating it.
  • MEASURED Every card carries a unique index, in the clear, on every presentation credentialStatus.statusListIndex is per-credential: #20 on one of these, #19 on the other. It is not selectively disclosable — it sits in the signed payload, outside _sd, so it goes to every verifier every time.
    Two verifiers comparing notes can tell they saw the same card, without any other field. That is inherent to StatusList2021 rather than a TWDIW mistake: the index is what makes revocation checkable. It is worth stating plainly because a wallet's selective-disclosure UI can easily imply the opposite.

7. A card in a wallet has no name it can show offline

Found by building the card list, rendering it, and looking at it. The row read 00000000_demo_drivinglicense_202504251418.

  • MEASURED The only name inside the credential is a machine identifier vc.type[1] is the sole field a wallet can call the card by, and it is an issuing-system identifier: forty-one characters, wrapping over two lines on a 390pt screen. The human-readable name lives in the issuer's metadata, at credential_configurations_supported[…].display[].namean HTTPS endpoint (measured: 701 KB, 882 configurations).
    So an offline wallet cannot tell its holder what the card in front of them is called. Standing at a counter, they read a string of underscores and digits and work out for themselves which one is the driving licence.
  • MEASURED And no signature covers that name, so caching does not fix it This differs from revocation, which at least degrades in a way that can be stated ("unknown after 24 hours offline"). A name fetched once and cached is not part of the credential — nothing signs it — so a wallet that displays a cached label is displaying its own note, not the issuer's assertion. There is no offline path to a trustworthy card name at all.
  • CODE What we do about it, and what we refuse to do CardInventory.readableType drops the two segments that are meaningless to a holder — a leading all-digit issuer code and a trailing 8–14 digit issuance timestamp — and shows the rest. The row above becomes demo_drivinglicense.
    Deliberately no lookup table. A table of known types is a guess about every type it does not contain, and the types it does not contain are exactly where a wrong guess would matter. Both edits are conditional on the shape being present, and the original string is returned whenever stripping would leave nothing.
    This improves legibility and does not solve the problem: demo_drivinglicense is still the issuing system's word, not the holder's. The fix belongs at the issuer, which is why it is item 8 below.

8. Eight things worth reporting upstream

Ordered by how much a user is harmed, not by technical severity. Numbers 6 and 7 are held back pending better evidence, and are listed here so the reason is visible rather than silent.

  • CODE 1. Selective disclosure can send fields the user did not select The wallet SDK decides which disclosures to keep by substring-matching the requested field name against the whole decoded JSON array — which contains the salt and the value as well as the name:
    String decoded = utf8.decode(base64.decode(base64Url)); if (decoded.contains(field)) { … }
    The driving licence has both id_number and controlnumber, and both contain the substring number. A value containing another field's name matches too; a random salt can match by chance. Separately, a continue sits in the inner loop, so a disclosure matching two requested fields is appended twice.
    A user who ticks "disclose only A" can send A and B. This is the core promise of the feature, and it is the one defect on this page whose harm needs no explanation.
    ⚠️ Source-only. No app version recorded, and we did not observe it in traffic.
  • CODE 2. TLS hostname verification is disabled on four outbound fetches Issuer public key, status list, schema, and the frontend DID lookup all pass setAllowedHostnames(null). In HttpUtils that does not mean "no allowlist, use the system default" — the custom HostnameVerifier returns true unconditionally. The binding between the TLS certificate and the hostname is gone on those four connections.
    RFC 8725 §3.8 requires jku to be restricted to an allowlist of trusted URLs.
    ⚠️ Source-only; this describes the published verifier stack, which each verifying organisation deploys itself. We cannot confirm what any given deployment runs.
  • CODE 3. Status list and schema are fetched before the issuer signature is verified validateVC() launches six steps as concurrent futures; fetching the status list (step 3) and the schema (step 5) start before the issuer's signature is checked (step 6), and both URLs come out of the not-yet-verified payload. Combined with finding 2, anyone who can POST a VP can make the verifier issue a GET to an arbitrary URL.
    Note also that StatusListCheckTask has no DID-first attempt — it goes straight to loadIssuerPublicKey(jku, kid). This is the one place where the jku pattern is genuinely live in production, and it is self-referential: the list names the key that verifies the list.
  • CODE 4. client_id carries no information, and two checks are switched off See §1. The ID Token signature is never verified; AccessTokenFilter's validation is commented out.
  • MEASURED 5. format does not describe the credential 882 of 882. See §3.
  • CODE 6. The VP object uses context, not @context All three VP-generating functions in the wallet SDK write 'context' without the at-sign. Searching for the VCDM context URL finds only those three, so no path in the system emits the correct key.
    ⚠️ We first described this as "JSON-LD processing silently drops the claims". That was wrong — the TWDIW verifier does not do JSON-LD expansion, so nothing is being dropped today. The accurate statement is that this is an interoperability defect for any third-party verifier that does expand, not a live data-loss bug. Worth reporting; not worth reporting the way we first wrote it.
  • n=1 7. Holder DIDs are not JCS-canonical — held back See §2. One sample, second-hand, no app version. We are not filing this until we have several DIDs from a stated build. Listed so that the gap is visible.
  • MEASURED 8. The credential carries no offline-readable name See §7. vc.type[1] is an internal identifier; the display name exists only in online metadata and is not covered by any signature.
    Suggested fix: carry the display name in the VC itself — a vc.name, or SD-JWT VC's vct together with signed type metadata — so that an offline wallet can show a name its holder recognises.
    Low on the harm ordering: nobody is impersonated by it. But it is the one item on this list that every holder meets every time they open the wallet, which most of the others do not.
  • Design And one that is not a defect: issuers cannot rotate keys Issuer identity is a did:key — the key is the identity. So rotating a signing key means becoming a different issuer, and every credential already issued has an iss that is no longer in the trust list. A wallet cannot distinguish "this issuer was removed" from "this issuer rotated and the old cards are still genuine".
    This is the only item here that would make cards already in people's hands collectively unassessable.

9. What we have not verified

Listed because a page of findings without this section is asking to be over-read.