summaryrefslogtreecommitdiff
path: root/packages/anastasis-core
diff options
context:
space:
mode:
authorFlorian Dold <florian@dold.me>2021-11-02 16:20:39 +0100
committerFlorian Dold <florian@dold.me>2021-11-02 16:20:46 +0100
commitaa78c1105e7b6b74d6185cc33daa42f93ccbea58 (patch)
tree59a9a41b918c03ae7a272bc48227963b2ebeb5e6 /packages/anastasis-core
parenta4cdc02e5017ba587c169cb28a7e7927fc64c7cf (diff)
downloadwallet-core-aa78c1105e7b6b74d6185cc33daa42f93ccbea58.tar.gz
wallet-core-aa78c1105e7b6b74d6185cc33daa42f93ccbea58.tar.bz2
wallet-core-aa78c1105e7b6b74d6185cc33daa42f93ccbea58.zip
anastasis-core: provide reducer CLI, refactor state machine
Diffstat (limited to 'packages/anastasis-core')
-rwxr-xr-xpackages/anastasis-core/bin/anastasis-ts-reducer.js14
-rw-r--r--packages/anastasis-core/package.json16
-rw-r--r--packages/anastasis-core/rollup.config.js30
-rw-r--r--packages/anastasis-core/src/cli.ts64
-rw-r--r--packages/anastasis-core/src/index.node.ts2
-rw-r--r--packages/anastasis-core/src/index.ts835
-rw-r--r--packages/anastasis-core/src/reducer-types.ts83
7 files changed, 646 insertions, 398 deletions
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..59998c93b
--- /dev/null
+++ b/packages/anastasis-core/rollup.config.js
@@ -0,0 +1,30 @@
+// 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";
+
+export default {
+ 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(),
+ ],
+};
diff --git a/packages/anastasis-core/src/cli.ts b/packages/anastasis-core/src/cli.ts
new file mode 100644
index 000000000..5ab7af6db
--- /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 the GNU Taler wallet.",
+ })
+ .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<string> {
+ 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/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 c9e2bcf36..07f8122e3 100644
--- a/packages/anastasis-core/src/index.ts
+++ b/packages/anastasis-core/src/index.ts
@@ -9,6 +9,8 @@ import {
encodeCrock,
getRandomBytes,
hash,
+ j2s,
+ Logger,
stringToBytes,
TalerErrorCode,
TalerSignaturePurpose,
@@ -26,12 +28,22 @@ import {
ActionArgEnterSecret,
ActionArgEnterSecretName,
ActionArgEnterUserAttributes,
+ ActionArgsAddPolicy,
+ ActionArgSelectContinent,
+ ActionArgSelectCountry,
ActionArgsSelectChallenge,
ActionArgsSolveChallengeRequest,
+ ActionArgsUpdateExpiration,
AuthenticationProviderStatus,
AuthenticationProviderStatusOk,
AuthMethod,
BackupStates,
+ codecForActionArgEnterUserAttributes,
+ codecForActionArgsAddPolicy,
+ codecForActionArgSelectChallenge,
+ codecForActionArgSelectContinent,
+ codecForActionArgSelectCountry,
+ codecForActionArgsUpdateExpiration,
ContinentInfo,
CountryInfo,
MethodSpec,
@@ -46,6 +58,7 @@ import {
ReducerStateError,
ReducerStateRecovery,
SuccessDetails,
+ UserAttributeSpec,
} from "./reducer-types.js";
import fetchPonyfill from "fetch-ponyfill";
import {
@@ -61,8 +74,6 @@ import {
PolicySalt,
TruthSalt,
secureAnswerHash,
- TruthKey,
- TruthUuid,
UserIdentifier,
userIdentifierDerive,
typedArrayConcat,
@@ -74,10 +85,12 @@ import {
import { unzlibSync, zlibSync } from "fflate";
import { EscrowMethod, RecoveryDocument } from "./recovery-document-types.js";
-const { fetch, Request, Response, Headers } = fetchPonyfill({});
+const { fetch } = fetchPonyfill({});
export * from "./reducer-types.js";
-export * as validators from './validators.js';
+export * as validators from "./validators.js";
+
+const logger = new Logger("anastasis-core:index.ts");
function getContinents(): ContinentInfo[] {
const continentSet = new Set<string>();
@@ -95,10 +108,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<ReducerStateBackup> {
@@ -115,19 +158,27 @@ export async function getRecoveryStartState(): Promise<ReducerStateRecovery> {
};
}
-async function backupSelectCountry(
- state: ReducerStateBackup,
- countryCode: string,
- currencies: string[],
-): Promise<ReducerStateError | ReducerStateBackupUserAttributesCollecting> {
+async function selectCountry(
+ selectedContinent: string,
+ args: ActionArgSelectCountry,
+): Promise<Partial<ReducerStateBackup> & Partial<ReducerStateRecovery>> {
+ 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]: {} } = {};
@@ -141,8 +192,6 @@ async function backupSelectCountry(
.required_attributes;
return {
- ...state,
- backup_state: BackupStates.UserAttributesCollecting,
selected_country: countryCode,
currencies,
required_attributes: ra,
@@ -150,38 +199,25 @@ async function backupSelectCountry(
};
}
+async function backupSelectCountry(
+ state: ReducerStateBackup,
+ args: ActionArgSelectCountry,
+): Promise<ReducerStateError | ReducerStateBackup> {
+ return {
+ ...state,
+ ...(await selectCountry(state.selected_continent!, args)),
+ backup_state: BackupStates.UserAttributesCollecting,
+ };
+}
+
async function recoverySelectCountry(
state: ReducerStateRecovery,
- countryCode: string,
- currencies: string[],
+ args: ActionArgSelectCountry,
): Promise<ReducerStateError | ReducerStateRecovery> {
- 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)),
};
}
@@ -231,8 +267,9 @@ async function getProviderInfo(
async function backupEnterUserAttributes(
state: ReducerStateBackup,
- attributes: Record<string, string>,
+ args: ActionArgEnterUserAttributes,
): Promise<ReducerStateBackup> {
+ const attributes = args.identity_attributes;
const providerUrls = Object.keys(state.authentication_providers ?? {});
const newProviders = state.authentication_providers ?? {};
for (const url of providerUrls) {
@@ -336,7 +373,7 @@ function suggestPolicies(
}
const policies: Policy[] = [];
const selections = enumerateSelections(numSel, numMethods);
- console.log("selections", selections);
+ logger.info(`selections: ${j2s(selections)}`);
for (const sel of selections) {
const p = assignProviders(methods, providers, sel);
if (p) {
@@ -409,7 +446,7 @@ async function getTruthValue(
* Compress the recovery document and add a size header.
*/
async function compressRecoveryDoc(rd: any): Promise<Uint8Array> {
- console.log("recovery document", rd);
+ logger.info(`recovery document: ${j2s(rd)}`);
const docBytes = stringToBytes(JSON.stringify(rd));
const sizeHeaderBuf = new ArrayBuffer(4);
const dvbuf = new DataView(sizeHeaderBuf);
@@ -509,10 +546,6 @@ async function uploadSecret(
? bytesToString(decodeCrock(authMethod.challenge))
: undefined,
);
- console.log(
- "encrypted key share len",
- decodeCrock(encryptedKeyShare).length,
- );
const tur: TruthUploadRequest = {
encrypted_truth: encryptedTruth,
key_share_data: encryptedKeyShare,
@@ -550,8 +583,6 @@ async function uploadSecret(
// 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,
@@ -662,7 +693,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");
@@ -683,7 +713,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!,
@@ -777,8 +806,6 @@ async function solveChallenge(
},
});
- console.log(resp);
-
if (resp.status !== 200) {
return {
code: TalerErrorCode.ANASTASIS_TRUTH_CHALLENGE_FAILED,
@@ -825,12 +852,12 @@ async function solveChallenge(
async function recoveryEnterUserAttributes(
state: ReducerStateRecovery,
- attributes: Record<string, string>,
+ args: ActionArgEnterUserAttributes,
): Promise<ReducerStateRecovery | ReducerStateError> {
// FIXME: validate attributes
const st: ReducerStateRecovery = {
...state,
- identity_attributes: attributes,
+ identity_attributes: args.identity_attributes,
};
return downloadPolicy(st);
}
@@ -853,8 +880,6 @@ async function selectChallenge(
},
});
- console.log(resp);
-
return {
...state,
recovery_state: RecoveryStates.ChallengeSolving,
@@ -862,352 +887,386 @@ async function selectChallenge(
};
}
-export async function reduceAction(
- state: ReducerState,
- action: string,
- args: any,
-): Promise<ReducerState> {
- 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}'`,
- };
- }
- }
- 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 (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}'`,
- };
- }
- }
- 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<string, AmountString> = {};
- 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}'`,
- };
- }
+async function backupSelectContinent(
+ state: ReducerStateBackup,
+ args: ActionArgSelectContinent,
+): Promise<ReducerStateBackup | ReducerStateError> {
+ const countries = getCountries(args.continent);
+ if (countries.length <= 0) {
+ return {
+ code: TalerErrorCode.ANASTASIS_REDUCER_INPUT_INVALID,
+ hint: "continent not found",
+ };
}
- 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}'`,
- };
+ return {
+ ...state,
+ backup_state: BackupStates.CountrySelecting,
+ countries,
+ selected_continent: args.continent,
+ };
+}
+
+async function recoverySelectContinent(
+ state: ReducerStateRecovery,
+ args: ActionArgSelectContinent,
+): Promise<ReducerStateRecovery | ReducerStateError> {
+ const countries = getCountries(args.continent);
+ return {
+ ...state,
+ recovery_state: RecoveryStates.CountrySelecting,
+ countries,
+ selected_continent: args.continent,
+ };
+}
+
+interface TransitionImpl<S, T> {
+ argCodec: Codec<T>;
+ handler: (s: S, args: T) => Promise<S | ReducerStateError>;
+}
+
+interface Transition<S, T> {
+ [x: string]: TransitionImpl<S, T>;
+}
+
+function transition<S, T>(
+ action: string,
+ argCodec: Codec<T>,
+ handler: (s: S, args: T) => Promise<S | ReducerStateError>,
+): Transition<S, T> {
+ return {
+ [action]: {
+ argCodec,
+ handler,
+ },
+ };
+}
+
+function transitionBackupJump(
+ action: string,
+ st: BackupStates,
+): Transition<ReducerStateBackup, void> {
+ return {
+ [action]: {
+ argCodec: codecForAny(),
+ handler: async (s, a) => ({ ...s, backup_state: st }),
+ },
+ };
+}
+
+function transitionRecoveryJump(
+ action: string,
+ st: RecoveryStates,
+): Transition<ReducerStateRecovery, void> {
+ return {
+ [action]: {
+ argCodec: codecForAny(),
+ handler: async (s, a) => ({ ...s, recovery_state: st }),
+ },
+ };
+}
+
+async function addAuthentication(
+ state: ReducerStateBackup,
+ args: ActionArgAddAuthentication,
+): Promise<ReducerStateBackup> {
+ return {
+ ...state,
+ authentication_methods: [
+ ...(state.authentication_methods ?? []),
+ args.authentication_method,
+ ],
+ };
+}
+
+async function deleteAuthentication(
+ state: ReducerStateBackup,
+ args: ActionArgDeleteAuthentication,
+): Promise<ReducerStateBackup> {
+ const m = state.authentication_methods ?? [];
+ m.splice(args.authentication_method, 1);
+ return {
+ ...state,
+ authentication_methods: m,
+ };
+}
+
+async function deletePolicy(
+ state: ReducerStateBackup,
+ args: ActionArgDeletePolicy,
+): Promise<ReducerStateBackup> {
+ const policies = [...(state.policies ?? [])];
+ policies.splice(args.policy_index, 1);
+ return {
+ ...state,
+ policies,
+ };
+}
+
+async function addPolicy(
+ state: ReducerStateBackup,
+ args: ActionArgsAddPolicy,
+): Promise<ReducerStateBackup> {
+ return {
+ ...state,
+ policies: [
+ ...(state.policies ?? []),
+ {
+ methods: args.policy,
+ },
+ ],
+ };
+}
+
+async function nextFromAuthenticationsEditing(
+ state: ReducerStateBackup,
+ args: {},
+): Promise<ReducerStateBackup | ReducerStateError> {
+ 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 (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 (!("http_status" in prov && prov.http_status === 200)) {
+ continue;
}
- }
- 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}'`,
- };
+ const methodCost: Record<string, AmountString> = {};
+ 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,
+ };
+}
- 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}'`,
- };
+async function updateUploadFees(
+ state: ReducerStateBackup,
+): Promise<ReducerStateBackup | ReducerStateError> {
+ for (const prov of state.policy_providers ?? []) {
+ const info = state.authentication_providers![prov.provider_url];
+ if (!("currency" in info)) {
+ continue;
}
}
+ return { ...state, upload_fees: [] };
+}
- 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}'`,
- };
- }
- }
+async function enterSecret(
+ state: ReducerStateBackup,
+ args: ActionArgEnterSecret,
+): Promise<ReducerStateBackup | ReducerStateError> {
+ return {
+ ...state,
+ expiration: args.expiration,
+ core_secret: {
+ mime: args.secret.mime ?? "text/plain",
+ value: args.secret.value,
+ },
+ };
+}
- 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}'`,
- };
- }
+async function nextFromChallengeSelecting(
+ state: ReducerStateRecovery,
+ args: void,
+): Promise<ReducerStateRecovery | ReducerStateError> {
+ 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",
+ };
+}
- 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}'`,
- };
- }
- }
+async function enterSecretName(
+ state: ReducerStateBackup,
+ args: ActionArgEnterSecretName,
+): Promise<ReducerStateBackup | ReducerStateError> {
+ return {
+ ...state,
+ secret_name: args.name,
+ };
+}
- 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}'`,
- };
- }
- }
+async function updateSecretExpiration(
+ state: ReducerStateBackup,
+ args: ActionArgsUpdateExpiration,
+): Promise<ReducerStateBackup | ReducerStateError> {
+ // FIXME: implement!
+ return {
+ ...state,
+ expiration: args.expiration,
+ };
+}
- 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}'`,
- };
- }
- }
+const backupTransitions: Record<
+ BackupStates,
+ Transition<ReducerStateBackup, any>
+> = {
+ [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",
+ codecForActionArgEnterUserAttributes(),
+ 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),
+ },
+ [BackupStates.SecretEditing]: {
+ ...transitionBackupJump("back", BackupStates.PoliciesPaying),
+ ...transition("next", codecForAny(), uploadSecret),
+ ...transition("enter_secret", codecForAny(), enterSecret),
+ ...transition(
+ "update_expiration",
+ codecForActionArgsUpdateExpiration(),
+ updateSecretExpiration,
+ ),
+ ...transition("enter_secret_name", codecForAny(), enterSecretName),
+ },
+ [BackupStates.PoliciesPaying]: {},
+ [BackupStates.TruthsPaying]: {},
+ [BackupStates.PoliciesPaying]: {},
+ [BackupStates.BackupFinished]: {
+ ...transitionBackupJump("back", BackupStates.SecretEditing),
+ },
+};
+
+const recoveryTransitions: Record<
+ RecoveryStates,
+ Transition<ReducerStateRecovery, any>
+> = {
+ [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",
+ codecForActionArgEnterUserAttributes(),
+ recoveryEnterUserAttributes,
+ ),
+ },
+ [RecoveryStates.SecretSelecting]: {
+ ...transitionRecoveryJump("back", RecoveryStates.UserAttributesCollecting),
+ ...transitionRecoveryJump("next", RecoveryStates.ChallengeSelecting),
+ },
+ [RecoveryStates.ChallengeSelecting]: {
+ ...transitionRecoveryJump("back", RecoveryStates.SecretSelecting),
+ ...transition(
+ "select_challenge",
+ codecForActionArgSelectChallenge(),
+ selectChallenge,
+ ),
+ ...transition("next", codecForAny(), nextFromChallengeSelecting),
+ },
+ [RecoveryStates.ChallengeSolving]: {
+ ...transitionRecoveryJump("back", RecoveryStates.ChallengeSelecting),
+ ...transition("solve_challenge", codecForAny(), solveChallenge),
+ },
+ [RecoveryStates.ChallengePaying]: {},
+ [RecoveryStates.RecoveryFinished]: {},
+};
- 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}'`,
- };
+export async function reduceAction(
+ state: ReducerState,
+ action: string,
+ args: any,
+): Promise<ReducerState> {
+ let h: TransitionImpl<any, any>;
+ 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 (!h) {
+ return {
+ code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
+ hint: `Unsupported action '${action}' in state '${stateName}'`,
+ };
+ }
+ 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(),
+ };
+ }
+ try {
+ return await h.handler(state, parsedArgs);
+ } catch (e) {
+ logger.error("action handler failed");
+ if (e instanceof ReducerError) {
+ return e.errorJson;
}
+ throw e;
}
-
- return {
- code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
- hint: "Reducer action invalid",
- };
}
diff --git a/packages/anastasis-core/src/reducer-types.ts b/packages/anastasis-core/src/reducer-types.ts
index 57f67f0d0..03883ce17 100644
--- a/packages/anastasis-core/src/reducer-types.ts
+++ b/packages/anastasis-core/src/reducer-types.ts
@@ -1,4 +1,14 @@
-import { Duration, Timestamp } from "@gnu-taler/taler-util";
+import {
+ AmountString,
+ buildCodecForObject,
+ codecForAny,
+ codecForList,
+ codecForNumber,
+ codecForString,
+ codecForTimestamp,
+ Duration,
+ Timestamp,
+} from "@gnu-taler/taler-util";
import { KeyShare } from "./crypto.js";
import { RecoveryDocument } from "./recovery-document-types.js";
@@ -23,7 +33,7 @@ export interface Policy {
authentication_method: number;
provider: string;
}[];
-}
+}
export interface PolicyProvider {
provider_url: string;
@@ -70,7 +80,9 @@ export interface ReducerStateBackup {
core_secret?: CoreSecret;
- expiration?: Duration;
+ expiration?: Timestamp;
+
+ upload_fees?: AmountString[];
}
export interface AuthMethod {
@@ -94,8 +106,8 @@ export interface UserAttributeSpec {
uuid: string;
widget: string;
optional?: boolean;
- 'validation-regex': string | undefined;
- 'validation-logic': string | undefined;
+ "validation-regex": string | undefined;
+ "validation-logic": string | undefined;
}
export interface RecoveryInternalData {
@@ -244,6 +256,11 @@ export interface ActionArgEnterUserAttributes {
identity_attributes: Record<string, string>;
}
+export const codecForActionArgEnterUserAttributes = () =>
+ buildCodecForObject<ActionArgEnterUserAttributes>()
+ .property("identity_attributes", codecForAny())
+ .build("ActionArgEnterUserAttributes");
+
export interface ActionArgAddAuthentication {
authentication_method: {
type: string;
@@ -270,15 +287,69 @@ export interface ActionArgEnterSecret {
value: string;
mime?: string;
};
- expiration: Duration;
+ expiration: Timestamp;
+}
+
+export interface ActionArgSelectContinent {
+ continent: string;
}
+export const codecForActionArgSelectContinent = () =>
+ buildCodecForObject<ActionArgSelectContinent>()
+ .property("continent", codecForString())
+ .build("ActionArgSelectContinent");
+
+export interface ActionArgSelectCountry {
+ country_code: string;
+ currencies: string[];
+}
+
+export const codecForActionArgSelectCountry = () =>
+ buildCodecForObject<ActionArgSelectCountry>()
+ .property("country_code", codecForString())
+ .property("currencies", codecForList(codecForString()))
+ .build("ActionArgSelectCountry");
+
export interface ActionArgsSelectChallenge {
uuid: string;
}
+export const codecForActionArgSelectChallenge = () =>
+ buildCodecForObject<ActionArgsSelectChallenge>()
+ .property("uuid", codecForString())
+ .build("ActionArgSelectChallenge");
+
export type ActionArgsSolveChallengeRequest = SolveChallengeAnswerRequest;
export interface SolveChallengeAnswerRequest {
answer: string;
}
+
+export interface PolicyMember {
+ authentication_method: number;
+ provider: string;
+}
+
+export interface ActionArgsAddPolicy {
+ policy: PolicyMember[];
+}
+
+export const codecForPolicyMember = () =>
+ buildCodecForObject<PolicyMember>()
+ .property("authentication_method", codecForNumber())
+ .property("provider", codecForString())
+ .build("PolicyMember");
+
+export const codecForActionArgsAddPolicy = () =>
+ buildCodecForObject<ActionArgsAddPolicy>()
+ .property("policy", codecForList(codecForPolicyMember()))
+ .build("ActionArgsAddPolicy");
+
+export interface ActionArgsUpdateExpiration {
+ expiration: Timestamp;
+}
+
+export const codecForActionArgsUpdateExpiration = () =>
+ buildCodecForObject<ActionArgsUpdateExpiration>()
+ .property("expiration", codecForTimestamp)
+ .build("ActionArgsUpdateExpiration");