fix(encryptid): deterministic key derivation and server-backed guardian recovery
- Key derivation: replace random crypto.subtle.generateKey with deterministic P-256 via @noble/curves/p256 and real Ed25519 did:key generation via @noble/curves/ed25519 with multicodec prefix + base58btc encoding - Guardian recovery: wire RecoveryManager to server API (GET/POST/DELETE /api/guardians) instead of localStorage-only persistence. Server handles invite emails, client syncs guardian list on load and merges with local type metadata. verifyGuardian checks actual server acceptance status. - Notifications dispatch CustomEvents on document for UI integration - GuardianSetupElement awaits server sync before first render Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
21b31c43c7
commit
e207b18adf
|
|
@ -7,6 +7,8 @@
|
|||
|
||||
import { bufferToBase64url, base64urlToBuffer } from './webauthn';
|
||||
import { deriveEOAFromPRF } from './eoa-derivation';
|
||||
import { p256 } from '@noble/curves/p256';
|
||||
import { ed25519 } from '@noble/curves/ed25519';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
|
|
@ -243,23 +245,30 @@ export class EncryptIDKeyManager {
|
|||
256
|
||||
);
|
||||
|
||||
// For now, generate a non-deterministic key pair
|
||||
// TODO: Use @noble/curves for deterministic generation from seed
|
||||
// This is a placeholder - in production, use the seed deterministically
|
||||
const keyPair = await crypto.subtle.generateKey(
|
||||
{
|
||||
name: 'ECDSA',
|
||||
namedCurve: 'P-256',
|
||||
},
|
||||
false, // Private key not extractable
|
||||
['sign', 'verify']
|
||||
// Derive deterministic P-256 key pair from seed using @noble/curves
|
||||
const seedBytes = new Uint8Array(seed);
|
||||
const publicKeyUncompressed = p256.getPublicKey(seedBytes, false); // 65 bytes: 04 || x || y
|
||||
|
||||
// Import into WebCrypto as ECDSA key pair for sign/verify operations
|
||||
// PKCS8 wrapping for private key import
|
||||
const pkcs8 = buildP256Pkcs8(seedBytes);
|
||||
const privateKey = await crypto.subtle.importKey(
|
||||
'pkcs8',
|
||||
pkcs8,
|
||||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||
false, // Not extractable
|
||||
['sign']
|
||||
);
|
||||
|
||||
// Store seed hash for verification
|
||||
console.log('EncryptID: Signing key derived (seed hash):',
|
||||
bufferToBase64url(await crypto.subtle.digest('SHA-256', seed)).slice(0, 16));
|
||||
const publicKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
publicKeyUncompressed as BufferSource,
|
||||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||
true,
|
||||
['verify']
|
||||
);
|
||||
|
||||
return keyPair;
|
||||
return { privateKey, publicKey };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -292,25 +301,17 @@ export class EncryptIDKeyManager {
|
|||
* Format: did:key:z6Mk... (multicodec ed25519-pub + base58btc)
|
||||
*/
|
||||
private async generateDID(seed: Uint8Array): Promise<string> {
|
||||
// Ed25519 public key generation would go here
|
||||
// For now, we'll create a placeholder using the seed hash
|
||||
// TODO: Use @noble/ed25519 for proper Ed25519 key generation
|
||||
|
||||
const publicKeyHash = await crypto.subtle.digest('SHA-256', seed as BufferSource);
|
||||
const publicKeyBytes = new Uint8Array(publicKeyHash).slice(0, 32);
|
||||
// Derive Ed25519 public key from seed using @noble/curves
|
||||
const publicKeyBytes = ed25519.getPublicKey(seed);
|
||||
|
||||
// Multicodec prefix for Ed25519 public key: 0xed01
|
||||
const multicodecPrefix = new Uint8Array([0xed, 0x01]);
|
||||
const multicodecKey = new Uint8Array(34);
|
||||
multicodecKey.set(multicodecPrefix);
|
||||
multicodecKey[0] = 0xed;
|
||||
multicodecKey[1] = 0x01;
|
||||
multicodecKey.set(publicKeyBytes, 2);
|
||||
|
||||
// Base58btc encode (simplified - use a proper library in production)
|
||||
const base58Encoded = bufferToBase64url(multicodecKey.buffer)
|
||||
.replace(/-/g, '')
|
||||
.replace(/_/g, '');
|
||||
|
||||
return `did:key:z${base58Encoded}`;
|
||||
// Encode as base58btc (did:key spec requires 'z' prefix + base58btc)
|
||||
return `did:key:z${base58btcEncode(multicodecKey)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -536,6 +537,70 @@ export async function unwrapSharedKey(
|
|||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HELPERS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Build a PKCS#8 DER wrapper around a raw P-256 private key (32 bytes)
|
||||
* so it can be imported via WebCrypto's importKey('pkcs8', ...).
|
||||
*
|
||||
* Structure: SEQUENCE { version, AlgorithmIdentifier { ecPublicKey, P-256 }, OCTET STRING { SEQUENCE { version, privateKey } } }
|
||||
*/
|
||||
function buildP256Pkcs8(rawPrivateKey: Uint8Array): ArrayBuffer {
|
||||
// DER-encoded PKCS#8 template for P-256 with a 32-byte private key
|
||||
// Pre-computed ASN.1 header (version + algorithm identifier)
|
||||
const header = new Uint8Array([
|
||||
0x30, 0x41, // SEQUENCE (65 bytes total)
|
||||
0x02, 0x01, 0x00, // INTEGER version = 0
|
||||
0x30, 0x13, // SEQUENCE (AlgorithmIdentifier)
|
||||
0x06, 0x07, // OID 1.2.840.10045.2.1 (ecPublicKey)
|
||||
0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01,
|
||||
0x06, 0x08, // OID 1.2.840.10045.3.1.7 (P-256)
|
||||
0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07,
|
||||
0x04, 0x27, // OCTET STRING (39 bytes)
|
||||
0x30, 0x25, // SEQUENCE (37 bytes)
|
||||
0x02, 0x01, 0x01, // INTEGER version = 1
|
||||
0x04, 0x20, // OCTET STRING (32 bytes) — the private key
|
||||
]);
|
||||
|
||||
const result = new Uint8Array(header.length + 32);
|
||||
result.set(header);
|
||||
result.set(rawPrivateKey, header.length);
|
||||
return result.buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base58btc encoding (Bitcoin alphabet).
|
||||
* Minimal implementation — no external dependency needed.
|
||||
*/
|
||||
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||
|
||||
function base58btcEncode(bytes: Uint8Array): string {
|
||||
// Count leading zeros
|
||||
let zeroes = 0;
|
||||
for (const b of bytes) {
|
||||
if (b !== 0) break;
|
||||
zeroes++;
|
||||
}
|
||||
|
||||
// Convert to base58 using bigint arithmetic
|
||||
let num = BigInt(0);
|
||||
for (const b of bytes) {
|
||||
num = num * 256n + BigInt(b);
|
||||
}
|
||||
|
||||
let encoded = '';
|
||||
while (num > 0n) {
|
||||
const remainder = Number(num % 58n);
|
||||
num = num / 58n;
|
||||
encoded = BASE58_ALPHABET[remainder] + encoded;
|
||||
}
|
||||
|
||||
// Prepend '1' for each leading zero byte
|
||||
return '1'.repeat(zeroes) + encoded;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SINGLETON INSTANCE
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -106,13 +106,34 @@ export interface RecoveryRequest {
|
|||
export class RecoveryManager {
|
||||
private config: RecoveryConfig | null = null;
|
||||
private activeRequest: RecoveryRequest | null = null;
|
||||
private loaded = false;
|
||||
|
||||
constructor() {
|
||||
this.loadConfig();
|
||||
this.loadLocalSettings();
|
||||
}
|
||||
|
||||
/** Get the auth token for server API calls */
|
||||
private getAuthToken(): string | null {
|
||||
try {
|
||||
return localStorage.getItem('encryptid_token');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve EncryptID API base URL */
|
||||
private getApiBase(): string {
|
||||
// When running on the encryptid domain, use relative paths
|
||||
if (typeof location !== 'undefined' && location.hostname.includes('auth.')) {
|
||||
return '';
|
||||
}
|
||||
// From rspace canvas, reach encryptid via its public URL
|
||||
return 'https://auth.rspace.online';
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize recovery with default configuration
|
||||
* Initialize recovery with default configuration.
|
||||
* Fetches existing guardians from server if authenticated.
|
||||
*/
|
||||
async initializeRecovery(threshold: number = 3): Promise<RecoveryConfig> {
|
||||
this.config = {
|
||||
|
|
@ -123,25 +144,69 @@ export class RecoveryManager {
|
|||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
await this.saveConfig();
|
||||
// Try to load existing guardians from server
|
||||
await this.syncFromServer();
|
||||
|
||||
await this.saveLocalSettings();
|
||||
return this.config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a guardian
|
||||
* Ensure config is loaded (call before operations that need config)
|
||||
*/
|
||||
async ensureLoaded(): Promise<void> {
|
||||
if (this.loaded) return;
|
||||
await this.syncFromServer();
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a guardian — persists to server API (which sends invite email).
|
||||
* Falls back to local-only if not authenticated.
|
||||
*/
|
||||
async addGuardian(guardian: Omit<Guardian, 'id' | 'addedAt'>): Promise<Guardian> {
|
||||
if (!this.config) {
|
||||
throw new Error('Recovery not initialized');
|
||||
}
|
||||
|
||||
if (this.config.guardians.length >= 7) {
|
||||
throw new Error('Maximum of 7 guardians allowed');
|
||||
if (this.config.guardians.length >= 3) {
|
||||
throw new Error('Maximum of 3 guardians allowed. Remove one first.');
|
||||
}
|
||||
|
||||
const token = this.getAuthToken();
|
||||
let serverId: string | undefined;
|
||||
|
||||
// POST to server — creates DB row + sends invite email
|
||||
if (token) {
|
||||
const res = await fetch(`${this.getApiBase()}/api/guardians`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: guardian.name,
|
||||
email: guardian.contactEmail || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: 'Server error' }));
|
||||
throw new Error(err.error || `Server error: ${res.status}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
serverId = data.guardian?.id;
|
||||
|
||||
console.log('EncryptID: Guardian added via server', {
|
||||
id: serverId,
|
||||
inviteUrl: data.inviteUrl,
|
||||
});
|
||||
}
|
||||
|
||||
const newGuardian: Guardian = {
|
||||
...guardian,
|
||||
id: bufferToBase64url(crypto.getRandomValues(new Uint8Array(16)).buffer),
|
||||
id: serverId || bufferToBase64url(crypto.getRandomValues(new Uint8Array(16)).buffer),
|
||||
addedAt: Date.now(),
|
||||
};
|
||||
|
||||
|
|
@ -149,7 +214,7 @@ export class RecoveryManager {
|
|||
this.config.guardianListHash = await this.hashGuardianList();
|
||||
this.config.updatedAt = Date.now();
|
||||
|
||||
await this.saveConfig();
|
||||
await this.saveLocalSettings();
|
||||
|
||||
console.log('EncryptID: Guardian added', {
|
||||
type: GuardianType[guardian.type],
|
||||
|
|
@ -160,7 +225,7 @@ export class RecoveryManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Remove a guardian
|
||||
* Remove a guardian — deletes from server API and local config.
|
||||
*/
|
||||
async removeGuardian(guardianId: string): Promise<void> {
|
||||
if (!this.config) {
|
||||
|
|
@ -172,20 +237,25 @@ export class RecoveryManager {
|
|||
throw new Error('Guardian not found');
|
||||
}
|
||||
|
||||
// Don't allow removing if it would make recovery impossible
|
||||
const remainingWeight = this.config.guardians
|
||||
.filter(g => g.id !== guardianId)
|
||||
.reduce((sum, g) => sum + g.weight, 0);
|
||||
// DELETE from server
|
||||
const token = this.getAuthToken();
|
||||
if (token) {
|
||||
const res = await fetch(`${this.getApiBase()}/api/guardians/${guardianId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (remainingWeight < this.config.threshold) {
|
||||
throw new Error('Cannot remove guardian: would make recovery impossible');
|
||||
if (!res.ok && res.status !== 404) {
|
||||
const err = await res.json().catch(() => ({ error: 'Server error' }));
|
||||
throw new Error(err.error || `Server error: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.config.guardians.splice(index, 1);
|
||||
this.config.guardianListHash = await this.hashGuardianList();
|
||||
this.config.updatedAt = Date.now();
|
||||
|
||||
await this.saveConfig();
|
||||
await this.saveLocalSettings();
|
||||
|
||||
console.log('EncryptID: Guardian removed');
|
||||
}
|
||||
|
|
@ -210,7 +280,7 @@ export class RecoveryManager {
|
|||
this.config.threshold = threshold;
|
||||
this.config.updatedAt = Date.now();
|
||||
|
||||
await this.saveConfig();
|
||||
await this.saveLocalSettings();
|
||||
|
||||
console.log('EncryptID: Threshold updated to', threshold);
|
||||
}
|
||||
|
|
@ -231,7 +301,7 @@ export class RecoveryManager {
|
|||
this.config.delaySeconds = delaySeconds;
|
||||
this.config.updatedAt = Date.now();
|
||||
|
||||
await this.saveConfig();
|
||||
await this.saveLocalSettings();
|
||||
|
||||
console.log('EncryptID: Delay updated to', delaySeconds, 'seconds');
|
||||
}
|
||||
|
|
@ -254,7 +324,8 @@ export class RecoveryManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Verify a guardian is still reachable/valid
|
||||
* Verify a guardian is still reachable/valid.
|
||||
* Checks server for current guardian status (pending/accepted).
|
||||
*/
|
||||
async verifyGuardian(guardianId: string): Promise<boolean> {
|
||||
if (!this.config) {
|
||||
|
|
@ -266,15 +337,39 @@ export class RecoveryManager {
|
|||
throw new Error('Guardian not found');
|
||||
}
|
||||
|
||||
// In production, this would:
|
||||
// - For SECONDARY_PASSKEY: verify credential still exists
|
||||
// - For TRUSTED_CONTACT: send verification request
|
||||
// - For INSTITUTIONAL: ping service endpoint
|
||||
// - For HARDWARE_KEY: request signature
|
||||
const token = this.getAuthToken();
|
||||
if (token) {
|
||||
// Fetch current guardian list from server to check status
|
||||
const res = await fetch(`${this.getApiBase()}/api/guardians`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const serverGuardian = data.guardians?.find((g: any) => g.id === guardianId);
|
||||
|
||||
if (!serverGuardian) {
|
||||
throw new Error('Guardian not found on server — may have been removed');
|
||||
}
|
||||
|
||||
// Check acceptance status
|
||||
if (serverGuardian.status === 'accepted') {
|
||||
guardian.lastVerified = Date.now();
|
||||
await this.saveLocalSettings();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Still pending — guardian hasn't accepted invite yet
|
||||
if (serverGuardian.status === 'pending') {
|
||||
console.log('EncryptID: Guardian invite still pending', { guardianId });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: mark as verified locally (offline mode)
|
||||
guardian.lastVerified = Date.now();
|
||||
await this.saveConfig();
|
||||
|
||||
await this.saveLocalSettings();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -458,9 +553,11 @@ export class RecoveryManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Save configuration to storage
|
||||
* Save local settings (threshold, delay) to localStorage.
|
||||
* Guardian CRUD goes through the server API — this only persists
|
||||
* config that the server doesn't track (threshold, delay, type metadata).
|
||||
*/
|
||||
private async saveConfig(): Promise<void> {
|
||||
private async saveLocalSettings(): Promise<void> {
|
||||
if (!this.config) return;
|
||||
|
||||
try {
|
||||
|
|
@ -471,9 +568,10 @@ export class RecoveryManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Load configuration from storage
|
||||
* Load local settings from localStorage (synchronous, for constructor).
|
||||
* Call syncFromServer() after construction for full server sync.
|
||||
*/
|
||||
private loadConfig(): void {
|
||||
private loadLocalSettings(): void {
|
||||
try {
|
||||
const stored = localStorage.getItem('encryptid_recovery');
|
||||
if (stored) {
|
||||
|
|
@ -485,23 +583,87 @@ export class RecoveryManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Notify user of recovery events
|
||||
* Sync guardian list from server API into local config.
|
||||
* Maps server guardian model to client Guardian type.
|
||||
*/
|
||||
private async syncFromServer(): Promise<void> {
|
||||
const token = this.getAuthToken();
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${this.getApiBase()}/api/guardians`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!res.ok) return;
|
||||
|
||||
const data = await res.json();
|
||||
if (!data.guardians || !Array.isArray(data.guardians)) return;
|
||||
|
||||
if (!this.config) {
|
||||
this.config = {
|
||||
threshold: data.threshold || 2,
|
||||
delaySeconds: 48 * 60 * 60,
|
||||
guardians: [],
|
||||
guardianListHash: '',
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
// Build a map of existing local guardians for metadata preservation
|
||||
const localMap = new Map(this.config.guardians.map(g => [g.id, g]));
|
||||
|
||||
// Merge server guardians with local type metadata
|
||||
this.config.guardians = data.guardians.map((sg: any) => {
|
||||
const local = localMap.get(sg.id);
|
||||
return {
|
||||
id: sg.id,
|
||||
type: local?.type ?? GuardianType.TRUSTED_CONTACT,
|
||||
name: sg.name,
|
||||
weight: local?.weight ?? 1,
|
||||
contactEmail: sg.email || local?.contactEmail,
|
||||
addedAt: sg.createdAt ? new Date(sg.createdAt).getTime() : (local?.addedAt ?? Date.now()),
|
||||
lastVerified: sg.status === 'accepted' ? (sg.acceptedAt ? new Date(sg.acceptedAt).getTime() : Date.now()) : local?.lastVerified,
|
||||
// Preserve type-specific fields from local
|
||||
credentialId: local?.credentialId,
|
||||
contactDID: local?.contactDID,
|
||||
serviceUrl: local?.serviceUrl,
|
||||
delaySeconds: local?.delaySeconds,
|
||||
} satisfies Guardian;
|
||||
});
|
||||
|
||||
this.config.guardianListHash = await this.hashGuardianList();
|
||||
this.config.updatedAt = Date.now();
|
||||
|
||||
await this.saveLocalSettings();
|
||||
|
||||
console.log('EncryptID: Synced guardians from server', {
|
||||
count: this.config.guardians.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('EncryptID: Failed to sync guardians from server', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify user of recovery events via CustomEvent on document.
|
||||
*/
|
||||
private async notifyUser(
|
||||
event: string,
|
||||
request: RecoveryRequest
|
||||
): Promise<void> {
|
||||
// In production, this would:
|
||||
// - Send email notification
|
||||
// - Send push notification
|
||||
// - Send SMS (if configured)
|
||||
// - Post to webhook
|
||||
|
||||
console.log('EncryptID: User notification', { event, requestId: request.id });
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.dispatchEvent(new CustomEvent('encryptid:recovery', {
|
||||
detail: { event, requestId: request.id, status: request.status },
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify guardians of recovery events
|
||||
* Notify guardians of recovery events via CustomEvent on document.
|
||||
* Server-side email notifications are handled by the guardian invite flow.
|
||||
*/
|
||||
private async notifyGuardians(
|
||||
event: string,
|
||||
|
|
@ -509,13 +671,19 @@ export class RecoveryManager {
|
|||
): Promise<void> {
|
||||
if (!this.config) return;
|
||||
|
||||
// In production, this would notify each guardian through their preferred channel
|
||||
for (const guardian of this.config.guardians) {
|
||||
console.log('EncryptID: Guardian notification', {
|
||||
event,
|
||||
guardianId: guardian.id,
|
||||
guardianType: GuardianType[guardian.type],
|
||||
});
|
||||
console.log('EncryptID: Guardian notification', {
|
||||
event,
|
||||
guardianCount: this.config.guardians.length,
|
||||
});
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.dispatchEvent(new CustomEvent('encryptid:guardian-notification', {
|
||||
detail: {
|
||||
event,
|
||||
requestId: request.id,
|
||||
guardianIds: this.config.guardians.map(g => g.id),
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -449,20 +449,22 @@ export class GuardianSetupElement extends HTMLElement {
|
|||
|
||||
connectedCallback() {
|
||||
this.loadConfig();
|
||||
this.render();
|
||||
}
|
||||
|
||||
private loadConfig() {
|
||||
private async loadConfig() {
|
||||
const manager = getRecoveryManager();
|
||||
|
||||
// Ensure server sync is complete before reading config
|
||||
await manager.ensureLoaded();
|
||||
this.config = manager.getConfig();
|
||||
|
||||
// Initialize if not configured
|
||||
if (!this.config) {
|
||||
manager.initializeRecovery(3).then(() => {
|
||||
this.config = manager.getConfig();
|
||||
this.render();
|
||||
});
|
||||
await manager.initializeRecovery(3);
|
||||
this.config = manager.getConfig();
|
||||
}
|
||||
|
||||
this.render();
|
||||
}
|
||||
|
||||
private render() {
|
||||
|
|
|
|||
Loading…
Reference in New Issue