diff options
-rw-r--r-- | packages/anastasis-core/package.json | 8 | ||||
-rw-r--r-- | packages/anastasis-core/src/crypto.ts | 152 | ||||
-rw-r--r-- | packages/anastasis-core/src/index.ts | 701 | ||||
-rw-r--r-- | packages/anastasis-core/src/provider-types.ts | 74 | ||||
-rw-r--r-- | packages/anastasis-core/src/reducer-types.ts | 241 | ||||
-rw-r--r-- | packages/anastasis-core/tsconfig.json | 2 |
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, |