DecideID Verification
Legacy provider
Section titled “Legacy provider”DecideID is one of four verification methods on
ohshii_governance, and it is now a legacy one. It carries Strong strength and every existing DecideID verification remains valid indefinitely — nothing here is being taken away from anyone who already holds it.What changes is intake: the provider no longer accepts new proofs since 2026-09-05 (
accepts_new_proofs = false). A wallet that wants to become verified uses Google (Standard) or Telegram (Strong). The configuration stays on-chain and is listed in §1, together with the two proposals that would re-activate the provider.DecideID has never powered the veto-override round and will not; that round needs a per-account anchor, which a per-wallet verification cannot supply. See Capture Resistance.
DecideID (DecideAI Proof-of-Personhood) is one of four identity-verification methods on ohshii_governance, and one of the three Strong ones. Every method is independent: each has its own DAO-controlled config, its own per-method verified set, and its own flags. A user verified via any method satisfies the standard gate; a critical action needs a Strong method.
This document is the developer and DAO-voter reference for the DecideID integration. The parallel provider docs are WORLD_ID_VERIFICATION.md, GOOGLE_VERIFICATION.md and TELEGRAM_VERIFICATION.md; the model that composes them is in GOVERNANCE.md.
1. Configuration states
Section titled “1. Configuration states”| State | issuer_canister_id | enabled | Effect on ONS voting | What the user sees on /verify |
|---|---|---|---|---|
| Not configured | 2vxsx-fae (anonymous) | either | No DecideID gate. (Another provider may still gate.) Verification flow refuses to start. | Dimmed DecideID method card with the badge “Not configured”. CTA disabled. |
| Optional | a real principal | false | No DecideID gate. (Another provider may still gate.) Verification is still possible — useful when the DAO wants to A/B test the flow before flipping it required. | Lit DecideID card, badge “Optional”, CTA reads “Verify with DecideID (optional)”. |
| Required | a real principal | true | vote() rejects unless is_any_method_verified(caller) returns true. | Lit DecideID card, badge “Required for voting”, CTA reads “Start DecideID verification”. |
Important persistence rule: switching a method from Required/Optional back to Not Configured does NOT clear existing verifications. Once a principal is in DECIDEID_VERIFIED_USERS, the flag stays until it is cleared via admin_clear_decideid_verified, executed by ohshii_governance through an approved proposal (and, during the transition phase, also by the deploying principals). The gate predicate is_any_method_verified in memory.rs reads the verified-user sets, not the enabled flags.
accepts_new_proofs — the retirement flag
Section titled “accepts_new_proofs — the retirement flag”DecideIdConfig carries a second, independent flag:
| Flag | Question it answers |
|---|---|
enabled | Does this provider make the verification gate mandatory? |
accepts_new_proofs | Will this provider still register a new proof? |
The three states in the table above are all states of enabled;
accepts_new_proofs cuts across them. true — the state today, and what a
configuration predating the flag resolves to — means everything on this page is
live. false is the retired state: every entry point that would register a
new proof refuses with a message saying so, in both the VC and the OIDC mode,
and everyone already verified stays verified with Strong strength. Retirement
removes intake, never standing.
The flag is flipped by set_decideid_accepts_new_proofs, executed by
ohshii_governance through an approved proposal (and, during the transition
phase, also by the deploying principals). The tier depends on the direction:
reopening intake (true) runs the critical route and is guardian-vetoable, because every
open provider is another way for one person to enter the electorate again on a second
wallet; closing intake (false) runs the standard route, so the defensive move stays
fast. The positional configuration setter preserves the flag by construction, so changing
any other field cannot silently reopen or close intake.
Important gate semantics — what happens when EVERY method is disabled: the gate composes the enabled flags of all four providers in one place. With every flag false, nothing is mandatory and ONS voting becomes open to any authenticated wallet — no verification required, no per-method check. Anonymous principals are still rejected by the reject_anonymous guard on vote(). All other voting rules (rate limit, voting power, proposal status) still apply. Concretely: a DAO that turns off every gate is choosing to remove the Sybil filter altogether; a DAO that wants to keep voting paused without lifting the filter must leave at least one method enabled = true and use the per-provider intake controls to block new verifications.
Combined state truth table, for an unverified caller on a standard proposal. S is any Strong provider (World ID, DecideID, Telegram); G is Google:
Any S.enabled | G.enabled | ONS vote() on a standard proposal | ONS vote() on a critical proposal |
|---|---|---|---|
| false | false | Allowed — nothing is mandatory | Allowed — nothing is mandatory |
| false | true | Rejected unless verified by any method | Rejected unless verified by any method |
| true | either | Rejected unless verified by any method | Rejected unless verified by a Strong method |
The row that surprises people is the second one: with only Google enabled, a critical proposal needs only Google. There is no Strong provider available to demand, and demanding one anyway would make the critical tier unreachable.
In every gated row, what answers “is this caller verified?” is the union over the
verified-user sets — the per-method enabled flags are ignored for that
answer (persistence-on-disable). A caller verified via World ID passes a
DecideID-only gate, and vice versa.
State transitions:
- Not Configured → Optional / Required: ONS proposal
set_decideid_config(<issuer principal>, "<origin>", "<credential_type>", "<derivation_origin>", true|false, 300). - Optional ↔ Required: same proposal, flipping
enabled. - Emergency pause: ONS proposal
set_decideid_daily_cap(0)halts new verifications without touchingenabledor any existing verifications. - Rollback to Not Configured: ONS proposal
set_decideid_config(2vxsx-fae, "", "", "", false, 300)returns the integration to dormant. Existing verified users keep voting access.
Authoritative configuration values
Section titled “Authoritative configuration values”The record below is the mainnet configuration as read with the anonymous query on 2026-09-05, the day the provider was closed to new proofs. Mainnet runs the OIDC path; the VC rows further down describe the alternative path, which mainnet does not run. None of these values is a secret — the OIDC client secret is a separate cell that no query returns. Read the live record at any time:
icp canister call xnsdw-yaaaa-aaaal-qsqua-cai get_decideid_config '()' -n ic --query --identity anonymous -o candid| Field | Value | Notes |
|---|---|---|
issuer_canister_id | qbw6f-caaaa-aaaah-qdcwa-cai | DecideAI’s issuer canister. |
issuer_origin | https://id.decideai.xyz | No trailing slash on mainnet: the OIDC path compares it to the token issuer; the trailing-slash rule below applies to the VC path only. |
credential_type | ProofOfPersonhood | |
derivation_origin | https://v5mnw-oaaaa-aaaal-qsica-cai.icp0.io | Native canister URL — DNS-renewal-independent. |
mode | variant { Oidc } | The OAuth 2.0 path (see §13). |
enabled | true until the switch-off step, then false | Engages the gate; does not affect who is already verified. |
accepts_new_proofs | unset (accepting) until 2026-09-05, then false | The retirement flag. |
presentation_max_age_seconds | 0 | No extra freshness bound beyond the token’s own. |
oidc_expected_issuer | https://id.decideai.xyz | |
oidc_client_id = oidc_expected_audience | 36f24e91-5686-4f42-aa38-50f9c024bdaf | A public OAuth client id, not a secret. |
oidc_authorize_url | https://id.decideai.xyz/#/authorize | |
oidc_token_url | https://rlz47-aqaaa-aaaah-qdcra-cai.icp0.io/token | |
oidc_userinfo_url | https://rlz47-aqaaa-aaaah-qdcra-cai.icp0.io/userinfo | |
oidc_jwks_url | https://rlz47-aqaaa-aaaah-qdcra-cai.icp0.io/.well-known/jwks.json | |
oidc_redirect_uri | https://v5mnw-oaaaa-aaaal-qsica-cai.icp0.io/decideid-callback | |
vc_minimum_verification_date | "" | VC path only. Empty → arguments: None. |
vc_minimum_reputation_level | "" | VC path only. Accepted values when set: "gold" or "silver". |
Retired to new proofs on 2026-09-05 — the configuration stays on-chain
Section titled “Retired to new proofs on 2026-09-05 — the configuration stays on-chain”On 2026-09-05 the provider was closed to new proofs (set_decideid_accepts_new_proofs(false)),
and its gate flag is switched off once Telegram and Google are enabled
(set_decideid_config with enabled = false) — in that order, so the gate never drops
in between. Neither step erases anything: set_decideid_config is a read-modify-write
that replaces only its six positional fields and keeps the OIDC fields, the mode and the
retirement flag, and the verified set is a separate store that no configuration change
touches.
Re-activating the provider takes two approved ONS proposals (during the transition phase, the deploying principals can also call both setters directly), and neither needs a value that is not in the table above or in the query:
set_decideid_accepts_new_proofs(true)— aCriticalGovernanceOperationproposal reopens intake. Reopening is refused on the standardExecuteAdminMethodroute; only closing (false) travels it.set_decideid_config(principal "qbw6f-caaaa-aaaah-qdcwa-cai", "https://id.decideai.xyz", "ProofOfPersonhood", "https://v5mnw-oaaaa-aaaal-qsica-cai.icp0.io", true, 0)— aCriticalGovernanceOperationproposal makes the gate mandatory again. The setter is positional, so all six values are passed again withenabledas the one that changes.
Asymmetric trailing-slash semantics
Section titled “Asymmetric trailing-slash semantics”The cfg field issuer_origin is stored with a trailing slash because two consumers need the string in different forms:
| Consumer | Required form | Failure if wrong |
|---|---|---|
Backend validate_ii_presentation_and_claims | "https://id.decideai.xyz/" (slash) | Validator returns InconsistentCredentialJwtClaims("inconsistent claim in VC"). |
Frontend requestVerifiablePresentation SDK call | "https://id.decideai.xyz" (no slash) | Issuer canister’s derivation_origin API rejects with UnsupportedOrigin. |
The frontend modal applies .replace(/\/+$/, '') before passing the value to the SDK. Backend reads cfg verbatim. One source of truth, two readers, asymmetric handling isolated to the frontend.
derivation_origin strategy
Section titled “derivation_origin strategy”The configuration pins derivation_origin to the native canister URL rather than a custom domain. This makes verification independent of custom-domain DNS / TLS renewals. DecideAI’s id.decideai.xyz advertises a matching ii-alternative-origins document that lists the canister URL, so the II alias-derivation path is consistent.
Implication: VC verification only succeeds when the user’s browser runtime origin matches the configured derivation_origin. Users on custom domains (e.g. launcher.ohshii.io) are bounced to the canonical native canister origin by the modal’s cross-origin guard before the flow starts.
Optional arguments (reputation gate, verification-date floor)
Section titled “Optional arguments (reputation gate, verification-date floor)”The ProofOfUniqueness credential supports two optional arguments:
| Argument | Type | Semantics |
|---|---|---|
minimumVerificationDate | ISO 8601 string | Reject presentations whose underlying user verification predates this date. |
minimumReputationLevel | "gold" | "silver" | Two-tier reputation gate. A user with a higher tier still passes a lower-tier check. |
Both are optional. Empty cfg fields produce arguments: None. When set via set_decideid_vc_arguments, the same values are forwarded by the frontend into the SDK request and verified verbatim by the backend — per DecideAI’s security note that requested and verified values must match.
2. End-user flow
Section titled “2. End-user flow”- User connects to OhShii with Internet Identity 2.0, the recommended sign-in
method (Plug Wallet is still available but not recommended, and is hidden from
the sign-in window unless the page is opened with
?wallets=all; NFID Wallet sign-in is not offered). See the “Wallets and Sign-in” user guide. - User opens
/verifyand clicks Start DecideID verification. DecideIDVerificationModalreadsget_decideid_config()from canister and callsrequestVerifiablePresentationfrom@dfinity/verifiable-credentials.- The library opens an Internet Identity popup. II authenticates the user, then opens the DecideID flow as the issuer (II → DecideID is a credential-fetching round-trip; OhShii is not in this loop).
- DecideID issues a JWT credential signed with its canister signature. II wraps it together with an
id_aliascredential (signed by II) into a Verifiable Presentation (JWT-VP). - The popup closes; the JWT-VP is delivered to OhShii’s frontend via
onSuccess. - The frontend forwards the raw JWT-VP to
register_decideid_verification(jwt_vp)on canister. - Canister validates synchronously (no inter-canister calls), records dedup digest, marks caller in
DECIDEID_VERIFIED_USERS, and writes a verification event to EVENT_LOG with emptydetails. - Frontend re-fetches
am_i_verified(); the universal banner flips to “Verified”.
3. Privacy model
Section titled “3. Privacy model”On canister, OhShii stores:
- Per-principal
verified = trueflag inDECIDEID_VERIFIED_USERS(no timestamp, no method-source metadata). - 32-byte SHA-256 of the JWT-VP in
USED_DECIDEID_PRESENTATIONS(dedup; cannot be reversed to recover the JWT). DecideIdConfig(issuer canister id, origin, credential type, the launcher’s derivation origin, enabled, max age) — public, returned byget_decideid_config().- A verification event in EVENT_LOG with empty
details— caller is NOT logged.
On canister, OhShii does NOT store:
- The JWT-VP itself.
- The id_alias credential payload.
- Any DecideAI-side principal, timestamp, or biometric metadata.
- A timestamp of the verification (would let observers time-correlate).
Unlinkability across relying parties is preserved by Internet Identity’s id_alias mechanism: DecideAI sees a per-(user, RP) alias that is different on every RP. DecideAI cannot correlate “user X on OhShii” with “user X on OpenChat”. OhShii cannot correlate either.
Per-method exposure on canister: the per-method introspection queries (is_world_id_verified, is_decideid_verified) are caller-only — they reject any call where the queried principal does not match msg_caller(). External enumeration of which provider a third-party principal used is not exposed. The universal gate query is_verified(p) returns only yes/no without provider information; that is the value used by the vote gate (see §9 Frontend integration).
4. Sybil resistance
Section titled “4. Sybil resistance”DecideID’s anti-sybil guarantee comes from two layers:
- DecideAI’s biometric uniqueness check (off-canister, off-OhShii). DecideAI verifies the user via biometric/document and refuses to issue a second credential to the same natural person. This is the upstream invariant OhShii relies on; the canister does not enforce it itself.
- Alias-tuple binding (on-canister, in
validate_ii_presentation_and_claims). The id_alias credential commits to (caller’s RP-side principal, RP origin, alias principal). Validation rejects if the JWT’seffective_vc_subjectdoes not matchmsg_caller(), so user A’s JWT cannot be replayed by user B.
Defense-in-depth: the canister additionally hashes the JWT-VP (SHA-256 → USED_DECIDEID_PRESENTATIONS) and reject any duplicate digest. This catches a class of bugs where a future change might weaken the alias-tuple binding.
What this does NOT prevent: a single user controlling multiple Internet Identity anchors (and getting DecideAI to verify each one separately) could in theory verify multiple OhShii principals. DecideAI’s published one-biometric-one-credential rule is the upstream defense; if DecideAI fails it, OhShii inherits the gap.
Downstream consumers of is_verified
Section titled “Downstream consumers of is_verified”is_verified is the union over all four providers (is_any_method_verified), so completing DecideID is fully sufficient for every gate that asks for verification — a DecideID-only user is treated identically to a World-ID-only user everywhere, and DecideID additionally carries Strong strength, so it also satisfies the gates a Google-only user cannot reach (critical proposals, the LGE purchase-tier ladder, the veto-override ballot). In particular, the Sponsor/referral system is verification-gated on this same OR: a sponsor↔user referral connection is only persisted on-canister, and a sponsor only earns, once the invited user is verified by either method. Until then the referrer code stays pending client-side (no canister storage) and the referral reward stays with the OhShii treasury. The DecideID side carries the same attribution trigger as World ID — a successful register_decideid_verification confirms a pending referral (the same piggyback_attribute_sponsor path register_world_id_verification uses). The check is made at purchase time and is not retroactive to that buyer’s earlier unverified purchases. See SPONSOR_SYSTEM.md → Referral registration & verification gating and the symmetric note in WORLD_ID_VERIFICATION.md §4.
5. JWT verification
Section titled “5. JWT verification”⚠ SCOPE — read this before the section below. Everything in section 5 describes the Verifiable-Credential (VC) path. DecideAI has discontinued that flow in favour of OIDC; it is a product decision on their side.
The live path is OIDC, and one property below does NOT carry over to it. Section 5 states “No HTTPS outcalls. No threshold signing. No external dependencies from
ohshii_governance.” That is accurate for VC verification, which is fully on-canister. The OIDC path does make HTTPS outcalls —/token,/.well-known/jwks.jsonand/userinfo— and uses threshold signing for the RP assertion. Section 5’s guarantees do not apply to the OIDC flow.Section 5 is kept because the mechanism is real, the vendored crate is still in tree, and the checks it documents are the ones a VC-based integration would perform. Read it as a description of that mechanism, not of what runs today.
Verification of the credential on the OhShii side is fully synchronous on-canister. No HTTPS outcalls. No threshold signing. No external dependencies from ohshii_governance. The pipeline lives in decideid_verification.rs and wraps the ic_verifiable_credentials crate (vendored under src/external_crates/, see VENDORED.md).
Important scope clarification: “no off-chain server” applies only to the canister-side verification of the JWT-VP. The underlying biometric check that DecideAI performs to issue the credential in the first place runs on DecideAI’s own off-chain infrastructure (face capture, liveness detection, document validation). Per the DecideAI Whitepaper, only a Zero-Knowledge proof of the result is then attested on-canister:
“The use of Zero-Knowledge (ZK) proofs means that in this process, the user’s credentials can be verified safely without the risk of sharing private information (e.g. biometric data). […] Decide ID sends back ZK proof — an encrypted version of the biometric and the verifier hash.”
So OhShii never receives biometric data; the canister only verifies a ZK-attested Verifiable Credential. But the privacy claim “fully on-chain” applies to the OhShii verification step, not to DecideAI’s biometric pipeline.
What validate_ii_presentation_and_claims checks:
- JWT-VP is well-formed (id_alias credential + issuer credential, both as JWS).
- Both credentials’ canister signatures verify against the IC root key (fetched via
ic_cdk::api::root_key(), DER prefix stripped viaextract_raw_root_pk_from_der). effective_vc_subject == msg_caller()(alias-tuple binding).effective_derivation_origin == cfg.derivation_origin(RP-binding).- Issuer credential’s
credential_type == cfg.credential_type(issuer-binding). - JWT
expclaim is in the future relative tocurrent_time_ns.
What the canister adds on top of the crate:
- 16 KB cap on
jwt_vp.len()to bound CPU and storage. - 32-byte SHA-256 dedup against
USED_DECIDEID_PRESENTATIONS.
Reserved (NOT currently enforced):
cfg.presentation_max_age_secondsis a placeholder for a future wallclock-style freshness bound stricter than the JWT’s intrinsicexpclaim. Today freshness relies solely on the validator’s internalcurrent_time_ns < expcheck — whatever TTL DecideAI puts on its credential is the actual ceiling. Setting a non-zero value has no runtime effect; the field is preserved in the Candid surface so adding the check later does not require a config migration. The missing check is non-exploitable in the current flow because: (a) same-caller idempotency short-circuits atis_decideid_verified(caller), and (b) cross-caller alias-tuple binding inside the validator rejects user A’s JWT submitted as user B.
Local testing: DecideID’s issuer canister exists only on mainnet, so a local replica cannot run the real flow without a stub issuer canister.
6. Rate limits and cycle budget
Section titled “6. Rate limits and cycle budget”| Limit | Default | Where enforced | Purpose |
|---|---|---|---|
| Per-call max JWT length | 16 KB | register_decideid_verification early-return | Bounds memory + hashing cost per call |
| Per-caller register attempts | 5 / hour, 30s cooldown | check_decideid_op_rate_limit (heap, non-persistent) | Primary DoS defense. Bounds the validation work any single principal can cause, including with distinct invalid JWTs that the digest dedup would not catch. |
| Daily cap on successful verifications | 1000 / 24h | consume_decideid_daily_cap_slot (stable memory) | Canister-wide ceiling on successful registrations. Consumed AFTER validate() succeeds, so invalid JWTs cannot drain it. |
| Per-caller in-flight register lock | 1 | DecideIdRegisterLockGuard (RAII, in-memory, #[must_use]) | Defensive: the VC function is fully synchronous, so concurrent calls from the same principal cannot interleave; the lock keeps that property for any flow that awaits. |
Order of defenses in register_decideid_verification (matters for DoS posture):
The critical ordering is 5 BEFORE 8 BEFORE 9: per-caller limit caps CPU spent by one attacker; cap consumed only on successful validation prevents canister-wide cap drain by invalid JWTs.
In VC mode DecideID has no HTTPS outcalls and no threshold-ECDSA signing, so the per-verification cycle cost is dominated by the BLS canister-signature checks inside validate_ii_presentation_and_claims — single-digit billion cycles per call, not the ~100 B+ that a World ID verification consumes. The daily cap default is correspondingly higher (1000 vs. World ID’s 500).
7. Governance control
Section titled “7. Governance control”ONS proposal types (all ExecuteAdminMethod, all target ohshii_governance):
| Method | Args | Threshold | Purpose |
|---|---|---|---|
set_decideid_config | principal, text, text, text, bool, nat64 | Critical (→ CriticalGovernanceOperation) | Full config update (issuer canister, origin, credential_type, derivation_origin, enabled, presentation_max_age_seconds). Refuses to enable while issuer is anonymous. |
set_decideid_mode | variant { Vc; Oidc } | Critical (→ CriticalGovernanceOperation) | Selects the verification path. Vc = synchronous on-canister JWT-VP validation (default, free, no outcalls). Oidc = OAuth 2.0 path with HTTPS outcalls (the path mainnet runs; see §13). |
set_decideid_vc_arguments | text, text | Standard | Sets vc_minimum_verification_date (ISO 8601) and vc_minimum_reputation_level ("" / "gold" / "silver"). Empty strings disable the constraint and produce arguments: None. |
set_decideid_daily_cap | nat64 | Standard | Daily-cap update; 0 pauses. |
Important: the canister enforces that enabled cannot be true while issuer_canister_id == Principal::anonymous() — this prevents a misconfigured proposal from locking everyone out of voting (the gate would fire but no one could verify).
Across code upgrades: the configuration the DAO set is kept as it is. An upgrade whose code cannot read the stored configuration is refused, and the canister keeps running its previous code with the stored value untouched; while the value cannot be read, the configuration reads as disabled and closed to new proofs, and set_decideid_config refuses to write. The canonical default configuration is applied at most once per canister, by the first upgrade with this code and only if no configuration exists, so a configuration the DAO cleared back to “Not configured” stays cleared. The marker that records this is protected the same way: an upgrade whose code cannot read it is refused.
8. Escape hatches
Section titled “8. Escape hatches”All of these admin_* methods carry is_admin_or_self_guard: they are reached by ohshii_governance self-calls (DAO proposal execution) and, during the transition phase, by the deploying principals — which are removed at the DAO handover, after which a proposal is the only route.
| Method | Effect |
|---|---|
admin_clear_decideid_verified(principal) | Removes a principal from DECIDEID_VERIFIED_USERS. Returns true if the principal was previously verified. |
admin_clear_decideid_presentation(jwt_digest_hex) | Frees a recorded JWT-VP digest so the same JWT can be submitted again. Freeing a digest weakens replay protection for that JWT, which is why it is a governance action logged with its argument. |
admin_reset_decideid_rate_limit(opt principal) | Clears the heap per-caller rate-limit counter for one principal, or for everyone if null. The heap map is already cleared on upgrade; this is the no-upgrade equivalent. |
The per-caller DecideIdRegisterLockGuard lives only in memory and is cleared on canister upgrade or when the in-flight register completes.
9. Frontend integration
Section titled “9. Frontend integration”A client deciding whether a wallet may vote should use the universal am_i_verified() query, not the per-method is_world_id_verified / is_decideid_verified: am_i_verified() is the answer the canister’s own gate uses, and the per-method queries exist for badge display. The launcher’s VotePanel.jsx follows this rule, and the canister-side gate (is_any_method_verified in memory.rs) reads the verified-user sets directly and not the enabled flags.
CSP additions in .ic-assets.json5:
connect-srcaddshttps://id.decideai.xyz,https://qbw6f-caaaa-aaaah-qdcwa-cai.icp0.io, andhttps://qbw6f-caaaa-aaaah-qdcwa-cai.raw.icp0.io.frame-srcaddshttps://id.decideai.xyzandhttps://qbw6f-caaaa-aaaah-qdcwa-cai.icp0.io.https://identity.ic0.appwas already present (II popup hosts the actual VC flow UI).
npm dep: @dfinity/verifiable-credentials@^1.0.0 (lazy-loaded by VotePanel.jsx via React.lazy, so the chunk is fetched on demand only when an unverified user opens the modal).
10. Security properties
Section titled “10. Security properties”| Property | How it’s guaranteed |
|---|---|
| Anonymous callers rejected | #[ic_cdk::update(guard = "reject_anonymous")] on register_decideid_verification. |
| Caller binding (no cross-principal replay) | effective_vc_subject = msg_caller() passed to validate_ii_presentation_and_claims; the alias-tuple binding inside the validator rejects mismatches. |
| JWT replay (same caller, same JWT) | USED_DECIDEID_PRESENTATIONS dedup map; checked before the expensive crypto validation. |
| TOCTOU between dedup and commit | register_decideid_verification is synchronous (no .await boundaries). Dedup-check, validate, dedup-record, mark-verified are atomic from the IC scheduler’s perspective. |
| In-flight double-register from same principal | DecideIdRegisterLockGuard (per-caller BTreeSet<Principal>, released in Drop so a callback trap still frees the lock). Named binding required at the call site (let _register_lock = ...). |
| Storable-bound panic on oversized input | 16 KB cap on jwt_vp.len() checked before any storage write. |
| Daily CPU exhaustion under spam | DECIDEID_DAILY_CAP (default 1000) consumed atomically before validation. |
| Verification persists across method-disable | is_any_method_verified reads the verified-user sets directly, not the enabled flags. |
| Privacy: no caller in event log | The verification event is logged with empty details. |
11. Pause and rollback levers (legacy)
Section titled “11. Pause and rollback levers (legacy)”Legacy. The provider is retired: intake has been closed (
accepts_new_proofs = false) since 2026-09-05, which takes nothing away from anyone already verified (§1).
Pause levers (in order of severity):
- Rate-limit pause: ONS proposal
set_decideid_daily_cap(0). Halts new verifications instantly. Existing verified users keep voting access. - Retire intake: ONS proposal
set_decideid_accepts_new_proofs(false). Refuses every new proof, in both VC and OIDC mode, permanently rather than by exhausting a cap, and leaves the gate contribution untouched. Existing verifications kept. - Method disable: ONS proposal
set_decideid_config(<same principal>, ..., enabled = false, ...). Drops the gate to Optional; new verifications still possible but the vote gate doesn’t fire from this method. Existing verifications kept. - Full rollback: ONS proposal
set_decideid_config(2vxsx-fae, "", "", "", false, 300). Returns to Not Configured. Existing verifications kept.
Levers 2 and 3 are the two axes and they are independent: retiring intake does not change whether the gate is mandatory, and disabling the gate does not close intake.
What you cannot pause without a governance action: a verified user retains voting access until their flag is cleared via admin_clear_decideid_verified (executed by ohshii_governance via an approved proposal) or the canister is downgraded.
12. Implementation map
Section titled “12. Implementation map”| Concern | File |
|---|---|
| Vendored crate | src/external_crates/ic-verifiable-credentials/ |
DecideIdConfig Rust type | src/ohshii_governance/src/types.rs |
| Stable storage layout | src/ohshii_governance/src/memory.rs (MemoryIds 59–63 for VC core; 64–69 for OIDC parallel path) |
| VC verification logic | src/ohshii_governance/src/decideid_verification.rs |
| OIDC verification logic | src/ohshii_governance/src/decideid_oidc.rs |
| Public methods + vote gate | src/ohshii_governance/src/lib.rs |
| Candid interface | src/ohshii_governance/ohshii_governance.did |
| Frontend modal | src/frontend/src/components/Governance/DecideIDVerificationModal.jsx |
| OIDC callback route | src/frontend/src/components/Governance/DecideIDOidcCallback.jsx |
/verify page | src/frontend/src/pages/VerifyPage.jsx |
| Vote panel gate | src/frontend/src/components/Governance/VotePanel.jsx |
| Proposal form entries | src/frontend/src/components/Governance/CreateProposalForm.jsx |
| CSP allowances | src/frontend/.ic-assets.json5 |
| Bootstrap constants | src/frontend/src/config/decideid.js |
13. VC and OIDC modes
Section titled “13. VC and OIDC modes”set_decideid_mode selects the verification path, through a critical proposal (§7):
Vc— synchronous on-canister validation of the JWT-VP (§5). No HTTPS outcalls.Oidc— the OAuth 2.0 path, which makes HTTPS outcalls to DecideAI’s/token,/.well-known/jwks.jsonand/userinfoendpoints. This is the path mainnet runs; its configured endpoints are listed in §1.
Switching modes does not invalidate existing verifications — DECIDEID_VERIFIED_USERS
is persisted independently of which path produced the entry. After any configuration
proposal executes, get_decideid_config (an anonymous query, §1) shows the live values.
The id_token is opaque, by DecideAI’s design
Section titled “The id_token is opaque, by DecideAI’s design”This page describes JWTs throughout, but the OIDC token DecideAI returns is not one. The shape is recorded here so the difference is not mistaken for a fault.
DecideAI’s /oauth/token endpoint returns id_token as a 36-character UUID (for example
698ff224-f6d5-46df-96dd-c6ae8819340c), not as a signed JWT (header.payload.signature in
base64url). According to DecideAI this is intentional, because of the ECDSA signing cost per
token.
What that means mechanically. The flow is OAuth 2.0 semantics wearing OIDC field names. An
opaque token carries no claims and no signature of its own, so identity attributes cannot be read
out of it — they are obtained from the provider’s /userinfo endpoint after the exchange. That is
why the pipeline calls /userinfo rather than parsing the token, and it is not a workaround: it is
the shape the provider ships.
What it does not mean. The JWT path described elsewhere on this page is not dead — it is the branch taken when a signed token IS returned, and it verifies the signature against the provider’s published keys. Both branches exist because the provider’s response shape decides which one runs.