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": { "dependencies": {
"@gnu-taler/taler-util": "workspace:^0.8.3", "@gnu-taler/taler-util": "workspace:^0.8.3",
"fetch-ponyfill": "^7.1.0", "fetch-ponyfill": "^7.1.0",
"fflate": "^0.6.0",
"hash-wasm": "^4.9.0", "hash-wasm": "^4.9.0",
"node-fetch": "^3.0.0" "node-fetch": "^3.0.0"
}, },

View File

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

View File

@ -10,6 +10,7 @@ import {
crypto_sign_keyPair_fromSeed, crypto_sign_keyPair_fromSeed,
stringToBytes, stringToBytes,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { gzipSync } from "fflate";
import { argon2id } from "hash-wasm"; import { argon2id } from "hash-wasm";
export type Flavor<T, FlavorT extends string> = T & { 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( export async function encryptRecoveryDocument(
userId: UserIdentifier, userId: UserIdentifier,
recoveryDoc: any, recoveryDocData: OpaqueData,
): Promise<OpaqueData> { ): Promise<OpaqueData> {
const plaintext = stringToBytes(JSON.stringify(recoveryDoc));
const nonce = encodeCrock(getRandomBytes(nonceSize)); const nonce = encodeCrock(getRandomBytes(nonceSize));
return anastasisEncrypt( return anastasisEncrypt(
nonce, nonce,
asOpaque(userId), asOpaque(userId),
encodeCrock(plaintext), recoveryDocData,
"erd", "erd",
); );
} }
function taConcat(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) {
payloadLen += c.byteLength; payloadLen += c.byteLength;
@ -120,7 +125,7 @@ export async function policyKeyDerive(
const chunks = keyShares.map((x) => decodeCrock(x)); const chunks = keyShares.map((x) => decodeCrock(x));
const polKey = kdfKw({ const polKey = kdfKw({
outputLength: 64, outputLength: 64,
ikm: taConcat(chunks), ikm: typedArrayConcat(chunks),
salt: decodeCrock(policySalt), salt: decodeCrock(policySalt),
info: stringToBytes("anastasis-policy-key-derive"), info: stringToBytes("anastasis-policy-key-derive"),
}); });
@ -150,7 +155,7 @@ async function anastasisEncrypt(
const key = await deriveKey(keySeed, nonce, salt); const key = await deriveKey(keySeed, nonce, salt);
const nonceBuf = decodeCrock(nonce); const nonceBuf = decodeCrock(nonce);
const cipherText = secretbox(decodeCrock(plaintext), decodeCrock(nonce), key); 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; export const asOpaque = (x: string): OpaqueData => x;

View File

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

View File

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

View File

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

View File

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

View File

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