aboutsummaryrefslogtreecommitdiff
path: root/packages/anastasis-core/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/anastasis-core/src')
-rw-r--r--packages/anastasis-core/src/crypto.test.ts5
-rw-r--r--packages/anastasis-core/src/crypto.ts50
-rw-r--r--packages/anastasis-core/src/index.ts329
-rw-r--r--packages/anastasis-core/src/recovery-document-types.ts66
-rw-r--r--packages/anastasis-core/src/reducer-types.ts51
5 files changed, 409 insertions, 92 deletions
diff --git a/packages/anastasis-core/src/crypto.test.ts b/packages/anastasis-core/src/crypto.test.ts
index 1c255014a..c0f5e41c1 100644
--- a/packages/anastasis-core/src/crypto.test.ts
+++ b/packages/anastasis-core/src/crypto.test.ts
@@ -1,6 +1,7 @@
import test from "ava";
import {
accountKeypairDerive,
+ decryptTruth,
encryptKeyshare,
encryptTruth,
policyKeyDerive,
@@ -94,4 +95,8 @@ test("truth encryption", async (t) => {
tv.input_truth,
);
t.is(enc, tv.output_encrypted_truth);
+
+ const dec = await decryptTruth(tv.input_truth_enc_key, enc);
+
+ t.is(dec, tv.input_truth);
});
diff --git a/packages/anastasis-core/src/crypto.ts b/packages/anastasis-core/src/crypto.ts
index 63de795b0..8df893f4b 100644
--- a/packages/anastasis-core/src/crypto.ts
+++ b/packages/anastasis-core/src/crypto.ts
@@ -9,6 +9,7 @@ import {
secretbox,
crypto_sign_keyPair_fromSeed,
stringToBytes,
+ secretbox_open,
} from "@gnu-taler/taler-util";
import { gzipSync } from "fflate";
import { argon2id } from "hash-wasm";
@@ -87,7 +88,7 @@ export function accountKeypairDerive(userId: UserIdentifier): AccountKeyPair {
/**
* Encrypt the recovery document.
- *
+ *
* The caller should first compress the recovery doc.
*/
export async function encryptRecoveryDocument(
@@ -95,12 +96,19 @@ export async function encryptRecoveryDocument(
recoveryDocData: OpaqueData,
): Promise<OpaqueData> {
const nonce = encodeCrock(getRandomBytes(nonceSize));
- return anastasisEncrypt(
- nonce,
- asOpaque(userId),
- recoveryDocData,
- "erd",
- );
+ return anastasisEncrypt(nonce, asOpaque(userId), recoveryDocData, "erd");
+}
+
+/**
+ * Encrypt the recovery document.
+ *
+ * The caller should first compress the recovery doc.
+ */
+export async function decryptRecoveryDocument(
+ userId: UserIdentifier,
+ recoveryDocData: OpaqueData,
+): Promise<OpaqueData> {
+ return anastasisDecrypt(asOpaque(userId), recoveryDocData, "erd");
}
export function typedArrayConcat(chunks: Uint8Array[]): Uint8Array {
@@ -158,6 +166,22 @@ async function anastasisEncrypt(
return encodeCrock(typedArrayConcat([nonceBuf, cipherText]));
}
+async function anastasisDecrypt(
+ keySeed: OpaqueData,
+ ciphertext: OpaqueData,
+ salt: string,
+): Promise<OpaqueData> {
+ const ctBuf = decodeCrock(ciphertext);
+ const nonceBuf = ctBuf.slice(0, nonceSize);
+ const enc = ctBuf.slice(nonceSize);
+ const key = await deriveKey(keySeed, encodeCrock(nonceBuf), salt);
+ const cipherText = secretbox_open(enc, nonceBuf, key);
+ if (!cipherText) {
+ throw Error("could not decrypt");
+ }
+ return encodeCrock(cipherText);
+}
+
export const asOpaque = (x: string): OpaqueData => x;
const asEncryptedKeyShare = (x: OpaqueData): EncryptedKeyShare => x as string;
const asEncryptedTruth = (x: OpaqueData): EncryptedTruth => x as string;
@@ -185,6 +209,18 @@ export async function encryptTruth(
);
}
+export async function decryptTruth(
+ truthEncKey: TruthKey,
+ truthEnc: EncryptedTruth,
+): Promise<OpaqueData> {
+ const salt = "ect";
+ return await anastasisDecrypt(
+ asOpaque(truthEncKey),
+ asOpaque(truthEnc),
+ salt,
+ );
+}
+
export interface CoreSecretEncResult {
encCoreSecret: EncryptedCoreSecret;
encMasterKeys: EncryptedMasterKey[];
diff --git a/packages/anastasis-core/src/index.ts b/packages/anastasis-core/src/index.ts
index c99bd5b44..b8fedf006 100644
--- a/packages/anastasis-core/src/index.ts
+++ b/packages/anastasis-core/src/index.ts
@@ -2,6 +2,8 @@ import {
AmountString,
buildSigPS,
bytesToString,
+ Codec,
+ codecForAny,
decodeCrock,
eddsaSign,
encodeCrock,
@@ -24,6 +26,7 @@ import {
ActionArgEnterSecret,
ActionArgEnterSecretName,
ActionArgEnterUserAttributes,
+ ActionArgSelectChallenge,
AuthenticationProviderStatus,
AuthenticationProviderStatusOk,
AuthMethod,
@@ -33,6 +36,8 @@ import {
MethodSpec,
Policy,
PolicyProvider,
+ RecoveryInformation,
+ RecoveryInternalData,
RecoveryStates,
ReducerState,
ReducerStateBackup,
@@ -60,78 +65,15 @@ import {
UserIdentifier,
userIdentifierDerive,
typedArrayConcat,
+ decryptRecoveryDocument,
} from "./crypto.js";
-import { zlibSync } from "fflate";
+import { unzlibSync, zlibSync } from "fflate";
+import { EscrowMethod, RecoveryDocument } from "./recovery-document-types.js";
const { fetch, Request, Response, Headers } = fetchPonyfill({});
export * from "./reducer-types.js";
-interface RecoveryDocument {
- // Human-readable name of the secret
- secret_name?: string;
-
- // Encrypted core secret.
- encrypted_core_secret: string; // bytearray of undefined length
-
- // List of escrow providers and selected authentication method.
- escrow_methods: EscrowMethod[];
-
- // List of possible decryption policies.
- policies: DecryptionPolicy[];
-}
-
-interface DecryptionPolicy {
- // Salt included to encrypt master key share when
- // using this decryption policy.
- salt: string;
-
- /**
- * Master key, AES-encrypted with key derived from
- * salt and keyshares revealed by the following list of
- * escrow methods identified by UUID.
- */
- master_key: string;
-
- /**
- * List of escrow methods identified by their UUID.
- */
- uuids: string[];
-}
-
-interface EscrowMethod {
- /**
- * URL of the escrow provider (including possibly this Anastasis server).
- */
- url: string;
-
- /**
- * Type of the escrow method (e.g. security question, SMS etc.).
- */
- escrow_type: string;
-
- // 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/.
- truth_key: TruthKey;
-
- /**
- * Salt to hash the security question answer if applicable.
- */
- truth_salt: TruthSalt;
-
- // Salt from the provider to derive the user ID
- // at this provider.
- provider_salt: string;
-
- // The instructions to give to the user (i.e. the security question
- // if this is challenge-response).
- instructions: string;
-}
-
function getContinents(): ContinentInfo[] {
const continentSet = new Set<string>();
const continents: ContinentInfo[] = [];
@@ -203,6 +145,41 @@ async function backupSelectCountry(
};
}
+async function recoverySelectCountry(
+ state: ReducerStateRecovery,
+ countryCode: string,
+ currencies: string[],
+): Promise<ReducerStateError | ReducerStateRecovery> {
+ const country = anastasisData.countriesList.countries.find(
+ (x) => x.code === countryCode,
+ );
+ if (!country) {
+ return {
+ code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
+ hint: "invalid country selected",
+ };
+ }
+
+ const providers: { [x: string]: {} } = {};
+ for (const prov of anastasisData.providersList.anastasis_provider) {
+ if (currencies.includes(prov.currency)) {
+ providers[prov.url] = {};
+ }
+ }
+
+ const ra = (anastasisData.countryDetails as any)[countryCode]
+ .required_attributes;
+
+ return {
+ ...state,
+ recovery_state: RecoveryStates.UserAttributesCollecting,
+ selected_country: countryCode,
+ currencies,
+ required_attributes: ra,
+ authentication_providers: providers,
+ };
+}
+
async function getProviderInfo(
providerBaseUrl: string,
): Promise<AuthenticationProviderStatus> {
@@ -436,6 +413,13 @@ async function compressRecoveryDoc(rd: any): Promise<Uint8Array> {
return typedArrayConcat([new Uint8Array(sizeHeaderBuf), zippedDoc]);
}
+async function uncompressRecoveryDoc(zippedRd: Uint8Array): Promise<any> {
+ const header = zippedRd.slice(0, 4);
+ const data = zippedRd.slice(4);
+ const res = unzlibSync(data);
+ return JSON.parse(bytesToString(res));
+}
+
async function uploadSecret(
state: ReducerStateBackup,
): Promise<ReducerStateBackup | ReducerStateError> {
@@ -632,6 +616,97 @@ async function uploadSecret(
};
}
+/**
+ * Download policy based on current user attributes and selected
+ * version in the state.
+ */
+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]: AuthenticationProviderStatus } = {};
+ const userAttributes = state.identity_attributes!;
+ 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;
+ const userId = await userIdentifierDerive(userAttributes, pi.salt);
+ const acctKeypair = accountKeypairDerive(userId);
+ const resp = await fetch(new URL(`policy/${acctKeypair.pub}`, url).href);
+ if (resp.status !== 200) {
+ continue;
+ }
+ const body = await resp.arrayBuffer();
+ const bodyDecrypted = await decryptRecoveryDocument(
+ userId,
+ encodeCrock(body),
+ );
+ const rd: RecoveryDocument = await uncompressRecoveryDoc(
+ decodeCrock(bodyDecrypted),
+ );
+ console.log("rd", rd);
+ let policyVersion = 0;
+ try {
+ policyVersion = Number(resp.headers.get("Anastasis-Version") ?? "0");
+ } catch (e) {}
+ foundRecoveryInfo = {
+ provider_url: url,
+ secret_name: rd.secret_name ?? "<unknown>",
+ version: policyVersion,
+ };
+ recoveryDoc = rd;
+ break;
+ }
+ if (!foundRecoveryInfo || !recoveryDoc) {
+ return {
+ code: TalerErrorCode.ANASTASIS_REDUCER_POLICY_LOOKUP_FAILED,
+ hint: "No backups found at any provider for your identity information.",
+ };
+ }
+ const recoveryInfo: RecoveryInformation = {
+ challenges: recoveryDoc.escrow_methods.map((x) => {
+ console.log("providers", state.authentication_providers);
+ 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) => {
+ return x.uuids.map((m) => {
+ return {
+ uuid: m,
+ };
+ });
+ }),
+ };
+ return {
+ ...state,
+ recovery_state: RecoveryStates.SecretSelecting,
+ recovery_document: foundRecoveryInfo,
+ recovery_information: recoveryInfo,
+ };
+}
+
+async function recoveryEnterUserAttributes(
+ state: ReducerStateRecovery,
+ attributes: Record<string, string>,
+): Promise<ReducerStateRecovery | ReducerStateError> {
+ // FIXME: validate attributes
+ const st: ReducerStateRecovery = {
+ ...state,
+ identity_attributes: attributes,
+ };
+ return downloadPolicy(st);
+}
+
export async function reduceAction(
state: ReducerState,
action: string,
@@ -827,6 +902,128 @@ export async function reduceAction(
};
}
}
+
+ if (state.recovery_state === RecoveryStates.ContinentSelecting) {
+ if (action === "select_continent") {
+ const continent: string = args.continent;
+ if (typeof continent !== "string") {
+ return {
+ code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
+ hint: "continent required",
+ };
+ }
+ return {
+ ...state,
+ recovery_state: RecoveryStates.CountrySelecting,
+ countries: getCountries(continent),
+ selected_continent: continent,
+ };
+ } else {
+ return {
+ code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
+ hint: `Unsupported action '${action}'`,
+ };
+ }
+ }
+
+ if (state.recovery_state === RecoveryStates.CountrySelecting) {
+ if (action === "back") {
+ return {
+ ...state,
+ recovery_state: RecoveryStates.ContinentSelecting,
+ countries: undefined,
+ };
+ } else if (action === "select_country") {
+ const countryCode = args.country_code;
+ if (typeof countryCode !== "string") {
+ return {
+ code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
+ hint: "country_code required",
+ };
+ }
+ const currencies = args.currencies;
+ return recoverySelectCountry(state, countryCode, currencies);
+ } else {
+ return {
+ code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
+ hint: `Unsupported action '${action}'`,
+ };
+ }
+ }
+
+ if (state.recovery_state === RecoveryStates.UserAttributesCollecting) {
+ if (action === "back") {
+ return {
+ ...state,
+ recovery_state: RecoveryStates.CountrySelecting,
+ };
+ } else if (action === "enter_user_attributes") {
+ const ta = args as ActionArgEnterUserAttributes;
+ return recoveryEnterUserAttributes(state, ta.identity_attributes);
+ } else {
+ return {
+ code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
+ hint: `Unsupported action '${action}'`,
+ };
+ }
+ }
+
+ if (state.recovery_state === RecoveryStates.SecretSelecting) {
+ if (action === "back") {
+ return {
+ ...state,
+ recovery_state: RecoveryStates.UserAttributesCollecting,
+ };
+ } else if (action === "next") {
+ return {
+ ...state,
+ recovery_state: RecoveryStates.ChallengeSelecting,
+ };
+ } else {
+ return {
+ code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
+ hint: `Unsupported action '${action}'`,
+ };
+ }
+ }
+
+ 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,
+ };
+ } else if (action === "back") {
+ return {
+ ...state,
+ recovery_state: RecoveryStates.SecretSelecting,
+ };
+ } else {
+ return {
+ code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
+ hint: `Unsupported action '${action}'`,
+ };
+ }
+ }
+
+ if (state.recovery_state === RecoveryStates.ChallengeSolving) {
+ if (action === "back") {
+ const ta: ActionArgSelectChallenge = args;
+ return {
+ ...state,
+ selected_challenge_uuid: undefined,
+ recovery_state: RecoveryStates.ChallengeSelecting,
+ };
+ } else {
+ return {
+ code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
+ hint: `Unsupported action '${action}'`,
+ };
+ }
+ }
+
return {
code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
hint: "Reducer action invalid",
diff --git a/packages/anastasis-core/src/recovery-document-types.ts b/packages/anastasis-core/src/recovery-document-types.ts
new file mode 100644
index 000000000..a1d9a55fc
--- /dev/null
+++ b/packages/anastasis-core/src/recovery-document-types.ts
@@ -0,0 +1,66 @@
+import { TruthKey, TruthSalt, TruthUuid } from "./crypto.js";
+
+export interface RecoveryDocument {
+ // Human-readable name of the secret
+ secret_name?: string;
+
+ // Encrypted core secret.
+ encrypted_core_secret: string; // bytearray of undefined length
+
+ // List of escrow providers and selected authentication method.
+ escrow_methods: EscrowMethod[];
+
+ // List of possible decryption policies.
+ policies: DecryptionPolicy[];
+}
+
+export interface DecryptionPolicy {
+ // Salt included to encrypt master key share when
+ // using this decryption policy.
+ salt: string;
+
+ /**
+ * Master key, AES-encrypted with key derived from
+ * salt and keyshares revealed by the following list of
+ * escrow methods identified by UUID.
+ */
+ master_key: string;
+
+ /**
+ * List of escrow methods identified by their UUID.
+ */
+ uuids: string[];
+}
+
+export interface EscrowMethod {
+ /**
+ * URL of the escrow provider (including possibly this Anastasis server).
+ */
+ url: string;
+
+ /**
+ * Type of the escrow method (e.g. security question, SMS etc.).
+ */
+ escrow_type: string;
+
+ // 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/.
+ truth_key: TruthKey;
+
+ /**
+ * Salt to hash the security question answer if applicable.
+ */
+ truth_salt: TruthSalt;
+
+ // Salt from the provider to derive the user ID
+ // at this provider.
+ provider_salt: string;
+
+ // 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 44761ea0a..4c73dfa66 100644
--- a/packages/anastasis-core/src/reducer-types.ts
+++ b/packages/anastasis-core/src/reducer-types.ts
@@ -93,6 +93,22 @@ export interface UserAttributeSpec {
widget: string;
}
+export interface RecoveryInternalData {
+ secret_name: string;
+ provider_url: string;
+ version: number;
+}
+
+export interface RecoveryInformation {
+ challenges: ChallengeInfo[];
+ policies: {
+ /**
+ * UUID of the associated challenge.
+ */
+ uuid: string;
+ }[][];
+}
+
export interface ReducerStateRecovery {
backup_state?: undefined;
recovery_state: RecoveryStates;
@@ -102,23 +118,20 @@ export interface ReducerStateRecovery {
continents?: any;
countries?: any;
+
+ selected_continent?: string;
+ selected_country?: string;
+ currencies?: string[];
+
required_attributes?: any;
- recovery_information?: {
- challenges: ChallengeInfo[];
- policies: {
- /**
- * UUID of the associated challenge.
- */
- uuid: string;
- }[][];
- };
+ /**
+ * Recovery information, used by the UI.
+ */
+ recovery_information?: RecoveryInformation;
- recovery_document?: {
- secret_name: string;
- provider_url: string;
- version: number;
- };
+ // FIXME: This should really be renamed to recovery_internal_data
+ recovery_document?: RecoveryInternalData;
selected_challenge_uuid?: string;
@@ -129,11 +142,7 @@ export interface ReducerStateRecovery {
value: string;
};
- authentication_providers?: {
- [url: string]: {
- business_name: string;
- };
- };
+ authentication_providers?: { [url: string]: AuthenticationProviderStatus };
recovery_error?: any;
}
@@ -244,3 +253,7 @@ export interface ActionArgEnterSecret {
};
expiration: Duration;
}
+
+export interface ActionArgSelectChallenge {
+ uuid: string;
+}