diff --git a/src/canister_tests/src/api/internet_identity/api_v2.rs b/src/canister_tests/src/api/internet_identity/api_v2.rs index 16f523be43..23539e8776 100644 --- a/src/canister_tests/src/api/internet_identity/api_v2.rs +++ b/src/canister_tests/src/api/internet_identity/api_v2.rs @@ -235,14 +235,15 @@ pub fn authn_method_registration_mode_enter( canister_id: CanisterId, sender: Principal, identity_number: IdentityNumber, -) -> Result, CallError> { + id: Option, +) -> Result, CallError> { call_candid_as( env, canister_id, RawEffectivePrincipal::None, sender, "authn_method_registration_mode_enter", - (identity_number,), + (identity_number, id), ) .map(|(x,)| x) } @@ -298,6 +299,40 @@ pub fn authn_method_confirm( .map(|(x,)| x) } +pub fn authn_method_check_tentative_device( + env: &PocketIc, + canister_id: CanisterId, + sender: Principal, + identity_number: IdentityNumber, +) -> Result, CallError> { + call_candid_as( + env, + canister_id, + RawEffectivePrincipal::None, + sender, + "authn_method_check_tentative_device", + (identity_number,), + ) + .map(|(x,)| x) +} + +pub fn authn_method_lookup_by_registration_mode_id( + env: &PocketIc, + canister_id: CanisterId, + sender: Principal, + id: String, +) -> Result, LookupByRegistrationIdError>, CallError> { + call_candid_as( + env, + canister_id, + RawEffectivePrincipal::None, + sender, + "authn_method_lookup_by_registration_mode_id", + (id,), + ) + .map(|(x,)| x) +} + pub fn create_account( env: &PocketIc, canister_id: CanisterId, diff --git a/src/frontend/src/lib/components/backgrounds/FlairBox.d.ts b/src/frontend/src/lib/components/backgrounds/FlairBox.d.ts new file mode 100644 index 0000000000..6779e72aab --- /dev/null +++ b/src/frontend/src/lib/components/backgrounds/FlairBox.d.ts @@ -0,0 +1,41 @@ +export interface FlairAnimationOptions { + location: + | "top" + | "left" + | "right" + | "bottom" + | "topLeft" + | "topRight" + | "bottomLeft" + | "bottomRight" + | "center" + | { x: number; y: number }; + target: ("motion" | "scale" | "opacity")[]; + motionType: + | "omni" + | "xy" + | "yx" + | "up" + | "down" + | "left" + | "right" + | "cw" + | "ccw"; + speed: "slow" | "medium" | "fast"; + intensity: "light" | "medium" | "strong"; + size: "large" | "medium" | "small"; +} + +export interface FlairBoxProps { + bgType?: "dots" | "grid" | "noisedots"; + spacing?: "large" | "medium" | "small"; + aspect?: "square" | "wide" | "ultrawide"; + visibility?: "always" | "animation"; + dotSize?: "large" | "medium" | "small"; + vignette?: "center" | "top" | "left" | "right" | "bottom" | "none"; + hoverAction?: "intense" | "minimal" | "none"; + springOrTween?: "spring" | "tween"; + backgroundClasses?: string; + foregroundClasses?: string; + triggerAnimation?: (opts: FlairAnimationOptions) => void; +} diff --git a/src/frontend/src/lib/components/backgrounds/FlairBox.svelte b/src/frontend/src/lib/components/backgrounds/FlairBox.svelte new file mode 100644 index 0000000000..259277476e --- /dev/null +++ b/src/frontend/src/lib/components/backgrounds/FlairBox.svelte @@ -0,0 +1,436 @@ + + + + + diff --git a/src/frontend/src/lib/components/backgrounds/FlairCanvas.d.ts b/src/frontend/src/lib/components/backgrounds/FlairCanvas.d.ts new file mode 100644 index 0000000000..1ba0df9411 --- /dev/null +++ b/src/frontend/src/lib/components/backgrounds/FlairCanvas.d.ts @@ -0,0 +1,62 @@ +import * as easingFunctions from "svelte/easing"; + +export interface FlairAnimationOptions { + location: + | "top" + | "left" + | "right" + | "bottom" + | "topLeft" + | "topRight" + | "bottomLeft" + | "bottomRight" + | "center" + | { x: number; y: number }; + target: ("motion" | "scale" | "opacity")[]; + motionType: + | "omni" + | "xy" + | "yx" + | "up" + | "down" + | "left" + | "right" + | "cw" + | "ccw" + | "perlin"; + speed: "slow" | "medium" | "fast" | number; + intensity: "light" | "medium" | "strong" | number; + size: "large" | "medium" | "small" | number; + nImpulses: "single" | "double"; + impulseEasing?: keyof typeof easingFunctions; +} + +export interface FlairCanvasProps { + bgType?: "dots" | "grid" | "noisedots"; + spacing?: "large" | "medium" | "small" | number; + aspect?: "square" | "wide" | "ultrawide" | number; + visibility?: "always" | "moving"; + dotSize?: "large" | "medium" | "small" | number; + vignette?: "center" | "top" | "left" | "right" | "bottom" | "none"; + hoverAction?: "intense" | "minimal" | "none"; + springOrTween?: + | { + type: "spring"; + stiffness: "low" | "medium" | "high" | number; + dampening: "low" | "medium" | "high" | number; + } + | { + type: "tween"; + duration: "short" | "medium" | "long" | number; + easing: keyof typeof easingFunctions; + }; + backgroundClasses?: string; + foregroundClasses?: string; + triggerAnimation?: (opts: FlairAnimationOptions) => void; +} + +export interface NodeMotion { + motion: Spring<{ x: number; y: number }> | Tween<{ x: number; y: number }>; + prev: { x: number; y: number }; + speed: number; +} diff --git a/src/frontend/src/lib/components/backgrounds/FlairCanvas.svelte b/src/frontend/src/lib/components/backgrounds/FlairCanvas.svelte new file mode 100644 index 0000000000..03bea3ff23 --- /dev/null +++ b/src/frontend/src/lib/components/backgrounds/FlairCanvas.svelte @@ -0,0 +1,640 @@ + + + + + diff --git a/src/frontend/src/lib/components/backgrounds/PerlinWaveBackground.svelte b/src/frontend/src/lib/components/backgrounds/PerlinWaveBackground.svelte new file mode 100644 index 0000000000..ca7a1d1342 --- /dev/null +++ b/src/frontend/src/lib/components/backgrounds/PerlinWaveBackground.svelte @@ -0,0 +1,162 @@ + + +
+ +
+ +

+ Click anywhere to make waves! +

+ + diff --git a/src/frontend/src/lib/components/backgrounds/ScaleWaveGridBackground.svelte b/src/frontend/src/lib/components/backgrounds/ScaleWaveGridBackground.svelte new file mode 100644 index 0000000000..4fbc12ee60 --- /dev/null +++ b/src/frontend/src/lib/components/backgrounds/ScaleWaveGridBackground.svelte @@ -0,0 +1,423 @@ + + + + + +{#if showControls} +
+
+

Wave Controls

+ +
+ +
+ +
+ + +
+ +
+ + +
+ + +
+ + +
+ +
+ + +
+ + +
+ + +
+ +
+ + +
+ + +
+ + +
+ +
+ + +
+ + +
+ + +
+ +
+ + +
+ + + +
+
+{/if} + + + + + diff --git a/src/frontend/src/lib/components/backgrounds/WaveGridBackground.svelte b/src/frontend/src/lib/components/backgrounds/WaveGridBackground.svelte new file mode 100644 index 0000000000..581f75e6a9 --- /dev/null +++ b/src/frontend/src/lib/components/backgrounds/WaveGridBackground.svelte @@ -0,0 +1,571 @@ + + + + +

+ Hold directional key to constrain impulses to single direction. Hold r for + rotational impulses. +

+ + +{#if showControls} +
+
+

Wave Controls

+ +
+ +
+ +
+ + +
+ +
+ + +
+ + +
+ + +
+ +
+ + +
+ + +
+ + +
+ +
+ + +
+ + +
+ + +
+ +
+ + +
+ + +
+ + +
+ +
+ + +
+ +
+ +
+ + + +
+
+
+ +
+ + + +
+
+ + + +
+
+{/if} + + + + + diff --git a/src/frontend/src/lib/flows/addAccessMethodFlow.svelte.ts b/src/frontend/src/lib/flows/addAccessMethodFlow.svelte.ts index 1fc3893376..92c2d70da2 100644 --- a/src/frontend/src/lib/flows/addAccessMethodFlow.svelte.ts +++ b/src/frontend/src/lib/flows/addAccessMethodFlow.svelte.ts @@ -139,7 +139,7 @@ export class AddAccessMethodFlow { // Always exit any ongoing registration mode first await actor.authn_method_registration_mode_exit(identityNumber); const { expiration } = await actor - .authn_method_registration_mode_enter(identityNumber) + .authn_method_registration_mode_enter(identityNumber, []) .then(throwCanisterError); while (!this.isRegistrationWindowPassed) { const info = await actor diff --git a/src/frontend/src/lib/generated/internet_identity_idl.js b/src/frontend/src/lib/generated/internet_identity_idl.js index 86e9b0893a..eec795e2a6 100644 --- a/src/frontend/src/lib/generated/internet_identity_idl.js +++ b/src/frontend/src/lib/generated/internet_identity_idl.js @@ -141,11 +141,14 @@ export const idlFactory = ({ IDL }) => { 'authn_method' : AuthnMethod, }); const AuthnMethodAddError = IDL.Variant({ 'InvalidMetadata' : IDL.Text }); + const CheckTentativeDeviceError = IDL.Variant({ 'Unauthorized' : IDL.Null }); const AuthnMethodConfirmationError = IDL.Variant({ 'RegistrationModeOff' : IDL.Null, 'NoAuthnMethodToConfirm' : IDL.Null, 'WrongCode' : IDL.Record({ 'retries_left' : IDL.Nat8 }), }); + const RegistrationId = IDL.Text; + const LookupByRegistrationIdError = IDL.Variant({ 'InvalidId' : IDL.Text }); const AuthnMethodMetadataReplaceError = IDL.Variant({ 'AuthnMethodNotFound' : IDL.Null, 'InvalidMetadata' : IDL.Text, @@ -159,6 +162,10 @@ export const idlFactory = ({ IDL }) => { 'RegistrationAlreadyInProgress' : IDL.Null, 'InvalidMetadata' : IDL.Text, }); + const AuthnMethodRegistrationModeEnterError = IDL.Variant({ + 'InvalidId' : IDL.Text, + 'AuthorizationFailure' : IDL.Text, + }); const AuthnMethodReplaceError = IDL.Variant({ 'AuthnMethodNotFound' : IDL.Null, 'InvalidMetadata' : IDL.Text, @@ -476,6 +483,11 @@ export const idlFactory = ({ IDL }) => { [IDL.Variant({ 'Ok' : IDL.Null, 'Err' : AuthnMethodAddError })], [], ), + 'authn_method_check_tentative_device' : IDL.Func( + [IdentityNumber], + [IDL.Variant({ 'Ok' : IDL.Bool, 'Err' : CheckTentativeDeviceError })], + ['query'], + ), 'authn_method_confirm' : IDL.Func( [IdentityNumber, IDL.Text], [ @@ -486,6 +498,16 @@ export const idlFactory = ({ IDL }) => { ], [], ), + 'authn_method_lookup_by_registration_mode_id' : IDL.Func( + [RegistrationId], + [ + IDL.Variant({ + 'Ok' : IDL.Opt(IdentityNumber), + 'Err' : LookupByRegistrationIdError, + }), + ], + ['query'], + ), 'authn_method_metadata_replace' : IDL.Func( [IdentityNumber, PublicKey, MetadataMapV2], [ @@ -507,11 +529,11 @@ export const idlFactory = ({ IDL }) => { [], ), 'authn_method_registration_mode_enter' : IDL.Func( - [IdentityNumber], + [IdentityNumber, IDL.Opt(RegistrationId)], [ IDL.Variant({ 'Ok' : IDL.Record({ 'expiration' : Timestamp }), - 'Err' : IDL.Null, + 'Err' : AuthnMethodRegistrationModeEnterError, }), ], [], diff --git a/src/frontend/src/lib/generated/internet_identity_types.d.ts b/src/frontend/src/lib/generated/internet_identity_types.d.ts index 1bfdfbff50..a2579cdc2a 100644 --- a/src/frontend/src/lib/generated/internet_identity_types.d.ts +++ b/src/frontend/src/lib/generated/internet_identity_types.d.ts @@ -76,6 +76,8 @@ export interface AuthnMethodRegistrationInfo { 'expiration' : Timestamp, 'authn_method' : [] | [AuthnMethodData], } +export type AuthnMethodRegistrationModeEnterError = { 'InvalidId' : string } | + { 'AuthorizationFailure' : string }; export type AuthnMethodReplaceError = { 'AuthnMethodNotFound' : null } | { 'InvalidMetadata' : string }; export interface AuthnMethodSecuritySettings { @@ -113,6 +115,7 @@ export interface CheckCaptchaArg { 'solution' : string } export type CheckCaptchaError = { 'NoRegistrationFlow' : null } | { 'UnexpectedCall' : { 'next_step' : RegistrationFlowNextStep } } | { 'WrongSolution' : { 'new_captcha_png_base64' : string } }; +export type CheckTentativeDeviceError = { 'Unauthorized' : null }; export type CreateAccountError = { 'AccountLimitReached' : null } | { 'InternalCanisterError' : string } | { 'Unauthorized' : Principal } | @@ -279,6 +282,7 @@ export type KeyType = { 'platform' : null } | { 'cross_platform' : null } | { 'unknown' : null } | { 'browser_storage_key' : null }; +export type LookupByRegistrationIdError = { 'InvalidId' : string }; export type MetadataMap = Array< [ string, @@ -355,6 +359,7 @@ export type RegistrationFlowNextStep = { 'CheckCaptcha' : { 'captcha_png_base64' : string } } | { 'Finish' : null }; +export type RegistrationId = string; export type Salt = Uint8Array | number[]; export type SessionKey = PublicKey; export interface SignedDelegation { @@ -408,11 +413,21 @@ export interface _SERVICE { { 'Ok' : null } | { 'Err' : AuthnMethodAddError } >, + 'authn_method_check_tentative_device' : ActorMethod< + [IdentityNumber], + { 'Ok' : boolean } | + { 'Err' : CheckTentativeDeviceError } + >, 'authn_method_confirm' : ActorMethod< [IdentityNumber, string], { 'Ok' : null } | { 'Err' : AuthnMethodConfirmationError } >, + 'authn_method_lookup_by_registration_mode_id' : ActorMethod< + [RegistrationId], + { 'Ok' : [] | [IdentityNumber] } | + { 'Err' : LookupByRegistrationIdError } + >, 'authn_method_metadata_replace' : ActorMethod< [IdentityNumber, PublicKey, MetadataMapV2], { 'Ok' : null } | @@ -424,9 +439,9 @@ export interface _SERVICE { { 'Err' : AuthnMethodRegisterError } >, 'authn_method_registration_mode_enter' : ActorMethod< - [IdentityNumber], + [IdentityNumber, [] | [RegistrationId]], { 'Ok' : { 'expiration' : Timestamp } } | - { 'Err' : null } + { 'Err' : AuthnMethodRegistrationModeEnterError } >, 'authn_method_registration_mode_exit' : ActorMethod< [IdentityNumber], diff --git a/src/frontend/src/lib/utils/UI/backgrounds/perlinNoise3d.ts b/src/frontend/src/lib/utils/UI/backgrounds/perlinNoise3d.ts new file mode 100644 index 0000000000..3375495071 --- /dev/null +++ b/src/frontend/src/lib/utils/UI/backgrounds/perlinNoise3d.ts @@ -0,0 +1,162 @@ +/** + * 3D Perlin Noise implementation in TypeScript. + * Adapted from P5.js and other sources. + */ +export class PerlinNoise3D { + private static readonly PERLIN_YWRAPB = 4; + private static readonly PERLIN_YWRAP = 1 << PerlinNoise3D.PERLIN_YWRAPB; + private static readonly PERLIN_ZWRAPB = 8; + private static readonly PERLIN_ZWRAP = 1 << PerlinNoise3D.PERLIN_ZWRAPB; + private static readonly PERLIN_SIZE = 4095; + + private static readonly SINCOS_PRECISION = 0.5; + private static readonly SINCOS_LENGTH = Math.floor( + 360 / PerlinNoise3D.SINCOS_PRECISION, + ); + private static readonly DEG_TO_RAD = Math.PI / 180.0; + private static readonly sinLUT: number[] = []; + private static readonly cosLUT: number[] = []; + private static readonly perlin_PI: number = PerlinNoise3D.SINCOS_LENGTH >> 1; + + private perlin: number[] | null = null; + public perlin_octaves: number = 4; + public perlin_amp_falloff: number = 0.5; + + constructor() { + // Initialize lookup tables if not already done + if (PerlinNoise3D.sinLUT.length === 0) { + for (let i = 0; i < PerlinNoise3D.SINCOS_LENGTH; i++) { + PerlinNoise3D.sinLUT[i] = Math.sin( + i * PerlinNoise3D.DEG_TO_RAD * PerlinNoise3D.SINCOS_PRECISION, + ); + PerlinNoise3D.cosLUT[i] = Math.cos( + i * PerlinNoise3D.DEG_TO_RAD * PerlinNoise3D.SINCOS_PRECISION, + ); + } + } + } + + /** + * Seeds the Perlin noise generator. + * @param seed The seed value. + */ + public noiseSeed(seed?: number): void { + // Linear Congruential Generator + const m = 4294967296; + const a = 1664525; + const c = 1013904223; + let z = (seed == null ? Math.random() * m : seed) >>> 0; + + this.perlin = new Array(PerlinNoise3D.PERLIN_SIZE + 1); + for (let i = 0; i < PerlinNoise3D.PERLIN_SIZE + 1; i++) { + z = (a * z + c) % m; + this.perlin[i] = z / m; + } + } + + /** + * Returns Perlin noise value at coordinates (x, y, z). + * @param x X coordinate + * @param y Y coordinate (default 0) + * @param z Z coordinate (default 0) + */ + public get(x: number, y: number = 0, z: number = 0): number { + if (!this.perlin) { + // Lazy initialize with random values + this.perlin = new Array(PerlinNoise3D.PERLIN_SIZE + 1); + for (let i = 0; i < PerlinNoise3D.PERLIN_SIZE + 1; i++) { + this.perlin[i] = Math.random(); + } + } + + x = Math.abs(x); + y = Math.abs(y); + z = Math.abs(z); + + let xi = Math.floor(x); + let yi = Math.floor(y); + let zi = Math.floor(z); + let xf = x - xi; + let yf = y - yi; + let zf = z - zi; + + let r = 0; + let ampl = 0.5; + + const noise_fsc = (i: number): number => { + // using cosine lookup table + return ( + 0.5 * + (1.0 - + PerlinNoise3D.cosLUT[ + Math.floor(i * PerlinNoise3D.perlin_PI) % + PerlinNoise3D.SINCOS_LENGTH + ]) + ); + }; + + for (let o = 0; o < this.perlin_octaves; o++) { + let of = + xi + + (yi << PerlinNoise3D.PERLIN_YWRAPB) + + (zi << PerlinNoise3D.PERLIN_ZWRAPB); + + const rxf = noise_fsc(xf); + const ryf = noise_fsc(yf); + + let n1 = this.perlin[of & PerlinNoise3D.PERLIN_SIZE]!; + n1 += rxf * (this.perlin[(of + 1) & PerlinNoise3D.PERLIN_SIZE]! - n1); + let n2 = + this.perlin[ + (of + PerlinNoise3D.PERLIN_YWRAP) & PerlinNoise3D.PERLIN_SIZE + ]!; + n2 += + rxf * + (this.perlin[ + (of + PerlinNoise3D.PERLIN_YWRAP + 1) & PerlinNoise3D.PERLIN_SIZE + ]! - + n2); + n1 += ryf * (n2 - n1); + + of += PerlinNoise3D.PERLIN_ZWRAP; + n2 = this.perlin[of & PerlinNoise3D.PERLIN_SIZE]!; + n2 += rxf * (this.perlin[(of + 1) & PerlinNoise3D.PERLIN_SIZE]! - n2); + let n3 = + this.perlin[ + (of + PerlinNoise3D.PERLIN_YWRAP) & PerlinNoise3D.PERLIN_SIZE + ]!; + n3 += + rxf * + (this.perlin[ + (of + PerlinNoise3D.PERLIN_YWRAP + 1) & PerlinNoise3D.PERLIN_SIZE + ]! - + n3); + n2 += ryf * (n3 - n2); + + n1 += noise_fsc(zf) * (n2 - n1); + + r += n1 * ampl; + ampl *= this.perlin_amp_falloff; + xi <<= 1; + xf *= 2; + yi <<= 1; + yf *= 2; + zi <<= 1; + zf *= 2; + + if (xf >= 1.0) { + xi++; + xf--; + } + if (yf >= 1.0) { + yi++; + yf--; + } + if (zf >= 1.0) { + zi++; + zf--; + } + } + return r; + } +} diff --git a/src/frontend/src/lib/utils/UI/backgrounds/waveBackground.ts b/src/frontend/src/lib/utils/UI/backgrounds/waveBackground.ts new file mode 100644 index 0000000000..6177831dbc --- /dev/null +++ b/src/frontend/src/lib/utils/UI/backgrounds/waveBackground.ts @@ -0,0 +1,737 @@ +import type { + FlairAnimationOptions, + FlairBoxProps, +} from "$lib/components/backgrounds/FlairBox.d.ts"; +import { quadInOut } from "svelte/easing"; +import { Spring, Tween } from "svelte/motion"; +import { EasingFunction } from "svelte/transition"; +import { PerlinNoise3D } from "./perlinNoise3d"; +import { NodeMotion } from "$lib/components/backgrounds/FlairCanvas"; + +////// ANIMATION ////// +export function createXYSprings( + xCount: number, + yCount: number, + SpringCtor: typeof Spring | typeof Tween, + stiffness: number, + damping: number, +) { + if (SpringCtor instanceof Spring) { + return Array.from({ length: xCount }, (_, x) => + Array.from( + { length: yCount }, + () => new SpringCtor({ x: 0, y: 0 }, { stiffness, damping }), + ), + ); + } else { + return Array.from({ length: xCount }, (_, x) => + Array.from( + { length: yCount }, + () => + new SpringCtor({ x: 0, y: 0 }, { duration: 200, easing: quadInOut }), + ), + ); + } +} + +export function createXYNodeMotions( + xCount: number, + yCount: number, + SpringCtor: typeof Spring | typeof Tween, + stiffness: number, + damping: number, +): NodeMotion[][] { + if (SpringCtor instanceof Spring) { + return Array.from({ length: xCount }, (_, x) => + Array.from({ length: yCount }, () => { + return { + motion: new SpringCtor({ x: 0, y: 0 }, { stiffness, damping }), + prev: { x: 0, y: 0 }, + speed: 0, + }; + }), + ); + } else { + return Array.from({ length: xCount }, (_, x) => + Array.from({ length: yCount }, () => { + return { + motion: new SpringCtor( + { x: 0, y: 0 }, + { duration: 200, easing: quadInOut }, + ), + prev: { x: 0, y: 0 }, + speed: 0, + }; + }), + ); + } +} + +export function createScalarSprings( + xCount: number, + yCount: number, + stiffness: number, + damping: number, +) { + return Array.from({ length: xCount }, (_, x) => + Array.from({ length: yCount }, () => new Spring(0, { stiffness, damping })), + ); +} + +export function leftPos( + xPos: number, + xIndex: number, + yIndex: number, + offsetX: number, + springs: NodeMotion[][] | undefined, +) { + if ( + springs === undefined || + springs[xIndex] === undefined || + springs[xIndex][yIndex] === undefined + ) + return xPos + offsetX; + return xPos + offsetX + springs[xIndex][yIndex].motion.current.x; +} + +export function topPos( + yPos: number, + xIndex: number, + yIndex: number, + offsetY: number, + springs: NodeMotion[][] | undefined, +) { + if ( + springs === undefined || + springs[xIndex] === undefined || + springs[xIndex][yIndex] === undefined + ) + return yPos + offsetY; + return yPos + offsetY + springs[xIndex][yIndex].motion.current.y; +} + +export function waveScale( + xIndex: number, + yIndex: number, + springs: (Spring | Tween | undefined)[][] | undefined, +) { + if ( + springs === undefined || + springs[xIndex] === undefined || + springs[xIndex][yIndex] === undefined + ) + return 1; + + return 1 + springs[xIndex][yIndex].current * 0.01; +} + +export function createContinuousWave( + mouseX: number, + mouseY: number, + xPositions: number[], + yPositions: number[], + offsetX: number, + offsetY: number, + springs: NodeMotion[][], + xSpacing: number, + ySpacing: number, + mouseRadius: number, + mouseScalar: number, + waveSpeed: number, + impulseDuration: number, + axis: "xy" | "x" | "y" = "xy", +) { + const minX = Math.max( + 0, + Math.floor((mouseX - offsetX - mouseRadius) / xSpacing), + ); + const maxX = Math.min( + xPositions.length - 1, + Math.ceil((mouseX - offsetX + mouseRadius) / xSpacing), + ); + const minY = Math.max( + 0, + Math.floor((mouseY - offsetY - mouseRadius) / ySpacing), + ); + const maxY = Math.min( + yPositions.length - 1, + Math.ceil((mouseY - offsetY + mouseRadius) / ySpacing), + ); + + for (let xIndex = minX; xIndex <= maxX; xIndex++) { + for (let yIndex = minY; yIndex <= maxY; yIndex++) { + const nodeX = xPositions[xIndex] + offsetX; + const nodeY = yPositions[yIndex] + offsetY; + const dx = nodeX - mouseX; + const dy = nodeY - mouseY; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist < mouseRadius) { + const delay = dist * waveSpeed; + const falloff = Math.pow(1 - dist / mouseRadius, 2); + const strength = falloff * mouseScalar * mouseRadius; + + setTimeout(() => { + if (strength > 0.01) { + let xVal = (dx / dist) * strength; + let yVal = (dy / dist) * strength; + if (axis === "x") yVal = 0; + if (axis === "y") xVal = 0; + springs[xIndex][yIndex].motion.target = { x: xVal, y: yVal }; + + setTimeout(() => { + springs[xIndex][yIndex].motion.target = { x: 0, y: 0 }; + }, impulseDuration); + } + }, delay); + } else { + springs[xIndex][yIndex].motion.target = { x: 0, y: 0 }; + } + } + } +} + +export function createContinuousScalarWave( + mouseX: number, + mouseY: number, + xPositions: number[], + yPositions: number[], + offsetX: number, + offsetY: number, + springs: Spring[][] | Tween[][], + xSpacing: number, + ySpacing: number, + mouseRadius: number, + mouseScalar: number, + waveSpeed: number, + impulseDuration: number, +) { + const minX = Math.max( + 0, + Math.floor((mouseX - offsetX - mouseRadius) / xSpacing), + ); + const maxX = Math.min( + xPositions.length - 1, + Math.ceil((mouseX - offsetX + mouseRadius) / xSpacing), + ); + const minY = Math.max( + 0, + Math.floor((mouseY - offsetY - mouseRadius) / ySpacing), + ); + const maxY = Math.min( + yPositions.length - 1, + Math.ceil((mouseY - offsetY + mouseRadius) / ySpacing), + ); + + for (let xIndex = minX; xIndex <= maxX; xIndex++) { + for (let yIndex = minY; yIndex <= maxY; yIndex++) { + const nodeX = xPositions[xIndex] + offsetX; + const nodeY = yPositions[yIndex] + offsetY; + const dx = nodeX - mouseX; + const dy = nodeY - mouseY; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist < mouseRadius) { + const delay = dist * waveSpeed; + const falloff = Math.pow(1 - dist / mouseRadius, 2); + const strength = falloff * mouseScalar * mouseRadius; + + setTimeout(() => { + if (strength > 0.01) { + springs[xIndex][yIndex].target = dist * strength; + setTimeout(() => { + springs[xIndex][yIndex].target = 0; + }, impulseDuration); + } + }, delay); + } else { + springs[xIndex][yIndex].target = 0; + } + } + } +} + +export function createImpulse( + x: number, + y: number, + xPositions: number[], + yPositions: number[], + offsetX: number, + offsetY: number, + springs: NodeMotion[][], + clickRadius: number, + impulseScalar: number, + waveSpeed: number, + impulseDuration: number, + direction: "omni" | "x" | "y", + impulseEasing?: EasingFunction, +) { + for (let xIndex = 0; xIndex < xPositions.length; xIndex++) { + for (let yIndex = 0; yIndex < yPositions.length; yIndex++) { + const nodeX = xPositions[xIndex] + offsetX; + const nodeY = yPositions[yIndex] + offsetY; + const dx = nodeX - x; + const dy = nodeY - y; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist < 1e-6) continue; + + const falloff = Math.max(0, 1 - dist / clickRadius); + const strength = Math.pow(falloff, 2) * impulseScalar * clickRadius; + + // Easing for the delay + const t = Math.min(dist / clickRadius, 1); // normalized distance [0,1] + const eased = impulseEasing ? impulseEasing(t) : t; // or any easing function you prefer + const delay = eased * clickRadius * waveSpeed; // or: delay = eased * dist * waveSpeed; + + setTimeout(() => { + if (strength > 0.01) { + let xVal = (dx / dist) * strength; + let yVal = (dy / dist) * strength; + if (direction === "x") yVal = 0; + if (direction === "y") xVal = 0; + springs[xIndex][yIndex].motion.target = { x: xVal, y: yVal }; + setTimeout(() => { + springs[xIndex][yIndex].motion.target = { x: 0, y: 0 }; + }, impulseDuration); + } + }, delay); + } + } +} + +export function createPerlinImpulse( + x: number, + y: number, + xPositions: number[], + yPositions: number[], + offsetX: number, + offsetY: number, + springs: NodeMotion[][], + clickRadius: number, + impulseScalar: number, + waveSpeed: number, + impulseDuration: number, + direction: "omni" | "x" | "y", + perlin: PerlinNoise3D, + perlinScale: number = 0.01, // scale for perlin input, tweak as needed + perlinZ: number = 0, // optional z value for animation + impulseEasing?: EasingFunction, +) { + for (let xIndex = 0; xIndex < xPositions.length; xIndex++) { + for (let yIndex = 0; yIndex < yPositions.length; yIndex++) { + const nodeX = xPositions[xIndex] + offsetX; + const nodeY = yPositions[yIndex] + offsetY; + const dx = nodeX - x; + const dy = nodeY - y; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist < 1e-6) continue; + + const falloff = Math.max(0, 1 - dist / clickRadius); + const strength = Math.pow(falloff, 2) * impulseScalar * clickRadius; + + // Perlin noise value for this node's position + const perlinValue = perlin.get( + nodeX * perlinScale, + nodeY * perlinScale, + perlinZ, + ); + // Map perlin [0,1] to [-1,1] + const perlinMapped = perlinValue - 0.5; + + // Easing for the delay + const t = Math.min(dist / clickRadius, 1); + const eased = impulseEasing ? impulseEasing(t) : t; + const delay = eased * clickRadius * waveSpeed; + + setTimeout(() => { + // Only apply if strength is significant + if (Math.abs(strength * perlinMapped) > 0.01) { + // Outward if perlinMapped > 0, inward if < 0 + let xVal = (dx / dist) * strength * perlinMapped * 30; + let yVal = (dy / dist) * strength * perlinMapped * 30; + if (direction === "x") yVal = 0; + if (direction === "y") xVal = 0; + springs[xIndex][yIndex].motion.target = { x: xVal, y: yVal }; + setTimeout(() => { + springs[xIndex][yIndex].motion.target = { x: 0, y: 0 }; + }, impulseDuration); + } + }, delay); + } + } +} + +export function createRotationalImpulse( + x: number, + y: number, + xPositions: number[], + yPositions: number[], + offsetX: number, + offsetY: number, + springs: NodeMotion[][], + clickRadius: number, + impulseScalar: number, + waveSpeed: number, + impulseDuration: number, + direction: "cw" | "ccw" = "cw", // clockwise or counterclockwise + impulseEasing?: EasingFunction, +) { + for (let xIndex = 0; xIndex < xPositions.length; xIndex++) { + for (let yIndex = 0; yIndex < yPositions.length; yIndex++) { + const nodeX = xPositions[xIndex] + offsetX; + const nodeY = yPositions[yIndex] + offsetY; + const dx = nodeX - x; + const dy = nodeY - y; + const dist = Math.sqrt(dx * dx + dy * dy); + + const falloff = Math.max(0, 1 - dist / clickRadius); + const strength = Math.pow(falloff, 2) * impulseScalar * clickRadius; + // Easing for the delay + const t = Math.min(dist / clickRadius, 1); // normalized distance [0,1] + const eased = impulseEasing ? impulseEasing(t) : t; + const delay = eased * clickRadius * waveSpeed; + + setTimeout(() => { + if (strength > 0.01 && dist > 0.0001) { + // Perpendicular vector: (-dy, dx) for ccw, (dy, -dx) for cw + let perpX, perpY; + if (direction === "cw") { + perpX = dy / dist; + perpY = -dx / dist; + } else { + perpX = -dy / dist; + perpY = dx / dist; + } + springs[xIndex][yIndex].motion.target = { + x: perpX * strength, + y: perpY * strength, + }; + setTimeout(() => { + springs[xIndex][yIndex].motion.target = { x: 0, y: 0 }; + }, impulseDuration); + } + }, delay); + } + } +} + +export function createDirectionalImpulse( + mouseX: number, + mouseY: number, + xPositions: number[], + yPositions: number[], + offsetX: number, + offsetY: number, + springs: NodeMotion[][], + clickRadius: number, + impulseScalar: number, + waveSpeed: number, + impulseDuration: number, + direction: "up" | "down" | "left" | "right", + axis: "xy" | "x" | "y" = "xy", + impulseEasing?: EasingFunction, +) { + for (let xIndex = 0; xIndex < xPositions.length; xIndex++) { + for (let yIndex = 0; yIndex < yPositions.length; yIndex++) { + const nodeX = xPositions[xIndex] + offsetX; + const nodeY = yPositions[yIndex] + offsetY; + const dx = nodeX - mouseX; + const dy = nodeY - mouseY; + const dist = Math.sqrt(dx * dx + dy * dy); + + // Directional filtering and scaling + let directionalScale = 0; + switch (direction) { + case "up": + if (dy < 0) directionalScale = -dy / dist; // sin(theta), negative dy is up + break; + case "down": + if (dy > 0) directionalScale = dy / dist; // sin(theta), positive dy is down + break; + case "left": + if (dx < 0) directionalScale = -dx / dist; // cos(theta), negative dx is left + break; + case "right": + if (dx > 0) directionalScale = dx / dist; // cos(theta), positive dx is right + break; + } + + // Only apply impulse if in the correct direction and not at the mouse position + if (directionalScale > 0 && dist > 0.0001) { + const falloff = Math.max(0, 1 - dist / clickRadius); + const strength = + Math.pow(falloff, 2) * impulseScalar * clickRadius * directionalScale; + // Easing for the delay + const t = Math.min(dist / clickRadius, 1); // normalized distance [0,1] + const eased = impulseEasing ? impulseEasing(t) : t; + const delay = eased * clickRadius * waveSpeed; + + setTimeout(() => { + if (strength > 0.01) { + let xVal = (dx / dist) * strength; + let yVal = (dy / dist) * strength; + if (axis === "x") yVal = 0; + if (axis === "y") xVal = 0; + springs[xIndex][yIndex].motion.target = { x: xVal, y: yVal }; + setTimeout(() => { + springs[xIndex][yIndex].motion.target = { x: 0, y: 0 }; + }, impulseDuration); + } + }, delay); + } + } + } +} + +export function createScalarImpulse( + mouseX: number, + mouseY: number, + xPositions: number[], + yPositions: number[], + offsetX: number, + offsetY: number, + springs: Spring[][] | Tween[][], + clickRadius: number, + impulseScalar: number, + waveSpeed: number, + impulseDuration: number, +) { + for (let xIndex = 0; xIndex < xPositions.length; xIndex++) { + for (let yIndex = 0; yIndex < yPositions.length; yIndex++) { + const nodeX = xPositions[xIndex] + offsetX; + const nodeY = yPositions[yIndex] + offsetY; + const dx = nodeX - mouseX; + const dy = nodeY - mouseY; + const dist = Math.sqrt(dx * dx + dy * dy); + + const falloff = Math.max(0, 1 - dist / clickRadius); + const strength = Math.pow(falloff, 2) * impulseScalar * clickRadius; + const delay = dist * waveSpeed; + + setTimeout(() => { + if (strength > 0.01) { + springs[xIndex][yIndex].target = dist * strength; + setTimeout(() => { + springs[xIndex][yIndex].target = 0; + }, impulseDuration); + } + }, delay); + } + } +} + +export function resetNodes(springs: NodeMotion[][]) { + for (let x = 0; x < springs.length; x++) { + for (let y = 0; y < springs[x].length; y++) { + springs[x][y].motion.target = { x: 0, y: 0 }; + } + } +} + +export function resetScalarNodes( + springs: Spring[][] | Tween[][], +) { + for (let x = 0; x < springs.length; x++) { + for (let y = 0; y < springs[x].length; y++) { + springs[x][y].target = 0; + } + } +} + +export function gridPath(x1: number, y1: number, x2: number, y2: number) { + const cx = (x1 + x2) / 2; + const cy = (y1 + y2) / 2; + + return `M ${x1} ${y1} C ${cx} ${cy}, ${cx} ${cy}, ${x2} ${y2}`; +} + +export function getImpulseLocation( + location: FlairAnimationOptions["location"], + innerWidth: number, + innerHeight: number, +) { + switch (location) { + case "top": + return { x: innerWidth / 2, y: 0 }; + case "left": + return { x: 0, y: innerHeight / 2 }; + case "right": + return { x: innerWidth, y: innerHeight / 2 }; + case "bottom": + return { x: innerWidth / 2, y: innerHeight }; + case "topLeft": + return { x: 0, y: 0 }; + case "topRight": + return { x: innerWidth, y: 0 }; + case "bottomLeft": + return { x: 0, y: innerHeight }; + case "bottomRight": + return { x: innerWidth, y: innerHeight }; + case "center": + return { x: innerWidth / 2, y: innerHeight / 2 }; + default: + if ( + typeof location === "object" && + location !== null && + typeof location.x === "number" && + typeof location.y === "number" + ) { + return location; + } + return { x: innerWidth / 2, y: innerHeight / 2 }; + } +} + +////// SVG ////// + +export function getVignetteConfig( + vignette: FlairBoxProps["vignette"], + innerHeight: number | undefined, + innerWidth: number | undefined, +) { + // Margin from the edge of the viewport + const margin = 0; + // How much smaller the vignette is compared to the viewport + const scale = 0.5; + + if (innerHeight === undefined || innerWidth === undefined) { + return { cx: 0, cy: 0, rx: 0, ry: 0 }; + } + + const w = + vignette === "top" || vignette === "bottom" ? innerWidth * 2 : innerWidth; + const h = + vignette === "left" || vignette === "right" ? innerHeight * 2 : innerHeight; + let rx = (w * scale) / 2; + let ry = (h * scale) / 2; + + let cx = innerWidth / 2; + let cy = innerHeight / 2; + + switch (vignette) { + case "top": + cy = 0; + + break; + case "bottom": + cy = h - margin; + + break; + case "left": + cx = margin; + + break; + case "right": + cx = w - margin; + + break; + case "center": + rx = w * scale; + ry = h * scale; + break; + default: + // already centered + break; + } + + return { cx, cy, rx, ry }; +} + +///////// CANVAS //////// + +export function drawNodes( + xPositions: number[], + yPositions: number[], + offsetX: number, + offsetY: number, + springs: NodeMotion[][], + radius: number, + visibility: "always" | "moving", + ctx: CanvasRenderingContext2D, +) { + const body = document.querySelector("body"); + if (!body) return; + const color = getComputedStyle(body).getPropertyValue("--fg-tertiary").trim(); + xPositions.forEach((xPos, xIndex) => { + yPositions.forEach((yPos, yIndex) => { + ctx.beginPath(); + ctx.ellipse( + leftPos(xPos, xIndex, yIndex, offsetX, springs), + topPos(yPos, xIndex, yIndex, offsetY, springs), + radius, + radius, + 0, + 0, + 360, + false, + ); + ctx.fillStyle = color; + + if (visibility === "moving") { + const speed = springs[xIndex][yIndex].speed; + ctx.fillStyle = hexToRgba(color, speed !== undefined ? speed : 0); + } + ctx.fill(); + ctx.closePath(); + }); + }); +} + +export function drawVignetteMask( + width: number, + height: number, + vignetteConfig: { cx: number; cy: number; rx: number; ry: number }, + ctx: CanvasRenderingContext2D, +) { + // Create a radial gradient for the vignette + const { cx, cy, rx, ry } = vignetteConfig; + + // Calculate center in pixels + const centerX = cx; + const centerY = cy; + // Use the larger of rx/ry for the gradient radius + const maxRadius = Math.max(rx, ry); + + // Create a radial gradient + const gradient = ctx.createRadialGradient( + centerX, + centerY, + 0, // inner circle (center, radius 0) + centerX, + centerY, + maxRadius, // outer circle (center, max radius) + ); + gradient.addColorStop(0, "rgba(0,0,0,0)"); // fully transparent at center + gradient.addColorStop(0.5, "rgba(0,0,0,0)"); + gradient.addColorStop(0.7, "rgba(0,0,0,0.2)"); + gradient.addColorStop(1, "rgba(0,0,0,1)"); // mostly opaque at edge + + // Optionally, blur the gradient by drawing to an offscreen canvas and using ctx.filter = 'blur(...)' + // For most use cases, the gradient alone is enough. + + // Draw the gradient over the canvas + ctx.save(); + ctx.globalCompositeOperation = "destination-out"; + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, width, height); + ctx.restore(); +} + +function hexToRgba(hex: string, alpha: number): string { + // Remove leading # + hex = hex.replace(/^#/, ""); + // Handle shorthand hex (#abc) + if (hex.length === 3) { + hex = hex + .split("") + .map((x) => x + x) + .join(""); + } + const num = parseInt(hex, 16); + const r = (num >> 16) & 255; + const g = (num >> 8) & 255; + const b = num & 255; + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} diff --git a/src/frontend/src/routes/(new-styling)/fancy-backgrounds/flair-box/+page.svelte b/src/frontend/src/routes/(new-styling)/fancy-backgrounds/flair-box/+page.svelte new file mode 100644 index 0000000000..d4081ed954 --- /dev/null +++ b/src/frontend/src/routes/(new-styling)/fancy-backgrounds/flair-box/+page.svelte @@ -0,0 +1,249 @@ + + + + + + +{#if showDropdown} +
+
+ FlairBox Controls + +
+
+ +
+ + + + + + + +
+ +
+ Impulse Controls +
+ + + + + + + +
+
+{/if} + + diff --git a/src/frontend/src/routes/(new-styling)/fancy-backgrounds/flair-canvas/+page.svelte b/src/frontend/src/routes/(new-styling)/fancy-backgrounds/flair-canvas/+page.svelte new file mode 100644 index 0000000000..c8dc3428a8 --- /dev/null +++ b/src/frontend/src/routes/(new-styling)/fancy-backgrounds/flair-canvas/+page.svelte @@ -0,0 +1,633 @@ + + + + + + +{#if showDropdown} +
+
+ FlairCanvas Controls + +
+
+ +
+ + + + + + + + + + + + + + + + Animation Controls +
+ + + + + + + + + +
+
+{/if} + +{#snippet flair()} + +{/snippet} + +{#if flairBoxProps.display === "bgOnly"} + {@render flair()} +{:else if flairBoxProps.display === "behindBox"} + {@render flair()} +
+ +

This is a Panel!

+

+ Lorem Ipsum is simply dummy text of the printing and typesetting + industry. Lorem Ipsum has been the industry's standard dummy text ever + since the 1500s, when an unknown printer took a galley of type and + scrambled it to make a type specimen book. +

+ + + +
+
+{:else if flairBoxProps.display === "insideBox"} +
+ + {@render flair()} +
+

This is a Panel!

+

+ Lorem Ipsum is simply dummy text of the printing and typesetting + industry. Lorem Ipsum has been the industry's standard dummy text ever + since the 1500s, when an unknown printer took a galley of type and + scrambled it to make a type specimen book. +

+ + + +
+
+
+{/if} diff --git a/src/frontend/src/routes/(new-styling)/fancy-backgrounds/perlin-wave/+page.svelte b/src/frontend/src/routes/(new-styling)/fancy-backgrounds/perlin-wave/+page.svelte new file mode 100644 index 0000000000..528abd6ca1 --- /dev/null +++ b/src/frontend/src/routes/(new-styling)/fancy-backgrounds/perlin-wave/+page.svelte @@ -0,0 +1,5 @@ + + + diff --git a/src/frontend/src/routes/(new-styling)/fancy-backgrounds/scale-wave-grid/+page.svelte b/src/frontend/src/routes/(new-styling)/fancy-backgrounds/scale-wave-grid/+page.svelte new file mode 100644 index 0000000000..0ce96e0407 --- /dev/null +++ b/src/frontend/src/routes/(new-styling)/fancy-backgrounds/scale-wave-grid/+page.svelte @@ -0,0 +1,5 @@ + + + diff --git a/src/frontend/src/routes/(new-styling)/fancy-backgrounds/wave-grid/+page.svelte b/src/frontend/src/routes/(new-styling)/fancy-backgrounds/wave-grid/+page.svelte new file mode 100644 index 0000000000..d6c54ee7b8 --- /dev/null +++ b/src/frontend/src/routes/(new-styling)/fancy-backgrounds/wave-grid/+page.svelte @@ -0,0 +1,5 @@ + + + diff --git a/src/internet_identity/internet_identity.did b/src/internet_identity/internet_identity.did index d504a90b4c..ca1d3f0a7a 100644 --- a/src/internet_identity/internet_identity.did +++ b/src/internet_identity/internet_identity.did @@ -493,6 +493,8 @@ type AuthnMethodConfirmationCode = record { expiration : Timestamp; }; +type RegistrationId = text; + type AuthnMethodRegisterError = variant { // Authentication method registration mode is off, either due to timeout or because it was never enabled. RegistrationModeOff; @@ -513,6 +515,19 @@ type AuthnMethodConfirmationError = variant { NoAuthnMethodToConfirm; }; +type AuthnMethodRegistrationModeEnterError = variant { + InvalidId : text; + AuthorizationFailure : text; +}; + +type CheckTentativeDeviceError = variant { + Unauthorized; +}; + +type LookupByRegistrationIdError = variant { + InvalidId : text; +}; + type IdentityAuthnInfo = record { authn_methods : vec AuthnMethod; recovery_authn_methods : vec AuthnMethod; @@ -839,7 +854,7 @@ service : (opt InternetIdentityInit) -> { // confirmed before it can be used for authentication on this identity. // The registration mode is automatically exited after the returned expiration timestamp. // Requires authentication. - authn_method_registration_mode_enter : (IdentityNumber) -> (variant { Ok : record { expiration : Timestamp }; Err }); + authn_method_registration_mode_enter : (IdentityNumber, opt RegistrationId) -> (variant { Ok : record { expiration : Timestamp }; Err : AuthnMethodRegistrationModeEnterError }); // Exits the authentication method registration mode for the identity. // Requires authentication. @@ -855,6 +870,10 @@ service : (opt InternetIdentityInit) -> { // Requires authentication. authn_method_confirm : (IdentityNumber, confirmation_code : text) -> (variant { Ok; Err : AuthnMethodConfirmationError }); + authn_method_check_tentative_device : (IdentityNumber) -> (variant { Ok : bool; Err: CheckTentativeDeviceError }) query; + + authn_method_lookup_by_registration_mode_id : (RegistrationId) -> (variant { Ok : opt IdentityNumber; Err : LookupByRegistrationIdError }) query; + // Authentication protocol // ======================= prepare_delegation : (UserNumber, FrontendHostname, SessionKey, maxTimeToLive : opt nat64) -> (UserKey, Timestamp); diff --git a/src/internet_identity/src/anchor_management.rs b/src/internet_identity/src/anchor_management.rs index 41ddc3a6f0..9ffe9a3985 100644 --- a/src/internet_identity/src/anchor_management.rs +++ b/src/internet_identity/src/anchor_management.rs @@ -1,7 +1,5 @@ use crate::archive::{archive_operation, device_diff}; use crate::openid::{OpenIdCredential, OpenIdCredentialKey}; -use crate::state::RegistrationState::DeviceTentativelyAdded; -use crate::state::TentativeDeviceRegistration; use crate::storage::anchor::{Anchor, AnchorError, Device}; use crate::{state, stats::activity_stats}; use ic_cdk::api::time; @@ -10,8 +8,7 @@ use internet_identity_interface::archive::types::{DeviceDataWithoutAlias, Operat use internet_identity_interface::internet_identity::types::openid::OpenIdCredentialData; use internet_identity_interface::internet_identity::types::{ AnchorNumber, AuthorizationKey, CredentialId, DeviceData, DeviceKey, DeviceKeyWithAnchor, - DeviceRegistrationInfo, DeviceWithUsage, IdentityAnchorInfo, IdentityPropertiesReplace, - MetadataEntry, + DeviceWithUsage, IdentityAnchorInfo, IdentityPropertiesReplace, MetadataEntry, }; use state::storage_borrow; use std::collections::HashMap; @@ -38,42 +35,16 @@ pub fn get_anchor_info(anchor_number: AnchorNumber) -> IdentityAnchorInfo { let name = anchor.name(); let now = time(); - state::tentative_device_registrations(|tentative_device_registrations| { - match tentative_device_registrations.get(&anchor_number) { - Some(TentativeDeviceRegistration { - expiration, - state: - DeviceTentativelyAdded { - tentative_device, .. - }, - }) if *expiration > now => IdentityAnchorInfo { - devices, - device_registration: Some(DeviceRegistrationInfo { - expiration: *expiration, - tentative_device: Some(tentative_device.clone()), - }), - openid_credentials, - name, - }, - Some(TentativeDeviceRegistration { expiration, .. }) if *expiration > now => { - IdentityAnchorInfo { - devices, - device_registration: Some(DeviceRegistrationInfo { - expiration: *expiration, - tentative_device: None, - }), - openid_credentials, - name, - } - } - None | Some(_) => IdentityAnchorInfo { - devices, - device_registration: None, - openid_credentials, - name, - }, - } - }) + let tentative_device_registration = + state::get_tentative_device_registration_by_identity(anchor_number); + + IdentityAnchorInfo { + devices, + device_registration: tentative_device_registration + .and_then(|reg| reg.to_info_if_still_valid(now)), + openid_credentials, + name, + } } /// Handles all the bookkeeping required on anchor activity: diff --git a/src/internet_identity/src/anchor_management/tentative_device_registration.rs b/src/internet_identity/src/anchor_management/tentative_device_registration.rs index 46fb16c0a8..7748c21805 100644 --- a/src/internet_identity/src/anchor_management/tentative_device_registration.rs +++ b/src/internet_identity/src/anchor_management/tentative_device_registration.rs @@ -1,10 +1,10 @@ use crate::anchor_management::add_device; -use crate::authz_utils::IdentityUpdateError; +use crate::authz_utils::{AuthorizationError, IdentityUpdateError}; use crate::state::RegistrationState::{DeviceRegistrationModeActive, DeviceTentativelyAdded}; use crate::state::TentativeDeviceRegistration; use crate::storage::anchor::Anchor; use crate::{secs_to_nanos, state}; -use candid::Principal; +use candid::{CandidType, Principal}; use ic_cdk::api::time; use ic_cdk::{call, trap}; use internet_identity_interface::archive::types::Operation; @@ -37,6 +37,7 @@ pub fn enter_device_registration_mode(anchor_number: AnchorNumber) -> Timestamp TentativeDeviceRegistration { expiration, state: DeviceRegistrationModeActive, + id: None, }, ); expiration @@ -45,10 +46,52 @@ pub fn enter_device_registration_mode(anchor_number: AnchorNumber) -> Timestamp }) } +/// Enables device registration mode for the given anchor and returns the expiration timestamp (when it will be disabled again). +/// If the device registration mode is already active it will just return the expiration timestamp again. +pub fn enter_device_registration_mode_v2( + identity_number: IdentityNumber, + id: RegistrationId, +) -> Timestamp { + state::tentative_device_registrations_mut(|registrations| { + state::lookup_tentative_device_registration_mut(|lookup| { + prune_expired_tentative_device_registrations_v2(registrations, lookup); + if registrations.len() >= MAX_ANCHORS_IN_REGISTRATION_MODE { + trap("too many anchors in device registration mode"); + } + + match registrations.get(&identity_number) { + Some(TentativeDeviceRegistration { expiration, .. }) => *expiration, // already enabled, just return the existing expiration + None => { + let expiration = time() + REGISTRATION_MODE_DURATION; + registrations.insert( + identity_number, + TentativeDeviceRegistration { + expiration, + state: DeviceRegistrationModeActive, + id: Some(id.clone()), + }, + ); + lookup.insert(id, identity_number); + + expiration + } + } + }) + }) +} + pub fn exit_device_registration_mode(anchor_number: AnchorNumber) { state::tentative_device_registrations_mut(|registrations| { - prune_expired_tentative_device_registrations(registrations); - registrations.remove(&anchor_number) + state::lookup_tentative_device_registration_mut(|lookup| { + prune_expired_tentative_device_registrations_v2(registrations, lookup); + if let Some(TentativeDeviceRegistration { + id: Some(reg_id), .. + }) = registrations.get(&anchor_number) + { + lookup.remove(reg_id); + } + registrations.remove(&anchor_number); + }); }); } @@ -144,38 +187,57 @@ fn get_verified_device( user_verification_code: DeviceVerificationCode, ) -> Result { state::tentative_device_registrations_mut(|registrations| { - prune_expired_tentative_device_registrations(registrations); + state::lookup_tentative_device_registration_mut(|lookup| { + prune_expired_tentative_device_registrations_v2(registrations, lookup); - let mut tentative_registration = registrations - .remove(&anchor_number) - .ok_or(VerifyTentativeDeviceError::DeviceRegistrationModeOff)?; - - match tentative_registration.state { - DeviceRegistrationModeActive => Err(VerifyTentativeDeviceError::NoDeviceToVerify), - DeviceTentativelyAdded { - failed_attempts, - verification_code, - tentative_device, - } => { - if user_verification_code == verification_code { - return Ok(tentative_device); - } + let mut tentative_registration = registrations + .remove(&anchor_number) + .ok_or(VerifyTentativeDeviceError::DeviceRegistrationModeOff)?; + + if let TentativeDeviceRegistration { + id: Some(ref reg_id), + .. + } = tentative_registration + { + lookup.remove(reg_id); + } - let failed_attempts = failed_attempts + 1; - if failed_attempts < MAX_DEVICE_REGISTRATION_ATTEMPTS { - tentative_registration.state = DeviceTentativelyAdded { - failed_attempts, - tentative_device, - verification_code, - }; - // reinsert because retries are allowed - registrations.insert(anchor_number, tentative_registration); + match tentative_registration.state { + DeviceRegistrationModeActive => Err(VerifyTentativeDeviceError::NoDeviceToVerify), + DeviceTentativelyAdded { + failed_attempts, + verification_code, + tentative_device, + } => { + if user_verification_code == verification_code { + return Ok(tentative_device); + } + + let failed_attempts = failed_attempts + 1; + if failed_attempts < MAX_DEVICE_REGISTRATION_ATTEMPTS { + tentative_registration.state = DeviceTentativelyAdded { + failed_attempts, + tentative_device, + verification_code, + }; + // reinsert because retries are allowed + registrations.insert(anchor_number, tentative_registration); + } + Err(VerifyTentativeDeviceError::WrongCode { + retries_left: (MAX_DEVICE_REGISTRATION_ATTEMPTS - failed_attempts), + }) } - Err(VerifyTentativeDeviceError::WrongCode { - retries_left: (MAX_DEVICE_REGISTRATION_ATTEMPTS - failed_attempts), - }) } - } + }) + }) +} + +/// Checks whether a tentative device has been verified without mutating anything +/// This is so that on the new client we can prompt for adding the final passkey as soon as +/// on the old client we have verified the temporary key +pub fn check_tentative_device(identity_number: IdentityNumber) -> bool { + state::tentative_device_registrations(|registrations| { + registrations.get(&identity_number).is_some() }) } @@ -204,3 +266,46 @@ fn prune_expired_tentative_device_registrations( registrations.retain(|_, TentativeDeviceRegistration { expiration, .. }| *expiration > now) } + +/// Removes __all__ expired device registrations -> there is no need to check expiration immediately after pruning. +fn prune_expired_tentative_device_registrations_v2( + registrations: &mut HashMap, + lookup: &mut HashMap, +) { + let now = time(); + + registrations.retain(|_, TentativeDeviceRegistration { expiration, id, .. }| { + if *expiration > now { + true + } else { + if id.is_some() { + lookup.remove(&id.clone().unwrap()); + } + false + } + }) +} + +#[derive(CandidType)] +pub enum CheckTentativeDeviceError { + Unauthorized, +} + +impl From for CheckTentativeDeviceError { + fn from(_err: AuthorizationError) -> Self { + CheckTentativeDeviceError::Unauthorized + } +} + +#[derive(CandidType, Clone, Eq, PartialEq, Hash)] +pub struct RegistrationId(String); + +impl RegistrationId { + pub fn new(s: String) -> Result { + if s.chars().count() == 5 { + Ok(RegistrationId(s)) + } else { + Err("RegistrationId must be exactly 5 characters".to_string()) + } + } +} diff --git a/src/internet_identity/src/http/metrics.rs b/src/internet_identity/src/http/metrics.rs index 9fc63a823e..4398e50776 100644 --- a/src/internet_identity/src/http/metrics.rs +++ b/src/internet_identity/src/http/metrics.rs @@ -154,6 +154,13 @@ fn encode_metrics(w: &mut MetricsEncoder>) -> std::io::Result<()> { "The number of users in registration mode", ) })?; + state::lookup_tentative_device_registration(|lookup_tentative_device_registration_v2| { + w.encode_gauge( + "internet_identity_users_in_registration_mode_v2", + lookup_tentative_device_registration_v2.len() as f64, + "The number of users in registration mode 2.0", + ) + })?; state::usage_metrics(|usage_metrics| { w.encode_gauge( "internet_identity_delegation_counter", diff --git a/src/internet_identity/src/main.rs b/src/internet_identity/src/main.rs index fdc4d43d25..17dfee277c 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -657,6 +657,13 @@ async fn random_salt() -> Salt { /// input. /// 4. Add additional features to the API v2, that were not possible with the old API. mod v2_api { + use crate::{ + anchor_management::tentative_device_registration::{ + check_tentative_device, CheckTentativeDeviceError, RegistrationId, + }, + state::get_identity_number_by_registration_id, + }; + use super::*; #[query] @@ -858,11 +865,30 @@ mod v2_api { #[update] fn authn_method_registration_mode_enter( identity_number: IdentityNumber, - ) -> Result { - let timeout = enter_device_registration_mode(identity_number); - Ok(RegistrationModeInfo { - expiration: timeout, - }) + id: Option, + ) -> Result { + check_authz_and_record_activity(identity_number).map_err(|err| { + AuthnMethodRegistrationModeEnterError::AuthorizationFailure(err.to_string()) + })?; + match id { + Some(reg_id) => { + let timeout = tentative_device_registration::enter_device_registration_mode_v2( + identity_number, + RegistrationId::new(reg_id) + .map_err(AuthnMethodRegistrationModeEnterError::InvalidId)?, + ); + Ok(RegistrationModeInfo { + expiration: timeout, + }) + } + None => { + let timeout = + tentative_device_registration::enter_device_registration_mode(identity_number); + Ok(RegistrationModeInfo { + expiration: timeout, + }) + } + } } #[update] @@ -915,6 +941,24 @@ mod v2_api { } } } + + #[query] + fn authn_method_check_tentative_device( + identity_number: IdentityNumber, + ) -> Result { + check_authorization(identity_number).map_err(CheckTentativeDeviceError::from)?; + Ok(check_tentative_device(identity_number)) + } + + #[query] + fn authn_method_lookup_by_registration_mode_id( + id: String, + ) -> Result, LookupByRegistrationIdError> { + let res = get_identity_number_by_registration_id( + &RegistrationId::new(id).map_err(LookupByRegistrationIdError::InvalidId)?, + ); + Ok(res) + } } /// API for OpenID credentials diff --git a/src/internet_identity/src/state.rs b/src/internet_identity/src/state.rs index a910889c19..0bc5727d1c 100644 --- a/src/internet_identity/src/state.rs +++ b/src/internet_identity/src/state.rs @@ -1,6 +1,8 @@ +use crate::anchor_management::tentative_device_registration::RegistrationId; use crate::archive::{ArchiveData, ArchiveState, ArchiveStatusCache}; use crate::state::flow_states::FlowStates; use crate::state::temp_keys::TempKeys; +use crate::state::RegistrationState::DeviceTentativelyAdded; use crate::stats::activity_stats::activity_counter::active_anchor_counter::ActiveAnchorCounter; use crate::stats::activity_stats::activity_counter::authn_method_counter::AuthnMethodCounter; use crate::stats::activity_stats::activity_counter::domain_active_anchor_counter::DomainActiveAnchorCounter; @@ -40,12 +42,40 @@ thread_local! { static ASSETS: RefCell = RefCell::new(CertifiedAssets::default()); } +#[derive(Clone)] pub struct TentativeDeviceRegistration { pub expiration: Timestamp, pub state: RegistrationState, + pub id: Option, +} + +impl TentativeDeviceRegistration { + pub fn to_info_if_still_valid(&self, now: Timestamp) -> Option { + match self { + TentativeDeviceRegistration { + expiration, + state: + DeviceTentativelyAdded { + tentative_device, .. + }, + .. + } if *expiration > now => Some(DeviceRegistrationInfo { + expiration: *expiration, + tentative_device: Some(tentative_device.clone()), + }), + TentativeDeviceRegistration { expiration, .. } if *expiration > now => { + Some(DeviceRegistrationInfo { + expiration: *expiration, + tentative_device: None, + }) + } + _ => None, + } + } } /// Registration state of new devices added using the two step device add flow +#[derive(Clone)] pub enum RegistrationState { DeviceRegistrationModeActive, DeviceTentativelyAdded { @@ -190,6 +220,8 @@ struct State { // tentative device registrations, not persisted across updates // if an anchor number is present in this map then registration mode is active until expiration tentative_device_registrations: RefCell>, + // lookup table so we can easily return a user's active registrations in identity_info + lookup_tentative_device_registration: RefCell>, // additional usage metrics, NOT persisted across updates (but probably should be in the future) usage_metrics: RefCell, // State that is temporarily persisted in stable memory during upgrades using @@ -313,6 +345,42 @@ pub fn tentative_device_registrations_mut( STATE.with(|s| f(&mut s.tentative_device_registrations.borrow_mut())) } +pub fn lookup_tentative_device_registration( + f: impl FnOnce(&HashMap) -> R, +) -> R { + STATE.with(|s| f(&s.lookup_tentative_device_registration.borrow())) +} + +pub fn lookup_tentative_device_registration_mut( + f: impl FnOnce(&mut HashMap) -> R, +) -> R { + STATE.with(|s| f(&mut s.lookup_tentative_device_registration.borrow_mut())) +} + +pub fn get_tentative_device_registration_by_identity( + identity_number: IdentityNumber, +) -> Option { + tentative_device_registrations(|registrations| registrations.get(&identity_number).cloned()) +} + +pub fn get_identity_number_by_registration_id(id: &RegistrationId) -> Option { + lookup_tentative_device_registration(|lookup| lookup.get(id).copied()).and_then( + |identity_number| { + if let Some(TentativeDeviceRegistration { expiration, .. }) = + get_tentative_device_registration_by_identity(identity_number) + { + if expiration > time() { + Some(identity_number) + } else { + None + } + } else { + None + } + }, + ) +} + pub fn assets_mut(f: impl FnOnce(&mut CertifiedAssets) -> R) -> R { ASSETS.with(|assets| f(&mut assets.borrow_mut())) } diff --git a/src/internet_identity/tests/integration/v2_api/authn_method_registration.rs b/src/internet_identity/tests/integration/v2_api/authn_method_registration.rs index 0e0dc3bb77..43f451a6bf 100644 --- a/src/internet_identity/tests/integration/v2_api/authn_method_registration.rs +++ b/src/internet_identity/tests/integration/v2_api/authn_method_registration.rs @@ -3,16 +3,13 @@ use crate::v2_api::authn_method_test_helpers::{ }; use candid::Principal; use canister_tests::api::internet_identity::api_v2; -use canister_tests::framework::{ - env, expect_user_error_with_message, install_ii_with_archive, time, -}; +use canister_tests::framework::{env, install_ii_with_archive, time}; use internet_identity_interface::internet_identity::types::{ AuthnMethodConfirmationCode, AuthnMethodConfirmationError, AuthnMethodRegisterError, - AuthnMethodRegistration, + AuthnMethodRegistration, AuthnMethodRegistrationModeEnterError, CheckTentativeDeviceError, + LookupByRegistrationIdError, }; use pocket_ic::CallError; -use pocket_ic::ErrorCode::CanisterCalledTrap; -use regex::Regex; use std::time::Duration; #[test] @@ -21,12 +18,14 @@ fn should_enter_authn_method_registration_mode() -> Result<(), CallError> { let canister_id = install_ii_with_archive(&env, None, None); let authn_method = test_authn_method(); let identity_number = create_identity_with_authn_method(&env, canister_id, &authn_method); + let registration_mode_id = "0fZr4".to_string(); let result = api_v2::authn_method_registration_mode_enter( &env, canister_id, authn_method.principal(), identity_number, + Some(registration_mode_id), )? .expect("authn_method_registration_mode_enter failed"); @@ -43,19 +42,22 @@ fn should_require_authentication_to_enter_authn_method_registration_mode() { let canister_id = install_ii_with_archive(&env, None, None); let authn_method = test_authn_method(); let identity_number = create_identity_with_authn_method(&env, canister_id, &authn_method); + let registration_mode_id = "0fZr4".to_string(); let result = api_v2::authn_method_registration_mode_enter( &env, canister_id, Principal::anonymous(), identity_number, + Some(registration_mode_id), ); - expect_user_error_with_message( - result, - CanisterCalledTrap, - Regex::new("[a-z0-9-]+ could not be authenticated.").unwrap(), - ); + assert!(matches!( + result.unwrap(), + Err(AuthnMethodRegistrationModeEnterError::AuthorizationFailure( + _ + )) + )) } #[test] @@ -64,12 +66,14 @@ fn should_register_authn_method() -> Result<(), CallError> { let canister_id = install_ii_with_archive(&env, None, None); let authn_method = test_authn_method(); let identity_number = create_identity_with_authn_method(&env, canister_id, &authn_method); + let registration_mode_id = "0fZr4".to_string(); api_v2::authn_method_registration_mode_enter( &env, canister_id, authn_method.principal(), identity_number, + Some(registration_mode_id), )? .expect("authn_method_registration_mode_enter failed"); @@ -99,12 +103,14 @@ fn should_verify_authn_method_after_failed_attempt() -> Result<(), CallError> { let canister_id = install_ii_with_archive(&env, None, None); let authn_method = test_authn_method(); let identity_number = create_identity_with_authn_method(&env, canister_id, &authn_method); + let registration_mode_id = "0fZr4".to_string(); api_v2::authn_method_registration_mode_enter( &env, canister_id, authn_method.principal(), identity_number, + Some(registration_mode_id), )? .expect("authn_method_registration_mode_enter failed"); @@ -147,12 +153,14 @@ fn identity_info_should_return_authn_method() -> Result<(), CallError> { let canister_id = install_ii_with_archive(&env, None, None); let authn_method = test_authn_method(); let identity_number = create_identity_with_authn_method(&env, canister_id, &authn_method); + let registration_mode_id = "0fZr4".to_string(); api_v2::authn_method_registration_mode_enter( &env, canister_id, authn_method.principal(), identity_number, + Some(registration_mode_id), )? .expect("authn_method_registration_mode_enter failed"); @@ -180,6 +188,7 @@ fn should_reject_authn_method_if_not_in_registration_mode() -> Result<(), CallEr let canister_id = install_ii_with_archive(&env, None, None); let authn_method = test_authn_method(); let identity_number = create_identity_with_authn_method(&env, canister_id, &authn_method); + let registration_mode_id = "0fZr4".to_string(); let result = api_v2::authn_method_register( &env, @@ -198,6 +207,7 @@ fn should_reject_authn_method_if_not_in_registration_mode() -> Result<(), CallEr canister_id, authn_method.principal(), identity_number, + Some(registration_mode_id), )? .expect("authn_method_registration_mode_enter failed"); api_v2::authn_method_registration_mode_exit( @@ -229,12 +239,14 @@ fn should_reject_authn_method_if_registration_mode_is_expired() -> Result<(), Ca let canister_id = install_ii_with_archive(&env, None, None); let authn_method = test_authn_method(); let identity_number = create_identity_with_authn_method(&env, canister_id, &authn_method); + let registration_mode_id = "0fZr4".to_string(); api_v2::authn_method_registration_mode_enter( &env, canister_id, authn_method.principal(), identity_number, + Some(registration_mode_id), )? .expect("authn_method_registration_mode_enter failed"); @@ -260,12 +272,14 @@ fn should_reject_confirmation_without_authn_method() -> Result<(), CallError> { let canister_id = install_ii_with_archive(&env, None, None); let authn_method = test_authn_method(); let identity_number = create_identity_with_authn_method(&env, canister_id, &authn_method); + let registration_mode_id = "0fZr4".to_string(); api_v2::authn_method_registration_mode_enter( &env, canister_id, authn_method.principal(), identity_number, + Some(registration_mode_id), )? .expect("authn_method_registration_mode_enter failed"); @@ -291,12 +305,14 @@ fn should_reject_confirmation_with_wrong_code() -> Result<(), CallError> { let canister_id = install_ii_with_archive(&env, None, None); let authn_method = test_authn_method(); let identity_number = create_identity_with_authn_method(&env, canister_id, &authn_method); + let registration_mode_id = "0fZr4".to_string(); api_v2::authn_method_registration_mode_enter( &env, canister_id, authn_method.principal(), identity_number, + Some(registration_mode_id), )? .expect("authn_method_registration_mode_enter failed"); @@ -335,3 +351,535 @@ fn should_reject_confirmation_with_wrong_code() -> Result<(), CallError> { )); Ok(()) } + +#[test] +fn should_return_false_when_no_tentative_device() -> Result<(), CallError> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let authn_method = test_authn_method(); + let identity_number = create_identity_with_authn_method(&env, canister_id, &authn_method); + + let result = api_v2::authn_method_check_tentative_device( + &env, + canister_id, + authn_method.principal(), + identity_number, + )? + .expect("check_tentative_device_verified failed"); + + assert!(!result); + Ok(()) +} + +#[test] +fn should_return_true_when_tentative_device_not_verified() -> Result<(), CallError> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let authn_method = test_authn_method(); + let identity_number = create_identity_with_authn_method(&env, canister_id, &authn_method); + let registration_mode_id = "0fZr4".to_string(); + + // Enter registration mode + api_v2::authn_method_registration_mode_enter( + &env, + canister_id, + authn_method.principal(), + identity_number, + Some(registration_mode_id), + )? + .expect("authn_method_registration_mode_enter failed"); + + // Register a tentative device + api_v2::authn_method_register( + &env, + canister_id, + identity_number, + &sample_pubkey_authn_method(1), + )? + .expect("authn_method_register failed"); + + let result = api_v2::authn_method_check_tentative_device( + &env, + canister_id, + authn_method.principal(), + identity_number, + )? + .expect("check_tentative_device_verified failed"); + + assert!(result); + Ok(()) +} + +#[test] +fn should_return_false_when_tentative_device_verified() -> Result<(), CallError> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let authn_method = test_authn_method(); + let identity_number = create_identity_with_authn_method(&env, canister_id, &authn_method); + let registration_mode_id = "0fZr4".to_string(); + + // Enter registration mode + api_v2::authn_method_registration_mode_enter( + &env, + canister_id, + authn_method.principal(), + identity_number, + Some(registration_mode_id), + )? + .expect("authn_method_registration_mode_enter failed"); + + // Register a tentative device + let add_response = api_v2::authn_method_register( + &env, + canister_id, + identity_number, + &sample_pubkey_authn_method(1), + )? + .expect("authn_method_register failed"); + + // Confirm the tentative device + api_v2::authn_method_confirm( + &env, + canister_id, + authn_method.principal(), + identity_number, + &add_response.confirmation_code, + )? + .expect("authn_method_confirm failed"); + + let result = api_v2::authn_method_check_tentative_device( + &env, + canister_id, + authn_method.principal(), + identity_number, + )? + .expect("check_tentative_device_verified failed"); + + assert!(!result); + Ok(()) +} + +#[test] +fn should_return_false_after_registration_mode_exit() -> Result<(), CallError> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let authn_method = test_authn_method(); + let identity_number = create_identity_with_authn_method(&env, canister_id, &authn_method); + let registration_mode_id = "0fZr4".to_string(); + + // Enter registration mode + api_v2::authn_method_registration_mode_enter( + &env, + canister_id, + authn_method.principal(), + identity_number, + Some(registration_mode_id), + )? + .expect("authn_method_registration_mode_enter failed"); + + // Register a tentative device + api_v2::authn_method_register( + &env, + canister_id, + identity_number, + &sample_pubkey_authn_method(1), + )? + .expect("authn_method_register failed"); + + // Exit registration mode without confirming + api_v2::authn_method_registration_mode_exit( + &env, + canister_id, + authn_method.principal(), + identity_number, + )? + .expect("authn_method_registration_mode_exit failed"); + + let result = api_v2::authn_method_check_tentative_device( + &env, + canister_id, + authn_method.principal(), + identity_number, + )? + .expect("check_tentative_device_verified failed"); + + assert!(!result); + Ok(()) +} + +#[test] +fn should_require_authentication_to_check_tentative_device() { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let authn_method = test_authn_method(); + let identity_number = create_identity_with_authn_method(&env, canister_id, &authn_method); + + let result = api_v2::authn_method_check_tentative_device( + &env, + canister_id, + Principal::anonymous(), + identity_number, + ); + + assert!(matches!( + result, + Ok(Err(CheckTentativeDeviceError::Unauthorized)) + )); +} + +#[test] +fn should_return_identity_number_for_existing_registration_id() -> Result<(), CallError> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let authn_method = test_authn_method(); + let identity_number = create_identity_with_authn_method(&env, canister_id, &authn_method); + let registration_mode_id = "0fZr4".to_string(); + + // Enter registration mode + api_v2::authn_method_registration_mode_enter( + &env, + canister_id, + authn_method.principal(), + identity_number, + Some(registration_mode_id.clone()), + )? + .expect("authn_method_registration_mode_enter failed"); + + let result = api_v2::authn_method_lookup_by_registration_mode_id( + &env, + canister_id, + Principal::anonymous(), + registration_mode_id, + )? + .expect("lookup_by_registration_mode_id failed"); + + assert_eq!(result, Some(identity_number)); + Ok(()) +} + +#[test] +fn should_return_none_after_registration_mode_exit() -> Result<(), CallError> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let authn_method = test_authn_method(); + let identity_number = create_identity_with_authn_method(&env, canister_id, &authn_method); + let registration_mode_id = "0fZr4".to_string(); + + // Enter registration mode + api_v2::authn_method_registration_mode_enter( + &env, + canister_id, + authn_method.principal(), + identity_number, + Some(registration_mode_id.clone()), + )? + .expect("authn_method_registration_mode_enter failed"); + + // Exit registration mode + api_v2::authn_method_registration_mode_exit( + &env, + canister_id, + authn_method.principal(), + identity_number, + )? + .expect("authn_method_registration_mode_exit failed"); + + let result = api_v2::authn_method_lookup_by_registration_mode_id( + &env, + canister_id, + Principal::anonymous(), + registration_mode_id, + )? + .expect("lookup_by_registration_mode_id failed"); + assert!(result.is_none()); + Ok(()) +} + +#[test] +fn should_return_none_after_tentative_device_confirmation() -> Result<(), CallError> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let authn_method = test_authn_method(); + let identity_number = create_identity_with_authn_method(&env, canister_id, &authn_method); + let registration_mode_id = "0fZr4".to_string(); + + // Enter registration mode + api_v2::authn_method_registration_mode_enter( + &env, + canister_id, + authn_method.principal(), + identity_number, + Some(registration_mode_id.clone()), + )? + .expect("authn_method_registration_mode_enter failed"); + + // Register and confirm a tentative device + let add_response = api_v2::authn_method_register( + &env, + canister_id, + identity_number, + &sample_pubkey_authn_method(1), + )? + .expect("authn_method_register failed"); + + api_v2::authn_method_confirm( + &env, + canister_id, + authn_method.principal(), + identity_number, + &add_response.confirmation_code, + )? + .expect("authn_method_confirm failed"); + + let result = api_v2::authn_method_lookup_by_registration_mode_id( + &env, + canister_id, + Principal::anonymous(), + registration_mode_id, + )? + .expect("lookup_by_registration_mode_id failed"); + + assert!(result.is_none()); + Ok(()) +} + +#[test] +fn should_return_none_after_registration_mode_expiration() -> Result<(), CallError> { + const REGISTRATION_MODE_EXPIRATION: std::time::Duration = std::time::Duration::from_secs(900); + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let authn_method = test_authn_method(); + let identity_number = create_identity_with_authn_method(&env, canister_id, &authn_method); + let registration_mode_id = "0fZr4".to_string(); + + // Enter registration mode + api_v2::authn_method_registration_mode_enter( + &env, + canister_id, + authn_method.principal(), + identity_number, + Some(registration_mode_id.clone()), + )? + .expect("authn_method_registration_mode_enter failed"); + + // Advance time past expiration + env.advance_time(REGISTRATION_MODE_EXPIRATION + std::time::Duration::from_secs(1)); + + let result = api_v2::authn_method_lookup_by_registration_mode_id( + &env, + canister_id, + Principal::anonymous(), + registration_mode_id, + )? + .expect("lookup_by_registration_mode_id failed"); + + assert!(result.is_none()); + Ok(()) +} + +#[test] +fn should_reject_registration_id_too_short() -> Result<(), CallError> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let invalid_id = "abc1".to_string(); // Too short + + let result = api_v2::authn_method_lookup_by_registration_mode_id( + &env, + canister_id, + Principal::anonymous(), + invalid_id, + )?; + + assert!(matches!( + result, + Err(LookupByRegistrationIdError::InvalidId(_)) + )); + Ok(()) +} + +#[test] +fn should_reject_registration_id_too_long() -> Result<(), CallError> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let invalid_id = "abcdef".to_string(); // Too long + + let result = api_v2::authn_method_lookup_by_registration_mode_id( + &env, + canister_id, + Principal::anonymous(), + invalid_id, + )?; + + assert!(matches!( + result, + Err(LookupByRegistrationIdError::InvalidId(_)) + )); + Ok(()) +} + +#[test] +fn should_handle_multiple_registration_ids() -> Result<(), CallError> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let authn_method1 = test_authn_method(); + let authn_method2 = sample_pubkey_authn_method(1); + + let identity_number1 = create_identity_with_authn_method(&env, canister_id, &authn_method1); + let identity_number2 = create_identity_with_authn_method(&env, canister_id, &authn_method2); + + let registration_mode_id1 = "abc12".to_string(); + let registration_mode_id2 = "def34".to_string(); + + // Enter registration mode for first identity + api_v2::authn_method_registration_mode_enter( + &env, + canister_id, + authn_method1.principal(), + identity_number1, + Some(registration_mode_id1.clone()), + )? + .expect("authn_method_registration_mode_enter failed"); + + // Enter registration mode for second identity + api_v2::authn_method_registration_mode_enter( + &env, + canister_id, + authn_method2.principal(), + identity_number2, + Some(registration_mode_id2.clone()), + )? + .expect("authn_method_registration_mode_enter failed"); + + // Verify both lookups work correctly + let result1 = api_v2::authn_method_lookup_by_registration_mode_id( + &env, + canister_id, + Principal::anonymous(), + registration_mode_id1, + )? + .expect("lookup_by_registration_mode_id failed"); + + let result2 = api_v2::authn_method_lookup_by_registration_mode_id( + &env, + canister_id, + Principal::anonymous(), + registration_mode_id2, + )? + .expect("lookup_by_registration_mode_id failed"); + + assert_eq!(result1, Some(identity_number1)); + assert_eq!(result2, Some(identity_number2)); + Ok(()) +} + +#[test] +fn should_exit_registrations_separately() -> Result<(), CallError> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let authn_method1 = test_authn_method(); + let authn_method2 = sample_pubkey_authn_method(1); + + let identity_number1 = create_identity_with_authn_method(&env, canister_id, &authn_method1); + let identity_number2 = create_identity_with_authn_method(&env, canister_id, &authn_method2); + + let registration_mode_id1 = "abc12".to_string(); + let registration_mode_id2 = "def34".to_string(); + + // Enter registration mode for first identity + api_v2::authn_method_registration_mode_enter( + &env, + canister_id, + authn_method1.principal(), + identity_number1, + Some(registration_mode_id1.clone()), + )? + .expect("authn_method_registration_mode_enter failed"); + + // Enter registration mode for second identity + api_v2::authn_method_registration_mode_enter( + &env, + canister_id, + authn_method2.principal(), + identity_number2, + Some(registration_mode_id2.clone()), + )? + .expect("authn_method_registration_mode_enter failed"); + + // Exit registration mode for first identity + api_v2::authn_method_registration_mode_exit( + &env, + canister_id, + authn_method1.principal(), + identity_number1, + )? + .expect("authn_method_registration_mode_enter failed"); + + // Verify both lookups work correctly + let result1 = api_v2::authn_method_lookup_by_registration_mode_id( + &env, + canister_id, + Principal::anonymous(), + registration_mode_id1.clone(), + )? + .expect("lookup_by_registration_mode_id failed"); + + let result2 = api_v2::authn_method_lookup_by_registration_mode_id( + &env, + canister_id, + Principal::anonymous(), + registration_mode_id2.clone(), + )? + .expect("lookup_by_registration_mode_id failed"); + + assert_eq!(result1, None); + assert_eq!(result2, Some(identity_number2)); + + // Exit registration mode for second identity + api_v2::authn_method_registration_mode_exit( + &env, + canister_id, + authn_method2.principal(), + identity_number2, + )? + .expect("authn_method_registration_mode_enter failed"); + + // Verify both lookups work correctly + let result1 = api_v2::authn_method_lookup_by_registration_mode_id( + &env, + canister_id, + Principal::anonymous(), + registration_mode_id1, + )? + .expect("lookup_by_registration_mode_id failed"); + + let result2 = api_v2::authn_method_lookup_by_registration_mode_id( + &env, + canister_id, + Principal::anonymous(), + registration_mode_id2, + )? + .expect("lookup_by_registration_mode_id failed"); + + assert_eq!(result1, None); + assert_eq!(result2, None); + + Ok(()) +} + +#[test] +fn should_return_none_for_nonexistent_registration_id() -> Result<(), CallError> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let nonexistent_id = "abc12".to_string(); + + let result = api_v2::authn_method_lookup_by_registration_mode_id( + &env, + canister_id, + Principal::anonymous(), + nonexistent_id, + )? + .expect("lookup_by_registration_mode_id failed"); + + assert!(result.is_none()); + Ok(()) +} diff --git a/src/internet_identity_interface/src/internet_identity/types/api_v2.rs b/src/internet_identity_interface/src/internet_identity/types/api_v2.rs index e7e9a4e1b8..b657dee788 100644 --- a/src/internet_identity_interface/src/internet_identity/types/api_v2.rs +++ b/src/internet_identity_interface/src/internet_identity/types/api_v2.rs @@ -136,6 +136,12 @@ pub enum AuthnMethodRegisterError { InvalidMetadata(String), } +#[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)] +pub enum AuthnMethodRegistrationModeEnterError { + AuthorizationFailure(String), + InvalidId(String), +} + #[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)] pub enum AuthnMethodConfirmationError { WrongCode { retries_left: u8 }, @@ -234,3 +240,16 @@ pub enum CreateIdentityData { PubkeyAuthn(IdRegFinishArg), OpenID(OpenIDRegFinishArg), } + +#[derive(CandidType, Clone, Eq, PartialEq, Hash)] +pub struct RegistrationId(String); + +#[derive(CandidType, Deserialize, Debug)] +pub enum CheckTentativeDeviceError { + Unauthorized, +} + +#[derive(CandidType, Deserialize, Debug)] +pub enum LookupByRegistrationIdError { + InvalidId(String), +}