nodio-cli 1.1.1 → 1.1.2
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/package.json +3 -1
- package/render.yaml +8 -0
- package/src/cli/session.js +93 -0
- package/src/server/index.js +3 -1
- package/src/server/middleware/verifyToken.js +35 -0
- package/src/server/models/user.js +17 -0
- package/src/server/models.js +4 -0
- package/src/server/routes/auth.js +96 -0
- package/src/server/routes.js +59 -26
- package/src/user/commands.js +161 -2
- package/src/user/index.js +33 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nodio-cli",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
4
4
|
"description": "Nodio distributed storage network",
|
|
5
5
|
"main": "src/server/index.js",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@filoz/synapse-sdk": "^0.41.0",
|
|
28
28
|
"axios": "^1.11.0",
|
|
29
|
+
"argon2": "^0.43.0",
|
|
30
|
+
"bcrypt": "^6.0.0",
|
|
29
31
|
"commander": "^14.0.1",
|
|
30
32
|
"cors": "^2.8.5",
|
|
31
33
|
"dotenv": "^17.4.2",
|
package/render.yaml
CHANGED
|
@@ -12,6 +12,14 @@ services:
|
|
|
12
12
|
value: 20
|
|
13
13
|
- key: NODIO_MONGO_URI
|
|
14
14
|
sync: false
|
|
15
|
+
- key: NODIO_WALLET_PRIVATE_KEY
|
|
16
|
+
sync: false
|
|
17
|
+
- key: NODIO_WALLET_ADDRESS
|
|
18
|
+
sync: false
|
|
19
|
+
- key: FILECOIN_NETWORK
|
|
20
|
+
sync: false
|
|
21
|
+
- key: FILECOIN_RPC_URL
|
|
22
|
+
sync: false
|
|
15
23
|
- key: NODIO_HEARTBEAT_INTERVAL_MS
|
|
16
24
|
value: "30000"
|
|
17
25
|
- key: NODIO_OFFLINE_AFTER_MISSES
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const fs = require('fs/promises');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
const argon2 = require('argon2');
|
|
6
|
+
const { encryptAes256Gcm, decryptAes256Gcm } = require('../common/crypto');
|
|
7
|
+
|
|
8
|
+
const SESSION_DIR = '.nodio';
|
|
9
|
+
const SESSION_FILE = 'session.json';
|
|
10
|
+
|
|
11
|
+
function getSessionPath() {
|
|
12
|
+
return path.join(os.homedir(), SESSION_DIR, SESSION_FILE);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function loadSession() {
|
|
16
|
+
try {
|
|
17
|
+
const raw = await fs.readFile(getSessionPath(), 'utf8');
|
|
18
|
+
return JSON.parse(raw);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if (error.code === 'ENOENT') {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function saveSession(session) {
|
|
28
|
+
const dir = path.join(os.homedir(), SESSION_DIR);
|
|
29
|
+
await fs.mkdir(dir, { recursive: true });
|
|
30
|
+
await fs.writeFile(getSessionPath(), JSON.stringify(session, null, 2));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function clearSession() {
|
|
34
|
+
try {
|
|
35
|
+
await fs.unlink(getSessionPath());
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error.code !== 'ENOENT') {
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function deriveMasterKey(password, argon2Salt) {
|
|
44
|
+
const saltBuffer = Buffer.from(argon2Salt, 'hex');
|
|
45
|
+
return argon2.hash(password, {
|
|
46
|
+
type: argon2.argon2id,
|
|
47
|
+
salt: saltBuffer,
|
|
48
|
+
hashLength: 32,
|
|
49
|
+
raw: true
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function deriveSessionKey(apiToken) {
|
|
54
|
+
return crypto.createHash('sha256').update(apiToken).digest();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function packEncryptedKey({ iv, authTag, cipherText }) {
|
|
58
|
+
return `${iv}:${authTag}:${cipherText.toString('base64')}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function unpackEncryptedKey(payload) {
|
|
62
|
+
const [iv, authTag, cipherText] = String(payload || '').split(':');
|
|
63
|
+
if (!iv || !authTag || !cipherText) {
|
|
64
|
+
throw new Error('invalid encryptedMasterKey format');
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
iv,
|
|
68
|
+
authTag,
|
|
69
|
+
cipherText: Buffer.from(cipherText, 'base64')
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function encryptMasterKey(masterKeyBuffer, apiToken) {
|
|
74
|
+
const sessionKey = deriveSessionKey(apiToken);
|
|
75
|
+
const encrypted = encryptAes256Gcm(masterKeyBuffer, sessionKey);
|
|
76
|
+
return packEncryptedKey(encrypted);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function decryptMasterKey(encryptedMasterKey, apiToken) {
|
|
80
|
+
const sessionKey = deriveSessionKey(apiToken);
|
|
81
|
+
const payload = unpackEncryptedKey(encryptedMasterKey);
|
|
82
|
+
return decryptAes256Gcm(payload.cipherText, sessionKey, payload.iv, payload.authTag);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = {
|
|
86
|
+
getSessionPath,
|
|
87
|
+
loadSession,
|
|
88
|
+
saveSession,
|
|
89
|
+
clearSession,
|
|
90
|
+
deriveMasterKey,
|
|
91
|
+
encryptMasterKey,
|
|
92
|
+
decryptMasterKey
|
|
93
|
+
};
|
package/src/server/index.js
CHANGED
|
@@ -4,6 +4,7 @@ const cors = require('cors');
|
|
|
4
4
|
const mongoose = require('mongoose');
|
|
5
5
|
const { getServerConfig } = require('./config');
|
|
6
6
|
const { buildRoutes } = require('./routes');
|
|
7
|
+
const authRoutes = require('./routes/auth');
|
|
7
8
|
const { NodeModel } = require('./models');
|
|
8
9
|
const { markNodeOfflineAndRecover } = require('./services');
|
|
9
10
|
const { getWalletBalance } = require('../../services/filecoin');
|
|
@@ -29,13 +30,14 @@ async function startServer() {
|
|
|
29
30
|
callback(new Error('Not allowed by CORS'));
|
|
30
31
|
},
|
|
31
32
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
|
32
|
-
allowedHeaders: ['Content-Type', 'Authorization'],
|
|
33
|
+
allowedHeaders: ['Content-Type', 'Authorization', 'x-api-token'],
|
|
33
34
|
optionsSuccessStatus: 200
|
|
34
35
|
};
|
|
35
36
|
|
|
36
37
|
app.use(cors(corsOptions));
|
|
37
38
|
app.options(/.*/, cors(corsOptions));
|
|
38
39
|
app.use(express.json({ limit: '50mb' }));
|
|
40
|
+
app.use('/api/auth', authRoutes);
|
|
39
41
|
app.use('/api', buildRoutes(config));
|
|
40
42
|
|
|
41
43
|
app.use((error, _req, res, _next) => {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const { UserModel } = require('../models');
|
|
2
|
+
|
|
3
|
+
function extractToken(req) {
|
|
4
|
+
const header = req.headers.authorization || '';
|
|
5
|
+
if (header.startsWith('Bearer ')) {
|
|
6
|
+
return header.slice('Bearer '.length).trim();
|
|
7
|
+
}
|
|
8
|
+
const apiTokenHeader = req.headers['x-api-token'];
|
|
9
|
+
if (typeof apiTokenHeader === 'string' && apiTokenHeader.trim()) {
|
|
10
|
+
return apiTokenHeader.trim();
|
|
11
|
+
}
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function verifyToken(req, res, next) {
|
|
16
|
+
try {
|
|
17
|
+
const token = extractToken(req);
|
|
18
|
+
if (!token) {
|
|
19
|
+
return res.status(401).json({ error: 'Unauthorized' });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const user = await UserModel.findOne({ apiToken: token }).lean();
|
|
23
|
+
if (!user) {
|
|
24
|
+
return res.status(401).json({ error: 'Unauthorized' });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
req.userId = user.userId;
|
|
28
|
+
req.user = user;
|
|
29
|
+
return next();
|
|
30
|
+
} catch (error) {
|
|
31
|
+
return next(error);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = verifyToken;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const mongoose = require('mongoose');
|
|
2
|
+
|
|
3
|
+
const userSchema = new mongoose.Schema(
|
|
4
|
+
{
|
|
5
|
+
userId: { type: String, required: true, unique: true, index: true },
|
|
6
|
+
email: { type: String, required: true, unique: true, index: true },
|
|
7
|
+
passwordHash: { type: String, required: true },
|
|
8
|
+
argon2Salt: { type: String, required: true },
|
|
9
|
+
apiToken: { type: String, required: true, unique: true, index: true },
|
|
10
|
+
createdAt: { type: Date, default: Date.now }
|
|
11
|
+
},
|
|
12
|
+
{ timestamps: false }
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
module.exports = {
|
|
16
|
+
UserModel: mongoose.model('User', userSchema)
|
|
17
|
+
};
|
package/src/server/models.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const mongoose = require('mongoose');
|
|
2
|
+
const { UserModel } = require('./models/user');
|
|
2
3
|
|
|
3
4
|
const nodeSchema = new mongoose.Schema(
|
|
4
5
|
{
|
|
@@ -23,6 +24,8 @@ const fileSchema = new mongoose.Schema(
|
|
|
23
24
|
sizeBytes: { type: Number, required: true, min: 0 },
|
|
24
25
|
shardCount: { type: Number, required: true, min: 1 },
|
|
25
26
|
cipher: { type: String, required: true, default: 'aes-256-gcm' },
|
|
27
|
+
userId: { type: String, default: null, index: true },
|
|
28
|
+
encryptedAESKey: { type: String, default: null },
|
|
26
29
|
filecoinCid: { type: String, default: null },
|
|
27
30
|
filecoinBackedUp: { type: Boolean, default: false },
|
|
28
31
|
metadata: { type: mongoose.Schema.Types.Mixed, default: {} }
|
|
@@ -110,6 +113,7 @@ const relayTaskSchema = new mongoose.Schema(
|
|
|
110
113
|
relayTaskSchema.index({ opId: 1, nodeId: 1, taskType: 1 });
|
|
111
114
|
|
|
112
115
|
module.exports = {
|
|
116
|
+
UserModel,
|
|
113
117
|
NodeModel: mongoose.model('Node', nodeSchema),
|
|
114
118
|
FileModel: mongoose.model('File', fileSchema),
|
|
115
119
|
ShardModel: mongoose.model('Shard', shardSchema),
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const crypto = require('crypto');
|
|
3
|
+
const bcrypt = require('bcrypt');
|
|
4
|
+
const { v4: uuidv4 } = require('uuid');
|
|
5
|
+
const { UserModel } = require('../models');
|
|
6
|
+
const verifyToken = require('../middleware/verifyToken');
|
|
7
|
+
|
|
8
|
+
function isValidEmail(email) {
|
|
9
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const router = express.Router();
|
|
13
|
+
|
|
14
|
+
router.post('/register', async (req, res, next) => {
|
|
15
|
+
try {
|
|
16
|
+
const email = String(req.body?.email || '').trim().toLowerCase();
|
|
17
|
+
const password = String(req.body?.password || '');
|
|
18
|
+
|
|
19
|
+
if (!email || !isValidEmail(email)) {
|
|
20
|
+
return res.status(400).json({ error: 'Invalid email format' });
|
|
21
|
+
}
|
|
22
|
+
if (!password) {
|
|
23
|
+
return res.status(400).json({ error: 'Password is required' });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const existing = await UserModel.findOne({ email }).lean();
|
|
27
|
+
if (existing) {
|
|
28
|
+
return res.status(400).json({ error: 'Email already exists' });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const passwordHash = await bcrypt.hash(password, 12);
|
|
32
|
+
const argon2Salt = crypto.randomBytes(32).toString('hex');
|
|
33
|
+
const apiToken = crypto.randomBytes(64).toString('hex');
|
|
34
|
+
const userId = uuidv4();
|
|
35
|
+
|
|
36
|
+
const user = await UserModel.create({
|
|
37
|
+
userId,
|
|
38
|
+
email,
|
|
39
|
+
passwordHash,
|
|
40
|
+
argon2Salt,
|
|
41
|
+
apiToken
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
res.json({
|
|
45
|
+
userId: user.userId,
|
|
46
|
+
apiToken: user.apiToken,
|
|
47
|
+
argon2Salt: user.argon2Salt
|
|
48
|
+
});
|
|
49
|
+
} catch (error) {
|
|
50
|
+
next(error);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
router.post('/login', async (req, res, next) => {
|
|
55
|
+
try {
|
|
56
|
+
const email = String(req.body?.email || '').trim().toLowerCase();
|
|
57
|
+
const password = String(req.body?.password || '');
|
|
58
|
+
|
|
59
|
+
if (!email || !password) {
|
|
60
|
+
return res.status(401).json({ error: 'Invalid email or password' });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const user = await UserModel.findOne({ email });
|
|
64
|
+
if (!user) {
|
|
65
|
+
return res.status(401).json({ error: 'Invalid email or password' });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const matches = await bcrypt.compare(password, user.passwordHash);
|
|
69
|
+
if (!matches) {
|
|
70
|
+
return res.status(401).json({ error: 'Invalid email or password' });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
res.json({
|
|
74
|
+
userId: user.userId,
|
|
75
|
+
email: user.email,
|
|
76
|
+
apiToken: user.apiToken,
|
|
77
|
+
argon2Salt: user.argon2Salt
|
|
78
|
+
});
|
|
79
|
+
} catch (error) {
|
|
80
|
+
next(error);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
router.get('/me', verifyToken, async (req, res, next) => {
|
|
85
|
+
try {
|
|
86
|
+
res.json({
|
|
87
|
+
userId: req.user?.userId,
|
|
88
|
+
email: req.user?.email,
|
|
89
|
+
argon2Salt: req.user?.argon2Salt
|
|
90
|
+
});
|
|
91
|
+
} catch (error) {
|
|
92
|
+
next(error);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
module.exports = router;
|
package/src/server/routes.js
CHANGED
|
@@ -9,6 +9,7 @@ const {
|
|
|
9
9
|
ReplicationTaskModel,
|
|
10
10
|
RelayTaskModel
|
|
11
11
|
} = require('./models');
|
|
12
|
+
const verifyToken = require('./middleware/verifyToken');
|
|
12
13
|
const { uploadToFilecoin } = require('../../services/filecoin');
|
|
13
14
|
const {
|
|
14
15
|
chooseDistinctOnlineNodes,
|
|
@@ -511,9 +512,9 @@ function buildRoutes(config) {
|
|
|
511
512
|
}
|
|
512
513
|
});
|
|
513
514
|
|
|
514
|
-
router.post('/files/register', async (req, res, next) => {
|
|
515
|
+
router.post('/files/register', verifyToken, async (req, res, next) => {
|
|
515
516
|
try {
|
|
516
|
-
const { fileId, originalName, sizeBytes, shardCount, cipher, metadata } = req.body;
|
|
517
|
+
const { fileId, originalName, sizeBytes, shardCount, cipher, metadata, encryptedAESKey } = req.body;
|
|
517
518
|
if (!originalName || !Number.isFinite(Number(sizeBytes)) || Number(sizeBytes) < 0) {
|
|
518
519
|
return res.status(400).json({ error: 'originalName and sizeBytes are required' });
|
|
519
520
|
}
|
|
@@ -521,17 +522,33 @@ function buildRoutes(config) {
|
|
|
521
522
|
const normalizedShardCount = parsePositiveInt(shardCount, 1);
|
|
522
523
|
const actualFileId = fileId || uuidv4();
|
|
523
524
|
|
|
525
|
+
const existing = await FileModel.findOne({ fileId: actualFileId }).lean();
|
|
526
|
+
if (existing?.userId && existing.userId !== req.userId) {
|
|
527
|
+
return res.status(403).json({ error: 'Forbidden' });
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const nextMetadata = metadata === undefined ? existing?.metadata || {} : metadata;
|
|
531
|
+
const update = {
|
|
532
|
+
fileId: actualFileId,
|
|
533
|
+
originalName,
|
|
534
|
+
sizeBytes: Number(sizeBytes),
|
|
535
|
+
shardCount: normalizedShardCount,
|
|
536
|
+
cipher: cipher || 'aes-256-gcm',
|
|
537
|
+
metadata: nextMetadata
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
if (!existing?.userId) {
|
|
541
|
+
update.userId = req.userId;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (typeof encryptedAESKey === 'string' && encryptedAESKey.trim()) {
|
|
545
|
+
update.encryptedAESKey = encryptedAESKey.trim();
|
|
546
|
+
}
|
|
547
|
+
|
|
524
548
|
const file = await FileModel.findOneAndUpdate(
|
|
525
549
|
{ fileId: actualFileId },
|
|
526
550
|
{
|
|
527
|
-
$set:
|
|
528
|
-
fileId: actualFileId,
|
|
529
|
-
originalName,
|
|
530
|
-
sizeBytes: Number(sizeBytes),
|
|
531
|
-
shardCount: normalizedShardCount,
|
|
532
|
-
cipher: cipher || 'aes-256-gcm',
|
|
533
|
-
metadata: metadata || {}
|
|
534
|
-
}
|
|
551
|
+
$set: update
|
|
535
552
|
},
|
|
536
553
|
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
|
537
554
|
);
|
|
@@ -542,7 +559,7 @@ function buildRoutes(config) {
|
|
|
542
559
|
}
|
|
543
560
|
});
|
|
544
561
|
|
|
545
|
-
router.post('/files/:fileId/filecoin', async (req, res, next) => {
|
|
562
|
+
router.post('/files/:fileId/filecoin', verifyToken, async (req, res, next) => {
|
|
546
563
|
try {
|
|
547
564
|
const { fileId } = req.params;
|
|
548
565
|
const { filecoinCid, filecoinBackedUp } = req.body;
|
|
@@ -554,7 +571,15 @@ function buildRoutes(config) {
|
|
|
554
571
|
return res.status(400).json({ error: 'filecoinCid is required' });
|
|
555
572
|
}
|
|
556
573
|
|
|
557
|
-
const file = await FileModel.
|
|
574
|
+
const file = await FileModel.findOne({ fileId });
|
|
575
|
+
if (!file) {
|
|
576
|
+
return res.status(404).json({ error: 'file not found' });
|
|
577
|
+
}
|
|
578
|
+
if (file.userId && file.userId !== req.userId) {
|
|
579
|
+
return res.status(403).json({ error: 'Forbidden' });
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const updated = await FileModel.findOneAndUpdate(
|
|
558
583
|
{ fileId },
|
|
559
584
|
{
|
|
560
585
|
$set: {
|
|
@@ -565,17 +590,13 @@ function buildRoutes(config) {
|
|
|
565
590
|
{ new: true }
|
|
566
591
|
);
|
|
567
592
|
|
|
568
|
-
|
|
569
|
-
return res.status(404).json({ error: 'file not found' });
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
res.json({ ok: true, fileId: file.fileId, filecoinCid: file.filecoinCid });
|
|
593
|
+
res.json({ ok: true, fileId: updated.fileId, filecoinCid: updated.filecoinCid });
|
|
573
594
|
} catch (error) {
|
|
574
595
|
next(error);
|
|
575
596
|
}
|
|
576
597
|
});
|
|
577
598
|
|
|
578
|
-
router.post('/files/:fileId/filecoin/upload', async (req, res, next) => {
|
|
599
|
+
router.post('/files/:fileId/filecoin/upload', verifyToken, async (req, res, next) => {
|
|
579
600
|
try {
|
|
580
601
|
const { fileId } = req.params;
|
|
581
602
|
const { dataBase64 } = req.body;
|
|
@@ -597,7 +618,15 @@ function buildRoutes(config) {
|
|
|
597
618
|
return res.status(502).json({ error: 'filecoin upload failed' });
|
|
598
619
|
}
|
|
599
620
|
|
|
600
|
-
const file = await FileModel.
|
|
621
|
+
const file = await FileModel.findOne({ fileId });
|
|
622
|
+
if (!file) {
|
|
623
|
+
return res.status(404).json({ error: 'file not found' });
|
|
624
|
+
}
|
|
625
|
+
if (file.userId && file.userId !== req.userId) {
|
|
626
|
+
return res.status(403).json({ error: 'Forbidden' });
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const updated = await FileModel.findOneAndUpdate(
|
|
601
630
|
{ fileId },
|
|
602
631
|
{
|
|
603
632
|
$set: {
|
|
@@ -608,11 +637,7 @@ function buildRoutes(config) {
|
|
|
608
637
|
{ new: true }
|
|
609
638
|
);
|
|
610
639
|
|
|
611
|
-
|
|
612
|
-
return res.status(404).json({ error: 'file not found' });
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
res.json({ ok: true, fileId: file.fileId, filecoinCid: cid });
|
|
640
|
+
res.json({ ok: true, fileId: updated.fileId, filecoinCid: cid });
|
|
616
641
|
} catch (error) {
|
|
617
642
|
next(error);
|
|
618
643
|
}
|
|
@@ -696,13 +721,16 @@ function buildRoutes(config) {
|
|
|
696
721
|
}
|
|
697
722
|
});
|
|
698
723
|
|
|
699
|
-
router.delete('/files/:fileId', async (req, res, next) => {
|
|
724
|
+
router.delete('/files/:fileId', verifyToken, async (req, res, next) => {
|
|
700
725
|
try {
|
|
701
726
|
const { fileId } = req.params;
|
|
702
727
|
const file = await FileModel.findOne({ fileId }).lean();
|
|
703
728
|
if (!file) {
|
|
704
729
|
return res.status(404).json({ error: 'file not found' });
|
|
705
730
|
}
|
|
731
|
+
if (file.userId && file.userId !== req.userId) {
|
|
732
|
+
return res.status(403).json({ error: 'Forbidden' });
|
|
733
|
+
}
|
|
706
734
|
|
|
707
735
|
const shards = await ShardModel.find({ fileId }).select('shardId').lean();
|
|
708
736
|
const shardIds = shards.map((shard) => shard.shardId);
|
|
@@ -771,13 +799,16 @@ function buildRoutes(config) {
|
|
|
771
799
|
}
|
|
772
800
|
});
|
|
773
801
|
|
|
774
|
-
router.get('/files/:fileId/manifest', async (req, res, next) => {
|
|
802
|
+
router.get('/files/:fileId/manifest', verifyToken, async (req, res, next) => {
|
|
775
803
|
try {
|
|
776
804
|
const { fileId } = req.params;
|
|
777
805
|
const file = await FileModel.findOne({ fileId }).lean();
|
|
778
806
|
if (!file) {
|
|
779
807
|
return res.status(404).json({ error: 'file not found' });
|
|
780
808
|
}
|
|
809
|
+
if (file.userId && file.userId !== req.userId) {
|
|
810
|
+
return res.status(403).json({ error: 'Forbidden' });
|
|
811
|
+
}
|
|
781
812
|
|
|
782
813
|
const shards = await ShardModel.find({ fileId }).sort({ order: 1 }).lean();
|
|
783
814
|
const placements = await ShardPlacementModel.find({ fileId, status: 'available' }).lean();
|
|
@@ -813,6 +844,8 @@ function buildRoutes(config) {
|
|
|
813
844
|
sizeBytes: file.sizeBytes,
|
|
814
845
|
shardCount: file.shardCount,
|
|
815
846
|
cipher: file.cipher,
|
|
847
|
+
userId: file.userId || null,
|
|
848
|
+
encryptedAESKey: file.encryptedAESKey || null,
|
|
816
849
|
metadata: file.metadata,
|
|
817
850
|
filecoinCid: file.filecoinCid || null,
|
|
818
851
|
filecoinBackedUp: Boolean(file.filecoinBackedUp)
|
package/src/user/commands.js
CHANGED
|
@@ -2,10 +2,132 @@ const fs = require('fs/promises');
|
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const crypto = require('crypto');
|
|
4
4
|
const axios = require('axios');
|
|
5
|
+
const readline = require('readline');
|
|
5
6
|
const { v4: uuidv4 } = require('uuid');
|
|
6
7
|
const { createApiClient, normalizeUrl } = require('./client');
|
|
7
8
|
const { encryptAes256Gcm, decryptAes256Gcm, sha256Hex } = require('../common/crypto');
|
|
8
9
|
const { retrieveFromFilecoin } = require('../../services/filecoin');
|
|
10
|
+
const {
|
|
11
|
+
loadSession,
|
|
12
|
+
saveSession,
|
|
13
|
+
clearSession,
|
|
14
|
+
deriveMasterKey,
|
|
15
|
+
encryptMasterKey,
|
|
16
|
+
decryptMasterKey
|
|
17
|
+
} = require('../cli/session');
|
|
18
|
+
|
|
19
|
+
function promptInput(promptText) {
|
|
20
|
+
return new Promise((resolve) => {
|
|
21
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
22
|
+
rl.question(promptText, (answer) => {
|
|
23
|
+
rl.close();
|
|
24
|
+
resolve(String(answer || '').trim());
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function promptHiddenInput(promptText) {
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
process.stdout.write(promptText);
|
|
32
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
33
|
+
rl.stdoutMuted = true;
|
|
34
|
+
rl._writeToOutput = function _writeToOutput(stringToWrite) {
|
|
35
|
+
if (rl.stdoutMuted) {
|
|
36
|
+
rl.output.write('*');
|
|
37
|
+
} else {
|
|
38
|
+
rl.output.write(stringToWrite);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
rl.question('', (answer) => {
|
|
42
|
+
rl.history = rl.history.slice(1);
|
|
43
|
+
rl.close();
|
|
44
|
+
process.stdout.write('\n');
|
|
45
|
+
resolve(String(answer || ''));
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function requireSession() {
|
|
51
|
+
const session = await loadSession();
|
|
52
|
+
if (!session) {
|
|
53
|
+
console.log('Please login first using: npx nodio-cli nodio login');
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
return session;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function attachSessionToken(api, session) {
|
|
60
|
+
if (session?.apiToken) {
|
|
61
|
+
api.defaults.headers['x-api-token'] = session.apiToken;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function packEncryptedKey({ iv, authTag, cipherText }) {
|
|
66
|
+
return `${iv}:${authTag}:${cipherText.toString('base64')}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function unpackEncryptedKey(payload) {
|
|
70
|
+
const [iv, authTag, cipherText] = String(payload || '').split(':');
|
|
71
|
+
if (!iv || !authTag || !cipherText) {
|
|
72
|
+
throw new Error('invalid encrypted key format');
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
iv,
|
|
76
|
+
authTag,
|
|
77
|
+
cipherText: Buffer.from(cipherText, 'base64')
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function buildSessionPayload(authResponse, password) {
|
|
82
|
+
const masterKey = await deriveMasterKey(password, authResponse.argon2Salt);
|
|
83
|
+
const encryptedMasterKey = encryptMasterKey(masterKey, authResponse.apiToken);
|
|
84
|
+
return {
|
|
85
|
+
apiToken: authResponse.apiToken,
|
|
86
|
+
argon2Salt: authResponse.argon2Salt,
|
|
87
|
+
userId: authResponse.userId,
|
|
88
|
+
email: authResponse.email || null,
|
|
89
|
+
encryptedMasterKey
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function login(options) {
|
|
94
|
+
const serverUrl = options.server;
|
|
95
|
+
const email = await promptInput('Email: ');
|
|
96
|
+
const password = await promptHiddenInput('Password: ');
|
|
97
|
+
|
|
98
|
+
const api = createApiClient(serverUrl);
|
|
99
|
+
const response = await api.post('/auth/login', { email, password });
|
|
100
|
+
const session = await buildSessionPayload(response.data, password);
|
|
101
|
+
await saveSession(session);
|
|
102
|
+
console.log('Logged in ✅');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function register(options) {
|
|
106
|
+
const serverUrl = options.server;
|
|
107
|
+
const email = await promptInput('Email: ');
|
|
108
|
+
const password = await promptHiddenInput('Password: ');
|
|
109
|
+
|
|
110
|
+
const api = createApiClient(serverUrl);
|
|
111
|
+
const response = await api.post('/auth/register', { email, password });
|
|
112
|
+
const session = await buildSessionPayload({ ...response.data, email }, password);
|
|
113
|
+
await saveSession(session);
|
|
114
|
+
console.log('Registered ✅');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function logout() {
|
|
118
|
+
await clearSession();
|
|
119
|
+
console.log('Logged out ✅');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function whoami(options) {
|
|
123
|
+
const serverUrl = options.server;
|
|
124
|
+
const session = await requireSession();
|
|
125
|
+
const api = createApiClient(serverUrl);
|
|
126
|
+
attachSessionToken(api, session);
|
|
127
|
+
const response = await api.get('/auth/me');
|
|
128
|
+
console.log(`email: ${response.data?.email || session.email || 'unknown'}`);
|
|
129
|
+
console.log(`userId: ${response.data?.userId || session.userId || 'unknown'}`);
|
|
130
|
+
}
|
|
9
131
|
|
|
10
132
|
function splitBuffer(buffer, shardSizeBytes) {
|
|
11
133
|
if (shardSizeBytes <= 0) {
|
|
@@ -261,6 +383,9 @@ async function uploadFile(options) {
|
|
|
261
383
|
const directTimeoutMs = Number(options.directTimeoutMs || 1200);
|
|
262
384
|
const relayFirst = Boolean(options.relayFirst);
|
|
263
385
|
|
|
386
|
+
const session = await requireSession();
|
|
387
|
+
const masterKey = decryptMasterKey(session.encryptedMasterKey, session.apiToken);
|
|
388
|
+
|
|
264
389
|
if (!Number.isFinite(shardSizeMb) || shardSizeMb <= 0) {
|
|
265
390
|
throw new Error('shard-size-mb must be greater than 0');
|
|
266
391
|
}
|
|
@@ -277,8 +402,11 @@ async function uploadFile(options) {
|
|
|
277
402
|
|
|
278
403
|
const keyBuffer = options.keyBase64 ? parseAesKey(options.keyBase64) : crypto.randomBytes(32);
|
|
279
404
|
const keyBase64 = keyBuffer.toString('base64');
|
|
405
|
+
const encryptedKeyPayload = encryptAes256Gcm(keyBuffer, masterKey);
|
|
406
|
+
const encryptedAESKey = packEncryptedKey(encryptedKeyPayload);
|
|
280
407
|
|
|
281
408
|
const api = createApiClient(serverUrl);
|
|
409
|
+
attachSessionToken(api, session);
|
|
282
410
|
const fileId = options.fileId || uuidv4();
|
|
283
411
|
const originalName = path.basename(filePath);
|
|
284
412
|
|
|
@@ -288,6 +416,7 @@ async function uploadFile(options) {
|
|
|
288
416
|
sizeBytes: plainBuffer.length,
|
|
289
417
|
shardCount: chunks.length,
|
|
290
418
|
cipher: 'aes-256-gcm',
|
|
419
|
+
encryptedAESKey,
|
|
291
420
|
metadata: {
|
|
292
421
|
encryption: {
|
|
293
422
|
algorithm: 'aes-256-gcm',
|
|
@@ -415,6 +544,7 @@ async function uploadFile(options) {
|
|
|
415
544
|
sizeBytes: plainBuffer.length,
|
|
416
545
|
shardCount: chunks.length,
|
|
417
546
|
cipher: 'aes-256-gcm',
|
|
547
|
+
encryptedAESKey,
|
|
418
548
|
metadata: {
|
|
419
549
|
encryption: {
|
|
420
550
|
algorithm: 'aes-256-gcm',
|
|
@@ -443,20 +573,43 @@ async function downloadFile(options) {
|
|
|
443
573
|
const fileId = options.fileId;
|
|
444
574
|
const serverUrl = options.server;
|
|
445
575
|
const output = options.output ? path.resolve(options.output) : path.resolve(`./${fileId}.downloaded`);
|
|
446
|
-
const keyBuffer = parseAesKey(options.keyBase64);
|
|
447
576
|
const directTimeoutMs = Number(options.directTimeoutMs || 1200);
|
|
448
577
|
const relayFirst = Boolean(options.relayFirst);
|
|
449
578
|
|
|
579
|
+
const session = await requireSession();
|
|
580
|
+
const masterKey = decryptMasterKey(session.encryptedMasterKey, session.apiToken);
|
|
581
|
+
|
|
450
582
|
if (!Number.isFinite(directTimeoutMs) || directTimeoutMs <= 0) {
|
|
451
583
|
throw new Error('direct-timeout-ms must be greater than 0');
|
|
452
584
|
}
|
|
453
585
|
|
|
454
586
|
const api = createApiClient(serverUrl);
|
|
587
|
+
attachSessionToken(api, session);
|
|
455
588
|
const manifestResponse = await api.get(`/files/${fileId}/manifest`);
|
|
456
589
|
const manifest = manifestResponse.data;
|
|
457
590
|
const metadata = manifest.file?.metadata || {};
|
|
458
591
|
const encryption = metadata.encryption;
|
|
459
592
|
|
|
593
|
+
let keyBuffer = null;
|
|
594
|
+
if (options.keyBase64) {
|
|
595
|
+
keyBuffer = parseAesKey(options.keyBase64);
|
|
596
|
+
} else if (manifest.file?.encryptedAESKey) {
|
|
597
|
+
const encryptedKeyPayload = unpackEncryptedKey(manifest.file.encryptedAESKey);
|
|
598
|
+
keyBuffer = decryptAes256Gcm(
|
|
599
|
+
encryptedKeyPayload.cipherText,
|
|
600
|
+
masterKey,
|
|
601
|
+
encryptedKeyPayload.iv,
|
|
602
|
+
encryptedKeyPayload.authTag
|
|
603
|
+
);
|
|
604
|
+
if (!Buffer.isBuffer(keyBuffer) || keyBuffer.length !== 32) {
|
|
605
|
+
throw new Error('invalid decrypted AES key length');
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (!keyBuffer) {
|
|
610
|
+
throw new Error('missing encryption key; provide --key-base64 or login to access encrypted keys');
|
|
611
|
+
}
|
|
612
|
+
|
|
460
613
|
if (!encryption || encryption.algorithm !== 'aes-256-gcm') {
|
|
461
614
|
throw new Error('missing or unsupported encryption metadata');
|
|
462
615
|
}
|
|
@@ -566,6 +719,8 @@ async function deleteFile(options) {
|
|
|
566
719
|
const serverUrl = options.server;
|
|
567
720
|
|
|
568
721
|
const api = createApiClient(serverUrl);
|
|
722
|
+
const session = await requireSession();
|
|
723
|
+
attachSessionToken(api, session);
|
|
569
724
|
const response = await api.delete(`/files/${fileId}`);
|
|
570
725
|
const payload = response.data || {};
|
|
571
726
|
|
|
@@ -582,5 +737,9 @@ async function deleteFile(options) {
|
|
|
582
737
|
module.exports = {
|
|
583
738
|
uploadFile,
|
|
584
739
|
downloadFile,
|
|
585
|
-
deleteFile
|
|
740
|
+
deleteFile,
|
|
741
|
+
login,
|
|
742
|
+
register,
|
|
743
|
+
logout,
|
|
744
|
+
whoami
|
|
586
745
|
};
|
package/src/user/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
const { Command } = require('commander');
|
|
3
|
-
const { uploadFile, downloadFile, deleteFile } = require('./commands');
|
|
3
|
+
const { uploadFile, downloadFile, deleteFile, login, logout, register, whoami } = require('./commands');
|
|
4
4
|
|
|
5
5
|
const program = new Command();
|
|
6
6
|
|
|
@@ -25,7 +25,7 @@ program
|
|
|
25
25
|
.command('download')
|
|
26
26
|
.description('Download, verify, decrypt, and reconstruct a file')
|
|
27
27
|
.requiredOption('--file-id <id>', 'file ID to download')
|
|
28
|
-
.
|
|
28
|
+
.option('--key-base64 <key>', '32-byte AES key in base64 from upload output')
|
|
29
29
|
.option('--server <url>', 'central server URL', 'https://api.nodio.me')
|
|
30
30
|
.option('--output <path>', 'output file path')
|
|
31
31
|
.option('--direct-timeout-ms <ms>', 'timeout for each direct donor attempt before relay fallback', '1200')
|
|
@@ -43,6 +43,37 @@ program
|
|
|
43
43
|
await deleteFile(options);
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
+
program
|
|
47
|
+
.command('login')
|
|
48
|
+
.description('Login and persist a session locally')
|
|
49
|
+
.option('--server <url>', 'central server URL', 'https://api.nodio.me')
|
|
50
|
+
.action(async (options) => {
|
|
51
|
+
await login(options);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
program
|
|
55
|
+
.command('logout')
|
|
56
|
+
.description('Clear local session data')
|
|
57
|
+
.action(async () => {
|
|
58
|
+
await logout();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
program
|
|
62
|
+
.command('register')
|
|
63
|
+
.description('Create an account and persist a session locally')
|
|
64
|
+
.option('--server <url>', 'central server URL', 'https://api.nodio.me')
|
|
65
|
+
.action(async (options) => {
|
|
66
|
+
await register(options);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
program
|
|
70
|
+
.command('whoami')
|
|
71
|
+
.description('Show the current authenticated user')
|
|
72
|
+
.option('--server <url>', 'central server URL', 'https://api.nodio.me')
|
|
73
|
+
.action(async (options) => {
|
|
74
|
+
await whoami(options);
|
|
75
|
+
});
|
|
76
|
+
|
|
46
77
|
program.parseAsync(process.argv).catch((error) => {
|
|
47
78
|
const apiErrorMessage = error.response?.data?.error;
|
|
48
79
|
if (apiErrorMessage) {
|