anastasis: make recovery work, at least for security questions

This commit is contained in:
Florian Dold 2021-10-21 18:51:19 +02:00
parent 0ee669f523
commit 3740010117
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
5 changed files with 277 additions and 26 deletions

View File

@ -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,

View File

@ -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,

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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>