case details and missing decision encryption

This commit is contained in:
Sebastian 2023-07-21 15:50:53 -03:00
parent e90f1b4206
commit 7e37b34744
No known key found for this signature in database
GPG Key ID: 173909D1A5F66069
10 changed files with 362 additions and 144 deletions

View File

@ -1,6 +1,7 @@
import { import {
PaytoUri, Amounts,
TalerSignaturePurpose, TalerSignaturePurpose,
amountToBuffer,
bufferForUint32, bufferForUint32,
buildSigPS, buildSigPS,
createEddsaKeyPair, createEddsaKeyPair,
@ -11,8 +12,10 @@ import {
encodeCrock, encodeCrock,
encryptWithDerivedKey, encryptWithDerivedKey,
getRandomBytesF, getRandomBytesF,
hash,
hashTruncate32,
stringToBytes, stringToBytes,
stringifyPaytoUri, timestampRoundedToBuffer
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { AmlExchangeBackend } from "./types.js"; import { AmlExchangeBackend } from "./types.js";
@ -60,12 +63,16 @@ export function buildQuerySignature(key: SigningKey): string {
} }
export function buildDecisionSignature( export function buildDecisionSignature(
key: SigningKey, key: SigningKey,
payto: PaytoUri, decision: AmlExchangeBackend.AmlDecision,
state: AmlExchangeBackend.AmlState,
): string { ): string {
const sigBlob = buildSigPS(TalerSignaturePurpose.TALER_SIGNATURE_AML_DECISION) const sigBlob = buildSigPS(TalerSignaturePurpose.TALER_SIGNATURE_AML_DECISION)
.put(decodeCrock(stringifyPaytoUri(payto))) .put(hash(stringToBytes(decision.justification)))
.put(bufferForUint32(state)) // .put(timestampRoundedToBuffer(decision.decision_time))
.put(amountToBuffer(decision.new_threshold))
.put(decodeCrock(decision.h_payto))
// .put(hash(stringToBytes(decision.kyc_requirements)))
.put(bufferForUint32(decision.new_state))
.build(); .build();
return encodeCrock(eddsaSign(sigBlob, key)); return encodeCrock(eddsaSign(sigBlob, key));

View File

@ -29,7 +29,7 @@ export type FormState<T> = {
? Partial<InputFieldState> ? Partial<InputFieldState>
: T[field] extends Array<infer P> : T[field] extends Array<infer P>
? Partial<InputArrayFieldState<P>> ? Partial<InputArrayFieldState<P>>
: T[field] extends object : T[field] extends (object | undefined)
? FormState<T[field]> ? FormState<T[field]>
: Partial<InputFieldState>; : Partial<InputFieldState>;
}; };

View File

@ -14,7 +14,7 @@ interface useBackendType {
path: string, path: string,
options?: RequestOptions, options?: RequestOptions,
) => Promise<HttpResponseOk<T>>; ) => Promise<HttpResponseOk<T>>;
fetcher: <T>(endpoint: string) => Promise<HttpResponseOk<T>>; fetcher: <T>(args: [string, string]) => Promise<HttpResponseOk<T>>;
paginatedFetcher: <T>( paginatedFetcher: <T>(
args: [string, number, number, string], args: [string, number, number, string],
) => Promise<HttpResponseOk<T>>; ) => Promise<HttpResponseOk<T>>;
@ -35,8 +35,10 @@ export function usePublicBackend(): useBackendType {
); );
const fetcher = useCallback( const fetcher = useCallback(
function fetcherImpl<T>(endpoint: string): Promise<HttpResponseOk<T>> { function fetcherImpl<T>([endpoint, talerAmlOfficerSignature]: [string,string]): Promise<HttpResponseOk<T>> {
return requestHandler<T>(baseUrl, endpoint); return requestHandler<T>(baseUrl, endpoint, {
talerAmlOfficerSignature
});
}, },
[baseUrl], [baseUrl],
); );

View File

@ -0,0 +1,162 @@
import {
HttpResponse,
HttpResponseOk,
RequestError
} from "@gnu-taler/web-util/browser";
import { AmlExchangeBackend } from "../types.js";
// FIX default import https://github.com/microsoft/TypeScript/issues/49189
import _useSWR, { SWRHook, useSWRConfig } from "swr";
import { AccountId } from "../account.js";
import { usePublicBackend } from "./useBackend.js";
const useSWR = _useSWR as unknown as SWRHook;
export function useCaseDetails(
account: AccountId,
paytoHash: string,
signature: string | undefined,
): HttpResponse<
AmlExchangeBackend.AmlDecisionDetails,
AmlExchangeBackend.AmlError
> {
const { fetcher } = usePublicBackend();
const { data, error } = useSWR<
HttpResponseOk<AmlExchangeBackend.AmlDecisionDetails>,
RequestError<AmlExchangeBackend.AmlError>
>( [
`aml/${account}/decision/${(paytoHash)}`,
signature,
],
fetcher, {
refreshInterval: 0,
refreshWhenHidden: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshWhenOffline: false,
errorRetryCount: 0,
errorRetryInterval: 1,
shouldRetryOnError: false,
keepPreviousData: true,
});
if (data) return data;
if (error) return error.cause;
return { loading: true };
}
const example1: AmlExchangeBackend.AmlDecisionDetails = {
aml_history: [
{
justification: "Lack of documentation",
decider_pub: "ASDASDASD",
decision_time: {
t_s: Date.now() / 1000,
},
new_state: 2,
new_threshold: "USD:0",
},
{
justification: "Doing a transfer of high amount",
decider_pub: "ASDASDASD",
decision_time: {
t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 6,
},
new_state: 1,
new_threshold: "USD:2000",
},
{
justification: "Account is known to the system",
decider_pub: "ASDASDASD",
decision_time: {
t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 9,
},
new_state: 0,
new_threshold: "USD:100",
},
],
kyc_attributes: [
{
collection_time: {
t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 8,
},
expiration_time: {
t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 4,
},
provider_section: "asdasd",
attributes: {
name: "Sebastian",
},
},
{
collection_time: {
t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 5,
},
expiration_time: {
t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 2,
},
provider_section: "asdasd",
attributes: {
creditCard: "12312312312",
},
},
],
};
export const exampleResponse: HttpResponse<AmlExchangeBackend.AmlDecisionDetails,AmlExchangeBackend.AmlError> = {
ok: true,
data: example1,
}
export function useAmlCasesAPI(): AmlCaseAPI {
const { request } = usePublicBackend();
const mutateAll = useMatchMutate();
const updateDecision = async (
officer: AccountId,
data: AmlExchangeBackend.AmlDecision,
): Promise<HttpResponseOk<void>> => {
const res = await request<void>(`aml/${officer}/decision`, {
method: "POST",
data,
contentType: "json",
});
await mutateAll(/.*aml.*/);
return res;
};
return {
updateDecision,
};
}
export interface AmlCaseAPI {
updateDecision: (
officer: AccountId,
data: AmlExchangeBackend.AmlDecision,
) => Promise<HttpResponseOk<void>>;
}
function useMatchMutate(): (
re: RegExp,
value?: unknown,
) => Promise<any> {
const { cache, mutate } = useSWRConfig();
if (!(cache instanceof Map)) {
throw new Error(
"matchMutate requires the cache provider to be a Map instance",
);
}
return function matchRegexMutate(re: RegExp, value?: unknown) {
const allKeys = Array.from(cache.keys());
const keys = allKeys.filter((key) => re.test(key));
const mutations = keys.map((key) => {
return mutate(key, value, true);
});
return Promise.all(mutations);
};
}

View File

@ -92,3 +92,56 @@ export function useCases(
} }
return { loading: true }; return { loading: true };
} }
const example1: AmlExchangeBackend.AmlRecords = {
records: [
{
current_state: 0,
h_payto: "QWEQWEQWEQWEWQE",
rowid: 1,
threshold: "USD 100",
},
{
current_state: 1,
h_payto: "ASDASDASD",
rowid: 1,
threshold: "USD 100",
},
{
current_state: 2,
h_payto: "ZXCZXCZXCXZC",
rowid: 1,
threshold: "USD 1000",
},
{
current_state: 0,
h_payto: "QWEQWEQWEQWEWQE",
rowid: 1,
threshold: "USD 100",
},
{
current_state: 1,
h_payto: "ASDASDASD",
rowid: 1,
threshold: "USD 100",
},
{
current_state: 2,
h_payto: "ZXCZXCZXCXZC",
rowid: 1,
threshold: "USD 1000",
},
].map((e, idx) => {
e.rowid = idx;
e.threshold = `${e.threshold}${idx}`;
return e;
}),
};
export const exampleResponse: HttpResponsePaginated<AmlExchangeBackend.AmlRecords,AmlExchangeBackend.AmlError> = {
ok: true,
data: example1,
loadMore: () => {},
loadMorePrev: () => {},
}

View File

@ -16,7 +16,7 @@ const cases: PageEntry = {
url: "#/cases", url: "#/cases",
view: Cases, view: Cases,
}; };
const account: PageEntry<{ account?: string }> = { const account: PageEntry<{ account: string }> = {
url: pageDefinition("#/account/:account"), url: pageDefinition("#/account/:account"),
view: CaseDetails, view: CaseDetails,
}; };

View File

@ -13,64 +13,13 @@ import { FlexibleForm } from "../forms/index.js";
import { UIFormField } from "../handlers/forms.js"; import { UIFormField } from "../handlers/forms.js";
import { Pages } from "../pages.js"; import { Pages } from "../pages.js";
import { AmlExchangeBackend } from "../types.js"; import { AmlExchangeBackend } from "../types.js";
import { HandleAccountNotReady } from "./HandleAccountNotReady.js";
import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { useOfficer } from "../hooks/useOfficer.js";
import { buildQuerySignature } from "../account.js";
import { useCaseDetails } from "../hooks/useCaseDetails.js";
import { handleNotOkResult } from "../utils/errors.js";
const response: AmlExchangeBackend.AmlDecisionDetails = {
aml_history: [
{
justification: "Lack of documentation",
decider_pub: "ASDASDASD",
decision_time: {
t_s: Date.now() / 1000,
},
new_state: 2,
new_threshold: "USD:0",
},
{
justification: "Doing a transfer of high amount",
decider_pub: "ASDASDASD",
decision_time: {
t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 6,
},
new_state: 1,
new_threshold: "USD:2000",
},
{
justification: "Account is known to the system",
decider_pub: "ASDASDASD",
decision_time: {
t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 9,
},
new_state: 0,
new_threshold: "USD:100",
},
],
kyc_attributes: [
{
collection_time: {
t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 8,
},
expiration_time: {
t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 4,
},
provider_section: "asdasd",
attributes: {
name: "Sebastian",
},
},
{
collection_time: {
t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 5,
},
expiration_time: {
t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 2,
},
provider_section: "asdasd",
attributes: {
creditCard: "12312312312",
},
},
],
};
type AmlEvent = AmlFormEvent | KycCollectionEvent | KycExpirationEvent; type AmlEvent = AmlFormEvent | KycCollectionEvent | KycExpirationEvent;
type AmlFormEvent = { type AmlFormEvent = {
type: "aml-form"; type: "aml-form";
@ -127,7 +76,7 @@ function getEventsFromAmlHistory(
}); });
prev.push({ prev.push({
type: "kyc-expiration", type: "kyc-expiration",
title: "expired" as TranslatedString, title: "expiration" as TranslatedString,
when: AbsoluteTime.fromProtocolTimestamp(k.expiration_time), when: AbsoluteTime.fromProtocolTimestamp(k.expiration_time),
fields: !k.attributes ? [] : Object.keys(k.attributes), fields: !k.attributes ? [] : Object.keys(k.attributes),
}); });
@ -136,19 +85,30 @@ function getEventsFromAmlHistory(
return ae.concat(ke).sort(selectSooner); return ae.concat(ke).sort(selectSooner);
} }
export function CaseDetails({ account }: { account?: string }) { export function CaseDetails({ account: paytoHash }: { account: string }) {
const events = getEventsFromAmlHistory( const [selected, setSelected] = useState<AmlEvent | undefined>(undefined);
response.aml_history,
response.kyc_attributes, const officer = useOfficer();
); const { i18n } = useTranslationContext();
console.log("DETAILS", events, events[events.length - 1 - 2]); if (officer.state !== "ready") {
const [selected, setSelected] = useState<AmlEvent>( return <HandleAccountNotReady officer={officer} />;
events[events.length - 1 - 2], }
); const signature =
officer.state === "ready"
? buildQuerySignature(officer.account.signingKey)
: undefined;
const details = useCaseDetails(officer.account.accountId, paytoHash, signature)
if (!details.ok && !details.loading) {
return handleNotOkResult(i18n)(details);
}
const aml_history = details.loading ? [] : details.data.aml_history
const kyc_attributes = details.loading ? [] : details.data.kyc_attributes
const events = getEventsFromAmlHistory(aml_history,kyc_attributes);
return ( return (
<div> <div>
<a <a
href={Pages.newFormEntry.url({ account })} href={Pages.newFormEntry.url({ account: paytoHash })}
class="m-4 block rounded-md w-fit border-0 px-3 py-2 text-center text-sm bg-indigo-700 text-white shadow-sm hover:bg-indigo-700" class="m-4 block rounded-md w-fit border-0 px-3 py-2 text-center text-sm bg-indigo-700 text-white shadow-sm hover:bg-indigo-700"
> >
New AML form New AML form
@ -271,13 +231,24 @@ function ShowConsolidated({
history: AmlEvent[]; history: AmlEvent[];
until: AmlEvent; until: AmlEvent;
}): VNode { }): VNode {
console.log("UNTIL", until);
const cons = getConsolidated(history, until.when); const cons = getConsolidated(history, until.when);
const form: FlexibleForm<Consolidated> = { const form: FlexibleForm<Consolidated> = {
versionId: "1", versionId: "1",
behavior: (form) => { behavior: (form) => {
return {}; return {
aml: {
threshold: {
hidden: !form.aml
},
since: {
hidden: !form.aml
},
state: {
hidden: !form.aml
}
}
};
}, },
design: [ design: [
{ {
@ -356,8 +327,8 @@ function ShowConsolidated({
interface Consolidated { interface Consolidated {
aml: { aml: {
state?: AmlExchangeBackend.AmlState; state: AmlExchangeBackend.AmlState;
threshold?: AmountJson; threshold: AmountJson;
since: AbsoluteTime; since: AbsoluteTime;
}; };
kyc: { kyc: {
@ -375,7 +346,13 @@ function getConsolidated(
): Consolidated { ): Consolidated {
const initial: Consolidated = { const initial: Consolidated = {
aml: { aml: {
since: AbsoluteTime.never(), state: AmlExchangeBackend.AmlState.normal,
threshold: {
currency: "ARS",
value: 1000,
fraction: 0,
},
since: AbsoluteTime.never()
}, },
kyc: {}, kyc: {},
}; };
@ -391,9 +368,11 @@ function getConsolidated(
break; break;
} }
case "aml-form": { case "aml-form": {
prev.aml.threshold = cur.threshold; prev.aml = {
prev.aml.state = cur.state; since: cur.when,
prev.aml.since = cur.when; state: cur.state,
threshold: cur.threshold
}
break; break;
} }
case "kyc-collection": { case "kyc-collection": {

View File

@ -12,59 +12,6 @@ import { buildQuerySignature } from "../account.js";
import { handleNotOkResult } from "../utils/errors.js"; import { handleNotOkResult } from "../utils/errors.js";
import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { useTranslationContext } from "@gnu-taler/web-util/browser";
const response: AmlExchangeBackend.AmlRecords = {
records: [
{
current_state: 0,
h_payto: "QWEQWEQWEQWEWQE",
rowid: 1,
threshold: "USD 100",
},
{
current_state: 1,
h_payto: "ASDASDASD",
rowid: 1,
threshold: "USD 100",
},
{
current_state: 2,
h_payto: "ZXCZXCZXCXZC",
rowid: 1,
threshold: "USD 1000",
},
{
current_state: 0,
h_payto: "QWEQWEQWEQWEWQE",
rowid: 1,
threshold: "USD 100",
},
{
current_state: 1,
h_payto: "ASDASDASD",
rowid: 1,
threshold: "USD 100",
},
{
current_state: 2,
h_payto: "ZXCZXCZXCXZC",
rowid: 1,
threshold: "USD 1000",
},
].map((e, idx) => {
e.rowid = idx;
e.threshold = `${e.threshold}${idx}`;
return e;
}),
};
function doFilter(
list: typeof response.records,
filter: AmlExchangeBackend.AmlState | undefined,
): typeof response.records {
if (filter === undefined) return list;
return list.filter((r) => r.current_state === filter);
}
export function Cases() { export function Cases() {
const officer = useOfficer(); const officer = useOfficer();
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();

View File

@ -2,8 +2,12 @@ import { VNode, h } from "preact";
import { allForms } from "./AntiMoneyLaunderingForm.js"; import { allForms } from "./AntiMoneyLaunderingForm.js";
import { Pages } from "../pages.js"; import { Pages } from "../pages.js";
import { NiceForm } from "../NiceForm.js"; import { NiceForm } from "../NiceForm.js";
import { AbsoluteTime, Amounts } from "@gnu-taler/taler-util"; import { AbsoluteTime, Amounts, TalerProtocolTimestamp } from "@gnu-taler/taler-util";
import { AmlExchangeBackend } from "../types.js"; import { AmlExchangeBackend } from "../types.js";
import { useAmlCasesAPI } from "../hooks/useCaseDetails.js";
import { useOfficer } from "../hooks/useOfficer.js";
import { HandleAccountNotReady } from "./HandleAccountNotReady.js";
import { buildDecisionSignature, buildQuerySignature } from "../account.js";
export function NewFormEntry({ export function NewFormEntry({
account, account,
@ -12,30 +16,58 @@ export function NewFormEntry({
account?: string; account?: string;
type?: string; type?: string;
}): VNode { }): VNode {
const officer = useOfficer();
if (!account) { if (!account) {
return <div>no account</div>; return <div>no account</div>;
} }
if (!type) { if (!type) {
return <SelectForm account={account} />; return <SelectForm account={account} />;
} }
if (officer.state !== "ready") {
return <HandleAccountNotReady officer={officer} />;
}
const selectedForm = Number.parseInt(type ?? "0", 10); const selectedForm = Number.parseInt(type ?? "0", 10);
if (Number.isNaN(selectedForm)) { if (Number.isNaN(selectedForm)) {
return <div>WHAT! {type}</div>; return <div>WHAT! {type}</div>;
} }
const showingFrom = allForms[selectedForm].impl; const showingFrom = allForms[selectedForm].impl;
const formName = allForms[selectedForm].name
const initial = { const initial = {
fullName: "loggedIn_user_fullname", fullName: "loggedIn_user_fullname",
when: AbsoluteTime.now(), when: AbsoluteTime.now(),
state: AmlExchangeBackend.AmlState.pending, state: AmlExchangeBackend.AmlState.pending,
threshold: Amounts.parseOrThrow("USD:10"), threshold: Amounts.parseOrThrow("ARS:1000"),
}; };
const api = useAmlCasesAPI()
return ( return (
<NiceForm <NiceForm
initial={initial} initial={initial}
form={showingFrom(initial)} form={showingFrom(initial)}
onSubmit={(v) => { onSubmit={(formValue) => {
alert(JSON.stringify(v)); if (formValue.state === undefined || formValue.threshold === undefined) return;
const justification = {
index: selectedForm,
name: formName,
value: formValue
}
const decision: AmlExchangeBackend.AmlDecision = {
justification: JSON.stringify(justification),
decision_time: TalerProtocolTimestamp.now(),
h_payto: account,
new_state: formValue.state,
new_threshold: Amounts.stringify(formValue.threshold),
officer_sig: "",
kyc_requirements: undefined
}
const signature = buildDecisionSignature(officer.account.signingKey, decision);
decision.officer_sig = signature
api.updateDecision(officer.account.accountId, decision);
// alert(JSON.stringify(formValue));
}} }}
> >
<div class="mt-6 flex items-center justify-end gap-x-6"> <div class="mt-6 flex items-center justify-end gap-x-6">

View File

@ -59,6 +59,9 @@ export namespace AmlExchangeBackend {
type PaytoHash = string; type PaytoHash = string;
type Integer = number; type Integer = number;
type Amount = string; type Amount = string;
// EdDSA signatures are transmitted as 64-bytes base32
// binary-encoded objects with just the R and S values (base32_ binary-only).
type EddsaSignature = string;
export interface AmlRecords { export interface AmlRecords {
// Array of AML records matching the query. // Array of AML records matching the query.
@ -85,4 +88,37 @@ export namespace AmlExchangeBackend {
pending = 1, pending = 1,
frozen = 2, frozen = 2,
} }
export interface AmlDecision {
// Human-readable justification for the decision.
justification: string;
// At what monthly transaction volume should the
// decision be automatically reviewed?
new_threshold: Amount;
// Which payto-address is the decision about?
// Identifies a GNU Taler wallet or an affected bank account.
h_payto: PaytoHash;
// What is the new AML state (e.g. frozen, unfrozen, etc.)
// Numerical values are defined in AmlDecisionState.
new_state: Integer;
// Signature by the AML officer over a
// TALER_MasterAmlOfficerStatusPS.
// Must have purpose TALER_SIGNATURE_MASTER_AML_KEY.
officer_sig: EddsaSignature;
// When was the decision made?
decision_time: Timestamp;
// Optional argument to impose new KYC requirements
// that the customer has to satisfy to unblock transactions.
kyc_requirements?: string[];
}
} }