Skip to content

Commit 0cae0ae

Browse files
committed
fix(internet-identity): use mo:core/CallerAttributes on the Motoko path
Replaces the manual Prim.callerInfoSigner / Prim.callerInfoData dance with CallerAttributes.getAttributes<system>() from mo:core (>= 2.5.0). The wrapper bakes in the trusted-signer check via the canister's trusted_attribute_signers env var, so the example no longer hardcodes the II principal in code: it moves to icp.yaml as deploy-time config. Notable changes: - Motoko example now imports mo:core/CallerAttributes (no more mo:prim) and reads time via mo:core/Time (Time.now() : Int) instead of the broken Nat64.toNat(Prim.time()) which had no Nat64 import. - consumePendingNonce stub mirrors the Rust register_finish pattern so the example compiles standalone. - New "Configuring trusted_attribute_signers" subsection shows the icp.yaml settings.environment_variables snippet. - Mistake #9 split per language: Motoko points at the env-var-based check, Rust still requires explicit msg_caller_info_signer. - Prerequisites bumps mo:core minimum to >= 2.5.0. - OpenID scopedKeys example wrapped in an async function to avoid bare top-level await at module scope (fixes the same Vite es2020 failure mode eval #6 already covers). - Eval #9 expected behavior accepts either the explicit Rust signer check or the Motoko env-var check. Rust path is unchanged: there is no ic-cdk wrapper yet.
1 parent 80cb221 commit 0cae0ae

2 files changed

Lines changed: 66 additions & 42 deletions

File tree

evaluations/internet-identity.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@
8787
"Requests verified_email (not just email) for access-gating use cases",
8888
"Explains that the email key is the raw value from the user's II-linked account and is NOT checked by II (treat as user-supplied input)",
8989
"Explains that verified_email is only present when the source OpenID provider (e.g. Google) marked the email as verified and II surfaced that signal — that is what makes verified_email trustworthy for authorisation",
90-
"On the backend, looks up implicit:nonce, implicit:origin, signer (against rdmx6-jaaaa-aaaaa-aaadq-cai), and the verified_email attribute key (or the openid:<provider>:verified_email scoped variant) before the allowlist check",
90+
"On the backend, verifies the bundle signer is the Internet Identity backend canister (`rdmx6-jaaaa-aaaaa-aaadq-cai`) — either via an explicit `msg_caller_info_signer()` check (Rust) or via `mo:core/CallerAttributes` with the `trusted_attribute_signers` env var configured (Motoko) — and looks up implicit:nonce, implicit:origin, and the verified_email attribute key (or the openid:<provider>:verified_email scoped variant) before the allowlist check",
9191
"Does NOT recommend reading the email field as a substitute for verified_email"
9292
]
9393
},

skills/internet-identity/SKILL.md

Lines changed: 65 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Internet Identity (II) is the Internet Computer's native authentication system.
1717
## Prerequisites
1818

1919
- `@icp-sdk/auth` (>= 7.0.0), `@icp-sdk/core` (>= 5.3.0) (`AttributesIdentity` was added in core v5.3.0)
20+
- For the Motoko backend example: `mo:core` >= 2.5.0 (the `CallerAttributes` module that wraps the caller-info primitives behind a single trusted-signer-aware call)
2021

2122
## Canister IDs
2223

@@ -43,7 +44,9 @@ Internet Identity (II) is the Internet Computer's native authentication system.
4344

4445
8. **Generating the attribute nonce on the frontend.** The nonce passed to `requestAttributes` MUST come from a backend canister call. A frontend-generated nonce defeats replay protection: the canister cannot verify that the bundle's `implicit:nonce` matches an action it actually started. Have the backend mint and return the nonce from a `registerBegin`-style method, and check it against the bundle's implicit fields when the user calls the protected method.
4546

46-
9. **Reading attribute data without verifying the signer.** `msg_caller_info_data` (Rust) and `Prim.callerInfoData` (Motoko) return whatever bundle the caller provided. The IC verifies the signature, not the identity of the signer — any canister can produce a valid bundle. Check `msg_caller_info_signer` / `Prim.callerInfoSigner` against `rdmx6-jaaaa-aaaaa-aaadq-cai` (Internet Identity) before trusting any attribute, otherwise an attacker canister can forge attributes like `email = "admin@you.com"`.
47+
9. **Reading attribute data without verifying the signer.** The IC verifies the signature, not the identity of the signer — any canister can produce a valid bundle. The trusted signer is `rdmx6-jaaaa-aaaaa-aaadq-cai` (Internet Identity). The check looks different per language:
48+
- **Motoko**: prefer `mo:core/CallerAttributes`. `CallerAttributes.getAttributes<system>()` returns `?Blob` and traps if the signer isn't listed in the canister's `trusted_attribute_signers` env var. Configure that env var in `icp.yaml` (see "Backend: Reading Identity Attributes"). Don't roll your own check on top of `Prim.callerInfoSigner` unless you have a reason to.
49+
- **Rust**: there is no CDK wrapper yet. Always check `msg_caller_info_signer()` against the trusted issuer principal before reading `msg_caller_info_data()`. Skipping this lets an attacker canister forge attributes like `email = "admin@you.com"`.
4750

4851
10. **Substituting `{tid}` in the Microsoft scoped-key prefix.** The `microsoft` OpenID provider URL is the literal string `https://login.microsoftonline.com/{tid}/v2.0``{tid}` is part of the URL, not a tenant-ID placeholder you fill in. Bundle keys returned by `scopedKeys({ openIdProvider: 'microsoft' })` look like `openid:https://login.microsoftonline.com/{tid}/v2.0:email` exactly, and the backend must look up that literal key. Replacing `{tid}` with a tenant GUID will silently miss every attribute lookup.
4952

@@ -249,42 +252,64 @@ const authClient = new AuthClient({
249252
openIdProvider: "google",
250253
});
251254
252-
const nonce = await backend.registerBegin();
255+
// Wrap the flow in an async function so this code works with any bundler
256+
// target (Vite defaults to es2020 which lacks top-level await).
257+
async function registerWithGoogle(backend, appCanisterId, appIdl) {
258+
const nonce = await backend.registerBegin();
253259
254-
const signInPromise = authClient.signIn({
255-
maxTimeToLive: BigInt(8) * BigInt(3_600_000_000_000),
256-
});
257-
// Requests name, email, and verified_email from the Google account
258-
// linked to the user's Internet Identity. The keys returned by
259-
// scopedKeys() arrive in the bundle as e.g.
260-
// "openid:https://accounts.google.com:email".
261-
const attributesPromise = authClient.requestAttributes({
262-
keys: scopedKeys({ openIdProvider: "google" }),
263-
nonce,
264-
});
260+
const signInPromise = authClient.signIn({
261+
maxTimeToLive: BigInt(8) * BigInt(3_600_000_000_000),
262+
});
263+
// Requests name, email, and verified_email from the Google account
264+
// linked to the user's Internet Identity. The keys returned by
265+
// scopedKeys() arrive in the bundle as e.g.
266+
// "openid:https://accounts.google.com:email".
267+
const attributesPromise = authClient.requestAttributes({
268+
keys: scopedKeys({ openIdProvider: "google" }),
269+
nonce,
270+
});
265271
266-
const identity = await signInPromise;
267-
const { data, signature } = await attributesPromise;
272+
const identity = await signInPromise;
273+
const { data, signature } = await attributesPromise;
268274
269-
const identityWithAttributes = new AttributesIdentity({
270-
inner: identity,
271-
attributes: { data, signature },
272-
signer: { canisterId: Principal.fromText("rdmx6-jaaaa-aaaaa-aaadq-cai") },
273-
});
275+
const identityWithAttributes = new AttributesIdentity({
276+
inner: identity,
277+
attributes: { data, signature },
278+
signer: { canisterId: Principal.fromText("rdmx6-jaaaa-aaaaa-aaadq-cai") },
279+
});
274280
275-
const agent = await HttpAgent.create({ identity: identityWithAttributes });
276-
const app = Actor.createActor(appIdl, { agent, canisterId: appCanisterId });
277-
await app.registerFinish();
281+
const agent = await HttpAgent.create({ identity: identityWithAttributes });
282+
const app = Actor.createActor(appIdl, { agent, canisterId: appCanisterId });
283+
await app.registerFinish();
284+
}
278285
```
279286

280287
### Backend: Reading Identity Attributes
281288

282289
When the frontend wraps an identity with `AttributesIdentity`, every call carries a verified attribute bundle.
283290

284-
- **Rust** (ic-cdk >= 0.20.1): `ic_cdk::api::msg_caller_info_data() -> Vec<u8>`, `ic_cdk::api::msg_caller_info_signer() -> Option<Principal>`.
285-
- **Motoko** (compiler with caller_info prims, e.g. >= 0.16): `Prim.callerInfoData<system>() : Blob`, `Prim.callerInfoSigner<system>() : Blob` (empty when no signer).
291+
- **Rust** (ic-cdk >= 0.20.1): `ic_cdk::api::msg_caller_info_data() -> Vec<u8>`, `ic_cdk::api::msg_caller_info_signer() -> Option<Principal>`. There is no CDK wrapper for the trusted-signer check yet; do it explicitly in your code.
292+
- **Motoko** (mo:core >= 2.5.0): `CallerAttributes.getAttributes<system>() : ?Blob` from `mo:core/CallerAttributes`. The wrapper returns `null` when no attributes are attached and **traps** when the signer isn't listed in the canister's `trusted_attribute_signers` env var, so you don't write the signer check yourself. Underlying primitives `Prim.callerInfoData<system>` / `Prim.callerInfoSigner<system>` are still exposed by the compiler but the wrapper is preferred.
293+
294+
**Always verify the signer.** The IC checks that the bundle is signed; it does not check *who* signed it. Any canister can produce a valid bundle. Trust the data only when the signer matches the trusted issuer (`rdmx6-jaaaa-aaaaa-aaadq-cai` for Internet Identity). Motoko handles this for you via the env var; Rust requires an explicit `msg_caller_info_signer()` check.
286295

287-
**Always verify the signer first.** The IC checks that the bundle is signed; it does not check *who* signed it. Any canister can produce a valid bundle. Trust the data only when the signer matches the trusted issuer (`rdmx6-jaaaa-aaaaa-aaadq-cai` for Internet Identity).
296+
#### Configuring `trusted_attribute_signers` (Motoko path)
297+
298+
`CallerAttributes.getAttributes` reads the trusted signer list from the canister's `trusted_attribute_signers` environment variable (a comma-separated list of principal texts). Set it in your `icp.yaml` so `icp deploy` configures the canister automatically:
299+
300+
```yaml
301+
canisters:
302+
- name: backend
303+
settings:
304+
environment_variables:
305+
# Mainnet II principal. List both the mainnet principal and your local II
306+
# canister principal if your tests run against a locally deployed II.
307+
trusted_attribute_signers: "rdmx6-jaaaa-aaaaa-aaadq-cai"
308+
```
309+
310+
If the env var is unset, `getAttributes` traps with `"trusted_attribute_signers environment variable is not set"`. That trap is the right behavior: an unconfigured canister should not trust attribute bundles.
311+
312+
#### Reading the bundle
288313

289314
The data is Candid-encoded as an ICRC-3 `Value::Map` whose entries are:
290315
- `implicit:nonce` (Blob) — must match a nonce your canister minted for this user/action.
@@ -294,13 +319,12 @@ The data is Candid-encoded as an ICRC-3 `Value::Map` whose entries are:
294319
- OpenID-scoped keys (e.g., `"openid:https://accounts.google.com:email"`) when `scopedKeys` was used on the frontend.
295320

296321
```motoko
297-
import Prim "mo:prim";
322+
import CallerAttributes "mo:core/CallerAttributes";
298323
import Principal "mo:core/Principal";
299324
import Runtime "mo:core/Runtime";
325+
import Time "mo:core/Time";
300326
301327
persistent actor {
302-
let iiPrincipal = Principal.fromText("rdmx6-jaaaa-aaaaa-aaadq-cai");
303-
304328
type Icrc3Value = {
305329
#Nat : Nat;
306330
#Int : Int;
@@ -332,17 +356,17 @@ persistent actor {
332356
};
333357
334358
// Pending nonces minted in registerBegin, keyed by caller. See the
335-
// "Storing the nonce" note below for storage patterns.
336-
// type PendingNonces = Map<Principal, Blob>;
337-
// var pendingNonces : PendingNonces = ...;
359+
// "Storing the nonce" note below for storage patterns. Provided by
360+
// the canister's stable-memory state (e.g. a Map<Principal, Blob>).
361+
func consumePendingNonce(_caller : Principal) : ?Blob {
362+
// pendingNonces.remove(caller)
363+
Runtime.trap("see Storing the nonce");
364+
};
338365
339-
// Returns the verified attribute map, trapping if the signer is not II.
366+
// Returns the verified attribute map. Traps when the signer is not
367+
// listed in the canister's trusted_attribute_signers env var.
340368
func iiAttributes() : [(Text, Icrc3Value)] {
341-
let signer = Prim.callerInfoSigner<system>();
342-
if (signer.size() == 0 or Principal.fromBlob(signer) != iiPrincipal) {
343-
Runtime.trap("Untrusted attribute signer");
344-
};
345-
let data = Prim.callerInfoData<system>();
369+
let ?data = CallerAttributes.getAttributes<system>() else Runtime.trap("no trusted attributes");
346370
let ?value : ?Icrc3Value = from_candid (data) else Runtime.trap("invalid attribute bundle");
347371
let #Map(entries) = value else Runtime.trap("expected attribute map");
348372
entries
@@ -357,13 +381,13 @@ persistent actor {
357381
358382
// Verify implicit:nonce matches a nonce we minted for this caller, and consume it.
359383
let ?nonce = lookupBlob(entries, "implicit:nonce") else Runtime.trap("missing nonce");
360-
let ?expected = pendingNonces.remove(caller) else Runtime.trap("no pending registration for caller");
384+
let ?expected = consumePendingNonce(caller) else Runtime.trap("no pending registration for caller");
361385
if (nonce != expected) Runtime.trap("Nonce mismatch");
362386
363387
// Verify implicit:issued_at_timestamp_ns is within a 5-minute freshness window.
388+
// Time.now() is Int (nanoseconds); Nat <: Int so the comparison works directly.
364389
let ?issuedAt = lookupNat(entries, "implicit:issued_at_timestamp_ns") else Runtime.trap("missing timestamp");
365-
let nowNs = Nat64.toNat(Prim.time());
366-
if (nowNs > issuedAt + 300_000_000_000) Runtime.trap("Bundle too old");
390+
if (Time.now() > issuedAt + 300_000_000_000) Runtime.trap("Bundle too old");
367391
368392
let ?email = lookupText(entries, "email") else Runtime.trap("missing email");
369393
"Registered " # Principal.toText(caller) # " with email " # email

0 commit comments

Comments
 (0)