summaryrefslogtreecommitdiff
path: root/packages/anastasis-core
diff options
context:
space:
mode:
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.js56
-rw-r--r--packages/anastasis-core/src/challenge-feedback-types.ts168
-rw-r--r--packages/anastasis-core/src/cli-entry.ts15
-rw-r--r--packages/anastasis-core/src/cli.ts64
-rw-r--r--packages/anastasis-core/src/crypto.ts9
-rw-r--r--packages/anastasis-core/src/index.node.ts2
-rw-r--r--packages/anastasis-core/src/index.ts1615
-rw-r--r--packages/anastasis-core/src/policy-suggestion.ts230
-rw-r--r--packages/anastasis-core/src/provider-types.ts13
-rw-r--r--packages/anastasis-core/src/recovery-document-types.ts13
-rw-r--r--packages/anastasis-core/src/reducer-types.ts226
-rw-r--r--packages/anastasis-core/src/validators.ts28
14 files changed, 1781 insertions, 688 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..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<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/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, FlavorT extends string> = 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<SecureAnswerHash> {
+ 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<string>();
@@ -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<ReducerStateBackup> {
@@ -114,19 +175,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: ActionArgsSelectCountry,
+): 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]: {} } = {};
@@ -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<ReducerStateError | ReducerStateBackup> {
+ return {
+ ...state,
+ ...(await selectCountry(state.selected_continent!, args)),
+ backup_state: BackupStates.UserAttributesCollecting,
+ };
+}
+
async function recoverySelectCountry(
state: ReducerStateRecovery,
- countryCode: string,
- currencies: string[],
+ args: ActionArgsSelectCountry,
): 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)),
};
}
@@ -230,8 +284,9 @@ async function getProviderInfo(
async function backupEnterUserAttributes(
state: ReducerStateBackup,
- attributes: Record<string, string>,
+ args: ActionArgsEnterUserAttributes,
): Promise<ReducerStateBackup> {
+ 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<string, AmountString>;
-}
-
-/**
- * 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<Uint8Array> {
- 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<any> {
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<ReducerStateBackup | ReducerStateError> {
+): Promise<ReducerStateBackup> {
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<string, TruthMetaData> = {};
@@ -472,17 +398,6 @@ async function uploadSecret(
const csr = await coreSecretEncrypt(policyKeys, coreSecret);
- const uidMap: Record<string, UserIdentifier> = {};
- 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,24 +409,92 @@ async function uploadSecret(
const provider = state.authentication_providers![
meth.provider
] as AuthenticationProviderStatusOk;
+ escrowMethods.push({
+ escrow_type: authMethod.type as any,
+ instructions: authMethod.instructions,
+ provider_salt: provider.salt,
+ truth_salt: tm.truth_salt,
+ truth_key: tm.truth_key,
+ url: meth.provider,
+ uuid: tm.uuid,
+ });
+ }
+
+ const rd: RecoveryDocument = {
+ secret_name: secretName,
+ encrypted_core_secret: csr.encCoreSecret,
+ escrow_methods: escrowMethods,
+ policies: policies.map((x, i) => {
+ return {
+ master_key: csr.encMasterKeys[i],
+ uuids: policyUuids[i],
+ salt: policySalts[i],
+ };
+ }),
+ };
+
+ return {
+ ...state,
+ recovery_data: {
+ recovery_document: rd,
+ truth_metadata: truthMetadataMap,
+ },
+ };
+}
+
+async function uploadSecret(
+ state: ReducerStateBackup,
+): Promise<ReducerStateBackup | ReducerStateError> {
+ 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<string, string> = {};
+
+ const userIdCache: Record<string, UserIdentifier> = {};
+ 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,
);
- const uid = uidMap[meth.provider];
+ logger.info(`uploading truth to ${meth.provider}`);
+ const userId = await getUserIdCaching(meth.provider);
const encryptedKeyShare = await encryptKeyshare(
tm.key_share,
- uid,
+ userId,
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,
@@ -519,59 +502,74 @@ async function uploadSecret(
type: authMethod.type,
truth_mime: authMethod.mime_type,
};
- const resp = await fetch(new URL(`truth/${tm.uuid}`, meth.provider).href, {
+ 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 !== 204) {
- return {
- code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED,
- hint: "could not upload policy",
- };
+ if (resp.status === HttpStatusCode.NoContent) {
+ continue;
}
-
- escrowMethods.push({
- escrow_type: authMethod.type,
- instructions: authMethod.instructions,
- provider_salt: provider.salt,
- truth_salt: tm.truth_salt,
- truth_key: tm.truth_key,
- url: meth.provider,
- uuid: tm.uuid,
- });
+ 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})`,
+ };
}
- // 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,
- escrow_methods: escrowMethods,
- policies: policies.map((x, i) => {
- return {
- master_key: csr.encMasterKeys[i],
- uuids: policyUuids[i],
- salt: policySalts[i],
- };
- }),
- };
+ 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<string, string> = {};
+ //const policyPaySecrets: Record<string, string> = {};
+
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),
+ const talerPayUri = state.policy_payment_requests?.find(
+ (x) => x.provider === prov.provider_url,
+ )?.payto;
+ let paySecret: string | undefined;
+ if (talerPayUri) {
+ paySecret = parsePayUri(talerPayUri)!.orderId;
+ }
+ 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,
+ }
+ : {}),
},
- );
- if (resp.status !== 204) {
- return {
- code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED,
- hint: "could not upload policy",
+ 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,
};
+ continue;
}
- 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,
+ 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,25 +810,92 @@ async function tryRecoverSecret(
return { ...state };
}
-async function solveChallenge(
+/**
+ * Re-check the status of challenges that are solved asynchronously.
+ */
+async function pollChallenges(
state: ReducerStateRecovery,
- ta: ActionArgsSolveChallengeRequest,
+ args: void,
): Promise<ReducerStateRecovery | ReducerStateError> {
- const recDoc: RecoveryDocument = state.verbatim_recovery_document!;
- const truth = recDoc.escrow_methods.find(
- (x) => x.uuid === state.selected_challenge_uuid,
- );
- if (!truth) {
- throw "truth for challenge not found";
+ 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<ReducerStateRecovery | ReducerStateError> {
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),
- );
+ 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: {
@@ -776,60 +903,140 @@ async function solveChallenge(
},
});
- console.log(resp);
+ logger.info(
+ `got GET /truth response from ${truth.url}, http status ${resp.status}`,
+ );
- if (resp.status !== 200) {
- return {
- code: TalerErrorCode.ANASTASIS_TRUTH_CHALLENGE_FAILED,
- hint: "got non-200 response",
- http_status: resp.status,
- } as ReducerStateError;
- }
+ if (resp.status === HttpStatusCode.Ok) {
+ let answerSalt: string | undefined = undefined;
+ if (
+ solveRequest &&
+ truth.escrow_type === "question" &&
+ "answer" in solveRequest
+ ) {
+ answerSalt = solveRequest.answer;
+ }
- const answerSalt = truth.escrow_type === "question" ? ta.answer : undefined;
+ const userId = await userIdentifierDerive(
+ state.identity_attributes,
+ truth.provider_salt,
+ );
- 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 respBody = new Uint8Array(await resp.arrayBuffer());
- const keyShare = await decryptKeyShare(
- encodeCrock(respBody),
- userId,
- answerSalt,
- );
+ const recoveredKeyShares = {
+ ...(state.recovered_key_shares ?? {}),
+ [truth.uuid]: keyShare,
+ };
- const recoveredKeyShares = {
- ...(state.recovered_key_shares ?? {}),
- [truth.uuid]: keyShare,
- };
+ const challengeFeedback: { [x: string]: ChallengeFeedback } = {
+ ...state.challenge_feedback,
+ [truth.uuid]: {
+ state: ChallengeFeedbackStatus.Solved,
+ },
+ };
- const challengeFeedback = {
- ...state.challenge_feedback,
- [truth.uuid]: {
- state: "solved",
- },
- };
+ const newState: ReducerStateRecovery = {
+ ...state,
+ recovery_state: RecoveryStates.ChallengeSelecting,
+ challenge_feedback: challengeFeedback,
+ recovered_key_shares: recoveredKeyShares,
+ };
- 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 tryRecoverSecret(newState);
+ 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,
+): Promise<ReducerStateRecovery | ReducerStateError> {
+ const recDoc: RecoveryDocument = state.verbatim_recovery_document!;
+ const truth = recDoc.escrow_methods.find(
+ (x) => x.uuid === state.selected_challenge_uuid,
+ );
+ if (!truth) {
+ throw Error("truth for challenge not found");
+ }
+ return requestTruth(state, truth, ta);
}
async function recoveryEnterUserAttributes(
state: ReducerStateRecovery,
- attributes: Record<string, string>,
+ args: ActionArgsEnterUserAttributes,
): Promise<ReducerStateRecovery | ReducerStateError> {
// 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<ReducerStateRecovery | ReducerStateError> {
+ 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);
+ return requestTruth({ ...state, selected_challenge_uuid: ta.uuid }, truth);
+}
- const resp = await fetch(url.href, {
- headers: {
- "Anastasis-Truth-Decryption-Key": truth.truth_key,
+async function backupSelectContinent(
+ state: ReducerStateBackup,
+ args: ActionArgsSelectContinent,
+): Promise<ReducerStateBackup | ReducerStateError> {
+ const countries = getCountries(args.continent);
+ if (countries.length <= 0) {
+ return {
+ code: TalerErrorCode.ANASTASIS_REDUCER_INPUT_INVALID,
+ hint: "continent not found",
+ };
+ }
+ return {
+ ...state,
+ backup_state: BackupStates.CountrySelecting,
+ countries,
+ selected_continent: args.continent,
+ };
+}
+
+async function recoverySelectContinent(
+ state: ReducerStateRecovery,
+ args: ActionArgsSelectContinent,
+): 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,
},
- });
+ };
+}
- console.log(resp);
+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: ActionArgsAddAuthentication,
+): Promise<ReducerStateBackup> {
return {
...state,
- recovery_state: RecoveryStates.ChallengeSolving,
- selected_challenge_uuid: ta.uuid,
+ authentication_methods: [
+ ...(state.authentication_methods ?? []),
+ args.authentication_method,
+ ],
};
}
-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}'`,
- };
+async function deleteAuthentication(
+ state: ReducerStateBackup,
+ args: ActionArgsDeleteAuthentication,
+): Promise<ReducerStateBackup> {
+ const m = state.authentication_methods ?? [];
+ m.splice(args.authentication_method, 1);
+ return {
+ ...state,
+ authentication_methods: m,
+ };
+}
+
+async function deletePolicy(
+ state: ReducerStateBackup,
+ args: ActionArgsDeletePolicy,
+): Promise<ReducerStateBackup> {
+ const policies = [...(state.policies ?? [])];
+ policies.splice(args.policy_index, 1);
+ return {
+ ...state,
+ policies,
+ };
+}
+
+async function updatePolicy(
+ state: ReducerStateBackup,
+ args: ActionArgsUpdatePolicy,
+): Promise<ReducerStateBackup> {
+ const policies = [...(state.policies ?? [])];
+ policies[args.policy_index] = { methods: args.policy };
+ 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.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}'`,
- };
+ if (!("http_status" in prov && prov.http_status === 200)) {
+ continue;
}
- }
- 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}'`,
- };
+ const methodCost: Record<string, AmountString> = {};
+ for (const meth of prov.methods) {
+ methodCost[meth.type] = meth.usage_fee;
}
+ providers.push({
+ methodCost,
+ url: provUrl,
+ });
}
- 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}'`,
- };
- }
+ const pol = suggestPolicies(methods, providers);
+ return {
+ ...state,
+ backup_state: BackupStates.PoliciesReviewing,
+ ...pol,
+ };
+}
+
+async function updateUploadFees(
+ state: ReducerStateBackup,
+): Promise<ReducerStateBackup | ReducerStateError> {
+ const expiration = state.expiration;
+ if (!expiration) {
+ return { ...state };
}
- 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}'`,
- };
+ logger.info("updating upload fees");
+ const feePerCurrency: Record<string, AmountJson> = {};
+ 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);
}
}
-
- 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",
- };
+ const coveredProvTruth = new Set<string>();
+ 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;
}
- return {
- ...state,
- recovery_state: RecoveryStates.CountrySelecting,
- countries: getCountries(continent),
- selected_continent: continent,
- };
- } else {
- return {
- code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
- hint: `Unsupported action '${action}'`,
- };
+ 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),
+ })),
+ };
+}
- 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: ActionArgsEnterSecret,
+): Promise<ReducerStateBackup | ReducerStateError> {
+ 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,
+ });
+}
- 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: ActionArgsEnterSecretName,
+): 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> {
+ return updateUploadFees({
+ ...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",
+ 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<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",
+ 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),
+ },
+};
- 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/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<string, AmountString>;
+}
+
+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<string>();
+ // 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<string>[] = [];
+ for (let i = 0; i < methods.length; i++) {
+ provPerMethod[i] = new Set<string>();
+ }
+ 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<string, TruthMetaData>;
+ 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<string, string>;
}
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<string, string>;
}
-export interface ActionArgAddAuthentication {
+export const codecForActionArgsEnterUserAttributes = () =>
+ buildCodecForObject<ActionArgsEnterUserAttributes>()
+ .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<ActionArgsSelectContinent>()
+ .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<ActionArgsChangeVersion>()
+ .property("provider_url", codecForString())
+ .property("version", codecForNumber())
+ .build("ActionArgsChangeVersion");
+
+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 const codecForActionArgsUpdateExpiration = () =>
+ buildCodecForObject<ActionArgsUpdateExpiration>()
+ .property("expiration", codecForTimestamp)
+ .build("ActionArgsUpdateExpiration");
+
+export const codecForActionArgsSelectChallenge = () =>
+ buildCodecForObject<ActionArgsSelectChallenge>()
+ .property("uuid", codecForString())
+ .build("ActionArgsSelectChallenge");
+
+export const codecForActionArgSelectCountry = () =>
+ buildCodecForObject<ActionArgsSelectCountry>()
+ .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)
+}
+