anastasis-core: compatible secret upload

This commit is contained in:
Florian Dold 2021-10-19 23:26:29 +02:00
parent 5dc0089392
commit 6c5d32be74
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
8 changed files with 80 additions and 35 deletions

View File

@ -23,6 +23,7 @@
"dependencies": {
"@gnu-taler/taler-util": "workspace:^0.8.3",
"fetch-ponyfill": "^7.1.0",
"fflate": "^0.6.0",
"hash-wasm": "^4.9.0",
"node-fetch": "^3.0.0"
},

View File

@ -1,6 +1,7 @@
import test from "ava";
import {
accountKeypairDerive,
encryptKeyshare,
encryptTruth,
policyKeyDerive,
secureAnswerHash,

View File

@ -10,6 +10,7 @@ import {
crypto_sign_keyPair_fromSeed,
stringToBytes,
} from "@gnu-taler/taler-util";
import { gzipSync } from "fflate";
import { argon2id } from "hash-wasm";
export type Flavor<T, FlavorT extends string> = T & {
@ -84,21 +85,25 @@ export function accountKeypairDerive(userId: UserIdentifier): AccountKeyPair {
};
}
/**
* Encrypt the recovery document.
*
* The caller should first compress the recovery doc.
*/
export async function encryptRecoveryDocument(
userId: UserIdentifier,
recoveryDoc: any,
recoveryDocData: OpaqueData,
): Promise<OpaqueData> {
const plaintext = stringToBytes(JSON.stringify(recoveryDoc));
const nonce = encodeCrock(getRandomBytes(nonceSize));
return anastasisEncrypt(
nonce,
asOpaque(userId),
encodeCrock(plaintext),
recoveryDocData,
"erd",
);
}
function taConcat(chunks: Uint8Array[]): Uint8Array {
export function typedArrayConcat(chunks: Uint8Array[]): Uint8Array {
let payloadLen = 0;
for (const c of chunks) {
payloadLen += c.byteLength;
@ -120,7 +125,7 @@ export async function policyKeyDerive(
const chunks = keyShares.map((x) => decodeCrock(x));
const polKey = kdfKw({
outputLength: 64,
ikm: taConcat(chunks),
ikm: typedArrayConcat(chunks),
salt: decodeCrock(policySalt),
info: stringToBytes("anastasis-policy-key-derive"),
});
@ -150,7 +155,7 @@ async function anastasisEncrypt(
const key = await deriveKey(keySeed, nonce, salt);
const nonceBuf = decodeCrock(nonce);
const cipherText = secretbox(decodeCrock(plaintext), decodeCrock(nonce), key);
return encodeCrock(taConcat([nonceBuf, cipherText]));
return encodeCrock(typedArrayConcat([nonceBuf, cipherText]));
}
export const asOpaque = (x: string): OpaqueData => x;

View File

@ -1,6 +1,7 @@
import {
AmountString,
buildSigPS,
bytesToString,
decodeCrock,
eddsaSign,
encodeCrock,
@ -58,7 +59,9 @@ import {
TruthUuid,
UserIdentifier,
userIdentifierDerive,
typedArrayConcat,
} from "./crypto.js";
import { zlibSync } from "fflate";
const { fetch, Request, Response, Headers } = fetchPonyfill({});
@ -93,7 +96,7 @@ interface DecryptionPolicy {
/**
* List of escrow methods identified by their UUID.
*/
uuid: string[];
uuids: string[];
}
interface EscrowMethod {
@ -115,8 +118,10 @@ interface EscrowMethod {
// Client has to provide this key to the server when using /truth/.
truth_key: TruthKey;
// Salt used to encrypt the truth on the Anastasis server.
salt: string;
/**
* Salt to hash the security question answer if applicable.
*/
truth_salt: TruthSalt;
// Salt from the provider to derive the user ID
// at this provider.
@ -401,7 +406,11 @@ async function getTruthValue(
switch (authMethod.type) {
case "question": {
return asOpaque(
await secureAnswerHash(authMethod.challenge, truthUuid, questionSalt),
await secureAnswerHash(
bytesToString(decodeCrock(authMethod.challenge)),
truthUuid,
questionSalt,
),
);
}
case "sms":
@ -414,12 +423,28 @@ async function getTruthValue(
}
}
/**
* Compress the recovery document and add a size header.
*/
async function compressRecoveryDoc(rd: any): Promise<Uint8Array> {
console.log("recovery document", rd);
const docBytes = stringToBytes(JSON.stringify(rd));
console.log("plain doc length", docBytes.length);
const sizeHeaderBuf = new ArrayBuffer(4);
const dvbuf = new DataView(sizeHeaderBuf);
dvbuf.setUint32(0, docBytes.length, false);
const zippedDoc = zlibSync(docBytes);
return typedArrayConcat([new Uint8Array(sizeHeaderBuf), zippedDoc]);
}
async function uploadSecret(
state: ReducerStateBackup,
): Promise<ReducerStateBackup | ReducerStateError> {
const policies = state.policies!;
const secretName = state.secret_name!;
const coreSecret = state.core_secret?.value!;
const coreSecret: OpaqueData = encodeCrock(
stringToBytes(JSON.stringify(state.core_secret!)),
);
// Truth key is `${methodIndex}/${providerUrl}`
const truthMetadataMap: Record<string, TruthMetaData> = {};
@ -435,8 +460,8 @@ async function uploadSecret(
const methUuids: string[] = [];
for (let methIndex = 0; methIndex < pol.methods.length; methIndex++) {
const meth = pol.methods[methIndex];
const truthKey = `${meth.authentication_method}:${meth.provider}`;
if (truthMetadataMap[truthKey]) {
const truthReference = `${meth.authentication_method}:${meth.provider}`;
if (truthMetadataMap[truthReference]) {
continue;
}
const keyShare = encodeCrock(getRandomBytes(32));
@ -445,15 +470,16 @@ async function uploadSecret(
key_share: keyShare,
nonce: encodeCrock(getRandomBytes(24)),
truth_salt: encodeCrock(getRandomBytes(16)),
truth_key: encodeCrock(getRandomBytes(32)),
truth_key: encodeCrock(getRandomBytes(64)),
uuid: encodeCrock(getRandomBytes(32)),
pol_method_index: methIndex,
policy_index: policyIndex,
};
methUuids.push(tm.uuid);
truthMetadataMap[truthKey] = tm;
truthMetadataMap[truthReference] = tm;
}
const policyKey = await policyKeyDerive(keyShares, policySalt);
policyUuids.push(methUuids);
policyKeys.push(policyKey);
policySalts.push(policySalt);
}
@ -492,7 +518,9 @@ async function uploadSecret(
const encryptedKeyShare = await encryptKeyshare(
tm.key_share,
uid,
tm.truth_salt,
authMethod.type === "question"
? bytesToString(decodeCrock(authMethod.challenge))
: undefined,
);
console.log(
"encrypted key share len",
@ -524,7 +552,7 @@ async function uploadSecret(
escrow_type: authMethod.type,
instructions: authMethod.instructions,
provider_salt: provider.salt,
salt: tm.truth_salt,
truth_salt: tm.truth_salt,
truth_key: tm.truth_key,
url: meth.provider,
uuid: tm.uuid,
@ -542,7 +570,7 @@ async function uploadSecret(
policies: policies.map((x, i) => {
return {
master_key: csr.encMasterKeys[i],
uuid: policyUuids[i],
uuids: policyUuids[i],
salt: policySalts[i],
};
}),
@ -553,7 +581,12 @@ async function uploadSecret(
for (const prov of state.policy_providers!) {
const uid = uidMap[prov.provider_url];
const acctKeypair = accountKeypairDerive(uid);
const encRecoveryDoc = await encryptRecoveryDocument(uid, rd);
const zippedDoc = await compressRecoveryDoc(rd);
console.log("zipped doc", zippedDoc);
const encRecoveryDoc = await encryptRecoveryDocument(
uid,
encodeCrock(zippedDoc),
);
const bodyHash = hash(decodeCrock(encRecoveryDoc));
const sigPS = buildSigPS(TalerSignaturePurpose.ANASTASIS_POLICY_UPLOAD)
.put(bodyHash)

View File

@ -34,6 +34,11 @@ export interface SuccessDetails {
};
}
export interface CoreSecret {
mime: string;
value: string;
}
export interface ReducerStateBackup {
recovery_state?: undefined;
backup_state: BackupStates;
@ -61,10 +66,7 @@ export interface ReducerStateBackup {
provider: string;
}[];
core_secret?: {
mime: string;
value: string;
};
core_secret?: CoreSecret;
expiration?: Duration;
}

View File

@ -3,7 +3,7 @@ import { BackupStates, getBackupStartState, getRecoveryStartState, RecoveryState
import { useState } from "preact/hooks";
const reducerBaseUrl = "http://localhost:5000/";
const remoteReducer = true;
const remoteReducer = false;
interface AnastasisState {
reducerState: ReducerState | undefined;

View File

@ -1,20 +1,19 @@
/* eslint-disable @typescript-eslint/camelcase */
import {
encodeCrock,
stringToBytes
} from "@gnu-taler/taler-util";
import { encodeCrock, stringToBytes } from "@gnu-taler/taler-util";
import { h, VNode } from "preact";
import { useState } from "preact/hooks";
import { BackupReducerProps, AnastasisClientFrame, LabeledInput } from "./index";
import {
BackupReducerProps,
AnastasisClientFrame,
LabeledInput,
} from "./index";
export function SecretEditorScreen(props: BackupReducerProps): VNode {
const { reducer } = props;
const [secretName, setSecretName] = useState(
props.backupState.secret_name ?? ""
);
const [secretValue, setSecretValue] = useState(
props.backupState.core_secret?.value ?? "" ?? ""
props.backupState.secret_name ?? "",
);
const [secretValue, setSecretValue] = useState("");
const secretNext = (): void => {
reducer.runTransaction(async (tx) => {
await tx.transition("enter_secret_name", {
@ -41,12 +40,14 @@ export function SecretEditorScreen(props: BackupReducerProps): VNode {
<LabeledInput
label="Secret Name:"
grabFocus
bind={[secretName, setSecretName]} />
bind={[secretName, setSecretName]}
/>
</div>
<div>
<LabeledInput
label="Secret Value:"
bind={[secretValue, setSecretValue]} />
bind={[secretValue, setSecretValue]}
/>
</div>
</AnastasisClientFrame>
);

View File

@ -17,12 +17,14 @@ importers:
'@gnu-taler/taler-util': workspace:^0.8.3
ava: ^3.15.0
fetch-ponyfill: ^7.1.0
fflate: ^0.6.0
hash-wasm: ^4.9.0
node-fetch: ^3.0.0
typescript: ^4.4.3
dependencies:
'@gnu-taler/taler-util': link:../taler-util
fetch-ponyfill: 7.1.0
fflate: 0.6.0
hash-wasm: 4.9.0
node-fetch: 3.0.0
devDependencies: