diff --git a/package.json b/package.json index 95ef6f5e1..b6e27b997 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,8 @@ "check": "pnpm run --filter '{packages}' --if-present test" }, "devDependencies": { - "@linaria/esbuild": "^3.0.0-beta.7", - "@linaria/shaker": "^3.0.0-beta.7", - "esbuild": "^0.12.21" + "@linaria/esbuild": "^3.0.0-beta.13", + "@linaria/shaker": "^3.0.0-beta.13", + "esbuild": "^0.12.29" } } diff --git a/packages/anastasis-core/bin/anastasis-ts-reducer.js b/packages/anastasis-core/bin/anastasis-ts-reducer.js new file mode 100755 index 000000000..9e1120516 --- /dev/null +++ b/packages/anastasis-core/bin/anastasis-ts-reducer.js @@ -0,0 +1,14 @@ +#!/usr/bin/env node + +async function r() { + try { + (await import("source-map-support")).install(); + } catch (e) { + console.warn("can't load souremaps"); + // Do nothing. + } + + (await import("../dist/anastasis-cli.js")).reducerCliMain(); +} + +r(); diff --git a/packages/anastasis-core/package.json b/packages/anastasis-core/package.json index 8dbef2d45..7e4fba9e3 100644 --- a/packages/anastasis-core/package.json +++ b/packages/anastasis-core/package.json @@ -6,8 +6,8 @@ "module": "./lib/index.js", "types": "./lib/index.d.ts", "scripts": { - "prepare": "tsc", - "compile": "tsc", + "prepare": "tsc && rollup -c", + "compile": "tsc && rollup -c", "pretty": "prettier --write src", "test": "tsc && ava", "coverage": "tsc && nyc ava", @@ -17,15 +17,23 @@ "license": "AGPL-3-or-later", "type": "module", "devDependencies": { + "@rollup/plugin-commonjs": "^21.0.1", + "@rollup/plugin-json": "^4.1.0", + "@rollup/plugin-node-resolve": "^13.0.6", "ava": "^3.15.0", - "typescript": "^4.4.3" + "rimraf": "^3.0.2", + "rollup": "^2.59.0", + "rollup-plugin-sourcemaps": "^0.6.3", + "source-map-support": "^0.5.19", + "typescript": "^4.4.4" }, "dependencies": { "@gnu-taler/taler-util": "workspace:^0.8.3", "fetch-ponyfill": "^7.1.0", "fflate": "^0.6.0", "hash-wasm": "^4.9.0", - "node-fetch": "^3.0.0" + "node-fetch": "^3.0.0", + "tslib": "^2.1.0" }, "ava": { "files": [ diff --git a/packages/anastasis-core/rollup.config.js b/packages/anastasis-core/rollup.config.js new file mode 100644 index 000000000..a9af1d3b5 --- /dev/null +++ b/packages/anastasis-core/rollup.config.js @@ -0,0 +1,56 @@ +// rollup.config.js +import commonjs from "@rollup/plugin-commonjs"; +import nodeResolve from "@rollup/plugin-node-resolve"; +import json from "@rollup/plugin-json"; +import builtins from "builtin-modules"; +import sourcemaps from "rollup-plugin-sourcemaps"; + +const cli = { + input: "lib/index.node.js", + output: { + file: "dist/anastasis-cli.js", + format: "es", + sourcemap: true, + }, + external: builtins, + plugins: [ + nodeResolve({ + preferBuiltins: true, + }), + + sourcemaps(), + + commonjs({ + sourceMap: true, + transformMixedEsModules: true, + }), + + json(), + ], +}; + +const standalone = { + input: "lib/cli-entry.js", + output: { + file: "dist/anastasis-cli-standalone.js", + format: "es", + sourcemap: true, + }, + external: [...builtins, "source-map-support"], + plugins: [ + nodeResolve({ + preferBuiltins: true, + }), + + sourcemaps(), + + commonjs({ + sourceMap: true, + transformMixedEsModules: true, + }), + + json(), + ], +}; + +export default [standalone, cli]; diff --git a/packages/anastasis-core/src/challenge-feedback-types.ts b/packages/anastasis-core/src/challenge-feedback-types.ts new file mode 100644 index 000000000..0770d9296 --- /dev/null +++ b/packages/anastasis-core/src/challenge-feedback-types.ts @@ -0,0 +1,168 @@ +import { AmountString, HttpStatusCode } from "@gnu-taler/taler-util"; + +export enum ChallengeFeedbackStatus { + Solved = "solved", + ServerFailure = "server-failure", + TruthUnknown = "truth-unknown", + Redirect = "redirect", + Payment = "payment", + Pending = "pending", + Message = "message", + Unsupported = "unsupported", + RateLimitExceeded = "rate-limit-exceeded", + AuthIban = "auth-iban", +} + +export type ChallengeFeedback = + | ChallengeFeedbackSolved + | ChallengeFeedbackPending + | ChallengeFeedbackPayment + | ChallengeFeedbackServerFailure + | ChallengeFeedbackRateLimitExceeded + | ChallengeFeedbackTruthUnknown + | ChallengeFeedbackRedirect + | ChallengeFeedbackMessage + | ChallengeFeedbackUnsupported + | ChallengeFeedbackAuthIban; + +/** + * Challenge has been solved and the key share has + * been retrieved. + */ +export interface ChallengeFeedbackSolved { + state: ChallengeFeedbackStatus.Solved; +} + +/** + * The challenge given by the server is unsupported + * by the current anastasis client. + */ +export interface ChallengeFeedbackUnsupported { + state: ChallengeFeedbackStatus.Unsupported; + http_status: HttpStatusCode; + /** + * Human-readable identifier of the unsupported method. + */ + unsupported_method: string; +} + +/** + * The user tried to answer too often with a wrong answer. + */ +export interface ChallengeFeedbackRateLimitExceeded { + state: ChallengeFeedbackStatus.RateLimitExceeded; +} + +/** + * Instructions for performing authentication via an + * IBAN bank transfer. + */ +export interface ChallengeFeedbackAuthIban { + state: ChallengeFeedbackStatus.AuthIban; + + /** + * Amount that should be transfered for a successful authentication. + */ + challenge_amount: AmountString; + + /** + * Account that should be credited. + */ + credit_iban: string; + + /** + * Creditor name. + */ + business_name: string; + + /** + * Unstructured remittance information that should + * be contained in the bank transfer. + */ + wire_transfer_subject: string; + + /** + * FIXME: This field is only present for compatibility with + * the C reducer test suite. + */ + method: "iban"; + + answer_code: number; + + /** + * FIXME: This field is only present for compatibility with + * the C reducer test suite. + */ + details: { + challenge_amount: AmountString; + credit_iban: string; + business_name: string; + wire_transfer_subject: string; + }; +} + +/** + * Challenge still needs to be solved. + */ +export interface ChallengeFeedbackPending { + state: ChallengeFeedbackStatus.Pending; +} + +/** + * Human-readable response from the provider + * after the user failed to solve the challenge + * correctly. + */ +export interface ChallengeFeedbackMessage { + state: ChallengeFeedbackStatus.Message; + message: string; +} + +/** + * The server experienced a temporary failure. + */ +export interface ChallengeFeedbackServerFailure { + state: ChallengeFeedbackStatus.ServerFailure; + http_status: HttpStatusCode | 0; + + /** + * Taler-style error response, if available. + */ + error_response?: any; +} + +/** + * The truth is unknown to the provider. There + * is no reason to continue trying to solve any + * challenges in the policy. + */ +export interface ChallengeFeedbackTruthUnknown { + state: ChallengeFeedbackStatus.TruthUnknown; +} + +/** + * The user should be asked to go to a URL + * to complete the authentication there. + */ +export interface ChallengeFeedbackRedirect { + state: ChallengeFeedbackStatus.Redirect; + http_status: number; + redirect_url: string; +} + +/** + * A payment is required before the user can + * even attempt to solve the challenge. + */ +export interface ChallengeFeedbackPayment { + state: ChallengeFeedbackStatus.Payment; + + taler_pay_uri: string; + + provider: string; + + /** + * FIXME: Why is this required?! + */ + payment_secret: string; +} diff --git a/packages/anastasis-core/src/cli-entry.ts b/packages/anastasis-core/src/cli-entry.ts new file mode 100644 index 000000000..151b47f2b --- /dev/null +++ b/packages/anastasis-core/src/cli-entry.ts @@ -0,0 +1,15 @@ +import { reducerCliMain } from "./cli.js"; + +async function r() { + try { + // @ts-ignore + (await import("source-map-support")).install(); + } catch (e) { + console.warn("can't load souremaps, please install source-map-support"); + // Do nothing. + } + + reducerCliMain(); +} + +r(); diff --git a/packages/anastasis-core/src/cli.ts b/packages/anastasis-core/src/cli.ts new file mode 100644 index 000000000..517f2876d --- /dev/null +++ b/packages/anastasis-core/src/cli.ts @@ -0,0 +1,64 @@ +import { clk } from "@gnu-taler/taler-util"; +import { + getBackupStartState, + getRecoveryStartState, + reduceAction, +} from "./index.js"; +import fs from "fs"; + +export const reducerCli = clk + .program("reducer", { + help: "Command line interface for Anastasis.", + }) + .flag("initBackup", ["-b", "--backup"]) + .flag("initRecovery", ["-r", "--restore"]) + .maybeOption("argumentsJson", ["-a", "--arguments"], clk.STRING) + .maybeArgument("action", clk.STRING) + .maybeArgument("stateFile", clk.STRING); + +async function read(stream: NodeJS.ReadStream): Promise { + const chunks = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + return Buffer.concat(chunks).toString("utf8"); +} + +reducerCli.action(async (x) => { + if (x.reducer.initBackup) { + console.log(JSON.stringify(await getBackupStartState())); + return; + } else if (x.reducer.initRecovery) { + console.log(JSON.stringify(await getRecoveryStartState())); + return; + } + + const action = x.reducer.action; + if (!action) { + console.log("action required"); + return; + } + + let lastState: any; + if (x.reducer.stateFile) { + const s = fs.readFileSync(x.reducer.stateFile, { encoding: "utf-8" }); + lastState = JSON.parse(s); + } else { + const s = await read(process.stdin); + lastState = JSON.parse(s); + } + + let args: any; + if (x.reducer.argumentsJson) { + args = JSON.parse(x.reducer.argumentsJson); + } else { + args = {}; + } + + const nextState = await reduceAction(lastState, action, args); + console.log(JSON.stringify(nextState)); +}); + +export function reducerCliMain() { + reducerCli.run(); +} diff --git a/packages/anastasis-core/src/crypto.ts b/packages/anastasis-core/src/crypto.ts index da8338636..206d9eca8 100644 --- a/packages/anastasis-core/src/crypto.ts +++ b/packages/anastasis-core/src/crypto.ts @@ -10,8 +10,10 @@ import { crypto_sign_keyPair_fromSeed, stringToBytes, secretbox_open, + hash, + Logger, + j2s, } from "@gnu-taler/taler-util"; -import { gzipSync } from "fflate"; import { argon2id } from "hash-wasm"; export type Flavor = T & { @@ -248,7 +250,6 @@ export async function coreSecretRecover(args: { args.encryptedMasterKey, "emk", ); - console.log("recovered master key", masterKey); return await anastasisDecrypt(masterKey, args.encryptedCoreSecret, "cse"); } @@ -283,6 +284,10 @@ export async function coreSecretEncrypt( }; } +export async function pinAnswerHash(pin: number): Promise { + return encodeCrock(hash(stringToBytes(pin.toString()))); +} + export async function secureAnswerHash( answer: string, truthUuid: TruthUuid, diff --git a/packages/anastasis-core/src/index.node.ts b/packages/anastasis-core/src/index.node.ts new file mode 100644 index 000000000..d08906a22 --- /dev/null +++ b/packages/anastasis-core/src/index.node.ts @@ -0,0 +1,2 @@ +export * from "./index.js"; +export { reducerCliMain } from "./cli.js"; diff --git a/packages/anastasis-core/src/index.ts b/packages/anastasis-core/src/index.ts index b4e911ffb..362ac3317 100644 --- a/packages/anastasis-core/src/index.ts +++ b/packages/anastasis-core/src/index.ts @@ -1,14 +1,22 @@ import { + AmountJson, + AmountLike, + Amounts, AmountString, buildSigPS, bytesToString, Codec, codecForAny, decodeCrock, + Duration, eddsaSign, encodeCrock, getRandomBytes, hash, + HttpStatusCode, + j2s, + Logger, + parsePayUri, stringToBytes, TalerErrorCode, TalerSignaturePurpose, @@ -17,35 +25,46 @@ import { import { anastasisData } from "./anastasis-data.js"; import { EscrowConfigurationResponse, + IbanExternalAuthResponse, TruthUploadRequest, } from "./provider-types.js"; import { - ActionArgAddAuthentication, - ActionArgDeleteAuthentication, - ActionArgDeletePolicy, - ActionArgEnterSecret, - ActionArgEnterSecretName, - ActionArgEnterUserAttributes, + ActionArgsAddAuthentication, + ActionArgsDeleteAuthentication, + ActionArgsDeletePolicy, + ActionArgsEnterSecret, + ActionArgsEnterSecretName, + ActionArgsEnterUserAttributes, + ActionArgsAddPolicy, + ActionArgsSelectContinent, + ActionArgsSelectCountry, ActionArgsSelectChallenge, ActionArgsSolveChallengeRequest, + ActionArgsUpdateExpiration, AuthenticationProviderStatus, AuthenticationProviderStatusOk, AuthMethod, BackupStates, + codecForActionArgsEnterUserAttributes, + codecForActionArgsAddPolicy, + codecForActionArgsSelectChallenge, + codecForActionArgSelectContinent, + codecForActionArgSelectCountry, + codecForActionArgsUpdateExpiration, ContinentInfo, CountryInfo, - MethodSpec, - Policy, - PolicyProvider, RecoveryInformation, RecoveryInternalData, RecoveryStates, ReducerState, ReducerStateBackup, - ReducerStateBackupUserAttributesCollecting, ReducerStateError, ReducerStateRecovery, SuccessDetails, + codecForActionArgsChangeVersion, + ActionArgsChangeVersion, + TruthMetaData, + ActionArgsUpdatePolicy, } from "./reducer-types.js"; import fetchPonyfill from "fetch-ponyfill"; import { @@ -61,8 +80,6 @@ import { PolicySalt, TruthSalt, secureAnswerHash, - TruthKey, - TruthUuid, UserIdentifier, userIdentifierDerive, typedArrayConcat, @@ -70,13 +87,27 @@ import { decryptKeyShare, KeyShare, coreSecretRecover, + pinAnswerHash, } from "./crypto.js"; import { unzlibSync, zlibSync } from "fflate"; -import { EscrowMethod, RecoveryDocument } from "./recovery-document-types.js"; +import { + ChallengeType, + EscrowMethod, + RecoveryDocument, +} from "./recovery-document-types.js"; +import { ProviderInfo, suggestPolicies } from "./policy-suggestion.js"; +import { + ChallengeFeedback, + ChallengeFeedbackStatus, +} from "./challenge-feedback-types.js"; -const { fetch, Request, Response, Headers } = fetchPonyfill({}); +const { fetch } = fetchPonyfill({}); export * from "./reducer-types.js"; +export * as validators from "./validators.js"; +export * from "./challenge-feedback-types.js"; + +const logger = new Logger("anastasis-core:index.ts"); function getContinents(): ContinentInfo[] { const continentSet = new Set(); @@ -94,10 +125,40 @@ function getContinents(): ContinentInfo[] { return continents; } +interface ErrorDetails { + code: TalerErrorCode; + message?: string; + hint?: string; +} + +export class ReducerError extends Error { + constructor(public errorJson: ErrorDetails) { + super( + errorJson.message ?? + errorJson.hint ?? + `${TalerErrorCode[errorJson.code]}`, + ); + + // Set the prototype explicitly. + Object.setPrototypeOf(this, ReducerError.prototype); + } +} + +/** + * Get countries for a continent, abort with ReducerError + * exception when continent doesn't exist. + */ function getCountries(continent: string): CountryInfo[] { - return anastasisData.countriesList.countries.filter( + const countries = anastasisData.countriesList.countries.filter( (x) => x.continent === continent, ); + if (countries.length <= 0) { + throw new ReducerError({ + code: TalerErrorCode.ANASTASIS_REDUCER_INPUT_INVALID, + hint: "continent not found", + }); + } + return countries; } export async function getBackupStartState(): Promise { @@ -114,19 +175,27 @@ export async function getRecoveryStartState(): Promise { }; } -async function backupSelectCountry( - state: ReducerStateBackup, - countryCode: string, - currencies: string[], -): Promise { +async function selectCountry( + selectedContinent: string, + args: ActionArgsSelectCountry, +): Promise & Partial> { + const countryCode = args.country_code; + const currencies = args.currencies; const country = anastasisData.countriesList.countries.find( (x) => x.code === countryCode, ); if (!country) { - return { + throw new ReducerError({ code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, hint: "invalid country selected", - }; + }); + } + + if (country.continent !== selectedContinent) { + throw new ReducerError({ + code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, + hint: "selected country is not in selected continent", + }); } const providers: { [x: string]: {} } = {}; @@ -140,8 +209,6 @@ async function backupSelectCountry( .required_attributes; return { - ...state, - backup_state: BackupStates.UserAttributesCollecting, selected_country: countryCode, currencies, required_attributes: ra, @@ -149,38 +216,25 @@ async function backupSelectCountry( }; } +async function backupSelectCountry( + state: ReducerStateBackup, + args: ActionArgsSelectCountry, +): Promise { + return { + ...state, + ...(await selectCountry(state.selected_continent!, args)), + backup_state: BackupStates.UserAttributesCollecting, + }; +} + async function recoverySelectCountry( state: ReducerStateRecovery, - countryCode: string, - currencies: string[], + args: ActionArgsSelectCountry, ): Promise { - const country = anastasisData.countriesList.countries.find( - (x) => x.code === countryCode, - ); - if (!country) { - return { - code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, - hint: "invalid country selected", - }; - } - - const providers: { [x: string]: {} } = {}; - for (const prov of anastasisData.providersList.anastasis_provider) { - if (currencies.includes(prov.currency)) { - providers[prov.url] = {}; - } - } - - const ra = (anastasisData.countryDetails as any)[countryCode] - .required_attributes; - return { ...state, recovery_state: RecoveryStates.UserAttributesCollecting, - selected_country: countryCode, - currencies, - required_attributes: ra, - authentication_providers: providers, + ...(await selectCountry(state.selected_continent!, args)), }; } @@ -230,8 +284,9 @@ async function getProviderInfo( async function backupEnterUserAttributes( state: ReducerStateBackup, - attributes: Record, + args: ActionArgsEnterUserAttributes, ): Promise { + const attributes = args.identity_attributes; const providerUrls = Object.keys(state.authentication_providers ?? {}); const newProviders = state.authentication_providers ?? {}; for (const url of providerUrls) { @@ -246,139 +301,6 @@ async function backupEnterUserAttributes( return newState; } -interface PolicySelectionResult { - policies: Policy[]; - policy_providers: PolicyProvider[]; -} - -type MethodSelection = number[]; - -function enumerateSelections(n: number, m: number): MethodSelection[] { - const selections: MethodSelection[] = []; - const a = new Array(n); - const sel = (i: number) => { - if (i === n) { - selections.push([...a]); - return; - } - const start = i == 0 ? 0 : a[i - 1] + 1; - for (let j = start; j < m; j++) { - a[i] = j; - sel(i + 1); - } - }; - sel(0); - return selections; -} - -/** - * Provider information used during provider/method mapping. - */ -interface ProviderInfo { - url: string; - methodCost: Record; -} - -/** - * Assign providers to a method selection. - */ -function assignProviders( - methods: AuthMethod[], - providers: ProviderInfo[], - methodSelection: number[], -): Policy | undefined { - const selectedProviders: string[] = []; - for (const mi of methodSelection) { - const m = methods[mi]; - let found = false; - for (const prov of providers) { - if (prov.methodCost[m.type]) { - selectedProviders.push(prov.url); - found = true; - break; - } - } - if (!found) { - /* No provider found for this method */ - return undefined; - } - } - return { - methods: methodSelection.map((x, i) => { - return { - authentication_method: x, - provider: selectedProviders[i], - }; - }), - }; -} - -function suggestPolicies( - methods: AuthMethod[], - providers: ProviderInfo[], -): PolicySelectionResult { - const numMethods = methods.length; - if (numMethods === 0) { - throw Error("no methods"); - } - let numSel: number; - if (numMethods <= 2) { - numSel = numMethods; - } else if (numMethods <= 4) { - numSel = numMethods - 1; - } else if (numMethods <= 6) { - numSel = numMethods - 2; - } else if (numMethods == 7) { - numSel = numMethods - 3; - } else { - numSel = 4; - } - const policies: Policy[] = []; - const selections = enumerateSelections(numSel, numMethods); - console.log("selections", selections); - for (const sel of selections) { - const p = assignProviders(methods, providers, sel); - if (p) { - policies.push(p); - } - } - return { - policies, - policy_providers: providers.map((x) => ({ - provider_url: x.url, - })), - }; -} - -/** - * Truth data as stored in the reducer. - */ -interface TruthMetaData { - uuid: string; - - key_share: string; - - policy_index: number; - - pol_method_index: number; - - /** - * Nonce used for encrypting the truth. - */ - nonce: string; - - /** - * Key that the truth (i.e. secret question answer, email address, mobile number, ...) - * is encrypted with when stored at the provider. - */ - truth_key: string; - - /** - * Truth-specific salt. - */ - truth_salt: string; -} - async function getTruthValue( authMethod: AuthMethod, truthUuid: string, @@ -408,7 +330,6 @@ async function getTruthValue( * Compress the recovery document and add a size header. */ async function compressRecoveryDoc(rd: any): Promise { - console.log("recovery document", rd); const docBytes = stringToBytes(JSON.stringify(rd)); const sizeHeaderBuf = new ArrayBuffer(4); const dvbuf = new DataView(sizeHeaderBuf); @@ -424,14 +345,19 @@ async function uncompressRecoveryDoc(zippedRd: Uint8Array): Promise { return JSON.parse(bytesToString(res)); } -async function uploadSecret( +/** + * Prepare the recovery document and truth metadata based + * on the selected policies. + */ +async function prepareRecoveryData( state: ReducerStateBackup, -): Promise { +): Promise { const policies = state.policies!; const secretName = state.secret_name!; const coreSecret: OpaqueData = encodeCrock( stringToBytes(JSON.stringify(state.core_secret!)), ); + // Truth key is `${methodIndex}/${providerUrl}` const truthMetadataMap: Record = {}; @@ -472,17 +398,6 @@ async function uploadSecret( const csr = await coreSecretEncrypt(policyKeys, coreSecret); - const uidMap: Record = {}; - for (const prov of state.policy_providers!) { - const provider = state.authentication_providers![ - prov.provider_url - ] as AuthenticationProviderStatusOk; - uidMap[prov.provider_url] = await userIdentifierDerive( - state.identity_attributes!, - provider.salt, - ); - } - const escrowMethods: EscrowMethod[] = []; for (const truthKey of Object.keys(truthMetadataMap)) { @@ -494,48 +409,8 @@ async function uploadSecret( const provider = state.authentication_providers![ meth.provider ] as AuthenticationProviderStatusOk; - const truthValue = await getTruthValue(authMethod, tm.uuid, tm.truth_salt); - const encryptedTruth = await encryptTruth( - tm.nonce, - tm.truth_key, - truthValue, - ); - const uid = uidMap[meth.provider]; - const encryptedKeyShare = await encryptKeyshare( - tm.key_share, - uid, - authMethod.type === "question" - ? bytesToString(decodeCrock(authMethod.challenge)) - : undefined, - ); - console.log( - "encrypted key share len", - decodeCrock(encryptedKeyShare).length, - ); - const tur: TruthUploadRequest = { - encrypted_truth: encryptedTruth, - key_share_data: encryptedKeyShare, - storage_duration_years: 5 /* FIXME */, - type: authMethod.type, - truth_mime: authMethod.mime_type, - }; - const resp = await fetch(new URL(`truth/${tm.uuid}`, meth.provider).href, { - method: "POST", - headers: { - "content-type": "application/json", - }, - body: JSON.stringify(tur), - }); - - if (resp.status !== 204) { - return { - code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED, - hint: "could not upload policy", - }; - } - escrowMethods.push({ - escrow_type: authMethod.type, + escrow_type: authMethod.type as any, instructions: authMethod.instructions, provider_salt: provider.salt, truth_salt: tm.truth_salt, @@ -545,12 +420,6 @@ async function uploadSecret( }); } - // FIXME: We need to store the truth metadata in - // the state, since it's possible that we'll run into - // a provider that requests a payment. - - console.log("policy UUIDs", policyUuids); - const rd: RecoveryDocument = { secret_name: secretName, encrypted_core_secret: csr.encCoreSecret, @@ -564,14 +433,143 @@ async function uploadSecret( }), }; + return { + ...state, + recovery_data: { + recovery_document: rd, + truth_metadata: truthMetadataMap, + }, + }; +} + +async function uploadSecret( + state: ReducerStateBackup, +): Promise { + if (!state.recovery_data) { + state = await prepareRecoveryData(state); + } + + const recoveryData = state.recovery_data; + if (!recoveryData) { + throw Error("invariant failed"); + } + + const truthMetadataMap = recoveryData.truth_metadata; + const rd = recoveryData.recovery_document; + + const truthPayUris: string[] = []; + const truthPaySecrets: Record = {}; + + const userIdCache: Record = {}; + const getUserIdCaching = async (providerUrl: string) => { + let userId = userIdCache[providerUrl]; + if (!userId) { + const provider = state.authentication_providers![ + providerUrl + ] as AuthenticationProviderStatusOk; + userId = userIdCache[providerUrl] = await userIdentifierDerive( + state.identity_attributes!, + provider.salt, + ); + } + return userId; + }; + for (const truthKey of Object.keys(truthMetadataMap)) { + const tm = truthMetadataMap[truthKey]; + const pol = state.policies![tm.policy_index]; + const meth = pol.methods[tm.pol_method_index]; + const authMethod = + state.authentication_methods![meth.authentication_method]; + const truthValue = await getTruthValue(authMethod, tm.uuid, tm.truth_salt); + const encryptedTruth = await encryptTruth( + tm.nonce, + tm.truth_key, + truthValue, + ); + logger.info(`uploading truth to ${meth.provider}`); + const userId = await getUserIdCaching(meth.provider); + const encryptedKeyShare = await encryptKeyshare( + tm.key_share, + userId, + authMethod.type === "question" + ? bytesToString(decodeCrock(authMethod.challenge)) + : undefined, + ); + const tur: TruthUploadRequest = { + encrypted_truth: encryptedTruth, + key_share_data: encryptedKeyShare, + storage_duration_years: 5 /* FIXME */, + type: authMethod.type, + truth_mime: authMethod.mime_type, + }; + const reqUrl = new URL(`truth/${tm.uuid}`, meth.provider); + const paySecret = (state.truth_upload_payment_secrets ?? {})[meth.provider]; + if (paySecret) { + // FIXME: Get this from the params + reqUrl.searchParams.set("timeout_ms", "500"); + } + const resp = await fetch(reqUrl.href, { + method: "POST", + headers: { + "content-type": "application/json", + ...(paySecret + ? { + "Anastasis-Payment-Identifier": paySecret, + } + : {}), + }, + body: JSON.stringify(tur), + }); + + if (resp.status === HttpStatusCode.NoContent) { + continue; + } + if (resp.status === HttpStatusCode.PaymentRequired) { + const talerPayUri = resp.headers.get("Taler"); + if (!talerPayUri) { + return { + code: TalerErrorCode.ANASTASIS_REDUCER_BACKEND_FAILURE, + hint: `payment requested, but no taler://pay URI given`, + }; + } + truthPayUris.push(talerPayUri); + const parsedUri = parsePayUri(talerPayUri); + if (!parsedUri) { + return { + code: TalerErrorCode.ANASTASIS_REDUCER_BACKEND_FAILURE, + hint: `payment requested, but no taler://pay URI given`, + }; + } + truthPaySecrets[meth.provider] = parsedUri.orderId; + continue; + } + return { + code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED, + hint: `could not upload truth (HTTP status ${resp.status})`, + }; + } + + if (truthPayUris.length > 0) { + return { + ...state, + backup_state: BackupStates.TruthsPaying, + truth_upload_payment_secrets: truthPaySecrets, + payments: truthPayUris, + }; + } + const successDetails: SuccessDetails = {}; + const policyPayUris: string[] = []; + const policyPayUriMap: Record = {}; + //const policyPaySecrets: Record = {}; + for (const prov of state.policy_providers!) { - const uid = uidMap[prov.provider_url]; - const acctKeypair = accountKeypairDerive(uid); + const userId = await getUserIdCaching(prov.provider_url); + const acctKeypair = accountKeypairDerive(userId); const zippedDoc = await compressRecoveryDoc(rd); const encRecoveryDoc = await encryptRecoveryDocument( - uid, + userId, encodeCrock(zippedDoc), ); const bodyHash = hash(decodeCrock(encRecoveryDoc)); @@ -579,44 +577,99 @@ async function uploadSecret( .put(bodyHash) .build(); const sig = eddsaSign(sigPS, decodeCrock(acctKeypair.priv)); - const resp = await fetch( - new URL(`policy/${acctKeypair.pub}`, prov.provider_url).href, - { - method: "POST", - headers: { - "Anastasis-Policy-Signature": encodeCrock(sig), - "If-None-Match": encodeCrock(bodyHash), - }, - body: decodeCrock(encRecoveryDoc), - }, - ); - if (resp.status !== 204) { - return { - code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED, - hint: "could not upload policy", - }; + const talerPayUri = state.policy_payment_requests?.find( + (x) => x.provider === prov.provider_url, + )?.payto; + let paySecret: string | undefined; + if (talerPayUri) { + paySecret = parsePayUri(talerPayUri)!.orderId; } - let policyVersion = 0; - let policyExpiration: Timestamp = { t_ms: 0 }; - try { - policyVersion = Number(resp.headers.get("Anastasis-Version") ?? "0"); - } catch (e) {} - try { - policyExpiration = { - t_ms: - 1000 * Number(resp.headers.get("Anastasis-Policy-Expiration") ?? "0"), + const reqUrl = new URL(`policy/${acctKeypair.pub}`, prov.provider_url); + if (paySecret) { + // FIXME: Get this from the params + reqUrl.searchParams.set("timeout_ms", "500"); + } + logger.info(`uploading policy to ${prov.provider_url}`); + const resp = await fetch(reqUrl.href, { + method: "POST", + headers: { + "Anastasis-Policy-Signature": encodeCrock(sig), + "If-None-Match": encodeCrock(bodyHash), + ...(paySecret + ? { + "Anastasis-Payment-Identifier": paySecret, + } + : {}), + }, + body: decodeCrock(encRecoveryDoc), + }); + logger.info(`got response for policy upload (http status ${resp.status})`); + if (resp.status === HttpStatusCode.NoContent) { + let policyVersion = 0; + let policyExpiration: Timestamp = { t_ms: 0 }; + try { + policyVersion = Number(resp.headers.get("Anastasis-Version") ?? "0"); + } catch (e) {} + try { + policyExpiration = { + t_ms: + 1000 * + Number(resp.headers.get("Anastasis-Policy-Expiration") ?? "0"), + }; + } catch (e) {} + successDetails[prov.provider_url] = { + policy_version: policyVersion, + policy_expiration: policyExpiration, }; - } catch (e) {} - successDetails[prov.provider_url] = { - policy_version: policyVersion, - policy_expiration: policyExpiration, + continue; + } + if (resp.status === HttpStatusCode.PaymentRequired) { + const talerPayUri = resp.headers.get("Taler"); + if (!talerPayUri) { + return { + code: TalerErrorCode.ANASTASIS_REDUCER_BACKEND_FAILURE, + hint: `payment requested, but no taler://pay URI given`, + }; + } + policyPayUris.push(talerPayUri); + const parsedUri = parsePayUri(talerPayUri); + if (!parsedUri) { + return { + code: TalerErrorCode.ANASTASIS_REDUCER_BACKEND_FAILURE, + hint: `payment requested, but no taler://pay URI given`, + }; + } + policyPayUriMap[prov.provider_url] = talerPayUri; + continue; + } + return { + code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED, + hint: `could not upload policy (http status ${resp.status})`, }; } + if (policyPayUris.length > 0) { + return { + ...state, + backup_state: BackupStates.PoliciesPaying, + payments: policyPayUris, + policy_payment_requests: Object.keys(policyPayUriMap).map((x) => { + return { + payto: policyPayUriMap[x], + provider: x, + }; + }), + }; + } + + logger.info("backup finished"); + return { ...state, + core_secret: undefined, backup_state: BackupStates.BackupFinished, success_details: successDetails, + payments: undefined, }; } @@ -633,6 +686,7 @@ async function downloadPolicy( const newProviderStatus: { [url: string]: AuthenticationProviderStatusOk } = {}; const userAttributes = state.identity_attributes!; + const restrictProvider = state.selected_provider_url; // FIXME: Shouldn't we also store the status of bad providers? for (const url of providerUrls) { const pi = await getProviderInfo(url); @@ -647,9 +701,17 @@ async function downloadPolicy( if (!pi) { continue; } + if (restrictProvider && url !== state.selected_provider_url) { + // User wants specific provider. + continue; + } const userId = await userIdentifierDerive(userAttributes, pi.salt); const acctKeypair = accountKeypairDerive(userId); - const resp = await fetch(new URL(`policy/${acctKeypair.pub}`, url).href); + const reqUrl = new URL(`policy/${acctKeypair.pub}`, url); + if (state.selected_version) { + reqUrl.searchParams.set("version", `${state.selected_version}`); + } + const resp = await fetch(reqUrl.href); if (resp.status !== 200) { continue; } @@ -661,7 +723,6 @@ async function downloadPolicy( const rd: RecoveryDocument = await uncompressRecoveryDoc( decodeCrock(bodyDecrypted), ); - console.log("rd", rd); let policyVersion = 0; try { policyVersion = Number(resp.headers.get("Anastasis-Version") ?? "0"); @@ -682,7 +743,6 @@ async function downloadPolicy( } const recoveryInfo: RecoveryInformation = { challenges: recoveryDoc.escrow_methods.map((x) => { - console.log("providers", newProviderStatus); const prov = newProviderStatus[x.url] as AuthenticationProviderStatusOk; return { cost: prov.methods.find((m) => m.type === x.escrow_type)?.usage_fee!, @@ -750,6 +810,199 @@ async function tryRecoverSecret( return { ...state }; } +/** + * Re-check the status of challenges that are solved asynchronously. + */ +async function pollChallenges( + state: ReducerStateRecovery, + args: void, +): Promise { + for (const truthUuid in state.challenge_feedback) { + if (state.recovery_state === RecoveryStates.RecoveryFinished) { + break; + } + const feedback = state.challenge_feedback[truthUuid]; + const truth = state.verbatim_recovery_document!.escrow_methods.find( + (x) => x.uuid === truthUuid, + ); + if (!truth) { + logger.warn( + "truth for challenge feedback entry not found in recovery document", + ); + continue; + } + if (feedback.state === ChallengeFeedbackStatus.AuthIban) { + const s2 = await requestTruth(state, truth, { + pin: feedback.answer_code, + }); + if (s2.recovery_state) { + state = s2; + } + } + } + return state; +} + +/** + * Request a truth, optionally with a challenge solution + * provided by the user. + */ +async function requestTruth( + state: ReducerStateRecovery, + truth: EscrowMethod, + solveRequest?: ActionArgsSolveChallengeRequest, +): Promise { + const url = new URL(`/truth/${truth.uuid}`, truth.url); + + if (solveRequest) { + logger.info(`handling solve request ${j2s(solveRequest)}`); + let respHash: string; + switch (truth.escrow_type) { + case ChallengeType.Question: { + if ("answer" in solveRequest) { + respHash = await secureAnswerHash( + solveRequest.answer, + truth.uuid, + truth.truth_salt, + ); + } else { + throw Error("unsupported answer request"); + } + break; + } + case ChallengeType.Email: + case ChallengeType.Sms: + case ChallengeType.Post: + case ChallengeType.Iban: + case ChallengeType.Totp: { + if ("answer" in solveRequest) { + const s = solveRequest.answer.trim().replace(/^A-/, ""); + let pin: number; + try { + pin = Number.parseInt(s); + } catch (e) { + throw Error("invalid pin format"); + } + respHash = await pinAnswerHash(pin); + } else if ("pin" in solveRequest) { + respHash = await pinAnswerHash(solveRequest.pin); + } else { + throw Error("unsupported answer request"); + } + break; + } + default: + throw Error(`unsupported challenge type "${truth.escrow_type}""`); + } + url.searchParams.set("response", respHash); + } + + const resp = await fetch(url.href, { + headers: { + "Anastasis-Truth-Decryption-Key": truth.truth_key, + }, + }); + + logger.info( + `got GET /truth response from ${truth.url}, http status ${resp.status}`, + ); + + if (resp.status === HttpStatusCode.Ok) { + let answerSalt: string | undefined = undefined; + if ( + solveRequest && + truth.escrow_type === "question" && + "answer" in solveRequest + ) { + answerSalt = solveRequest.answer; + } + + const userId = await userIdentifierDerive( + state.identity_attributes, + truth.provider_salt, + ); + + const respBody = new Uint8Array(await resp.arrayBuffer()); + const keyShare = await decryptKeyShare( + encodeCrock(respBody), + userId, + answerSalt, + ); + + const recoveredKeyShares = { + ...(state.recovered_key_shares ?? {}), + [truth.uuid]: keyShare, + }; + + const challengeFeedback: { [x: string]: ChallengeFeedback } = { + ...state.challenge_feedback, + [truth.uuid]: { + state: ChallengeFeedbackStatus.Solved, + }, + }; + + const newState: ReducerStateRecovery = { + ...state, + recovery_state: RecoveryStates.ChallengeSelecting, + challenge_feedback: challengeFeedback, + recovered_key_shares: recoveredKeyShares, + }; + + return tryRecoverSecret(newState); + } + + if (resp.status === HttpStatusCode.Forbidden) { + return { + ...state, + recovery_state: RecoveryStates.ChallengeSolving, + challenge_feedback: { + ...state.challenge_feedback, + [truth.uuid]: { + state: ChallengeFeedbackStatus.Message, + message: "Challenge should be solved", + }, + }, + }; + } + + if (resp.status === HttpStatusCode.Accepted) { + const body = await resp.json(); + logger.info(`got body ${j2s(body)}`); + if (body.method === "iban") { + const b = body as IbanExternalAuthResponse; + return { + ...state, + recovery_state: RecoveryStates.ChallengeSolving, + challenge_feedback: { + ...state.challenge_feedback, + [truth.uuid]: { + state: ChallengeFeedbackStatus.AuthIban, + answer_code: b.answer_code, + business_name: b.details.business_name, + challenge_amount: b.details.challenge_amount, + credit_iban: b.details.credit_iban, + wire_transfer_subject: b.details.wire_transfer_subject, + details: b.details, + method: "iban", + }, + }, + }; + } else { + return { + code: TalerErrorCode.ANASTASIS_TRUTH_CHALLENGE_FAILED, + hint: "unknown external authentication method", + http_status: resp.status, + } as ReducerStateError; + } + } + + return { + code: TalerErrorCode.ANASTASIS_TRUTH_CHALLENGE_FAILED, + hint: "got unexpected /truth/ response status", + http_status: resp.status, + } as ReducerStateError; +} + async function solveChallenge( state: ReducerStateRecovery, ta: ActionArgsSolveChallengeRequest, @@ -759,77 +1012,31 @@ async function solveChallenge( (x) => x.uuid === state.selected_challenge_uuid, ); if (!truth) { - throw "truth for challenge not found"; + throw Error("truth for challenge not found"); } - - const url = new URL(`/truth/${truth.uuid}`, truth.url); - - // FIXME: This isn't correct for non-question truth responses. - url.searchParams.set( - "response", - await secureAnswerHash(ta.answer, truth.uuid, truth.truth_salt), - ); - - const resp = await fetch(url.href, { - headers: { - "Anastasis-Truth-Decryption-Key": truth.truth_key, - }, - }); - - console.log(resp); - - if (resp.status !== 200) { - return { - code: TalerErrorCode.ANASTASIS_TRUTH_CHALLENGE_FAILED, - hint: "got non-200 response", - http_status: resp.status, - } as ReducerStateError; - } - - const answerSalt = truth.escrow_type === "question" ? ta.answer : undefined; - - const userId = await userIdentifierDerive( - state.identity_attributes, - truth.provider_salt, - ); - - const respBody = new Uint8Array(await resp.arrayBuffer()); - const keyShare = await decryptKeyShare( - encodeCrock(respBody), - userId, - answerSalt, - ); - - const recoveredKeyShares = { - ...(state.recovered_key_shares ?? {}), - [truth.uuid]: keyShare, - }; - - const challengeFeedback = { - ...state.challenge_feedback, - [truth.uuid]: { - state: "solved", - }, - }; - - const newState: ReducerStateRecovery = { - ...state, - recovery_state: RecoveryStates.ChallengeSelecting, - challenge_feedback: challengeFeedback, - recovered_key_shares: recoveredKeyShares, - }; - - return tryRecoverSecret(newState); + return requestTruth(state, truth, ta); } async function recoveryEnterUserAttributes( state: ReducerStateRecovery, - attributes: Record, + args: ActionArgsEnterUserAttributes, ): Promise { // FIXME: validate attributes const st: ReducerStateRecovery = { ...state, - identity_attributes: attributes, + identity_attributes: args.identity_attributes, + }; + return downloadPolicy(st); +} + +async function changeVersion( + state: ReducerStateRecovery, + args: ActionArgsChangeVersion, +): Promise { + const st: ReducerStateRecovery = { + ...state, + selected_version: args.version, + selected_provider_url: args.provider_url, }; return downloadPolicy(st); } @@ -844,369 +1051,457 @@ async function selectChallenge( throw "truth for challenge not found"; } - const url = new URL(`/truth/${truth.uuid}`, truth.url); - - const resp = await fetch(url.href, { - headers: { - "Anastasis-Truth-Decryption-Key": truth.truth_key, - }, - }); - - console.log(resp); + return requestTruth({ ...state, selected_challenge_uuid: ta.uuid }, truth); +} +async function backupSelectContinent( + state: ReducerStateBackup, + args: ActionArgsSelectContinent, +): Promise { + const countries = getCountries(args.continent); + if (countries.length <= 0) { + return { + code: TalerErrorCode.ANASTASIS_REDUCER_INPUT_INVALID, + hint: "continent not found", + }; + } return { ...state, - recovery_state: RecoveryStates.ChallengeSolving, - selected_challenge_uuid: ta.uuid, + backup_state: BackupStates.CountrySelecting, + countries, + selected_continent: args.continent, }; } +async function recoverySelectContinent( + state: ReducerStateRecovery, + args: ActionArgsSelectContinent, +): Promise { + const countries = getCountries(args.continent); + return { + ...state, + recovery_state: RecoveryStates.CountrySelecting, + countries, + selected_continent: args.continent, + }; +} + +interface TransitionImpl { + argCodec: Codec; + handler: (s: S, args: T) => Promise; +} + +interface Transition { + [x: string]: TransitionImpl; +} + +function transition( + action: string, + argCodec: Codec, + handler: (s: S, args: T) => Promise, +): Transition { + return { + [action]: { + argCodec, + handler, + }, + }; +} + +function transitionBackupJump( + action: string, + st: BackupStates, +): Transition { + return { + [action]: { + argCodec: codecForAny(), + handler: async (s, a) => ({ ...s, backup_state: st }), + }, + }; +} + +function transitionRecoveryJump( + action: string, + st: RecoveryStates, +): Transition { + return { + [action]: { + argCodec: codecForAny(), + handler: async (s, a) => ({ ...s, recovery_state: st }), + }, + }; +} + +async function addAuthentication( + state: ReducerStateBackup, + args: ActionArgsAddAuthentication, +): Promise { + return { + ...state, + authentication_methods: [ + ...(state.authentication_methods ?? []), + args.authentication_method, + ], + }; +} + +async function deleteAuthentication( + state: ReducerStateBackup, + args: ActionArgsDeleteAuthentication, +): Promise { + const m = state.authentication_methods ?? []; + m.splice(args.authentication_method, 1); + return { + ...state, + authentication_methods: m, + }; +} + +async function deletePolicy( + state: ReducerStateBackup, + args: ActionArgsDeletePolicy, +): Promise { + const policies = [...(state.policies ?? [])]; + policies.splice(args.policy_index, 1); + return { + ...state, + policies, + }; +} + +async function updatePolicy( + state: ReducerStateBackup, + args: ActionArgsUpdatePolicy, +): Promise { + const policies = [...(state.policies ?? [])]; + policies[args.policy_index] = { methods: args.policy }; + return { + ...state, + policies, + }; +} + +async function addPolicy( + state: ReducerStateBackup, + args: ActionArgsAddPolicy, +): Promise { + return { + ...state, + policies: [ + ...(state.policies ?? []), + { + methods: args.policy, + }, + ], + }; +} + +async function nextFromAuthenticationsEditing( + state: ReducerStateBackup, + args: {}, +): Promise { + const methods = state.authentication_methods ?? []; + const providers: ProviderInfo[] = []; + for (const provUrl of Object.keys(state.authentication_providers ?? {})) { + const prov = state.authentication_providers![provUrl]; + if ("error_code" in prov) { + continue; + } + if (!("http_status" in prov && prov.http_status === 200)) { + continue; + } + const methodCost: Record = {}; + for (const meth of prov.methods) { + methodCost[meth.type] = meth.usage_fee; + } + providers.push({ + methodCost, + url: provUrl, + }); + } + const pol = suggestPolicies(methods, providers); + return { + ...state, + backup_state: BackupStates.PoliciesReviewing, + ...pol, + }; +} + +async function updateUploadFees( + state: ReducerStateBackup, +): Promise { + const expiration = state.expiration; + if (!expiration) { + return { ...state }; + } + logger.info("updating upload fees"); + const feePerCurrency: Record = {}; + const addFee = (x: AmountLike) => { + x = Amounts.jsonifyAmount(x); + feePerCurrency[x.currency] = Amounts.add( + feePerCurrency[x.currency] ?? Amounts.getZero(x.currency), + x, + ).amount; + }; + const years = Duration.toIntegerYears(Duration.getRemaining(expiration)); + logger.info(`computing fees for ${years} years`); + // For now, we compute fees for *all* available providers. + for (const provUrl in state.authentication_providers ?? {}) { + const prov = state.authentication_providers![provUrl]; + if ("annual_fee" in prov) { + const annualFee = Amounts.mult(prov.annual_fee, years).amount; + logger.info(`adding annual fee ${Amounts.stringify(annualFee)}`); + addFee(annualFee); + } + } + const coveredProvTruth = new Set(); + for (const x of state.policies ?? []) { + for (const m of x.methods) { + const prov = state.authentication_providers![ + m.provider + ] as AuthenticationProviderStatusOk; + const authMethod = state.authentication_methods![m.authentication_method]; + const key = `${m.authentication_method}@${m.provider}`; + if (coveredProvTruth.has(key)) { + continue; + } + logger.info( + `adding cost for auth method ${authMethod.challenge} / "${authMethod.instructions}" at ${m.provider}`, + ); + coveredProvTruth.add(key); + addFee(prov.truth_upload_fee); + } + } + return { + ...state, + upload_fees: Object.values(feePerCurrency).map((x) => ({ + fee: Amounts.stringify(x), + })), + }; +} + +async function enterSecret( + state: ReducerStateBackup, + args: ActionArgsEnterSecret, +): Promise { + return updateUploadFees({ + ...state, + expiration: args.expiration, + core_secret: { + mime: args.secret.mime ?? "text/plain", + value: args.secret.value, + }, + // A new secret invalidates the existing recovery data. + recovery_data: undefined, + }); +} + +async function nextFromChallengeSelecting( + state: ReducerStateRecovery, + args: void, +): Promise { + const s2 = await tryRecoverSecret(state); + if (s2.recovery_state === RecoveryStates.RecoveryFinished) { + return s2; + } + return { + code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, + hint: "Not enough challenges solved", + }; +} + +async function enterSecretName( + state: ReducerStateBackup, + args: ActionArgsEnterSecretName, +): Promise { + return { + ...state, + secret_name: args.name, + }; +} + +async function updateSecretExpiration( + state: ReducerStateBackup, + args: ActionArgsUpdateExpiration, +): Promise { + return updateUploadFees({ + ...state, + expiration: args.expiration, + }); +} + +const backupTransitions: Record< + BackupStates, + Transition +> = { + [BackupStates.ContinentSelecting]: { + ...transition( + "select_continent", + codecForActionArgSelectContinent(), + backupSelectContinent, + ), + }, + [BackupStates.CountrySelecting]: { + ...transitionBackupJump("back", BackupStates.ContinentSelecting), + ...transition( + "select_country", + codecForActionArgSelectCountry(), + backupSelectCountry, + ), + ...transition( + "select_continent", + codecForActionArgSelectContinent(), + backupSelectContinent, + ), + }, + [BackupStates.UserAttributesCollecting]: { + ...transitionBackupJump("back", BackupStates.CountrySelecting), + ...transition( + "enter_user_attributes", + codecForActionArgsEnterUserAttributes(), + backupEnterUserAttributes, + ), + }, + [BackupStates.AuthenticationsEditing]: { + ...transitionBackupJump("back", BackupStates.UserAttributesCollecting), + ...transition("add_authentication", codecForAny(), addAuthentication), + ...transition("delete_authentication", codecForAny(), deleteAuthentication), + ...transition("next", codecForAny(), nextFromAuthenticationsEditing), + }, + [BackupStates.PoliciesReviewing]: { + ...transitionBackupJump("back", BackupStates.AuthenticationsEditing), + ...transitionBackupJump("next", BackupStates.SecretEditing), + ...transition("add_policy", codecForActionArgsAddPolicy(), addPolicy), + ...transition("delete_policy", codecForAny(), deletePolicy), + ...transition("update_policy", codecForAny(), updatePolicy), + }, + [BackupStates.SecretEditing]: { + ...transitionBackupJump("back", BackupStates.PoliciesReviewing), + ...transition("next", codecForAny(), uploadSecret), + ...transition("enter_secret", codecForAny(), enterSecret), + ...transition( + "update_expiration", + codecForActionArgsUpdateExpiration(), + updateSecretExpiration, + ), + ...transition("enter_secret_name", codecForAny(), enterSecretName), + }, + [BackupStates.PoliciesPaying]: { + ...transitionBackupJump("back", BackupStates.SecretEditing), + ...transition("pay", codecForAny(), uploadSecret), + }, + [BackupStates.TruthsPaying]: { + ...transitionBackupJump("back", BackupStates.SecretEditing), + ...transition("pay", codecForAny(), uploadSecret), + }, + [BackupStates.BackupFinished]: { + ...transitionBackupJump("back", BackupStates.SecretEditing), + }, +}; + +const recoveryTransitions: Record< + RecoveryStates, + Transition +> = { + [RecoveryStates.ContinentSelecting]: { + ...transition( + "select_continent", + codecForActionArgSelectContinent(), + recoverySelectContinent, + ), + }, + [RecoveryStates.CountrySelecting]: { + ...transitionRecoveryJump("back", RecoveryStates.ContinentSelecting), + ...transition( + "select_country", + codecForActionArgSelectCountry(), + recoverySelectCountry, + ), + ...transition( + "select_continent", + codecForActionArgSelectContinent(), + recoverySelectContinent, + ), + }, + [RecoveryStates.UserAttributesCollecting]: { + ...transitionRecoveryJump("back", RecoveryStates.CountrySelecting), + ...transition( + "enter_user_attributes", + codecForActionArgsEnterUserAttributes(), + recoveryEnterUserAttributes, + ), + }, + [RecoveryStates.SecretSelecting]: { + ...transitionRecoveryJump("back", RecoveryStates.UserAttributesCollecting), + ...transitionRecoveryJump("next", RecoveryStates.ChallengeSelecting), + ...transition( + "change_version", + codecForActionArgsChangeVersion(), + changeVersion, + ), + }, + [RecoveryStates.ChallengeSelecting]: { + ...transitionRecoveryJump("back", RecoveryStates.SecretSelecting), + ...transition( + "select_challenge", + codecForActionArgsSelectChallenge(), + selectChallenge, + ), + ...transition("poll", codecForAny(), pollChallenges), + ...transition("next", codecForAny(), nextFromChallengeSelecting), + }, + [RecoveryStates.ChallengeSolving]: { + ...transitionRecoveryJump("back", RecoveryStates.ChallengeSelecting), + ...transition("solve_challenge", codecForAny(), solveChallenge), + }, + [RecoveryStates.ChallengePaying]: {}, + [RecoveryStates.RecoveryFinished]: { + ...transitionRecoveryJump("back", RecoveryStates.ChallengeSelecting), + }, +}; + export async function reduceAction( state: ReducerState, action: string, args: any, ): Promise { - console.log(`ts reducer: handling action ${action}`); - if (state.backup_state === BackupStates.ContinentSelecting) { - if (action === "select_continent") { - const continent: string = args.continent; - if (typeof continent !== "string") { - return { - code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, - hint: "continent required", - }; - } - return { - ...state, - backup_state: BackupStates.CountrySelecting, - countries: getCountries(continent), - selected_continent: continent, - }; - } else { - return { - code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, - hint: `Unsupported action '${action}'`, - }; - } + let h: TransitionImpl; + let stateName: string; + if ("backup_state" in state && state.backup_state) { + stateName = state.backup_state; + h = backupTransitions[state.backup_state][action]; + } else if ("recovery_state" in state && state.recovery_state) { + stateName = state.recovery_state; + h = recoveryTransitions[state.recovery_state][action]; + } else { + return { + code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, + hint: `Invalid state (needs backup_state or recovery_state)`, + }; } - if (state.backup_state === BackupStates.CountrySelecting) { - if (action === "back") { - return { - ...state, - backup_state: BackupStates.ContinentSelecting, - countries: undefined, - }; - } else if (action === "select_country") { - const countryCode = args.country_code; - if (typeof countryCode !== "string") { - return { - code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, - hint: "country_code required", - }; - } - const currencies = args.currencies; - return backupSelectCountry(state, countryCode, currencies); - } else { - return { - code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, - hint: `Unsupported action '${action}'`, - }; - } + if (!h) { + return { + code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, + hint: `Unsupported action '${action}' in state '${stateName}'`, + }; } - if (state.backup_state === BackupStates.UserAttributesCollecting) { - if (action === "back") { - return { - ...state, - backup_state: BackupStates.CountrySelecting, - }; - } else if (action === "enter_user_attributes") { - const ta = args as ActionArgEnterUserAttributes; - return backupEnterUserAttributes(state, ta.identity_attributes); - } else { - return { - code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, - hint: `Unsupported action '${action}'`, - }; - } + let parsedArgs: any; + try { + parsedArgs = h.argCodec.decode(args); + } catch (e: any) { + return { + code: TalerErrorCode.ANASTASIS_REDUCER_INPUT_INVALID, + hint: "argument validation failed", + message: e.toString(), + }; } - if (state.backup_state === BackupStates.AuthenticationsEditing) { - if (action === "back") { - return { - ...state, - backup_state: BackupStates.UserAttributesCollecting, - }; - } else if (action === "add_authentication") { - const ta = args as ActionArgAddAuthentication; - return { - ...state, - authentication_methods: [ - ...(state.authentication_methods ?? []), - ta.authentication_method, - ], - }; - } else if (action === "delete_authentication") { - const ta = args as ActionArgDeleteAuthentication; - const m = state.authentication_methods ?? []; - m.splice(ta.authentication_method, 1); - return { - ...state, - authentication_methods: m, - }; - } else if (action === "next") { - const methods = state.authentication_methods ?? []; - const providers: ProviderInfo[] = []; - for (const provUrl of Object.keys(state.authentication_providers ?? {})) { - const prov = state.authentication_providers![provUrl]; - if ("error_code" in prov) { - continue; - } - if (!("http_status" in prov && prov.http_status === 200)) { - continue; - } - const methodCost: Record = {}; - for (const meth of prov.methods) { - methodCost[meth.type] = meth.usage_fee; - } - providers.push({ - methodCost, - url: provUrl, - }); - } - const pol = suggestPolicies(methods, providers); - console.log("policies", pol); - return { - ...state, - backup_state: BackupStates.PoliciesReviewing, - ...pol, - }; - } else { - return { - code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, - hint: `Unsupported action '${action}'`, - }; + try { + return await h.handler(state, parsedArgs); + } catch (e) { + logger.error("action handler failed"); + if (e instanceof ReducerError) { + return e.errorJson; } + throw e; } - if (state.backup_state === BackupStates.PoliciesReviewing) { - if (action === "back") { - return { - ...state, - backup_state: BackupStates.AuthenticationsEditing, - }; - } else if (action === "delete_policy") { - const ta = args as ActionArgDeletePolicy; - const policies = [...(state.policies ?? [])]; - policies.splice(ta.policy_index, 1); - return { - ...state, - policies, - }; - } else if (action === "next") { - return { - ...state, - backup_state: BackupStates.SecretEditing, - }; - } else { - return { - code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, - hint: `Unsupported action '${action}'`, - }; - } - } - if (state.backup_state === BackupStates.SecretEditing) { - if (action === "back") { - return { - ...state, - backup_state: BackupStates.PoliciesReviewing, - }; - } else if (action === "enter_secret_name") { - const ta = args as ActionArgEnterSecretName; - return { - ...state, - secret_name: ta.name, - }; - } else if (action === "enter_secret") { - const ta = args as ActionArgEnterSecret; - return { - ...state, - expiration: ta.expiration, - core_secret: { - mime: ta.secret.mime ?? "text/plain", - value: ta.secret.value, - }, - }; - } else if (action === "next") { - return uploadSecret(state); - } else { - return { - code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, - hint: `Unsupported action '${action}'`, - }; - } - } - if (state.backup_state === BackupStates.BackupFinished) { - if (action === "back") { - return { - ...state, - backup_state: BackupStates.SecretEditing, - }; - } else { - return { - code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, - hint: `Unsupported action '${action}'`, - }; - } - } - - if (state.recovery_state === RecoveryStates.ContinentSelecting) { - if (action === "select_continent") { - const continent: string = args.continent; - if (typeof continent !== "string") { - return { - code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, - hint: "continent required", - }; - } - return { - ...state, - recovery_state: RecoveryStates.CountrySelecting, - countries: getCountries(continent), - selected_continent: continent, - }; - } else { - return { - code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, - hint: `Unsupported action '${action}'`, - }; - } - } - - if (state.recovery_state === RecoveryStates.CountrySelecting) { - if (action === "back") { - return { - ...state, - recovery_state: RecoveryStates.ContinentSelecting, - countries: undefined, - }; - } else if (action === "select_country") { - const countryCode = args.country_code; - if (typeof countryCode !== "string") { - return { - code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, - hint: "country_code required", - }; - } - const currencies = args.currencies; - return recoverySelectCountry(state, countryCode, currencies); - } else { - return { - code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, - hint: `Unsupported action '${action}'`, - }; - } - } - - if (state.recovery_state === RecoveryStates.UserAttributesCollecting) { - if (action === "back") { - return { - ...state, - recovery_state: RecoveryStates.CountrySelecting, - }; - } else if (action === "enter_user_attributes") { - const ta = args as ActionArgEnterUserAttributes; - return recoveryEnterUserAttributes(state, ta.identity_attributes); - } else { - return { - code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, - hint: `Unsupported action '${action}'`, - }; - } - } - - if (state.recovery_state === RecoveryStates.SecretSelecting) { - if (action === "back") { - return { - ...state, - recovery_state: RecoveryStates.UserAttributesCollecting, - }; - } else if (action === "next") { - return { - ...state, - recovery_state: RecoveryStates.ChallengeSelecting, - }; - } else { - return { - code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, - hint: `Unsupported action '${action}'`, - }; - } - } - - if (state.recovery_state === RecoveryStates.ChallengeSelecting) { - if (action === "select_challenge") { - const ta: ActionArgsSelectChallenge = args; - return selectChallenge(state, ta); - } else if (action === "back") { - return { - ...state, - recovery_state: RecoveryStates.SecretSelecting, - }; - } else if (action === "next") { - const s2 = await tryRecoverSecret(state); - if (s2.recovery_state === RecoveryStates.RecoveryFinished) { - return s2; - } - return { - code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, - hint: "Not enough challenges solved", - }; - } else { - return { - code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, - hint: `Unsupported action '${action}'`, - }; - } - } - - if (state.recovery_state === RecoveryStates.ChallengeSolving) { - if (action === "back") { - const ta: ActionArgsSelectChallenge = args; - return { - ...state, - selected_challenge_uuid: undefined, - recovery_state: RecoveryStates.ChallengeSelecting, - }; - } else if (action === "solve_challenge") { - const ta: ActionArgsSolveChallengeRequest = args; - return solveChallenge(state, ta); - } else { - return { - code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, - hint: `Unsupported action '${action}'`, - }; - } - } - - if (state.recovery_state === RecoveryStates.RecoveryFinished) { - if (action === "back") { - const ta: ActionArgsSelectChallenge = args; - return { - ...state, - selected_challenge_uuid: undefined, - recovery_state: RecoveryStates.ChallengeSelecting, - }; - } else if (action === "solve_challenge") { - const ta: ActionArgsSolveChallengeRequest = args; - return solveChallenge(state, ta); - } else { - return { - code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, - hint: `Unsupported action '${action}'`, - }; - } - } - - return { - code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, - hint: "Reducer action invalid", - }; } diff --git a/packages/anastasis-core/src/policy-suggestion.ts b/packages/anastasis-core/src/policy-suggestion.ts new file mode 100644 index 000000000..7eb6c21cc --- /dev/null +++ b/packages/anastasis-core/src/policy-suggestion.ts @@ -0,0 +1,230 @@ +import { AmountString, j2s, Logger } from "@gnu-taler/taler-util"; +import { AuthMethod, Policy, PolicyProvider } from "./reducer-types.js"; + +const logger = new Logger("anastasis-core:policy-suggestion.ts"); + +const maxMethodSelections = 200; +const maxPolicyEvaluations = 10000; + +/** + * Provider information used during provider/method mapping. + */ +export interface ProviderInfo { + url: string; + methodCost: Record; +} + +export function suggestPolicies( + methods: AuthMethod[], + providers: ProviderInfo[], +): PolicySelectionResult { + const numMethods = methods.length; + if (numMethods === 0) { + throw Error("no methods"); + } + let numSel: number; + if (numMethods <= 2) { + numSel = numMethods; + } else if (numMethods <= 4) { + numSel = numMethods - 1; + } else if (numMethods <= 6) { + numSel = numMethods - 2; + } else if (numMethods == 7) { + numSel = numMethods - 3; + } else { + numSel = 4; + } + const policies: Policy[] = []; + const selections = enumerateMethodSelections( + numSel, + numMethods, + maxMethodSelections, + ); + logger.info(`selections: ${j2s(selections)}`); + for (const sel of selections) { + const p = assignProviders(policies, methods, providers, sel); + if (p) { + policies.push(p); + } + } + logger.info(`suggesting policies ${j2s(policies)}`); + return { + policies, + policy_providers: providers.map((x) => ({ + provider_url: x.url, + })), + }; +} + +/** + * Assign providers to a method selection. + * + * The evaluation of the assignment is made with respect to + * previously generated policies. + */ +function assignProviders( + existingPolicies: Policy[], + methods: AuthMethod[], + providers: ProviderInfo[], + methodSelection: number[], +): Policy | undefined { + const providerSelections = enumerateProviderMappings( + methodSelection.length, + providers.length, + maxPolicyEvaluations, + ); + + let bestProvSel: ProviderSelection | undefined; + // Number of different providers selected, larger is better + let bestDiversity = 0; + // Number of identical challenges duplicated at different providers, + // smaller is better + let bestDuplication = Number.MAX_SAFE_INTEGER; + + for (const provSel of providerSelections) { + // First, check if selection is even possible with the methods offered + let possible = true; + for (const methIndex in provSel) { + const provIndex = provSel[methIndex]; + const meth = methods[methIndex]; + const prov = providers[provIndex]; + if (!prov.methodCost[meth.type]) { + possible = false; + break; + } + } + if (!possible) { + continue; + } + + // Evaluate diversity, always prefer policies + // that increase diversity. + const providerSet = new Set(); + // The C reducer evaluates diversity only per policy + // for (const pol of existingPolicies) { + // for (const m of pol.methods) { + // providerSet.add(m.provider); + // } + // } + for (const provIndex of provSel) { + const prov = providers[provIndex]; + providerSet.add(prov.url); + } + + const diversity = providerSet.size; + + // Number of providers that each method shows up at. + const provPerMethod: Set[] = []; + for (let i = 0; i < methods.length; i++) { + provPerMethod[i] = new Set(); + } + for (const pol of existingPolicies) { + for (const m of pol.methods) { + provPerMethod[m.authentication_method].add(m.provider); + } + } + for (const methSelIndex in provSel) { + const prov = providers[provSel[methSelIndex]]; + provPerMethod[methodSelection[methSelIndex]].add(prov.url); + } + + let duplication = 0; + for (const provSet of provPerMethod) { + duplication += provSet.size; + } + + logger.info(`diversity ${diversity}, duplication ${duplication}`); + + if (!bestProvSel || diversity > bestDiversity) { + bestProvSel = provSel; + bestDiversity = diversity; + bestDuplication = duplication; + logger.info(`taking based on diversity`); + } else if (diversity == bestDiversity && duplication < bestDuplication) { + bestProvSel = provSel; + bestDiversity = diversity; + bestDuplication = duplication; + logger.info(`taking based on duplication`); + } + // TODO: also evaluate costs + } + + if (!bestProvSel) { + return undefined; + } + + return { + methods: bestProvSel.map((x, i) => ({ + authentication_method: methodSelection[i], + provider: providers[x].url, + })), + }; +} + +/** + * A provider selection maps a method selection index to a provider index. + */ +type ProviderSelection = number[]; + +/** + * Compute provider mappings. + * Enumerates all n-combinations with repetition of m providers. + */ +function enumerateProviderMappings( + n: number, + m: number, + limit?: number, +): ProviderSelection[] { + const selections: ProviderSelection[] = []; + const a = new Array(n); + const sel = (i: number, start: number = 0) => { + if (i === n) { + selections.push([...a]); + return; + } + for (let j = start; j < m; j++) { + a[i] = j; + sel(i + 1, j); + if (limit && selections.length >= limit) { + break; + } + } + }; + sel(0); + return selections; +} + +interface PolicySelectionResult { + policies: Policy[]; + policy_providers: PolicyProvider[]; +} + +type MethodSelection = number[]; + +/** + * Compute method selections. + * Enumerates all n-combinations without repetition of m methods. + */ +function enumerateMethodSelections( + n: number, + m: number, + limit?: number, +): MethodSelection[] { + const selections: MethodSelection[] = []; + const a = new Array(n); + const sel = (i: number, start: number = 0) => { + if (i === n) { + selections.push([...a]); + return; + } + for (let j = start; j < m; j++) { + a[i] = j; + sel(i + 1, j + 1); + if (limit && selections.length >= limit) { + break; + } + } + }; + sel(0); + return selections; +} diff --git a/packages/anastasis-core/src/provider-types.ts b/packages/anastasis-core/src/provider-types.ts index b477c09b9..f4d998e0a 100644 --- a/packages/anastasis-core/src/provider-types.ts +++ b/packages/anastasis-core/src/provider-types.ts @@ -1,4 +1,4 @@ -import { AmountString } from "@gnu-taler/taler-util"; +import { Amounts, AmountString } from "@gnu-taler/taler-util"; export interface EscrowConfigurationResponse { // Protocol identifier, clarifies that this is an Anastasis provider. @@ -72,3 +72,14 @@ export interface TruthUploadRequest { // store the truth? storage_duration_years: number; } + +export interface IbanExternalAuthResponse { + method: "iban"; + answer_code: number; + details: { + challenge_amount: AmountString; + credit_iban: string; + business_name: string; + wire_transfer_subject: string; + }; +} diff --git a/packages/anastasis-core/src/recovery-document-types.ts b/packages/anastasis-core/src/recovery-document-types.ts index 74003ccb1..3dc4481ff 100644 --- a/packages/anastasis-core/src/recovery-document-types.ts +++ b/packages/anastasis-core/src/recovery-document-types.ts @@ -1,5 +1,14 @@ import { TruthKey, TruthSalt, TruthUuid } from "./crypto.js"; +export enum ChallengeType { + Question = "question", + Sms = "sms", + Email = "email", + Post = "post", + Totp = "totp", + Iban = "iban", +} + export interface RecoveryDocument { /** * Human-readable name of the secret @@ -9,7 +18,7 @@ export interface RecoveryDocument { /** * Encrypted core secret. - * + * * Variable-size length, base32-crock encoded. */ encrypted_core_secret: string; @@ -56,7 +65,7 @@ export interface EscrowMethod { /** * Type of the escrow method (e.g. security question, SMS etc.). */ - escrow_type: string; + escrow_type: ChallengeType; /** * UUID of the escrow method. diff --git a/packages/anastasis-core/src/reducer-types.ts b/packages/anastasis-core/src/reducer-types.ts index 1a443bf9b..0f64be4eb 100644 --- a/packages/anastasis-core/src/reducer-types.ts +++ b/packages/anastasis-core/src/reducer-types.ts @@ -1,4 +1,15 @@ -import { Duration, Timestamp } from "@gnu-taler/taler-util"; +import { + AmountString, + buildCodecForObject, + codecForAny, + codecForList, + codecForNumber, + codecForString, + codecForTimestamp, + Duration, + Timestamp, +} from "@gnu-taler/taler-util"; +import { ChallengeFeedback } from "./challenge-feedback-types.js"; import { KeyShare } from "./crypto.js"; import { RecoveryDocument } from "./recovery-document-types.js"; @@ -23,7 +34,7 @@ export interface Policy { authentication_method: number; provider: string; }[]; -} +} export interface PolicyProvider { provider_url: string; @@ -47,7 +58,7 @@ export interface ReducerStateBackup { code?: undefined; currencies?: string[]; continents?: ContinentInfo[]; - countries?: any; + countries?: CountryInfo[]; identity_attributes?: { [n: string]: string }; authentication_providers?: { [url: string]: AuthenticationProviderStatus }; authentication_methods?: AuthMethod[]; @@ -56,21 +67,53 @@ export interface ReducerStateBackup { selected_country?: string; secret_name?: string; policies?: Policy[]; + + recovery_data?: { + /** + * Map from truth key (`${methodIndex}/${providerUrl}`) to + * the truth metadata. + */ + truth_metadata: Record; + recovery_document: RecoveryDocument; + }; + /** * Policy providers are providers that we checked to be functional * and that are actually used in policies. */ policy_providers?: PolicyProvider[]; success_details?: SuccessDetails; + + /** + * Currently requested payments. + * + * List of taler://pay URIs. + * + * FIXME: There should be more information in this, + * including the provider and amount. + */ payments?: string[]; + + /** + * FIXME: Why is this not a map from provider to payto? + */ policy_payment_requests?: { + /** + * FIXME: This is not a payto URI, right?! + */ payto: string; provider: string; }[]; core_secret?: CoreSecret; - expiration?: Duration; + expiration?: Timestamp; + + upload_fees?: { fee: AmountString }[]; + + // FIXME: The payment secrets and pay URIs should + // probably be consolidated into a single field. + truth_upload_payment_secrets?: Record; } export interface AuthMethod { @@ -93,6 +136,9 @@ export interface UserAttributeSpec { type: string; uuid: string; widget: string; + optional?: boolean; + "validation-regex": string | undefined; + "validation-logic": string | undefined; } export interface RecoveryInternalData { @@ -126,8 +172,8 @@ export interface ReducerStateRecovery { identity_attributes?: { [n: string]: string }; - continents?: any; - countries?: any; + continents?: ContinentInfo[]; + countries?: CountryInfo[]; selected_continent?: string; selected_country?: string; @@ -148,6 +194,18 @@ export interface ReducerStateRecovery { selected_challenge_uuid?: string; + /** + * Explicitly selected version by the user. + * FIXME: In the C reducer this is called "version". + */ + selected_version?: number; + + /** + * Explicitly selected provider URL by the user. + * FIXME: In the C reducer this is called "provider_url". + */ + selected_provider_url?: string; + challenge_feedback?: { [uuid: string]: ChallengeFeedback }; /** @@ -161,12 +219,35 @@ export interface ReducerStateRecovery { }; authentication_providers?: { [url: string]: AuthenticationProviderStatus }; - - recovery_error?: any; } -export interface ChallengeFeedback { - state: string; +/** + * Truth data as stored in the reducer. + */ +export interface TruthMetaData { + uuid: string; + + key_share: string; + + policy_index: number; + + pol_method_index: number; + + /** + * Nonce used for encrypting the truth. + */ + nonce: string; + + /** + * Key that the truth (i.e. secret question answer, email address, mobile number, ...) + * is encrypted with when stored at the provider. + */ + truth_key: string; + + /** + * Truth-specific salt. + */ + truth_salt: string; } export interface ReducerStateError { @@ -239,11 +320,16 @@ export interface ReducerStateBackupUserAttributesCollecting authentication_providers: { [url: string]: AuthenticationProviderStatus }; } -export interface ActionArgEnterUserAttributes { +export interface ActionArgsEnterUserAttributes { identity_attributes: Record; } -export interface ActionArgAddAuthentication { +export const codecForActionArgsEnterUserAttributes = () => + buildCodecForObject() + .property("identity_attributes", codecForAny()) + .build("ActionArgsEnterUserAttributes"); + +export interface ActionArgsAddAuthentication { authentication_method: { type: string; instructions: string; @@ -252,32 +338,134 @@ export interface ActionArgAddAuthentication { }; } -export interface ActionArgDeleteAuthentication { +export interface ActionArgsDeleteAuthentication { authentication_method: number; } -export interface ActionArgDeletePolicy { +export interface ActionArgsDeletePolicy { policy_index: number; } -export interface ActionArgEnterSecretName { +export interface ActionArgsEnterSecretName { name: string; } -export interface ActionArgEnterSecret { +export interface ActionArgsEnterSecret { secret: { value: string; mime?: string; }; - expiration: Duration; + expiration: Timestamp; +} + +export interface ActionArgsSelectContinent { + continent: string; +} + +export const codecForActionArgSelectContinent = () => + buildCodecForObject() + .property("continent", codecForString()) + .build("ActionArgSelectContinent"); + +export interface ActionArgsSelectCountry { + country_code: string; + currencies: string[]; } export interface ActionArgsSelectChallenge { uuid: string; } -export type ActionArgsSolveChallengeRequest = SolveChallengeAnswerRequest; +export type ActionArgsSolveChallengeRequest = + | SolveChallengeAnswerRequest + | SolveChallengePinRequest + | SolveChallengeHashRequest; +/** + * Answer to a challenge. + * + * For "question" challenges, this is a string with the answer. + * + * For "sms" / "email" / "post" this is a numeric code with optionally + * the "A-" prefix. + */ export interface SolveChallengeAnswerRequest { answer: string; } + +/** + * Answer to a challenge that requires a numeric response. + * + * XXX: Should be deprecated in favor of just "answer". + */ +export interface SolveChallengePinRequest { + pin: number; +} + +/** + * Answer to a challenge by directly providing the hash. + * + * XXX: When / why is this even used? + */ +export interface SolveChallengeHashRequest { + /** + * Base32-crock encoded hash code. + */ + hash: string; +} + +export interface PolicyMember { + authentication_method: number; + provider: string; +} + +export interface ActionArgsAddPolicy { + policy: PolicyMember[]; +} + +export interface ActionArgsUpdateExpiration { + expiration: Timestamp; +} + +export interface ActionArgsChangeVersion { + provider_url: string; + version: number; +} + +export interface ActionArgsUpdatePolicy { + policy_index: number; + policy: PolicyMember[]; +} + +export const codecForActionArgsChangeVersion = () => + buildCodecForObject() + .property("provider_url", codecForString()) + .property("version", codecForNumber()) + .build("ActionArgsChangeVersion"); + +export const codecForPolicyMember = () => + buildCodecForObject() + .property("authentication_method", codecForNumber()) + .property("provider", codecForString()) + .build("PolicyMember"); + +export const codecForActionArgsAddPolicy = () => + buildCodecForObject() + .property("policy", codecForList(codecForPolicyMember())) + .build("ActionArgsAddPolicy"); + +export const codecForActionArgsUpdateExpiration = () => + buildCodecForObject() + .property("expiration", codecForTimestamp) + .build("ActionArgsUpdateExpiration"); + +export const codecForActionArgsSelectChallenge = () => + buildCodecForObject() + .property("uuid", codecForString()) + .build("ActionArgsSelectChallenge"); + +export const codecForActionArgSelectCountry = () => + buildCodecForObject() + .property("country_code", codecForString()) + .property("currencies", codecForList(codecForString())) + .build("ActionArgSelectCountry"); diff --git a/packages/anastasis-core/src/validators.ts b/packages/anastasis-core/src/validators.ts new file mode 100644 index 000000000..1c04bfdb3 --- /dev/null +++ b/packages/anastasis-core/src/validators.ts @@ -0,0 +1,28 @@ +function isPrime(num: number): boolean { + for (let i = 2, s = Math.sqrt(num); i <= s; i++) + if (num % i === 0) return false; + return num > 1; +} + +export function AL_NID_check(s: string): boolean { return true } +export function BE_NRN_check(s: string): boolean { return true } +export function CH_AHV_check(s: string): boolean { return true } +export function CZ_BN_check(s: string): boolean { return true } +export function DE_TIN_check(s: string): boolean { return true } +export function DE_SVN_check(s: string): boolean { return true } +export function ES_DNI_check(s: string): boolean { return true } +export function IN_AADHAR_check(s: string): boolean { return true } +export function IT_CF_check(s: string): boolean { + return true +} + +export function XX_SQUARE_check(s: string): boolean { + const n = parseInt(s, 10) + const r = Math.sqrt(n) + return n === r * r; +} +export function XY_PRIME_check(s: string): boolean { + const n = parseInt(s, 10) + return isPrime(n) +} + diff --git a/packages/anastasis-webui/.storybook/preview.js b/packages/anastasis-webui/.storybook/preview.js index 7cb9405ba..9ab4d9404 100644 --- a/packages/anastasis-webui/.storybook/preview.js +++ b/packages/anastasis-webui/.storybook/preview.js @@ -21,6 +21,12 @@ import { h } from 'preact'; export const parameters = { controls: { expanded: true }, + options: { + storySort: (a, b) => { + return (a[1].args.order ?? 0) - (b[1].args.order ?? 0) + // return a[1].kind === b[1].kind ? 0 : a[1].id.localeCompare(b[1].id, undefined, { numeric: true }) + } + }, } export const globalTypes = { diff --git a/packages/anastasis-webui/package.json b/packages/anastasis-webui/package.json index 57cfdd8d4..96d2d65f9 100644 --- a/packages/anastasis-webui/package.json +++ b/packages/anastasis-webui/package.json @@ -4,9 +4,9 @@ "version": "0.0.0", "license": "MIT", "scripts": { - "build": "preact build", + "build": "preact build --no-sw --no-esm", "serve": "sirv build --port 8080 --cors --single", - "dev": "preact watch", + "dev": "preact watch --no-sw --no-esm", "lint": "eslint 'src/**/*.{js,jsx,ts,tsx}'", "test": "jest ./tests", "build-storybook": "build-storybook", @@ -25,37 +25,40 @@ "dependencies": { "@gnu-taler/taler-util": "workspace:^0.8.3", "anastasis-core": "workspace:^0.0.1", + "date-fns": "2.25.0", "jed": "1.1.1", - "preact": "^10.3.1", - "preact-render-to-string": "^5.1.4", - "preact-router": "^3.2.1" + "preact": "^10.5.15", + "preact-render-to-string": "^5.1.19", + "preact-router": "^3.2.1", + "qrcode-generator": "^1.4.4" }, "devDependencies": { "@creativebulma/bulma-tooltip": "^1.2.0", - "@storybook/addon-a11y": "^6.2.9", - "@storybook/addon-actions": "^6.2.9", - "@storybook/addon-essentials": "^6.2.9", - "@storybook/addon-links": "^6.2.9", - "@storybook/preact": "^6.2.9", + "@storybook/addon-a11y": "^6.3.12", + "@storybook/addon-actions": "^6.3.12", + "@storybook/addon-essentials": "^6.3.12", + "@storybook/addon-links": "^6.3.12", + "@storybook/preact": "^6.3.12", "@storybook/preset-scss": "^1.0.3", - "@types/enzyme": "^3.10.5", - "@types/jest": "^26.0.8", - "@typescript-eslint/eslint-plugin": "^2.25.0", - "@typescript-eslint/parser": "^2.25.0", + "@types/enzyme": "^3.10.10", + "@types/jest": "^27.0.2", + "@typescript-eslint/eslint-plugin": "^5.3.0", + "@typescript-eslint/parser": "^5.3.0", "bulma": "^0.9.3", "bulma-checkbox": "^1.1.1", "bulma-radio": "^1.1.1", "enzyme": "^3.11.0", - "enzyme-adapter-preact-pure": "^3.1.0", - "eslint": "^6.8.0", - "eslint-config-preact": "^1.1.1", - "jest": "^26.2.2", - "jest-preset-preact": "^4.0.2", - "preact-cli": "^3.2.2", - "sass": "^1.32.13", - "sass-loader": "^10.1.1", - "sirv-cli": "^1.0.0-next.3", - "typescript": "^3.7.5" + "enzyme-adapter-preact-pure": "^3.2.0", + "eslint": "^8.1.0", + "eslint-config-preact": "^1.2.0", + "jest": "^27.3.1", + "jest-preset-preact": "^4.0.5", + "jssha": "^3.2.0", + "preact-cli": "^3.3.1", + "sass": "1.32.13", + "sass-loader": "^10", + "sirv-cli": "^1.0.14", + "typescript": "^4.4.4" }, "jest": { "preset": "jest-preset-preact", diff --git a/packages/anastasis-webui/src/assets/empty.png b/packages/anastasis-webui/src/assets/empty.png new file mode 100644 index 000000000..5120d3138 Binary files /dev/null and b/packages/anastasis-webui/src/assets/empty.png differ diff --git a/packages/anastasis-webui/src/assets/example/id1.jpg b/packages/anastasis-webui/src/assets/example/id1.jpg new file mode 100644 index 000000000..5d022a379 Binary files /dev/null and b/packages/anastasis-webui/src/assets/example/id1.jpg differ diff --git a/packages/anastasis-webui/src/assets/icons/auth_method/email.svg b/packages/anastasis-webui/src/assets/icons/auth_method/email.svg new file mode 100644 index 000000000..3e44b8779 --- /dev/null +++ b/packages/anastasis-webui/src/assets/icons/auth_method/email.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/anastasis-webui/src/assets/icons/auth_method/postal.svg b/packages/anastasis-webui/src/assets/icons/auth_method/postal.svg new file mode 100644 index 000000000..3787b8350 --- /dev/null +++ b/packages/anastasis-webui/src/assets/icons/auth_method/postal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/anastasis-webui/src/assets/icons/auth_method/question.svg b/packages/anastasis-webui/src/assets/icons/auth_method/question.svg new file mode 100644 index 000000000..a346556b2 --- /dev/null +++ b/packages/anastasis-webui/src/assets/icons/auth_method/question.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/anastasis-webui/src/assets/icons/auth_method/sms.svg b/packages/anastasis-webui/src/assets/icons/auth_method/sms.svg new file mode 100644 index 000000000..ed15679bf --- /dev/null +++ b/packages/anastasis-webui/src/assets/icons/auth_method/sms.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/anastasis-webui/src/assets/icons/auth_method/video.svg b/packages/anastasis-webui/src/assets/icons/auth_method/video.svg new file mode 100644 index 000000000..69de5e0b4 --- /dev/null +++ b/packages/anastasis-webui/src/assets/icons/auth_method/video.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/anastasis-webui/src/components/AsyncButton.tsx b/packages/anastasis-webui/src/components/AsyncButton.tsx new file mode 100644 index 000000000..92bef2219 --- /dev/null +++ b/packages/anastasis-webui/src/components/AsyncButton.tsx @@ -0,0 +1,49 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ + +import { ComponentChildren, h, VNode } from "preact"; +// import { LoadingModal } from "../modal"; +import { useAsync } from "../hooks/async"; +// import { Translate } from "../../i18n"; + +type Props = { + children: ComponentChildren; + disabled?: boolean; + onClick?: () => Promise; + [rest: string]: any; +}; + +export function AsyncButton({ onClick, disabled, children, ...rest }: Props): VNode { + const { isLoading, request } = useAsync(onClick); + + // if (isSlow) { + // return ; + // } + if (isLoading) { + return ; + } + + return + + ; +} diff --git a/packages/anastasis-webui/src/components/Notifications.tsx b/packages/anastasis-webui/src/components/Notifications.tsx new file mode 100644 index 000000000..c916020d7 --- /dev/null +++ b/packages/anastasis-webui/src/components/Notifications.tsx @@ -0,0 +1,59 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ + +import { h, VNode } from "preact"; + +export interface Notification { + message: string; + description?: string | VNode; + type: MessageType; +} + +export type MessageType = 'INFO' | 'WARN' | 'ERROR' | 'SUCCESS' + +interface Props { + notifications: Notification[]; + removeNotification?: (n: Notification) => void; +} + +function messageStyle(type: MessageType): string { + switch (type) { + case "INFO": return "message is-info"; + case "WARN": return "message is-warning"; + case "ERROR": return "message is-danger"; + case "SUCCESS": return "message is-success"; + default: return "message" + } +} + +export function Notifications({ notifications, removeNotification }: Props): VNode { + return
+ {notifications.map((n,i) =>
+
+

{n.message}

+
+ {n.description &&
+ {n.description} +
} +
)} +
+} \ No newline at end of file diff --git a/packages/anastasis-webui/src/components/QR.tsx b/packages/anastasis-webui/src/components/QR.tsx new file mode 100644 index 000000000..48f1a7c12 --- /dev/null +++ b/packages/anastasis-webui/src/components/QR.tsx @@ -0,0 +1,35 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +import { h, VNode } from "preact"; +import { useEffect, useRef } from "preact/hooks"; +import qrcode from "qrcode-generator"; + +export function QR({ text }: { text: string }): VNode { + const divRef = useRef(null); + useEffect(() => { + const qr = qrcode(0, 'L'); + qr.addData(text); + qr.make(); + if (divRef.current) divRef.current.innerHTML = qr.createSvgTag({ + scalable: true, + }); + }); + + return
+
+
; +} diff --git a/packages/anastasis-webui/src/components/fields/DateInput.tsx b/packages/anastasis-webui/src/components/fields/DateInput.tsx new file mode 100644 index 000000000..3148c953f --- /dev/null +++ b/packages/anastasis-webui/src/components/fields/DateInput.tsx @@ -0,0 +1,74 @@ +import { format, isAfter, parse, sub, subYears } from "date-fns"; +import { h, VNode } from "preact"; +import { useLayoutEffect, useRef, useState } from "preact/hooks"; +import { DatePicker } from "../picker/DatePicker"; + +export interface DateInputProps { + label: string; + grabFocus?: boolean; + tooltip?: string; + error?: string; + years?: Array; + bind: [string, (x: string) => void]; +} + +export function DateInput(props: DateInputProps): VNode { + const inputRef = useRef(null); + useLayoutEffect(() => { + if (props.grabFocus) { + inputRef.current?.focus(); + } + }, [props.grabFocus]); + const [opened, setOpened] = useState(false) + + const value = props.bind[0] || ""; + const [dirty, setDirty] = useState(false) + const showError = dirty && props.error + + const calendar = subYears(new Date(), 30) + + return
+ +
+
+

+ { + const text = e.currentTarget.value + setDirty(true) + props.bind[1](text); + }} + ref={inputRef} /> +

+

+ { setOpened(true) }}> + + +

+
+
+

Using the format yyyy-mm-dd

+ {showError &&

{props.error}

} + setOpened(false)} + dateReceiver={(d) => { + setDirty(true) + const v = format(d, 'yyyy-MM-dd') + props.bind[1](v); + }} + /> +
+ ; + +} diff --git a/packages/anastasis-webui/src/components/fields/EmailInput.tsx b/packages/anastasis-webui/src/components/fields/EmailInput.tsx new file mode 100644 index 000000000..e21418fea --- /dev/null +++ b/packages/anastasis-webui/src/components/fields/EmailInput.tsx @@ -0,0 +1,44 @@ +import { h, VNode } from "preact"; +import { useLayoutEffect, useRef, useState } from "preact/hooks"; + +export interface TextInputProps { + label: string; + grabFocus?: boolean; + error?: string; + placeholder?: string; + tooltip?: string; + bind: [string, (x: string) => void]; +} + +export function EmailInput(props: TextInputProps): VNode { + const inputRef = useRef(null); + useLayoutEffect(() => { + if (props.grabFocus) { + inputRef.current?.focus(); + } + }, [props.grabFocus]); + const value = props.bind[0]; + const [dirty, setDirty] = useState(false) + const showError = dirty && props.error + return (
+ +
+ {setDirty(true); props.bind[1]((e.target as HTMLInputElement).value)}} + ref={inputRef} + style={{ display: "block" }} /> +
+ {showError &&

{props.error}

} +
+ ); +} diff --git a/packages/anastasis-webui/src/components/fields/FileInput.tsx b/packages/anastasis-webui/src/components/fields/FileInput.tsx new file mode 100644 index 000000000..8b144ea43 --- /dev/null +++ b/packages/anastasis-webui/src/components/fields/FileInput.tsx @@ -0,0 +1,81 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ +import { h, VNode } from "preact"; +import { useLayoutEffect, useRef, useState } from "preact/hooks"; +import { TextInputProps } from "./TextInput"; + +const MAX_IMAGE_UPLOAD_SIZE = 1024 * 1024 + +export function FileInput(props: TextInputProps): VNode { + const inputRef = useRef(null); + useLayoutEffect(() => { + if (props.grabFocus) { + inputRef.current?.focus(); + } + }, [props.grabFocus]); + + const value = props.bind[0]; + // const [dirty, setDirty] = useState(false) + const image = useRef(null) + const [sizeError, setSizeError] = useState(false) + function onChange(v: string): void { + // setDirty(true); + props.bind[1](v); + } + return
+ +
+ { + const f: FileList | null = e.currentTarget.files + if (!f || f.length != 1) { + return onChange("") + } + if (f[0].size > MAX_IMAGE_UPLOAD_SIZE) { + setSizeError(true) + return onChange("") + } + setSizeError(false) + return f[0].arrayBuffer().then(b => { + const b64 = btoa( + new Uint8Array(b) + .reduce((data, byte) => data + String.fromCharCode(byte), '') + ) + return onChange(`data:${f[0].type};base64,${b64}` as any) + }) + }} /> + {props.error &&

{props.error}

} + {sizeError &&

+ File should be smaller than 1 MB +

} +
+
+} + diff --git a/packages/anastasis-webui/src/components/fields/ImageInput.tsx b/packages/anastasis-webui/src/components/fields/ImageInput.tsx new file mode 100644 index 000000000..d5bf643d4 --- /dev/null +++ b/packages/anastasis-webui/src/components/fields/ImageInput.tsx @@ -0,0 +1,81 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ +import { h, VNode } from "preact"; +import { useLayoutEffect, useRef, useState } from "preact/hooks"; +import emptyImage from "../../assets/empty.png"; +import { TextInputProps } from "./TextInput"; + +const MAX_IMAGE_UPLOAD_SIZE = 1024 * 1024 + +export function ImageInput(props: TextInputProps): VNode { + const inputRef = useRef(null); + useLayoutEffect(() => { + if (props.grabFocus) { + inputRef.current?.focus(); + } + }, [props.grabFocus]); + + const value = props.bind[0]; + // const [dirty, setDirty] = useState(false) + const image = useRef(null) + const [sizeError, setSizeError] = useState(false) + function onChange(v: string): void { + // setDirty(true); + props.bind[1](v); + } + return
+ +
+ image.current?.click()} /> + { + const f: FileList | null = e.currentTarget.files + if (!f || f.length != 1) { + return onChange(emptyImage) + } + if (f[0].size > MAX_IMAGE_UPLOAD_SIZE) { + setSizeError(true) + return onChange(emptyImage) + } + setSizeError(false) + return f[0].arrayBuffer().then(b => { + const b64 = btoa( + new Uint8Array(b) + .reduce((data, byte) => data + String.fromCharCode(byte), '') + ) + return onChange(`data:${f[0].type};base64,${b64}` as any) + }) + }} /> + {props.error &&

{props.error}

} + {sizeError &&

+ Image should be smaller than 1 MB +

} +
+
+} + diff --git a/packages/anastasis-webui/src/components/fields/NumberInput.tsx b/packages/anastasis-webui/src/components/fields/NumberInput.tsx new file mode 100644 index 000000000..2afb242b8 --- /dev/null +++ b/packages/anastasis-webui/src/components/fields/NumberInput.tsx @@ -0,0 +1,43 @@ +import { h, VNode } from "preact"; +import { useLayoutEffect, useRef, useState } from "preact/hooks"; + +export interface TextInputProps { + label: string; + grabFocus?: boolean; + error?: string; + placeholder?: string; + tooltip?: string; + bind: [string, (x: string) => void]; +} + +export function NumberInput(props: TextInputProps): VNode { + const inputRef = useRef(null); + useLayoutEffect(() => { + if (props.grabFocus) { + inputRef.current?.focus(); + } + }, [props.grabFocus]); + const value = props.bind[0]; + const [dirty, setDirty] = useState(false) + const showError = dirty && props.error + return (
+ +
+ {setDirty(true); props.bind[1]((e.target as HTMLInputElement).value)}} + ref={inputRef} + style={{ display: "block" }} /> +
+ {showError &&

{props.error}

} +
+ ); +} diff --git a/packages/anastasis-webui/src/components/fields/TextInput.tsx b/packages/anastasis-webui/src/components/fields/TextInput.tsx new file mode 100644 index 000000000..c093689c5 --- /dev/null +++ b/packages/anastasis-webui/src/components/fields/TextInput.tsx @@ -0,0 +1,42 @@ +import { h, VNode } from "preact"; +import { useLayoutEffect, useRef, useState } from "preact/hooks"; + +export interface TextInputProps { + label: string; + grabFocus?: boolean; + error?: string; + placeholder?: string; + tooltip?: string; + bind: [string, (x: string) => void]; +} + +export function TextInput(props: TextInputProps): VNode { + const inputRef = useRef(null); + useLayoutEffect(() => { + if (props.grabFocus) { + inputRef.current?.focus(); + } + }, [props.grabFocus]); + const value = props.bind[0]; + const [dirty, setDirty] = useState(false) + const showError = dirty && props.error + return (
+ +
+ {setDirty(true); props.bind[1]((e.target as HTMLInputElement).value)}} + ref={inputRef} + style={{ display: "block" }} /> +
+ {showError &&

{props.error}

} +
+ ); +} diff --git a/packages/anastasis-webui/src/components/menu/NavigationBar.tsx b/packages/anastasis-webui/src/components/menu/NavigationBar.tsx index e1bb4c7c0..935951ab9 100644 --- a/packages/anastasis-webui/src/components/menu/NavigationBar.tsx +++ b/packages/anastasis-webui/src/components/menu/NavigationBar.tsx @@ -49,7 +49,7 @@ export function NavigationBar({ onMobileMenu, title }: Props): VNode {
diff --git a/packages/anastasis-webui/src/components/menu/SideBar.tsx b/packages/anastasis-webui/src/components/menu/SideBar.tsx index df582a5d0..72655662f 100644 --- a/packages/anastasis-webui/src/components/menu/SideBar.tsx +++ b/packages/anastasis-webui/src/components/menu/SideBar.tsx @@ -33,14 +33,15 @@ interface Props { export function Sidebar({ mobile }: Props): VNode { // const config = useConfigContext(); const config = { version: 'none' } + // FIXME: add replacement for __VERSION__ with the current version const process = { env: { __VERSION__: '0.0.0' } } const reducer = useAnastasisContext()! return (