feat(store): 添加门店、门店验证
This commit is contained in:
@@ -2,14 +2,17 @@
|
||||
"name": "@atlas/worker",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"dev": "tsx watch src/main.ts",
|
||||
"start": "node dist/main.js",
|
||||
"check:ozon-store": "tsx src/check-ozon-store.ts",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atlas/contracts": "workspace:*",
|
||||
"@atlas/database": "workspace:*",
|
||||
"bullmq": "6.3.4",
|
||||
"ioredis": "6.0.0"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { createApplicationDatabase } from '@atlas/database';
|
||||
import { workerConfig } from './config.js';
|
||||
import { decryptStoreSecret } from './store-secret.crypto.js';
|
||||
|
||||
type StoreCredential = {
|
||||
workspace_id: string;
|
||||
store_id: string;
|
||||
client_id: string;
|
||||
ciphertext: Buffer;
|
||||
nonce: Buffer;
|
||||
};
|
||||
|
||||
const storeName = process.argv[2];
|
||||
if (!storeName) {
|
||||
throw new Error('Usage: tsx src/check-ozon-store.ts <store-name>');
|
||||
}
|
||||
|
||||
const database = createApplicationDatabase(workerConfig.databaseUrl);
|
||||
|
||||
async function main() {
|
||||
const workspaces = await database.query<{ id: string }>(
|
||||
'SELECT id FROM atlas.workspaces',
|
||||
);
|
||||
const matches: StoreCredential[] = [];
|
||||
for (const workspace of workspaces.rows) {
|
||||
const found = await database.workspaceTransaction(workspace.id, (tx) =>
|
||||
tx.query<StoreCredential>(
|
||||
`SELECT s.workspace_id, s.id AS store_id, s.platform_client_id AS client_id,
|
||||
secret.secret_ciphertext AS ciphertext, secret.secret_nonce AS nonce
|
||||
FROM atlas.stores s
|
||||
JOIN atlas.platforms p ON p.id = s.platform_id AND p.code = 'OZON'
|
||||
JOIN atlas.store_secrets secret ON secret.store_id = s.id
|
||||
AND secret.kind = 'seller_api_key' AND secret.revoked_at IS NULL
|
||||
WHERE s.name = $1 AND s.deleted_at IS NULL`,
|
||||
[storeName],
|
||||
),
|
||||
);
|
||||
matches.push(...found.rows);
|
||||
}
|
||||
if (matches.length === 0) throw new Error(`No active Ozon store named “${storeName}”`);
|
||||
if (matches.length > 1) throw new Error(`Multiple active Ozon stores named “${storeName}”; use a unique name`);
|
||||
|
||||
const store = matches[0];
|
||||
const apiKey = decryptStoreSecret(store.ciphertext, store.nonce, workerConfig.storeSecretEncryptionKey);
|
||||
const checks = [
|
||||
{ name: 'warehouse-list', path: '/v1/warehouse/list', body: {} },
|
||||
{ name: 'category-tree', path: '/v1/description-category/tree', body: { language: 'ZH_HANS' } },
|
||||
];
|
||||
for (const check of checks) {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`https://api-seller.ozon.ru${check.path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Client-Id': store.client_id,
|
||||
'Api-Key': apiKey,
|
||||
},
|
||||
body: JSON.stringify(check.body),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
} catch {
|
||||
console.info(`${check.name}: network error or timeout`);
|
||||
continue;
|
||||
}
|
||||
console.info(`${check.name}: HTTP ${response.status} ${response.ok ? 'OK' : 'FAILED'}`);
|
||||
}
|
||||
}
|
||||
|
||||
void main().finally(() => database.close());
|
||||
@@ -0,0 +1,23 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { loadEnvFile } from 'node:process';
|
||||
|
||||
for (const directory of [process.cwd(), resolve(process.cwd(), '..'), resolve(process.cwd(), '../..')]) {
|
||||
const path = resolve(directory, '.env');
|
||||
if (existsSync(path)) {
|
||||
loadEnvFile(path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function required(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) throw new Error(`${name} is required`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export const workerConfig = {
|
||||
databaseUrl: required('DATABASE_URL'),
|
||||
redisUrl: required('REDIS_URL'),
|
||||
storeSecretEncryptionKey: required('STORE_SECRET_ENCRYPTION_KEY'),
|
||||
};
|
||||
+192
-18
@@ -1,28 +1,202 @@
|
||||
import { Queue } from 'bullmq';
|
||||
import IORedis from 'ioredis';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Queue, Worker } from 'bullmq';
|
||||
import { Redis } from 'ioredis';
|
||||
import { createApplicationDatabase, type Database } from '@atlas/database';
|
||||
import { workerConfig } from './config.js';
|
||||
import { OutboxDispatcher, type OutboxJob } from './outbox-dispatcher.js';
|
||||
import { OzonCredentialError, OzonRequestError, verifyOzonCredentials } from './ozon-client.js';
|
||||
import { decryptStoreSecret } from './store-secret.crypto.js';
|
||||
|
||||
// 显式构造客户端实例传给 BullMQ:原生 ESM(pnpm start)下 BullMQ 无法按 connection
|
||||
// 选项自动加载 ioredis;maxRetriesPerRequest: null 是 BullMQ Worker 的要求
|
||||
const connection = new IORedis(
|
||||
process.env.REDIS_URL ?? 'redis://localhost:6379',
|
||||
{
|
||||
maxRetriesPerRequest: null,
|
||||
const connection = new Redis(workerConfig.redisUrl, { maxRetriesPerRequest: null });
|
||||
const database = createApplicationDatabase(workerConfig.databaseUrl);
|
||||
const queue = new Queue<OutboxJob>('atlas-dispatch', { connection });
|
||||
const dispatcher = new OutboxDispatcher(database, queue, `worker_${randomUUID()}`);
|
||||
|
||||
const worker = new Worker<OutboxJob>(
|
||||
'atlas-dispatch',
|
||||
async (job) => {
|
||||
if (job.name === 'store_credential_verification.requested') {
|
||||
await verifyStoreCredentials(database, job.data);
|
||||
}
|
||||
},
|
||||
{ connection, concurrency: 5 },
|
||||
);
|
||||
const dispatchQueue = new Queue('atlas-dispatch', { connection });
|
||||
|
||||
worker.on('failed', (job, error) => {
|
||||
if (!job || job.attemptsMade < (job.opts.attempts ?? 1)) return;
|
||||
void markUnavailable(database, job.data, error);
|
||||
});
|
||||
|
||||
async function verifyStoreCredentials(database: Database, job: OutboxJob) {
|
||||
const source = await database.workspaceTransaction(job.workspaceId, async (tx) => {
|
||||
await tx.query(
|
||||
`UPDATE atlas.operations SET status = 'running', started_at = COALESCE(started_at, now()),
|
||||
attempt_count = attempt_count + 1 WHERE id = $1 AND status IN ('queued', 'running')`,
|
||||
[job.payload.operation_id],
|
||||
);
|
||||
const result = await tx.query<{
|
||||
platform_code: string; platform_client_id: string; secret_ciphertext: Buffer; secret_nonce: Buffer;
|
||||
}>(
|
||||
`SELECT p.code AS platform_code, s.platform_client_id, secret.secret_ciphertext, secret.secret_nonce
|
||||
FROM atlas.operations operation JOIN atlas.stores s ON s.id = operation.resource_id AND s.deleted_at IS NULL
|
||||
JOIN atlas.platforms p ON p.id = s.platform_id JOIN atlas.store_secrets secret ON secret.id = $2
|
||||
WHERE operation.id = $1 AND operation.kind = 'store_credential_verification'
|
||||
AND secret.store_id = s.id AND secret.kind = 'seller_api_key' AND secret.revoked_at IS NULL`,
|
||||
[job.payload.operation_id, job.payload.secret_id ?? ''],
|
||||
);
|
||||
return result.rows[0] ?? null;
|
||||
});
|
||||
if (!source) return cancelStaleOperation(database, job);
|
||||
const apiKey = decryptStoreSecret(source.secret_ciphertext, source.secret_nonce, workerConfig.storeSecretEncryptionKey);
|
||||
try {
|
||||
if (source.platform_code.toUpperCase() !== 'OZON') throw new OzonRequestError(422);
|
||||
await verifyOzonCredentials(source.platform_client_id, apiKey);
|
||||
await completeVerification(database, job, true);
|
||||
} catch (error) {
|
||||
if (error instanceof OzonCredentialError) {
|
||||
await completeVerification(database, job, false, error.status);
|
||||
return;
|
||||
}
|
||||
if (error instanceof OzonRequestError) {
|
||||
await markRequestRejected(database, job, error.status);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelStaleOperation(database: Database, job: OutboxJob) {
|
||||
await database.workspaceTransaction(job.workspaceId, (tx) => tx.query(
|
||||
`UPDATE atlas.operations SET status = 'cancelled', completed_at = now(),
|
||||
error_code = 'CREDENTIAL_REPLACED', error_message = '凭据已更新,旧验证任务已取消'
|
||||
WHERE id = $1 AND status IN ('queued', 'running')`,
|
||||
[job.payload.operation_id],
|
||||
));
|
||||
}
|
||||
|
||||
async function completeVerification(
|
||||
database: Database,
|
||||
job: OutboxJob,
|
||||
valid: boolean,
|
||||
rejectedStatus?: 401 | 403,
|
||||
) {
|
||||
await database.workspaceTransaction(job.workspaceId, async (tx) => {
|
||||
const current = await tx.query<{ id: string }>(
|
||||
`SELECT s.id FROM atlas.stores s JOIN atlas.store_secrets secret ON secret.id = $2
|
||||
WHERE s.id = $1 AND s.deleted_at IS NULL AND secret.store_id = s.id
|
||||
AND secret.revoked_at IS NULL FOR UPDATE`,
|
||||
[job.payload.store_id, job.payload.secret_id ?? ''],
|
||||
);
|
||||
if (!current.rows[0]) return;
|
||||
const state = valid ? 'valid' : 'invalid';
|
||||
await tx.query(
|
||||
`UPDATE atlas.store_secrets SET status = $1::atlas.credential_status,
|
||||
last_verified_at = now(), updated_at = now() WHERE id = $2`,
|
||||
[state, job.payload.secret_id],
|
||||
);
|
||||
await tx.query(
|
||||
valid
|
||||
? `UPDATE atlas.stores SET status = 'active', credential_status = 'valid',
|
||||
credential_checked_at = now(), last_error_code = NULL, last_error_message = NULL,
|
||||
last_error_at = NULL, revision = revision + 1, updated_at = now() WHERE id = $1`
|
||||
: `UPDATE atlas.stores SET status = 'credential_invalid', credential_status = 'invalid',
|
||||
credential_checked_at = now(), last_error_code = $2,
|
||||
last_error_message = $3, last_error_at = now(),
|
||||
revision = revision + 1, updated_at = now() WHERE id = $1`,
|
||||
valid ? [job.payload.store_id] : [
|
||||
job.payload.store_id,
|
||||
rejectedStatus === 403 ? 'OZON_CREDENTIAL_FORBIDDEN' : 'OZON_CREDENTIAL_UNAUTHORIZED',
|
||||
rejectedStatus === 403
|
||||
? 'Ozon 拒绝访问:请检查 Client ID 与 API Key 是否属于同一账户,及密钥是否具备读取权限'
|
||||
: 'Ozon 未接受该 Client ID 或 API Key',
|
||||
],
|
||||
);
|
||||
await tx.query(
|
||||
valid
|
||||
? `UPDATE atlas.operations SET status = 'succeeded', completed_at = now(),
|
||||
result = jsonb_build_object('credential_status', 'valid'), error_code = NULL, error_message = NULL
|
||||
WHERE id = $1 AND status IN ('queued', 'running')`
|
||||
: `UPDATE atlas.operations SET status = 'failed', completed_at = now(),
|
||||
error_code = $2, error_message = $3
|
||||
WHERE id = $1 AND status IN ('queued', 'running')`,
|
||||
valid ? [job.payload.operation_id] : [
|
||||
job.payload.operation_id,
|
||||
rejectedStatus === 403 ? 'OZON_CREDENTIAL_FORBIDDEN' : 'OZON_CREDENTIAL_UNAUTHORIZED',
|
||||
rejectedStatus === 403
|
||||
? 'Ozon 拒绝访问:请检查 Client ID 与 API Key 是否属于同一账户,及密钥是否具备读取权限'
|
||||
: 'Ozon 未接受该 Client ID 或 API Key',
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function markRequestRejected(database: Database, job: OutboxJob, status: number) {
|
||||
await database.workspaceTransaction(job.workspaceId, async (tx) => {
|
||||
const current = await tx.query<{ id: string }>(
|
||||
`SELECT s.id FROM atlas.stores s JOIN atlas.store_secrets secret ON secret.id = $2
|
||||
WHERE s.id = $1 AND s.deleted_at IS NULL AND secret.store_id = s.id
|
||||
AND secret.revoked_at IS NULL FOR UPDATE`,
|
||||
[job.payload.store_id, job.payload.secret_id ?? ''],
|
||||
);
|
||||
if (!current.rows[0]) return;
|
||||
const message = `Ozon 拒绝了验证请求(HTTP ${status});这不代表 API Key 无效`;
|
||||
await tx.query(
|
||||
`UPDATE atlas.operations SET status = 'failed', completed_at = now(),
|
||||
error_code = 'OZON_VERIFICATION_REQUEST_REJECTED', error_message = $2,
|
||||
result = jsonb_build_object('ozon_http_status', $3)
|
||||
WHERE id = $1 AND status IN ('queued', 'running')`,
|
||||
[job.payload.operation_id, message, status],
|
||||
);
|
||||
await tx.query(
|
||||
`UPDATE atlas.stores SET status = 'sync_error', last_error_code = 'OZON_VERIFICATION_REQUEST_REJECTED',
|
||||
last_error_message = $2, last_error_at = now(), revision = revision + 1, updated_at = now()
|
||||
WHERE id = $1 AND status = 'verifying'`,
|
||||
[job.payload.store_id, message],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function markUnavailable(database: Database, job: OutboxJob, _error: Error) {
|
||||
await database.workspaceTransaction(job.workspaceId, async (tx) => {
|
||||
const current = await tx.query<{ id: string }>(
|
||||
`SELECT s.id FROM atlas.stores s JOIN atlas.store_secrets secret ON secret.id = $2
|
||||
WHERE s.id = $1 AND s.deleted_at IS NULL AND secret.store_id = s.id
|
||||
AND secret.revoked_at IS NULL FOR UPDATE`,
|
||||
[job.payload.store_id, job.payload.secret_id ?? ''],
|
||||
);
|
||||
if (!current.rows[0]) return;
|
||||
await tx.query(
|
||||
`UPDATE atlas.operations SET status = 'failed', completed_at = now(),
|
||||
error_code = 'OZON_VERIFICATION_UNAVAILABLE', error_message = '暂时无法验证 Ozon 凭据,请稍后重试'
|
||||
WHERE id = $1 AND status IN ('queued', 'running')`,
|
||||
[job.payload.operation_id],
|
||||
);
|
||||
await tx.query(
|
||||
`UPDATE atlas.stores SET status = 'sync_error', last_error_code = 'OZON_VERIFICATION_UNAVAILABLE',
|
||||
last_error_message = '暂时无法验证 Ozon 凭据,请稍后重试', last_error_at = now(),
|
||||
revision = revision + 1, updated_at = now() WHERE id = $1 AND status = 'verifying'`,
|
||||
[job.payload.store_id],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
async function bootstrap() {
|
||||
await dispatchQueue.waitUntilReady();
|
||||
console.info(
|
||||
'Atlas Worker is ready; it will consume transactional-outbox events after that module exists.',
|
||||
);
|
||||
await queue.waitUntilReady();
|
||||
await worker.waitUntilReady();
|
||||
await dispatcher.dispatchBatch();
|
||||
timer = setInterval(() => void dispatcher.dispatchBatch().catch((error: unknown) => {
|
||||
console.error('outbox_dispatch_failed', error instanceof Error ? error.message : error);
|
||||
}), 2_000);
|
||||
console.info('Atlas Worker is ready; consuming transactional-outbox events.');
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
|
||||
void bootstrap().catch((error: unknown) => {
|
||||
console.error('worker_startup_failed', error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
async function shutdown() {
|
||||
await dispatchQueue.close();
|
||||
if (timer) clearInterval(timer);
|
||||
await worker.close(); await queue.close(); await database.close(); await connection.quit();
|
||||
}
|
||||
|
||||
process.once('SIGINT', () => void shutdown());
|
||||
process.once('SIGTERM', () => void shutdown());
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { Queue } from 'bullmq';
|
||||
import type { Database } from '@atlas/database';
|
||||
|
||||
export type OutboxJob = {
|
||||
eventId: string;
|
||||
workspaceId: string;
|
||||
eventType: string;
|
||||
payload: { store_id: string; operation_id: string; secret_id?: string };
|
||||
};
|
||||
|
||||
type EventRow = Omit<OutboxJob, 'eventId' | 'workspaceId' | 'eventType'> & {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
event_type: string;
|
||||
event_payload: OutboxJob['payload'];
|
||||
};
|
||||
|
||||
export class OutboxDispatcher {
|
||||
public constructor(
|
||||
private readonly database: Database,
|
||||
private readonly queue: Queue<OutboxJob>,
|
||||
private readonly workerId: string,
|
||||
) {}
|
||||
|
||||
public async dispatchBatch() {
|
||||
// 业务表受 RLS 保护;先枚举工作空间,再在各自租户事务中认领事件。
|
||||
const workspaces = await this.database.query<{ id: string }>(
|
||||
'SELECT id FROM atlas.workspaces',
|
||||
);
|
||||
for (const workspace of workspaces.rows) {
|
||||
const events = await this.claim(workspace.id);
|
||||
for (const event of events) await this.dispatch(event);
|
||||
}
|
||||
}
|
||||
|
||||
private async claim(workspaceId: string): Promise<OutboxJob[]> {
|
||||
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const events = await tx.query<EventRow>(
|
||||
`WITH candidates AS (
|
||||
SELECT id FROM atlas.outbox_events
|
||||
WHERE (status = 'pending' AND next_attempt_at <= now())
|
||||
OR (status = 'dispatching' AND locked_until < now())
|
||||
ORDER BY created_at
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 20
|
||||
)
|
||||
UPDATE atlas.outbox_events event
|
||||
SET status = 'dispatching', locked_by = $1,
|
||||
locked_until = now() + interval '2 minutes', updated_at = now()
|
||||
FROM candidates
|
||||
WHERE event.id = candidates.id
|
||||
RETURNING event.id, event.workspace_id, event.event_type, event.event_payload`,
|
||||
[this.workerId],
|
||||
);
|
||||
return events.rows.map((event) => ({
|
||||
eventId: event.id,
|
||||
workspaceId: event.workspace_id,
|
||||
eventType: event.event_type,
|
||||
payload: event.event_payload,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
private async dispatch(event: OutboxJob) {
|
||||
try {
|
||||
await this.queue.add(event.eventType, event, {
|
||||
jobId: event.eventId,
|
||||
attempts: 5,
|
||||
backoff: { type: 'exponential', delay: 5_000 },
|
||||
removeOnComplete: 1_000,
|
||||
removeOnFail: 1_000,
|
||||
});
|
||||
await this.database.workspaceTransaction(event.workspaceId, (tx) =>
|
||||
tx.query(
|
||||
`UPDATE atlas.outbox_events SET status = 'dispatched', dispatched_at = now(),
|
||||
locked_by = NULL, locked_until = NULL, updated_at = now()
|
||||
WHERE id = $1 AND status = 'dispatching' AND locked_by = $2`,
|
||||
[event.eventId, this.workerId],
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
await this.database.workspaceTransaction(event.workspaceId, (tx) =>
|
||||
tx.query(
|
||||
`UPDATE atlas.outbox_events SET status = 'pending', attempt_count = attempt_count + 1,
|
||||
next_attempt_at = now() + interval '30 seconds', locked_by = NULL, locked_until = NULL,
|
||||
last_error_code = 'QUEUE_UNAVAILABLE', last_error_message = '任务队列暂不可用', updated_at = now()
|
||||
WHERE id = $1 AND status = 'dispatching' AND locked_by = $2`,
|
||||
[event.eventId, this.workerId],
|
||||
),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/** 401 是认证失败;403 表示密钥权限或账户组合不被允许。 */
|
||||
export class OzonCredentialError extends Error {
|
||||
public constructor(public readonly status: 401 | 403) {
|
||||
super(`Ozon credential request failed (${status})`);
|
||||
}
|
||||
}
|
||||
/** 认证以外的确定性 4xx,不能误报为凭据无效。 */
|
||||
export class OzonRequestError extends Error {
|
||||
public constructor(public readonly status: number) {
|
||||
super(`Ozon verification request was rejected (${status})`);
|
||||
}
|
||||
}
|
||||
export class OzonRetryableError extends Error {}
|
||||
|
||||
/**
|
||||
* Uses the category-tree read endpoint as the credential probe. It has no write side effect and,
|
||||
* unlike warehouse-list, is verified against the current Ozon account integration.
|
||||
*/
|
||||
export async function verifyOzonCredentials(
|
||||
clientId: string,
|
||||
apiKey: string,
|
||||
): Promise<void> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch('https://api-seller.ozon.ru/v1/description-category/tree', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Client-Id': clientId,
|
||||
'Api-Key': apiKey,
|
||||
},
|
||||
body: JSON.stringify({ language: 'ZH_HANS' }),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
} catch {
|
||||
throw new OzonRetryableError('Ozon request could not be completed');
|
||||
}
|
||||
if (response.ok) return;
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new OzonCredentialError(response.status);
|
||||
}
|
||||
if (response.status === 429 || response.status >= 500) {
|
||||
throw new OzonRetryableError(`Ozon temporarily unavailable (${response.status})`);
|
||||
}
|
||||
throw new OzonRequestError(response.status);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createDecipheriv, createHash } from 'node:crypto';
|
||||
|
||||
/** Decrypt only inside a running Worker; callers must never log the returned API key. */
|
||||
export function decryptStoreSecret(
|
||||
ciphertextWithTag: Buffer,
|
||||
nonce: Buffer,
|
||||
keyMaterial: string,
|
||||
): string {
|
||||
const tagLength = 16;
|
||||
if (ciphertextWithTag.length <= tagLength) {
|
||||
throw new Error('Invalid encrypted store secret');
|
||||
}
|
||||
const key = createHash('sha256').update(keyMaterial).digest();
|
||||
const decipher = createDecipheriv('aes-256-gcm', key, nonce);
|
||||
decipher.setAuthTag(ciphertextWithTag.subarray(-tagLength));
|
||||
return Buffer.concat([
|
||||
decipher.update(ciphertextWithTag.subarray(0, -tagLength)),
|
||||
decipher.final(),
|
||||
]).toString('utf8');
|
||||
}
|
||||
Reference in New Issue
Block a user