mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 02:27:44 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
import type { LocalDispatchCandidate } from '../local-runtime/localDispatch';
|
||||
import {
|
||||
MAX_SECRET_REF_BYTES,
|
||||
createSecretRef,
|
||||
parseSecretRef,
|
||||
type SecretReference,
|
||||
} from './secretReference';
|
||||
|
||||
export const LOCAL_SECRET_ALGORITHM = 'aes-256-gcm';
|
||||
export const MAX_LOCAL_SECRET_NAME_BYTES = 128;
|
||||
export const MAX_LOCAL_SECRET_VERSION = 2_147_483_647;
|
||||
export const MAX_LOCAL_SECRET_REF_BYTES = MAX_SECRET_REF_BYTES;
|
||||
export const MAX_LOCAL_SECRET_MUTATION_ID_BYTES = 64;
|
||||
export const MAX_LOCAL_SECRET_KEY_ID_BYTES = 128;
|
||||
export const MAX_LOCAL_SECRET_PLAINTEXT_BYTES = 16 * 1024;
|
||||
export const MAX_LOCAL_SECRET_BATCH_SIZE = 64;
|
||||
|
||||
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/;
|
||||
const KEY_ID_PATTERN = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
/** @deprecated Use the profile-neutral SecretReference contract. */
|
||||
export type LocalSecretReference = SecretReference;
|
||||
|
||||
export interface LocalSecretEnvelope {
|
||||
readonly projectId: string;
|
||||
readonly name: string;
|
||||
readonly version: number;
|
||||
readonly mutationId: string;
|
||||
readonly keyId: string;
|
||||
readonly algorithm: typeof LOCAL_SECRET_ALGORITHM;
|
||||
readonly nonce: string;
|
||||
readonly ciphertext: string;
|
||||
readonly authTag: string;
|
||||
readonly createdAtMs: number;
|
||||
}
|
||||
|
||||
export interface AppendLocalSecretEnvelopeCommand {
|
||||
readonly envelope: LocalSecretEnvelope;
|
||||
readonly expectedCurrentVersion: number;
|
||||
}
|
||||
|
||||
export type AppendLocalSecretEnvelopeResult = Readonly<{
|
||||
status: 'inserted' | 'existing';
|
||||
envelope: LocalSecretEnvelope;
|
||||
}>;
|
||||
|
||||
export interface LocalSecretEnvelopeRepository {
|
||||
appendLocalSecretEnvelope(
|
||||
command: AppendLocalSecretEnvelopeCommand,
|
||||
): Promise<AppendLocalSecretEnvelopeResult>;
|
||||
findLocalSecretEnvelopeByMutation(
|
||||
projectId: string,
|
||||
name: string,
|
||||
mutationId: string,
|
||||
): Promise<LocalSecretEnvelope | null>;
|
||||
resolveLocalSecretEnvelopes(
|
||||
references: readonly LocalSecretReference[],
|
||||
): Promise<readonly (LocalSecretEnvelope | null)[]>;
|
||||
}
|
||||
|
||||
export interface LocalSecretKeyMaterial {
|
||||
readonly keyId: string;
|
||||
/** Exactly 32 bytes. The consumer owns this copy and must wipe it. */
|
||||
readonly key: Uint8Array;
|
||||
}
|
||||
|
||||
export interface LocalSecretKeyProvider {
|
||||
active(): Promise<LocalSecretKeyMaterial>;
|
||||
resolve(keyId: string): Promise<LocalSecretKeyMaterial | null>;
|
||||
}
|
||||
|
||||
export interface LocalSecretEnvironmentProvider {
|
||||
resolveLocalSecretEnvironment(request: {
|
||||
readonly candidate: LocalDispatchCandidate;
|
||||
readonly secretRefs: readonly string[];
|
||||
}): Promise<readonly string[] | null>;
|
||||
}
|
||||
|
||||
export interface PutEncryptedLocalSecretCommand {
|
||||
readonly projectId: string;
|
||||
readonly name: string;
|
||||
readonly plaintext: string;
|
||||
readonly mutationId: string;
|
||||
readonly expectedCurrentVersion: number;
|
||||
readonly createdAtMs: number;
|
||||
}
|
||||
|
||||
export interface PutEncryptedLocalSecretResult {
|
||||
readonly status: 'inserted' | 'existing';
|
||||
readonly version: number;
|
||||
readonly secretRef: string;
|
||||
}
|
||||
|
||||
export class InvalidLocalSecretError extends TypeError {
|
||||
readonly code = 'LOCAL_SECRET_INVALID';
|
||||
|
||||
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 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 assertIdentifier(
|
||||
name: string,
|
||||
value: unknown,
|
||||
maximumBytes: number,
|
||||
): asserts value is string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length === 0 ||
|
||||
Buffer.byteLength(value, 'utf8') > maximumBytes ||
|
||||
/[\u0000-\u001f\u007f]/.test(value)
|
||||
) {
|
||||
throw new InvalidLocalSecretError(`${name} is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertLocalSecretProjectId(value: unknown): asserts value is string {
|
||||
assertIdentifier('projectId', value, 128);
|
||||
}
|
||||
|
||||
export function assertLocalSecretName(value: unknown): asserts value is string {
|
||||
assertIdentifier('name', value, MAX_LOCAL_SECRET_NAME_BYTES);
|
||||
}
|
||||
|
||||
export function assertLocalSecretVersion(value: unknown): asserts value is number {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
(value as number) < 1 ||
|
||||
(value as number) > MAX_LOCAL_SECRET_VERSION
|
||||
) {
|
||||
throw new InvalidLocalSecretError('version is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
export function assertLocalSecretMutationId(value: unknown): asserts value is string {
|
||||
assertIdentifier('mutationId', value, MAX_LOCAL_SECRET_MUTATION_ID_BYTES);
|
||||
}
|
||||
|
||||
export function assertLocalSecretKeyId(value: unknown): asserts value is string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length === 0 ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_LOCAL_SECRET_KEY_ID_BYTES ||
|
||||
!KEY_ID_PATTERN.test(value)
|
||||
) {
|
||||
throw new InvalidLocalSecretError('keyId is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
export function assertLocalSecretPlaintext(value: unknown): asserts value is string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_LOCAL_SECRET_PLAINTEXT_BYTES
|
||||
) {
|
||||
throw new InvalidLocalSecretError('plaintext is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
export function assertLocalSecretExpectedVersion(value: unknown): asserts value is number {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
(value as number) < 0 ||
|
||||
(value as number) >= MAX_LOCAL_SECRET_VERSION
|
||||
) {
|
||||
throw new InvalidLocalSecretError('expectedCurrentVersion is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function decodeBase64Url(name: string, value: unknown, 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 !== undefined && decoded.length !== bytes)
|
||||
) {
|
||||
decoded.fill(0);
|
||||
throw new InvalidLocalSecretError(`${name} is invalid`);
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
export function localSecretBinary(
|
||||
name: 'nonce' | 'ciphertext' | 'authTag',
|
||||
value: unknown,
|
||||
): Buffer {
|
||||
const decoded = decodeBase64Url(
|
||||
name,
|
||||
value,
|
||||
name === 'nonce' ? 12 : name === 'authTag' ? 16 : undefined,
|
||||
);
|
||||
if (
|
||||
name === 'ciphertext' &&
|
||||
decoded.length > MAX_LOCAL_SECRET_PLAINTEXT_BYTES
|
||||
) {
|
||||
decoded.fill(0);
|
||||
throw new InvalidLocalSecretError('ciphertext is too large');
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
export function createLocalSecretRef(reference: LocalSecretReference): string {
|
||||
try {
|
||||
return createSecretRef(reference);
|
||||
} catch {
|
||||
throw new InvalidLocalSecretError('reference is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
export function parseLocalSecretRef(value: unknown): LocalSecretReference {
|
||||
try {
|
||||
return parseSecretRef(value);
|
||||
} catch {
|
||||
throw new InvalidLocalSecretError('reference is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeLocalSecretEnvelope(
|
||||
envelope: LocalSecretEnvelope,
|
||||
): LocalSecretEnvelope {
|
||||
if (
|
||||
!envelope ||
|
||||
typeof envelope !== 'object' ||
|
||||
Array.isArray(envelope) ||
|
||||
!exactKeys(envelope, [
|
||||
'projectId',
|
||||
'name',
|
||||
'version',
|
||||
'mutationId',
|
||||
'keyId',
|
||||
'algorithm',
|
||||
'nonce',
|
||||
'ciphertext',
|
||||
'authTag',
|
||||
'createdAtMs',
|
||||
])
|
||||
) {
|
||||
throw new InvalidLocalSecretError('envelope is invalid');
|
||||
}
|
||||
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');
|
||||
}
|
||||
for (const name of ['nonce', 'ciphertext', 'authTag'] as const) {
|
||||
localSecretBinary(name, envelope[name]).fill(0);
|
||||
}
|
||||
if (!Number.isSafeInteger(envelope.createdAtMs) || envelope.createdAtMs < 0) {
|
||||
throw new InvalidLocalSecretError('createdAtMs is invalid');
|
||||
}
|
||||
return Object.freeze({ ...envelope });
|
||||
}
|
||||
|
||||
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',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type {
|
||||
AppendLocalSecretEnvelopeResult,
|
||||
LocalSecretEnvelope,
|
||||
} from './localSecret';
|
||||
import type { SecurityPolicyFence, SecuritySubject } from '../security/security';
|
||||
import type {
|
||||
SecurityAuditRecord,
|
||||
SecurityAuditSink,
|
||||
} from '../security/audit/securityAudit';
|
||||
|
||||
export interface LocalSecretAdministrationMutation {
|
||||
readonly envelope: LocalSecretEnvelope;
|
||||
readonly audit: SecurityAuditRecord;
|
||||
}
|
||||
|
||||
export interface AppendAuthorizedLocalSecretEnvelopeCommand {
|
||||
readonly expectedCurrentVersion: number;
|
||||
readonly envelope: LocalSecretEnvelope;
|
||||
readonly subject: SecuritySubject;
|
||||
readonly fence: SecurityPolicyFence;
|
||||
readonly audit: SecurityAuditRecord;
|
||||
}
|
||||
|
||||
export interface AppendAuthorizedLocalSecretEnvelopeResult
|
||||
extends AppendLocalSecretEnvelopeResult {
|
||||
readonly audit: Readonly<SecurityAuditRecord>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Short-lived management authority. Implementations must revalidate the
|
||||
* Project/RoleBinding fence and append the envelope plus audit atomically.
|
||||
*/
|
||||
export interface LocalSecretAdministrationRepository
|
||||
extends SecurityAuditSink {
|
||||
resolveLocalSecretAdministrationMutation(
|
||||
projectId: string,
|
||||
name: string,
|
||||
mutationId: string,
|
||||
): Promise<Readonly<LocalSecretAdministrationMutation> | null>;
|
||||
appendAuthorizedLocalSecretEnvelope(
|
||||
command: AppendAuthorizedLocalSecretEnvelopeCommand,
|
||||
): Promise<AppendAuthorizedLocalSecretEnvelopeResult>;
|
||||
}
|
||||
|
||||
export class LocalSecretAuthorizationFenceConflictError extends Error {
|
||||
readonly code = 'LOCAL_SECRET_AUTHORIZATION_FENCE_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('Local Secret authorization changed');
|
||||
this.name = 'LocalSecretAuthorizationFenceConflictError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
export const SECRET_REF_PREFIX = 'qlsecret:v1:';
|
||||
export const MAX_SECRET_REF_BYTES = 512;
|
||||
export const MAX_SECRET_NAME_BYTES = 128;
|
||||
export const MAX_SECRET_VERSION = 2_147_483_647;
|
||||
|
||||
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/;
|
||||
|
||||
export interface SecretReference {
|
||||
readonly projectId: string;
|
||||
readonly name: string;
|
||||
readonly version?: number;
|
||||
}
|
||||
|
||||
export class InvalidSecretReferenceError extends TypeError {
|
||||
readonly code = 'SECRET_REFERENCE_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Secret reference is invalid: ${message}`);
|
||||
this.name = 'InvalidSecretReferenceError';
|
||||
}
|
||||
}
|
||||
|
||||
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 boundedIdentifier(
|
||||
value: unknown,
|
||||
label: string,
|
||||
maximumBytes: number,
|
||||
): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length === 0 ||
|
||||
Buffer.byteLength(value, 'utf8') > maximumBytes ||
|
||||
/[\u0000-\u001f\u007f]/.test(value)
|
||||
) {
|
||||
throw new InvalidSecretReferenceError(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function secretVersion(value: unknown): number {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
(value as number) < 1 ||
|
||||
(value as number) > MAX_SECRET_VERSION
|
||||
) {
|
||||
throw new InvalidSecretReferenceError('version is invalid');
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
export function createSecretRef(reference: SecretReference): string {
|
||||
if (
|
||||
!reference ||
|
||||
typeof reference !== 'object' ||
|
||||
Array.isArray(reference) ||
|
||||
!exactKeys(
|
||||
reference,
|
||||
reference.version === undefined
|
||||
? ['projectId', 'name']
|
||||
: ['projectId', 'name', 'version'],
|
||||
)
|
||||
) {
|
||||
throw new InvalidSecretReferenceError('shape is invalid');
|
||||
}
|
||||
const projectId = boundedIdentifier(reference.projectId, 'projectId', 128);
|
||||
const name = boundedIdentifier(reference.name, 'name', MAX_SECRET_NAME_BYTES);
|
||||
const version =
|
||||
reference.version === undefined
|
||||
? undefined
|
||||
: secretVersion(reference.version);
|
||||
const payload = JSON.stringify({
|
||||
projectId,
|
||||
name,
|
||||
...(version === undefined ? {} : { version }),
|
||||
});
|
||||
const result = `${SECRET_REF_PREFIX}${Buffer.from(payload, 'utf8').toString(
|
||||
'base64url',
|
||||
)}`;
|
||||
if (Buffer.byteLength(result, 'utf8') > MAX_SECRET_REF_BYTES) {
|
||||
throw new InvalidSecretReferenceError('byte budget exceeded');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseSecretRef(value: unknown): SecretReference {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_SECRET_REF_BYTES ||
|
||||
!value.startsWith(SECRET_REF_PREFIX)
|
||||
) {
|
||||
throw new InvalidSecretReferenceError('encoding is invalid');
|
||||
}
|
||||
const encoded = value.slice(SECRET_REF_PREFIX.length);
|
||||
if (encoded.length === 0 || !BASE64URL_PATTERN.test(encoded)) {
|
||||
throw new InvalidSecretReferenceError('encoding is invalid');
|
||||
}
|
||||
const decoded = Buffer.from(encoded, 'base64url');
|
||||
try {
|
||||
if (decoded.toString('base64url') !== encoded) {
|
||||
throw new InvalidSecretReferenceError('encoding is not canonical');
|
||||
}
|
||||
const parsed = JSON.parse(decoded.toString('utf8')) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new InvalidSecretReferenceError('payload is invalid');
|
||||
}
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const reference: SecretReference = {
|
||||
projectId: record.projectId as string,
|
||||
name: record.name as string,
|
||||
...(record.version === undefined
|
||||
? {}
|
||||
: { version: record.version as number }),
|
||||
};
|
||||
if (createSecretRef(reference) !== value) {
|
||||
throw new InvalidSecretReferenceError('encoding is not canonical');
|
||||
}
|
||||
return Object.freeze(reference);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidSecretReferenceError) throw error;
|
||||
throw new InvalidSecretReferenceError('payload is invalid');
|
||||
} finally {
|
||||
decoded.fill(0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user