mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-23 03:18:09 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@qinglong/local-secret",
|
||||
"version": "3.0.0-alpha.0",
|
||||
"private": true,
|
||||
"description": "QingLong 3.0 encrypted local SecretStore and private keyring",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=24.18.0 <25"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"require": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*.js",
|
||||
"dist/**/*.d.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"check": "node ../../scripts/ql3-build-package-closure.cjs && tsc -p tsconfig.json --noEmit",
|
||||
"test": "node ../../scripts/ql3-build-package-closure.cjs && node --test test/*.test.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@qinglong/runtime-core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@qinglong/local-sqlite": "workspace:*",
|
||||
"@types/node": "24.13.3",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export * from './secret-custody/crypto';
|
||||
export * from './secret-custody/keyring';
|
||||
export * from './secret-custody/keyMaterial';
|
||||
export * from './secret-custody/service';
|
||||
export type {
|
||||
AppendLocalSecretEnvelopeCommand,
|
||||
AppendLocalSecretEnvelopeResult,
|
||||
LocalSecretEnvelope,
|
||||
LocalSecretEnvelopeRepository,
|
||||
LocalSecretEnvironmentProvider,
|
||||
LocalSecretKeyMaterial,
|
||||
LocalSecretKeyProvider,
|
||||
LocalSecretReference,
|
||||
PutEncryptedLocalSecretCommand,
|
||||
PutEncryptedLocalSecretResult,
|
||||
} from '@qinglong/runtime-core/local-secret';
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
randomBytes,
|
||||
} from 'node:crypto';
|
||||
import { TextDecoder } from 'node:util';
|
||||
import {
|
||||
LOCAL_SECRET_ALGORITHM,
|
||||
LocalSecretUnavailableError,
|
||||
assertLocalSecretPlaintext,
|
||||
localSecretBinary,
|
||||
localSecretEnvelopeAad,
|
||||
normalizeLocalSecretEnvelope,
|
||||
type LocalSecretEnvelope,
|
||||
} from '@qinglong/runtime-core/local-secret';
|
||||
|
||||
const SECRET_KEY_BYTES = 32;
|
||||
const SECRET_NONCE_BYTES = 12;
|
||||
const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true });
|
||||
|
||||
export type LocalSecretNonceFactory = () => Uint8Array;
|
||||
|
||||
function ownedSecretKey(key: Uint8Array): Buffer {
|
||||
if (!(key instanceof Uint8Array) || key.byteLength !== SECRET_KEY_BYTES) {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
return Buffer.from(key);
|
||||
}
|
||||
|
||||
export function encryptLocalSecretEnvelope(
|
||||
metadata: Omit<LocalSecretEnvelope, 'nonce' | 'ciphertext' | 'authTag'>,
|
||||
plaintext: string,
|
||||
key: Uint8Array,
|
||||
nonceFactory: LocalSecretNonceFactory = () => randomBytes(SECRET_NONCE_BYTES),
|
||||
): LocalSecretEnvelope {
|
||||
assertLocalSecretPlaintext(plaintext);
|
||||
const ownedKey = ownedSecretKey(key);
|
||||
const plaintextBuffer = Buffer.from(plaintext, 'utf8');
|
||||
let nonce: Buffer | undefined;
|
||||
try {
|
||||
nonce = Buffer.from(nonceFactory());
|
||||
if (nonce.length !== SECRET_NONCE_BYTES) {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
const cipher = createCipheriv(LOCAL_SECRET_ALGORITHM, ownedKey, nonce, {
|
||||
authTagLength: 16,
|
||||
});
|
||||
const aad = localSecretEnvelopeAad(metadata);
|
||||
try {
|
||||
cipher.setAAD(aad);
|
||||
} finally {
|
||||
aad.fill(0);
|
||||
}
|
||||
const ciphertext = Buffer.concat([
|
||||
cipher.update(plaintextBuffer),
|
||||
cipher.final(),
|
||||
]);
|
||||
try {
|
||||
return normalizeLocalSecretEnvelope({
|
||||
...metadata,
|
||||
nonce: nonce.toString('base64url'),
|
||||
ciphertext: ciphertext.toString('base64url'),
|
||||
authTag: cipher.getAuthTag().toString('base64url'),
|
||||
});
|
||||
} finally {
|
||||
ciphertext.fill(0);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof LocalSecretUnavailableError) throw error;
|
||||
throw new LocalSecretUnavailableError();
|
||||
} finally {
|
||||
ownedKey.fill(0);
|
||||
plaintextBuffer.fill(0);
|
||||
nonce?.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export function decryptLocalSecretEnvelopeToBuffer(
|
||||
envelope: LocalSecretEnvelope,
|
||||
key: Uint8Array,
|
||||
): Buffer {
|
||||
const normalized = normalizeLocalSecretEnvelope(envelope);
|
||||
const ownedKey = ownedSecretKey(key);
|
||||
const nonce = localSecretBinary('nonce', normalized.nonce);
|
||||
const ciphertext = localSecretBinary('ciphertext', normalized.ciphertext);
|
||||
const authTag = localSecretBinary('authTag', normalized.authTag);
|
||||
const aad = localSecretEnvelopeAad(normalized);
|
||||
try {
|
||||
const decipher = createDecipheriv(LOCAL_SECRET_ALGORITHM, ownedKey, nonce, {
|
||||
authTagLength: 16,
|
||||
});
|
||||
decipher.setAAD(aad);
|
||||
decipher.setAuthTag(authTag);
|
||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
} catch {
|
||||
throw new LocalSecretUnavailableError();
|
||||
} finally {
|
||||
ownedKey.fill(0);
|
||||
nonce.fill(0);
|
||||
ciphertext.fill(0);
|
||||
authTag.fill(0);
|
||||
aad.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeLocalSecretPlaintext(plaintext: Buffer): string {
|
||||
try {
|
||||
return UTF8_DECODER.decode(plaintext);
|
||||
} catch {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import {
|
||||
LocalSecretUnavailableError,
|
||||
assertLocalSecretKeyId,
|
||||
type LocalSecretEnvelope,
|
||||
type LocalSecretKeyMaterial,
|
||||
} from '@qinglong/runtime-core/local-secret';
|
||||
import { decryptLocalSecretEnvelopeToBuffer } from './crypto';
|
||||
|
||||
export function ownedLocalSecretKeyMaterial(
|
||||
material: LocalSecretKeyMaterial | null,
|
||||
expectedKeyId?: string,
|
||||
): { keyId: string; key: Buffer } {
|
||||
if (!material || !(material.key instanceof Uint8Array)) {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
try {
|
||||
assertLocalSecretKeyId(material.keyId);
|
||||
if (
|
||||
(expectedKeyId !== undefined && material.keyId !== expectedKeyId) ||
|
||||
material.key.byteLength !== 32
|
||||
) {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
return { keyId: material.keyId, key: Buffer.from(material.key) };
|
||||
} catch {
|
||||
throw new LocalSecretUnavailableError();
|
||||
} finally {
|
||||
material.key.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export function localSecretPlaintextMatches(
|
||||
envelope: LocalSecretEnvelope,
|
||||
key: Uint8Array,
|
||||
expected: string,
|
||||
): boolean {
|
||||
const actual = decryptLocalSecretEnvelopeToBuffer(envelope, key);
|
||||
const wanted = Buffer.from(expected, 'utf8');
|
||||
try {
|
||||
return actual.length === wanted.length && timingSafeEqual(actual, wanted);
|
||||
} finally {
|
||||
actual.fill(0);
|
||||
wanted.fill(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import { randomBytes, createHash } from 'node:crypto';
|
||||
import { constants } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
LocalSecretUnavailableError,
|
||||
assertLocalSecretKeyId,
|
||||
type LocalSecretKeyMaterial,
|
||||
type LocalSecretKeyProvider,
|
||||
} from '@qinglong/runtime-core/local-secret';
|
||||
|
||||
const MAX_KEYRING_BYTES = 16 * 1024;
|
||||
const MAX_KEY_COUNT = 16;
|
||||
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/;
|
||||
|
||||
interface LocalSecretKeyringManifest {
|
||||
readonly version: 1;
|
||||
readonly activeKeyId: string;
|
||||
readonly keys: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
export interface LocalSecretKeyringSummary {
|
||||
readonly version: 1;
|
||||
readonly activeKeyId: string;
|
||||
readonly keyIds: readonly string[];
|
||||
readonly digest: string;
|
||||
}
|
||||
|
||||
export class LocalSecretKeyringConflictError extends Error {
|
||||
readonly code = 'LOCAL_SECRET_KEYRING_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('Local Secret keyring state changed');
|
||||
this.name = 'LocalSecretKeyringConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
function exactKeys(value: object, expected: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort();
|
||||
const sortedExpected = [...expected].sort();
|
||||
return (
|
||||
actual.length === sortedExpected.length &&
|
||||
actual.every((key, index) => key === sortedExpected[index])
|
||||
);
|
||||
}
|
||||
|
||||
function assertKeyringPath(filePath: unknown): asserts filePath is string {
|
||||
if (
|
||||
typeof filePath !== 'string' ||
|
||||
!path.isAbsolute(filePath) ||
|
||||
filePath.includes('\0') ||
|
||||
Buffer.byteLength(filePath, 'utf8') > 4096
|
||||
) {
|
||||
throw new TypeError('Local Secret keyring path must be absolute');
|
||||
}
|
||||
}
|
||||
|
||||
function parseManifest(value: Buffer): LocalSecretKeyringManifest {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value.toString('utf8'));
|
||||
} catch {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed !== 'object' ||
|
||||
Array.isArray(parsed) ||
|
||||
!exactKeys(parsed, ['activeKeyId', 'keys', 'version'])
|
||||
) {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
const record = parsed as Record<string, unknown>;
|
||||
if (
|
||||
record.version !== 1 ||
|
||||
!record.keys ||
|
||||
typeof record.keys !== 'object' ||
|
||||
Array.isArray(record.keys)
|
||||
) {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
try {
|
||||
assertLocalSecretKeyId(record.activeKeyId);
|
||||
} catch {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
const entries = Object.entries(record.keys as Record<string, unknown>);
|
||||
if (entries.length < 1 || entries.length > MAX_KEY_COUNT) {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
const keys: Record<string, string> = Object.create(null);
|
||||
for (const [keyId, encoded] of entries) {
|
||||
let decoded: Buffer | undefined;
|
||||
try {
|
||||
assertLocalSecretKeyId(keyId);
|
||||
decoded =
|
||||
typeof encoded === 'string'
|
||||
? Buffer.from(encoded, 'base64url')
|
||||
: Buffer.alloc(0);
|
||||
if (
|
||||
typeof encoded !== 'string' ||
|
||||
!BASE64URL_PATTERN.test(encoded) ||
|
||||
decoded.length !== 32 ||
|
||||
decoded.toString('base64url') !== encoded
|
||||
) {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
keys[keyId] = encoded;
|
||||
} catch {
|
||||
throw new LocalSecretUnavailableError();
|
||||
} finally {
|
||||
decoded?.fill(0);
|
||||
}
|
||||
}
|
||||
if (!keys[record.activeKeyId]) {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
return Object.freeze({
|
||||
version: 1,
|
||||
activeKeyId: record.activeKeyId,
|
||||
keys: Object.freeze(keys),
|
||||
});
|
||||
}
|
||||
|
||||
function canonicalManifest(manifest: LocalSecretKeyringManifest): Buffer {
|
||||
const keys = Object.fromEntries(
|
||||
Object.entries(manifest.keys).sort(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
),
|
||||
);
|
||||
return Buffer.from(
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
activeKeyId: manifest.activeKeyId,
|
||||
keys,
|
||||
})}\n`,
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
|
||||
function summary(manifest: LocalSecretKeyringManifest): LocalSecretKeyringSummary {
|
||||
const canonical = canonicalManifest(manifest);
|
||||
try {
|
||||
return Object.freeze({
|
||||
version: 1,
|
||||
activeKeyId: manifest.activeKeyId,
|
||||
keyIds: Object.freeze(Object.keys(manifest.keys).sort()),
|
||||
digest: createHash('sha256').update(canonical).digest('hex'),
|
||||
});
|
||||
} finally {
|
||||
canonical.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertRealParent(filePath: string): Promise<void> {
|
||||
const stat = await fs.lstat(path.dirname(filePath));
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
async function readManifest(filePath: string): Promise<LocalSecretKeyringManifest> {
|
||||
let file: fs.FileHandle | undefined;
|
||||
let contents: Buffer | undefined;
|
||||
try {
|
||||
file = await fs.open(
|
||||
filePath,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const stat = await file.stat();
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
(stat.mode & 0o077) !== 0 ||
|
||||
stat.size < 1 ||
|
||||
stat.size > MAX_KEYRING_BYTES
|
||||
) {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
contents = await file.readFile();
|
||||
if (contents.length !== stat.size) {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
return parseManifest(contents);
|
||||
} catch {
|
||||
throw new LocalSecretUnavailableError();
|
||||
} finally {
|
||||
contents?.fill(0);
|
||||
await file?.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function syncDirectory(directory: string): Promise<void> {
|
||||
const handle = await fs.open(directory, constants.O_RDONLY);
|
||||
try {
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
function newKeyId(): string {
|
||||
return `qlsk-${randomBytes(12).toString('base64url')}`;
|
||||
}
|
||||
|
||||
async function writeTemporary(
|
||||
filePath: string,
|
||||
contents: Buffer,
|
||||
): Promise<string> {
|
||||
const temporary = `${filePath}.tmp-${randomBytes(12).toString('hex')}`;
|
||||
const handle = await fs.open(
|
||||
temporary,
|
||||
constants.O_CREAT |
|
||||
constants.O_EXCL |
|
||||
constants.O_WRONLY |
|
||||
(constants.O_NOFOLLOW ?? 0),
|
||||
0o600,
|
||||
);
|
||||
try {
|
||||
await handle.writeFile(contents);
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
return temporary;
|
||||
}
|
||||
|
||||
export class LocalSecretKeyringFileProvider implements LocalSecretKeyProvider {
|
||||
private readonly filePath: string;
|
||||
|
||||
constructor(filePath: string) {
|
||||
assertKeyringPath(filePath);
|
||||
this.filePath = path.resolve(filePath);
|
||||
}
|
||||
|
||||
async active(): Promise<LocalSecretKeyMaterial> {
|
||||
const manifest = await readManifest(this.filePath);
|
||||
return this.material(manifest, manifest.activeKeyId)!;
|
||||
}
|
||||
|
||||
async resolve(keyId: string): Promise<LocalSecretKeyMaterial | null> {
|
||||
try {
|
||||
assertLocalSecretKeyId(keyId);
|
||||
} catch {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
return this.material(await readManifest(this.filePath), keyId);
|
||||
}
|
||||
|
||||
async inspect(): Promise<LocalSecretKeyringSummary> {
|
||||
return summary(await readManifest(this.filePath));
|
||||
}
|
||||
|
||||
private material(
|
||||
manifest: LocalSecretKeyringManifest,
|
||||
keyId: string,
|
||||
): LocalSecretKeyMaterial | null {
|
||||
const encoded = manifest.keys[keyId];
|
||||
return encoded
|
||||
? Object.freeze({
|
||||
keyId,
|
||||
key: Uint8Array.from(Buffer.from(encoded, 'base64url')),
|
||||
})
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function provisionLocalSecretKeyring(
|
||||
filePath: string,
|
||||
): Promise<LocalSecretKeyringSummary> {
|
||||
assertKeyringPath(filePath);
|
||||
const resolved = path.resolve(filePath);
|
||||
await assertRealParent(resolved);
|
||||
const keyId = newKeyId();
|
||||
const key = randomBytes(32);
|
||||
const manifest: LocalSecretKeyringManifest = Object.freeze({
|
||||
version: 1,
|
||||
activeKeyId: keyId,
|
||||
keys: Object.freeze({ [keyId]: key.toString('base64url') }),
|
||||
});
|
||||
const contents = canonicalManifest(manifest);
|
||||
let temporary: string | undefined;
|
||||
try {
|
||||
temporary = await writeTemporary(resolved, contents);
|
||||
await fs.link(temporary, resolved);
|
||||
await fs.unlink(temporary);
|
||||
temporary = undefined;
|
||||
await syncDirectory(path.dirname(resolved));
|
||||
return summary(manifest);
|
||||
} catch {
|
||||
throw new LocalSecretUnavailableError();
|
||||
} finally {
|
||||
key.fill(0);
|
||||
contents.fill(0);
|
||||
if (temporary) await fs.unlink(temporary).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export async function rotateLocalSecretKeyring(options: {
|
||||
readonly filePath: string;
|
||||
readonly expectedActiveKeyId: string;
|
||||
}): Promise<LocalSecretKeyringSummary> {
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw new TypeError('Local Secret keyring rotation options are invalid');
|
||||
}
|
||||
assertKeyringPath(options.filePath);
|
||||
try {
|
||||
assertLocalSecretKeyId(options.expectedActiveKeyId);
|
||||
} catch {
|
||||
throw new TypeError('Local Secret expected active key is invalid');
|
||||
}
|
||||
const resolved = path.resolve(options.filePath);
|
||||
await assertRealParent(resolved);
|
||||
const lockPath = `${resolved}.lock`;
|
||||
let lock: fs.FileHandle | undefined;
|
||||
let temporary: string | undefined;
|
||||
let key: Buffer | undefined;
|
||||
let contents: Buffer | undefined;
|
||||
try {
|
||||
lock = await fs.open(
|
||||
lockPath,
|
||||
constants.O_CREAT |
|
||||
constants.O_EXCL |
|
||||
constants.O_WRONLY |
|
||||
(constants.O_NOFOLLOW ?? 0),
|
||||
0o600,
|
||||
);
|
||||
await lock.sync();
|
||||
const current = await readManifest(resolved);
|
||||
if (current.activeKeyId !== options.expectedActiveKeyId) {
|
||||
throw new LocalSecretKeyringConflictError();
|
||||
}
|
||||
if (Object.keys(current.keys).length >= MAX_KEY_COUNT) {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
const keyId = newKeyId();
|
||||
key = randomBytes(32);
|
||||
const next: LocalSecretKeyringManifest = Object.freeze({
|
||||
version: 1,
|
||||
activeKeyId: keyId,
|
||||
keys: Object.freeze({
|
||||
...current.keys,
|
||||
[keyId]: key.toString('base64url'),
|
||||
}),
|
||||
});
|
||||
contents = canonicalManifest(next);
|
||||
temporary = await writeTemporary(resolved, contents);
|
||||
await fs.rename(temporary, resolved);
|
||||
temporary = undefined;
|
||||
await syncDirectory(path.dirname(resolved));
|
||||
return summary(next);
|
||||
} catch (error) {
|
||||
if (error instanceof LocalSecretKeyringConflictError) throw error;
|
||||
throw new LocalSecretUnavailableError();
|
||||
} finally {
|
||||
key?.fill(0);
|
||||
contents?.fill(0);
|
||||
if (temporary) await fs.unlink(temporary).catch(() => undefined);
|
||||
await lock?.close().catch(() => undefined);
|
||||
if (lock) await fs.unlink(lockPath).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import { normalizeLocalDispatchCandidate } from '@qinglong/runtime-core/local-dispatch';
|
||||
import {
|
||||
LOCAL_SECRET_ALGORITHM,
|
||||
MAX_LOCAL_SECRET_BATCH_SIZE,
|
||||
LocalSecretMutationConflictError,
|
||||
LocalSecretUnavailableError,
|
||||
LocalSecretVersionConflictError,
|
||||
assertLocalSecretExpectedVersion,
|
||||
assertLocalSecretMutationId,
|
||||
assertLocalSecretName,
|
||||
assertLocalSecretPlaintext,
|
||||
assertLocalSecretProjectId,
|
||||
createLocalSecretRef,
|
||||
parseLocalSecretRef,
|
||||
type LocalSecretEnvelope,
|
||||
type LocalSecretEnvelopeRepository,
|
||||
type LocalSecretEnvironmentProvider,
|
||||
type LocalSecretKeyProvider,
|
||||
type PutEncryptedLocalSecretCommand,
|
||||
type PutEncryptedLocalSecretResult,
|
||||
} from '@qinglong/runtime-core/local-secret';
|
||||
import {
|
||||
decodeLocalSecretPlaintext,
|
||||
decryptLocalSecretEnvelopeToBuffer,
|
||||
encryptLocalSecretEnvelope,
|
||||
type LocalSecretNonceFactory,
|
||||
} from './crypto';
|
||||
import {
|
||||
localSecretPlaintextMatches,
|
||||
ownedLocalSecretKeyMaterial,
|
||||
} from './keyMaterial';
|
||||
|
||||
function exactKeys(value: object, expected: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort();
|
||||
const sortedExpected = [...expected].sort();
|
||||
return (
|
||||
actual.length === sortedExpected.length &&
|
||||
actual.every((key, index) => key === sortedExpected[index])
|
||||
);
|
||||
}
|
||||
|
||||
function assertPutCommand(command: PutEncryptedLocalSecretCommand): void {
|
||||
if (
|
||||
!command ||
|
||||
typeof command !== 'object' ||
|
||||
Array.isArray(command) ||
|
||||
!exactKeys(command, [
|
||||
'projectId',
|
||||
'name',
|
||||
'plaintext',
|
||||
'mutationId',
|
||||
'expectedCurrentVersion',
|
||||
'createdAtMs',
|
||||
])
|
||||
) {
|
||||
throw new TypeError('Local Secret write command is invalid');
|
||||
}
|
||||
assertLocalSecretProjectId(command.projectId);
|
||||
assertLocalSecretName(command.name);
|
||||
assertLocalSecretPlaintext(command.plaintext);
|
||||
assertLocalSecretMutationId(command.mutationId);
|
||||
assertLocalSecretExpectedVersion(command.expectedCurrentVersion);
|
||||
if (!Number.isSafeInteger(command.createdAtMs) || command.createdAtMs < 0) {
|
||||
throw new TypeError('Local Secret creation time is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
export interface LocalProjectSecretMaterialRequest {
|
||||
readonly projectId: string;
|
||||
readonly secretRef: string;
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface LocalProjectSecretMaterial {
|
||||
readonly secretRef: string;
|
||||
readonly bytes: Uint8Array;
|
||||
dispose(): void | Promise<void>;
|
||||
}
|
||||
|
||||
export class EncryptedLocalSecretService
|
||||
implements LocalSecretEnvironmentProvider
|
||||
{
|
||||
constructor(
|
||||
private readonly envelopes: LocalSecretEnvelopeRepository,
|
||||
private readonly keys: LocalSecretKeyProvider,
|
||||
private readonly nonceFactory?: LocalSecretNonceFactory,
|
||||
) {}
|
||||
|
||||
async put(
|
||||
command: PutEncryptedLocalSecretCommand,
|
||||
): Promise<PutEncryptedLocalSecretResult> {
|
||||
assertPutCommand(command);
|
||||
try {
|
||||
return await this.putValidated(command);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof LocalSecretVersionConflictError ||
|
||||
error instanceof LocalSecretMutationConflictError ||
|
||||
error instanceof LocalSecretUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
private async putValidated(
|
||||
command: PutEncryptedLocalSecretCommand,
|
||||
): Promise<PutEncryptedLocalSecretResult> {
|
||||
const existing = await this.envelopes.findLocalSecretEnvelopeByMutation(
|
||||
command.projectId,
|
||||
command.name,
|
||||
command.mutationId,
|
||||
);
|
||||
if (existing) {
|
||||
const material = ownedLocalSecretKeyMaterial(
|
||||
await this.keys.resolve(existing.keyId),
|
||||
existing.keyId,
|
||||
);
|
||||
try {
|
||||
if (
|
||||
existing.version !== command.expectedCurrentVersion + 1 ||
|
||||
!localSecretPlaintextMatches(
|
||||
existing,
|
||||
material.key,
|
||||
command.plaintext,
|
||||
)
|
||||
) {
|
||||
throw new LocalSecretMutationConflictError();
|
||||
}
|
||||
} finally {
|
||||
material.key.fill(0);
|
||||
}
|
||||
return this.result('existing', existing);
|
||||
}
|
||||
|
||||
const material = ownedLocalSecretKeyMaterial(await this.keys.active());
|
||||
try {
|
||||
const envelope = encryptLocalSecretEnvelope(
|
||||
{
|
||||
projectId: command.projectId,
|
||||
name: command.name,
|
||||
version: command.expectedCurrentVersion + 1,
|
||||
mutationId: command.mutationId,
|
||||
keyId: material.keyId,
|
||||
algorithm: LOCAL_SECRET_ALGORITHM,
|
||||
createdAtMs: command.createdAtMs,
|
||||
},
|
||||
command.plaintext,
|
||||
material.key,
|
||||
this.nonceFactory,
|
||||
);
|
||||
const appended = await this.envelopes.appendLocalSecretEnvelope({
|
||||
envelope,
|
||||
expectedCurrentVersion: command.expectedCurrentVersion,
|
||||
});
|
||||
if (appended.status === 'existing') {
|
||||
const existingMaterial =
|
||||
appended.envelope.keyId === material.keyId
|
||||
? material
|
||||
: ownedLocalSecretKeyMaterial(
|
||||
await this.keys.resolve(appended.envelope.keyId),
|
||||
appended.envelope.keyId,
|
||||
);
|
||||
try {
|
||||
if (
|
||||
appended.envelope.version !== command.expectedCurrentVersion + 1 ||
|
||||
!localSecretPlaintextMatches(
|
||||
appended.envelope,
|
||||
existingMaterial.key,
|
||||
command.plaintext,
|
||||
)
|
||||
) {
|
||||
throw new LocalSecretMutationConflictError();
|
||||
}
|
||||
} finally {
|
||||
if (existingMaterial !== material) existingMaterial.key.fill(0);
|
||||
}
|
||||
}
|
||||
return this.result(appended.status, appended.envelope);
|
||||
} finally {
|
||||
material.key.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
async resolveLocalSecretEnvironment(request: {
|
||||
readonly candidate: Parameters<
|
||||
LocalSecretEnvironmentProvider['resolveLocalSecretEnvironment']
|
||||
>[0]['candidate'];
|
||||
readonly secretRefs: readonly string[];
|
||||
}): Promise<readonly string[] | null> {
|
||||
const cachedKeys = new Map<string, Buffer>();
|
||||
try {
|
||||
if (!request || typeof request !== 'object' || Array.isArray(request)) {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
const candidate = normalizeLocalDispatchCandidate(request.candidate);
|
||||
if (
|
||||
!Array.isArray(request.secretRefs) ||
|
||||
request.secretRefs.length > MAX_LOCAL_SECRET_BATCH_SIZE
|
||||
) {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
const references = request.secretRefs.map(parseLocalSecretRef);
|
||||
if (
|
||||
references.some(
|
||||
(reference) => reference.projectId !== candidate.projectId,
|
||||
)
|
||||
) {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
const envelopes = await this.envelopes.resolveLocalSecretEnvelopes(
|
||||
references,
|
||||
);
|
||||
if (
|
||||
envelopes.length !== references.length ||
|
||||
envelopes.some((item) => item === null)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const plaintext: string[] = [];
|
||||
for (const envelope of envelopes as readonly LocalSecretEnvelope[]) {
|
||||
let key = cachedKeys.get(envelope.keyId);
|
||||
if (!key) {
|
||||
const material = ownedLocalSecretKeyMaterial(
|
||||
await this.keys.resolve(envelope.keyId),
|
||||
envelope.keyId,
|
||||
);
|
||||
key = material.key;
|
||||
cachedKeys.set(envelope.keyId, key);
|
||||
}
|
||||
const bytes = decryptLocalSecretEnvelopeToBuffer(envelope, key);
|
||||
try {
|
||||
plaintext.push(decodeLocalSecretPlaintext(bytes));
|
||||
} finally {
|
||||
bytes.fill(0);
|
||||
}
|
||||
}
|
||||
return Object.freeze(plaintext);
|
||||
} catch (error) {
|
||||
if (error instanceof LocalSecretUnavailableError) throw error;
|
||||
throw new LocalSecretUnavailableError();
|
||||
} finally {
|
||||
for (const key of cachedKeys.values()) key.fill(0);
|
||||
cachedKeys.clear();
|
||||
}
|
||||
}
|
||||
|
||||
async resolveProjectSecretMaterial(
|
||||
request: Readonly<LocalProjectSecretMaterialRequest>,
|
||||
): Promise<Readonly<LocalProjectSecretMaterial> | null> {
|
||||
let key: Buffer | undefined;
|
||||
let plaintext: Buffer | undefined;
|
||||
try {
|
||||
if (
|
||||
!request ||
|
||||
typeof request !== 'object' ||
|
||||
Array.isArray(request) ||
|
||||
!exactKeys(
|
||||
request,
|
||||
request.signal === undefined
|
||||
? ['projectId', 'secretRef']
|
||||
: ['projectId', 'secretRef', 'signal'],
|
||||
) ||
|
||||
(request.signal !== undefined &&
|
||||
typeof request.signal.aborted !== 'boolean') ||
|
||||
request.signal?.aborted
|
||||
) {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
assertLocalSecretProjectId(request.projectId);
|
||||
const reference = parseLocalSecretRef(request.secretRef);
|
||||
if (reference.projectId !== request.projectId) {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
const [envelope] = await this.envelopes.resolveLocalSecretEnvelopes([
|
||||
reference,
|
||||
]);
|
||||
if (!envelope) return null;
|
||||
const material = ownedLocalSecretKeyMaterial(
|
||||
await this.keys.resolve(envelope.keyId),
|
||||
envelope.keyId,
|
||||
);
|
||||
key = material.key;
|
||||
plaintext = decryptLocalSecretEnvelopeToBuffer(envelope, key);
|
||||
const ownedPlaintext = plaintext;
|
||||
plaintext = undefined;
|
||||
let disposed = false;
|
||||
return Object.freeze({
|
||||
secretRef: request.secretRef,
|
||||
bytes: ownedPlaintext,
|
||||
dispose(): void {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
ownedPlaintext.fill(0);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
plaintext?.fill(0);
|
||||
if (error instanceof LocalSecretUnavailableError) throw error;
|
||||
throw new LocalSecretUnavailableError();
|
||||
} finally {
|
||||
key?.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
private result(
|
||||
status: PutEncryptedLocalSecretResult['status'],
|
||||
envelope: LocalSecretEnvelope,
|
||||
): PutEncryptedLocalSecretResult {
|
||||
return Object.freeze({
|
||||
status,
|
||||
version: envelope.version,
|
||||
secretRef: createLocalSecretRef({
|
||||
projectId: envelope.projectId,
|
||||
name: envelope.name,
|
||||
version: envelope.version,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
LocalSecretMutationConflictError,
|
||||
LocalSecretUnavailableError,
|
||||
LocalSecretVersionConflictError,
|
||||
createLocalSecretRef,
|
||||
parseLocalSecretRef,
|
||||
} = require('@qinglong/runtime-core/local-secret');
|
||||
const { migrateLocalSqlitePath } = require('@qinglong/local-sqlite/migration');
|
||||
const {
|
||||
openLocalSqliteRuntimeDatabase,
|
||||
} = require('@qinglong/local-sqlite/runtime');
|
||||
const {
|
||||
EncryptedLocalSecretService,
|
||||
LocalSecretKeyringConflictError,
|
||||
LocalSecretKeyringFileProvider,
|
||||
decryptLocalSecretEnvelopeToBuffer,
|
||||
encryptLocalSecretEnvelope,
|
||||
provisionLocalSecretKeyring,
|
||||
rotateLocalSecretKeyring,
|
||||
} = require('../dist');
|
||||
|
||||
const KEY = Buffer.alloc(32, 0x11);
|
||||
|
||||
function fixture(t, profile = 'edge') {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-secret-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
return {
|
||||
directory,
|
||||
profile,
|
||||
databasePath: path.join(directory, 'qinglong3.sqlite'),
|
||||
keyringPath: path.join(directory, 'secret-keyring.json'),
|
||||
};
|
||||
}
|
||||
|
||||
function candidate(projectId = 'default') {
|
||||
return {
|
||||
runId: 'run-secret',
|
||||
attemptId: 'attempt-secret',
|
||||
projectId,
|
||||
taskId: 'task-secret',
|
||||
taskRevision: 'revision-secret',
|
||||
attemptNumber: 1,
|
||||
executorType: 'local_process',
|
||||
priority: 0,
|
||||
queuedAtMs: 1_760_000_000_000,
|
||||
attemptCreatedAtMs: 1_760_000_000_000,
|
||||
};
|
||||
}
|
||||
|
||||
async function store(t, profile = 'edge') {
|
||||
const value = fixture(t, profile);
|
||||
await migrateLocalSqlitePath(value);
|
||||
await provisionLocalSecretKeyring(value.keyringPath);
|
||||
const runtime = await openLocalSqliteRuntimeDatabase(value);
|
||||
t.after(() => runtime.close());
|
||||
const keys = new LocalSecretKeyringFileProvider(value.keyringPath);
|
||||
return {
|
||||
...value,
|
||||
runtime,
|
||||
keys,
|
||||
service: new EncryptedLocalSecretService(runtime.localSecrets, keys),
|
||||
};
|
||||
}
|
||||
|
||||
test('uses canonical Project-bound refs and metadata-authenticated AES-GCM', () => {
|
||||
const ref = createLocalSecretRef({
|
||||
projectId: 'default',
|
||||
name: 'TOKEN',
|
||||
version: 2,
|
||||
});
|
||||
assert.deepEqual(parseLocalSecretRef(ref), {
|
||||
projectId: 'default',
|
||||
name: 'TOKEN',
|
||||
version: 2,
|
||||
});
|
||||
const unknown = Buffer.from(
|
||||
JSON.stringify({ projectId: 'default', name: 'TOKEN', extra: true }),
|
||||
).toString('base64url');
|
||||
assert.throws(() => parseLocalSecretRef(`qlsecret:v1:${unknown}`));
|
||||
|
||||
const envelope = encryptLocalSecretEnvelope(
|
||||
{
|
||||
projectId: 'default',
|
||||
name: 'TOKEN',
|
||||
version: 1,
|
||||
mutationId: 'create-token',
|
||||
keyId: 'edge-key-1',
|
||||
algorithm: 'aes-256-gcm',
|
||||
createdAtMs: 100,
|
||||
},
|
||||
'fixed-secret',
|
||||
KEY,
|
||||
() => Buffer.alloc(12, 0x22),
|
||||
);
|
||||
assert.deepEqual(
|
||||
{
|
||||
nonce: envelope.nonce,
|
||||
ciphertext: envelope.ciphertext,
|
||||
authTag: envelope.authTag,
|
||||
},
|
||||
{
|
||||
nonce: 'IiIiIiIiIiIiIiIi',
|
||||
ciphertext: 'cZ5_LKTi7DqGTbtI',
|
||||
authTag: '2af0_G7xHiOEBvMlsBSr0Q',
|
||||
},
|
||||
);
|
||||
const plaintext = decryptLocalSecretEnvelopeToBuffer(envelope, KEY);
|
||||
assert.equal(plaintext.toString('utf8'), 'fixed-secret');
|
||||
plaintext.fill(0);
|
||||
assert.throws(
|
||||
() =>
|
||||
decryptLocalSecretEnvelopeToBuffer(
|
||||
{ ...envelope, projectId: 'another-project' },
|
||||
KEY,
|
||||
),
|
||||
LocalSecretUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
test('provisions once, reloads active rotation and keeps historical keys', async (t) => {
|
||||
const value = fixture(t);
|
||||
const first = await provisionLocalSecretKeyring(value.keyringPath);
|
||||
assert.equal(first.keyIds.length, 1);
|
||||
assert.equal(fs.statSync(value.keyringPath).mode & 0o777, 0o600);
|
||||
await assert.rejects(
|
||||
provisionLocalSecretKeyring(value.keyringPath),
|
||||
LocalSecretUnavailableError,
|
||||
);
|
||||
|
||||
const provider = new LocalSecretKeyringFileProvider(value.keyringPath);
|
||||
const firstMaterial = await provider.active();
|
||||
assert.equal(firstMaterial.keyId, first.activeKeyId);
|
||||
firstMaterial.key.fill(0);
|
||||
const second = await rotateLocalSecretKeyring({
|
||||
filePath: value.keyringPath,
|
||||
expectedActiveKeyId: first.activeKeyId,
|
||||
});
|
||||
assert.equal(second.keyIds.length, 2);
|
||||
assert.notEqual(second.activeKeyId, first.activeKeyId);
|
||||
const activeMaterial = await provider.active();
|
||||
assert.equal(activeMaterial.keyId, second.activeKeyId);
|
||||
activeMaterial.key.fill(0);
|
||||
const historicalMaterial = await provider.resolve(first.activeKeyId);
|
||||
assert.equal(historicalMaterial.key.length, 32);
|
||||
historicalMaterial.key.fill(0);
|
||||
await assert.rejects(
|
||||
rotateLocalSecretKeyring({
|
||||
filePath: value.keyringPath,
|
||||
expectedActiveKeyId: first.activeKeyId,
|
||||
}),
|
||||
LocalSecretKeyringConflictError,
|
||||
);
|
||||
|
||||
fs.chmodSync(value.keyringPath, 0o644);
|
||||
await assert.rejects(provider.active(), LocalSecretUnavailableError);
|
||||
});
|
||||
|
||||
test('rejects keyring symlinks and preserves an existing rotation lock', async (t) => {
|
||||
const value = fixture(t);
|
||||
const first = await provisionLocalSecretKeyring(value.keyringPath);
|
||||
const symlinkPath = path.join(value.directory, 'secret-keyring-link.json');
|
||||
fs.symlinkSync(value.keyringPath, symlinkPath);
|
||||
const symlinkProvider = new LocalSecretKeyringFileProvider(symlinkPath);
|
||||
await assert.rejects(symlinkProvider.active(), LocalSecretUnavailableError);
|
||||
await assert.rejects(
|
||||
rotateLocalSecretKeyring({
|
||||
filePath: symlinkPath,
|
||||
expectedActiveKeyId: first.activeKeyId,
|
||||
}),
|
||||
LocalSecretUnavailableError,
|
||||
);
|
||||
|
||||
const lockPath = `${value.keyringPath}.lock`;
|
||||
fs.writeFileSync(lockPath, 'external-manager\n', { mode: 0o600, flag: 'wx' });
|
||||
await assert.rejects(
|
||||
rotateLocalSecretKeyring({
|
||||
filePath: value.keyringPath,
|
||||
expectedActiveKeyId: first.activeKeyId,
|
||||
}),
|
||||
LocalSecretUnavailableError,
|
||||
);
|
||||
assert.equal(fs.readFileSync(lockPath, 'utf8'), 'external-manager\n');
|
||||
assert.equal(
|
||||
(await new LocalSecretKeyringFileProvider(value.keyringPath).inspect())
|
||||
.activeKeyId,
|
||||
first.activeKeyId,
|
||||
);
|
||||
});
|
||||
|
||||
test('creates and rotates append-only ciphertext through the Node 24 SQLite authority', async (t) => {
|
||||
const value = await store(t);
|
||||
const firstPlaintext = 'first-plaintext-never-persisted';
|
||||
const secondPlaintext = 'second-plaintext-never-persisted';
|
||||
const first = await value.service.put({
|
||||
projectId: 'default',
|
||||
name: 'TOKEN',
|
||||
plaintext: firstPlaintext,
|
||||
mutationId: 'create-token',
|
||||
expectedCurrentVersion: 0,
|
||||
createdAtMs: 100,
|
||||
});
|
||||
const rotatedKeyring = await rotateLocalSecretKeyring({
|
||||
filePath: value.keyringPath,
|
||||
expectedActiveKeyId: (await value.keys.inspect()).activeKeyId,
|
||||
});
|
||||
const second = await value.service.put({
|
||||
projectId: 'default',
|
||||
name: 'TOKEN',
|
||||
plaintext: secondPlaintext,
|
||||
mutationId: 'rotate-token',
|
||||
expectedCurrentVersion: 1,
|
||||
createdAtMs: 200,
|
||||
});
|
||||
assert.equal(first.version, 1);
|
||||
assert.equal(second.version, 2);
|
||||
assert.equal(
|
||||
(await value.keys.inspect()).activeKeyId,
|
||||
rotatedKeyring.activeKeyId,
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
await value.service.resolveLocalSecretEnvironment({
|
||||
candidate: candidate(),
|
||||
secretRefs: [
|
||||
createLocalSecretRef({ projectId: 'default', name: 'TOKEN' }),
|
||||
first.secretRef,
|
||||
second.secretRef,
|
||||
],
|
||||
}),
|
||||
[secondPlaintext, firstPlaintext, secondPlaintext],
|
||||
);
|
||||
const material = await value.service.resolveProjectSecretMaterial({
|
||||
projectId: 'default',
|
||||
secretRef: createLocalSecretRef({
|
||||
projectId: 'default',
|
||||
name: 'TOKEN',
|
||||
}),
|
||||
});
|
||||
assert.ok(material);
|
||||
assert.equal(material.secretRef.includes(secondPlaintext), false);
|
||||
assert.equal(Buffer.from(material.bytes).toString('utf8'), secondPlaintext);
|
||||
const ownedBytes = material.bytes;
|
||||
await material.dispose();
|
||||
assert.deepEqual([...ownedBytes], new Array(ownedBytes.length).fill(0));
|
||||
await material.dispose();
|
||||
|
||||
const database = new DatabaseSync(value.databasePath, { readOnly: true });
|
||||
try {
|
||||
const rows = database
|
||||
.prepare(
|
||||
`SELECT ciphertext, key_id FROM "QingLong3LocalSecretEnvelopes"
|
||||
ORDER BY version`,
|
||||
)
|
||||
.all();
|
||||
assert.equal(rows.length, 2);
|
||||
assert.notEqual(rows[0].key_id, rows[1].key_id);
|
||||
assert.equal(JSON.stringify(rows).includes(firstPlaintext), false);
|
||||
assert.equal(JSON.stringify(rows).includes(secondPlaintext), false);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('replays semantic mutations, fences stale versions and checks Project before storage', async (t) => {
|
||||
const value = await store(t, 'standalone');
|
||||
const command = {
|
||||
projectId: 'default',
|
||||
name: 'TOKEN',
|
||||
plaintext: 'same-value',
|
||||
mutationId: 'create-token',
|
||||
expectedCurrentVersion: 0,
|
||||
createdAtMs: 100,
|
||||
};
|
||||
assert.equal((await value.service.put(command)).status, 'inserted');
|
||||
assert.equal(
|
||||
(await value.service.put({ ...command, createdAtMs: 999 })).status,
|
||||
'existing',
|
||||
);
|
||||
await assert.rejects(
|
||||
value.service.put({ ...command, plaintext: 'different-value' }),
|
||||
LocalSecretMutationConflictError,
|
||||
);
|
||||
await assert.rejects(
|
||||
value.service.put({ ...command, mutationId: 'stale-create' }),
|
||||
LocalSecretVersionConflictError,
|
||||
);
|
||||
await assert.rejects(
|
||||
value.service.resolveLocalSecretEnvironment({
|
||||
candidate: candidate('default'),
|
||||
secretRefs: [
|
||||
createLocalSecretRef({ projectId: 'another', name: 'TOKEN' }),
|
||||
],
|
||||
}),
|
||||
LocalSecretUnavailableError,
|
||||
);
|
||||
await assert.rejects(
|
||||
value.service.resolveProjectSecretMaterial({
|
||||
projectId: 'default',
|
||||
secretRef: createLocalSecretRef({
|
||||
projectId: 'another',
|
||||
name: 'TOKEN',
|
||||
}),
|
||||
}),
|
||||
LocalSecretUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
test('two SQLite authorities allow exactly one rotation for one expected version', async (t) => {
|
||||
const value = fixture(t, 'standalone');
|
||||
await migrateLocalSqlitePath(value);
|
||||
await provisionLocalSecretKeyring(value.keyringPath);
|
||||
const firstRuntime = await openLocalSqliteRuntimeDatabase(value);
|
||||
const secondRuntime = await openLocalSqliteRuntimeDatabase(value);
|
||||
t.after(() => Promise.all([firstRuntime.close(), secondRuntime.close()]));
|
||||
const keys = new LocalSecretKeyringFileProvider(value.keyringPath);
|
||||
const first = new EncryptedLocalSecretService(
|
||||
firstRuntime.localSecrets,
|
||||
keys,
|
||||
);
|
||||
const second = new EncryptedLocalSecretService(
|
||||
secondRuntime.localSecrets,
|
||||
keys,
|
||||
);
|
||||
await first.put({
|
||||
projectId: 'default',
|
||||
name: 'TOKEN',
|
||||
plaintext: 'initial',
|
||||
mutationId: 'initial',
|
||||
expectedCurrentVersion: 0,
|
||||
createdAtMs: 1,
|
||||
});
|
||||
const results = await Promise.allSettled([
|
||||
first.put({
|
||||
projectId: 'default',
|
||||
name: 'TOKEN',
|
||||
plaintext: 'winner-a',
|
||||
mutationId: 'rotate-a',
|
||||
expectedCurrentVersion: 1,
|
||||
createdAtMs: 2,
|
||||
}),
|
||||
second.put({
|
||||
projectId: 'default',
|
||||
name: 'TOKEN',
|
||||
plaintext: 'winner-b',
|
||||
mutationId: 'rotate-b',
|
||||
expectedCurrentVersion: 1,
|
||||
createdAtMs: 2,
|
||||
}),
|
||||
]);
|
||||
assert.equal(
|
||||
results.filter(({ status }) => status === 'fulfilled').length,
|
||||
1,
|
||||
);
|
||||
assert.equal(results.filter(({ status }) => status === 'rejected').length, 1);
|
||||
assert.equal(
|
||||
results.find(({ status }) => status === 'rejected').reason.constructor,
|
||||
LocalSecretVersionConflictError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"],
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": false
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user