Skip to content

Shield Core (crate)

A reusable in-canister firewall for the Internet Computer.

This crate is the portable extraction of the OhShii Shield — the layered defence layer first introduced to protect four high-value endpoints on the OhShii Launcher backend (prepare_token_creation, prepare_ico_creation, request_refund, claim_tokens). It now wraps 39 endpoints across four canisters — the launcher backend, ohshii_governance (ONS), sons_governance (SONS), and the OhShii Locker backend — and applies to any third-party IC canister facing the same threat model. Some access-controlled canisters (pool_manager, the dao_storage / lock_storage storage canisters, the locker proxy / strongbox) are deliberately not Shield-wrapped — they rely on caller guards + inspect_message. The full, source-verified protected-surface inventory (which method, which endpoint_id, which verification level) lives in the OhShii Shield documentation.

A single check() function you call at the top of any expensive #[update] method:

use ohshii_shield_core::{check, ShieldConfig, ShieldDecision};
#[ic_cdk::update]
async fn my_expensive_endpoint(
pow_solution: Option<Vec<u8>>,
) -> Result<(), String> {
let caller = ic_cdk::api::msg_caller();
let decision = check(
&MY_ENDPOINT_SHIELD, // ShieldConfig const
caller,
&MyGateway, // your VerificationGateway impl
None, // optional campaign_id
pow_solution.as_deref(),
)
.await;
if let Some(err) = decision.to_user_error() {
return Err(err);
}
// ... expensive work ...
Ok(())
}

The pipeline runs in this exact order: T → F → P → B → E → C → A → D

  1. T — trusted bypass for admin / self-canister / canister-authority calls.
  2. F — cycle-reserve floor (last-line DoS defence).
  3. P — pre-auth cache lookup; fresh positive hits skip only B.
  4. B — global cross-caller rate limit (Sybil-proof).
  5. E — per-caller rate limit (friction, two-threshold hygiene).
  6. C — Defcon state machine observation (anti-flap holds).
  7. A — identity verification dispatch via the consumer-provided VerificationGateway (cached 5 min).
  8. D — Argon2id PoW recompute (only under Defcon).

The Shield defends against Sybil-driven cycle drain on the IC’s reverse-gas model, where the canister pays cycles per call and the attacker pays nothing per identity (dfx identity new is free; II anchors cost ~0.0002 ICP and are scriptable). Per-principal counters alone are insufficient, so this crate implements a four-layer defence model:

  1. cycle-reserve floor — refuse high-value calls before the balance nears the freezing threshold.
  2. global cross-caller cap — a single Sybil-proof counter (one (window, count) tuple per endpoint, no per-key allocation). This is the real ceiling on accepted burn, sized so the highest accepted steady-state rate is non-urgent.
  3. per-caller friction — per-principal counters. Zero-cost to evade with fresh identities, so this is friction, not a ceiling.
  4. economic gate — the endpoint’s own pre-existing economic commitment: an ICRC-2 payment from a real-balance account, or a contribution-of-record row in storage. This is why most protected endpoints carry VerificationLevel::None — the cost of entry is already paid upstream, so identity gating would only lock out legitimate Guest-tier callers.

The memory-hard PoW (Step D) is a fifth, Defcon-only friction layer, not one of the four economic layers — see “What the PoW layer is — and is not” below for why its parameters are deliberately small.

  • The Shield is an in-canister defence that begins after a boundary node has admitted the message. It addresses the cheap-fresh-principal cycle-drain vector that reaches the canister; it is not a network-layer (volumetric) DDoS defence — that is handled upstream by the IC boundary-node layer. (inspect_message runs on a single replica pre-consensus and can be bypassed by a boundary node forwarding the message anyway, so it is a cycle-saving pre-filter, never a gate.)
  • The Shield defends against untrusted public callers. It does not defend against a compromised trusted key (an admin allowlist entry or the governance-set guardian). Trusted keys are, by design, the recovery path and bypass every layer; the centralized kill switch even lets one trusted key put the trusted core into TrustedOnly. Protecting those keys (multisig / HSM / rotation) is an operational concern outside this crate — see the “Kill-switch trust boundary” note in the Shield Pre-Auth Cache document.
ModulePurpose
powArgon2id verification, challenge-seed rotation, Argon2Params Candid record
rate_limitGlobal + per-caller layered defence, two-threshold hygiene
triggerPer-endpoint Defcon state machine with anti-flap holds
verifyGeneric 5-minute cache wrapper around a consumer-provided ICC
configShieldConfig, VerificationLevel, Fallback
decisionShieldDecision enum + to_user_error() string contracts
gatewayVerificationGateway trait you implement for your canister

A consumer frontend that has shipped against the OhShii Shield wire format MUST keep working byte-for-byte across crate upgrades. The following are stable forever:

  • Argon2id parameters: m_cost = 512 KiB, t_cost = 1, p_cost = 1, output_len = 32, algorithm Argon2id, version 0x13.
  • Challenge window: 5 minutes (single bucket — frontend must refresh its seed at ~270 s).
  • Minimum difficulty: 4 leading zero bits.
  • PoW payload: exactly 40 bytes = nonce_le8 || hash32.
  • Salt derivation: sha256("ohshii-shield-pow-v1" || caller_bytes || endpoint_id || (now_ns / window_ns).to_le_bytes()).
  • ShieldDecision::to_user_error() strings: byte-identical to the values the frontend’s SHIELD_ERROR_MARKERS regex contract expects.

Tightening the difficulty or changing any of the above is a breaking change for every consumer; treat as a major version bump.

The Argon2id PoW (Step D) is a Defcon-only, memory-hard, per-call friction tax — not the economic gate of the four-layer model. Two consequences are worth stating so the small parameters are not misread:

  • The 4-bit difficulty and 512 KiB m_cost are deliberately low. m_cost is bounded by the wasm32 verification budget (~250 ms / ~1.5 B cycles, well under the per-message instruction ceiling), and PoW gets its teeth not from the bit-count but from the single-use nonce ledger (try_consume_pow_nonce, overridden by every consumer): each valid (nonce, hash) is consumed once per 5-minute window, so under Defcon every admitted non-trusted call must carry a fresh solve. It is a per-call cost, not a 5-minute ticket. ~4 bits is ~16 expected hashes — modest by intent; the economic ceiling is the global cap (Layer 2), not the PoW.
  • Difficulty is a compile-time constant (MIN_DIFFICULTY_BITS) with no runtime / governance / Defcon knob. Step D is binary (PoW on under Defcon, off otherwise), not an intensity dial that scales with load. Raising the difficulty is a code change requiring a coordinated major-version redeploy of both vendored crate copies — intentionally rare. (The frontend already solves any advertised difficulty; only the backend constant is frozen.)

The crate’s defensive posture rests on these IC canister security properties:

  • Four-layer rate-limit defence — cycle reserve floor + global cross-caller cap + per-caller friction + economic gate hook, sized for DAO-timescale (slow-governance) threat models.
  • Anonymous-principal rejection + the Sybil-bypass caveat: per-principal limits are zero-cost to evade with fresh identities, so the global cap is the real ceiling.
  • Bounded vs unbounded inter-canister wait + the msg_caller()-before-await binding pitfall.
  • Dependency-outage UX — fail closed without masking an upstream outage as a user error.

The full operational guide and the frontend/backend wire contract live in the OhShii Shield documentation (docs/).

Dual-licensed under MIT or Apache-2.0, at your option.