diff options
Diffstat (limited to 'packages/anastasis-core/src/index.ts')
-rw-r--r-- | packages/anastasis-core/src/index.ts | 701 |
1 files changed, 692 insertions, 9 deletions
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", + }; +} |