diff options
author | Florian Dold <florian@dold.me> | 2022-04-12 12:54:57 +0200 |
---|---|---|
committer | Florian Dold <florian@dold.me> | 2022-04-12 12:55:32 +0200 |
commit | 1e92093a50962f4702339e872caa4f82af90af70 (patch) | |
tree | 10d927c7e51e8fd1cf52629dd3d19d3fbd28b1bf /packages/anastasis-core/src | |
parent | afecab8000fa59475fe02a402176789e360651ba (diff) | |
download | wallet-core-1e92093a50962f4702339e872caa4f82af90af70.tar.xz |
anastasis: discovery
Diffstat (limited to 'packages/anastasis-core/src')
-rw-r--r-- | packages/anastasis-core/src/crypto.ts | 39 | ||||
-rw-r--r-- | packages/anastasis-core/src/index.ts | 152 | ||||
-rw-r--r-- | packages/anastasis-core/src/provider-types.ts | 24 | ||||
-rw-r--r-- | packages/anastasis-core/src/reducer-types.ts | 75 |
4 files changed, 228 insertions, 62 deletions
diff --git a/packages/anastasis-core/src/crypto.ts b/packages/anastasis-core/src/crypto.ts index 75bd4b323..37e8c4f54 100644 --- a/packages/anastasis-core/src/crypto.ts +++ b/packages/anastasis-core/src/crypto.ts @@ -1,16 +1,15 @@ import { - bytesToString, canonicalJson, decodeCrock, encodeCrock, getRandomBytes, - kdf, kdfKw, secretbox, crypto_sign_keyPair_fromSeed, stringToBytes, secretbox_open, hash, + bytesToString, } from "@gnu-taler/taler-util"; import { argon2id } from "hash-wasm"; @@ -111,6 +110,42 @@ export async function decryptRecoveryDocument( return anastasisDecrypt(asOpaque(userId), recoveryDocData, "erd"); } +export interface PolicyMetadata { + secret_name: string; + policy_hash: string; +} + +export async function encryptPolicyMetadata( + userId: UserIdentifier, + metadata: PolicyMetadata, +): Promise<OpaqueData> { + const metadataBytes = typedArrayConcat([ + decodeCrock(metadata.policy_hash), + stringToBytes(metadata.secret_name), + ]); + const nonce = encodeCrock(getRandomBytes(nonceSize)); + return anastasisEncrypt( + nonce, + asOpaque(userId), + encodeCrock(metadataBytes), + "rmd", + ); +} + +export async function decryptPolicyMetadata( + userId: UserIdentifier, + metadataEnc: OpaqueData, +): Promise<PolicyMetadata> { + const plain = await anastasisDecrypt(asOpaque(userId), metadataEnc, "rmd"); + const metadataBytes = decodeCrock(plain); + const policyHash = encodeCrock(metadataBytes.slice(0, 64)); + const secretName = bytesToString(metadataBytes.slice(64)); + return { + policy_hash: policyHash, + secret_name: secretName, + }; +} + export function typedArrayConcat(chunks: Uint8Array[]): Uint8Array { let payloadLen = 0; for (const c of chunks) { diff --git a/packages/anastasis-core/src/index.ts b/packages/anastasis-core/src/index.ts index a355eaa54..5a9199e02 100644 --- a/packages/anastasis-core/src/index.ts +++ b/packages/anastasis-core/src/index.ts @@ -22,11 +22,13 @@ import { TalerProtocolTimestamp, TalerSignaturePurpose, AbsoluteTime, + URL, } from "@gnu-taler/taler-util"; import { anastasisData } from "./anastasis-data.js"; import { EscrowConfigurationResponse, IbanExternalAuthResponse, + RecoveryMetaResponse as RecoveryMetaResponse, TruthUploadRequest, } from "./provider-types.js"; import { @@ -68,6 +70,10 @@ import { ActionArgsUpdatePolicy, ActionArgsAddProvider, ActionArgsDeleteProvider, + DiscoveryCursor, + DiscoveryResult, + PolicyMetaInfo, + ChallengeInfo, } from "./reducer-types.js"; import fetchPonyfill from "fetch-ponyfill"; import { @@ -91,6 +97,8 @@ import { KeyShare, coreSecretRecover, pinAnswerHash, + decryptPolicyMetadata, + encryptPolicyMetadata, } from "./crypto.js"; import { unzlibSync, zlibSync } from "fflate"; import { @@ -112,6 +120,8 @@ export * from "./challenge-feedback-types.js"; const logger = new Logger("anastasis-core:index.ts"); +const ANASTASIS_HTTP_HEADER_POLICY_META_DATA = "Anastasis-Policy-Meta-Data"; + function getContinents( opts: { requireProvider?: boolean } = {}, ): ContinentInfo[] { @@ -224,10 +234,12 @@ async function selectCountry( }); } - const providers: { [x: string]: {} } = {}; + const providers: { [x: string]: AuthenticationProviderStatus } = {}; for (const prov of anastasisData.providersList.anastasis_provider) { if (currencies.includes(prov.currency)) { - providers[prov.url] = {}; + providers[prov.url] = { + status: "not-contacted", + }; } } @@ -273,12 +285,14 @@ async function getProviderInfo( resp = await fetch(new URL("config", providerBaseUrl).href); } catch (e) { return { + status: "error", code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED, hint: "request to provider failed", }; } if (resp.status !== 200) { return { + status: "error", code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED, hint: "unexpected status", http_status: resp.status, @@ -287,6 +301,7 @@ async function getProviderInfo( try { const jsonResp: EscrowConfigurationResponse = await resp.json(); return { + status: "ok", http_status: 200, annual_fee: jsonResp.annual_fee, business_name: jsonResp.business_name, @@ -299,9 +314,10 @@ async function getProviderInfo( 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 { + status: "error", code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED, hint: "provider did not return JSON", }; @@ -594,6 +610,7 @@ async function uploadSecret( const userId = await getUserIdCaching(prov.provider_url); const acctKeypair = accountKeypairDerive(userId); const zippedDoc = await compressRecoveryDoc(rd); + const recoveryDocHash = encodeCrock(hash(zippedDoc)); const encRecoveryDoc = await encryptRecoveryDocument( userId, encodeCrock(zippedDoc), @@ -603,6 +620,10 @@ async function uploadSecret( .put(bodyHash) .build(); const sig = eddsaSign(sigPS, decodeCrock(acctKeypair.priv)); + const metadataEnc = await encryptPolicyMetadata(userId, { + policy_hash: recoveryDocHash, + secret_name: state.secret_name ?? "<unnamed secret>", + }); const talerPayUri = state.policy_payment_requests?.find( (x) => x.provider === prov.provider_url, )?.payto; @@ -621,6 +642,7 @@ async function uploadSecret( headers: { "Anastasis-Policy-Signature": encodeCrock(sig), "If-None-Match": encodeCrock(bodyHash), + [ANASTASIS_HTTP_HEADER_POLICY_META_DATA]: metadataEnc, ...(paySecret ? { "Anastasis-Payment-Identifier": paySecret, @@ -704,37 +726,21 @@ async function uploadSecret( async function downloadPolicy( state: ReducerStateRecovery, ): Promise<ReducerStateRecovery | ReducerStateError> { - const providerUrls = Object.keys(state.authentication_providers ?? {}); let foundRecoveryInfo: RecoveryInternalData | undefined = undefined; let recoveryDoc: RecoveryDocument | undefined = undefined; - const newProviderStatus: { [url: string]: AuthenticationProviderStatusOk } = - {}; const userAttributes = state.identity_attributes!; - const restrictProvider = state.selected_provider_url; - // FIXME: Shouldn't we also store the status of bad providers? - for (const url of providerUrls) { - const pi = await getProviderInfo(url); - if ("error_code" in pi || !("http_status" in pi)) { - // Could not even get /config of the provider - continue; - } - newProviderStatus[url] = pi; + if (!state.selected_version) { + throw Error("invalid state"); } - for (const url of providerUrls) { - const pi = newProviderStatus[url]; - if (!pi) { - continue; - } - if (restrictProvider && url !== state.selected_provider_url) { - // User wants specific provider. + for (const prov of state.selected_version.providers) { + const pi = state.authentication_providers?.[prov.provider_url]; + if (!pi || pi.status !== "ok") { continue; } const userId = await userIdentifierDerive(userAttributes, pi.salt); const acctKeypair = accountKeypairDerive(userId); - const reqUrl = new URL(`policy/${acctKeypair.pub}`, url); - if (state.selected_version) { - reqUrl.searchParams.set("version", `${state.selected_version}`); - } + const reqUrl = new URL(`policy/${acctKeypair.pub}`, prov.provider_url); + reqUrl.searchParams.set("version", `${prov.version}`); const resp = await fetch(reqUrl.href); if (resp.status !== 200) { continue; @@ -752,7 +758,7 @@ async function downloadPolicy( policyVersion = Number(resp.headers.get("Anastasis-Version") ?? "0"); } catch (e) {} foundRecoveryInfo = { - provider_url: url, + provider_url: prov.provider_url, secret_name: rd.secret_name ?? "<unknown>", version: policyVersion, }; @@ -765,16 +771,24 @@ async function downloadPolicy( hint: "No backups found at any provider for your identity information.", }; } + + const challenges: ChallengeInfo[] = []; + + for (const x of recoveryDoc.escrow_methods) { + const pi = state.authentication_providers?.[x.url]; + if (!pi || pi.status !== "ok") { + continue; + } + challenges.push({ + cost: pi.methods.find((m) => m.type === x.escrow_type)?.usage_fee!, + instructions: x.instructions, + type: x.escrow_type, + uuid: x.uuid, + }); + } + 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, - }; - }), + challenges, policies: recoveryDoc.policies.map((x) => { return x.uuids.map((m) => { return { @@ -785,7 +799,7 @@ async function downloadPolicy( }; return { ...state, - recovery_state: RecoveryStates.SecretSelecting, + recovery_state: RecoveryStates.ChallengeSelecting, recovery_document: foundRecoveryInfo, recovery_information: recoveryInfo, verbatim_recovery_document: recoveryDoc, @@ -1019,10 +1033,11 @@ async function recoveryEnterUserAttributes( } const st: ReducerStateRecovery = { ...state, + recovery_state: RecoveryStates.SecretSelecting, identity_attributes: args.identity_attributes, authentication_providers: newProviders, }; - return downloadPolicy(st); + return st; } async function changeVersion( @@ -1031,8 +1046,7 @@ async function changeVersion( ): Promise<ReducerStateRecovery | ReducerStateError> { const st: ReducerStateRecovery = { ...state, - selected_version: args.version, - selected_provider_url: args.provider_url, + selected_version: args.selection, }; return downloadPolicy(st); } @@ -1313,10 +1327,7 @@ async function nextFromAuthenticationsEditing( 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)) { + if (prov.status !== "ok") { continue; } const methodCost: Record<string, AmountString> = {}; @@ -1574,6 +1585,59 @@ const recoveryTransitions: Record< }, }; +export async function discoverPolicies( + state: ReducerState, + cursor?: DiscoveryCursor, +): Promise<DiscoveryResult> { + if (!state.recovery_state) { + throw Error("can only discover providers in recovery state"); + } + + const policies: PolicyMetaInfo[] = []; + + const providerUrls = Object.keys(state.authentication_providers || {}); + // FIXME: Do we need to re-contact providers here / check if they're disabled? + + for (const providerUrl of providerUrls) { + const providerInfo = await getProviderInfo(providerUrl); + if (providerInfo.status !== "ok") { + continue; + } + const userId = await userIdentifierDerive( + state.identity_attributes!, + providerInfo.salt, + ); + const acctKeypair = accountKeypairDerive(userId); + const reqUrl = new URL(`policy/${acctKeypair.pub}/meta`, providerUrl); + const resp = await fetch(reqUrl.href); + if (resp.status !== 200) { + logger.warn(`Could not fetch policy metadate from ${reqUrl.href}`); + continue; + } + const respJson: RecoveryMetaResponse = await resp.json(); + const versions = Object.keys(respJson); + for (const version of versions) { + const item = respJson[version]; + if (!item.meta) { + continue; + } + const metaData = await decryptPolicyMetadata(userId, item.meta!); + policies.push({ + attribute_mask: 0, + provider_url: providerUrl, + server_time: item.upload_time, + version: Number.parseInt(version, 10), + secret_name: metaData.secret_name, + policy_hash: metaData.policy_hash, + }); + } + } + return { + policies, + cursor: undefined, + }; +} + export async function reduceAction( state: ReducerState, action: string, diff --git a/packages/anastasis-core/src/provider-types.ts b/packages/anastasis-core/src/provider-types.ts index f4d998e0a..fe6292b02 100644 --- a/packages/anastasis-core/src/provider-types.ts +++ b/packages/anastasis-core/src/provider-types.ts @@ -1,4 +1,9 @@ -import { Amounts, AmountString } from "@gnu-taler/taler-util"; +import { + Amounts, + AmountString, + TalerProtocolDuration, + TalerProtocolTimestamp, +} from "@gnu-taler/taler-util"; export interface EscrowConfigurationResponse { // Protocol identifier, clarifies that this is an Anastasis provider. @@ -83,3 +88,20 @@ export interface IbanExternalAuthResponse { wire_transfer_subject: string; }; } + +export interface RecoveryMetaResponse { + /** + * Version numbers as a string (!) are used as keys. + */ + [version: string]: RecoveryMetaDataItem; +} + +export interface RecoveryMetaDataItem { + // The meta value can be NULL if the document + // exists but no meta data was provided. + meta?: string; + + // Server-time indicative of when the recovery + // document was uploaded. + upload_time: TalerProtocolTimestamp; +} diff --git a/packages/anastasis-core/src/reducer-types.ts b/packages/anastasis-core/src/reducer-types.ts index 4682eddb7..47238cd3f 100644 --- a/packages/anastasis-core/src/reducer-types.ts +++ b/packages/anastasis-core/src/reducer-types.ts @@ -202,14 +202,9 @@ export interface ReducerStateRecovery { /** * Explicitly selected version by the user. * FIXME: In the C reducer this is called "version". + * FIXME: rename to selected_secret / selected_policy? */ - selected_version?: number; - - /** - * Explicitly selected provider URL by the user. - * FIXME: In the C reducer this is called "provider_url". - */ - selected_provider_url?: string; + selected_version?: AggregatedPolicyMetaInfo; challenge_feedback?: { [uuid: string]: ChallengeFeedback }; @@ -291,10 +286,12 @@ export interface MethodSpec { usage_fee: string; } -// FIXME: This should be tagged! -export type AuthenticationProviderStatusEmpty = {}; +export type AuthenticationProviderStatusEmpty = { + status: "not-contacted"; +}; export interface AuthenticationProviderStatusOk { + status: "ok"; annual_fee: string; business_name: string; currency: string; @@ -304,11 +301,15 @@ export interface AuthenticationProviderStatusOk { storage_limit_in_megabytes: number; truth_upload_fee: string; methods: MethodSpec[]; + // FIXME: add timestamp? } export interface AuthenticationProviderStatusError { - http_status: number; - error_code: number; + status: "error"; + http_status?: number; + code: number; + hint?: string; + // FIXME: add timestamp? } export type AuthenticationProviderStatus = @@ -441,8 +442,7 @@ export interface ActionArgsUpdateExpiration { } export interface ActionArgsChangeVersion { - provider_url: string; - version: number; + selection: AggregatedPolicyMetaInfo; } export interface ActionArgsUpdatePolicy { @@ -450,10 +450,55 @@ export interface ActionArgsUpdatePolicy { policy: PolicyMember[]; } +/** + * Cursor for a provider discovery process. + */ +export interface DiscoveryCursor { + position: { + provider_url: string; + mask: number; + max_version?: number; + }[]; +} + +export interface PolicyMetaInfo { + policy_hash: string; + provider_url: string; + version: number; + attribute_mask: number; + server_time: TalerProtocolTimestamp; + secret_name?: string; +} + + +/** + * Aggregated / de-duplicated policy meta info. + */ +export interface AggregatedPolicyMetaInfo { + secret_name?: string; + policy_hash: string; + attribute_mask: number; + providers: { + provider_url: string; + version: number; + }[]; +} + +export interface DiscoveryResult { + /** + * Found policies. + */ + policies: PolicyMetaInfo[]; + + /** + * Cursor that allows getting more results. + */ + cursor?: DiscoveryCursor; +} + export const codecForActionArgsChangeVersion = () => buildCodecForObject<ActionArgsChangeVersion>() - .property("provider_url", codecForString()) - .property("version", codecForNumber()) + .property("selection", codecForAny()) .build("ActionArgsChangeVersion"); export const codecForPolicyMember = () => |