summaryrefslogtreecommitdiff
path: root/packages/anastasis-core/src/index.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/anastasis-core/src/index.ts')
-rw-r--r--packages/anastasis-core/src/index.ts2202
1 files changed, 1486 insertions, 716 deletions
diff --git a/packages/anastasis-core/src/index.ts b/packages/anastasis-core/src/index.ts
index b4e911ffb..05fa4a49f 100644
--- a/packages/anastasis-core/src/index.ts
+++ b/packages/anastasis-core/src/index.ts
@@ -1,53 +1,102 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
import {
+ AmountJson,
+ AmountLike,
+ Amounts,
AmountString,
buildSigPS,
bytesToString,
Codec,
codecForAny,
decodeCrock,
+ Duration,
eddsaSign,
encodeCrock,
getRandomBytes,
hash,
+ HttpStatusCode,
+ Logger,
+ parsePayUri,
stringToBytes,
TalerErrorCode,
+ TalerProtocolTimestamp,
TalerSignaturePurpose,
- Timestamp,
+ AbsoluteTime,
+ URL,
+ j2s,
} from "@gnu-taler/taler-util";
+import { HttpResponse } from "@gnu-taler/taler-util/http";
import { anastasisData } from "./anastasis-data.js";
import {
+ codecForChallengeInstructionMessage,
EscrowConfigurationResponse,
+ RecoveryMetaResponse,
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,
+ ActionArgsAddProvider,
+ ActionArgsDeleteProvider,
+ DiscoveryCursor,
+ DiscoveryResult,
+ PolicyMetaInfo,
+ ChallengeInfo,
+ AggregatedPolicyMetaInfo,
+ AuthenticationProviderStatusMap,
} from "./reducer-types.js";
-import fetchPonyfill from "fetch-ponyfill";
import {
accountKeypairDerive,
asOpaque,
@@ -61,8 +110,6 @@ import {
PolicySalt,
TruthSalt,
secureAnswerHash,
- TruthKey,
- TruthUuid,
UserIdentifier,
userIdentifierDerive,
typedArrayConcat,
@@ -70,13 +117,29 @@ import {
decryptKeyShare,
KeyShare,
coreSecretRecover,
+ pinAnswerHash,
+ decryptPolicyMetadata,
+ encryptPolicyMetadata,
} from "./crypto.js";
import { unzlibSync, zlibSync } from "fflate";
-import { EscrowMethod, RecoveryDocument } from "./recovery-document-types.js";
-
-const { fetch, Request, Response, Headers } = fetchPonyfill({});
+import {
+ ChallengeType,
+ EscrowMethod,
+ RecoveryDocument,
+} from "./recovery-document-types.js";
+import { ProviderInfo, suggestPolicies } from "./policy-suggestion.js";
+import {
+ ChallengeFeedback,
+ ChallengeFeedbackStatus,
+} from "./challenge-feedback-types.js";
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");
+
+const ANASTASIS_HTTP_HEADER_POLICY_META_DATA = "Anastasis-Policy-Meta-Data";
function getContinents(): ContinentInfo[] {
const continentSet = new Set<string>();
@@ -94,14 +157,45 @@ 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 ${continent} not found`,
+ });
+ }
+ return countries;
}
export async function getBackupStartState(): Promise<ReducerStateBackup> {
return {
+ reducer_type: "backup",
backup_state: BackupStates.ContinentSelecting,
continents: getContinents(),
};
@@ -109,30 +203,43 @@ export async function getBackupStartState(): Promise<ReducerStateBackup> {
export async function getRecoveryStartState(): Promise<ReducerStateRecovery> {
return {
+ reducer_type: "recovery",
recovery_state: RecoveryStates.ContinentSelecting,
continents: getContinents(),
};
}
-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 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]: {} } = {};
+ const providers: { [x: string]: AuthenticationProviderStatus } = {};
for (const prov of anastasisData.providersList.anastasis_provider) {
- if (currencies.includes(prov.currency)) {
- providers[prov.url] = {};
+ let shouldAdd =
+ country.code === prov.restricted ||
+ (country.code !== "xx" && !prov.restricted);
+ if (shouldAdd) {
+ providers[prov.url] = {
+ status: "not-contacted",
+ };
}
}
@@ -140,47 +247,31 @@ async function backupSelectCountry(
.required_attributes;
return {
- ...state,
- backup_state: BackupStates.UserAttributesCollecting,
selected_country: countryCode,
- currencies,
required_attributes: ra,
authentication_providers: providers,
};
}
+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)),
};
}
@@ -192,13 +283,20 @@ async function getProviderInfo(
try {
resp = await fetch(new URL("config", providerBaseUrl).href);
} catch (e) {
+ console.warn(
+ "Encountered an HTTP error whilst trying to get the provider's config: ",
+ e,
+ );
return {
+ status: "error",
code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED,
- hint: "request to provider failed",
+ hint: "request to anastasis provider failed",
};
}
- if (resp.status !== 200) {
+ if (!resp.ok) {
+ console.warn("Got bad response code whilst getting provider config", resp);
return {
+ status: "error",
code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED,
hint: "unexpected status",
http_status: resp.status,
@@ -206,7 +304,15 @@ async function getProviderInfo(
}
try {
const jsonResp: EscrowConfigurationResponse = await resp.json();
+ if (!jsonResp.provider_salt) {
+ return {
+ status: "error",
+ code: TalerErrorCode.ANASTASIS_REDUCER_PROVIDER_CONFIG_FAILED,
+ hint: "provider did not have provider salt",
+ };
+ }
return {
+ status: "ok",
http_status: 200,
annual_fee: jsonResp.annual_fee,
business_name: jsonResp.business_name,
@@ -216,12 +322,13 @@ async function getProviderInfo(
type: x.type,
usage_fee: x.cost,
})),
- salt: jsonResp.server_salt,
+ provider_salt: jsonResp.provider_salt,
storage_limit_in_megabytes: jsonResp.storage_limit_in_megabytes,
truth_upload_fee: jsonResp.truth_upload_fee,
- } as AuthenticationProviderStatusOk;
+ };
} catch (e) {
return {
+ status: "error",
code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED,
hint: "provider did not return JSON",
};
@@ -230,155 +337,17 @@ async function getProviderInfo(
async function backupEnterUserAttributes(
state: ReducerStateBackup,
- attributes: Record<string, string>,
+ args: ActionArgsEnterUserAttributes,
): Promise<ReducerStateBackup> {
- const providerUrls = Object.keys(state.authentication_providers ?? {});
- const newProviders = state.authentication_providers ?? {};
- for (const url of providerUrls) {
- newProviders[url] = await getProviderInfo(url);
- }
+ const attributes = args.identity_attributes;
const newState = {
...state,
backup_state: BackupStates.AuthenticationsEditing,
- authentication_providers: newProviders,
identity_attributes: attributes,
};
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,
@@ -398,9 +367,10 @@ async function getTruthValue(
case "email":
case "totp":
case "iban":
+ case "post":
return authMethod.challenge;
default:
- throw Error("unknown auth type");
+ throw Error(`unknown auth type '${authMethod.type}'`);
}
}
@@ -408,7 +378,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 +393,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> = {};
@@ -453,7 +427,7 @@ async function uploadSecret(
tm = {
key_share: encodeCrock(getRandomBytes(32)),
nonce: encodeCrock(getRandomBytes(24)),
- truth_salt: encodeCrock(getRandomBytes(16)),
+ master_salt: encodeCrock(getRandomBytes(16)),
truth_key: encodeCrock(getRandomBytes(64)),
uuid: encodeCrock(getRandomBytes(32)),
pol_method_index: methIndex,
@@ -472,17 +446,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 +457,92 @@ async function uploadSecret(
const provider = state.authentication_providers![
meth.provider
] as AuthenticationProviderStatusOk;
- const truthValue = await getTruthValue(authMethod, tm.uuid, tm.truth_salt);
+ escrowMethods.push({
+ escrow_type: authMethod.type as any,
+ instructions: authMethod.instructions,
+ provider_salt: provider.provider_salt,
+ question_salt: tm.master_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.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.master_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 +550,78 @@ 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 {
+ reducer_type: "error",
+ 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 {
+ reducer_type: "error",
+ code: TalerErrorCode.ANASTASIS_REDUCER_BACKEND_FAILURE,
+ hint: `payment requested, but no taler://pay URI given`,
+ };
+ }
+ truthPaySecrets[meth.provider] = parsedUri.orderId;
+ continue;
+ }
+ return {
+ reducer_type: "error",
+ 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 recoveryDocHash = encodeCrock(hash(zippedDoc));
const encRecoveryDoc = await encryptRecoveryDocument(
- uid,
+ userId,
encodeCrock(zippedDoc),
);
const bodyHash = hash(decodeCrock(encRecoveryDoc));
@@ -579,44 +629,164 @@ 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 metadataEnc = await encryptPolicyMetadata(userId, {
+ policy_hash: recoveryDocHash,
+ secret_name: state.secret_name ?? "<unnamed secret>",
+ });
+ 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": JSON.stringify(encodeCrock(bodyHash)),
+ [ANASTASIS_HTTP_HEADER_POLICY_META_DATA]: metadataEnc,
+ ...(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: TalerProtocolTimestamp = { t_s: 0 };
+ try {
+ policyVersion = Number(resp.headers.get("Anastasis-Version") ?? "0");
+ } catch (e) {}
+ try {
+ policyExpiration = {
+ t_s: 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 {
+ reducer_type: "error",
+ 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 {
+ reducer_type: "error",
+ code: TalerErrorCode.ANASTASIS_REDUCER_BACKEND_FAILURE,
+ hint: `payment requested, but no taler://pay URI given`,
+ };
+ }
+ policyPayUriMap[prov.provider_url] = talerPayUri;
+ continue;
+ }
+ return {
+ reducer_type: "error",
+ 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,
+ };
+}
+
+interface PolicyDownloadResult {
+ recoveryDoc: RecoveryDocument;
+ recoveryData: RecoveryInternalData;
+}
+
+async function downloadPolicyFromProvider(
+ state: ReducerStateRecovery,
+ providerUrl: string,
+ version: number,
+): Promise<PolicyDownloadResult | undefined> {
+ logger.info(`trying to download policy from ${providerUrl}`);
+ const userAttributes = state.identity_attributes!;
+ let pi = state.authentication_providers?.[providerUrl];
+ if (!pi || pi.status !== "ok") {
+ // FIXME: this one blocks!
+ logger.info(`fetching provider info for ${providerUrl}`);
+ pi = await getProviderInfo(providerUrl);
+ }
+ logger.info(`new provider status is ${pi.status}`);
+ if (pi.status !== "ok") {
+ return undefined;
+ }
+ const userId = await userIdentifierDerive(userAttributes, pi.provider_salt);
+ const acctKeypair = accountKeypairDerive(userId);
+ const reqUrl = new URL(`policy/${acctKeypair.pub}`, providerUrl);
+ reqUrl.searchParams.set("version", `${version}`);
+ const resp = await fetch(reqUrl.href);
+ if (resp.status !== 200) {
+ logger.info(
+ `Could not download policy from provider ${providerUrl}, status ${resp.status}`,
+ );
+ return undefined;
+ }
+ const body = await resp.arrayBuffer();
+ const bodyDecrypted = await decryptRecoveryDocument(
+ userId,
+ encodeCrock(body),
+ );
+ const rd: RecoveryDocument = await uncompressRecoveryDoc(
+ decodeCrock(bodyDecrypted),
+ );
+ // FIXME: Not clear why we do this, since we always have an explicit version by now.
+ let policyVersion = 0;
+ try {
+ policyVersion = Number(resp.headers.get("Anastasis-Version") ?? "0");
+ } catch (e) {
+ logger.warn("Could not read policy version header");
+ policyVersion = version;
+ }
+ return {
+ recoveryDoc: rd,
+ recoveryData: {
+ provider_url: providerUrl,
+ secret_name: rd.secret_name ?? "<unknown>",
+ version: policyVersion,
+ },
};
}
@@ -627,70 +797,40 @@ async function uploadSecret(
async function downloadPolicy(
state: ReducerStateRecovery,
): Promise<ReducerStateRecovery | ReducerStateError> {
- const providerUrls = Object.keys(state.authentication_providers ?? {});
- let foundRecoveryInfo: RecoveryInternalData | undefined = undefined;
- let recoveryDoc: RecoveryDocument | undefined = undefined;
- const newProviderStatus: { [url: string]: AuthenticationProviderStatusOk } =
- {};
- const userAttributes = state.identity_attributes!;
- // FIXME: Shouldn't we also store the status of bad providers?
- for (const url of providerUrls) {
- const pi = await getProviderInfo(url);
- if ("error_code" in pi || !("http_status" in pi)) {
- // Could not even get /config of the provider
- continue;
- }
- newProviderStatus[url] = pi;
+ logger.info("downloading policy");
+ if (!state.selected_version) {
+ throw Error("invalid state");
}
- for (const url of providerUrls) {
- const pi = newProviderStatus[url];
- if (!pi) {
- continue;
+ let policyDownloadResult: PolicyDownloadResult | undefined = undefined;
+ // FIXME: Do this concurrently/asynchronously so that one slow provider doesn't block us.
+ for (const prov of state.selected_version.providers) {
+ const res = await downloadPolicyFromProvider(state, prov.url, prov.version);
+ if (res) {
+ policyDownloadResult = res;
+ break;
}
- const userId = await userIdentifierDerive(userAttributes, pi.salt);
- const acctKeypair = accountKeypairDerive(userId);
- const resp = await fetch(new URL(`policy/${acctKeypair.pub}`, url).href);
- if (resp.status !== 200) {
- continue;
- }
- const body = await resp.arrayBuffer();
- const bodyDecrypted = await decryptRecoveryDocument(
- userId,
- encodeCrock(body),
- );
- const rd: RecoveryDocument = await uncompressRecoveryDoc(
- decodeCrock(bodyDecrypted),
- );
- console.log("rd", rd);
- let policyVersion = 0;
- try {
- policyVersion = Number(resp.headers.get("Anastasis-Version") ?? "0");
- } catch (e) {}
- foundRecoveryInfo = {
- provider_url: url,
- secret_name: rd.secret_name ?? "<unknown>",
- version: policyVersion,
- };
- recoveryDoc = rd;
- break;
}
- if (!foundRecoveryInfo || !recoveryDoc) {
+ if (!policyDownloadResult) {
return {
+ reducer_type: "error",
code: TalerErrorCode.ANASTASIS_REDUCER_POLICY_LOOKUP_FAILED,
hint: "No backups found at any provider for your identity information.",
};
}
+
+ const challenges: ChallengeInfo[] = [];
+ const recoveryDoc = policyDownloadResult.recoveryDoc;
+
+ for (const x of recoveryDoc.escrow_methods) {
+ challenges.push({
+ instructions: x.instructions,
+ type: x.escrow_type,
+ uuid: x.uuid,
+ });
+ }
+
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!,
- instructions: x.instructions,
- type: x.escrow_type,
- uuid: x.uuid,
- };
- }),
+ challenges,
policies: recoveryDoc.policies.map((x) => {
return x.uuids.map((m) => {
return {
@@ -701,8 +841,8 @@ async function downloadPolicy(
};
return {
...state,
- recovery_state: RecoveryStates.SecretSelecting,
- recovery_document: foundRecoveryInfo,
+ recovery_state: RecoveryStates.ChallengeSelecting,
+ recovery_document: policyDownloadResult.recoveryData,
recovery_information: recoveryInfo,
verbatim_recovery_document: recoveryDoc,
};
@@ -750,86 +890,223 @@ 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.IbanInstructions) {
+ const s2 = await requestTruth(state, truth, {
+ pin: feedback.answer_code,
+ });
+ if (s2.reducer_type === "recovery") {
+ state = s2;
+ }
+ }
}
+ return state;
+}
- const url = new URL(`/truth/${truth.uuid}`, truth.url);
+async function getResponseHash(
+ truth: EscrowMethod,
+ solveRequest: ActionArgsSolveChallengeRequest,
+): Promise<string> {
+ let respHash: string;
+ switch (truth.escrow_type) {
+ case ChallengeType.Question: {
+ if ("answer" in solveRequest) {
+ respHash = await secureAnswerHash(
+ solveRequest.answer,
+ truth.uuid,
+ truth.question_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}""`);
+ }
+ return respHash;
+}
- // FIXME: This isn't correct for non-question truth responses.
- url.searchParams.set(
- "response",
- await secureAnswerHash(ta.answer, truth.uuid, truth.truth_salt),
- );
+/**
+ * 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}/solve`, truth.url);
- const resp = await fetch(url.href, {
- headers: {
- "Anastasis-Truth-Decryption-Key": truth.truth_key,
- },
- });
+ const hresp = await getResponseHash(truth, solveRequest);
- console.log(resp);
+ let resp: Response;
- if (resp.status !== 200) {
+ try {
+ resp = await fetch(url.href, {
+ method: "POST",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ truth_decryption_key: truth.truth_key,
+ h_response: hresp,
+ }),
+ });
+ } catch (e) {
return {
+ reducer_type: "error",
code: TalerErrorCode.ANASTASIS_TRUTH_CHALLENGE_FAILED,
- hint: "got non-200 response",
- http_status: resp.status,
+ hint: "network error",
} as ReducerStateError;
}
- const answerSalt = truth.escrow_type === "question" ? ta.answer : undefined;
-
- const userId = await userIdentifierDerive(
- state.identity_attributes,
- truth.provider_salt,
+ logger.info(
+ `got POST /truth/.../solve response from ${truth.url}, http status ${resp.status}`,
);
- const respBody = new Uint8Array(await resp.arrayBuffer());
- const keyShare = await decryptKeyShare(
- encodeCrock(respBody),
- userId,
- answerSalt,
- );
+ if (resp.status === HttpStatusCode.Ok) {
+ let answerSalt: string | undefined = undefined;
+ if (
+ solveRequest &&
+ truth.escrow_type === "question" &&
+ "answer" in solveRequest
+ ) {
+ answerSalt = solveRequest.answer;
+ }
- const recoveredKeyShares = {
- ...(state.recovered_key_shares ?? {}),
- [truth.uuid]: keyShare,
- };
+ const userId = await userIdentifierDerive(
+ state.identity_attributes,
+ truth.provider_salt,
+ );
- const challengeFeedback = {
- ...state.challenge_feedback,
- [truth.uuid]: {
- state: "solved",
- },
- };
+ const respBody = new Uint8Array(await resp.arrayBuffer());
+ const keyShare = await decryptKeyShare(
+ encodeCrock(respBody),
+ userId,
+ answerSalt,
+ );
- const newState: ReducerStateRecovery = {
- ...state,
- recovery_state: RecoveryStates.ChallengeSelecting,
- challenge_feedback: challengeFeedback,
- recovered_key_shares: recoveredKeyShares,
- };
+ const recoveredKeyShares = {
+ ...(state.recovered_key_shares ?? {}),
+ [truth.uuid]: keyShare,
+ };
- return tryRecoverSecret(newState);
+ const challengeFeedback: { [x: string]: ChallengeFeedback } = {
+ ...state.challenge_feedback,
+ [truth.uuid]: {
+ state: ChallengeFeedbackStatus.Solved,
+ },
+ };
+
+ const newState: ReducerStateRecovery = {
+ ...state,
+ recovery_state: RecoveryStates.ChallengeSelecting,
+ challenge_feedback: challengeFeedback,
+ recovered_key_shares: recoveredKeyShares,
+ };
+
+ return tryRecoverSecret(newState);
+ }
+
+ if (resp.status === HttpStatusCode.Forbidden) {
+ const challengeFeedback: { [x: string]: ChallengeFeedback } = {
+ ...state.challenge_feedback,
+ [truth.uuid]: {
+ state: ChallengeFeedbackStatus.IncorrectAnswer,
+ },
+ };
+ return {
+ ...state,
+ challenge_feedback: challengeFeedback,
+ };
+ }
+
+ return {
+ reducer_type: "error",
+ 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,
+ recovery_state: RecoveryStates.SecretSelecting,
+ identity_attributes: args.identity_attributes,
+ };
+ return st;
+}
+
+async function changeVersion(
+ state: ReducerStateRecovery,
+ args: ActionArgsChangeVersion,
+): Promise<ReducerStateRecovery | ReducerStateError> {
+ const st: ReducerStateRecovery = {
+ ...state,
+ selected_version: args,
};
return downloadPolicy(st);
}
@@ -844,369 +1121,862 @@ async function selectChallenge(
throw "truth for challenge not found";
}
- const url = new URL(`/truth/${truth.uuid}`, truth.url);
+ const url = new URL(`/truth/${truth.uuid}/challenge`, truth.url);
- const resp = await fetch(url.href, {
- headers: {
- "Anastasis-Truth-Decryption-Key": truth.truth_key,
- },
- });
+ const newFeedback = { ...state.challenge_feedback };
+ delete newFeedback[truth.uuid];
+
+ switch (truth.escrow_type) {
+ case ChallengeType.Question:
+ case ChallengeType.Totp: {
+ return {
+ ...state,
+ recovery_state: RecoveryStates.ChallengeSolving,
+ selected_challenge_uuid: truth.uuid,
+ challenge_feedback: newFeedback,
+ };
+ }
+ }
- console.log(resp);
+ let resp: Response;
+
+ try {
+ resp = await fetch(url.href, {
+ method: "POST",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ truth_decryption_key: truth.truth_key,
+ }),
+ });
+ } catch (e) {
+ const feedback: ChallengeFeedback = {
+ state: ChallengeFeedbackStatus.ServerFailure,
+ http_status: 0,
+ };
+ return {
+ ...state,
+ recovery_state: RecoveryStates.ChallengeSelecting,
+ selected_challenge_uuid: truth.uuid,
+ challenge_feedback: {
+ ...state.challenge_feedback,
+ [truth.uuid]: feedback,
+ },
+ };
+ }
+
+ logger.info(
+ `got GET /truth/.../challenge response from ${truth.url}, http status ${resp.status}`,
+ );
+
+ if (resp.status === HttpStatusCode.Ok) {
+ const respBodyJson = await resp.json();
+ logger.info(`validating ${j2s(respBodyJson)}`);
+ const instr = codecForChallengeInstructionMessage().decode(respBodyJson);
+ let feedback: ChallengeFeedback;
+ switch (instr.challenge_type) {
+ case "FILE_WRITTEN": {
+ feedback = {
+ state: ChallengeFeedbackStatus.CodeInFile,
+ display_hint: "TAN code is in file (for debugging)",
+ filename: instr.filename,
+ };
+ break;
+ }
+ case "IBAN_WIRE": {
+ feedback = {
+ state: ChallengeFeedbackStatus.IbanInstructions,
+ answer_code: instr.wire_details.answer_code,
+ target_business_name: instr.wire_details.business_name,
+ challenge_amount: instr.wire_details.challenge_amount,
+ target_iban: instr.wire_details.credit_iban,
+ wire_transfer_subject: instr.wire_details.wire_transfer_subject,
+ };
+ break;
+ }
+ case "TAN_SENT": {
+ feedback = {
+ state: ChallengeFeedbackStatus.CodeSent,
+ address_hint: instr.tan_address_hint,
+ display_hint: "Code sent to address",
+ };
+ }
+ }
+ return {
+ ...state,
+ recovery_state: RecoveryStates.ChallengeSolving,
+ selected_challenge_uuid: truth.uuid,
+ challenge_feedback: {
+ ...state.challenge_feedback,
+ [truth.uuid]: feedback,
+ },
+ };
+ }
+
+ // FIXME: look at more error codes in response
return {
+ reducer_type: "error",
+ code: TalerErrorCode.ANASTASIS_TRUTH_CHALLENGE_FAILED,
+ hint: `got unexpected /truth/.../challenge response status (${resp.status})`,
+ http_status: resp.status,
+ } as ReducerStateError;
+}
+
+async function backupSelectContinent(
+ state: ReducerStateBackup,
+ args: ActionArgsSelectContinent,
+): Promise<ReducerStateBackup | ReducerStateError> {
+ const countries = getCountries(args.continent);
+ if (countries.length <= 0) {
+ return {
+ reducer_type: "error",
+ code: TalerErrorCode.ANASTASIS_REDUCER_INPUT_INVALID,
+ hint: "continent not found",
+ };
+ }
+ return {
...state,
- recovery_state: RecoveryStates.ChallengeSolving,
- selected_challenge_uuid: ta.uuid,
+ backup_state: BackupStates.CountrySelecting,
+ countries,
+ selected_continent: args.continent,
};
}
-export async function reduceAction(
- state: ReducerState,
+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> {
+ [x: string]: TransitionImpl<S, any>;
+}
+
+function transition<S, T>(
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}'`,
- };
+ argCodec: Codec<T>,
+ handler: (s: S, args: T) => Promise<S | ReducerStateError>,
+): Transition<S> {
+ return {
+ [action]: {
+ argCodec,
+ handler,
+ },
+ };
+}
+
+function transitionBackupJump(
+ action: string,
+ st: BackupStates,
+): Transition<ReducerStateBackup> {
+ return {
+ [action]: {
+ argCodec: codecForAny(),
+ handler: async (s, a) => ({ ...s, backup_state: st }),
+ },
+ };
+}
+
+function transitionRecoveryJump(
+ action: string,
+ st: RecoveryStates,
+): Transition<ReducerStateRecovery> {
+ return {
+ [action]: {
+ argCodec: codecForAny(),
+ handler: async (s, a) => ({ ...s, recovery_state: st }),
+ },
+ };
+}
+
+async function addProviderBackup(
+ state: ReducerStateBackup,
+ args: ActionArgsAddProvider,
+): Promise<ReducerStateBackup> {
+ const info = await getProviderInfo(args.provider_url);
+ return {
+ ...state,
+ authentication_providers: {
+ ...(state.authentication_providers ?? {}),
+ [args.provider_url]: info,
+ },
+ };
+}
+
+async function deleteProviderBackup(
+ state: ReducerStateBackup,
+ args: ActionArgsDeleteProvider,
+): Promise<ReducerStateBackup> {
+ const authentication_providers = {
+ ...(state.authentication_providers ?? {}),
+ };
+ delete authentication_providers[args.provider_url];
+ return {
+ ...state,
+ authentication_providers,
+ };
+}
+
+async function addProviderRecovery(
+ state: ReducerStateRecovery,
+ args: ActionArgsAddProvider,
+): Promise<ReducerStateRecovery> {
+ const info = await getProviderInfo(args.provider_url);
+ return {
+ ...state,
+ authentication_providers: {
+ ...(state.authentication_providers ?? {}),
+ [args.provider_url]: info,
+ },
+ };
+}
+
+async function deleteProviderRecovery(
+ state: ReducerStateRecovery,
+ args: ActionArgsDeleteProvider,
+): Promise<ReducerStateRecovery> {
+ const authentication_providers = {
+ ...(state.authentication_providers ?? {}),
+ };
+ delete authentication_providers[args.provider_url];
+ return {
+ ...state,
+ authentication_providers,
+ };
+}
+
+async function addAuthentication(
+ state: ReducerStateBackup,
+ args: ActionArgsAddAuthentication,
+): Promise<ReducerStateBackup> {
+ return {
+ ...state,
+ authentication_methods: [
+ ...(state.authentication_methods ?? []),
+ args.authentication_method,
+ ],
+ };
+}
+
+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 (prov.status !== "ok") {
+ continue;
}
- }
- 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}'`,
- };
+ 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.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}'`,
- };
+ const pol = suggestPolicies(methods, providers);
+ if (pol.policies.length === 0) {
+ return {
+ reducer_type: "error",
+ code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
+ detail:
+ "Unable to suggest any policies. Check if providers are available and reachable.",
+ };
+ }
+ return {
+ ...state,
+ backup_state: BackupStates.PoliciesReviewing,
+ ...pol,
+ };
+}
+
+async function updateUploadFees(
+ state: ReducerStateBackup,
+): Promise<ReducerStateBackup | ReducerStateError> {
+ const expiration = state.expiration;
+ if (!expiration) {
+ return { ...state };
+ }
+ 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.zeroOfAmount(x),
+ x,
+ ).amount;
+ };
+ const expirationTime = AbsoluteTime.fromProtocolTimestamp(expiration);
+ const years = Duration.toIntegerYears(Duration.getRemaining(expirationTime));
+ 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.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 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;
}
- 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}'`,
- };
+ logger.info(
+ `adding cost for auth method ${authMethod.challenge} / "${authMethod.instructions}" at ${m.provider}`,
+ );
+ coveredProvTruth.add(key);
+ addFee(prov.truth_upload_fee);
}
}
- 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,
+ upload_fees: Object.values(feePerCurrency).map((x) => ({
+ fee: Amounts.stringify(x),
+ })),
+ };
+}
+
+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,
+ filename: args.secret.filename,
+ },
+ // A new secret invalidates the existing recovery data.
+ recovery_data: undefined,
+ });
+}
+
+async function nextFromChallengeSelecting(
+ state: ReducerStateRecovery,
+ args: void,
+): Promise<ReducerStateRecovery | ReducerStateError> {
+ const s2 = await tryRecoverSecret(state);
+ if (
+ s2.reducer_type === "recovery" &&
+ s2.recovery_state === RecoveryStates.RecoveryFinished
+ ) {
+ return s2;
}
- 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}'`,
- };
- }
+ return {
+ reducer_type: "error",
+ code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
+ hint: "Not enough challenges solved",
+ };
+}
+
+async function syncOneProviderRecoveryTransition(
+ state: ReducerStateRecovery,
+ args: void,
+): Promise<ReducerStateRecovery | ReducerStateError> {
+ // FIXME: Should we not add this when we obtain the recovery document?
+ const escrowMethods = state.verbatim_recovery_document?.escrow_methods ?? [];
+ if (escrowMethods.length === 0) {
+ return {
+ reducer_type: "error",
+ code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
+ hint: "Can't sync, no escrow methods in recovery doc.",
+ };
}
- 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}'`,
- };
+ for (const x of escrowMethods) {
+ const pi = state.authentication_providers?.[x.url];
+ if (pi?.status === "ok") {
+ logger.info(`provider ${x.url} is synced`);
+ continue;
}
+ const newPi = await getProviderInfo(x.url);
+ return {
+ ...state,
+ authentication_providers: {
+ ...state.authentication_providers,
+ [x.url]: newPi,
+ },
+ };
}
- 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}'`,
- };
+ for (const [provUrl, pi] of Object.entries(
+ state.authentication_providers ?? {},
+ )) {
+ if (
+ pi.status === "ok" ||
+ pi.status === "disabled" ||
+ pi.status === "error"
+ ) {
+ continue;
}
+ const newPi = await getProviderInfo(provUrl);
+ return {
+ ...state,
+ authentication_providers: {
+ ...state.authentication_providers,
+ [provUrl]: newPi,
+ },
+ };
}
+ return {
+ reducer_type: "error",
+ code: TalerErrorCode.ANASTASIS_REDUCER_PROVIDERS_ALREADY_SYNCED,
+ hint: "all providers are already synced",
+ };
+}
- 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 syncOneProviderBackupTransition(
+ state: ReducerStateBackup,
+ args: void,
+): Promise<ReducerStateBackup | ReducerStateError> {
+ for (const [provUrl, pi] of Object.entries(
+ state.authentication_providers ?? {},
+ )) {
+ if (
+ pi.status === "ok" ||
+ pi.status === "disabled" ||
+ pi.status === "error"
+ ) {
+ continue;
}
+ const newPi = await getProviderInfo(provUrl);
+ return {
+ ...state,
+ authentication_providers: {
+ ...state.authentication_providers,
+ [provUrl]: newPi,
+ },
+ };
}
+ return {
+ reducer_type: "error",
+ code: TalerErrorCode.ANASTASIS_REDUCER_PROVIDERS_ALREADY_SYNCED,
+ hint: "all providers are already synced",
+ };
+}
- 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);
+async function enterSecretName(
+ state: ReducerStateBackup,
+ args: ActionArgsEnterSecretName,
+): Promise<ReducerStateBackup | ReducerStateError> {
+ return {
+ ...state,
+ secret_name: args.name,
+ };
+}
+
+async function updateSecretExpiration(
+ state: ReducerStateBackup,
+ args: ActionArgsUpdateExpiration,
+): Promise<ReducerStateBackup | ReducerStateError> {
+ return updateUploadFees({
+ ...state,
+ expiration: args.expiration,
+ });
+}
+
+export function mergeDiscoveryAggregate(
+ newPolicies: PolicyMetaInfo[],
+ oldAgg: AggregatedPolicyMetaInfo[],
+): AggregatedPolicyMetaInfo[] {
+ const aggregatedPolicies: AggregatedPolicyMetaInfo[] = [...oldAgg];
+ const polHashToIndex: Record<string, number> = {};
+ for (const pol of newPolicies) {
+ const oldIndex = polHashToIndex[pol.policy_hash];
+ if (oldIndex != null) {
+ aggregatedPolicies[oldIndex].providers.push({
+ url: pol.provider_url,
+ version: pol.version,
+ });
} else {
- return {
- code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
- hint: `Unsupported action '${action}'`,
- };
+ aggregatedPolicies.push({
+ attribute_mask: pol.attribute_mask,
+ policy_hash: pol.policy_hash,
+ providers: [
+ {
+ url: pol.provider_url,
+ version: pol.version,
+ },
+ ],
+ secret_name: pol.secret_name,
+ });
+ polHashToIndex[pol.policy_hash] = aggregatedPolicies.length - 1;
}
}
+ return aggregatedPolicies;
+}
- 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}'`,
- };
- }
+const backupTransitions: Record<
+ BackupStates,
+ Transition<ReducerStateBackup>
+> = {
+ [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,
+ ),
+ ...transition(
+ "sync_providers",
+ codecForAny(),
+ syncOneProviderBackupTransition,
+ ),
+ },
+ [BackupStates.AuthenticationsEditing]: {
+ ...transitionBackupJump("back", BackupStates.UserAttributesCollecting),
+ ...transition("add_authentication", codecForAny(), addAuthentication),
+ ...transition("delete_authentication", codecForAny(), deleteAuthentication),
+ ...transition("add_provider", codecForAny(), addProviderBackup),
+ ...transition("delete_provider", codecForAny(), deleteProviderBackup),
+ ...transition(
+ "sync_providers",
+ codecForAny(),
+ syncOneProviderBackupTransition,
+ ),
+ ...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>
+> = {
+ [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("add_provider", codecForAny(), addProviderRecovery),
+ ...transition("delete_provider", codecForAny(), deleteProviderRecovery),
+ ...transition(
+ "select_version",
+ codecForActionArgsChangeVersion(),
+ changeVersion,
+ ),
+ },
+ [RecoveryStates.ChallengeSelecting]: {
+ ...transitionRecoveryJump("back", RecoveryStates.SecretSelecting),
+ ...transition(
+ "select_challenge",
+ codecForActionArgsSelectChallenge(),
+ selectChallenge,
+ ),
+ ...transition("poll", codecForAny(), pollChallenges),
+ ...transition("next", codecForAny(), nextFromChallengeSelecting),
+ ...transition(
+ "sync_providers",
+ codecForAny(),
+ syncOneProviderRecoveryTransition,
+ ),
+ },
+ [RecoveryStates.ChallengeSolving]: {
+ ...transitionRecoveryJump("back", RecoveryStates.ChallengeSelecting),
+ ...transition("solve_challenge", codecForAny(), solveChallenge),
+ },
+ [RecoveryStates.ChallengePaying]: {},
+ [RecoveryStates.RecoveryFinished]: {
+ ...transitionRecoveryJump("back", RecoveryStates.ChallengeSelecting),
+ },
+};
+
+export async function discoverPolicies(
+ state: ReducerState,
+ cursor?: DiscoveryCursor,
+): Promise<DiscoveryResult> {
+ if (state.reducer_type !== "recovery") {
+ throw Error("can only discover providers in recovery state");
}
- 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;
+ const policies: PolicyMetaInfo[] = [];
+
+ const providerUrls = Object.keys(state.authentication_providers || {});
+ // FIXME: Do we need to re-contact providers here / check if they're disabled?
+ // FIXME: Do this concurrently and take the first. Otherwise, one provider might block for a long time.
+
+ for (const providerUrl of providerUrls) {
+ const providerInfo = await getProviderInfo(providerUrl);
+ if (providerInfo.status !== "ok") {
+ continue;
+ }
+ const userId = await userIdentifierDerive(
+ state.identity_attributes!,
+ providerInfo.provider_salt,
+ );
+ const acctKeypair = accountKeypairDerive(userId);
+ const reqUrl = new URL(`policy/${acctKeypair.pub}/meta`, providerUrl);
+ const resp = await fetch(reqUrl.href);
+ if (resp.status !== 200) {
+ logger.warn(`Could not fetch policy metadate from ${reqUrl.href}`);
+ continue;
+ }
+ const respJson: RecoveryMetaResponse = await resp.json();
+ const versions = Object.keys(respJson);
+ for (const version of versions) {
+ const item = respJson[version];
+ if (!item.meta) {
+ continue;
}
- return {
- code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
- hint: "Not enough challenges solved",
- };
- } else {
- return {
- code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
- hint: `Unsupported action '${action}'`,
- };
+ const metaData = await decryptPolicyMetadata(userId, item.meta!);
+ policies.push({
+ attribute_mask: 0,
+ provider_url: providerUrl,
+ server_time: item.upload_time,
+ version: Number.parseInt(version, 10),
+ secret_name: metaData.secret_name,
+ policy_hash: metaData.policy_hash,
+ });
}
}
+ return {
+ policies,
+ cursor: undefined,
+ };
+}
- 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 {
+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 {
+ reducer_type: "error",
+ code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
+ hint: `Invalid state (needs backup_state or recovery_state)`,
+ };
+ }
+ if (!h) {
+ return {
+ reducer_type: "error",
+ 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 {
+ reducer_type: "error",
+ code: TalerErrorCode.ANASTASIS_REDUCER_INPUT_INVALID,
+ hint: "argument validation failed",
+ detail: e.toString(),
+ };
+ }
+ try {
+ return await h.handler(state, parsedArgs);
+ } catch (e: any) {
+ logger.error("action handler failed");
+ logger.error(`${e?.stack ?? e}`);
+ if (e instanceof ReducerError) {
return {
- code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
- hint: `Unsupported action '${action}'`,
+ reducer_type: "error",
+ ...e.errorJson,
};
}
+ throw e;
}
+}
- 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}'`,
- };
+/**
+ * Update provider status of providers that we still need to contact.
+ *
+ * Returns updates as soon as new information about at least one provider
+ * is found.
+ *
+ * Returns an empty object if provider information is complete.
+ *
+ * FIXME: Also pass a cancellation token.
+ */
+export async function completeProviderStatus(
+ providerMap: AuthenticationProviderStatusMap,
+): Promise<AuthenticationProviderStatusMap> {
+ const updateTasks: Promise<[string, AuthenticationProviderStatus]>[] = [];
+ for (const [provUrl, pi] of Object.entries(providerMap)) {
+ switch (pi.status) {
+ case "ok":
+ case "error":
+ case "disabled":
+ default:
+ continue;
+ case "not-contacted":
+ updateTasks.push(
+ (async () => {
+ return [provUrl, await getProviderInfo(provUrl)];
+ })(),
+ );
}
}
+ if (updateTasks.length === 0) {
+ return {};
+ }
+
+ const [firstUrl, firstStatus] = await Promise.race(updateTasks);
return {
- code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
- hint: "Reducer action invalid",
+ [firstUrl]: firstStatus,
};
}