anastasis: discovery

This commit is contained in:
Florian Dold 2022-04-12 12:54:57 +02:00
parent afecab8000
commit 1e92093a50
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
8 changed files with 401 additions and 77 deletions

View File

@ -1,16 +1,15 @@
import { import {
bytesToString,
canonicalJson, canonicalJson,
decodeCrock, decodeCrock,
encodeCrock, encodeCrock,
getRandomBytes, getRandomBytes,
kdf,
kdfKw, kdfKw,
secretbox, secretbox,
crypto_sign_keyPair_fromSeed, crypto_sign_keyPair_fromSeed,
stringToBytes, stringToBytes,
secretbox_open, secretbox_open,
hash, hash,
bytesToString,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { argon2id } from "hash-wasm"; import { argon2id } from "hash-wasm";
@ -111,6 +110,42 @@ export async function decryptRecoveryDocument(
return anastasisDecrypt(asOpaque(userId), recoveryDocData, "erd"); 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 { export function typedArrayConcat(chunks: Uint8Array[]): Uint8Array {
let payloadLen = 0; let payloadLen = 0;
for (const c of chunks) { for (const c of chunks) {

View File

@ -22,11 +22,13 @@ import {
TalerProtocolTimestamp, TalerProtocolTimestamp,
TalerSignaturePurpose, TalerSignaturePurpose,
AbsoluteTime, AbsoluteTime,
URL,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { anastasisData } from "./anastasis-data.js"; import { anastasisData } from "./anastasis-data.js";
import { import {
EscrowConfigurationResponse, EscrowConfigurationResponse,
IbanExternalAuthResponse, IbanExternalAuthResponse,
RecoveryMetaResponse as RecoveryMetaResponse,
TruthUploadRequest, TruthUploadRequest,
} from "./provider-types.js"; } from "./provider-types.js";
import { import {
@ -68,6 +70,10 @@ import {
ActionArgsUpdatePolicy, ActionArgsUpdatePolicy,
ActionArgsAddProvider, ActionArgsAddProvider,
ActionArgsDeleteProvider, ActionArgsDeleteProvider,
DiscoveryCursor,
DiscoveryResult,
PolicyMetaInfo,
ChallengeInfo,
} from "./reducer-types.js"; } from "./reducer-types.js";
import fetchPonyfill from "fetch-ponyfill"; import fetchPonyfill from "fetch-ponyfill";
import { import {
@ -91,6 +97,8 @@ import {
KeyShare, KeyShare,
coreSecretRecover, coreSecretRecover,
pinAnswerHash, pinAnswerHash,
decryptPolicyMetadata,
encryptPolicyMetadata,
} from "./crypto.js"; } from "./crypto.js";
import { unzlibSync, zlibSync } from "fflate"; import { unzlibSync, zlibSync } from "fflate";
import { import {
@ -112,6 +120,8 @@ export * from "./challenge-feedback-types.js";
const logger = new Logger("anastasis-core:index.ts"); const logger = new Logger("anastasis-core:index.ts");
const ANASTASIS_HTTP_HEADER_POLICY_META_DATA = "Anastasis-Policy-Meta-Data";
function getContinents( function getContinents(
opts: { requireProvider?: boolean } = {}, opts: { requireProvider?: boolean } = {},
): ContinentInfo[] { ): 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) { for (const prov of anastasisData.providersList.anastasis_provider) {
if (currencies.includes(prov.currency)) { 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); resp = await fetch(new URL("config", providerBaseUrl).href);
} catch (e) { } catch (e) {
return { return {
status: "error",
code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED, code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED,
hint: "request to provider failed", hint: "request to provider failed",
}; };
} }
if (resp.status !== 200) { if (resp.status !== 200) {
return { return {
status: "error",
code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED, code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED,
hint: "unexpected status", hint: "unexpected status",
http_status: resp.status, http_status: resp.status,
@ -287,6 +301,7 @@ async function getProviderInfo(
try { try {
const jsonResp: EscrowConfigurationResponse = await resp.json(); const jsonResp: EscrowConfigurationResponse = await resp.json();
return { return {
status: "ok",
http_status: 200, http_status: 200,
annual_fee: jsonResp.annual_fee, annual_fee: jsonResp.annual_fee,
business_name: jsonResp.business_name, business_name: jsonResp.business_name,
@ -299,9 +314,10 @@ async function getProviderInfo(
salt: jsonResp.server_salt, salt: jsonResp.server_salt,
storage_limit_in_megabytes: jsonResp.storage_limit_in_megabytes, storage_limit_in_megabytes: jsonResp.storage_limit_in_megabytes,
truth_upload_fee: jsonResp.truth_upload_fee, truth_upload_fee: jsonResp.truth_upload_fee,
} as AuthenticationProviderStatusOk; };
} catch (e) { } catch (e) {
return { return {
status: "error",
code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED, code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED,
hint: "provider did not return JSON", hint: "provider did not return JSON",
}; };
@ -594,6 +610,7 @@ async function uploadSecret(
const userId = await getUserIdCaching(prov.provider_url); const userId = await getUserIdCaching(prov.provider_url);
const acctKeypair = accountKeypairDerive(userId); const acctKeypair = accountKeypairDerive(userId);
const zippedDoc = await compressRecoveryDoc(rd); const zippedDoc = await compressRecoveryDoc(rd);
const recoveryDocHash = encodeCrock(hash(zippedDoc));
const encRecoveryDoc = await encryptRecoveryDocument( const encRecoveryDoc = await encryptRecoveryDocument(
userId, userId,
encodeCrock(zippedDoc), encodeCrock(zippedDoc),
@ -603,6 +620,10 @@ async function uploadSecret(
.put(bodyHash) .put(bodyHash)
.build(); .build();
const sig = eddsaSign(sigPS, decodeCrock(acctKeypair.priv)); 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( const talerPayUri = state.policy_payment_requests?.find(
(x) => x.provider === prov.provider_url, (x) => x.provider === prov.provider_url,
)?.payto; )?.payto;
@ -621,6 +642,7 @@ async function uploadSecret(
headers: { headers: {
"Anastasis-Policy-Signature": encodeCrock(sig), "Anastasis-Policy-Signature": encodeCrock(sig),
"If-None-Match": encodeCrock(bodyHash), "If-None-Match": encodeCrock(bodyHash),
[ANASTASIS_HTTP_HEADER_POLICY_META_DATA]: metadataEnc,
...(paySecret ...(paySecret
? { ? {
"Anastasis-Payment-Identifier": paySecret, "Anastasis-Payment-Identifier": paySecret,
@ -704,37 +726,21 @@ async function uploadSecret(
async function downloadPolicy( async function downloadPolicy(
state: ReducerStateRecovery, state: ReducerStateRecovery,
): Promise<ReducerStateRecovery | ReducerStateError> { ): Promise<ReducerStateRecovery | ReducerStateError> {
const providerUrls = Object.keys(state.authentication_providers ?? {});
let foundRecoveryInfo: RecoveryInternalData | undefined = undefined; let foundRecoveryInfo: RecoveryInternalData | undefined = undefined;
let recoveryDoc: RecoveryDocument | undefined = undefined; let recoveryDoc: RecoveryDocument | undefined = undefined;
const newProviderStatus: { [url: string]: AuthenticationProviderStatusOk } =
{};
const userAttributes = state.identity_attributes!; const userAttributes = state.identity_attributes!;
const restrictProvider = state.selected_provider_url; if (!state.selected_version) {
// FIXME: Shouldn't we also store the status of bad providers? throw Error("invalid state");
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;
} }
for (const url of providerUrls) { for (const prov of state.selected_version.providers) {
const pi = newProviderStatus[url]; const pi = state.authentication_providers?.[prov.provider_url];
if (!pi) { if (!pi || pi.status !== "ok") {
continue;
}
if (restrictProvider && url !== state.selected_provider_url) {
// User wants specific provider.
continue; continue;
} }
const userId = await userIdentifierDerive(userAttributes, pi.salt); const userId = await userIdentifierDerive(userAttributes, pi.salt);
const acctKeypair = accountKeypairDerive(userId); const acctKeypair = accountKeypairDerive(userId);
const reqUrl = new URL(`policy/${acctKeypair.pub}`, url); const reqUrl = new URL(`policy/${acctKeypair.pub}`, prov.provider_url);
if (state.selected_version) { reqUrl.searchParams.set("version", `${prov.version}`);
reqUrl.searchParams.set("version", `${state.selected_version}`);
}
const resp = await fetch(reqUrl.href); const resp = await fetch(reqUrl.href);
if (resp.status !== 200) { if (resp.status !== 200) {
continue; continue;
@ -752,7 +758,7 @@ async function downloadPolicy(
policyVersion = Number(resp.headers.get("Anastasis-Version") ?? "0"); policyVersion = Number(resp.headers.get("Anastasis-Version") ?? "0");
} catch (e) {} } catch (e) {}
foundRecoveryInfo = { foundRecoveryInfo = {
provider_url: url, provider_url: prov.provider_url,
secret_name: rd.secret_name ?? "<unknown>", secret_name: rd.secret_name ?? "<unknown>",
version: policyVersion, version: policyVersion,
}; };
@ -765,16 +771,24 @@ async function downloadPolicy(
hint: "No backups found at any provider for your identity information.", 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 = { const recoveryInfo: RecoveryInformation = {
challenges: recoveryDoc.escrow_methods.map((x) => { challenges,
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,
};
}),
policies: recoveryDoc.policies.map((x) => { policies: recoveryDoc.policies.map((x) => {
return x.uuids.map((m) => { return x.uuids.map((m) => {
return { return {
@ -785,7 +799,7 @@ async function downloadPolicy(
}; };
return { return {
...state, ...state,
recovery_state: RecoveryStates.SecretSelecting, recovery_state: RecoveryStates.ChallengeSelecting,
recovery_document: foundRecoveryInfo, recovery_document: foundRecoveryInfo,
recovery_information: recoveryInfo, recovery_information: recoveryInfo,
verbatim_recovery_document: recoveryDoc, verbatim_recovery_document: recoveryDoc,
@ -1019,10 +1033,11 @@ async function recoveryEnterUserAttributes(
} }
const st: ReducerStateRecovery = { const st: ReducerStateRecovery = {
...state, ...state,
recovery_state: RecoveryStates.SecretSelecting,
identity_attributes: args.identity_attributes, identity_attributes: args.identity_attributes,
authentication_providers: newProviders, authentication_providers: newProviders,
}; };
return downloadPolicy(st); return st;
} }
async function changeVersion( async function changeVersion(
@ -1031,8 +1046,7 @@ async function changeVersion(
): Promise<ReducerStateRecovery | ReducerStateError> { ): Promise<ReducerStateRecovery | ReducerStateError> {
const st: ReducerStateRecovery = { const st: ReducerStateRecovery = {
...state, ...state,
selected_version: args.version, selected_version: args.selection,
selected_provider_url: args.provider_url,
}; };
return downloadPolicy(st); return downloadPolicy(st);
} }
@ -1313,10 +1327,7 @@ async function nextFromAuthenticationsEditing(
const providers: ProviderInfo[] = []; const providers: ProviderInfo[] = [];
for (const provUrl of Object.keys(state.authentication_providers ?? {})) { for (const provUrl of Object.keys(state.authentication_providers ?? {})) {
const prov = state.authentication_providers![provUrl]; const prov = state.authentication_providers![provUrl];
if ("error_code" in prov) { if (prov.status !== "ok") {
continue;
}
if (!("http_status" in prov && prov.http_status === 200)) {
continue; continue;
} }
const methodCost: Record<string, AmountString> = {}; 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( export async function reduceAction(
state: ReducerState, state: ReducerState,
action: string, action: string,

View File

@ -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 { export interface EscrowConfigurationResponse {
// Protocol identifier, clarifies that this is an Anastasis provider. // Protocol identifier, clarifies that this is an Anastasis provider.
@ -83,3 +88,20 @@ export interface IbanExternalAuthResponse {
wire_transfer_subject: string; 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;
}

View File

@ -202,14 +202,9 @@ export interface ReducerStateRecovery {
/** /**
* Explicitly selected version by the user. * Explicitly selected version by the user.
* FIXME: In the C reducer this is called "version". * FIXME: In the C reducer this is called "version".
* FIXME: rename to selected_secret / selected_policy?
*/ */
selected_version?: number; selected_version?: AggregatedPolicyMetaInfo;
/**
* Explicitly selected provider URL by the user.
* FIXME: In the C reducer this is called "provider_url".
*/
selected_provider_url?: string;
challenge_feedback?: { [uuid: string]: ChallengeFeedback }; challenge_feedback?: { [uuid: string]: ChallengeFeedback };
@ -291,10 +286,12 @@ export interface MethodSpec {
usage_fee: string; usage_fee: string;
} }
// FIXME: This should be tagged! export type AuthenticationProviderStatusEmpty = {
export type AuthenticationProviderStatusEmpty = {}; status: "not-contacted";
};
export interface AuthenticationProviderStatusOk { export interface AuthenticationProviderStatusOk {
status: "ok";
annual_fee: string; annual_fee: string;
business_name: string; business_name: string;
currency: string; currency: string;
@ -304,11 +301,15 @@ export interface AuthenticationProviderStatusOk {
storage_limit_in_megabytes: number; storage_limit_in_megabytes: number;
truth_upload_fee: string; truth_upload_fee: string;
methods: MethodSpec[]; methods: MethodSpec[];
// FIXME: add timestamp?
} }
export interface AuthenticationProviderStatusError { export interface AuthenticationProviderStatusError {
http_status: number; status: "error";
error_code: number; http_status?: number;
code: number;
hint?: string;
// FIXME: add timestamp?
} }
export type AuthenticationProviderStatus = export type AuthenticationProviderStatus =
@ -441,8 +442,7 @@ export interface ActionArgsUpdateExpiration {
} }
export interface ActionArgsChangeVersion { export interface ActionArgsChangeVersion {
provider_url: string; selection: AggregatedPolicyMetaInfo;
version: number;
} }
export interface ActionArgsUpdatePolicy { export interface ActionArgsUpdatePolicy {
@ -450,10 +450,55 @@ export interface ActionArgsUpdatePolicy {
policy: PolicyMember[]; 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 = () => export const codecForActionArgsChangeVersion = () =>
buildCodecForObject<ActionArgsChangeVersion>() buildCodecForObject<ActionArgsChangeVersion>()
.property("provider_url", codecForString()) .property("selection", codecForAny())
.property("version", codecForNumber())
.build("ActionArgsChangeVersion"); .build("ActionArgsChangeVersion");
export const codecForPolicyMember = () => export const codecForPolicyMember = () =>

View File

@ -46,7 +46,7 @@ export function NavigationBar({ onMobileMenu, title }: Props): VNode {
Contact us Contact us
</a> </a>
<a <a
href="https://bugs.anastasis.li/" href="https://bugs.anastasis.lu/"
style={{ alignSelf: "center", padding: "0.5em" }} style={{ alignSelf: "center", padding: "0.5em" }}
> >
Report a bug Report a bug

View File

@ -23,11 +23,9 @@ import { createContext, h, VNode } from "preact";
import { useContext } from "preact/hooks"; import { useContext } from "preact/hooks";
import { AnastasisReducerApi } from "../hooks/use-anastasis-reducer"; import { AnastasisReducerApi } from "../hooks/use-anastasis-reducer";
type Type = AnastasisReducerApi | undefined;
const initial = undefined; const initial = undefined;
const Context = createContext<Type>(initial); const Context = createContext<AnastasisReducerApi | undefined>(initial);
interface Props { interface Props {
value: AnastasisReducerApi; value: AnastasisReducerApi;
@ -38,4 +36,5 @@ export const AnastasisProvider = ({ value, children }: Props): VNode => {
return h(Context.Provider, { value, children }); return h(Context.Provider, { value, children });
}; };
export const useAnastasisContext = (): Type => useContext(Context); export const useAnastasisContext = (): AnastasisReducerApi | undefined =>
useContext(Context);

View File

@ -1,8 +1,12 @@
import { TalerErrorCode } from "@gnu-taler/taler-util"; import { TalerErrorCode } from "@gnu-taler/taler-util";
import { import {
AggregatedPolicyMetaInfo,
BackupStates, BackupStates,
discoverPolicies,
DiscoveryCursor,
getBackupStartState, getBackupStartState,
getRecoveryStartState, getRecoveryStartState,
PolicyMetaInfo,
RecoveryStates, RecoveryStates,
reduceAction, reduceAction,
ReducerState, ReducerState,
@ -15,6 +19,7 @@ const remoteReducer = false;
interface AnastasisState { interface AnastasisState {
reducerState: ReducerState | undefined; reducerState: ReducerState | undefined;
currentError: any; currentError: any;
discoveryState: DiscoveryUiState;
} }
async function getBackupStartStateRemote(): Promise<ReducerState> { async function getBackupStartStateRemote(): Promise<ReducerState> {
@ -98,9 +103,21 @@ export interface ReducerTransactionHandle {
transition(action: string, args: any): Promise<ReducerState>; transition(action: string, args: any): Promise<ReducerState>;
} }
/**
* UI-relevant state of the policy discovery process.
*/
export interface DiscoveryUiState {
state: "none" | "active" | "finished";
aggregatedPolicies?: AggregatedPolicyMetaInfo[];
cursor?: DiscoveryCursor;
}
export interface AnastasisReducerApi { export interface AnastasisReducerApi {
currentReducerState: ReducerState | undefined; currentReducerState: ReducerState | undefined;
currentError: any; currentError: any;
discoveryState: DiscoveryUiState;
dismissError: () => void; dismissError: () => void;
startBackup: () => void; startBackup: () => void;
startRecover: () => void; startRecover: () => void;
@ -109,6 +126,8 @@ export interface AnastasisReducerApi {
transition(action: string, args: any): Promise<void>; transition(action: string, args: any): Promise<void>;
exportState: () => string; exportState: () => string;
importState: (s: string) => void; importState: (s: string) => void;
discoverStart(): Promise<void>;
discoverMore(): Promise<void>;
/** /**
* Run multiple reducer steps in a transaction without * Run multiple reducer steps in a transaction without
* affecting the UI-visible transition state in-between. * affecting the UI-visible transition state in-between.
@ -152,6 +171,9 @@ export function useAnastasisReducer(): AnastasisReducerApi {
() => ({ () => ({
reducerState: getStateFromStorage(), reducerState: getStateFromStorage(),
currentError: undefined, currentError: undefined,
discoveryState: {
state: "none",
},
}), }),
); );
@ -192,6 +214,7 @@ export function useAnastasisReducer(): AnastasisReducerApi {
return { return {
currentReducerState: anastasisState.reducerState, currentReducerState: anastasisState.reducerState,
currentError: anastasisState.currentError, currentError: anastasisState.currentError,
discoveryState: anastasisState.discoveryState,
async startBackup() { async startBackup() {
let s: ReducerState; let s: ReducerState;
if (remoteReducer) { if (remoteReducer) {
@ -213,17 +236,59 @@ export function useAnastasisReducer(): AnastasisReducerApi {
} }
}, },
exportState() { exportState() {
const state = getStateFromStorage() const state = getStateFromStorage();
return JSON.stringify(state) return JSON.stringify(state);
}, },
importState(s: string) { importState(s: string) {
try { try {
const state = JSON.parse(s) const state = JSON.parse(s);
setAnastasisState({ reducerState: state, currentError: undefined }) setAnastasisState({
reducerState: state,
currentError: undefined,
discoveryState: {
state: "none",
},
});
} catch (e) { } catch (e) {
throw Error('could not restore the state') throw Error("could not restore the state");
} }
}, },
async discoverStart(): Promise<void> {
const res = await discoverPolicies(this.currentReducerState!, undefined);
const aggregatedPolicies: AggregatedPolicyMetaInfo[] = [];
const polHashToIndex: Record<string, number> = {};
for (const pol of res.policies) {
const oldIndex = polHashToIndex[pol.policy_hash];
if (oldIndex != null) {
aggregatedPolicies[oldIndex].providers.push({
provider_url: pol.provider_url,
version: pol.version,
});
} else {
aggregatedPolicies.push({
attribute_mask: pol.attribute_mask,
policy_hash: pol.policy_hash,
providers: [
{
provider_url: pol.provider_url,
version: pol.version,
},
],
secret_name: pol.secret_name,
});
polHashToIndex[pol.policy_hash] = aggregatedPolicies.length - 1;
}
}
setAnastasisState({
...anastasisState,
discoveryState: {
state: "finished",
aggregatedPolicies,
cursor: res.cursor,
},
});
},
async discoverMore(): Promise<void> {},
async startRecover() { async startRecover() {
let s: ReducerState; let s: ReducerState;
if (remoteReducer) { if (remoteReducer) {
@ -301,7 +366,7 @@ export function useAnastasisReducer(): AnastasisReducerApi {
} }
class ReducerTxImpl implements ReducerTransactionHandle { class ReducerTxImpl implements ReducerTransactionHandle {
constructor(public transactionState: ReducerState) { } constructor(public transactionState: ReducerState) {}
async transition(action: string, args: any): Promise<ReducerState> { async transition(action: string, args: any): Promise<ReducerState> {
let s: ReducerState; let s: ReducerState;
if (remoteReducer) { if (remoteReducer) {

View File

@ -1,9 +1,10 @@
import { import {
AuthenticationProviderStatus, AuthenticationProviderStatus,
AuthenticationProviderStatusOk, AuthenticationProviderStatusOk,
PolicyMetaInfo,
} from "@gnu-taler/anastasis-core"; } from "@gnu-taler/anastasis-core";
import { h, VNode } from "preact"; import { h, VNode } from "preact";
import { useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { AsyncButton } from "../../components/AsyncButton"; import { AsyncButton } from "../../components/AsyncButton";
import { PhoneNumberInput } from "../../components/fields/NumberInput"; import { PhoneNumberInput } from "../../components/fields/NumberInput";
import { useAnastasisContext } from "../../context/anastasis"; import { useAnastasisContext } from "../../context/anastasis";
@ -13,8 +14,100 @@ import { AnastasisClientFrame } from "./index";
export function SecretSelectionScreen(): VNode { export function SecretSelectionScreen(): VNode {
const [selectingVersion, setSelectingVersion] = useState<boolean>(false); const [selectingVersion, setSelectingVersion] = useState<boolean>(false);
const reducer = useAnastasisContext(); const reducer = useAnastasisContext();
const [manageProvider, setManageProvider] = useState(false); const [manageProvider, setManageProvider] = useState(false);
useEffect(() => {
async function f() {
if (reducer) {
await reducer.discoverStart();
}
}
f().catch((e) => console.log(e));
}, []);
if (!reducer) {
return <div>no reducer in context</div>;
}
if (
!reducer.currentReducerState ||
reducer.currentReducerState.recovery_state === undefined
) {
return <div>invalid state</div>;
}
const provs = reducer.currentReducerState.authentication_providers ?? {};
const recoveryDocument = reducer.currentReducerState.recovery_document;
if (manageProvider) {
return <AddingProviderScreen onCancel={() => setManageProvider(false)} />;
}
if (reducer.discoveryState.state === "none") {
// Can this even happen?
return (
<AnastasisClientFrame title="Recovery: Select secret">
<div>waiting to start discovery</div>
</AnastasisClientFrame>
);
}
if (reducer.discoveryState.state === "active") {
return (
<AnastasisClientFrame title="Recovery: Select secret">
<div>loading secret versions</div>
</AnastasisClientFrame>
);
}
const policies = reducer.discoveryState.aggregatedPolicies ?? [];
if (policies.length === 0) {
return (
<ChooseAnotherProviderScreen
providers={provs}
selected=""
onChange={(newProv) => () => {}}
></ChooseAnotherProviderScreen>
);
}
return (
<AnastasisClientFrame title="Recovery: Select secret" hideNext="Please select version to recover">
<p>Found versions:</p>
{policies.map((x) => (
<div>
{x.policy_hash} / {x.secret_name}
<button
onClick={async () => {
await reducer.transition("change_version", {
selection: x,
});
}}
>
Recover
</button>
</div>
))}
<button>Load older versions</button>
</AnastasisClientFrame>
);
}
export function OldSecretSelectionScreen(): VNode {
const [selectingVersion, setSelectingVersion] = useState<boolean>(false);
const reducer = useAnastasisContext();
const [manageProvider, setManageProvider] = useState(false);
useEffect(() => {
async function f() {
if (reducer) {
await reducer.discoverStart();
}
}
f().catch((e) => console.log(e));
}, []);
const currentVersion = const currentVersion =
(reducer?.currentReducerState && (reducer?.currentReducerState &&
"recovery_document" in reducer.currentReducerState && "recovery_document" in reducer.currentReducerState &&
@ -71,15 +164,16 @@ export function SecretSelectionScreen(): VNode {
return <AddingProviderScreen onCancel={() => setManageProvider(false)} />; return <AddingProviderScreen onCancel={() => setManageProvider(false)} />;
} }
const provierInfo = provs[ const providerInfo = provs[
recoveryDocument.provider_url recoveryDocument.provider_url
] as AuthenticationProviderStatusOk; ] as AuthenticationProviderStatusOk;
return ( return (
<AnastasisClientFrame title="Recovery: Select secret"> <AnastasisClientFrame title="Recovery: Select secret">
<div class="columns"> <div class="columns">
<div class="column"> <div class="column">
<div class="box" style={{ border: "2px solid green" }}> <div class="box" style={{ border: "2px solid green" }}>
<h1 class="subtitle">{provierInfo.business_name}</h1> <h1 class="subtitle">{providerInfo.business_name}</h1>
<div class="block"> <div class="block">
{currentVersion === 0 ? ( {currentVersion === 0 ? (
<p>Set to recover the latest version</p> <p>Set to recover the latest version</p>