summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFlorian Dold <florian@dold.me>2021-10-18 19:18:34 +0200
committerFlorian Dold <florian@dold.me>2021-10-18 19:18:34 +0200
commitb1034801d124b53cbb683e4a430ac00c7979bca1 (patch)
treee9ff53c03369c978436af9700e2dffcc2841d111
parent1b425294791fadda2d5751b85cda6b90a4546556 (diff)
downloadwallet-core-b1034801d124b53cbb683e4a430ac00c7979bca1.tar.gz
wallet-core-b1034801d124b53cbb683e4a430ac00c7979bca1.tar.bz2
wallet-core-b1034801d124b53cbb683e4a430ac00c7979bca1.zip
reducer implementation WIP
-rw-r--r--packages/anastasis-core/package.json8
-rw-r--r--packages/anastasis-core/src/crypto.ts152
-rw-r--r--packages/anastasis-core/src/index.ts701
-rw-r--r--packages/anastasis-core/src/provider-types.ts74
-rw-r--r--packages/anastasis-core/src/reducer-types.ts241
-rw-r--r--packages/anastasis-core/tsconfig.json2
6 files changed, 1154 insertions, 24 deletions
diff --git a/packages/anastasis-core/package.json b/packages/anastasis-core/package.json
index acc46f7c1..f4b611ed1 100644
--- a/packages/anastasis-core/package.json
+++ b/packages/anastasis-core/package.json
@@ -2,7 +2,9 @@
"name": "anastasis-core",
"version": "0.0.1",
"description": "",
- "main": "index.js",
+ "main": "./lib/index.js",
+ "module": "./lib/index.js",
+ "types": "./lib/index.d.ts",
"scripts": {
"prepare": "tsc",
"compile": "tsc",
@@ -20,7 +22,9 @@
},
"dependencies": {
"@gnu-taler/taler-util": "workspace:^0.8.3",
- "hash-wasm": "^4.9.0"
+ "fetch-ponyfill": "^7.1.0",
+ "hash-wasm": "^4.9.0",
+ "node-fetch": "^3.0.0"
},
"ava": {
"files": [
diff --git a/packages/anastasis-core/src/crypto.ts b/packages/anastasis-core/src/crypto.ts
index c20d323a7..5da3a4cce 100644
--- a/packages/anastasis-core/src/crypto.ts
+++ b/packages/anastasis-core/src/crypto.ts
@@ -1,15 +1,44 @@
import {
+ bytesToString,
canonicalJson,
decodeCrock,
encodeCrock,
+ getRandomBytes,
+ kdf,
+ secretbox,
stringToBytes,
} from "@gnu-taler/taler-util";
import { argon2id } from "hash-wasm";
+export type Flavor<T, FlavorT> = T & { _flavor?: FlavorT };
+export type FlavorP<T, FlavorT, S extends number> = T & {
+ _flavor?: FlavorT;
+ _size?: S;
+};
+
+export type UserIdentifier = Flavor<string, "UserIdentifier">;
+export type ServerSalt = Flavor<string, "ServerSalt">;
+export type PolicySalt = Flavor<string, "PolicySalt">;
+export type PolicyKey = FlavorP<string, "PolicyKey", 64>;
+export type KeyShare = Flavor<string, "KeyShare">;
+export type EncryptedKeyShare = Flavor<string, "EncryptedKeyShare">;
+export type EncryptedTruth = Flavor<string, "EncryptedTruth">;
+export type EncryptedCoreSecret = Flavor<string, "EncryptedCoreSecret">;
+export type EncryptedMasterKey = Flavor<string, "EncryptedMasterKey">;
+/**
+ * Truth key, found in the recovery document.
+ */
+export type TruthKey = Flavor<string, "TruthKey">;
+export type EncryptionNonce = Flavor<string, "EncryptionNonce">;
+export type OpaqueData = Flavor<string, "OpaqueData">;
+
+const nonceSize = 24;
+const masterKeySize = 64;
+
export async function userIdentifierDerive(
idData: any,
- serverSalt: string,
-): Promise<string> {
+ serverSalt: ServerSalt,
+): Promise<UserIdentifier> {
const canonIdData = canonicalJson(idData);
const hashInput = stringToBytes(canonIdData);
const result = await argon2id({
@@ -24,15 +53,114 @@ export async function userIdentifierDerive(
return encodeCrock(result);
}
-// interface Keypair {
-// pub: string;
-// priv: string;
-// }
+function taConcat(chunks: Uint8Array[]): Uint8Array {
+ let payloadLen = 0;
+ for (const c of chunks) {
+ payloadLen += c.byteLength;
+ }
+ const buf = new ArrayBuffer(payloadLen);
+ const u8buf = new Uint8Array(buf);
+ let p = 0;
+ for (const c of chunks) {
+ u8buf.set(c, p);
+ p += c.byteLength;
+ }
+ return u8buf;
+}
-// async function accountKeypairDerive(): Promise<Keypair> {}
+export async function policyKeyDerive(
+ keyShares: KeyShare[],
+ policySalt: PolicySalt,
+): Promise<PolicyKey> {
+ const chunks = keyShares.map((x) => decodeCrock(x));
+ const polKey = kdf(
+ 64,
+ taConcat(chunks),
+ decodeCrock(policySalt),
+ new Uint8Array(0),
+ );
+ return encodeCrock(polKey);
+}
+
+async function deriveKey(
+ keySeed: OpaqueData,
+ nonce: EncryptionNonce,
+ salt: string,
+): Promise<Uint8Array> {
+ return kdf(32, decodeCrock(keySeed), stringToBytes(salt), decodeCrock(nonce));
+}
+
+async function anastasisEncrypt(
+ nonce: EncryptionNonce,
+ keySeed: OpaqueData,
+ plaintext: OpaqueData,
+ salt: string,
+): Promise<OpaqueData> {
+ const key = await deriveKey(keySeed, nonce, salt);
+ const nonceBuf = decodeCrock(nonce);
+ const cipherText = secretbox(decodeCrock(plaintext), decodeCrock(nonce), key);
+ return encodeCrock(taConcat([nonceBuf, cipherText]));
+}
-// async function secureAnswerHash(
-// answer: string,
-// truthUuid: string,
-// questionSalt: string,
-// ): Promise<string> {}
+const asOpaque = (x: string): OpaqueData => x;
+const asEncryptedKeyShare = (x: OpaqueData): EncryptedKeyShare => x as string;
+const asEncryptedTruth = (x: OpaqueData): EncryptedTruth => x as string;
+
+export async function encryptKeyshare(
+ keyShare: KeyShare,
+ userId: UserIdentifier,
+ answerSalt?: string,
+): Promise<EncryptedKeyShare> {
+ const s = answerSalt ?? "eks";
+ const nonce = encodeCrock(getRandomBytes(24));
+ return asEncryptedKeyShare(
+ await anastasisEncrypt(nonce, asOpaque(userId), asOpaque(keyShare), s),
+ );
+}
+
+export async function encryptTruth(
+ nonce: EncryptionNonce,
+ truthEncKey: TruthKey,
+ truth: OpaqueData,
+): Promise<EncryptedTruth> {
+ const salt = "ect";
+ return asEncryptedTruth(
+ await anastasisEncrypt(nonce, asOpaque(truthEncKey), truth, salt),
+ );
+}
+
+export interface CoreSecretEncResult {
+ encCoreSecret: EncryptedCoreSecret;
+ encMasterKeys: EncryptedMasterKey[];
+}
+
+export async function coreSecretEncrypt(
+ policyKeys: PolicyKey[],
+ coreSecret: OpaqueData,
+): Promise<CoreSecretEncResult> {
+ const masterKey = getRandomBytes(masterKeySize);
+ const nonce = encodeCrock(getRandomBytes(nonceSize));
+ const coreSecretEncSalt = "cse";
+ const masterKeyEncSalt = "emk";
+ const encCoreSecret = (await anastasisEncrypt(
+ nonce,
+ encodeCrock(masterKey),
+ coreSecret,
+ coreSecretEncSalt,
+ )) as string;
+ const encMasterKeys: EncryptedMasterKey[] = [];
+ for (let i = 0; i < policyKeys.length; i++) {
+ const polNonce = encodeCrock(getRandomBytes(nonceSize));
+ const encMasterKey = await anastasisEncrypt(
+ polNonce,
+ asOpaque(policyKeys[i]),
+ encodeCrock(masterKey),
+ masterKeyEncSalt,
+ );
+ encMasterKeys.push(encMasterKey as string);
+ }
+ return {
+ encCoreSecret,
+ encMasterKeys,
+ };
+}
diff --git a/packages/anastasis-core/src/index.ts b/packages/anastasis-core/src/index.ts
index 7a14440a6..f33a0be46 100644
--- a/packages/anastasis-core/src/index.ts
+++ b/packages/anastasis-core/src/index.ts
@@ -1,14 +1,697 @@
-import { md5, sha1, sha512, sha3 } from 'hash-wasm';
+import {
+ AmountString,
+ codecForGetExchangeWithdrawalInfo,
+ decodeCrock,
+ encodeCrock,
+ getRandomBytes,
+ TalerErrorCode,
+} 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,
+ AuthenticationProviderStatus,
+ AuthenticationProviderStatusOk,
+ AuthMethod,
+ BackupStates,
+ ContinentInfo,
+ CountryInfo,
+ MethodSpec,
+ Policy,
+ PolicyProvider,
+ RecoveryStates,
+ ReducerState,
+ ReducerStateBackup,
+ ReducerStateBackupUserAttributesCollecting,
+ ReducerStateError,
+ ReducerStateRecovery,
+} from "./reducer-types.js";
+import fetchPonyfill from "fetch-ponyfill";
+import {
+ coreSecretEncrypt,
+ encryptKeyshare,
+ encryptTruth,
+ PolicyKey,
+ policyKeyDerive,
+ UserIdentifier,
+ userIdentifierDerive,
+} from "./crypto.js";
-async function run() {
- console.log('MD5:', await md5('demo'));
+const { fetch, Request, Response, Headers } = fetchPonyfill({});
- const int8Buffer = new Uint8Array([0, 1, 2, 3]);
- console.log('SHA1:', await sha1(int8Buffer));
- console.log('SHA512:', await sha512(int8Buffer));
+export * from "./reducer-types.js";
- const int32Buffer = new Uint32Array([1056, 641]);
- console.log('SHA3-256:', await sha3(int32Buffer, 256));
+interface RecoveryDocument {
+ // Human-readable name of the secret
+ secret_name?: string;
+
+ // Encrypted core secret.
+ encrypted_core_secret: string; // bytearray of undefined length
+
+ // List of escrow providers and selected authentication method.
+ escrow_methods: EscrowMethod[];
+
+ // List of possible decryption policies.
+ policies: DecryptionPolicy[];
+}
+
+interface DecryptionPolicy {
+ // Salt included to encrypt master key share when
+ // using this decryption policy.
+ salt: string;
+
+ /**
+ * Master key, AES-encrypted with key derived from
+ * salt and keyshares revealed by the following list of
+ * escrow methods identified by UUID.
+ */
+ master_key: string;
+
+ /**
+ * List of escrow methods identified by their UUID.
+ */
+ uuid: string[];
+}
+
+interface EscrowMethod {
+ /**
+ * URL of the escrow provider (including possibly this Anastasis server).
+ */
+ url: string;
+
+ /**
+ * Type of the escrow method (e.g. security question, SMS etc.).
+ */
+ escrow_type: string;
+
+ // UUID of the escrow method (see /truth/ API below).
+ // 16 bytes base32-crock encoded.
+ uuid: string;
+
+ // Key used to encrypt the Truth this EscrowMethod is related to.
+ // Client has to provide this key to the server when using /truth/.
+ truth_key: string;
+
+ // Salt used to encrypt the truth on the Anastasis server.
+ salt: string;
+
+ // Salt from the provider to derive the user ID
+ // at this provider.
+ provider_salt: string;
+
+ // The instructions to give to the user (i.e. the security question
+ // if this is challenge-response).
+ // (Q: as string in base32 encoding?)
+ // (Q: what is the mime-type of this value?)
+ //
+ // The plaintext challenge is not revealed to the
+ // Anastasis server.
+ instructions: string;
+}
+
+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;
+}
+
+function getCountries(continent: string): CountryInfo[] {
+ return anastasisData.countriesList.countries.filter(
+ (x) => x.continent === continent,
+ );
+}
+
+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(),
+ };
+}
+
+async function backupSelectCountry(
+ state: ReducerStateBackup,
+ countryCode: string,
+ currencies: string[],
+): Promise<ReducerStateError | ReducerStateBackupUserAttributesCollecting> {
+ 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,
+ backup_state: BackupStates.UserAttributesCollecting,
+ selected_country: countryCode,
+ currencies,
+ required_attributes: ra,
+ authentication_providers: providers,
+ };
+}
+
+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,
+ attributes: Record<string, string>,
+): 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 newState = {
+ ...state,
+ backup_state: BackupStates.AuthenticationsEditing,
+ authentication_providers: newProviders,
+ identity_attributes: attributes,
+ };
+ return newState;
}
-run(); \ No newline at end of file
+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.
+ */
+ salt: string;
+}
+
+async function uploadSecret(
+ state: ReducerStateBackup,
+): Promise<ReducerStateBackup | ReducerStateError> {
+ const policies = state.policies!;
+ const secretName = state.secret_name!;
+ const coreSecret = state.core_secret?.value!;
+ // Truth key is `${methodIndex}/${providerUrl}`
+ const truthMetadataMap: Record<string, TruthMetaData> = {};
+ const policyKeys: PolicyKey[] = [];
+
+ for (let policyIndex = 0; policyIndex < policies.length; policyIndex++) {
+ const pol = policies[policyIndex];
+ const policySalt = encodeCrock(getRandomBytes(64));
+ const keyShares: string[] = [];
+ for (let methIndex = 0; methIndex < pol.methods.length; methIndex++) {
+ const meth = pol.methods[methIndex];
+ const truthKey = `${meth.authentication_method}:${meth.provider}`;
+ if (truthMetadataMap[truthKey]) {
+ continue;
+ }
+ const keyShare = encodeCrock(getRandomBytes(32));
+ keyShares.push(keyShare);
+ const tm: TruthMetaData = {
+ key_share: keyShare,
+ nonce: encodeCrock(getRandomBytes(24)),
+ salt: encodeCrock(getRandomBytes(16)),
+ truth_key: encodeCrock(getRandomBytes(32)),
+ uuid: encodeCrock(getRandomBytes(32)),
+ pol_method_index: methIndex,
+ policy_index: policyIndex,
+ };
+ truthMetadataMap[truthKey] = tm;
+ }
+ const policyKey = await policyKeyDerive(keyShares, policySalt);
+ policyKeys.push(policyKey);
+ }
+
+ 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;
+ const encryptedTruth = await encryptTruth(
+ tm.nonce,
+ tm.truth_key,
+ authMethod.challenge,
+ );
+ const uid = uidMap[meth.provider];
+ const encryptedKeyShare = await encryptKeyshare(tm.key_share, uid, tm.salt);
+ console.log(
+ "encrypted key share len",
+ decodeCrock(encryptedKeyShare).length,
+ );
+ const tur: TruthUploadRequest = {
+ encrypted_truth: encryptedTruth,
+ key_share_data: encryptedKeyShare,
+ storage_duration_years: 5 /* FIXME */,
+ type: authMethod.type,
+ truth_mime: authMethod.mime_type,
+ };
+ const resp = await fetch(new URL(`truth/${tm.uuid}`, meth.provider).href, {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ },
+ body: JSON.stringify(tur),
+ });
+
+ escrowMethods.push({
+ escrow_type: authMethod.type,
+ instructions: authMethod.instructions,
+ provider_salt: provider.salt,
+ salt: tm.salt,
+ 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],
+ uuid: [],
+ salt:
+ };
+ }),
+ };
+
+ for (const prov of state.policy_providers!) {
+ // FIXME: Upload recovery document.
+ }
+
+ return {
+ code: 123,
+ hint: "not implemented",
+ };
+}
+
+export async function reduceAction(
+ state: ReducerState,
+ action: string,
+ args: any,
+): Promise<ReducerState> {
+ console.log(`ts reducer: handling action ${action}`);
+ if (state.backup_state === BackupStates.ContinentSelecting) {
+ if (action === "select_continent") {
+ const continent: string = args.continent;
+ if (typeof continent !== "string") {
+ return {
+ code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
+ hint: "continent required",
+ };
+ }
+ return {
+ ...state,
+ backup_state: BackupStates.CountrySelecting,
+ countries: getCountries(continent),
+ selected_continent: continent,
+ };
+ } else {
+ return {
+ code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
+ hint: `Unsupported action '${action}'`,
+ };
+ }
+ }
+ if (state.backup_state === BackupStates.CountrySelecting) {
+ if (action === "back") {
+ return {
+ ...state,
+ backup_state: BackupStates.ContinentSelecting,
+ countries: undefined,
+ };
+ } else if (action === "select_country") {
+ const countryCode = args.country_code;
+ if (typeof countryCode !== "string") {
+ return {
+ code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
+ hint: "country_code required",
+ };
+ }
+ const currencies = args.currencies;
+ return backupSelectCountry(state, countryCode, currencies);
+ } else {
+ return {
+ code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
+ hint: `Unsupported action '${action}'`,
+ };
+ }
+ }
+ if (state.backup_state === BackupStates.UserAttributesCollecting) {
+ if (action === "back") {
+ return {
+ ...state,
+ backup_state: BackupStates.CountrySelecting,
+ };
+ } else if (action === "enter_user_attributes") {
+ const ta = args as ActionArgEnterUserAttributes;
+ return backupEnterUserAttributes(state, ta.identity_attributes);
+ } else {
+ return {
+ code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
+ hint: `Unsupported action '${action}'`,
+ };
+ }
+ }
+ if (state.backup_state === BackupStates.AuthenticationsEditing) {
+ if (action === "back") {
+ return {
+ ...state,
+ backup_state: BackupStates.UserAttributesCollecting,
+ };
+ } else if (action === "add_authentication") {
+ const ta = args as ActionArgAddAuthentication;
+ return {
+ ...state,
+ authentication_methods: [
+ ...(state.authentication_methods ?? []),
+ ta.authentication_method,
+ ],
+ };
+ } else if (action === "delete_authentication") {
+ const ta = args as ActionArgDeleteAuthentication;
+ const m = state.authentication_methods ?? [];
+ m.splice(ta.authentication_method, 1);
+ return {
+ ...state,
+ authentication_methods: m,
+ };
+ } else if (action === "next") {
+ const methods = state.authentication_methods ?? [];
+ const providers: ProviderInfo[] = [];
+ for (const provUrl of Object.keys(state.authentication_providers ?? {})) {
+ const prov = state.authentication_providers![provUrl];
+ if ("error_code" in prov) {
+ continue;
+ }
+ if (!("http_status" in prov && prov.http_status === 200)) {
+ continue;
+ }
+ const methodCost: Record<string, AmountString> = {};
+ for (const meth of prov.methods) {
+ methodCost[meth.type] = meth.usage_fee;
+ }
+ providers.push({
+ methodCost,
+ url: provUrl,
+ });
+ }
+ const pol = suggestPolicies(methods, providers);
+ console.log("policies", pol);
+ return {
+ ...state,
+ backup_state: BackupStates.PoliciesReviewing,
+ ...pol,
+ };
+ } else {
+ return {
+ code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
+ hint: `Unsupported action '${action}'`,
+ };
+ }
+ }
+ if (state.backup_state === BackupStates.PoliciesReviewing) {
+ if (action === "back") {
+ return {
+ ...state,
+ backup_state: BackupStates.AuthenticationsEditing,
+ };
+ } else if (action === "delete_policy") {
+ const ta = args as ActionArgDeletePolicy;
+ const policies = [...(state.policies ?? [])];
+ policies.splice(ta.policy_index, 1);
+ return {
+ ...state,
+ policies,
+ };
+ } else if (action === "next") {
+ return {
+ ...state,
+ backup_state: BackupStates.SecretEditing,
+ };
+ } else {
+ return {
+ code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
+ hint: `Unsupported action '${action}'`,
+ };
+ }
+ }
+ if (state.backup_state === BackupStates.SecretEditing) {
+ if (action === "back") {
+ return {
+ ...state,
+ backup_state: BackupStates.PoliciesReviewing,
+ };
+ } else if (action === "enter_secret_name") {
+ const ta = args as ActionArgEnterSecretName;
+ return {
+ ...state,
+ secret_name: ta.name,
+ };
+ } else if (action === "enter_secret") {
+ const ta = args as ActionArgEnterSecret;
+ return {
+ ...state,
+ expiration: ta.expiration,
+ core_secret: {
+ mime: ta.secret.mime ?? "text/plain",
+ value: ta.secret.value,
+ },
+ };
+ } else if (action === "next") {
+ return uploadSecret(state);
+ } else {
+ return {
+ code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
+ hint: `Unsupported action '${action}'`,
+ };
+ }
+ }
+ return {
+ code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
+ hint: "Reducer action invalid",
+ };
+}
diff --git a/packages/anastasis-core/src/provider-types.ts b/packages/anastasis-core/src/provider-types.ts
new file mode 100644
index 000000000..b477c09b9
--- /dev/null
+++ b/packages/anastasis-core/src/provider-types.ts
@@ -0,0 +1,74 @@
+import { AmountString } from "@gnu-taler/taler-util";
+
+export interface EscrowConfigurationResponse {
+ // Protocol identifier, clarifies that this is an Anastasis provider.
+ name: "anastasis";
+
+ // libtool-style representation of the Exchange protocol version, see
+ // https://www.gnu.org/software/libtool/manual/html_node/Versioning.html#Versioning
+ // The format is "current:revision:age".
+ version: string;
+
+ // Currency in which this provider processes payments.
+ currency: string;
+
+ // Supported authorization methods.
+ methods: AuthorizationMethodConfig[];
+
+ // Maximum policy upload size supported.
+ storage_limit_in_megabytes: number;
+
+ // Payment required to maintain an account to store policy documents for a year.
+ // Users can pay more, in which case the storage time will go up proportionally.
+ annual_fee: AmountString;
+
+ // Payment required to upload truth. To be paid per upload.
+ truth_upload_fee: AmountString;
+
+ // Limit on the liability that the provider is offering with
+ // respect to the services provided.
+ liability_limit: AmountString;
+
+ // Salt value with 128 bits of entropy.
+ // Different providers
+ // will use different high-entropy salt values. The resulting
+ // **provider salt** is then used in various operations to ensure
+ // cryptographic operations differ by provider. A provider must
+ // never change its salt value.
+ server_salt: string;
+
+ business_name: string;
+}
+
+export interface AuthorizationMethodConfig {
+ // Name of the authorization method.
+ type: string;
+
+ // Fee for accessing key share using this method.
+ cost: AmountString;
+}
+
+export interface TruthUploadRequest {
+ // Contains the information of an interface EncryptedKeyShare, but simply
+ // as one binary block (in Crockford Base32 encoding for JSON).
+ key_share_data: string;
+
+ // Key share method, i.e. "security question", "SMS", "e-mail", ...
+ type: string;
+
+ // Variable-size truth. After decryption,
+ // this contains the ground truth, i.e. H(challenge answer),
+ // phone number, e-mail address, picture, fingerprint, ...
+ // **base32 encoded**.
+ //
+ // The nonce of the HKDF for this encryption must include the
+ // string "ECT".
+ encrypted_truth: string; //bytearray
+
+ // MIME type of truth, i.e. text/ascii, image/jpeg, etc.
+ truth_mime?: string;
+
+ // For how many years from now would the client like us to
+ // store the truth?
+ storage_duration_years: number;
+}
diff --git a/packages/anastasis-core/src/reducer-types.ts b/packages/anastasis-core/src/reducer-types.ts
new file mode 100644
index 000000000..0d1754bd9
--- /dev/null
+++ b/packages/anastasis-core/src/reducer-types.ts
@@ -0,0 +1,241 @@
+import { Duration } from "@gnu-taler/taler-util";
+
+export type ReducerState =
+ | ReducerStateBackup
+ | ReducerStateRecovery
+ | ReducerStateError;
+
+export interface ContinentInfo {
+ name: string;
+}
+
+export interface CountryInfo {
+ code: string;
+ name: string;
+ continent: string;
+ currency: string;
+}
+
+export interface Policy {
+ methods: {
+ authentication_method: number;
+ provider: string;
+ }[];
+}
+
+export interface PolicyProvider {
+ provider_url: string;
+}
+
+export interface ReducerStateBackup {
+ recovery_state?: undefined;
+ backup_state: BackupStates;
+ code?: undefined;
+ currencies?: string[];
+ continents?: ContinentInfo[];
+ countries?: any;
+ identity_attributes?: { [n: string]: string };
+ authentication_providers?: { [url: string]: AuthenticationProviderStatus };
+ authentication_methods?: AuthMethod[];
+ required_attributes?: any;
+ selected_continent?: string;
+ selected_country?: string;
+ secret_name?: string;
+ policies?: Policy[];
+ /**
+ * Policy providers are providers that we checked to be functional
+ * and that are actually used in policies.
+ */
+ policy_providers?: PolicyProvider[];
+ success_details?: {
+ [provider_url: string]: {
+ policy_version: number;
+ };
+ };
+ payments?: string[];
+ policy_payment_requests?: {
+ payto: string;
+ provider: string;
+ }[];
+
+ core_secret?: {
+ mime: string;
+ value: string;
+ };
+
+ expiration?: Duration;
+}
+
+export interface AuthMethod {
+ type: string;
+ instructions: string;
+ challenge: string;
+ mime_type?: string;
+}
+
+export interface ChallengeInfo {
+ cost: string;
+ instructions: string;
+ type: string;
+ uuid: string;
+}
+
+export interface UserAttributeSpec {
+ label: string;
+ name: string;
+ type: string;
+ uuid: string;
+ widget: string;
+}
+
+export interface ReducerStateRecovery {
+ backup_state?: undefined;
+ recovery_state: RecoveryStates;
+ code?: undefined;
+
+ identity_attributes?: { [n: string]: string };
+
+ continents?: any;
+ countries?: any;
+ required_attributes?: any;
+
+ recovery_information?: {
+ challenges: ChallengeInfo[];
+ policies: {
+ /**
+ * UUID of the associated challenge.
+ */
+ uuid: string;
+ }[][];
+ };
+
+ recovery_document?: {
+ secret_name: string;
+ provider_url: string;
+ version: number;
+ };
+
+ selected_challenge_uuid?: string;
+
+ challenge_feedback?: { [uuid: string]: ChallengeFeedback };
+
+ core_secret?: {
+ mime: string;
+ value: string;
+ };
+
+ authentication_providers?: {
+ [url: string]: {
+ business_name: string;
+ };
+ };
+
+ recovery_error?: any;
+}
+
+export interface ChallengeFeedback {
+ state: string;
+}
+
+export interface ReducerStateError {
+ backup_state?: undefined;
+ recovery_state?: undefined;
+ code: number;
+ hint?: string;
+ message?: string;
+}
+
+export enum BackupStates {
+ ContinentSelecting = "CONTINENT_SELECTING",
+ CountrySelecting = "COUNTRY_SELECTING",
+ UserAttributesCollecting = "USER_ATTRIBUTES_COLLECTING",
+ AuthenticationsEditing = "AUTHENTICATIONS_EDITING",
+ PoliciesReviewing = "POLICIES_REVIEWING",
+ SecretEditing = "SECRET_EDITING",
+ TruthsPaying = "TRUTHS_PAYING",
+ PoliciesPaying = "POLICIES_PAYING",
+ BackupFinished = "BACKUP_FINISHED",
+}
+
+export enum RecoveryStates {
+ ContinentSelecting = "CONTINENT_SELECTING",
+ CountrySelecting = "COUNTRY_SELECTING",
+ UserAttributesCollecting = "USER_ATTRIBUTES_COLLECTING",
+ SecretSelecting = "SECRET_SELECTING",
+ ChallengeSelecting = "CHALLENGE_SELECTING",
+ ChallengePaying = "CHALLENGE_PAYING",
+ ChallengeSolving = "CHALLENGE_SOLVING",
+ RecoveryFinished = "RECOVERY_FINISHED",
+}
+
+export interface MethodSpec {
+ type: string;
+ usage_fee: string;
+}
+
+// FIXME: This should be tagged!
+export type AuthenticationProviderStatusEmpty = {};
+
+export interface AuthenticationProviderStatusOk {
+ annual_fee: string;
+ business_name: string;
+ currency: string;
+ http_status: 200;
+ liability_limit: string;
+ salt: string;
+ storage_limit_in_megabytes: number;
+ truth_upload_fee: string;
+ methods: MethodSpec[];
+}
+
+export interface AuthenticationProviderStatusError {
+ http_status: number;
+ error_code: number;
+}
+
+export type AuthenticationProviderStatus =
+ | AuthenticationProviderStatusEmpty
+ | AuthenticationProviderStatusError
+ | AuthenticationProviderStatusOk;
+
+export interface ReducerStateBackupUserAttributesCollecting
+ extends ReducerStateBackup {
+ backup_state: BackupStates.UserAttributesCollecting;
+ selected_country: string;
+ currencies: string[];
+ required_attributes: UserAttributeSpec[];
+ authentication_providers: { [url: string]: AuthenticationProviderStatus };
+}
+
+export interface ActionArgEnterUserAttributes {
+ identity_attributes: Record<string, string>;
+}
+
+export interface ActionArgAddAuthentication {
+ authentication_method: {
+ type: string;
+ instructions: string;
+ challenge: string;
+ mime?: string;
+ };
+}
+
+export interface ActionArgDeleteAuthentication {
+ authentication_method: number;
+}
+
+export interface ActionArgDeletePolicy {
+ policy_index: number;
+}
+
+export interface ActionArgEnterSecretName {
+ name: string;
+}
+
+export interface ActionArgEnterSecret {
+ secret: {
+ value: string;
+ mime?: string;
+ };
+ expiration: Duration;
+}
diff --git a/packages/anastasis-core/tsconfig.json b/packages/anastasis-core/tsconfig.json
index 34027c4ac..b5476273c 100644
--- a/packages/anastasis-core/tsconfig.json
+++ b/packages/anastasis-core/tsconfig.json
@@ -6,7 +6,7 @@
"module": "ESNext",
"moduleResolution": "node",
"sourceMap": true,
- "lib": ["es6"],
+ "lib": ["es6", "DOM"],
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"strict": true,