2021-10-18 19:18:34 +02:00
|
|
|
import {
|
|
|
|
AmountString,
|
2021-10-19 18:39:38 +02:00
|
|
|
buildSigPS,
|
2021-10-19 23:26:29 +02:00
|
|
|
bytesToString,
|
2021-10-21 13:11:17 +02:00
|
|
|
Codec,
|
|
|
|
codecForAny,
|
2021-10-18 19:18:34 +02:00
|
|
|
decodeCrock,
|
2021-10-19 18:39:38 +02:00
|
|
|
eddsaSign,
|
2021-10-18 19:18:34 +02:00
|
|
|
encodeCrock,
|
|
|
|
getRandomBytes,
|
2021-10-19 18:39:38 +02:00
|
|
|
hash,
|
2021-11-02 16:20:39 +01:00
|
|
|
j2s,
|
|
|
|
Logger,
|
2021-10-19 20:51:31 +02:00
|
|
|
stringToBytes,
|
2021-10-18 19:18:34 +02:00
|
|
|
TalerErrorCode,
|
2021-10-19 18:39:38 +02:00
|
|
|
TalerSignaturePurpose,
|
2021-10-19 20:51:31 +02:00
|
|
|
Timestamp,
|
2021-10-18 19:18:34 +02:00
|
|
|
} from "@gnu-taler/taler-util";
|
|
|
|
import { anastasisData } from "./anastasis-data.js";
|
|
|
|
import {
|
|
|
|
EscrowConfigurationResponse,
|
|
|
|
TruthUploadRequest,
|
|
|
|
} from "./provider-types.js";
|
|
|
|
import {
|
|
|
|
ActionArgAddAuthentication,
|
|
|
|
ActionArgDeleteAuthentication,
|
|
|
|
ActionArgDeletePolicy,
|
|
|
|
ActionArgEnterSecret,
|
|
|
|
ActionArgEnterSecretName,
|
|
|
|
ActionArgEnterUserAttributes,
|
2021-11-02 16:20:39 +01:00
|
|
|
ActionArgsAddPolicy,
|
|
|
|
ActionArgSelectContinent,
|
|
|
|
ActionArgSelectCountry,
|
2021-10-21 18:51:19 +02:00
|
|
|
ActionArgsSelectChallenge,
|
|
|
|
ActionArgsSolveChallengeRequest,
|
2021-11-02 16:20:39 +01:00
|
|
|
ActionArgsUpdateExpiration,
|
2021-10-18 19:18:34 +02:00
|
|
|
AuthenticationProviderStatus,
|
|
|
|
AuthenticationProviderStatusOk,
|
|
|
|
AuthMethod,
|
|
|
|
BackupStates,
|
2021-11-02 16:20:39 +01:00
|
|
|
codecForActionArgEnterUserAttributes,
|
|
|
|
codecForActionArgsAddPolicy,
|
|
|
|
codecForActionArgSelectChallenge,
|
|
|
|
codecForActionArgSelectContinent,
|
|
|
|
codecForActionArgSelectCountry,
|
|
|
|
codecForActionArgsUpdateExpiration,
|
2021-10-18 19:18:34 +02:00
|
|
|
ContinentInfo,
|
|
|
|
CountryInfo,
|
|
|
|
MethodSpec,
|
|
|
|
Policy,
|
|
|
|
PolicyProvider,
|
2021-10-21 13:11:17 +02:00
|
|
|
RecoveryInformation,
|
|
|
|
RecoveryInternalData,
|
2021-10-18 19:18:34 +02:00
|
|
|
RecoveryStates,
|
|
|
|
ReducerState,
|
|
|
|
ReducerStateBackup,
|
|
|
|
ReducerStateBackupUserAttributesCollecting,
|
|
|
|
ReducerStateError,
|
|
|
|
ReducerStateRecovery,
|
2021-10-19 18:39:38 +02:00
|
|
|
SuccessDetails,
|
2021-11-02 16:20:39 +01:00
|
|
|
UserAttributeSpec,
|
2021-10-18 19:18:34 +02:00
|
|
|
} from "./reducer-types.js";
|
|
|
|
import fetchPonyfill from "fetch-ponyfill";
|
|
|
|
import {
|
2021-10-18 21:48:22 +02:00
|
|
|
accountKeypairDerive,
|
2021-10-19 20:51:31 +02:00
|
|
|
asOpaque,
|
2021-10-18 19:18:34 +02:00
|
|
|
coreSecretEncrypt,
|
|
|
|
encryptKeyshare,
|
2021-10-18 21:48:22 +02:00
|
|
|
encryptRecoveryDocument,
|
2021-10-18 19:18:34 +02:00
|
|
|
encryptTruth,
|
2021-10-19 20:51:31 +02:00
|
|
|
OpaqueData,
|
2021-10-18 19:18:34 +02:00
|
|
|
PolicyKey,
|
|
|
|
policyKeyDerive,
|
2021-10-19 18:39:38 +02:00
|
|
|
PolicySalt,
|
2021-10-19 20:51:31 +02:00
|
|
|
TruthSalt,
|
|
|
|
secureAnswerHash,
|
2021-10-18 19:18:34 +02:00
|
|
|
UserIdentifier,
|
|
|
|
userIdentifierDerive,
|
2021-10-19 23:26:29 +02:00
|
|
|
typedArrayConcat,
|
2021-10-21 13:11:17 +02:00
|
|
|
decryptRecoveryDocument,
|
2021-10-21 18:51:19 +02:00
|
|
|
decryptKeyShare,
|
|
|
|
KeyShare,
|
|
|
|
coreSecretRecover,
|
2021-10-18 19:18:34 +02:00
|
|
|
} from "./crypto.js";
|
2021-10-21 13:11:17 +02:00
|
|
|
import { unzlibSync, zlibSync } from "fflate";
|
|
|
|
import { EscrowMethod, RecoveryDocument } from "./recovery-document-types.js";
|
2021-10-07 12:01:40 +02:00
|
|
|
|
2021-11-02 16:20:39 +01:00
|
|
|
const { fetch } = fetchPonyfill({});
|
2021-10-07 12:01:40 +02:00
|
|
|
|
2021-10-18 19:18:34 +02:00
|
|
|
export * from "./reducer-types.js";
|
2021-11-02 16:20:39 +01:00
|
|
|
export * as validators from "./validators.js";
|
|
|
|
|
|
|
|
const logger = new Logger("anastasis-core:index.ts");
|
2021-10-07 12:01:40 +02:00
|
|
|
|
2021-10-18 19:18:34 +02:00
|
|
|
function getContinents(): ContinentInfo[] {
|
|
|
|
const continentSet = new Set<string>();
|
|
|
|
const continents: ContinentInfo[] = [];
|
|
|
|
for (const country of anastasisData.countriesList.countries) {
|
|
|
|
if (continentSet.has(country.continent)) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
continentSet.add(country.continent);
|
|
|
|
continents.push({
|
|
|
|
...{ name_i18n: country.continent_i18n },
|
|
|
|
name: country.continent,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
return continents;
|
|
|
|
}
|
|
|
|
|
2021-11-02 16:20:39 +01:00
|
|
|
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.
|
|
|
|
*/
|
2021-10-18 19:18:34 +02:00
|
|
|
function getCountries(continent: string): CountryInfo[] {
|
2021-11-02 16:20:39 +01:00
|
|
|
const countries = anastasisData.countriesList.countries.filter(
|
2021-10-18 19:18:34 +02:00
|
|
|
(x) => x.continent === continent,
|
|
|
|
);
|
2021-11-02 16:20:39 +01:00
|
|
|
if (countries.length <= 0) {
|
|
|
|
throw new ReducerError({
|
|
|
|
code: TalerErrorCode.ANASTASIS_REDUCER_INPUT_INVALID,
|
|
|
|
hint: "continent not found",
|
|
|
|
});
|
|
|
|
}
|
|
|
|
return countries;
|
2021-10-18 19:18:34 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
export async function getBackupStartState(): Promise<ReducerStateBackup> {
|
|
|
|
return {
|
|
|
|
backup_state: BackupStates.ContinentSelecting,
|
|
|
|
continents: getContinents(),
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
export async function getRecoveryStartState(): Promise<ReducerStateRecovery> {
|
|
|
|
return {
|
|
|
|
recovery_state: RecoveryStates.ContinentSelecting,
|
|
|
|
continents: getContinents(),
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2021-11-02 16:20:39 +01:00
|
|
|
async function selectCountry(
|
|
|
|
selectedContinent: string,
|
|
|
|
args: ActionArgSelectCountry,
|
|
|
|
): Promise<Partial<ReducerStateBackup> & Partial<ReducerStateRecovery>> {
|
|
|
|
const countryCode = args.country_code;
|
|
|
|
const currencies = args.currencies;
|
2021-10-18 19:18:34 +02:00
|
|
|
const country = anastasisData.countriesList.countries.find(
|
|
|
|
(x) => x.code === countryCode,
|
|
|
|
);
|
|
|
|
if (!country) {
|
2021-11-02 16:20:39 +01:00
|
|
|
throw new ReducerError({
|
2021-10-18 19:18:34 +02:00
|
|
|
code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
|
|
|
|
hint: "invalid country selected",
|
2021-11-02 16:20:39 +01:00
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
if (country.continent !== selectedContinent) {
|
|
|
|
throw new ReducerError({
|
|
|
|
code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
|
|
|
|
hint: "selected country is not in selected continent",
|
|
|
|
});
|
2021-10-18 19:18:34 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
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 {
|
|
|
|
selected_country: countryCode,
|
|
|
|
currencies,
|
|
|
|
required_attributes: ra,
|
|
|
|
authentication_providers: providers,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2021-11-02 16:20:39 +01:00
|
|
|
async function backupSelectCountry(
|
|
|
|
state: ReducerStateBackup,
|
|
|
|
args: ActionArgSelectCountry,
|
|
|
|
): Promise<ReducerStateError | ReducerStateBackup> {
|
|
|
|
return {
|
|
|
|
...state,
|
|
|
|
...(await selectCountry(state.selected_continent!, args)),
|
|
|
|
backup_state: BackupStates.UserAttributesCollecting,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2021-10-21 13:11:17 +02:00
|
|
|
async function recoverySelectCountry(
|
|
|
|
state: ReducerStateRecovery,
|
2021-11-02 16:20:39 +01:00
|
|
|
args: ActionArgSelectCountry,
|
2021-10-21 13:11:17 +02:00
|
|
|
): Promise<ReducerStateError | ReducerStateRecovery> {
|
|
|
|
return {
|
|
|
|
...state,
|
|
|
|
recovery_state: RecoveryStates.UserAttributesCollecting,
|
2021-11-02 16:20:39 +01:00
|
|
|
...(await selectCountry(state.selected_continent!, args)),
|
2021-10-21 13:11:17 +02:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2021-10-18 19:18:34 +02:00
|
|
|
async function getProviderInfo(
|
|
|
|
providerBaseUrl: string,
|
|
|
|
): Promise<AuthenticationProviderStatus> {
|
|
|
|
// FIXME: Use a reasonable timeout here.
|
|
|
|
let resp: Response;
|
|
|
|
try {
|
|
|
|
resp = await fetch(new URL("config", providerBaseUrl).href);
|
|
|
|
} catch (e) {
|
|
|
|
return {
|
|
|
|
code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED,
|
|
|
|
hint: "request to provider failed",
|
|
|
|
};
|
|
|
|
}
|
|
|
|
if (resp.status !== 200) {
|
|
|
|
return {
|
|
|
|
code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED,
|
|
|
|
hint: "unexpected status",
|
|
|
|
http_status: resp.status,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
try {
|
|
|
|
const jsonResp: EscrowConfigurationResponse = await resp.json();
|
|
|
|
return {
|
|
|
|
http_status: 200,
|
|
|
|
annual_fee: jsonResp.annual_fee,
|
|
|
|
business_name: jsonResp.business_name,
|
|
|
|
currency: jsonResp.currency,
|
|
|
|
liability_limit: jsonResp.liability_limit,
|
|
|
|
methods: jsonResp.methods.map((x) => ({
|
|
|
|
type: x.type,
|
|
|
|
usage_fee: x.cost,
|
|
|
|
})),
|
|
|
|
salt: jsonResp.server_salt,
|
|
|
|
storage_limit_in_megabytes: jsonResp.storage_limit_in_megabytes,
|
|
|
|
truth_upload_fee: jsonResp.truth_upload_fee,
|
|
|
|
} as AuthenticationProviderStatusOk;
|
|
|
|
} catch (e) {
|
|
|
|
return {
|
|
|
|
code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED,
|
|
|
|
hint: "provider did not return JSON",
|
|
|
|
};
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async function backupEnterUserAttributes(
|
|
|
|
state: ReducerStateBackup,
|
2021-11-02 16:20:39 +01:00
|
|
|
args: ActionArgEnterUserAttributes,
|
2021-10-18 19:18:34 +02:00
|
|
|
): Promise<ReducerStateBackup> {
|
2021-11-02 16:20:39 +01:00
|
|
|
const attributes = args.identity_attributes;
|
2021-10-18 19:18:34 +02:00
|
|
|
const providerUrls = Object.keys(state.authentication_providers ?? {});
|
|
|
|
const newProviders = state.authentication_providers ?? {};
|
|
|
|
for (const url of providerUrls) {
|
|
|
|
newProviders[url] = await getProviderInfo(url);
|
|
|
|
}
|
|
|
|
const newState = {
|
|
|
|
...state,
|
|
|
|
backup_state: BackupStates.AuthenticationsEditing,
|
|
|
|
authentication_providers: newProviders,
|
|
|
|
identity_attributes: attributes,
|
|
|
|
};
|
|
|
|
return newState;
|
2021-10-07 12:01:40 +02:00
|
|
|
}
|
|
|
|
|
2021-10-18 19:18:34 +02:00
|
|
|
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);
|
2021-11-02 16:20:39 +01:00
|
|
|
logger.info(`selections: ${j2s(selections)}`);
|
2021-10-18 19:18:34 +02:00
|
|
|
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.
|
|
|
|
*/
|
2021-10-19 20:51:31 +02:00
|
|
|
truth_salt: string;
|
|
|
|
}
|
|
|
|
|
|
|
|
async function getTruthValue(
|
|
|
|
authMethod: AuthMethod,
|
|
|
|
truthUuid: string,
|
|
|
|
questionSalt: TruthSalt,
|
|
|
|
): Promise<OpaqueData> {
|
|
|
|
switch (authMethod.type) {
|
|
|
|
case "question": {
|
|
|
|
return asOpaque(
|
2021-10-19 23:26:29 +02:00
|
|
|
await secureAnswerHash(
|
|
|
|
bytesToString(decodeCrock(authMethod.challenge)),
|
|
|
|
truthUuid,
|
|
|
|
questionSalt,
|
|
|
|
),
|
2021-10-19 20:51:31 +02:00
|
|
|
);
|
|
|
|
}
|
|
|
|
case "sms":
|
|
|
|
case "email":
|
|
|
|
case "totp":
|
|
|
|
case "iban":
|
2021-10-19 23:49:11 +02:00
|
|
|
return authMethod.challenge;
|
2021-10-19 20:51:31 +02:00
|
|
|
default:
|
|
|
|
throw Error("unknown auth type");
|
|
|
|
}
|
2021-10-18 19:18:34 +02:00
|
|
|
}
|
|
|
|
|
2021-10-19 23:26:29 +02:00
|
|
|
/**
|
|
|
|
* Compress the recovery document and add a size header.
|
|
|
|
*/
|
|
|
|
async function compressRecoveryDoc(rd: any): Promise<Uint8Array> {
|
2021-11-02 16:20:39 +01:00
|
|
|
logger.info(`recovery document: ${j2s(rd)}`);
|
2021-10-19 23:26:29 +02:00
|
|
|
const docBytes = stringToBytes(JSON.stringify(rd));
|
|
|
|
const sizeHeaderBuf = new ArrayBuffer(4);
|
|
|
|
const dvbuf = new DataView(sizeHeaderBuf);
|
|
|
|
dvbuf.setUint32(0, docBytes.length, false);
|
|
|
|
const zippedDoc = zlibSync(docBytes);
|
|
|
|
return typedArrayConcat([new Uint8Array(sizeHeaderBuf), zippedDoc]);
|
|
|
|
}
|
|
|
|
|
2021-10-21 13:11:17 +02:00
|
|
|
async function uncompressRecoveryDoc(zippedRd: Uint8Array): Promise<any> {
|
|
|
|
const header = zippedRd.slice(0, 4);
|
|
|
|
const data = zippedRd.slice(4);
|
|
|
|
const res = unzlibSync(data);
|
|
|
|
return JSON.parse(bytesToString(res));
|
|
|
|
}
|
|
|
|
|
2021-10-18 19:18:34 +02:00
|
|
|
async function uploadSecret(
|
|
|
|
state: ReducerStateBackup,
|
|
|
|
): Promise<ReducerStateBackup | ReducerStateError> {
|
|
|
|
const policies = state.policies!;
|
|
|
|
const secretName = state.secret_name!;
|
2021-10-19 23:26:29 +02:00
|
|
|
const coreSecret: OpaqueData = encodeCrock(
|
|
|
|
stringToBytes(JSON.stringify(state.core_secret!)),
|
|
|
|
);
|
2021-10-18 19:18:34 +02:00
|
|
|
// Truth key is `${methodIndex}/${providerUrl}`
|
|
|
|
const truthMetadataMap: Record<string, TruthMetaData> = {};
|
2021-10-19 18:39:38 +02:00
|
|
|
|
2021-10-18 19:18:34 +02:00
|
|
|
const policyKeys: PolicyKey[] = [];
|
2021-10-19 18:39:38 +02:00
|
|
|
const policySalts: PolicySalt[] = [];
|
|
|
|
// truth UUIDs for every policy.
|
|
|
|
const policyUuids: string[][] = [];
|
2021-10-18 19:18:34 +02:00
|
|
|
|
|
|
|
for (let policyIndex = 0; policyIndex < policies.length; policyIndex++) {
|
|
|
|
const pol = policies[policyIndex];
|
|
|
|
const policySalt = encodeCrock(getRandomBytes(64));
|
|
|
|
const keyShares: string[] = [];
|
2021-10-19 18:39:38 +02:00
|
|
|
const methUuids: string[] = [];
|
2021-10-18 19:18:34 +02:00
|
|
|
for (let methIndex = 0; methIndex < pol.methods.length; methIndex++) {
|
|
|
|
const meth = pol.methods[methIndex];
|
2021-10-19 23:26:29 +02:00
|
|
|
const truthReference = `${meth.authentication_method}:${meth.provider}`;
|
2021-10-19 23:49:11 +02:00
|
|
|
let tm = truthMetadataMap[truthReference];
|
|
|
|
if (!tm) {
|
|
|
|
tm = {
|
|
|
|
key_share: encodeCrock(getRandomBytes(32)),
|
|
|
|
nonce: encodeCrock(getRandomBytes(24)),
|
|
|
|
truth_salt: encodeCrock(getRandomBytes(16)),
|
|
|
|
truth_key: encodeCrock(getRandomBytes(64)),
|
|
|
|
uuid: encodeCrock(getRandomBytes(32)),
|
|
|
|
pol_method_index: methIndex,
|
|
|
|
policy_index: policyIndex,
|
|
|
|
};
|
|
|
|
truthMetadataMap[truthReference] = tm;
|
2021-10-18 19:18:34 +02:00
|
|
|
}
|
2021-10-19 23:49:11 +02:00
|
|
|
keyShares.push(tm.key_share);
|
2021-10-19 18:39:38 +02:00
|
|
|
methUuids.push(tm.uuid);
|
2021-10-18 19:18:34 +02:00
|
|
|
}
|
|
|
|
const policyKey = await policyKeyDerive(keyShares, policySalt);
|
2021-10-19 23:26:29 +02:00
|
|
|
policyUuids.push(methUuids);
|
2021-10-18 19:18:34 +02:00
|
|
|
policyKeys.push(policyKey);
|
2021-10-19 18:39:38 +02:00
|
|
|
policySalts.push(policySalt);
|
2021-10-18 19:18:34 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
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)) {
|
|
|
|
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 provider = state.authentication_providers![
|
|
|
|
meth.provider
|
|
|
|
] as AuthenticationProviderStatusOk;
|
2021-10-19 20:51:31 +02:00
|
|
|
const truthValue = await getTruthValue(authMethod, tm.uuid, tm.truth_salt);
|
2021-10-18 19:18:34 +02:00
|
|
|
const encryptedTruth = await encryptTruth(
|
|
|
|
tm.nonce,
|
|
|
|
tm.truth_key,
|
2021-10-19 20:51:31 +02:00
|
|
|
truthValue,
|
2021-10-18 19:18:34 +02:00
|
|
|
);
|
|
|
|
const uid = uidMap[meth.provider];
|
2021-10-19 20:51:31 +02:00
|
|
|
const encryptedKeyShare = await encryptKeyshare(
|
|
|
|
tm.key_share,
|
|
|
|
uid,
|
2021-10-19 23:26:29 +02:00
|
|
|
authMethod.type === "question"
|
|
|
|
? bytesToString(decodeCrock(authMethod.challenge))
|
|
|
|
: undefined,
|
2021-10-19 20:51:31 +02:00
|
|
|
);
|
2021-10-18 19:18:34 +02:00
|
|
|
const tur: TruthUploadRequest = {
|
|
|
|
encrypted_truth: encryptedTruth,
|
|
|
|
key_share_data: encryptedKeyShare,
|
|
|
|
storage_duration_years: 5 /* FIXME */,
|
|
|
|
type: authMethod.type,
|
|
|
|
truth_mime: authMethod.mime_type,
|
|
|
|
};
|
|
|
|
const resp = await fetch(new URL(`truth/${tm.uuid}`, meth.provider).href, {
|
|
|
|
method: "POST",
|
|
|
|
headers: {
|
|
|
|
"content-type": "application/json",
|
|
|
|
},
|
|
|
|
body: JSON.stringify(tur),
|
|
|
|
});
|
|
|
|
|
2021-10-19 18:39:38 +02:00
|
|
|
if (resp.status !== 204) {
|
|
|
|
return {
|
|
|
|
code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED,
|
|
|
|
hint: "could not upload policy",
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2021-10-18 19:18:34 +02:00
|
|
|
escrowMethods.push({
|
|
|
|
escrow_type: authMethod.type,
|
|
|
|
instructions: authMethod.instructions,
|
|
|
|
provider_salt: provider.salt,
|
2021-10-19 23:26:29 +02:00
|
|
|
truth_salt: tm.truth_salt,
|
2021-10-18 19:18:34 +02:00
|
|
|
truth_key: tm.truth_key,
|
|
|
|
url: meth.provider,
|
|
|
|
uuid: tm.uuid,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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.
|
|
|
|
|
|
|
|
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],
|
2021-10-19 23:26:29 +02:00
|
|
|
uuids: policyUuids[i],
|
2021-10-19 18:39:38 +02:00
|
|
|
salt: policySalts[i],
|
2021-10-18 19:18:34 +02:00
|
|
|
};
|
|
|
|
}),
|
|
|
|
};
|
|
|
|
|
2021-10-19 18:39:38 +02:00
|
|
|
const successDetails: SuccessDetails = {};
|
|
|
|
|
2021-10-18 19:18:34 +02:00
|
|
|
for (const prov of state.policy_providers!) {
|
2021-10-19 18:39:38 +02:00
|
|
|
const uid = uidMap[prov.provider_url];
|
2021-10-18 21:48:22 +02:00
|
|
|
const acctKeypair = accountKeypairDerive(uid);
|
2021-10-19 23:26:29 +02:00
|
|
|
const zippedDoc = await compressRecoveryDoc(rd);
|
|
|
|
const encRecoveryDoc = await encryptRecoveryDocument(
|
|
|
|
uid,
|
|
|
|
encodeCrock(zippedDoc),
|
|
|
|
);
|
2021-10-19 18:39:38 +02:00
|
|
|
const bodyHash = hash(decodeCrock(encRecoveryDoc));
|
|
|
|
const sigPS = buildSigPS(TalerSignaturePurpose.ANASTASIS_POLICY_UPLOAD)
|
|
|
|
.put(bodyHash)
|
|
|
|
.build();
|
|
|
|
const sig = eddsaSign(sigPS, decodeCrock(acctKeypair.priv));
|
2021-10-18 21:48:22 +02:00
|
|
|
const resp = await fetch(
|
|
|
|
new URL(`policy/${acctKeypair.pub}`, prov.provider_url).href,
|
|
|
|
{
|
|
|
|
method: "POST",
|
2021-10-19 18:39:38 +02:00
|
|
|
headers: {
|
|
|
|
"Anastasis-Policy-Signature": encodeCrock(sig),
|
|
|
|
"If-None-Match": encodeCrock(bodyHash),
|
|
|
|
},
|
2021-10-18 21:48:22 +02:00
|
|
|
body: decodeCrock(encRecoveryDoc),
|
|
|
|
},
|
|
|
|
);
|
2021-10-19 18:39:38 +02:00
|
|
|
if (resp.status !== 204) {
|
|
|
|
return {
|
|
|
|
code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED,
|
|
|
|
hint: "could not upload policy",
|
|
|
|
};
|
|
|
|
}
|
|
|
|
let policyVersion = 0;
|
2021-10-19 20:51:31 +02:00
|
|
|
let policyExpiration: Timestamp = { t_ms: 0 };
|
2021-10-19 18:39:38 +02:00
|
|
|
try {
|
|
|
|
policyVersion = Number(resp.headers.get("Anastasis-Version") ?? "0");
|
|
|
|
} catch (e) {}
|
2021-10-19 20:51:31 +02:00
|
|
|
try {
|
|
|
|
policyExpiration = {
|
|
|
|
t_ms:
|
|
|
|
1000 * Number(resp.headers.get("Anastasis-Policy-Expiration") ?? "0"),
|
|
|
|
};
|
|
|
|
} catch (e) {}
|
2021-10-19 18:39:38 +02:00
|
|
|
successDetails[prov.provider_url] = {
|
|
|
|
policy_version: policyVersion,
|
2021-10-19 20:51:31 +02:00
|
|
|
policy_expiration: policyExpiration,
|
2021-10-19 18:39:38 +02:00
|
|
|
};
|
2021-10-18 19:18:34 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
2021-10-19 18:39:38 +02:00
|
|
|
...state,
|
|
|
|
backup_state: BackupStates.BackupFinished,
|
|
|
|
success_details: successDetails,
|
2021-10-18 19:18:34 +02:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2021-10-21 13:11:17 +02:00
|
|
|
/**
|
|
|
|
* Download policy based on current user attributes and selected
|
|
|
|
* version in the state.
|
|
|
|
*/
|
|
|
|
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;
|
2021-10-21 18:51:19 +02:00
|
|
|
const newProviderStatus: { [url: string]: AuthenticationProviderStatusOk } =
|
|
|
|
{};
|
2021-10-21 13:11:17 +02:00
|
|
|
const userAttributes = state.identity_attributes!;
|
2021-10-21 18:51:19 +02:00
|
|
|
// FIXME: Shouldn't we also store the status of bad providers?
|
2021-10-21 13:11:17 +02:00
|
|
|
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;
|
2021-10-21 18:51:19 +02:00
|
|
|
}
|
|
|
|
for (const url of providerUrls) {
|
|
|
|
const pi = newProviderStatus[url];
|
|
|
|
if (!pi) {
|
|
|
|
continue;
|
|
|
|
}
|
2021-10-21 13:11:17 +02:00
|
|
|
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),
|
|
|
|
);
|
|
|
|
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) {
|
|
|
|
return {
|
|
|
|
code: TalerErrorCode.ANASTASIS_REDUCER_POLICY_LOOKUP_FAILED,
|
|
|
|
hint: "No backups found at any provider for your identity information.",
|
|
|
|
};
|
|
|
|
}
|
|
|
|
const recoveryInfo: RecoveryInformation = {
|
|
|
|
challenges: recoveryDoc.escrow_methods.map((x) => {
|
|
|
|
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,
|
|
|
|
};
|
|
|
|
}),
|
|
|
|
policies: recoveryDoc.policies.map((x) => {
|
|
|
|
return x.uuids.map((m) => {
|
|
|
|
return {
|
|
|
|
uuid: m,
|
|
|
|
};
|
|
|
|
});
|
|
|
|
}),
|
|
|
|
};
|
|
|
|
return {
|
|
|
|
...state,
|
|
|
|
recovery_state: RecoveryStates.SecretSelecting,
|
|
|
|
recovery_document: foundRecoveryInfo,
|
|
|
|
recovery_information: recoveryInfo,
|
2021-10-21 18:51:19 +02:00
|
|
|
verbatim_recovery_document: recoveryDoc,
|
2021-10-21 13:11:17 +02:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2021-10-21 18:51:19 +02:00
|
|
|
/**
|
|
|
|
* Try to reconstruct the secret from the available shares.
|
|
|
|
*
|
|
|
|
* Returns the state unmodified if not enough key shares are available yet.
|
|
|
|
*/
|
|
|
|
async function tryRecoverSecret(
|
|
|
|
state: ReducerStateRecovery,
|
|
|
|
): Promise<ReducerStateRecovery | ReducerStateError> {
|
|
|
|
const rd = state.verbatim_recovery_document!;
|
|
|
|
for (const p of rd.policies) {
|
|
|
|
const keyShares: KeyShare[] = [];
|
|
|
|
let missing = false;
|
|
|
|
for (const truthUuid of p.uuids) {
|
|
|
|
const ks = (state.recovered_key_shares ?? {})[truthUuid];
|
|
|
|
if (!ks) {
|
|
|
|
missing = true;
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
keyShares.push(ks);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (missing) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
const policyKey = await policyKeyDerive(keyShares, p.salt);
|
|
|
|
const coreSecretBytes = await coreSecretRecover({
|
|
|
|
encryptedCoreSecret: rd.encrypted_core_secret,
|
|
|
|
encryptedMasterKey: p.master_key,
|
|
|
|
policyKey,
|
|
|
|
});
|
|
|
|
|
|
|
|
return {
|
|
|
|
...state,
|
|
|
|
recovery_state: RecoveryStates.RecoveryFinished,
|
|
|
|
selected_challenge_uuid: undefined,
|
|
|
|
core_secret: JSON.parse(bytesToString(decodeCrock(coreSecretBytes))),
|
|
|
|
};
|
|
|
|
}
|
|
|
|
return { ...state };
|
|
|
|
}
|
|
|
|
|
|
|
|
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 "truth for challenge not found";
|
|
|
|
}
|
|
|
|
|
|
|
|
const url = new URL(`/truth/${truth.uuid}`, truth.url);
|
|
|
|
|
|
|
|
// FIXME: This isn't correct for non-question truth responses.
|
|
|
|
url.searchParams.set(
|
|
|
|
"response",
|
|
|
|
await secureAnswerHash(ta.answer, truth.uuid, truth.truth_salt),
|
|
|
|
);
|
|
|
|
|
|
|
|
const resp = await fetch(url.href, {
|
|
|
|
headers: {
|
|
|
|
"Anastasis-Truth-Decryption-Key": truth.truth_key,
|
|
|
|
},
|
|
|
|
});
|
|
|
|
|
|
|
|
if (resp.status !== 200) {
|
|
|
|
return {
|
|
|
|
code: TalerErrorCode.ANASTASIS_TRUTH_CHALLENGE_FAILED,
|
|
|
|
hint: "got non-200 response",
|
|
|
|
http_status: resp.status,
|
|
|
|
} as ReducerStateError;
|
|
|
|
}
|
|
|
|
|
|
|
|
const answerSalt = truth.escrow_type === "question" ? ta.answer : undefined;
|
|
|
|
|
|
|
|
const userId = await userIdentifierDerive(
|
|
|
|
state.identity_attributes,
|
|
|
|
truth.provider_salt,
|
|
|
|
);
|
|
|
|
|
|
|
|
const respBody = new Uint8Array(await resp.arrayBuffer());
|
|
|
|
const keyShare = await decryptKeyShare(
|
|
|
|
encodeCrock(respBody),
|
|
|
|
userId,
|
|
|
|
answerSalt,
|
|
|
|
);
|
|
|
|
|
|
|
|
const recoveredKeyShares = {
|
|
|
|
...(state.recovered_key_shares ?? {}),
|
|
|
|
[truth.uuid]: keyShare,
|
|
|
|
};
|
|
|
|
|
|
|
|
const challengeFeedback = {
|
|
|
|
...state.challenge_feedback,
|
|
|
|
[truth.uuid]: {
|
|
|
|
state: "solved",
|
|
|
|
},
|
|
|
|
};
|
|
|
|
|
|
|
|
const newState: ReducerStateRecovery = {
|
|
|
|
...state,
|
|
|
|
recovery_state: RecoveryStates.ChallengeSelecting,
|
|
|
|
challenge_feedback: challengeFeedback,
|
|
|
|
recovered_key_shares: recoveredKeyShares,
|
|
|
|
};
|
|
|
|
|
|
|
|
return tryRecoverSecret(newState);
|
|
|
|
}
|
|
|
|
|
2021-10-21 13:11:17 +02:00
|
|
|
async function recoveryEnterUserAttributes(
|
|
|
|
state: ReducerStateRecovery,
|
2021-11-02 16:20:39 +01:00
|
|
|
args: ActionArgEnterUserAttributes,
|
2021-10-21 13:11:17 +02:00
|
|
|
): Promise<ReducerStateRecovery | ReducerStateError> {
|
|
|
|
// FIXME: validate attributes
|
|
|
|
const st: ReducerStateRecovery = {
|
|
|
|
...state,
|
2021-11-02 16:20:39 +01:00
|
|
|
identity_attributes: args.identity_attributes,
|
2021-10-21 13:11:17 +02:00
|
|
|
};
|
|
|
|
return downloadPolicy(st);
|
|
|
|
}
|
|
|
|
|
2021-10-21 18:51:19 +02:00
|
|
|
async function selectChallenge(
|
|
|
|
state: ReducerStateRecovery,
|
|
|
|
ta: ActionArgsSelectChallenge,
|
|
|
|
): Promise<ReducerStateRecovery | ReducerStateError> {
|
|
|
|
const recDoc: RecoveryDocument = state.verbatim_recovery_document!;
|
|
|
|
const truth = recDoc.escrow_methods.find((x) => x.uuid === ta.uuid);
|
|
|
|
if (!truth) {
|
|
|
|
throw "truth for challenge not found";
|
|
|
|
}
|
|
|
|
|
|
|
|
const url = new URL(`/truth/${truth.uuid}`, truth.url);
|
|
|
|
|
|
|
|
const resp = await fetch(url.href, {
|
|
|
|
headers: {
|
|
|
|
"Anastasis-Truth-Decryption-Key": truth.truth_key,
|
|
|
|
},
|
|
|
|
});
|
|
|
|
|
|
|
|
return {
|
|
|
|
...state,
|
|
|
|
recovery_state: RecoveryStates.ChallengeSolving,
|
|
|
|
selected_challenge_uuid: ta.uuid,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2021-11-02 16:20:39 +01:00
|
|
|
async function backupSelectContinent(
|
|
|
|
state: ReducerStateBackup,
|
|
|
|
args: ActionArgSelectContinent,
|
|
|
|
): Promise<ReducerStateBackup | ReducerStateError> {
|
|
|
|
const countries = getCountries(args.continent);
|
|
|
|
if (countries.length <= 0) {
|
|
|
|
return {
|
|
|
|
code: TalerErrorCode.ANASTASIS_REDUCER_INPUT_INVALID,
|
|
|
|
hint: "continent not found",
|
|
|
|
};
|
2021-10-18 19:18:34 +02:00
|
|
|
}
|
2021-11-02 16:20:39 +01:00
|
|
|
return {
|
|
|
|
...state,
|
|
|
|
backup_state: BackupStates.CountrySelecting,
|
|
|
|
countries,
|
|
|
|
selected_continent: args.continent,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
async function recoverySelectContinent(
|
|
|
|
state: ReducerStateRecovery,
|
|
|
|
args: ActionArgSelectContinent,
|
|
|
|
): Promise<ReducerStateRecovery | ReducerStateError> {
|
|
|
|
const countries = getCountries(args.continent);
|
|
|
|
return {
|
|
|
|
...state,
|
|
|
|
recovery_state: RecoveryStates.CountrySelecting,
|
|
|
|
countries,
|
|
|
|
selected_continent: args.continent,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
interface TransitionImpl<S, T> {
|
|
|
|
argCodec: Codec<T>;
|
|
|
|
handler: (s: S, args: T) => Promise<S | ReducerStateError>;
|
|
|
|
}
|
|
|
|
|
|
|
|
interface Transition<S, T> {
|
|
|
|
[x: string]: TransitionImpl<S, T>;
|
|
|
|
}
|
|
|
|
|
|
|
|
function transition<S, T>(
|
|
|
|
action: string,
|
|
|
|
argCodec: Codec<T>,
|
|
|
|
handler: (s: S, args: T) => Promise<S | ReducerStateError>,
|
|
|
|
): Transition<S, T> {
|
|
|
|
return {
|
|
|
|
[action]: {
|
|
|
|
argCodec,
|
|
|
|
handler,
|
|
|
|
},
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
function transitionBackupJump(
|
|
|
|
action: string,
|
|
|
|
st: BackupStates,
|
|
|
|
): Transition<ReducerStateBackup, void> {
|
|
|
|
return {
|
|
|
|
[action]: {
|
|
|
|
argCodec: codecForAny(),
|
|
|
|
handler: async (s, a) => ({ ...s, backup_state: st }),
|
|
|
|
},
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
function transitionRecoveryJump(
|
|
|
|
action: string,
|
|
|
|
st: RecoveryStates,
|
|
|
|
): Transition<ReducerStateRecovery, void> {
|
|
|
|
return {
|
|
|
|
[action]: {
|
|
|
|
argCodec: codecForAny(),
|
|
|
|
handler: async (s, a) => ({ ...s, recovery_state: st }),
|
|
|
|
},
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
async function addAuthentication(
|
|
|
|
state: ReducerStateBackup,
|
|
|
|
args: ActionArgAddAuthentication,
|
|
|
|
): Promise<ReducerStateBackup> {
|
|
|
|
return {
|
|
|
|
...state,
|
|
|
|
authentication_methods: [
|
|
|
|
...(state.authentication_methods ?? []),
|
|
|
|
args.authentication_method,
|
|
|
|
],
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
async function deleteAuthentication(
|
|
|
|
state: ReducerStateBackup,
|
|
|
|
args: ActionArgDeleteAuthentication,
|
|
|
|
): Promise<ReducerStateBackup> {
|
|
|
|
const m = state.authentication_methods ?? [];
|
|
|
|
m.splice(args.authentication_method, 1);
|
|
|
|
return {
|
|
|
|
...state,
|
|
|
|
authentication_methods: m,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
async function deletePolicy(
|
|
|
|
state: ReducerStateBackup,
|
|
|
|
args: ActionArgDeletePolicy,
|
|
|
|
): Promise<ReducerStateBackup> {
|
|
|
|
const policies = [...(state.policies ?? [])];
|
|
|
|
policies.splice(args.policy_index, 1);
|
|
|
|
return {
|
|
|
|
...state,
|
|
|
|
policies,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
async function addPolicy(
|
|
|
|
state: ReducerStateBackup,
|
|
|
|
args: ActionArgsAddPolicy,
|
|
|
|
): Promise<ReducerStateBackup> {
|
|
|
|
return {
|
|
|
|
...state,
|
|
|
|
policies: [
|
|
|
|
...(state.policies ?? []),
|
|
|
|
{
|
|
|
|
methods: args.policy,
|
|
|
|
},
|
|
|
|
],
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
async function nextFromAuthenticationsEditing(
|
|
|
|
state: ReducerStateBackup,
|
|
|
|
args: {},
|
|
|
|
): Promise<ReducerStateBackup | ReducerStateError> {
|
|
|
|
const methods = state.authentication_methods ?? [];
|
|
|
|
const providers: ProviderInfo[] = [];
|
|
|
|
for (const provUrl of Object.keys(state.authentication_providers ?? {})) {
|
|
|
|
const prov = state.authentication_providers![provUrl];
|
|
|
|
if ("error_code" in prov) {
|
|
|
|
continue;
|
2021-10-18 19:18:34 +02:00
|
|
|
}
|
2021-11-02 16:20:39 +01:00
|
|
|
if (!("http_status" in prov && prov.http_status === 200)) {
|
|
|
|
continue;
|
2021-10-18 19:18:34 +02:00
|
|
|
}
|
2021-11-02 16:20:39 +01:00
|
|
|
const methodCost: Record<string, AmountString> = {};
|
|
|
|
for (const meth of prov.methods) {
|
|
|
|
methodCost[meth.type] = meth.usage_fee;
|
2021-10-19 18:39:38 +02:00
|
|
|
}
|
2021-11-02 16:20:39 +01:00
|
|
|
providers.push({
|
|
|
|
methodCost,
|
|
|
|
url: provUrl,
|
|
|
|
});
|
2021-10-19 18:39:38 +02:00
|
|
|
}
|
2021-11-02 16:20:39 +01:00
|
|
|
const pol = suggestPolicies(methods, providers);
|
|
|
|
return {
|
|
|
|
...state,
|
|
|
|
backup_state: BackupStates.PoliciesReviewing,
|
|
|
|
...pol,
|
|
|
|
};
|
|
|
|
}
|
2021-10-21 13:11:17 +02:00
|
|
|
|
2021-11-02 16:20:39 +01:00
|
|
|
async function updateUploadFees(
|
|
|
|
state: ReducerStateBackup,
|
|
|
|
): Promise<ReducerStateBackup | ReducerStateError> {
|
|
|
|
for (const prov of state.policy_providers ?? []) {
|
|
|
|
const info = state.authentication_providers![prov.provider_url];
|
|
|
|
if (!("currency" in info)) {
|
|
|
|
continue;
|
2021-10-21 13:11:17 +02:00
|
|
|
}
|
|
|
|
}
|
2021-11-02 16:20:39 +01:00
|
|
|
return { ...state, upload_fees: [] };
|
|
|
|
}
|
2021-10-21 13:11:17 +02:00
|
|
|
|
2021-11-02 16:20:39 +01:00
|
|
|
async function enterSecret(
|
|
|
|
state: ReducerStateBackup,
|
|
|
|
args: ActionArgEnterSecret,
|
|
|
|
): Promise<ReducerStateBackup | ReducerStateError> {
|
|
|
|
return {
|
|
|
|
...state,
|
|
|
|
expiration: args.expiration,
|
|
|
|
core_secret: {
|
|
|
|
mime: args.secret.mime ?? "text/plain",
|
|
|
|
value: args.secret.value,
|
|
|
|
},
|
|
|
|
};
|
|
|
|
}
|
2021-10-21 13:11:17 +02:00
|
|
|
|
2021-11-02 16:20:39 +01:00
|
|
|
async function nextFromChallengeSelecting(
|
|
|
|
state: ReducerStateRecovery,
|
|
|
|
args: void,
|
|
|
|
): Promise<ReducerStateRecovery | ReducerStateError> {
|
|
|
|
const s2 = await tryRecoverSecret(state);
|
|
|
|
if (s2.recovery_state === RecoveryStates.RecoveryFinished) {
|
|
|
|
return s2;
|
2021-10-21 13:11:17 +02:00
|
|
|
}
|
2021-11-02 16:20:39 +01:00
|
|
|
return {
|
|
|
|
code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
|
|
|
|
hint: "Not enough challenges solved",
|
|
|
|
};
|
|
|
|
}
|
2021-10-21 13:11:17 +02:00
|
|
|
|
2021-11-02 16:20:39 +01:00
|
|
|
async function enterSecretName(
|
|
|
|
state: ReducerStateBackup,
|
|
|
|
args: ActionArgEnterSecretName,
|
|
|
|
): Promise<ReducerStateBackup | ReducerStateError> {
|
|
|
|
return {
|
|
|
|
...state,
|
|
|
|
secret_name: args.name,
|
|
|
|
};
|
|
|
|
}
|
2021-10-21 13:11:17 +02:00
|
|
|
|
2021-11-02 16:20:39 +01:00
|
|
|
async function updateSecretExpiration(
|
|
|
|
state: ReducerStateBackup,
|
|
|
|
args: ActionArgsUpdateExpiration,
|
|
|
|
): Promise<ReducerStateBackup | ReducerStateError> {
|
|
|
|
// FIXME: implement!
|
|
|
|
return {
|
|
|
|
...state,
|
|
|
|
expiration: args.expiration,
|
|
|
|
};
|
|
|
|
}
|
2021-10-21 13:11:17 +02:00
|
|
|
|
2021-11-02 16:20:39 +01:00
|
|
|
const backupTransitions: Record<
|
|
|
|
BackupStates,
|
|
|
|
Transition<ReducerStateBackup, any>
|
|
|
|
> = {
|
|
|
|
[BackupStates.ContinentSelecting]: {
|
|
|
|
...transition(
|
|
|
|
"select_continent",
|
|
|
|
codecForActionArgSelectContinent(),
|
|
|
|
backupSelectContinent,
|
|
|
|
),
|
|
|
|
},
|
|
|
|
[BackupStates.CountrySelecting]: {
|
|
|
|
...transitionBackupJump("back", BackupStates.ContinentSelecting),
|
|
|
|
...transition(
|
|
|
|
"select_country",
|
|
|
|
codecForActionArgSelectCountry(),
|
|
|
|
backupSelectCountry,
|
|
|
|
),
|
|
|
|
...transition(
|
|
|
|
"select_continent",
|
|
|
|
codecForActionArgSelectContinent(),
|
|
|
|
backupSelectContinent,
|
|
|
|
),
|
|
|
|
},
|
|
|
|
[BackupStates.UserAttributesCollecting]: {
|
|
|
|
...transitionBackupJump("back", BackupStates.CountrySelecting),
|
|
|
|
...transition(
|
|
|
|
"enter_user_attributes",
|
|
|
|
codecForActionArgEnterUserAttributes(),
|
|
|
|
backupEnterUserAttributes,
|
|
|
|
),
|
|
|
|
},
|
|
|
|
[BackupStates.AuthenticationsEditing]: {
|
|
|
|
...transitionBackupJump("back", BackupStates.UserAttributesCollecting),
|
|
|
|
...transition("add_authentication", codecForAny(), addAuthentication),
|
|
|
|
...transition("delete_authentication", codecForAny(), deleteAuthentication),
|
|
|
|
...transition("next", codecForAny(), nextFromAuthenticationsEditing),
|
|
|
|
},
|
|
|
|
[BackupStates.PoliciesReviewing]: {
|
|
|
|
...transitionBackupJump("back", BackupStates.AuthenticationsEditing),
|
|
|
|
...transitionBackupJump("next", BackupStates.SecretEditing),
|
|
|
|
...transition("add_policy", codecForActionArgsAddPolicy(), addPolicy),
|
|
|
|
...transition("delete_policy", codecForAny(), deletePolicy),
|
|
|
|
},
|
|
|
|
[BackupStates.SecretEditing]: {
|
|
|
|
...transitionBackupJump("back", BackupStates.PoliciesPaying),
|
|
|
|
...transition("next", codecForAny(), uploadSecret),
|
|
|
|
...transition("enter_secret", codecForAny(), enterSecret),
|
|
|
|
...transition(
|
|
|
|
"update_expiration",
|
|
|
|
codecForActionArgsUpdateExpiration(),
|
|
|
|
updateSecretExpiration,
|
|
|
|
),
|
|
|
|
...transition("enter_secret_name", codecForAny(), enterSecretName),
|
|
|
|
},
|
|
|
|
[BackupStates.PoliciesPaying]: {},
|
|
|
|
[BackupStates.TruthsPaying]: {},
|
|
|
|
[BackupStates.PoliciesPaying]: {},
|
|
|
|
[BackupStates.BackupFinished]: {
|
|
|
|
...transitionBackupJump("back", BackupStates.SecretEditing),
|
|
|
|
},
|
|
|
|
};
|
|
|
|
|
|
|
|
const recoveryTransitions: Record<
|
|
|
|
RecoveryStates,
|
|
|
|
Transition<ReducerStateRecovery, any>
|
|
|
|
> = {
|
|
|
|
[RecoveryStates.ContinentSelecting]: {
|
|
|
|
...transition(
|
|
|
|
"select_continent",
|
|
|
|
codecForActionArgSelectContinent(),
|
|
|
|
recoverySelectContinent,
|
|
|
|
),
|
|
|
|
},
|
|
|
|
[RecoveryStates.CountrySelecting]: {
|
|
|
|
...transitionRecoveryJump("back", RecoveryStates.ContinentSelecting),
|
|
|
|
...transition(
|
|
|
|
"select_country",
|
|
|
|
codecForActionArgSelectCountry(),
|
|
|
|
recoverySelectCountry,
|
|
|
|
),
|
|
|
|
...transition(
|
|
|
|
"select_continent",
|
|
|
|
codecForActionArgSelectContinent(),
|
|
|
|
recoverySelectContinent,
|
|
|
|
),
|
|
|
|
},
|
|
|
|
[RecoveryStates.UserAttributesCollecting]: {
|
|
|
|
...transitionRecoveryJump("back", RecoveryStates.CountrySelecting),
|
|
|
|
...transition(
|
|
|
|
"enter_user_attributes",
|
|
|
|
codecForActionArgEnterUserAttributes(),
|
|
|
|
recoveryEnterUserAttributes,
|
|
|
|
),
|
|
|
|
},
|
|
|
|
[RecoveryStates.SecretSelecting]: {
|
|
|
|
...transitionRecoveryJump("back", RecoveryStates.UserAttributesCollecting),
|
|
|
|
...transitionRecoveryJump("next", RecoveryStates.ChallengeSelecting),
|
|
|
|
},
|
|
|
|
[RecoveryStates.ChallengeSelecting]: {
|
|
|
|
...transitionRecoveryJump("back", RecoveryStates.SecretSelecting),
|
|
|
|
...transition(
|
|
|
|
"select_challenge",
|
|
|
|
codecForActionArgSelectChallenge(),
|
|
|
|
selectChallenge,
|
|
|
|
),
|
|
|
|
...transition("next", codecForAny(), nextFromChallengeSelecting),
|
|
|
|
},
|
|
|
|
[RecoveryStates.ChallengeSolving]: {
|
|
|
|
...transitionRecoveryJump("back", RecoveryStates.ChallengeSelecting),
|
|
|
|
...transition("solve_challenge", codecForAny(), solveChallenge),
|
|
|
|
},
|
|
|
|
[RecoveryStates.ChallengePaying]: {},
|
|
|
|
[RecoveryStates.RecoveryFinished]: {},
|
|
|
|
};
|
2021-10-21 18:51:19 +02:00
|
|
|
|
2021-11-02 16:20:39 +01:00
|
|
|
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;
|
2021-10-21 13:11:17 +02:00
|
|
|
}
|
2021-11-02 16:20:39 +01:00
|
|
|
throw e;
|
2021-10-21 13:11:17 +02:00
|
|
|
}
|
2021-10-18 19:18:34 +02:00
|
|
|
}
|