mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
271 lines
7.9 KiB
TypeScript
271 lines
7.9 KiB
TypeScript
import { MAX_EXECUTION_ENVIRONMENT_VALUE_BYTES } from './executionContext';
|
|
|
|
export const LOCAL_SECRET_ALGORITHM = 'aes-256-gcm';
|
|
export const MAX_LOCAL_SECRET_NAME_LENGTH = 128;
|
|
export const MAX_LOCAL_SECRET_VERSION = 2_147_483_647;
|
|
export const MAX_LOCAL_SECRET_REF_LENGTH = 512;
|
|
export const MAX_LOCAL_SECRET_MUTATION_ID_LENGTH = 64;
|
|
export const MAX_LOCAL_SECRET_KEY_ID_LENGTH = 128;
|
|
|
|
const SECRET_REF_PREFIX = 'qlsecret:v1:';
|
|
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
const KEY_ID_PATTERN = /^[A-Za-z0-9._-]+$/;
|
|
|
|
export interface LocalSecretReference {
|
|
projectId: string;
|
|
name: string;
|
|
version?: number;
|
|
}
|
|
|
|
export interface LocalSecretEnvelope {
|
|
projectId: string;
|
|
name: string;
|
|
version: number;
|
|
mutationId: string;
|
|
keyId: string;
|
|
algorithm: typeof LOCAL_SECRET_ALGORITHM;
|
|
nonce: string;
|
|
ciphertext: string;
|
|
authTag: string;
|
|
createdAtMs: number;
|
|
}
|
|
|
|
export class InvalidLocalSecretError extends TypeError {
|
|
constructor(message: string) {
|
|
super(`Local Secret value is invalid: ${message}`);
|
|
this.name = 'InvalidLocalSecretError';
|
|
}
|
|
}
|
|
|
|
export class LocalSecretUnavailableError extends Error {
|
|
readonly code = 'LOCAL_SECRET_UNAVAILABLE';
|
|
|
|
constructor() {
|
|
super('Local Secret is unavailable');
|
|
this.name = 'LocalSecretUnavailableError';
|
|
}
|
|
}
|
|
|
|
export class LocalSecretVersionConflictError extends Error {
|
|
readonly code = 'LOCAL_SECRET_VERSION_CONFLICT';
|
|
|
|
constructor() {
|
|
super('Local Secret current version changed');
|
|
this.name = 'LocalSecretVersionConflictError';
|
|
}
|
|
}
|
|
|
|
export class LocalSecretMutationConflictError extends Error {
|
|
readonly code = 'LOCAL_SECRET_MUTATION_CONFLICT';
|
|
|
|
constructor() {
|
|
super('Local Secret mutation does not match its previous request');
|
|
this.name = 'LocalSecretMutationConflictError';
|
|
}
|
|
}
|
|
|
|
function assertIdentifier(name: string, value: string, maximum: number): void {
|
|
if (
|
|
typeof value !== 'string' ||
|
|
value.length < 1 ||
|
|
value.length > maximum ||
|
|
/[\u0000-\u001f\u007f]/.test(value)
|
|
) {
|
|
throw new InvalidLocalSecretError(`${name} is invalid`);
|
|
}
|
|
}
|
|
|
|
export function assertLocalSecretProjectId(value: string): void {
|
|
assertIdentifier('projectId', value, 128);
|
|
}
|
|
|
|
export function assertLocalSecretName(value: string): void {
|
|
assertIdentifier('name', value, MAX_LOCAL_SECRET_NAME_LENGTH);
|
|
}
|
|
|
|
export function assertLocalSecretVersion(value: number): void {
|
|
if (
|
|
!Number.isSafeInteger(value) ||
|
|
value < 1 ||
|
|
value > MAX_LOCAL_SECRET_VERSION
|
|
) {
|
|
throw new InvalidLocalSecretError('version is invalid');
|
|
}
|
|
}
|
|
|
|
export function assertLocalSecretMutationId(value: string): void {
|
|
assertIdentifier('mutationId', value, MAX_LOCAL_SECRET_MUTATION_ID_LENGTH);
|
|
}
|
|
|
|
export function assertLocalSecretKeyId(value: string): void {
|
|
if (
|
|
typeof value !== 'string' ||
|
|
value.length < 1 ||
|
|
value.length > MAX_LOCAL_SECRET_KEY_ID_LENGTH ||
|
|
!KEY_ID_PATTERN.test(value)
|
|
) {
|
|
throw new InvalidLocalSecretError('keyId is invalid');
|
|
}
|
|
}
|
|
|
|
export function assertLocalSecretPlaintext(value: string): void {
|
|
if (
|
|
typeof value !== 'string' ||
|
|
value.includes('\0') ||
|
|
Buffer.byteLength(value, 'utf8') > MAX_EXECUTION_ENVIRONMENT_VALUE_BYTES
|
|
) {
|
|
throw new InvalidLocalSecretError('plaintext is invalid');
|
|
}
|
|
}
|
|
|
|
function decodeBase64Url(name: string, value: string, bytes?: number): Buffer {
|
|
if (
|
|
typeof value !== 'string' ||
|
|
(value.length > 0 && !BASE64URL_PATTERN.test(value))
|
|
) {
|
|
throw new InvalidLocalSecretError(`${name} is invalid`);
|
|
}
|
|
const decoded = Buffer.from(value, 'base64url');
|
|
if (
|
|
decoded.toString('base64url') !== value ||
|
|
(bytes && decoded.length !== bytes)
|
|
) {
|
|
throw new InvalidLocalSecretError(`${name} is invalid`);
|
|
}
|
|
return decoded;
|
|
}
|
|
|
|
export function localSecretBinary(
|
|
name: 'nonce' | 'ciphertext' | 'authTag',
|
|
value: string,
|
|
): Buffer {
|
|
const decoded = decodeBase64Url(
|
|
name,
|
|
value,
|
|
name === 'nonce' ? 12 : name === 'authTag' ? 16 : undefined,
|
|
);
|
|
if (
|
|
name === 'ciphertext' &&
|
|
decoded.length > MAX_EXECUTION_ENVIRONMENT_VALUE_BYTES
|
|
) {
|
|
throw new InvalidLocalSecretError('ciphertext is too large');
|
|
}
|
|
return decoded;
|
|
}
|
|
|
|
export function createLocalSecretRef(reference: LocalSecretReference): string {
|
|
assertLocalSecretProjectId(reference.projectId);
|
|
assertLocalSecretName(reference.name);
|
|
if (reference.version !== undefined) {
|
|
assertLocalSecretVersion(reference.version);
|
|
}
|
|
const payload = JSON.stringify({
|
|
projectId: reference.projectId,
|
|
name: reference.name,
|
|
...(reference.version === undefined ? {} : { version: reference.version }),
|
|
});
|
|
const value =
|
|
SECRET_REF_PREFIX + Buffer.from(payload, 'utf8').toString('base64url');
|
|
if (value.length > MAX_LOCAL_SECRET_REF_LENGTH) {
|
|
throw new InvalidLocalSecretError('reference is too large');
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function parseLocalSecretRef(value: string): LocalSecretReference {
|
|
if (
|
|
typeof value !== 'string' ||
|
|
value.length > MAX_LOCAL_SECRET_REF_LENGTH ||
|
|
!value.startsWith(SECRET_REF_PREFIX)
|
|
) {
|
|
throw new InvalidLocalSecretError('reference is invalid');
|
|
}
|
|
const encoded = value.slice(SECRET_REF_PREFIX.length);
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(decodeBase64Url('reference', encoded).toString('utf8'));
|
|
} catch (error) {
|
|
if (error instanceof InvalidLocalSecretError) throw error;
|
|
throw new InvalidLocalSecretError('reference is invalid');
|
|
}
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
throw new InvalidLocalSecretError('reference is invalid');
|
|
}
|
|
const record = parsed as Record<string, unknown>;
|
|
const keys = Object.keys(record).sort();
|
|
const expectedKeys =
|
|
record.version === undefined
|
|
? ['name', 'projectId']
|
|
: ['name', 'projectId', 'version'];
|
|
if (
|
|
keys.length !== expectedKeys.length ||
|
|
keys.some((key, index) => key !== expectedKeys[index])
|
|
) {
|
|
throw new InvalidLocalSecretError('reference is invalid');
|
|
}
|
|
const reference: LocalSecretReference = {
|
|
projectId: record.projectId as string,
|
|
name: record.name as string,
|
|
...(record.version === undefined
|
|
? {}
|
|
: { version: record.version as number }),
|
|
};
|
|
if (createLocalSecretRef(reference) !== value) {
|
|
throw new InvalidLocalSecretError('reference is not canonical');
|
|
}
|
|
return Object.freeze(reference);
|
|
}
|
|
|
|
export function normalizeLocalSecretEnvelope(
|
|
envelope: LocalSecretEnvelope,
|
|
): LocalSecretEnvelope {
|
|
if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) {
|
|
throw new InvalidLocalSecretError('envelope must be an object');
|
|
}
|
|
assertLocalSecretProjectId(envelope.projectId);
|
|
assertLocalSecretName(envelope.name);
|
|
assertLocalSecretVersion(envelope.version);
|
|
assertLocalSecretMutationId(envelope.mutationId);
|
|
assertLocalSecretKeyId(envelope.keyId);
|
|
if (envelope.algorithm !== LOCAL_SECRET_ALGORITHM) {
|
|
throw new InvalidLocalSecretError('algorithm is invalid');
|
|
}
|
|
localSecretBinary('nonce', envelope.nonce);
|
|
localSecretBinary('ciphertext', envelope.ciphertext);
|
|
localSecretBinary('authTag', envelope.authTag);
|
|
if (!Number.isSafeInteger(envelope.createdAtMs) || envelope.createdAtMs < 0) {
|
|
throw new InvalidLocalSecretError('createdAtMs is invalid');
|
|
}
|
|
return Object.freeze({
|
|
projectId: envelope.projectId,
|
|
name: envelope.name,
|
|
version: envelope.version,
|
|
mutationId: envelope.mutationId,
|
|
keyId: envelope.keyId,
|
|
algorithm: LOCAL_SECRET_ALGORITHM,
|
|
nonce: envelope.nonce,
|
|
ciphertext: envelope.ciphertext,
|
|
authTag: envelope.authTag,
|
|
createdAtMs: envelope.createdAtMs,
|
|
});
|
|
}
|
|
|
|
export function localSecretEnvelopeAad(
|
|
envelope: Pick<
|
|
LocalSecretEnvelope,
|
|
'projectId' | 'name' | 'version' | 'mutationId' | 'keyId' | 'algorithm'
|
|
>,
|
|
): Buffer {
|
|
return Buffer.from(
|
|
JSON.stringify({
|
|
projectId: envelope.projectId,
|
|
name: envelope.name,
|
|
version: envelope.version,
|
|
mutationId: envelope.mutationId,
|
|
keyId: envelope.keyId,
|
|
algorithm: envelope.algorithm,
|
|
}),
|
|
'utf8',
|
|
);
|
|
}
|