Canic v0.16 Authentication System

Feedback please. It’s coded, it has tests, and should mostly work. It should scale pretty well imo.

Canic Authentication and Delegated Authority

Overview

Canic separates infrastructure identity from application identity. The raw IC caller (msg_caller()) remains authoritative for root/controller checks, parent/child checks, subnet-registration trust, topology predicates, and proof provisioning. A separate authenticated_subject exists only for application-facing user authorization, primarily through authenticated(...). That subject may diverge from the transport caller only after a valid delegated-session bootstrap; it never rewrites infrastructure trust.

This is the key architectural decision: Canic does not treat “user proved by a token” as equivalent to “caller trusted as infrastructure.” The design preserves a strict split between control-plane trust and user-plane authorization.

Identity model

Canic’s auth model is explicitly layered:

  • transport_caller: raw ingress or inter-canister caller

  • authenticated_subject: resolved user/application subject

  • token_subject: delegated token claims.sub

  • attested_subject: role-attestation subject

  • capability_subject: capability/delegated-grant subject

Only authenticated_subject may differ from transport_caller, and only in wallet-driven delegated-session flows. Role attestations and capability proofs are intentionally not reinterpreted through delegated-session state.

Parent/child authentication

Parent/child trust in Canic is raw-caller-bound, not session-bound. The access layer implements is_parent by comparing the caller against the configured parent PID from environment state, and is_child by checking the caller against the local children registry. Those checks are topology predicates, so they are not affected by delegated session state or token subject substitution.

That matters operationally: a child canister may authenticate a user with a delegated token for business endpoints, while still requiring raw-caller parent authority for provisioning or coordination endpoints. In user_shard, for example, issue_token_for_wallet, setup_delegation, register_user, and bind_session_principal are parent-only, while user profile and account methods are guarded by authenticated().

Cascading state and topology

Canic’s topology layer is built around explicit cascades rather than implicit discovery. The architecture hardening work moved propagation into workflow, validated parent/child propagation hop-by-hop, added cycle/termination guarantees, and made failures abort cleanly rather than leaving partial topology state behind. Earlier topology foundations also introduced synchronized state + directory snapshots, so canisters receive both identity context and authoritative placement/directory information as part of the topology model.

This is why auth, sharding, and discovery fit together cleanly: verifier targets, shard parents, and app/subnet directories are all part of one topology discipline rather than ad hoc per-canister state.

RPC model

Canic has two distinct RPC ideas:

  1. ordinary topology RPC, where trust comes from raw caller + topology predicates such as parent/child/root/registered-canister; and

  2. privileged distributed RPC, where root can authorize a call by signed artifact instead of by immediate caller identity.

The second lane evolved through root-signed role attestations and then signed capability envelopes with replay protection and capability hashing for cross-canister root calls. Parent-funded control-plane RPC later reused that same discipline with replay-safe execution. In other words, Canic does not collapse RPC into one universal mechanism: it uses topology predicates for local hierarchy, and signed artifacts for distributed root authority.

Delegated tokens

Delegated tokens are audience-bound, scope-bound, and locally verified. The access layer decodes the delegated token from ingress arg0, verifies it against root authority and verifier-local proof state, enforces caller-subject binding (sub == caller), and checks endpoint-required scopes before access is granted. In production semantics, verification is local and fail-closed: missing proof, wrong audience, expired token, or missing scope all deny access.

That local-verification property is one of the system’s invariants: the hot path does not depend on root being available at request time. The shard signs; verifiers verify locally.

Sharding and why auth is tied to it

Canic’s sharding model is explicit: sharding placement is a deterministic policy concern, while auth issuance is a separate signer/verifier concern. The changelog makes two important points here:

  • shard placement and delegated-auth issuance were deliberately decoupled again, so placement does not silently imply delegation provisioning; and

  • the current naming/contracts make sharding identity explicit through partition_key and explicit kind rules.

In practice, user_hub is the placement/registration parent, and user_shard is the per-user or per-partition signer/storage node. The sample canisters describe this split directly: user_hub does placement, while user_shard initiates delegation with root.

End-to-end flow: user signs up to user_hub

1. Root bootstraps the application graph

Root starts the app, creates the child canisters, and publishes them through the app/subnet directories. Tests and app code discover user_hub, project_hub, market, and other roles via those directory surfaces rather than by hardcoded PIDs.

2. The wallet calls user_hub

In Toko, user_hub exposes:

  • register_user(delegation_pid) -> shard_pid

  • authenticate(delegation_pid, ttl_secs) -> (shard_pid, DelegatedToken)

Both use msg_caller() as the wallet principal and pass that into hub ops. So the wallet is the transport caller, while delegation_pid is the application subject the user wants bound into delegated auth.

3. user_hub allocates or resolves a user_shard

Conceptually, user_hub is the parent that decides where the user lives in the sharded graph. In the sample Canic test canister, create_account(pid) explicitly assigns the tenant key into the user_shards pool via ShardingApi::assign_to_pool; in Toko, register_user similarly returns the assigned shard PID.

4. user_shard becomes the signer for that user domain

user_shard owns the user record and exposes two auth-relevant surfaces:

  • issue_token(ttl_secs) for wallet-authenticated minting

  • issue_token_for_wallet(wallet_principal, ttl_secs) for parent-mediated one-shot authentication

It also has a parent-only setup_delegation() endpoint used to provision delegation material from root.

5. user_shard asks root for delegation material

When user_shard needs to issue a token, it first requires a local proof. If no proof is present or the proof is expired, it calls setup_delegation(). That method discovers verifier targets from the app/subnet directories, builds a DelegationRequest whose audience includes itself plus the static verifier canisters (project_hub, market, user_hub, and optionally root), and then calls root’s canic_request_delegation endpoint. Root signs the cert and pushes proof material to the signer and verifier targets.

This is the core delegated-authority model: root delegates signing authority to the shard, and root also prepares the verifier set that will later authenticate those tokens.

6. user_shard mints the delegated token

Once proof material is present, user_shard resolves the caller to a user, derives scopes from user authority, chooses the token subject (delegation_pid if present, otherwise wallet PID), builds claims, and signs the token with DelegationApi::sign_token. The token’s audience is copied from the proof cert, so issuance is tightly bound to the verifier set that root approved.

7. A verifier such as project_hub consumes the token

A canister guarded by authenticated() decodes the token from ingress arg0, verifies signature/time/audience/scope against verifier-local proof state, and enforces claims.sub == caller. The integration tests exercise this directly: a token issued on user_shard is accepted on a verifier canister when subject, scope, audience, and proof all line up, and rejected when subject mismatches, scope is missing, token is expired, or claims are structurally invalid.

Delegated sessions

Canic also supports a bounded delegated-session layer, but only as an application-auth convenience. Bootstrap must verify a delegated token first, enforce delegated_subject == token.claims.sub, reject obvious infrastructure principals, clamp session TTL to token lifetime, and store the session as wallet-scoped state. Even with an active delegated session, authenticated() still requires a delegated token in ingress arg0; the session only substitutes the resolved authenticated subject, not the proof check itself.

0.16 direction

The current 0.16 line is not a new auth model so much as a hardening of the 0.15 one. The main follow-up is delegation proof evolution: stricter audience binding for verifier installs and delegated-session bootstrap, plus support for better verifier readiness and repair/prewarm observability. The design motivation is that verifier readiness must remain local and fail-closed, while proof storage evolves beyond the old single-active-proof limitation for multi-shard scenarios.

Short thesis

The shortest accurate summary is:

Canic treats authentication as a composition of topology trust, delegated user authority, and signed control-plane RPC. Raw caller identity governs parent/child/root and infrastructure decisions; delegated tokens govern user-facing authorization on authenticated() endpoints; signed role/capability artifacts govern distributed root authority. Sharding does not weaken those rules: user_hub places users into shards, user_shard asks root for delegation, root provisions signer/verifier proof state, and downstream canisters verify user tokens locally and fail closed.

This is the Toko config file (canic.toml). The canister setup is below.

# -------------------------------
# Root
# -------------------------------
[subnets.prime.canisters.root]
kind = "root"

# -------------------------------
# Asset
# -------------------------------
[subnets.prime.canisters.asset]
kind = "singleton"

# -------------------------------
# DiscoveryHub & DiscoveryShard
# -------------------------------
[subnets.prime.canisters.discovery_hub]
kind = "singleton"

[subnets.prime.canisters.discovery_hub.sharding.pools.discovery]
canister_role = "discovery_shard"
policy.capacity = 100_000
policy.max_shards = 2

[subnets.prime.canisters.discovery_shard]
kind = "shard"

# -------------------------------
# Http
# -------------------------------
[subnets.prime.canisters.http_hub]
kind = "singleton"
initial_cycles = "10T"
topup_policy = { amount = "10T", threshold = "20T" }

[subnets.prime.canisters.http_hub.scaling.pools.workers]
canister_role = "http_worker"

[subnets.prime.canisters.http_worker]
kind = "replica"
topup_policy = {}

# -------------------------------
# Market
# -------------------------------
[subnets.prime.canisters.market]
kind = "singleton"
topup_policy = {}

# -------------------------------
# Oracle
# -------------------------------
[subnets.prime.canisters.oracle_registry]
kind = "singleton"
topup_policy = { amount = "10T", threshold = "20T" }

[subnets.prime.canisters.oracle_pokemon]
kind = "singleton"
topup_policy = { amount = "10T", threshold = "20T" }

# -------------------------------
# ProjectHub
# -------------------------------
[subnets.prime.canisters.project_hub]
kind = "singleton"
topup_policy = {}

# -------------------------------
# Project Instance (Tenant)
# -------------------------------
[subnets.prime.canisters.project_instance]
kind = "tenant"

# -------------------------------
# Project Ledger
# -------------------------------
[subnets.prime.canisters.project_ledger]
kind = "singleton"
topup_policy = {}

# -------------------------------
# Project Registry
# -------------------------------
[subnets.prime.canisters.project_registry]
kind = "singleton"

# -------------------------------
# Project User
# -------------------------------
[subnets.prime.canisters.project_user]
kind = "singleton"

# -------------------------------
# UserHub & UserShard
# -------------------------------
[subnets.prime.canisters.user_hub]
kind = "singleton"
topup_policy = {}

[subnets.prime.canisters.user_hub.sharding.pools.user]
canister_role = "user_shard"
policy.capacity = 10_000
policy.max_shards = 4

[subnets.prime.canisters.user_shard]
kind = "shard"

1 Like