155 lines
6.1 KiB
JavaScript
155 lines
6.1 KiB
JavaScript
'use strict';
|
|
|
|
const assert = require('assert');
|
|
const crypto = require('crypto');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
|
|
const {
|
|
createRechargeCredentialStore,
|
|
_private,
|
|
} = require('../src/services/recharge_credentials_store');
|
|
|
|
function createTestProtection() {
|
|
const key = crypto.randomBytes(32);
|
|
return {
|
|
async protect(plain) {
|
|
const iv = crypto.randomBytes(12);
|
|
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
const encrypted = Buffer.concat([cipher.update(plain), cipher.final()]);
|
|
return Buffer.concat([iv, cipher.getAuthTag(), encrypted]);
|
|
},
|
|
async unprotect(payload) {
|
|
const iv = payload.subarray(0, 12);
|
|
const tag = payload.subarray(12, 28);
|
|
const encrypted = payload.subarray(28);
|
|
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
|
decipher.setAuthTag(tag);
|
|
return Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
|
},
|
|
clear() { key.fill(0); },
|
|
};
|
|
}
|
|
|
|
(async () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'recharge-credentials-'));
|
|
const protection = createTestProtection();
|
|
try {
|
|
const store = createRechargeCredentialStore({
|
|
dataDir: tempDir,
|
|
protect: protection.protect,
|
|
unprotect: protection.unprotect,
|
|
});
|
|
|
|
assert.deepStrictEqual(store.getMeta(), {
|
|
configured: false,
|
|
keyIdMasked: '',
|
|
updatedAt: null,
|
|
});
|
|
|
|
const keyId = 'kid-example-12345678';
|
|
const secret = 'unit-test-secret-value';
|
|
const saved = await store.saveCredentials(keyId, secret);
|
|
assert.strictEqual(saved.configured, true);
|
|
assert.strictEqual(saved.keyIdMasked, 'kid-••••5678');
|
|
assert.match(saved.updatedAt, /^\d{4}-\d{2}-\d{2}T/);
|
|
|
|
const diskText = fs.readFileSync(store.filePath, 'utf8');
|
|
assert(!diskText.includes(keyId), '凭据文件不得出现完整 Key ID 明文');
|
|
assert(!diskText.includes(secret), '凭据文件不得出现 Secret 明文');
|
|
const envelope = JSON.parse(diskText);
|
|
assert.strictEqual(envelope.provider, 'external_recharge');
|
|
assert.strictEqual(envelope.version, 1);
|
|
assert.strictEqual(typeof envelope.protectedData, 'string');
|
|
|
|
const loaded = await store.loadCredentials();
|
|
assert.deepStrictEqual(loaded, { keyId, secret });
|
|
|
|
const replacementKeyId = 'kid-replacement-87654321';
|
|
const replacementSecret = 'replacement-unit-secret';
|
|
const replaced = await store.saveCredentials(replacementKeyId, replacementSecret);
|
|
assert.strictEqual(replaced.configured, true, '已配置凭据必须支持原子更新');
|
|
assert.deepStrictEqual(
|
|
await store.loadCredentials(),
|
|
{ keyId: replacementKeyId, secret: replacementSecret },
|
|
);
|
|
const replacedDiskText = fs.readFileSync(store.filePath, 'utf8');
|
|
assert(!replacedDiskText.includes(replacementKeyId));
|
|
assert(!replacedDiskText.includes(replacementSecret));
|
|
|
|
const meta = store.getMeta();
|
|
assert.deepStrictEqual(meta, {
|
|
configured: true,
|
|
keyIdMasked: 'kid-••••4321',
|
|
updatedAt: replaced.updatedAt,
|
|
});
|
|
|
|
await assert.rejects(
|
|
() => store.saveCredentials('', secret),
|
|
error => error && error.code === 'RECHARGE_KEY_ID_INVALID',
|
|
);
|
|
await assert.rejects(
|
|
() => store.saveCredentials(keyId, '••••••••'),
|
|
error => error && error.code === 'RECHARGE_SECRET_PLACEHOLDER',
|
|
);
|
|
|
|
await store.deleteCredentials();
|
|
assert.strictEqual(fs.existsSync(store.filePath), false);
|
|
assert.strictEqual(await store.loadCredentials(), null);
|
|
|
|
fs.writeFileSync(store.filePath, '{not-json', 'utf8');
|
|
assert.throws(
|
|
() => store.getMeta(),
|
|
error => error && error.code === 'RECHARGE_CREDENTIALS_CORRUPT',
|
|
);
|
|
|
|
assert.strictEqual(_private.maskKeyId('a'), 'a••••');
|
|
assert.strictEqual(_private.maskKeyId('abcd'), 'a••••d');
|
|
assert.strictEqual(_private.maskKeyId('abcdefgh'), 'ab••••gh');
|
|
assert.strictEqual(_private.maskKeyId('abcdefghijkl'), 'abcd••••ijkl');
|
|
|
|
const sentinel = 'must-never-appear-in-process-arguments';
|
|
const invocation = _private.buildPowerShellInvocation();
|
|
const sentinelBuffer = Buffer.from(sentinel, 'utf8');
|
|
const stdinPayload = _private.buildDpapiStdinPayload('protect', sentinelBuffer);
|
|
const commandLine = [invocation.command, ...invocation.args].join(' ');
|
|
assert(!commandLine.includes(sentinel));
|
|
assert(!commandLine.includes(sentinelBuffer.toString('base64')));
|
|
assert(stdinPayload.toString('utf8').includes(sentinelBuffer.toString('base64')));
|
|
const encodedIndex = invocation.args.indexOf('-EncodedCommand') + 1;
|
|
assert(encodedIndex > 0 && invocation.args[encodedIndex]);
|
|
const decodedScript = Buffer.from(invocation.args[encodedIndex], 'base64').toString('utf16le');
|
|
assert(decodedScript.includes('[Console]::In.ReadToEnd()'), 'DPAPI 输入必须来自 PowerShell stdin');
|
|
assert(decodedScript.includes('DataProtectionScope]::CurrentUser'), 'DPAPI 必须使用 CurrentUser 范围');
|
|
sentinelBuffer.fill(0);
|
|
stdinPayload.fill(0);
|
|
|
|
if (process.platform === 'win32' && !process.env.CODEX_CI) {
|
|
const probe = crypto.randomBytes(37);
|
|
let protectedProbe = null;
|
|
let unprotectedProbe = null;
|
|
try {
|
|
protectedProbe = await _private.runDpapiPowerShell('protect', probe);
|
|
assert(!protectedProbe.equals(probe));
|
|
unprotectedProbe = await _private.runDpapiPowerShell('unprotect', protectedProbe);
|
|
assert(unprotectedProbe.equals(probe), 'Windows DPAPI 应能在当前用户下完成往返解密');
|
|
} finally {
|
|
probe.fill(0);
|
|
if (protectedProbe) protectedProbe.fill(0);
|
|
if (unprotectedProbe) unprotectedProbe.fill(0);
|
|
}
|
|
} else if (process.platform === 'win32') {
|
|
console.log('recharge_credentials_store.test.js: DPAPI runtime probe skipped in Codex CI sandbox');
|
|
}
|
|
|
|
console.log('recharge_credentials_store.test.js: PASS');
|
|
} finally {
|
|
protection.clear();
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
})().catch(error => {
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
});
|