daku 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +199 -0
- package/index.js +256 -0
- package/package.json +50 -0
package/README.md
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# daku
|
|
2
|
+
|
|
3
|
+
> Leave no trace. Just authenticate.
|
|
4
|
+
|
|
5
|
+
Cryptographic authentication library with built-in proof-of-work spam protection. No emails, no passwords, no personal data.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install daku
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
### 1. Generate a Keypair
|
|
16
|
+
|
|
17
|
+
```javascript
|
|
18
|
+
import { generateKeyPair } from 'daku';
|
|
19
|
+
|
|
20
|
+
// Generate once and store securely (localStorage, secure storage, etc.)
|
|
21
|
+
const { privateKey, publicKey } = generateKeyPair();
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### 2. Client-Side: Create Authentication Request
|
|
25
|
+
|
|
26
|
+
```javascript
|
|
27
|
+
import { createAuth } from 'daku';
|
|
28
|
+
|
|
29
|
+
// Create authentication token (includes timestamp, nonce, signature, and POW)
|
|
30
|
+
const token = await createAuth(privateKey);
|
|
31
|
+
|
|
32
|
+
// Send to your server
|
|
33
|
+
fetch('/api/login', {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: {
|
|
36
|
+
'Content-Type': 'application/json',
|
|
37
|
+
'daku': token
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### 3. Server-Side: Verify Authentication
|
|
43
|
+
|
|
44
|
+
```javascript
|
|
45
|
+
import { verifyAuth } from 'daku';
|
|
46
|
+
|
|
47
|
+
const publicKey = await verifyAuth(req.headers['daku']);
|
|
48
|
+
|
|
49
|
+
if (publicKey) {
|
|
50
|
+
// ✅ Authenticated! Use publicKey as unique user ID
|
|
51
|
+
console.log(`User ${publicKey} authenticated`);
|
|
52
|
+
} else {
|
|
53
|
+
// ❌ Invalid or expired authentication
|
|
54
|
+
res.status(401).json({ error: 'Unauthorized' });
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## ExpressJS Middleware
|
|
59
|
+
|
|
60
|
+
```javascript
|
|
61
|
+
import express from 'express';
|
|
62
|
+
import { verifyAuth } from 'daku';
|
|
63
|
+
|
|
64
|
+
const app = express();
|
|
65
|
+
|
|
66
|
+
// Reusable authentication middleware
|
|
67
|
+
const daku = (powDifficulty = 2) => {
|
|
68
|
+
return async (req, res, next) => {
|
|
69
|
+
const token = req.headers['daku'];
|
|
70
|
+
|
|
71
|
+
if (!token) {
|
|
72
|
+
return res.status(401).json({ error: 'Unauthorized' });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const publicKey = await verifyAuth(token, powDifficulty);
|
|
76
|
+
|
|
77
|
+
if (!publicKey) {
|
|
78
|
+
return res.status(401).json({ error: 'Unauthorized' });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Attach user's public key to request
|
|
82
|
+
req.userId = publicKey;
|
|
83
|
+
next();
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Use on protected routes
|
|
88
|
+
app.post('/api/protected', daku(), (req, res) => {
|
|
89
|
+
res.json({
|
|
90
|
+
message: 'Access granted',
|
|
91
|
+
userId: req.userId
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Use with custom POW difficulty
|
|
96
|
+
app.post('/api/high-security', daku(4), (req, res) => {
|
|
97
|
+
res.json({ message: 'High security endpoint' });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
app.listen(3000);
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Key Features
|
|
104
|
+
|
|
105
|
+
- **🕵️ Anonymous**: No email, phone, or personal data required
|
|
106
|
+
- **🛡️ Spam Protection**: Built-in proof-of-work (default: 2 leading zeros)
|
|
107
|
+
- **🔐 Secure**: secp256k1 cryptographic signatures (same as Bitcoin/Ethereum)
|
|
108
|
+
- **⚡ Lightweight**: Minimal dependencies (@noble/secp256k1, @noble/hashes)
|
|
109
|
+
- **🌐 Cross-Platform**: Works in Node.js and browsers
|
|
110
|
+
- **⏱️ Time-Limited**: Auth requests expire after 1 minute
|
|
111
|
+
|
|
112
|
+
## API Reference
|
|
113
|
+
|
|
114
|
+
### Authentication Functions
|
|
115
|
+
|
|
116
|
+
#### `createAuth(privateKey, pow = 2)`
|
|
117
|
+
|
|
118
|
+
Creates a complete authentication token with timestamp, nonce, signature, and proof-of-work.
|
|
119
|
+
|
|
120
|
+
**Returns:** String (base64-encoded token)
|
|
121
|
+
|
|
122
|
+
**Example:**
|
|
123
|
+
```javascript
|
|
124
|
+
const token = await createAuth(privateKey);
|
|
125
|
+
// "eyJwdWJsaWNrZXkiOiIwMmE..."
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
#### `verifyAuth(token, pow = 2)`
|
|
129
|
+
|
|
130
|
+
Verifies authentication token. Checks signature validity, proof-of-work, and timestamp (must be within 1 minute).
|
|
131
|
+
|
|
132
|
+
**Returns:** `publicKey` string on success, `null` on failure
|
|
133
|
+
|
|
134
|
+
**Example:**
|
|
135
|
+
```javascript
|
|
136
|
+
const publicKey = await verifyAuth(token);
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
### General Signing Functions
|
|
142
|
+
|
|
143
|
+
Use these for signing arbitrary messages (not authentication).
|
|
144
|
+
|
|
145
|
+
#### `sign(message, privateKey, pow = 2)`
|
|
146
|
+
|
|
147
|
+
Signs any message with proof-of-work.
|
|
148
|
+
|
|
149
|
+
**Returns:** Object with `{ signature, pow }`
|
|
150
|
+
|
|
151
|
+
**Example:**
|
|
152
|
+
```javascript
|
|
153
|
+
const result = await sign('Hello World', privateKey);
|
|
154
|
+
// { signature: 'a1b2c3...', pow: 42 }
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
#### `verify(message, signatureData, publicKey, pow = 2)`
|
|
158
|
+
|
|
159
|
+
Verifies a signed message.
|
|
160
|
+
|
|
161
|
+
**Returns:** `true` if valid, `false` otherwise
|
|
162
|
+
|
|
163
|
+
**Example:**
|
|
164
|
+
```javascript
|
|
165
|
+
const isValid = await verify('Hello World', { signature: 'a1b2...', pow: 42 }, publicKey);
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
### Utility Functions
|
|
171
|
+
|
|
172
|
+
#### `generateKeyPair()`
|
|
173
|
+
|
|
174
|
+
Generates a new secp256k1 keypair.
|
|
175
|
+
|
|
176
|
+
**Returns:** `{ privateKey: string, publicKey: string }`
|
|
177
|
+
|
|
178
|
+
#### `getPublicKey(privateKey)`
|
|
179
|
+
|
|
180
|
+
Derives the public key from a private key.
|
|
181
|
+
|
|
182
|
+
**Returns:** `string` (compressed public key in hex)
|
|
183
|
+
|
|
184
|
+
#### `sha256(message)`
|
|
185
|
+
|
|
186
|
+
SHA-256 hash helper.
|
|
187
|
+
|
|
188
|
+
**Returns:** `Uint8Array`
|
|
189
|
+
|
|
190
|
+
## Use Cases
|
|
191
|
+
|
|
192
|
+
- **Authentication**: Use `createAuth()` + `verifyAuth()` for login flows
|
|
193
|
+
- **Message Signing**: Use `sign()` + `verify()` for arbitrary data signatures
|
|
194
|
+
- **Spam Prevention**: POW difficulty prevents automated abuse
|
|
195
|
+
- **Privacy-First Apps**: No PII required, just cryptographic proofs
|
|
196
|
+
|
|
197
|
+
## License
|
|
198
|
+
|
|
199
|
+
ISC
|
package/index.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// Signature-Login: Cross-platform cryptographic login/verify module
|
|
2
|
+
import * as secp from "@noble/secp256k1";
|
|
3
|
+
import { hmac } from "@noble/hashes/hmac";
|
|
4
|
+
import { sha256 as nobleSha256 } from "@noble/hashes/sha2";
|
|
5
|
+
|
|
6
|
+
// Set up HMAC for secp256k1
|
|
7
|
+
secp.etc.hmacSha256Sync = (key, ...msgs) => hmac(nobleSha256, key, secp.etc.concatBytes(...msgs));
|
|
8
|
+
|
|
9
|
+
// Browser compatibility helper for TextEncoder
|
|
10
|
+
async function getTextEncoder() {
|
|
11
|
+
if (typeof window !== "undefined") {
|
|
12
|
+
return new window.TextEncoder();
|
|
13
|
+
} else {
|
|
14
|
+
// Dynamic import for Node.js only
|
|
15
|
+
const util = await import("node:util");
|
|
16
|
+
return new util.TextEncoder();
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Helper function to convert bytes to hex
|
|
21
|
+
function bytesToHex(bytes) {
|
|
22
|
+
return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Helper function to convert hex to bytes
|
|
26
|
+
function hexToBytes(hex) {
|
|
27
|
+
return new Uint8Array(hex.match(/.{1,2}/g).map(byte => parseInt(byte, 16)));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Helper function to generate random bytes
|
|
31
|
+
async function randomBytes(length) {
|
|
32
|
+
if (typeof window !== "undefined" && window.crypto) {
|
|
33
|
+
const bytes = new Uint8Array(length);
|
|
34
|
+
window.crypto.getRandomValues(bytes);
|
|
35
|
+
return bytes;
|
|
36
|
+
} else {
|
|
37
|
+
// Dynamic import for Node.js only
|
|
38
|
+
const crypto = await import("node:crypto");
|
|
39
|
+
return new Uint8Array(crypto.randomBytes(length));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// --- Base64 Encoding/Decoding Helpers ---
|
|
44
|
+
// Browser-safe base64 encode
|
|
45
|
+
function base64Encode(obj) {
|
|
46
|
+
const json = JSON.stringify(obj);
|
|
47
|
+
if (typeof window !== "undefined") {
|
|
48
|
+
return btoa(json);
|
|
49
|
+
} else {
|
|
50
|
+
return Buffer.from(json).toString('base64');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Browser-safe base64 decode
|
|
55
|
+
function base64Decode(str) {
|
|
56
|
+
if (typeof window !== "undefined") {
|
|
57
|
+
return JSON.parse(atob(str));
|
|
58
|
+
} else {
|
|
59
|
+
return JSON.parse(Buffer.from(str, 'base64').toString());
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// --- Key Generation ---
|
|
64
|
+
export function generateKeyPair() {
|
|
65
|
+
const privateKey = secp.utils.randomPrivateKey();
|
|
66
|
+
const publicKey = secp.getPublicKey(privateKey, true); // compressed
|
|
67
|
+
return {
|
|
68
|
+
privateKey: bytesToHex(privateKey),
|
|
69
|
+
publicKey: bytesToHex(publicKey),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// --- Get Public Key from Private Key ---
|
|
74
|
+
export function getPublicKey(privateKeyHex) {
|
|
75
|
+
const privateKeyBytes = hexToBytes(privateKeyHex);
|
|
76
|
+
const publicKeyBytes = secp.getPublicKey(privateKeyBytes, true); // compressed
|
|
77
|
+
return bytesToHex(publicKeyBytes);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// --- Hashing (SHA-256) ---
|
|
81
|
+
export async function sha256(msg) {
|
|
82
|
+
const encoder = await getTextEncoder();
|
|
83
|
+
const encoded = encoder.encode(msg);
|
|
84
|
+
|
|
85
|
+
if (typeof window === "undefined") {
|
|
86
|
+
// Node.js - dynamic import
|
|
87
|
+
const crypto = await import("node:crypto");
|
|
88
|
+
return new Uint8Array(crypto.createHash("sha256").update(encoded).digest());
|
|
89
|
+
} else {
|
|
90
|
+
// Browser
|
|
91
|
+
const hash = await window.crypto.subtle.digest("SHA-256", encoded);
|
|
92
|
+
return new Uint8Array(hash);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// --- Proof of Work Helper ---
|
|
97
|
+
async function solveProofOfWork(message, difficulty = 2) {
|
|
98
|
+
if (difficulty < 1) {
|
|
99
|
+
difficulty = 1; // Minimum POW is 1
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const target = '0'.repeat(difficulty);
|
|
103
|
+
let nonce = 0;
|
|
104
|
+
|
|
105
|
+
while (true) {
|
|
106
|
+
const combined = message + nonce;
|
|
107
|
+
const hash = await sha256(combined);
|
|
108
|
+
const hexHash = bytesToHex(hash);
|
|
109
|
+
|
|
110
|
+
if (hexHash.startsWith(target)) {
|
|
111
|
+
return nonce;
|
|
112
|
+
}
|
|
113
|
+
nonce++;
|
|
114
|
+
|
|
115
|
+
// Yield to event loop every 1000 attempts to avoid blocking
|
|
116
|
+
if (nonce % 1000 === 0) {
|
|
117
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// --- Verify Proof of Work ---
|
|
123
|
+
async function verifyProofOfWork(message, powNonce, difficulty = 2) {
|
|
124
|
+
if (difficulty < 1) {
|
|
125
|
+
difficulty = 1; // Minimum POW is 1
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (powNonce === null || powNonce === undefined) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const target = '0'.repeat(difficulty);
|
|
133
|
+
const combined = message + powNonce;
|
|
134
|
+
const hash = await sha256(combined);
|
|
135
|
+
const hexHash = bytesToHex(hash);
|
|
136
|
+
|
|
137
|
+
return hexHash.startsWith(target);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// --- Sign Message ---
|
|
141
|
+
export async function sign(message, privateKeyHex, pow = 2) {
|
|
142
|
+
// Enforce minimum POW of 1
|
|
143
|
+
if (pow < 1) {
|
|
144
|
+
pow = 1;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const hash = await sha256(message);
|
|
148
|
+
const privateKeyBytes = hexToBytes(privateKeyHex);
|
|
149
|
+
const sig = secp.sign(hash, privateKeyBytes);
|
|
150
|
+
const signature = bytesToHex(sig.toCompactRawBytes());
|
|
151
|
+
|
|
152
|
+
// Always generate POW and return object format
|
|
153
|
+
const powNonce = await solveProofOfWork(message, pow);
|
|
154
|
+
return { signature, pow: powNonce };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// --- Verify Signature ---
|
|
158
|
+
export async function verify(message, signatureData, publicKeyHex, pow = 2) {
|
|
159
|
+
try {
|
|
160
|
+
// Enforce minimum POW of 1
|
|
161
|
+
if (pow < 1) {
|
|
162
|
+
pow = 1;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Always expect object format { signature, pow }
|
|
166
|
+
const signatureHex = signatureData.signature;
|
|
167
|
+
const powNonce = signatureData.pow;
|
|
168
|
+
|
|
169
|
+
if (!signatureHex || powNonce === undefined || powNonce === null) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Verify POW
|
|
174
|
+
const powValid = await verifyProofOfWork(message, powNonce, pow);
|
|
175
|
+
if (!powValid) {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Verify signature
|
|
180
|
+
const hash = await sha256(message);
|
|
181
|
+
const signatureBytes = hexToBytes(signatureHex);
|
|
182
|
+
const publicKeyBytes = hexToBytes(publicKeyHex);
|
|
183
|
+
return secp.verify(signatureBytes, hash, publicKeyBytes);
|
|
184
|
+
} catch {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// --- Auth Header Helper ---
|
|
190
|
+
export async function createAuth(privateKeyHex, pow = 2) {
|
|
191
|
+
// Enforce minimum POW of 1
|
|
192
|
+
if (pow < 1) {
|
|
193
|
+
pow = 1;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const publicKeyHex = getPublicKey(privateKeyHex);
|
|
197
|
+
|
|
198
|
+
const timestamp = Date.now();
|
|
199
|
+
const nonceBytes = await randomBytes(16);
|
|
200
|
+
const nonce = bytesToHex(nonceBytes);
|
|
201
|
+
const message = `${timestamp}:${nonce}`;
|
|
202
|
+
|
|
203
|
+
const signatureData = await sign(message, privateKeyHex, pow);
|
|
204
|
+
|
|
205
|
+
const authPayload = {
|
|
206
|
+
publickey: publicKeyHex,
|
|
207
|
+
signature: signatureData.signature,
|
|
208
|
+
pow: signatureData.pow,
|
|
209
|
+
message,
|
|
210
|
+
timestamp,
|
|
211
|
+
nonce
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
return base64Encode(authPayload);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// --- Verify Auth Token ---
|
|
218
|
+
export async function verifyAuth(token, pow = 2) {
|
|
219
|
+
try {
|
|
220
|
+
// Enforce minimum POW of 1
|
|
221
|
+
if (pow < 1) {
|
|
222
|
+
pow = 1;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Decode token
|
|
226
|
+
const authData = base64Decode(token);
|
|
227
|
+
const publicKeyHex = authData.publickey;
|
|
228
|
+
const { signature, message, pow: powNonce } = authData;
|
|
229
|
+
|
|
230
|
+
if (!publicKeyHex || !signature || !message || powNonce === undefined || powNonce === null) {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Extract timestamp from message
|
|
235
|
+
const timestamp = Number(message.split(':')[0]);
|
|
236
|
+
|
|
237
|
+
// Check timestamp is within 1 minute
|
|
238
|
+
const maxAgeMs = 1 * 60 * 1000; // Hardcoded to 1 minute
|
|
239
|
+
const now = Date.now();
|
|
240
|
+
if (isNaN(timestamp) || Math.abs(now - timestamp) > maxAgeMs) {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Verify signature and POW
|
|
245
|
+
const signatureData = { signature, pow: powNonce };
|
|
246
|
+
const isValid = await verify(message, signatureData, publicKeyHex, pow);
|
|
247
|
+
if (!isValid) {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return publicKeyHex;
|
|
252
|
+
|
|
253
|
+
} catch {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "daku",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Leave no trace. Just authenticate.",
|
|
5
|
+
"homepage": "https://github.com/besoeasy/daku#readme",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"authentication",
|
|
8
|
+
"anonymous",
|
|
9
|
+
"privacy",
|
|
10
|
+
"passwordless",
|
|
11
|
+
"no-email",
|
|
12
|
+
"cryptographic",
|
|
13
|
+
"signature",
|
|
14
|
+
"secp256k1",
|
|
15
|
+
"proof-of-work",
|
|
16
|
+
"pow",
|
|
17
|
+
"spam-protection",
|
|
18
|
+
"login",
|
|
19
|
+
"auth",
|
|
20
|
+
"crypto",
|
|
21
|
+
"keypair",
|
|
22
|
+
"bitcoin",
|
|
23
|
+
"ethereum",
|
|
24
|
+
"web3",
|
|
25
|
+
"zero-knowledge",
|
|
26
|
+
"gdpr",
|
|
27
|
+
"no-pii",
|
|
28
|
+
"anonymous-auth",
|
|
29
|
+
"private-auth",
|
|
30
|
+
"ghost"
|
|
31
|
+
],
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/besoeasy/daku/issues"
|
|
34
|
+
},
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/besoeasy/daku.git"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@noble/hashes": "^1.8.0",
|
|
41
|
+
"@noble/secp256k1": "^2.3.0"
|
|
42
|
+
},
|
|
43
|
+
"license": "ISC",
|
|
44
|
+
"author": "besoeasy",
|
|
45
|
+
"type": "module",
|
|
46
|
+
"main": "index.js",
|
|
47
|
+
"scripts": {
|
|
48
|
+
"test": "node test.js"
|
|
49
|
+
}
|
|
50
|
+
}
|