anastasis: discovery
This commit is contained in:
parent
afecab8000
commit
1e92093a50
@ -1,16 +1,15 @@
|
||||
import {
|
||||
bytesToString,
|
||||
canonicalJson,
|
||||
decodeCrock,
|
||||
encodeCrock,
|
||||
getRandomBytes,
|
||||
kdf,
|
||||
kdfKw,
|
||||
secretbox,
|
||||
crypto_sign_keyPair_fromSeed,
|
||||
stringToBytes,
|
||||
secretbox_open,
|
||||
hash,
|
||||
bytesToString,
|
||||
} from "@gnu-taler/taler-util";
|
||||
import { argon2id } from "hash-wasm";
|
||||
|
||||
@ -111,6 +110,42 @@ export async function decryptRecoveryDocument(
|
||||
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 {
|
||||
let payloadLen = 0;
|
||||
for (const c of chunks) {
|
||||
|
@ -22,11 +22,13 @@ import {
|
||||
TalerProtocolTimestamp,
|
||||
TalerSignaturePurpose,
|
||||
AbsoluteTime,
|
||||
URL,
|
||||
} from "@gnu-taler/taler-util";
|
||||
import { anastasisData } from "./anastasis-data.js";
|
||||
import {
|
||||
EscrowConfigurationResponse,
|
||||
IbanExternalAuthResponse,
|
||||
RecoveryMetaResponse as RecoveryMetaResponse,
|
||||
TruthUploadRequest,
|
||||
} from "./provider-types.js";
|
||||
import {
|
||||
@ -68,6 +70,10 @@ import {
|
||||
ActionArgsUpdatePolicy,
|
||||
ActionArgsAddProvider,
|
||||
ActionArgsDeleteProvider,
|
||||
DiscoveryCursor,
|
||||
DiscoveryResult,
|
||||
PolicyMetaInfo,
|
||||
ChallengeInfo,
|
||||
} from "./reducer-types.js";
|
||||
import fetchPonyfill from "fetch-ponyfill";
|
||||
import {
|
||||
@ -91,6 +97,8 @@ import {
|
||||
KeyShare,
|
||||
coreSecretRecover,
|
||||
pinAnswerHash,
|
||||
decryptPolicyMetadata,
|
||||
encryptPolicyMetadata,
|
||||
} from "./crypto.js";
|
||||
import { unzlibSync, zlibSync } from "fflate";
|
||||
import {
|
||||
@ -112,6 +120,8 @@ export * from "./challenge-feedback-types.js";
|
||||
|
||||
const logger = new Logger("anastasis-core:index.ts");
|
||||
|
||||
const ANASTASIS_HTTP_HEADER_POLICY_META_DATA = "Anastasis-Policy-Meta-Data";
|
||||
|
||||
function getContinents(
|
||||
opts: { requireProvider?: boolean } = {},
|
||||
): 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) {
|
||||
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);
|
||||
} catch (e) {
|
||||
return {
|
||||
status: "error",
|
||||
code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED,
|
||||
hint: "request to provider failed",
|
||||
};
|
||||
}
|
||||
if (resp.status !== 200) {
|
||||
return {
|
||||
status: "error",
|
||||
code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED,
|
||||
hint: "unexpected status",
|
||||
http_status: resp.status,
|
||||
@ -287,6 +301,7 @@ async function getProviderInfo(
|
||||
try {
|
||||
const jsonResp: EscrowConfigurationResponse = await resp.json();
|
||||
return {
|
||||
status: "ok",
|
||||
http_status: 200,
|
||||
annual_fee: jsonResp.annual_fee,
|
||||
business_name: jsonResp.business_name,
|
||||
@ -299,9 +314,10 @@ async function getProviderInfo(
|
||||
salt: jsonResp.server_salt,
|
||||
storage_limit_in_megabytes: jsonResp.storage_limit_in_megabytes,
|
||||
truth_upload_fee: jsonResp.truth_upload_fee,
|
||||
} as AuthenticationProviderStatusOk;
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
status: "error",
|
||||
code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED,
|
||||
hint: "provider did not return JSON",
|
||||
};
|
||||
@ -594,6 +610,7 @@ async function uploadSecret(
|
||||
const userId = await getUserIdCaching(prov.provider_url);
|
||||
const acctKeypair = accountKeypairDerive(userId);
|
||||
const zippedDoc = await compressRecoveryDoc(rd);
|
||||
const recoveryDocHash = encodeCrock(hash(zippedDoc));
|
||||
const encRecoveryDoc = await encryptRecoveryDocument(
|
||||
userId,
|
||||
encodeCrock(zippedDoc),
|
||||
@ -603,6 +620,10 @@ async function uploadSecret(
|
||||
.put(bodyHash)
|
||||
.build();
|
||||
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(
|
||||
(x) => x.provider === prov.provider_url,
|
||||
)?.payto;
|
||||
@ -621,6 +642,7 @@ async function uploadSecret(
|
||||
headers: {
|
||||
"Anastasis-Policy-Signature": encodeCrock(sig),
|
||||
"If-None-Match": encodeCrock(bodyHash),
|
||||
[ANASTASIS_HTTP_HEADER_POLICY_META_DATA]: metadataEnc,
|
||||
...(paySecret
|
||||
? {
|
||||
"Anastasis-Payment-Identifier": paySecret,
|
||||
@ -704,37 +726,21 @@ async function uploadSecret(
|
||||
async function downloadPolicy(
|
||||
state: ReducerStateRecovery,
|
||||
): Promise<ReducerStateRecovery | ReducerStateError> {
|
||||
const providerUrls = Object.keys(state.authentication_providers ?? {});
|
||||
let foundRecoveryInfo: RecoveryInternalData | undefined = undefined;
|
||||
let recoveryDoc: RecoveryDocument | undefined = undefined;
|
||||
const newProviderStatus: { [url: string]: AuthenticationProviderStatusOk } =
|
||||
{};
|
||||
const userAttributes = state.identity_attributes!;
|
||||
const restrictProvider = state.selected_provider_url;
|
||||
// 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)) {
|
||||
// Could not even get /config of the provider
|
||||
continue;
|
||||
}
|
||||
newProviderStatus[url] = pi;
|
||||
if (!state.selected_version) {
|
||||
throw Error("invalid state");
|
||||
}
|
||||
for (const url of providerUrls) {
|
||||
const pi = newProviderStatus[url];
|
||||
if (!pi) {
|
||||
continue;
|
||||
}
|
||||
if (restrictProvider && url !== state.selected_provider_url) {
|
||||
// User wants specific provider.
|
||||
for (const prov of state.selected_version.providers) {
|
||||
const pi = state.authentication_providers?.[prov.provider_url];
|
||||
if (!pi || pi.status !== "ok") {
|
||||
continue;
|
||||
}
|
||||
const userId = await userIdentifierDerive(userAttributes, pi.salt);
|
||||
const acctKeypair = accountKeypairDerive(userId);
|
||||
const reqUrl = new URL(`policy/${acctKeypair.pub}`, url);
|
||||
if (state.selected_version) {
|
||||
reqUrl.searchParams.set("version", `${state.selected_version}`);
|
||||
}
|
||||
const reqUrl = new URL(`policy/${acctKeypair.pub}`, prov.provider_url);
|
||||
reqUrl.searchParams.set("version", `${prov.version}`);
|
||||
const resp = await fetch(reqUrl.href);
|
||||
if (resp.status !== 200) {
|
||||
continue;
|
||||
@ -752,7 +758,7 @@ async function downloadPolicy(
|
||||
policyVersion = Number(resp.headers.get("Anastasis-Version") ?? "0");
|
||||
} catch (e) {}
|
||||
foundRecoveryInfo = {
|
||||
provider_url: url,
|
||||
provider_url: prov.provider_url,
|
||||
secret_name: rd.secret_name ?? "<unknown>",
|
||||
version: policyVersion,
|
||||
};
|
||||
@ -765,16 +771,24 @@ async function downloadPolicy(
|
||||
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 = {
|
||||
challenges: recoveryDoc.escrow_methods.map((x) => {
|
||||
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,
|
||||
};
|
||||
}),
|
||||
challenges,
|
||||
policies: recoveryDoc.policies.map((x) => {
|
||||
return x.uuids.map((m) => {
|
||||
return {
|
||||
@ -785,7 +799,7 @@ async function downloadPolicy(
|
||||
};
|
||||
return {
|
||||
...state,
|
||||
recovery_state: RecoveryStates.SecretSelecting,
|
||||
recovery_state: RecoveryStates.ChallengeSelecting,
|
||||
recovery_document: foundRecoveryInfo,
|
||||
recovery_information: recoveryInfo,
|
||||
verbatim_recovery_document: recoveryDoc,
|
||||
@ -1019,10 +1033,11 @@ async function recoveryEnterUserAttributes(
|
||||
}
|
||||
const st: ReducerStateRecovery = {
|
||||
...state,
|
||||
recovery_state: RecoveryStates.SecretSelecting,
|
||||
identity_attributes: args.identity_attributes,
|
||||
authentication_providers: newProviders,
|
||||
};
|
||||
return downloadPolicy(st);
|
||||
return st;
|
||||
}
|
||||
|
||||
async function changeVersion(
|
||||
@ -1031,8 +1046,7 @@ async function changeVersion(
|
||||
): Promise<ReducerStateRecovery | ReducerStateError> {
|
||||
const st: ReducerStateRecovery = {
|
||||
...state,
|
||||
selected_version: args.version,
|
||||
selected_provider_url: args.provider_url,
|
||||
selected_version: args.selection,
|
||||
};
|
||||
return downloadPolicy(st);
|
||||
}
|
||||
@ -1313,10 +1327,7 @@ async function nextFromAuthenticationsEditing(
|
||||
const providers: ProviderInfo[] = [];
|
||||
for (const provUrl of Object.keys(state.authentication_providers ?? {})) {
|
||||
const prov = state.authentication_providers![provUrl];
|
||||
if ("error_code" in prov) {
|
||||
continue;
|
||||
}
|
||||
if (!("http_status" in prov && prov.http_status === 200)) {
|
||||
if (prov.status !== "ok") {
|
||||
continue;
|
||||
}
|
||||
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(
|
||||
state: ReducerState,
|
||||
action: string,
|
||||
|
@ -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 {
|
||||
// Protocol identifier, clarifies that this is an Anastasis provider.
|
||||
@ -83,3 +88,20 @@ export interface IbanExternalAuthResponse {
|
||||
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;
|
||||
}
|
||||
|
@ -202,14 +202,9 @@ export interface ReducerStateRecovery {
|
||||
/**
|
||||
* Explicitly selected version by the user.
|
||||
* FIXME: In the C reducer this is called "version".
|
||||
* FIXME: rename to selected_secret / selected_policy?
|
||||
*/
|
||||
selected_version?: number;
|
||||
|
||||
/**
|
||||
* Explicitly selected provider URL by the user.
|
||||
* FIXME: In the C reducer this is called "provider_url".
|
||||
*/
|
||||
selected_provider_url?: string;
|
||||
selected_version?: AggregatedPolicyMetaInfo;
|
||||
|
||||
challenge_feedback?: { [uuid: string]: ChallengeFeedback };
|
||||
|
||||
@ -291,10 +286,12 @@ export interface MethodSpec {
|
||||
usage_fee: string;
|
||||
}
|
||||
|
||||
// FIXME: This should be tagged!
|
||||
export type AuthenticationProviderStatusEmpty = {};
|
||||
export type AuthenticationProviderStatusEmpty = {
|
||||
status: "not-contacted";
|
||||
};
|
||||
|
||||
export interface AuthenticationProviderStatusOk {
|
||||
status: "ok";
|
||||
annual_fee: string;
|
||||
business_name: string;
|
||||
currency: string;
|
||||
@ -304,11 +301,15 @@ export interface AuthenticationProviderStatusOk {
|
||||
storage_limit_in_megabytes: number;
|
||||
truth_upload_fee: string;
|
||||
methods: MethodSpec[];
|
||||
// FIXME: add timestamp?
|
||||
}
|
||||
|
||||
export interface AuthenticationProviderStatusError {
|
||||
http_status: number;
|
||||
error_code: number;
|
||||
status: "error";
|
||||
http_status?: number;
|
||||
code: number;
|
||||
hint?: string;
|
||||
// FIXME: add timestamp?
|
||||
}
|
||||
|
||||
export type AuthenticationProviderStatus =
|
||||
@ -441,8 +442,7 @@ export interface ActionArgsUpdateExpiration {
|
||||
}
|
||||
|
||||
export interface ActionArgsChangeVersion {
|
||||
provider_url: string;
|
||||
version: number;
|
||||
selection: AggregatedPolicyMetaInfo;
|
||||
}
|
||||
|
||||
export interface ActionArgsUpdatePolicy {
|
||||
@ -450,10 +450,55 @@ export interface ActionArgsUpdatePolicy {
|
||||
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 = () =>
|
||||
buildCodecForObject<ActionArgsChangeVersion>()
|
||||
.property("provider_url", codecForString())
|
||||
.property("version", codecForNumber())
|
||||
.property("selection", codecForAny())
|
||||
.build("ActionArgsChangeVersion");
|
||||
|
||||
export const codecForPolicyMember = () =>
|
||||
|
@ -46,7 +46,7 @@ export function NavigationBar({ onMobileMenu, title }: Props): VNode {
|
||||
Contact us
|
||||
</a>
|
||||
<a
|
||||
href="https://bugs.anastasis.li/"
|
||||
href="https://bugs.anastasis.lu/"
|
||||
style={{ alignSelf: "center", padding: "0.5em" }}
|
||||
>
|
||||
Report a bug
|
||||
|
@ -23,11 +23,9 @@ import { createContext, h, VNode } from "preact";
|
||||
import { useContext } from "preact/hooks";
|
||||
import { AnastasisReducerApi } from "../hooks/use-anastasis-reducer";
|
||||
|
||||
type Type = AnastasisReducerApi | undefined;
|
||||
|
||||
const initial = undefined;
|
||||
|
||||
const Context = createContext<Type>(initial);
|
||||
const Context = createContext<AnastasisReducerApi | undefined>(initial);
|
||||
|
||||
interface Props {
|
||||
value: AnastasisReducerApi;
|
||||
@ -38,4 +36,5 @@ export const AnastasisProvider = ({ value, children }: Props): VNode => {
|
||||
return h(Context.Provider, { value, children });
|
||||
};
|
||||
|
||||
export const useAnastasisContext = (): Type => useContext(Context);
|
||||
export const useAnastasisContext = (): AnastasisReducerApi | undefined =>
|
||||
useContext(Context);
|
||||
|
@ -1,8 +1,12 @@
|
||||
import { TalerErrorCode } from "@gnu-taler/taler-util";
|
||||
import {
|
||||
AggregatedPolicyMetaInfo,
|
||||
BackupStates,
|
||||
discoverPolicies,
|
||||
DiscoveryCursor,
|
||||
getBackupStartState,
|
||||
getRecoveryStartState,
|
||||
PolicyMetaInfo,
|
||||
RecoveryStates,
|
||||
reduceAction,
|
||||
ReducerState,
|
||||
@ -15,6 +19,7 @@ const remoteReducer = false;
|
||||
interface AnastasisState {
|
||||
reducerState: ReducerState | undefined;
|
||||
currentError: any;
|
||||
discoveryState: DiscoveryUiState;
|
||||
}
|
||||
|
||||
async function getBackupStartStateRemote(): Promise<ReducerState> {
|
||||
@ -98,9 +103,21 @@ export interface ReducerTransactionHandle {
|
||||
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 {
|
||||
currentReducerState: ReducerState | undefined;
|
||||
currentError: any;
|
||||
discoveryState: DiscoveryUiState;
|
||||
dismissError: () => void;
|
||||
startBackup: () => void;
|
||||
startRecover: () => void;
|
||||
@ -109,6 +126,8 @@ export interface AnastasisReducerApi {
|
||||
transition(action: string, args: any): Promise<void>;
|
||||
exportState: () => string;
|
||||
importState: (s: string) => void;
|
||||
discoverStart(): Promise<void>;
|
||||
discoverMore(): Promise<void>;
|
||||
/**
|
||||
* Run multiple reducer steps in a transaction without
|
||||
* affecting the UI-visible transition state in-between.
|
||||
@ -152,6 +171,9 @@ export function useAnastasisReducer(): AnastasisReducerApi {
|
||||
() => ({
|
||||
reducerState: getStateFromStorage(),
|
||||
currentError: undefined,
|
||||
discoveryState: {
|
||||
state: "none",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@ -192,6 +214,7 @@ export function useAnastasisReducer(): AnastasisReducerApi {
|
||||
return {
|
||||
currentReducerState: anastasisState.reducerState,
|
||||
currentError: anastasisState.currentError,
|
||||
discoveryState: anastasisState.discoveryState,
|
||||
async startBackup() {
|
||||
let s: ReducerState;
|
||||
if (remoteReducer) {
|
||||
@ -213,17 +236,59 @@ export function useAnastasisReducer(): AnastasisReducerApi {
|
||||
}
|
||||
},
|
||||
exportState() {
|
||||
const state = getStateFromStorage()
|
||||
return JSON.stringify(state)
|
||||
const state = getStateFromStorage();
|
||||
return JSON.stringify(state);
|
||||
},
|
||||
importState(s: string) {
|
||||
try {
|
||||
const state = JSON.parse(s)
|
||||
setAnastasisState({ reducerState: state, currentError: undefined })
|
||||
const state = JSON.parse(s);
|
||||
setAnastasisState({
|
||||
reducerState: state,
|
||||
currentError: undefined,
|
||||
discoveryState: {
|
||||
state: "none",
|
||||
},
|
||||
});
|
||||
} 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() {
|
||||
let s: ReducerState;
|
||||
if (remoteReducer) {
|
||||
@ -301,7 +366,7 @@ export function useAnastasisReducer(): AnastasisReducerApi {
|
||||
}
|
||||
|
||||
class ReducerTxImpl implements ReducerTransactionHandle {
|
||||
constructor(public transactionState: ReducerState) { }
|
||||
constructor(public transactionState: ReducerState) {}
|
||||
async transition(action: string, args: any): Promise<ReducerState> {
|
||||
let s: ReducerState;
|
||||
if (remoteReducer) {
|
||||
|
@ -1,9 +1,10 @@
|
||||
import {
|
||||
AuthenticationProviderStatus,
|
||||
AuthenticationProviderStatusOk,
|
||||
PolicyMetaInfo,
|
||||
} from "@gnu-taler/anastasis-core";
|
||||
import { h, VNode } from "preact";
|
||||
import { useState } from "preact/hooks";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { AsyncButton } from "../../components/AsyncButton";
|
||||
import { PhoneNumberInput } from "../../components/fields/NumberInput";
|
||||
import { useAnastasisContext } from "../../context/anastasis";
|
||||
@ -13,8 +14,100 @@ import { AnastasisClientFrame } from "./index";
|
||||
export function SecretSelectionScreen(): 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));
|
||||
}, []);
|
||||
|
||||
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 =
|
||||
(reducer?.currentReducerState &&
|
||||
"recovery_document" in reducer.currentReducerState &&
|
||||
@ -71,15 +164,16 @@ export function SecretSelectionScreen(): VNode {
|
||||
return <AddingProviderScreen onCancel={() => setManageProvider(false)} />;
|
||||
}
|
||||
|
||||
const provierInfo = provs[
|
||||
const providerInfo = provs[
|
||||
recoveryDocument.provider_url
|
||||
] as AuthenticationProviderStatusOk;
|
||||
|
||||
return (
|
||||
<AnastasisClientFrame title="Recovery: Select secret">
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<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">
|
||||
{currentVersion === 0 ? (
|
||||
<p>Set to recover the latest version</p>
|
||||
|
Loading…
Reference in New Issue
Block a user