mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
refactor(sync-core): extract encryption primitives
This commit is contained in:
parent
4bbcafbbab
commit
4b856b3411
18 changed files with 158 additions and 52 deletions
|
|
@ -81,7 +81,7 @@ export interface SuperSyncPrivateCfg extends SyncProviderPrivateCfgBase {
|
|||
|
||||
### Phase 2: Encryption Function (0.5 day)
|
||||
|
||||
**File:** `src/app/op-log/encryption/encryption.ts`
|
||||
**File:** `packages/sync-core/src/encryption.ts`
|
||||
|
||||
**Add:**
|
||||
|
||||
|
|
@ -196,7 +196,7 @@ async getEncryptKey(): Promise<string | undefined> {
|
|||
| File | Changes |
|
||||
| ------------------------------------------------------------ | -------------------------------------------------- |
|
||||
| `packages/sync-providers/src/super-sync/super-sync.model.ts` | Add `isAutoEncryptionEnabled`, `autoEncryptionKey` |
|
||||
| `src/app/op-log/encryption/encryption.ts` | Add `deriveKeyFromHighEntropy()` |
|
||||
| `packages/sync-core/src/encryption.ts` | Add `deriveKeyFromHighEntropy()` |
|
||||
| `src/app/op-log/sync-providers/super-sync/super-sync.ts` | Modify `getEncryptKey()` |
|
||||
| `src/app/op-log/sync/operation-encryption.service.ts` | Support pre-derived keys |
|
||||
| `src/app/features/config/form-cfgs/sync-form.const.ts` | Add UI toggle |
|
||||
|
|
|
|||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -29733,6 +29733,10 @@
|
|||
"packages/sync-core": {
|
||||
"name": "@sp/sync-core",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^2.2.0",
|
||||
"hash-wasm": "^4.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@
|
|||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^2.2.0",
|
||||
"hash-wasm": "^4.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { argon2id } from 'hash-wasm';
|
||||
import { gcm } from '@noble/ciphers/aes.js';
|
||||
import { WebCryptoNotAvailableError } from '../core/errors/sync-errors';
|
||||
import { Log } from '../../core/log';
|
||||
import { WebCryptoNotAvailableError } from './web-crypto-error';
|
||||
|
||||
const ALGORITHM = 'AES-GCM' as const;
|
||||
const SALT_LENGTH = 16;
|
||||
|
|
@ -16,6 +15,54 @@ const DEFAULT_ARGON2_PARAMS = {
|
|||
|
||||
let _argon2Params = { ...DEFAULT_ARGON2_PARAMS };
|
||||
|
||||
interface EncryptionProcessGlobal {
|
||||
process?: {
|
||||
env?: {
|
||||
NODE_ENV?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface EncryptionGlobals extends EncryptionProcessGlobal {
|
||||
crypto?: Crypto;
|
||||
atob?: (input: string) => string;
|
||||
btoa?: (input: string) => string;
|
||||
}
|
||||
|
||||
const globals = (): EncryptionGlobals => globalThis as unknown as EncryptionGlobals;
|
||||
|
||||
const getRequiredCrypto = (): Crypto => {
|
||||
const cryptoApi = globals().crypto;
|
||||
if (cryptoApi === undefined) {
|
||||
throw new WebCryptoNotAvailableError('Crypto API is not available');
|
||||
}
|
||||
return cryptoApi;
|
||||
};
|
||||
|
||||
const getRequiredSubtle = (): SubtleCrypto => {
|
||||
const subtle = getRequiredCrypto().subtle;
|
||||
if (subtle === undefined) {
|
||||
throw new WebCryptoNotAvailableError();
|
||||
}
|
||||
return subtle;
|
||||
};
|
||||
|
||||
const getRequiredAtob = (): ((input: string) => string) => {
|
||||
const atob = globals().atob;
|
||||
if (atob === undefined) {
|
||||
throw new Error('atob is not available in this runtime');
|
||||
}
|
||||
return atob;
|
||||
};
|
||||
|
||||
const getRequiredBtoa = (): ((input: string) => string) => {
|
||||
const btoa = globals().btoa;
|
||||
if (btoa === undefined) {
|
||||
throw new Error('btoa is not available in this runtime');
|
||||
}
|
||||
return btoa;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the current Argon2 parameters.
|
||||
* Tests can override these via `setArgon2ParamsForTesting()`.
|
||||
|
|
@ -29,6 +76,9 @@ export const getArgon2Params = (): typeof DEFAULT_ARGON2_PARAMS => _argon2Params
|
|||
export const setArgon2ParamsForTesting = (
|
||||
params?: Partial<typeof DEFAULT_ARGON2_PARAMS>,
|
||||
): void => {
|
||||
if (globals().process?.env?.NODE_ENV === 'production') {
|
||||
throw new Error('setArgon2ParamsForTesting must not be called in production');
|
||||
}
|
||||
_argon2Params = params
|
||||
? { ...DEFAULT_ARGON2_PARAMS, ...params }
|
||||
: { ...DEFAULT_ARGON2_PARAMS };
|
||||
|
|
@ -49,11 +99,7 @@ export const setArgon2ParamsForTesting = (
|
|||
* Returns false in insecure contexts (http://, some custom schemes like Android Capacitor).
|
||||
*/
|
||||
export const isCryptoSubtleAvailable = (): boolean => {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
typeof window.crypto !== 'undefined' &&
|
||||
typeof window.crypto.subtle !== 'undefined'
|
||||
);
|
||||
return globals().crypto?.subtle !== undefined;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -74,7 +120,7 @@ export type DerivedKeyInfo =
|
|||
* Strategy interface for cryptographic operations.
|
||||
* Implemented by WebCrypto and @noble/ciphers backends.
|
||||
*/
|
||||
interface CryptoStrategy {
|
||||
export interface CryptoStrategy {
|
||||
encrypt(key: DerivedKeyInfo, iv: Uint8Array, data: Uint8Array): Promise<Uint8Array>;
|
||||
decrypt(key: DerivedKeyInfo, iv: Uint8Array, data: Uint8Array): Promise<Uint8Array>;
|
||||
deriveKey(password: string, salt: Uint8Array): Promise<DerivedKeyInfo>;
|
||||
|
|
@ -109,7 +155,7 @@ const webCryptoStrategy: CryptoStrategy = {
|
|||
if (keyInfo.type !== 'webcrypto') {
|
||||
throw new Error('WebCrypto strategy requires webcrypto key type');
|
||||
}
|
||||
const encrypted = await window.crypto.subtle.encrypt(
|
||||
const encrypted = await getRequiredSubtle().encrypt(
|
||||
{ name: ALGORITHM, iv: iv as Uint8Array<ArrayBuffer> },
|
||||
keyInfo.key,
|
||||
data as Uint8Array<ArrayBuffer>,
|
||||
|
|
@ -121,7 +167,7 @@ const webCryptoStrategy: CryptoStrategy = {
|
|||
if (keyInfo.type !== 'webcrypto') {
|
||||
throw new Error('WebCrypto strategy requires webcrypto key type');
|
||||
}
|
||||
const decrypted = await window.crypto.subtle.decrypt(
|
||||
const decrypted = await getRequiredSubtle().decrypt(
|
||||
{ name: ALGORITHM, iv: iv as Uint8Array<ArrayBuffer> },
|
||||
keyInfo.key,
|
||||
data as Uint8Array<ArrayBuffer>,
|
||||
|
|
@ -131,7 +177,7 @@ const webCryptoStrategy: CryptoStrategy = {
|
|||
|
||||
deriveKey: async (password, salt) => {
|
||||
const derivedBytes = await deriveKeyBytesArgon(password, salt);
|
||||
const key = await window.crypto.subtle.importKey(
|
||||
const key = await getRequiredSubtle().importKey(
|
||||
'raw',
|
||||
derivedBytes.buffer as ArrayBuffer,
|
||||
{ name: ALGORITHM },
|
||||
|
|
@ -250,7 +296,7 @@ export const getSessionKeyCacheStats = (): {
|
|||
// ============================================================================
|
||||
|
||||
export const base642ab = (base64: string): ArrayBuffer => {
|
||||
const binary_string = window.atob(base64);
|
||||
const binary_string = getRequiredAtob()(base64);
|
||||
const len = binary_string.length;
|
||||
const bytes = new Uint8Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
|
|
@ -263,7 +309,7 @@ export const ab2base64 = (buffer: ArrayBuffer): string => {
|
|||
const binary = Array.prototype.map
|
||||
.call(new Uint8Array(buffer), (byte: number) => String.fromCharCode(byte))
|
||||
.join('');
|
||||
return window.btoa(binary);
|
||||
return getRequiredBtoa()(binary);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -271,7 +317,7 @@ export const ab2base64 = (buffer: ArrayBuffer): string => {
|
|||
* Uses crypto.getRandomValues which is available even without crypto.subtle.
|
||||
*/
|
||||
const getRandomBytes = (length: number): Uint8Array<ArrayBuffer> => {
|
||||
return window.crypto.getRandomValues(new Uint8Array(length));
|
||||
return getRequiredCrypto().getRandomValues(new Uint8Array(length));
|
||||
};
|
||||
|
||||
// Minimum sizes for format detection
|
||||
|
|
@ -313,25 +359,22 @@ const _generateKey = async (password: string): Promise<CryptoKey> => {
|
|||
iterations: 1000,
|
||||
hash: 'SHA-256',
|
||||
};
|
||||
const key = await window.crypto.subtle.importKey(
|
||||
const key = await getRequiredSubtle().importKey(
|
||||
'raw',
|
||||
passwordBuffer,
|
||||
{ name: 'PBKDF2' },
|
||||
false,
|
||||
['deriveBits', 'deriveKey'],
|
||||
);
|
||||
return window.crypto.subtle.deriveKey(
|
||||
ops,
|
||||
key,
|
||||
{ name: ALGORITHM, length: 256 },
|
||||
true,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
return getRequiredSubtle().deriveKey(ops, key, { name: ALGORITHM, length: 256 }, true, [
|
||||
'encrypt',
|
||||
'decrypt',
|
||||
]);
|
||||
};
|
||||
|
||||
export const generateKey = async (password: string): Promise<string> => {
|
||||
const cryptoKey = await _generateKey(password);
|
||||
const exportKey = await window.crypto.subtle.exportKey('raw', cryptoKey);
|
||||
const exportKey = await getRequiredSubtle().exportKey('raw', cryptoKey);
|
||||
return ab2base64(exportKey);
|
||||
};
|
||||
|
||||
|
|
@ -351,18 +394,12 @@ async function decryptLegacy(data: string, password: string): Promise<string> {
|
|||
const iv = new Uint8Array(dataBuffer, 0, IV_LENGTH);
|
||||
const encryptedData = new Uint8Array(dataBuffer, IV_LENGTH);
|
||||
const key = await _generateKey(password);
|
||||
const decryptedContent = await window.crypto.subtle.decrypt(
|
||||
const decryptedContent = await getRequiredSubtle().decrypt(
|
||||
{ name: ALGORITHM, iv: iv },
|
||||
key,
|
||||
encryptedData,
|
||||
);
|
||||
|
||||
// Only warn AFTER successful decryption, to avoid false positives when
|
||||
// Argon2id decryption fails for other reasons (wrong password, corrupted data)
|
||||
Log.warn(
|
||||
'[DEPRECATION] Legacy PBKDF2 encryption detected. Consider re-syncing to migrate to Argon2id.',
|
||||
);
|
||||
|
||||
const dec = new TextDecoder();
|
||||
return dec.decode(decryptedContent);
|
||||
}
|
||||
|
|
@ -410,8 +447,6 @@ export const decrypt = async (data: string, password: string): Promise<string> =
|
|||
// Fallback to legacy decryption (pre-Argon2 format)
|
||||
// NOTE: Legacy PBKDF2 decryption requires WebCrypto. If WebCrypto is unavailable
|
||||
// and this is legacy data, the user will get a clear error.
|
||||
// The deprecation warning is only emitted if legacy decryption SUCCEEDS,
|
||||
// avoiding false positives when Argon2id fails for other reasons (wrong password).
|
||||
return await decryptLegacy(data, password);
|
||||
}
|
||||
};
|
||||
|
|
@ -428,6 +463,8 @@ export interface DecryptResult {
|
|||
migratedCiphertext?: string;
|
||||
/** True if the data was encrypted with legacy PBKDF2 */
|
||||
wasLegacy: boolean;
|
||||
/** True if the data used the legacy PBKDF2 KDF */
|
||||
wasLegacyKdf?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -452,7 +489,7 @@ export const decryptWithMigration = async (
|
|||
// Fallback to legacy PBKDF2 format - decrypt and prepare migration
|
||||
const plaintext = await decryptLegacy(data, password);
|
||||
const migratedCiphertext = await encrypt(plaintext, password);
|
||||
return { plaintext, migratedCiphertext, wasLegacy: true };
|
||||
return { plaintext, migratedCiphertext, wasLegacy: true, wasLegacyKdf: true };
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -55,8 +55,31 @@ export {
|
|||
} from './compression';
|
||||
export type { GzipCompressionLogMessages, GzipCompressionOptions } from './compression';
|
||||
|
||||
// Encryption primitives — Argon2id KDF + AES-GCM, Web Crypto with @noble fallback.
|
||||
export {
|
||||
encrypt,
|
||||
decrypt,
|
||||
encryptBatch,
|
||||
decryptBatch,
|
||||
generateKey,
|
||||
deriveKeyFromPassword,
|
||||
encryptWithDerivedKey,
|
||||
decryptWithDerivedKey,
|
||||
decryptWithMigration,
|
||||
getCryptoStrategy,
|
||||
isCryptoSubtleAvailable,
|
||||
clearSessionKeyCache,
|
||||
getSessionKeyCacheStats,
|
||||
getArgon2Params,
|
||||
setArgon2ParamsForTesting,
|
||||
base642ab,
|
||||
ab2base64,
|
||||
} from './encryption';
|
||||
export type { CryptoStrategy, DerivedKeyInfo, DecryptResult } from './encryption';
|
||||
|
||||
// Generic error helpers.
|
||||
export { extractErrorMessage } from './error.util';
|
||||
export { WebCryptoNotAvailableError } from './web-crypto-error';
|
||||
|
||||
// Full-state operation classification helper. Hosts supply their own op strings.
|
||||
export {
|
||||
|
|
|
|||
7
packages/sync-core/src/web-crypto-error.ts
Normal file
7
packages/sync-core/src/web-crypto-error.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export class WebCryptoNotAvailableError extends Error {
|
||||
override name = 'WebCryptoNotAvailableError';
|
||||
|
||||
constructor(message = 'Web Crypto API (crypto.subtle) is not available') {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
36
packages/sync-core/tests/encryption.spec.ts
Normal file
36
packages/sync-core/tests/encryption.spec.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
WebCryptoNotAvailableError,
|
||||
decrypt,
|
||||
encrypt,
|
||||
isCryptoSubtleAvailable,
|
||||
setArgon2ParamsForTesting,
|
||||
} from '../src';
|
||||
|
||||
describe('encryption primitives', () => {
|
||||
it('exposes Web Crypto availability check', () => {
|
||||
expect(typeof isCryptoSubtleAvailable()).toBe('boolean');
|
||||
});
|
||||
|
||||
it('round-trips a string through encrypt/decrypt with the same password', async () => {
|
||||
if (!isCryptoSubtleAvailable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setArgon2ParamsForTesting({ parallelism: 1, memorySize: 8, iterations: 1 });
|
||||
try {
|
||||
const plaintext = 'hello sync world';
|
||||
const ciphertext = await encrypt(plaintext, 'correct horse battery staple');
|
||||
expect(ciphertext).not.toBe(plaintext);
|
||||
await expect(decrypt(ciphertext, 'correct horse battery staple')).resolves.toBe(
|
||||
plaintext,
|
||||
);
|
||||
} finally {
|
||||
setArgon2ParamsForTesting();
|
||||
}
|
||||
});
|
||||
|
||||
it('exports WebCryptoNotAvailableError', () => {
|
||||
expect(new WebCryptoNotAvailableError()).toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
"target": "ES2022",
|
||||
"module": "preserve",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { isOperationSyncCapable } from '../../op-log/sync/operation-sync.util';
|
|||
import { SyncProviderId } from '../../op-log/sync-providers/provider.const';
|
||||
import type { SuperSyncPrivateCfg } from '@sp/sync-providers/super-sync';
|
||||
import { SyncLog } from '../../core/log';
|
||||
import { clearSessionKeyCache } from '../../op-log/encryption/encryption';
|
||||
import { clearSessionKeyCache } from '@sp/sync-core';
|
||||
import { CleanSlateService } from '../../op-log/clean-slate/clean-slate.service';
|
||||
import { OperationLogUploadService } from '../../op-log/sync/operation-log-upload.service';
|
||||
import { SyncWrapperService } from './sync-wrapper.service';
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { FileBasedSyncAdapterService } from '../../op-log/sync-providers/file-ba
|
|||
import { CURRENT_SCHEMA_VERSION } from '../../op-log/persistence/schema-migration.service';
|
||||
import { uuidv7 } from '../../util/uuid-v7';
|
||||
import { GlobalConfigService } from '../../features/config/global-config.service';
|
||||
import { clearSessionKeyCache } from '../../op-log/encryption/encryption';
|
||||
import { clearSessionKeyCache } from '@sp/sync-core';
|
||||
|
||||
const LOG_PREFIX = 'FileBasedEncryptionService';
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import {
|
|||
} from '../../op-log/sync-providers/provider.interface';
|
||||
import { VectorClock } from '../../core/util/vector-clock';
|
||||
import { OperationEncryptionService } from '../../op-log/sync/operation-encryption.service';
|
||||
import { isCryptoSubtleAvailable } from '../../op-log/encryption/encryption';
|
||||
import { isCryptoSubtleAvailable } from '@sp/sync-core';
|
||||
import { WebCryptoNotAvailableError } from '../../op-log/core/errors/sync-errors';
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
} from '../../op-log/sync-exports';
|
||||
import { DEFAULT_GLOBAL_CONFIG } from '../../features/config/default-global-config.const';
|
||||
import { SyncLog } from '../../core/log';
|
||||
import { clearSessionKeyCache } from '../../op-log/encryption/encryption';
|
||||
import { clearSessionKeyCache } from '@sp/sync-core';
|
||||
import type { SuperSyncPrivateCfg } from '@sp/sync-providers/super-sync';
|
||||
import { SyncWrapperService } from './sync-wrapper.service';
|
||||
|
||||
|
|
|
|||
|
|
@ -406,9 +406,7 @@ export class BackupImportFailedError extends AdditionalLogErrorBase {
|
|||
override name = 'BackupImportFailedError';
|
||||
}
|
||||
|
||||
export class WebCryptoNotAvailableError extends Error {
|
||||
override name = 'WebCryptoNotAvailableError';
|
||||
}
|
||||
export { WebCryptoNotAvailableError } from '@sp/sync-core';
|
||||
|
||||
/**
|
||||
* Thrown when IndexedDB storage quota is exceeded during operation log write.
|
||||
|
|
|
|||
|
|
@ -2,9 +2,8 @@ import {
|
|||
extractSyncFileStateFromPrefix,
|
||||
getSyncFilePrefix,
|
||||
} from '../util/sync-file-prefix';
|
||||
import type { SyncLogger } from '@sp/sync-core';
|
||||
import { decryptBatch, encryptBatch, type SyncLogger } from '@sp/sync-core';
|
||||
import { OP_LOG_SYNC_LOGGER } from '../core/sync-logger.adapter';
|
||||
import { decryptBatch, encryptBatch } from './encryption';
|
||||
import {
|
||||
DecryptError,
|
||||
DecryptNoPasswordError,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
clearSessionKeyCache,
|
||||
getSessionKeyCacheStats,
|
||||
setArgon2ParamsForTesting,
|
||||
} from './encryption';
|
||||
} from '@sp/sync-core';
|
||||
|
||||
describe('Encryption', () => {
|
||||
const PASSWORD = 'super_secret_password';
|
||||
|
|
@ -110,6 +110,7 @@ describe('Encryption', () => {
|
|||
|
||||
expect(result.plaintext).toBe(DATA);
|
||||
expect(result.wasLegacy).toBe(true);
|
||||
expect(result.wasLegacyKdf).toBe(true);
|
||||
expect(result.migratedCiphertext).toBeDefined();
|
||||
|
||||
// Verify migrated ciphertext is valid Argon2id
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { encrypt, decrypt, encryptBatch, decryptBatch } from '../encryption/encryption';
|
||||
import { decrypt, decryptBatch, encrypt, encryptBatch } from '@sp/sync-core';
|
||||
import { SyncOperation } from '../sync-providers/provider.interface';
|
||||
import { DecryptError } from '../core/errors/sync-errors';
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
*
|
||||
* Usage with Jasmine spyOn (recommended):
|
||||
* ```typescript
|
||||
* import * as encryptionModule from '../../encryption/encryption';
|
||||
* import * as encryptionModule from '@sp/sync-core';
|
||||
* import { mockEncrypt, mockDecrypt } from './mock-encryption.helper';
|
||||
*
|
||||
* beforeEach(() => {
|
||||
|
|
|
|||
|
|
@ -12,10 +12,7 @@ import {
|
|||
} from '../../../core/operation.types';
|
||||
import { OperationLogStoreService } from '../../../persistence/operation-log-store.service';
|
||||
import { SyncImportFilterService } from '../../../sync/sync-import-filter.service';
|
||||
import {
|
||||
clearSessionKeyCache,
|
||||
setArgon2ParamsForTesting,
|
||||
} from '../../../encryption/encryption';
|
||||
import { clearSessionKeyCache, setArgon2ParamsForTesting } from '@sp/sync-core';
|
||||
|
||||
describe('File-Based Sync Integration - Edge Cases', () => {
|
||||
let harness: FileBasedSyncTestHarness;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue