From 3740010117df56c0ab8cfa97c983d9cf0143daf1 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Thu, 21 Oct 2021 18:51:19 +0200 Subject: anastasis: make recovery work, at least for security questions --- packages/anastasis-core/src/crypto.ts | 26 +++ packages/anastasis-core/src/index.ts | 201 ++++++++++++++++++++- .../anastasis-core/src/recovery-document-types.ts | 47 +++-- packages/anastasis-core/src/reducer-types.ts | 28 ++- .../src/pages/home/RecoveryFinishedScreen.tsx | 1 - 5 files changed, 277 insertions(+), 26 deletions(-) (limited to 'packages') diff --git a/packages/anastasis-core/src/crypto.ts b/packages/anastasis-core/src/crypto.ts index 8df893f4b..da8338636 100644 --- a/packages/anastasis-core/src/crypto.ts +++ b/packages/anastasis-core/src/crypto.ts @@ -185,6 +185,7 @@ async function anastasisDecrypt( export const asOpaque = (x: string): OpaqueData => x; const asEncryptedKeyShare = (x: OpaqueData): EncryptedKeyShare => x as string; const asEncryptedTruth = (x: OpaqueData): EncryptedTruth => x as string; +const asKeyShare = (x: OpaqueData): KeyShare => x as string; export async function encryptKeyshare( keyShare: KeyShare, @@ -198,6 +199,17 @@ export async function encryptKeyshare( ); } +export async function decryptKeyShare( + encKeyShare: EncryptedKeyShare, + userId: UserIdentifier, + answerSalt?: string, +): Promise { + const s = answerSalt ?? "eks"; + return asKeyShare( + await anastasisDecrypt(asOpaque(userId), asOpaque(encKeyShare), s), + ); +} + export async function encryptTruth( nonce: EncryptionNonce, truthEncKey: TruthKey, @@ -226,6 +238,20 @@ export interface CoreSecretEncResult { encMasterKeys: EncryptedMasterKey[]; } +export async function coreSecretRecover(args: { + encryptedMasterKey: OpaqueData; + policyKey: PolicyKey; + encryptedCoreSecret: OpaqueData; +}): Promise { + const masterKey = await anastasisDecrypt( + asOpaque(args.policyKey), + args.encryptedMasterKey, + "emk", + ); + console.log("recovered master key", masterKey); + return await anastasisDecrypt(masterKey, args.encryptedCoreSecret, "cse"); +} + export async function coreSecretEncrypt( policyKeys: PolicyKey[], coreSecret: OpaqueData, diff --git a/packages/anastasis-core/src/index.ts b/packages/anastasis-core/src/index.ts index b8fedf006..b4e911ffb 100644 --- a/packages/anastasis-core/src/index.ts +++ b/packages/anastasis-core/src/index.ts @@ -26,7 +26,8 @@ import { ActionArgEnterSecret, ActionArgEnterSecretName, ActionArgEnterUserAttributes, - ActionArgSelectChallenge, + ActionArgsSelectChallenge, + ActionArgsSolveChallengeRequest, AuthenticationProviderStatus, AuthenticationProviderStatusOk, AuthMethod, @@ -66,6 +67,9 @@ import { userIdentifierDerive, typedArrayConcat, decryptRecoveryDocument, + decryptKeyShare, + KeyShare, + coreSecretRecover, } from "./crypto.js"; import { unzlibSync, zlibSync } from "fflate"; import { EscrowMethod, RecoveryDocument } from "./recovery-document-types.js"; @@ -626,8 +630,10 @@ async function downloadPolicy( const providerUrls = Object.keys(state.authentication_providers ?? {}); let foundRecoveryInfo: RecoveryInternalData | undefined = undefined; let recoveryDoc: RecoveryDocument | undefined = undefined; - const newProviderStatus: { [url: string]: AuthenticationProviderStatus } = {}; + const newProviderStatus: { [url: string]: AuthenticationProviderStatusOk } = + {}; const userAttributes = state.identity_attributes!; + // 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)) { @@ -635,6 +641,12 @@ async function downloadPolicy( continue; } newProviderStatus[url] = pi; + } + for (const url of providerUrls) { + const pi = newProviderStatus[url]; + if (!pi) { + continue; + } const userId = await userIdentifierDerive(userAttributes, pi.salt); const acctKeypair = accountKeypairDerive(userId); const resp = await fetch(new URL(`policy/${acctKeypair.pub}`, url).href); @@ -670,7 +682,7 @@ async function downloadPolicy( } const recoveryInfo: RecoveryInformation = { challenges: recoveryDoc.escrow_methods.map((x) => { - console.log("providers", state.authentication_providers); + console.log("providers", newProviderStatus); const prov = newProviderStatus[x.url] as AuthenticationProviderStatusOk; return { cost: prov.methods.find((m) => m.type === x.escrow_type)?.usage_fee!, @@ -692,9 +704,124 @@ async function downloadPolicy( recovery_state: RecoveryStates.SecretSelecting, recovery_document: foundRecoveryInfo, recovery_information: recoveryInfo, + verbatim_recovery_document: recoveryDoc, }; } +/** + * Try to reconstruct the secret from the available shares. + * + * Returns the state unmodified if not enough key shares are available yet. + */ +async function tryRecoverSecret( + state: ReducerStateRecovery, +): Promise { + const rd = state.verbatim_recovery_document!; + for (const p of rd.policies) { + const keyShares: KeyShare[] = []; + let missing = false; + for (const truthUuid of p.uuids) { + const ks = (state.recovered_key_shares ?? {})[truthUuid]; + if (!ks) { + missing = true; + break; + } + keyShares.push(ks); + } + + if (missing) { + continue; + } + + const policyKey = await policyKeyDerive(keyShares, p.salt); + const coreSecretBytes = await coreSecretRecover({ + encryptedCoreSecret: rd.encrypted_core_secret, + encryptedMasterKey: p.master_key, + policyKey, + }); + + return { + ...state, + recovery_state: RecoveryStates.RecoveryFinished, + selected_challenge_uuid: undefined, + core_secret: JSON.parse(bytesToString(decodeCrock(coreSecretBytes))), + }; + } + return { ...state }; +} + +async function solveChallenge( + state: ReducerStateRecovery, + ta: ActionArgsSolveChallengeRequest, +): Promise { + const recDoc: RecoveryDocument = state.verbatim_recovery_document!; + const truth = recDoc.escrow_methods.find( + (x) => x.uuid === state.selected_challenge_uuid, + ); + if (!truth) { + throw "truth for challenge not found"; + } + + const url = new URL(`/truth/${truth.uuid}`, truth.url); + + // FIXME: This isn't correct for non-question truth responses. + url.searchParams.set( + "response", + await secureAnswerHash(ta.answer, truth.uuid, truth.truth_salt), + ); + + const resp = await fetch(url.href, { + headers: { + "Anastasis-Truth-Decryption-Key": truth.truth_key, + }, + }); + + console.log(resp); + + if (resp.status !== 200) { + return { + code: TalerErrorCode.ANASTASIS_TRUTH_CHALLENGE_FAILED, + hint: "got non-200 response", + http_status: resp.status, + } as ReducerStateError; + } + + const answerSalt = truth.escrow_type === "question" ? ta.answer : undefined; + + const userId = await userIdentifierDerive( + state.identity_attributes, + truth.provider_salt, + ); + + const respBody = new Uint8Array(await resp.arrayBuffer()); + const keyShare = await decryptKeyShare( + encodeCrock(respBody), + userId, + answerSalt, + ); + + const recoveredKeyShares = { + ...(state.recovered_key_shares ?? {}), + [truth.uuid]: keyShare, + }; + + const challengeFeedback = { + ...state.challenge_feedback, + [truth.uuid]: { + state: "solved", + }, + }; + + const newState: ReducerStateRecovery = { + ...state, + recovery_state: RecoveryStates.ChallengeSelecting, + challenge_feedback: challengeFeedback, + recovered_key_shares: recoveredKeyShares, + }; + + return tryRecoverSecret(newState); +} + async function recoveryEnterUserAttributes( state: ReducerStateRecovery, attributes: Record, @@ -707,6 +834,33 @@ async function recoveryEnterUserAttributes( return downloadPolicy(st); } +async function selectChallenge( + state: ReducerStateRecovery, + ta: ActionArgsSelectChallenge, +): Promise { + const recDoc: RecoveryDocument = state.verbatim_recovery_document!; + const truth = recDoc.escrow_methods.find((x) => x.uuid === ta.uuid); + if (!truth) { + throw "truth for challenge not found"; + } + + const url = new URL(`/truth/${truth.uuid}`, truth.url); + + const resp = await fetch(url.href, { + headers: { + "Anastasis-Truth-Decryption-Key": truth.truth_key, + }, + }); + + console.log(resp); + + return { + ...state, + recovery_state: RecoveryStates.ChallengeSolving, + selected_challenge_uuid: ta.uuid, + }; +} + export async function reduceAction( state: ReducerState, action: string, @@ -989,17 +1143,22 @@ export async function reduceAction( if (state.recovery_state === RecoveryStates.ChallengeSelecting) { if (action === "select_challenge") { - const ta: ActionArgSelectChallenge = args; - return { - ...state, - recovery_state: RecoveryStates.ChallengeSolving, - selected_challenge_uuid: ta.uuid, - }; + const ta: ActionArgsSelectChallenge = args; + return selectChallenge(state, ta); } else if (action === "back") { return { ...state, recovery_state: RecoveryStates.SecretSelecting, }; + } else if (action === "next") { + const s2 = await tryRecoverSecret(state); + if (s2.recovery_state === RecoveryStates.RecoveryFinished) { + return s2; + } + return { + code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, + hint: "Not enough challenges solved", + }; } else { return { code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, @@ -1010,12 +1169,34 @@ export async function reduceAction( if (state.recovery_state === RecoveryStates.ChallengeSolving) { if (action === "back") { - const ta: ActionArgSelectChallenge = args; + const ta: ActionArgsSelectChallenge = args; + return { + ...state, + selected_challenge_uuid: undefined, + recovery_state: RecoveryStates.ChallengeSelecting, + }; + } else if (action === "solve_challenge") { + const ta: ActionArgsSolveChallengeRequest = args; + return solveChallenge(state, ta); + } else { + return { + code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, + hint: `Unsupported action '${action}'`, + }; + } + } + + if (state.recovery_state === RecoveryStates.RecoveryFinished) { + if (action === "back") { + const ta: ActionArgsSelectChallenge = args; return { ...state, selected_challenge_uuid: undefined, recovery_state: RecoveryStates.ChallengeSelecting, }; + } else if (action === "solve_challenge") { + const ta: ActionArgsSolveChallengeRequest = args; + return solveChallenge(state, ta); } else { return { code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, diff --git a/packages/anastasis-core/src/recovery-document-types.ts b/packages/anastasis-core/src/recovery-document-types.ts index a1d9a55fc..74003ccb1 100644 --- a/packages/anastasis-core/src/recovery-document-types.ts +++ b/packages/anastasis-core/src/recovery-document-types.ts @@ -1,22 +1,37 @@ import { TruthKey, TruthSalt, TruthUuid } from "./crypto.js"; export interface RecoveryDocument { - // Human-readable name of the secret + /** + * Human-readable name of the secret + * FIXME: Why is this optional? + */ secret_name?: string; - // Encrypted core secret. - encrypted_core_secret: string; // bytearray of undefined length + /** + * Encrypted core secret. + * + * Variable-size length, base32-crock encoded. + */ + encrypted_core_secret: string; - // List of escrow providers and selected authentication method. + /** + * List of escrow providers and selected authentication method. + */ escrow_methods: EscrowMethod[]; - // List of possible decryption policies. + /** + * List of possible decryption policies. + */ policies: DecryptionPolicy[]; } export interface DecryptionPolicy { - // Salt included to encrypt master key share when - // using this decryption policy. + /** + * Salt included to encrypt master key share when + * using this decryption policy. + * + * FIXME: Rename to policy_salt + */ salt: string; /** @@ -43,12 +58,16 @@ export interface EscrowMethod { */ escrow_type: string; - // UUID of the escrow method. - // 16 bytes base32-crock encoded. + /** + * UUID of the escrow method. + * 16 bytes base32-crock encoded. + */ uuid: TruthUuid; - // Key used to encrypt the Truth this EscrowMethod is related to. - // Client has to provide this key to the server when using /truth/. + /** + * 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: TruthKey; /** @@ -60,7 +79,9 @@ export interface EscrowMethod { // at this provider. provider_salt: string; - // The instructions to give to the user (i.e. the security question - // if this is challenge-response). + /** + * The instructions to give to the user (i.e. the security question + * if this is challenge-response). + */ instructions: string; } diff --git a/packages/anastasis-core/src/reducer-types.ts b/packages/anastasis-core/src/reducer-types.ts index 4c73dfa66..f7ba9e0f1 100644 --- a/packages/anastasis-core/src/reducer-types.ts +++ b/packages/anastasis-core/src/reducer-types.ts @@ -1,4 +1,6 @@ import { Duration, Timestamp } from "@gnu-taler/taler-util"; +import { KeyShare } from "./crypto.js"; +import { RecoveryDocument } from "./recovery-document-types.js"; export type ReducerState = | ReducerStateBackup @@ -110,8 +112,16 @@ export interface RecoveryInformation { } export interface ReducerStateRecovery { - backup_state?: undefined; recovery_state: RecoveryStates; + + /** + * Unused in the recovery states. + */ + backup_state?: undefined; + + /** + * Unused in the recovery states. + */ code?: undefined; identity_attributes?: { [n: string]: string }; @@ -133,10 +143,18 @@ export interface ReducerStateRecovery { // FIXME: This should really be renamed to recovery_internal_data recovery_document?: RecoveryInternalData; + // FIXME: The C reducer should also use this! + verbatim_recovery_document?: RecoveryDocument; + selected_challenge_uuid?: string; challenge_feedback?: { [uuid: string]: ChallengeFeedback }; + /** + * Key shares that we managed to recover so far. + */ + recovered_key_shares?: { [truth_uuid: string]: KeyShare }; + core_secret?: { mime: string; value: string; @@ -254,6 +272,12 @@ export interface ActionArgEnterSecret { expiration: Duration; } -export interface ActionArgSelectChallenge { +export interface ActionArgsSelectChallenge { uuid: string; } + +export type ActionArgsSolveChallengeRequest = SolveChallengeAnswerRequest; + +export interface SolveChallengeAnswerRequest { + answer: string; +} diff --git a/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.tsx b/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.tsx index 7ef9f345c..7ccc511ff 100644 --- a/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.tsx @@ -8,7 +8,6 @@ import { RecoveryReducerProps, AnastasisClientFrame } from "./index"; export function RecoveryFinishedScreen(props: RecoveryReducerProps): VNode { return ( -

Recovery Finished

Secret: {bytesToString(decodeCrock(props.recoveryState.core_secret?.value!))}

-- cgit v1.2.3