Add TPIN reset via OTP, with server-clock countdowns and redacted API logs
- Reset TPIN: empty-body initiate -> OTP -> encrypted new TPIN via /tpin/reset/*, from a Services tile, search, and "Forgot your TPIN?" on the transfer's TPIN sheet (returns to the same transfer review) - Handle every documented code: SESSION_INVALID signs out, TPIN_NOT_SET offers Create TPIN, CBS customer/account/org refusals stop the flow, outages retry from the start, expired or spent OTP/token restarts, policy VALIDATION_ERROR stays on the new-TPIN step - Secrets live in screen state only and are dropped as each step ends - Countdowns use the envelope timestamp, kept aside by the interceptor, so a wrong device clock cannot skew OTP or token expiry - New TPINs refuse any three-digit run (1235, 5987), for create too - Dev API logger redacts the session token, OTPs, PINs, encrypted payloads and one-time tokens - Tests: request bodies, RSA-OAEP SHA-256 payload decrypted with Node, fresh nonce on key retry, server-clock deadlines, log redaction Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f1def81eb7
commit
5fbd13ba12
@@ -0,0 +1,131 @@
|
||||
import type { AxiosResponse, InternalAxiosRequestConfig } from 'axios';
|
||||
|
||||
import { apiLogger, redactSecrets } from './apiLogger';
|
||||
|
||||
describe('redactSecrets', () => {
|
||||
it('hides OTPs, PINs, encrypted payloads and every kind of token', () => {
|
||||
expect(
|
||||
redactSecrets({
|
||||
otp: '123456',
|
||||
debugOtp: '123456',
|
||||
mpin: '2580',
|
||||
tpin: '5826',
|
||||
encryptedMpin: 'base64…',
|
||||
encryptedTpin: 'base64…',
|
||||
sessionToken: 'live-session',
|
||||
setupToken: 'setup',
|
||||
resetToken: 'mpin-reset',
|
||||
tpinCreateToken: 'create',
|
||||
tpinResetToken: 'reset',
|
||||
}),
|
||||
).toEqual({
|
||||
otp: '<redacted>',
|
||||
debugOtp: '<redacted>',
|
||||
mpin: '<redacted>',
|
||||
tpin: '<redacted>',
|
||||
encryptedMpin: '<redacted>',
|
||||
encryptedTpin: '<redacted>',
|
||||
sessionToken: '<redacted>',
|
||||
setupToken: '<redacted>',
|
||||
resetToken: '<redacted>',
|
||||
tpinCreateToken: '<redacted>',
|
||||
tpinResetToken: '<redacted>',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the fields that help debugging — expiries, statuses, ids', () => {
|
||||
const data = {
|
||||
tpinResetId: 'e79eb452-32c9-4690-955b-81d8bb223ec3',
|
||||
status: 'PENDING_OTP',
|
||||
otpExpiresAt: '2026-09-30T13:50:31+05:30',
|
||||
tokenExpiresAt: '2026-09-30T13:51:02+05:30',
|
||||
resetTokenExpiresAt: '2026-09-30T13:51:02+05:30',
|
||||
attemptsRemaining: 3,
|
||||
mpinStatus: 'ACTIVE',
|
||||
};
|
||||
|
||||
expect(redactSecrets(data)).toEqual(data);
|
||||
});
|
||||
|
||||
it('reaches into the envelope and into arrays', () => {
|
||||
expect(
|
||||
redactSecrets({
|
||||
success: true,
|
||||
data: { tpinResetToken: 'reset', items: [{ otp: '1' }, { name: 'x' }] },
|
||||
}),
|
||||
).toEqual({
|
||||
success: true,
|
||||
data: { tpinResetToken: '<redacted>', items: [{ otp: '<redacted>' }, { name: 'x' }] },
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves an absent secret visibly absent, and non-objects alone', () => {
|
||||
expect(redactSecrets({ debugOtp: null })).toEqual({ debugOtp: null });
|
||||
expect(redactSecrets('(no body)')).toBe('(no body)');
|
||||
expect(redactSecrets(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('apiLogger', () => {
|
||||
let log: jest.SpyInstance;
|
||||
let warn: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
log = jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
log.mockRestore();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
const config = (data: unknown) =>
|
||||
({
|
||||
method: 'post',
|
||||
baseURL: 'https://bank.example/api/v1',
|
||||
url: '/tpin/reset/confirm',
|
||||
headers: { Authorization: 'Bearer live-session-token', 'Content-Type': 'application/json' },
|
||||
data,
|
||||
}) as unknown as InternalAxiosRequestConfig;
|
||||
|
||||
it('prints the curl without the session token or the secrets in the body', () => {
|
||||
apiLogger.request(config({ tpinResetToken: 'one-time-reset-token', encryptedTpin: 'cipher' }));
|
||||
|
||||
const printed = log.mock.calls.map((call) => String(call[0])).join('\n');
|
||||
expect(printed).toContain('/tpin/reset/confirm');
|
||||
expect(printed).toContain('Authorization: Bearer <session token>');
|
||||
expect(printed).not.toContain('live-session-token');
|
||||
expect(printed).not.toContain('one-time-reset-token');
|
||||
expect(printed).not.toContain('cipher');
|
||||
});
|
||||
|
||||
it('redacts a body the caller serialised themselves', () => {
|
||||
apiLogger.request(config(JSON.stringify({ tpinResetId: 'id-1', otp: '654321' })));
|
||||
|
||||
const printed = log.mock.calls.map((call) => String(call[0])).join('\n');
|
||||
expect(printed).toContain('id-1');
|
||||
expect(printed).not.toContain('654321');
|
||||
});
|
||||
|
||||
it('redacts secrets in what came back, success or failure', () => {
|
||||
const request = config({});
|
||||
apiLogger.response({
|
||||
status: 200,
|
||||
config: request,
|
||||
data: { success: true, data: { tpinResetToken: 'one-time-reset-token' } },
|
||||
} as AxiosResponse);
|
||||
apiLogger.failure({
|
||||
status: 400,
|
||||
config: request,
|
||||
data: { success: false, data: { otp: '654321' }, errorCode: 'VALIDATION_ERROR' },
|
||||
} as AxiosResponse);
|
||||
|
||||
const printed = [...log.mock.calls, ...warn.mock.calls]
|
||||
.map((call) => String(call[0]))
|
||||
.join('\n');
|
||||
expect(printed).toContain('VALIDATION_ERROR');
|
||||
expect(printed).not.toContain('one-time-reset-token');
|
||||
expect(printed).not.toContain('654321');
|
||||
});
|
||||
});
|
||||
@@ -3,23 +3,88 @@ import type { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axio
|
||||
/**
|
||||
* Dev-only request/response logging for the CBS client.
|
||||
*
|
||||
* Every request is printed as a runnable `curl`, so a failure can be reproduced
|
||||
* against the backend outside the app — which is usually the fastest way to
|
||||
* settle whether a problem is the handset, the client, or the server.
|
||||
* Every request is printed as a `curl`, so a failure can be reproduced against
|
||||
* the backend outside the app — which is usually the fastest way to settle
|
||||
* whether a problem is the handset, the client, or the server.
|
||||
*
|
||||
* ── WHY THIS IS BEHIND `__DEV__`, WITHOUT AN OVERRIDE ───────────────────────
|
||||
* The curl carries whatever the request carried: the `Authorization` header
|
||||
* with a live session token, and the RSA-encrypted MPIN blob on the
|
||||
* registration calls. That is exactly what makes it useful, and exactly why it
|
||||
* must never run in a release build — a bank's production logs are not a place
|
||||
* to put a working credential. There is deliberately no runtime flag to turn
|
||||
* this on outside development; `__DEV__` is compiled out, so none of this code
|
||||
* ships.
|
||||
* ── SECRETS ARE REDACTED, EVEN HERE ─────────────────────────────────────────
|
||||
* The bank's integration contracts forbid logging the session token, OTPs,
|
||||
* PINs, encrypted PIN payloads and one-time tokens anywhere — the console
|
||||
* included — and Metro logs get pasted into tickets and chats. So the
|
||||
* `Authorization` header prints as a placeholder, and any body field named like
|
||||
* a secret prints as `<redacted>`, on the way out and on the way back. The rest
|
||||
* of the curl still runs: paste a session token in place of the placeholder.
|
||||
*
|
||||
* ── AND IT IS BEHIND `__DEV__`, WITHOUT AN OVERRIDE ─────────────────────────
|
||||
* Redaction is by field name, so it is a safety net rather than a guarantee.
|
||||
* The log must still never run in a release build: there is deliberately no
|
||||
* runtime flag to turn it on outside development, and `__DEV__` is compiled
|
||||
* out, so none of this code ships.
|
||||
*/
|
||||
|
||||
/** Set false to silence the log without unpicking the interceptors. */
|
||||
const ENABLED = __DEV__;
|
||||
|
||||
/**
|
||||
* Body fields never printed: OTPs (a dev `debugOtp` included), plain and
|
||||
* encrypted PINs, and every token — session, setup, MPIN reset, TPIN create
|
||||
* and reset. Their `…ExpiresAt` companions stay: when a code expired is often
|
||||
* the whole question.
|
||||
*/
|
||||
const SECRET_FIELD = /^(otp|debugOtp|mpin|tpin|password)$|^encrypted|token$/i;
|
||||
|
||||
const REDACTED = '<redacted>';
|
||||
|
||||
/** What an `Authorization` header prints as. Paste a real token to run the curl. */
|
||||
const AUTHORIZATION_PLACEHOLDER = 'Bearer <session token>';
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
};
|
||||
|
||||
/**
|
||||
* A copy of `value` with every secret-named field replaced, at any depth.
|
||||
* Anything that is not a plain object or array — FormData, a Blob — is left
|
||||
* as it is.
|
||||
*/
|
||||
export const redactSecrets = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(redactSecrets);
|
||||
}
|
||||
|
||||
if (!isPlainObject(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, field]) => [
|
||||
key,
|
||||
SECRET_FIELD.test(key) && field !== null && field !== undefined
|
||||
? REDACTED
|
||||
: redactSecrets(field),
|
||||
]),
|
||||
);
|
||||
};
|
||||
|
||||
/** A JSON string, redacted; any other string as it came. */
|
||||
const redactString = (value: string): string => {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
|
||||
return typeof parsed === 'object' && parsed !== null
|
||||
? JSON.stringify(redactSecrets(parsed))
|
||||
: value;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-request bookkeeping, so a response can be matched to its request in a
|
||||
* busy log and timed.
|
||||
@@ -109,11 +174,11 @@ const readBody = (config: InternalAxiosRequestConfig): string | undefined => {
|
||||
}
|
||||
|
||||
if (typeof data === 'string') {
|
||||
return data;
|
||||
return redactString(data);
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(data);
|
||||
return JSON.stringify(redactSecrets(data));
|
||||
} catch {
|
||||
return String(data);
|
||||
}
|
||||
@@ -124,7 +189,8 @@ const toCurl = (config: InternalAxiosRequestConfig): string => {
|
||||
const lines = [`curl -i -X ${method} ${quote(readUrl(config))}`];
|
||||
|
||||
for (const [key, value] of Object.entries(readHeaders(config))) {
|
||||
lines.push(`-H ${quote(`${key}: ${value}`)}`);
|
||||
const shown = key.toLowerCase() === 'authorization' ? AUTHORIZATION_PLACEHOLDER : value;
|
||||
lines.push(`-H ${quote(`${key}: ${shown}`)}`);
|
||||
}
|
||||
|
||||
const body = readBody(config);
|
||||
@@ -194,7 +260,7 @@ export const apiLogger = {
|
||||
console.log(
|
||||
`\n┌── API ← #${idOf(response.config)} ${response.status} ${label(
|
||||
response.config as InternalAxiosRequestConfig,
|
||||
)} (${elapsed(response.config)})\n${pretty(response.data)}\n└──`,
|
||||
)} (${elapsed(response.config)})\n${pretty(redactSecrets(response.data))}\n└──`,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -212,7 +278,7 @@ export const apiLogger = {
|
||||
)} (${elapsed(response?.config)})`;
|
||||
|
||||
console.warn(
|
||||
`\n${head}${note ? `\n${note}` : ''}\n${pretty(response?.data ?? '(no body)')}\n└──`,
|
||||
`\n${head}${note ? `\n${note}` : ''}\n${pretty(redactSecrets(response?.data ?? '(no body)'))}\n└──`,
|
||||
);
|
||||
},
|
||||
|
||||
|
||||
@@ -41,11 +41,20 @@ export const endpoints = {
|
||||
* token; `createConfirm` spends that token on the new, encrypted TPIN. There
|
||||
* is no resend endpoint: a fresh `createInitiate` — MPIN and all — is the
|
||||
* resend.
|
||||
*
|
||||
* Resetting it follows the same three steps under `/tpin/reset`, with two
|
||||
* differences: `resetInitiate` takes an empty body — the session alone
|
||||
* identifies the customer, and the bank re-verifies them before the OTP goes
|
||||
* out — and it needs a TPIN to already be active. The same session must carry
|
||||
* all three calls; the server refuses a flow that changes user or device.
|
||||
*/
|
||||
tpin: {
|
||||
createInitiate: '/tpin/create/initiate',
|
||||
createVerifyOtp: '/tpin/create/verify-otp',
|
||||
createConfirm: '/tpin/create/confirm',
|
||||
resetInitiate: '/tpin/reset/initiate',
|
||||
resetVerifyOtp: '/tpin/reset/verify-otp',
|
||||
resetConfirm: '/tpin/reset/confirm',
|
||||
},
|
||||
accounts: {
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,7 @@ import { sessionManager } from '../security/sessionManager';
|
||||
import { secureStorage } from '../storage/secureStorage';
|
||||
import { apiLogger } from './apiLogger';
|
||||
import { CbsApiError, isCbsErrorCode, type FieldErrors } from './cbsErrors';
|
||||
import { rememberServerTime } from './serverClock';
|
||||
|
||||
const unauthorizedHandlers = new Set<() => void>();
|
||||
|
||||
@@ -83,8 +84,9 @@ export const attachInterceptors = (client: AxiosInstance) => {
|
||||
config.headers = nextHeaders as never;
|
||||
}
|
||||
|
||||
// Logged here, at the end of the interceptor, so the curl carries the
|
||||
// Authorization header the request will actually be sent with.
|
||||
// Logged here, at the end of the interceptor, so the curl shows the
|
||||
// headers the request will actually be sent with — the token itself as a
|
||||
// placeholder.
|
||||
apiLogger.request(config);
|
||||
|
||||
return config;
|
||||
@@ -117,7 +119,12 @@ export const attachInterceptors = (client: AxiosInstance) => {
|
||||
}
|
||||
|
||||
// Unwrap so callers read the payload directly rather than the envelope.
|
||||
return { ...response, data: envelope.data };
|
||||
// The server's `timestamp` is kept aside for anything counting down to
|
||||
// a server expiry — see `serverClock`.
|
||||
const unwrapped = { ...response, data: envelope.data };
|
||||
rememberServerTime(unwrapped, envelope.timestamp);
|
||||
|
||||
return unwrapped;
|
||||
},
|
||||
async (error: AxiosError) => {
|
||||
if (error instanceof CbsApiError) {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
parseServerInstant,
|
||||
rememberServerTime,
|
||||
secondsUntilDeadline,
|
||||
serverTimeOf,
|
||||
toLocalDeadline,
|
||||
} from './serverClock';
|
||||
|
||||
describe('parseServerInstant', () => {
|
||||
it('reads the backend timestamp, seven fractional digits and an offset included', () => {
|
||||
expect(parseServerInstant('2026-09-30T13:50:31.1653828+05:30')).toBe(
|
||||
Date.UTC(2026, 8, 30, 8, 20, 31, 165),
|
||||
);
|
||||
});
|
||||
|
||||
it('reads timestamps with fewer fractional digits, none, or a Z', () => {
|
||||
expect(parseServerInstant('2026-09-30T08:20:31.5Z')).toBe(Date.UTC(2026, 8, 30, 8, 20, 31, 500));
|
||||
expect(parseServerInstant('2026-09-30T13:50:31+05:30')).toBe(Date.UTC(2026, 8, 30, 8, 20, 31));
|
||||
expect(parseServerInstant('2026-09-30T08:20:31.123Z')).toBe(Date.UTC(2026, 8, 30, 8, 20, 31, 123));
|
||||
});
|
||||
|
||||
it('gives null for nothing, or for something that is not a date', () => {
|
||||
expect(parseServerInstant(null)).toBeNull();
|
||||
expect(parseServerInstant(undefined)).toBeNull();
|
||||
expect(parseServerInstant('')).toBeNull();
|
||||
expect(parseServerInstant('soon')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toLocalDeadline', () => {
|
||||
// The initiate response from the TPIN reset guide: five minutes to go.
|
||||
const serverNow = '2026-09-30T13:45:31.1653828+05:30';
|
||||
const otpExpiresAt = '2026-09-30T13:50:31.1653828+05:30';
|
||||
|
||||
it('counts the server’s time left from when the answer arrived, ignoring the device clock', () => {
|
||||
// A handset an hour and a bit fast. The deadline still lands five minutes
|
||||
// after the answer arrived, not five minutes after 13:45 server time.
|
||||
const receivedAt = Date.UTC(2026, 8, 30, 9, 30, 0);
|
||||
|
||||
expect(toLocalDeadline(otpExpiresAt, serverNow, receivedAt)).toBe(receivedAt + 5 * 60 * 1000);
|
||||
});
|
||||
|
||||
it('takes the expiry at face value when the server sent no timestamp', () => {
|
||||
expect(toLocalDeadline(otpExpiresAt, undefined, 0)).toBe(parseServerInstant(otpExpiresAt));
|
||||
expect(toLocalDeadline(otpExpiresAt, 'garbage', 0)).toBe(parseServerInstant(otpExpiresAt));
|
||||
});
|
||||
|
||||
it('has no deadline without an expiry', () => {
|
||||
expect(toLocalDeadline(null, serverNow)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('secondsUntilDeadline', () => {
|
||||
it('counts down in whole seconds and stops at zero', () => {
|
||||
expect(secondsUntilDeadline(10_000, 0)).toBe(10);
|
||||
expect(secondsUntilDeadline(10_000, 9_700)).toBe(0);
|
||||
expect(secondsUntilDeadline(10_000, 60_000)).toBe(0);
|
||||
});
|
||||
|
||||
it('is unknown when the deadline is', () => {
|
||||
expect(secondsUntilDeadline(null, 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rememberServerTime', () => {
|
||||
it('files the envelope timestamp against the response it came with', () => {
|
||||
const response = {};
|
||||
rememberServerTime(response, '2026-09-30T13:45:31+05:30');
|
||||
|
||||
expect(serverTimeOf(response)).toBe('2026-09-30T13:45:31+05:30');
|
||||
expect(serverTimeOf({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores a missing or non-string timestamp', () => {
|
||||
const response = {};
|
||||
rememberServerTime(response, undefined);
|
||||
rememberServerTime(response, 1790000000);
|
||||
|
||||
expect(serverTimeOf(response)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* The server's clock, as each response reported it.
|
||||
*
|
||||
* Every envelope carries a `timestamp` — the server's "now" when it answered.
|
||||
* The interceptor unwraps the envelope, so it files that reading here, keyed on
|
||||
* the unwrapped response it hands back, rather than adding a field to axios's
|
||||
* response type. The entry goes when axios drops the response.
|
||||
*
|
||||
* Why it matters: an OTP or a one-time token comes back with an absolute
|
||||
* `expiresAt`. Counting down to that against the handset's clock is wrong by
|
||||
* however far the handset's clock is off — minutes, on a phone with automatic
|
||||
* time switched off. The gap between `expiresAt` and the server's own
|
||||
* `timestamp` is the true time left, whatever the device thinks the time is.
|
||||
*/
|
||||
const serverTimes = new WeakMap<object, string>();
|
||||
|
||||
/** Called by the interceptor with the envelope's `timestamp`. */
|
||||
export const rememberServerTime = (response: object, timestamp: unknown) => {
|
||||
if (typeof timestamp === 'string' && timestamp.length > 0) {
|
||||
serverTimes.set(response, timestamp);
|
||||
}
|
||||
};
|
||||
|
||||
/** The server's `timestamp` for this response, when it sent one. */
|
||||
export const serverTimeOf = (response: object): string | undefined => serverTimes.get(response);
|
||||
|
||||
/**
|
||||
* Epoch milliseconds from the backend's ISO-8601 offset timestamps.
|
||||
*
|
||||
* Java writes up to nine fractional digits (`13:50:31.1653828+05:30`), and the
|
||||
* ECMAScript date format only promises three. V8 is lenient about the rest;
|
||||
* other engines need not be — so the fraction is cut to exactly three first.
|
||||
*/
|
||||
export const parseServerInstant = (iso: string | null | undefined): number | null => {
|
||||
if (!iso) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalised = iso.replace(/\.(\d+)/, (_match, fraction: string) =>
|
||||
`.${`${fraction}00`.slice(0, 3)}`,
|
||||
);
|
||||
const ms = Date.parse(normalised);
|
||||
|
||||
return Number.isFinite(ms) ? ms : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* A server expiry as a deadline on this device's clock: the time the server
|
||||
* says is left, counted from when the answer arrived. Only the elapsed time is
|
||||
* measured locally, so a wrong device clock does not move the deadline.
|
||||
*
|
||||
* Without the server's `timestamp` the expiry is taken at face value — which is
|
||||
* right exactly when the device clock is.
|
||||
*/
|
||||
export const toLocalDeadline = (
|
||||
expiresAt: string | null | undefined,
|
||||
serverNow: string | null | undefined,
|
||||
receivedAt: number = Date.now(),
|
||||
): number | null => {
|
||||
const expiry = parseServerInstant(expiresAt);
|
||||
|
||||
if (expiry === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = parseServerInstant(serverNow);
|
||||
|
||||
return now === null ? expiry : receivedAt + (expiry - now);
|
||||
};
|
||||
|
||||
/** Whole seconds until a local deadline, never below zero. `null` when unknown. */
|
||||
export const secondsUntilDeadline = (deadline: number | null, now: number = Date.now()) =>
|
||||
deadline === null ? null : Math.max(0, Math.round((deadline - now) / 1000));
|
||||
@@ -106,7 +106,7 @@ export const mpinCrypto = {
|
||||
return this.encryptPin('mpin', mpin);
|
||||
},
|
||||
|
||||
/** Travels as `encryptedTpin`: TPIN creation and every fund transfer. */
|
||||
/** Travels as `encryptedTpin`: TPIN creation and reset, and every fund transfer. */
|
||||
encryptTpin(tpin: string): Promise<string> {
|
||||
return this.encryptPin('tpin', tpin);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user