Slave/client crypto #7
Generated
+7
@@ -12,6 +12,7 @@
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@uswriting/exiftool": "1.0.9",
|
||||
"axios": "^1.20.0",
|
||||
"hash-wasm": "^4.12.0",
|
||||
"pinia": "^4.0.3",
|
||||
"pretty-bytes": "^7.2.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
@@ -2513,6 +2514,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hash-wasm": {
|
||||
"version": "4.12.0",
|
||||
"resolved": "https://registry.npmjs.org/hash-wasm/-/hash-wasm-4.12.0.tgz",
|
||||
"integrity": "sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@uswriting/exiftool": "1.0.9",
|
||||
"axios": "^1.20.0",
|
||||
"hash-wasm": "^4.12.0",
|
||||
"pinia": "^4.0.3",
|
||||
"pretty-bytes": "^7.2.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export type EncryptedZkUpload = {
|
||||
fragmentKey: string;
|
||||
dek: Uint8Array;
|
||||
iv: Uint8Array;
|
||||
ciphertext: Uint8Array;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export type WrappedZkDek = {
|
||||
wrappedDek: Uint8Array;
|
||||
wrapIv: Uint8Array;
|
||||
salt: Uint8Array;
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { UploadOptionsRequest } from '@/types/upload/UploadOptionsRequest';
|
||||
|
||||
export type ZkUploadPrepareOptions =
|
||||
| (UploadOptionsRequest & { passwordProtected: false })
|
||||
| (UploadOptionsRequest & { passwordProtected: true; password: string });
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { deriveZkWrapKeyBytes } from '@/utils/crypto/deriveZkWrapKeyBytes';
|
||||
import { generateDek } from '@/utils/crypto/generateDek';
|
||||
|
||||
describe('deriveZkWrapKeyBytes', () => {
|
||||
const salt = new Uint8Array([8, 7, 6, 5, 4, 3, 2, 1, 0, 9, 8, 7, 6, 5, 4, 3]);
|
||||
const fragmentSecret = generateDek();
|
||||
const password = 'wrap-password';
|
||||
|
||||
it('returns a 32-byte wrap key', async () => {
|
||||
const wrapKey = await deriveZkWrapKeyBytes(password, salt, fragmentSecret);
|
||||
|
||||
expect(wrapKey.byteLength).toBe(32);
|
||||
});
|
||||
|
||||
it('is deterministic for the same inputs', async () => {
|
||||
const first = await deriveZkWrapKeyBytes(password, salt, fragmentSecret);
|
||||
const second = await deriveZkWrapKeyBytes(password, salt, fragmentSecret);
|
||||
|
||||
expect(first).toEqual(second);
|
||||
});
|
||||
|
||||
it('binds the URL fragment secret into the wrap key', async () => {
|
||||
const otherFragment = generateDek();
|
||||
|
||||
const wrapKeyA = await deriveZkWrapKeyBytes(password, salt, fragmentSecret);
|
||||
const wrapKeyB = await deriveZkWrapKeyBytes(password, salt, otherFragment);
|
||||
|
||||
expect(wrapKeyA).not.toEqual(wrapKeyB);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { hashPasswordArgon2id } from '@/utils/crypto/hashPasswordArgon2id';
|
||||
import { toArrayBuffer } from '@/utils/toArrayBuffer';
|
||||
|
||||
export const deriveZkWrapKeyBytes = async (
|
||||
password: string,
|
||||
salt: Uint8Array,
|
||||
fragmentSecret: Uint8Array
|
||||
): Promise<Uint8Array> => {
|
||||
const kek = await hashPasswordArgon2id(password, salt);
|
||||
const combined = new Uint8Array(kek.length + fragmentSecret.length);
|
||||
combined.set(kek, 0);
|
||||
combined.set(fragmentSecret, kek.length);
|
||||
|
||||
const digest = await crypto.subtle.digest('SHA-256', toArrayBuffer(combined));
|
||||
|
||||
return new Uint8Array(digest);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { hashPasswordArgon2id } from '@/utils/crypto/hashPasswordArgon2id';
|
||||
|
||||
describe('hashPasswordArgon2id', () => {
|
||||
const salt = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]);
|
||||
|
||||
it('returns a 32-byte hash', async () => {
|
||||
const hash = await hashPasswordArgon2id('test-password', salt);
|
||||
|
||||
expect(hash).toBeInstanceOf(Uint8Array);
|
||||
expect(hash.byteLength).toBe(32);
|
||||
});
|
||||
|
||||
it('is deterministic for the same password and salt', async () => {
|
||||
const first = await hashPasswordArgon2id('same', salt);
|
||||
const second = await hashPasswordArgon2id('same', salt);
|
||||
|
||||
expect(first).toEqual(second);
|
||||
});
|
||||
|
||||
it('changes when the salt changes', async () => {
|
||||
const otherSalt = new Uint8Array(salt);
|
||||
otherSalt[0] ^= 0xff;
|
||||
|
||||
const hashA = await hashPasswordArgon2id('same', salt);
|
||||
const hashB = await hashPasswordArgon2id('same', otherSalt);
|
||||
|
||||
expect(hashA).not.toEqual(hashB);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { argon2id } from 'hash-wasm';
|
||||
|
||||
export const hashPasswordArgon2id = async (password: string, salt: Uint8Array): Promise<Uint8Array> => {
|
||||
const iterations = 3;
|
||||
const memorySizeKib = 65536;
|
||||
const parallelism = 1;
|
||||
const hashByteLength = 32;
|
||||
|
||||
const hash = await argon2id({
|
||||
password,
|
||||
salt,
|
||||
parallelism,
|
||||
iterations,
|
||||
memorySize: memorySizeKib,
|
||||
hashLength: hashByteLength,
|
||||
outputType: 'binary'
|
||||
});
|
||||
|
||||
return new Uint8Array(hash);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { base64ToBytes } from '@/utils/encoding/base64ToBytes';
|
||||
import { decryptPasswordProtectedZkUpload } from '@/utils/upload/decryptPasswordProtectedZkUpload';
|
||||
import { prepareZkUploadRequest } from '@/utils/upload/prepareZkUploadRequest';
|
||||
|
||||
describe('decryptPasswordProtectedZkUpload', () => {
|
||||
const passwordOptions = {
|
||||
expiresAt: null,
|
||||
maxViews: null,
|
||||
passwordProtected: true as const,
|
||||
password: 'viewer-secret'
|
||||
};
|
||||
|
||||
it('decrypts ciphertext when fragment key and password match upload', async () => {
|
||||
const plaintext = new TextEncoder().encode('password-protected image');
|
||||
const { request, fragmentKey } = await prepareZkUploadRequest(plaintext, passwordOptions);
|
||||
|
||||
const recovered = await decryptPasswordProtectedZkUpload(
|
||||
base64ToBytes(request.ciphertext),
|
||||
base64ToBytes(request.iv),
|
||||
fragmentKey,
|
||||
passwordOptions.password,
|
||||
base64ToBytes(request.wrappedDek!),
|
||||
base64ToBytes(request.wrapIv!),
|
||||
base64ToBytes(request.salt!)
|
||||
);
|
||||
|
||||
expect(recovered).toEqual(plaintext);
|
||||
});
|
||||
|
||||
it('rejects decryption with the wrong password', async () => {
|
||||
const plaintext = new Uint8Array([42]);
|
||||
const { request, fragmentKey } = await prepareZkUploadRequest(plaintext, passwordOptions);
|
||||
|
||||
await expect(
|
||||
decryptPasswordProtectedZkUpload(
|
||||
base64ToBytes(request.ciphertext),
|
||||
base64ToBytes(request.iv),
|
||||
fragmentKey,
|
||||
'wrong-password',
|
||||
base64ToBytes(request.wrappedDek!),
|
||||
base64ToBytes(request.wrapIv!),
|
||||
base64ToBytes(request.salt!)
|
||||
)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { decryptAesGcm } from '@/utils/crypto/decryptAesGcm';
|
||||
import { importAesGcmKey } from '@/utils/crypto/importAesGcmKey';
|
||||
import { base64UrlToBytes } from '@/utils/encoding/base64UrlToBytes';
|
||||
import { unwrapZkDek } from '@/utils/upload/unwrapZkDek';
|
||||
|
||||
export const decryptPasswordProtectedZkUpload = async (
|
||||
ciphertext: Uint8Array,
|
||||
iv: Uint8Array,
|
||||
fragmentKey: string,
|
||||
password: string,
|
||||
wrappedDek: Uint8Array,
|
||||
wrapIv: Uint8Array,
|
||||
salt: Uint8Array
|
||||
): Promise<Uint8Array> => {
|
||||
const fragmentSecret = base64UrlToBytes(fragmentKey);
|
||||
const dek = await unwrapZkDek(wrappedDek, wrapIv, salt, password, fragmentSecret);
|
||||
const key = await importAesGcmKey(dek);
|
||||
|
||||
return decryptAesGcm(ciphertext, key, iv);
|
||||
};
|
||||
@@ -7,7 +7,8 @@ import { encryptZkUpload } from '@/utils/upload/encryptZkUpload';
|
||||
describe('decryptZkUpload', () => {
|
||||
it('decrypts using the URL fragment key (base64url-encoded DEK)', async () => {
|
||||
const plaintext = new TextEncoder().encode('viewer-payload');
|
||||
const { ciphertext, iv, fragmentKey } = await encryptZkUpload(plaintext);
|
||||
const { dek, ciphertext, iv } = await encryptZkUpload(plaintext);
|
||||
const fragmentKey = bytesToBase64Url(dek);
|
||||
|
||||
const recovered = await decryptZkUpload(ciphertext, iv, fragmentKey);
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AES_GCM_IV_BYTE_LENGTH } from '@/consts/crypto/aesGcmIvByteLength';
|
||||
import { AES_GCM_KEY_BYTE_LENGTH } from '@/consts/crypto/aesGcmKeyByteLength';
|
||||
import { base64UrlToBytes } from '@/utils/encoding/base64UrlToBytes';
|
||||
import { bytesToBase64 } from '@/utils/encoding/bytesToBase64';
|
||||
import { encryptZkUpload } from '@/utils/upload/encryptZkUpload';
|
||||
|
||||
describe('encryptZkUpload', () => {
|
||||
it('exposes fragmentKey as base64url-encoded DEK', async () => {
|
||||
it('returns a random DEK and IV', async () => {
|
||||
const encrypted = await encryptZkUpload(new Uint8Array([1]));
|
||||
|
||||
expect(encrypted.fragmentKey).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
expect(base64UrlToBytes(encrypted.fragmentKey)).toHaveLength(AES_GCM_KEY_BYTE_LENGTH);
|
||||
expect(encrypted.dek).toHaveLength(AES_GCM_KEY_BYTE_LENGTH);
|
||||
expect(encrypted.iv).toHaveLength(AES_GCM_IV_BYTE_LENGTH);
|
||||
});
|
||||
|
||||
it('serializes iv and ciphertext for the upload API', async () => {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { encryptAesGcm } from '@/utils/crypto/encryptAesGcm';
|
||||
import { generateDek } from '@/utils/crypto/generateDek';
|
||||
import { generateIv } from '@/utils/crypto/generateIv';
|
||||
import { importAesGcmKey } from '@/utils/crypto/importAesGcmKey';
|
||||
import { bytesToBase64Url } from '@/utils/encoding/bytesToBase64Url';
|
||||
|
||||
export const encryptZkUpload = async (plaintext: Uint8Array): Promise<EncryptedZkUpload> => {
|
||||
const dek = generateDek();
|
||||
@@ -11,9 +10,5 @@ export const encryptZkUpload = async (plaintext: Uint8Array): Promise<EncryptedZ
|
||||
const key = await importAesGcmKey(dek);
|
||||
const ciphertext = await encryptAesGcm(plaintext, key, iv);
|
||||
|
||||
return {
|
||||
fragmentKey: bytesToBase64Url(dek),
|
||||
iv,
|
||||
ciphertext
|
||||
};
|
||||
return { dek, iv, ciphertext };
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { base64ToBytes } from '@/utils/encoding/base64ToBytes';
|
||||
import { buildZkViewerPath } from '@/utils/share/buildZkViewerPath';
|
||||
import { decryptPasswordProtectedZkUpload } from '@/utils/upload/decryptPasswordProtectedZkUpload';
|
||||
import { decryptZkUpload } from '@/utils/upload/decryptZkUpload';
|
||||
import { prepareZkUploadRequest } from '@/utils/upload/prepareZkUploadRequest';
|
||||
|
||||
@@ -58,4 +59,54 @@ describe('prepareZkUploadRequest', () => {
|
||||
|
||||
expect(recovered).toEqual(plaintext);
|
||||
});
|
||||
|
||||
describe('password protected', () => {
|
||||
const passwordOptions = {
|
||||
expiresAt: null,
|
||||
maxViews: null,
|
||||
passwordProtected: true as const,
|
||||
password: 'batch-secret'
|
||||
};
|
||||
|
||||
it('includes wrap fields and omits password from the POST body', async () => {
|
||||
const { request, fragmentKey } = await prepareZkUploadRequest(new Uint8Array([1]), passwordOptions);
|
||||
|
||||
expect(request.passwordProtected).toBe(true);
|
||||
expect(request.wrappedDek).toMatch(/^[A-Za-z0-9+/]+=*$/);
|
||||
expect(request.wrapIv).toMatch(/^[A-Za-z0-9+/]+=*$/);
|
||||
expect(request.salt).toMatch(/^[A-Za-z0-9+/]+=*$/);
|
||||
expect(request).not.toHaveProperty('password');
|
||||
expect(fragmentKey).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
});
|
||||
|
||||
it('does not decrypt with URL fragment alone', async () => {
|
||||
const plaintext = new TextEncoder().encode('protected');
|
||||
const { request, fragmentKey } = await prepareZkUploadRequest(plaintext, passwordOptions);
|
||||
|
||||
await expect(
|
||||
decryptZkUpload(
|
||||
base64ToBytes(request.ciphertext),
|
||||
base64ToBytes(request.iv),
|
||||
fragmentKey
|
||||
)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('roundtrips with fragment key and password', async () => {
|
||||
const plaintext = new TextEncoder().encode('protected-payload');
|
||||
const { request, fragmentKey } = await prepareZkUploadRequest(plaintext, passwordOptions);
|
||||
|
||||
const recovered = await decryptPasswordProtectedZkUpload(
|
||||
base64ToBytes(request.ciphertext),
|
||||
base64ToBytes(request.iv),
|
||||
fragmentKey,
|
||||
passwordOptions.password,
|
||||
base64ToBytes(request.wrappedDek!),
|
||||
base64ToBytes(request.wrapIv!),
|
||||
base64ToBytes(request.salt!)
|
||||
);
|
||||
|
||||
expect(recovered).toEqual(plaintext);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +1,44 @@
|
||||
import type { CreateZkUploadRequest } from '@/types/upload/CreateZkUploadRequest';
|
||||
import type { UploadOptionsRequest } from '@/types/upload/UploadOptionsRequest';
|
||||
import type { ZkUploadPrepareOptions } from '@/types/upload/ZkUploadPrepareOptions';
|
||||
import { bytesToBase64 } from '@/utils/encoding/bytesToBase64';
|
||||
import { bytesToBase64Url } from '@/utils/encoding/bytesToBase64Url';
|
||||
import { generateDek } from '@/utils/crypto/generateDek';
|
||||
import { encryptZkUpload } from '@/utils/upload/encryptZkUpload';
|
||||
import { wrapZkDek } from '@/utils/upload/wrapZkDek';
|
||||
|
||||
export const prepareZkUploadRequest = async (
|
||||
plaintext: Uint8Array,
|
||||
options: UploadOptionsRequest & { passwordProtected: false }
|
||||
options: ZkUploadPrepareOptions
|
||||
): Promise<{ request: CreateZkUploadRequest; fragmentKey: string }> => {
|
||||
const encrypted = await encryptZkUpload(plaintext);
|
||||
if (!options.passwordProtected) {
|
||||
const { dek, iv, ciphertext } = await encryptZkUpload(plaintext);
|
||||
|
||||
return {
|
||||
fragmentKey: bytesToBase64Url(dek),
|
||||
request: {
|
||||
...options,
|
||||
iv: bytesToBase64(iv),
|
||||
ciphertext: bytesToBase64(ciphertext),
|
||||
passwordProtected: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const { password, ...uploadOptions } = options;
|
||||
const { dek, iv, ciphertext } = await encryptZkUpload(plaintext);
|
||||
const fragmentSecret = generateDek();
|
||||
const { wrappedDek, wrapIv, salt } = await wrapZkDek(dek, password, fragmentSecret);
|
||||
|
||||
return {
|
||||
fragmentKey: encrypted.fragmentKey,
|
||||
fragmentKey: bytesToBase64Url(fragmentSecret),
|
||||
request: {
|
||||
...options,
|
||||
iv: bytesToBase64(encrypted.iv),
|
||||
ciphertext: bytesToBase64(encrypted.ciphertext),
|
||||
passwordProtected: false
|
||||
...uploadOptions,
|
||||
iv: bytesToBase64(iv),
|
||||
ciphertext: bytesToBase64(ciphertext),
|
||||
passwordProtected: true,
|
||||
wrappedDek: bytesToBase64(wrappedDek),
|
||||
wrapIv: bytesToBase64(wrapIv),
|
||||
salt: bytesToBase64(salt)
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { generateDek } from '@/utils/crypto/generateDek';
|
||||
import { unwrapZkDek } from '@/utils/upload/unwrapZkDek';
|
||||
import { wrapZkDek } from '@/utils/upload/wrapZkDek';
|
||||
|
||||
describe('unwrapZkDek', () => {
|
||||
it('fails unwrap with a mismatched fragment secret', async () => {
|
||||
const dek = generateDek();
|
||||
const fragmentSecret = generateDek();
|
||||
const wrapped = await wrapZkDek(dek, 'password', fragmentSecret);
|
||||
const otherFragment = generateDek();
|
||||
|
||||
await expect(
|
||||
unwrapZkDek(wrapped.wrappedDek, wrapped.wrapIv, wrapped.salt, 'password', otherFragment)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { decryptAesGcm } from '@/utils/crypto/decryptAesGcm';
|
||||
import { deriveZkWrapKeyBytes } from '@/utils/crypto/deriveZkWrapKeyBytes';
|
||||
import { importAesGcmKey } from '@/utils/crypto/importAesGcmKey';
|
||||
|
||||
export const unwrapZkDek = async (
|
||||
wrappedDek: Uint8Array,
|
||||
wrapIv: Uint8Array,
|
||||
salt: Uint8Array,
|
||||
password: string,
|
||||
fragmentSecret: Uint8Array
|
||||
): Promise<Uint8Array> => {
|
||||
const wrapKeyBytes = await deriveZkWrapKeyBytes(password, salt, fragmentSecret);
|
||||
const wrapKey = await importAesGcmKey(wrapKeyBytes);
|
||||
|
||||
return decryptAesGcm(wrappedDek, wrapKey, wrapIv);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { generateDek } from '@/utils/crypto/generateDek';
|
||||
import { unwrapZkDek } from '@/utils/upload/unwrapZkDek';
|
||||
import { wrapZkDek } from '@/utils/upload/wrapZkDek';
|
||||
|
||||
describe('wrapZkDek', () => {
|
||||
it('roundtrips the DEK with password and fragment secret', async () => {
|
||||
const dek = generateDek();
|
||||
const fragmentSecret = generateDek();
|
||||
const password = 'viewer-password';
|
||||
|
||||
const wrapped = await wrapZkDek(dek, password, fragmentSecret);
|
||||
const recovered = await unwrapZkDek(
|
||||
wrapped.wrappedDek,
|
||||
wrapped.wrapIv,
|
||||
wrapped.salt,
|
||||
password,
|
||||
fragmentSecret
|
||||
);
|
||||
|
||||
expect(recovered).toEqual(dek);
|
||||
});
|
||||
|
||||
it('fails unwrap with wrong password', async () => {
|
||||
const dek = generateDek();
|
||||
const fragmentSecret = generateDek();
|
||||
const wrapped = await wrapZkDek(dek, 'correct', fragmentSecret);
|
||||
|
||||
await expect(
|
||||
unwrapZkDek(wrapped.wrappedDek, wrapped.wrapIv, wrapped.salt, 'wrong', fragmentSecret)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { WrappedZkDek } from '@/types/upload/WrappedZkDek';
|
||||
import { encryptAesGcm } from '@/utils/crypto/encryptAesGcm';
|
||||
import { deriveZkWrapKeyBytes } from '@/utils/crypto/deriveZkWrapKeyBytes';
|
||||
import { generateIv } from '@/utils/crypto/generateIv';
|
||||
import { generateRandomBytes } from '@/utils/crypto/generateRandomBytes';
|
||||
import { importAesGcmKey } from '@/utils/crypto/importAesGcmKey';
|
||||
|
||||
export const wrapZkDek = async (
|
||||
dek: Uint8Array,
|
||||
password: string,
|
||||
fragmentSecret: Uint8Array
|
||||
): Promise<WrappedZkDek> => {
|
||||
const saltByteLength = 16;
|
||||
const salt = generateRandomBytes(saltByteLength);
|
||||
const wrapKeyBytes = await deriveZkWrapKeyBytes(password, salt, fragmentSecret);
|
||||
const wrapIv = generateIv();
|
||||
const wrapKey = await importAesGcmKey(wrapKeyBytes);
|
||||
const wrappedDek = await encryptAesGcm(dek, wrapKey, wrapIv);
|
||||
|
||||
return { wrappedDek, wrapIv, salt };
|
||||
};
|
||||
Reference in New Issue
Block a user