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,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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user