aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFlorian Dold <florian@dold.me>2021-10-21 18:51:19 +0200
committerFlorian Dold <florian@dold.me>2021-10-21 18:51:19 +0200
commit3740010117df56c0ab8cfa97c983d9cf0143daf1 (patch)
treee290a211f9e76af226e69a30012f3d2079b93829
parent0ee669f52341a8331394a1e9892264c0ef0bb7d7 (diff)
anastasis: make recovery work, at least for security questions
-rw-r--r--packages/anastasis-core/src/crypto.ts26
-rw-r--r--packages/anastasis-core/src/index.ts201
-rw-r--r--packages/anastasis-core/src/recovery-document-types.ts47
-rw-r--r--packages/anastasis-core/src/reducer-types.ts28
-rw-r--r--packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.tsx1
5 files changed, 277 insertions, 26 deletions
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<KeyShare> {
+ 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<OpaqueData> {
+ 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<ReducerStateRecovery | ReducerStateError> {
+ 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<ReducerStateRecovery | ReducerStateError> {
+ 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<string, string>,
@@ -707,6 +834,33 @@ async function recoveryEnterUserAttributes(
return downloadPolicy(st);
}
+async function selectChallenge(
+ state: ReducerStateRecovery,
+ ta: ActionArgsSelectChallenge,
+): Promise<ReducerStateRecovery | ReducerStateError> {
+ 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 (
<AnastasisClientFrame title="Recovery Finished" hideNext>
- <h1>Recovery Finished</h1>
<p>
Secret: {bytesToString(decodeCrock(props.recoveryState.core_secret?.value!))}
</p>