wallet-core/packages/anastasis-core/src/crypto.ts

363 lines
10 KiB
TypeScript

/*
This file is part of GNU Anastasis
(C) 2021-2022 Anastasis SARL
GNU Anastasis is free software; you can redistribute it and/or modify it under the
terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License along with
GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import {
canonicalJson,
decodeCrock,
encodeCrock,
getRandomBytes,
kdfKw,
secretbox,
crypto_sign_keyPair_fromSeed,
stringToBytes,
secretbox_open,
hash,
bytesToString,
} from "@gnu-taler/taler-util";
import { argon2id } from "hash-wasm";
export type Flavor<T, FlavorT extends string> = T & {
_flavor?: `anastasis.${FlavorT}`;
};
export type FlavorP<T, FlavorT extends string, S extends number> = T & {
_flavor?: `anastasis.${FlavorT}`;
_size?: S;
};
export type UserIdentifier = Flavor<string, "UserIdentifier">;
export type ServerSalt = Flavor<string, "ServerSalt">;
export type PolicySalt = Flavor<string, "PolicySalt">;
export type PolicyKey = FlavorP<string, "PolicyKey", 64>;
export type KeyShare = Flavor<string, "KeyShare">;
export type EncryptedKeyShare = Flavor<string, "EncryptedKeyShare">;
export type EncryptedTruth = Flavor<string, "EncryptedTruth">;
export type EncryptedCoreSecret = Flavor<string, "EncryptedCoreSecret">;
export type EncryptedMasterKey = Flavor<string, "EncryptedMasterKey">;
export type EddsaPublicKey = Flavor<string, "EddsaPublicKey">;
export type EddsaPrivateKey = Flavor<string, "EddsaPrivateKey">;
export type TruthUuid = Flavor<string, "TruthUuid">;
export type SecureAnswerHash = Flavor<string, "SecureAnswerHash">;
/**
* Truth-specific randomness, also called question salt sometimes.
*/
export type TruthSalt = Flavor<string, "TruthSalt">;
/**
* Truth key, found in the recovery document.
*/
export type TruthKey = Flavor<string, "TruthKey">;
export type EncryptionNonce = Flavor<string, "EncryptionNonce">;
export type OpaqueData = Flavor<string, "OpaqueData">;
const nonceSize = 24;
const masterKeySize = 64;
export async function userIdentifierDerive(
idData: any,
serverSalt: ServerSalt,
): Promise<UserIdentifier> {
const canonIdData = canonicalJson(idData);
const hashInput = stringToBytes(canonIdData);
const result = await argon2id({
hashLength: 64,
iterations: 3,
memorySize: 1024 /* kibibytes */,
parallelism: 1,
password: hashInput,
salt: decodeCrock(serverSalt),
outputType: "binary",
});
return encodeCrock(result);
}
export interface AccountKeyPair {
priv: EddsaPrivateKey;
pub: EddsaPublicKey;
}
export function accountKeypairDerive(userId: UserIdentifier): AccountKeyPair {
// FIXME: the KDF invocation looks fishy, but that's what the C code presently does.
const d = kdfKw({
outputLength: 32,
ikm: decodeCrock(userId),
info: stringToBytes("ver"),
});
const pair = crypto_sign_keyPair_fromSeed(d);
return {
priv: encodeCrock(d),
pub: encodeCrock(pair.publicKey),
};
}
/**
* Encrypt the recovery document.
*
* The caller should first compress the recovery doc.
*/
export async function encryptRecoveryDocument(
userId: UserIdentifier,
recoveryDocData: OpaqueData,
): Promise<OpaqueData> {
const nonce = encodeCrock(getRandomBytes(nonceSize));
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 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) {
payloadLen += c.byteLength;
}
const buf = new ArrayBuffer(payloadLen);
const u8buf = new Uint8Array(buf);
let p = 0;
for (const c of chunks) {
u8buf.set(c, p);
p += c.byteLength;
}
return u8buf;
}
export async function policyKeyDerive(
keyShares: KeyShare[],
policySalt: PolicySalt,
): Promise<PolicyKey> {
const chunks = keyShares.map((x) => decodeCrock(x));
const polKey = kdfKw({
outputLength: 64,
ikm: typedArrayConcat(chunks),
salt: decodeCrock(policySalt),
info: stringToBytes("anastasis-policy-key-derive"),
});
return encodeCrock(polKey);
}
async function deriveKey(
keySeed: OpaqueData,
nonce: EncryptionNonce,
salt: string,
): Promise<Uint8Array> {
return kdfKw({
outputLength: 32,
salt: decodeCrock(nonce),
ikm: decodeCrock(keySeed),
info: stringToBytes(salt),
});
}
async function anastasisEncrypt(
nonce: EncryptionNonce,
keySeed: OpaqueData,
plaintext: OpaqueData,
salt: string,
): Promise<OpaqueData> {
const key = await deriveKey(keySeed, nonce, salt);
const nonceBuf = decodeCrock(nonce);
const cipherText = secretbox(decodeCrock(plaintext), decodeCrock(nonce), key);
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 clearText = secretbox_open(enc, nonceBuf, key);
if (!clearText) {
throw Error("could not decrypt");
}
return encodeCrock(clearText);
}
export const asOpaque = (x: string): OpaqueData => x;
const asEncryptedKeyShare = (x: OpaqueData): EncryptedKeyShare => x as string;
const asEncryptedTruth = (x: OpaqueData): EncryptedTruth => x as string;
const asKeyShare = (x: OpaqueData): KeyShare => x as string;
export async function encryptKeyshare(
keyShare: KeyShare,
userId: UserIdentifier,
answerSalt?: string,
): Promise<EncryptedKeyShare> {
const s = answerSalt ?? "eks";
const nonce = encodeCrock(getRandomBytes(24));
return asEncryptedKeyShare(
await anastasisEncrypt(nonce, asOpaque(userId), asOpaque(keyShare), s),
);
}
export async function decryptKeyShare(
encKeyShare: EncryptedKeyShare,
userId: UserIdentifier,
answerSalt?: string,
): Promise<KeyShare> {
const s = answerSalt ?? "eks";
return asKeyShare(
await anastasisDecrypt(asOpaque(userId), asOpaque(encKeyShare), s),
);
}
export async function encryptTruth(
nonce: EncryptionNonce,
truthEncKey: TruthKey,
truth: OpaqueData,
): Promise<EncryptedTruth> {
const salt = "ect";
return asEncryptedTruth(
await anastasisEncrypt(nonce, asOpaque(truthEncKey), truth, salt),
);
}
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[];
}
export async function coreSecretRecover(args: {
encryptedMasterKey: OpaqueData;
policyKey: PolicyKey;
encryptedCoreSecret: OpaqueData;
}): Promise<OpaqueData> {
const masterKey = await anastasisDecrypt(
asOpaque(args.policyKey),
args.encryptedMasterKey,
"emk",
);
return await anastasisDecrypt(masterKey, args.encryptedCoreSecret, "cse");
}
export async function coreSecretEncrypt(
policyKeys: PolicyKey[],
coreSecret: OpaqueData,
): Promise<CoreSecretEncResult> {
const masterKey = getRandomBytes(masterKeySize);
const nonce = encodeCrock(getRandomBytes(nonceSize));
const coreSecretEncSalt = "cse";
const masterKeyEncSalt = "emk";
const encCoreSecret = (await anastasisEncrypt(
nonce,
encodeCrock(masterKey),
coreSecret,
coreSecretEncSalt,
)) as string;
const encMasterKeys: EncryptedMasterKey[] = [];
for (let i = 0; i < policyKeys.length; i++) {
const polNonce = encodeCrock(getRandomBytes(nonceSize));
const encMasterKey = await anastasisEncrypt(
polNonce,
asOpaque(policyKeys[i]),
encodeCrock(masterKey),
masterKeyEncSalt,
);
encMasterKeys.push(encMasterKey as string);
}
return {
encCoreSecret,
encMasterKeys,
};
}
export async function pinAnswerHash(pin: number): Promise<SecureAnswerHash> {
return encodeCrock(hash(stringToBytes(pin.toString())));
}
export async function secureAnswerHash(
answer: string,
truthUuid: TruthUuid,
questionSalt: TruthSalt,
): Promise<SecureAnswerHash> {
const powResult = await argon2id({
hashLength: 64,
iterations: 3,
memorySize: 1024 /* kibibytes */,
parallelism: 1,
password: stringToBytes(answer),
salt: decodeCrock(questionSalt),
outputType: "binary",
});
const kdfResult = kdfKw({
outputLength: 64,
salt: decodeCrock(truthUuid),
ikm: powResult,
info: stringToBytes("anastasis-secure-question-hashing"),
});
return encodeCrock(kdfResult);
}