anastasis: make recovery work, at least for security questions
This commit is contained in:
parent
0ee669f523
commit
3740010117
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user