nodio-cli 1.1.1 → 1.1.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodio-cli",
3
- "version": "1.1.1",
3
+ "version": "1.1.3",
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
+ };
@@ -4,7 +4,9 @@ const cors = require('cors');
4
4
  const mongoose = require('mongoose');
5
5
  const { getServerConfig } = require('./config');
6
6
  const { buildRoutes } = require('./routes');
7
- const { NodeModel } = require('./models');
7
+ const authRoutes = require('./routes/auth');
8
+ const { NodeModel, FileModel } = require('./models');
9
+ const verifyToken = require('./middleware/verifyToken');
8
10
  const { markNodeOfflineAndRecover } = require('./services');
9
11
  const { getWalletBalance } = require('../../services/filecoin');
10
12
 
@@ -29,15 +31,29 @@ async function startServer() {
29
31
  callback(new Error('Not allowed by CORS'));
30
32
  },
31
33
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
32
- allowedHeaders: ['Content-Type', 'Authorization'],
34
+ allowedHeaders: ['Content-Type', 'Authorization', 'x-api-token'],
33
35
  optionsSuccessStatus: 200
34
36
  };
35
37
 
36
38
  app.use(cors(corsOptions));
37
39
  app.options(/.*/, cors(corsOptions));
38
40
  app.use(express.json({ limit: '50mb' }));
41
+ app.use('/api/auth', authRoutes);
39
42
  app.use('/api', buildRoutes(config));
40
43
 
44
+ app.get('/api/files', verifyToken, async (req, res, next) => {
45
+ try {
46
+ const files = await FileModel.find({ userId: req.userId })
47
+ .sort({ createdAt: -1 })
48
+ .select('fileId originalName sizeBytes createdAt filecoinBackedUp filecoinCid')
49
+ .lean();
50
+
51
+ res.json({ files });
52
+ } catch (error) {
53
+ next(error);
54
+ }
55
+ });
56
+
41
57
  app.use((error, _req, res, _next) => {
42
58
  const status = error.statusCode || 500;
43
59
  const message = error.message || 'internal server error';
@@ -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
+ };
@@ -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;
@@ -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,
@@ -56,6 +57,19 @@ function buildRoutes(config) {
56
57
  res.json({ ok: true, service: 'nodio-server' });
57
58
  });
58
59
 
60
+ router.get('/files', verifyToken, async (req, res, next) => {
61
+ try {
62
+ const files = await FileModel.find({ userId: req.userId })
63
+ .sort({ createdAt: -1 })
64
+ .select('fileId originalName sizeBytes createdAt filecoinBackedUp filecoinCid')
65
+ .lean();
66
+
67
+ res.json({ files });
68
+ } catch (error) {
69
+ next(error);
70
+ }
71
+ });
72
+
59
73
  router.post('/nodes/register', async (req, res, next) => {
60
74
  try {
61
75
  const { nodeId, deviceKey, nodeKey, knownNodeIds, url, capacityBytes, freeBytes } = req.body;
@@ -511,9 +525,9 @@ function buildRoutes(config) {
511
525
  }
512
526
  });
513
527
 
514
- router.post('/files/register', async (req, res, next) => {
528
+ router.post('/files/register', verifyToken, async (req, res, next) => {
515
529
  try {
516
- const { fileId, originalName, sizeBytes, shardCount, cipher, metadata } = req.body;
530
+ const { fileId, originalName, sizeBytes, shardCount, cipher, metadata, encryptedAESKey } = req.body;
517
531
  if (!originalName || !Number.isFinite(Number(sizeBytes)) || Number(sizeBytes) < 0) {
518
532
  return res.status(400).json({ error: 'originalName and sizeBytes are required' });
519
533
  }
@@ -521,17 +535,33 @@ function buildRoutes(config) {
521
535
  const normalizedShardCount = parsePositiveInt(shardCount, 1);
522
536
  const actualFileId = fileId || uuidv4();
523
537
 
538
+ const existing = await FileModel.findOne({ fileId: actualFileId }).lean();
539
+ if (existing?.userId && existing.userId !== req.userId) {
540
+ return res.status(403).json({ error: 'Forbidden' });
541
+ }
542
+
543
+ const nextMetadata = metadata === undefined ? existing?.metadata || {} : metadata;
544
+ const update = {
545
+ fileId: actualFileId,
546
+ originalName,
547
+ sizeBytes: Number(sizeBytes),
548
+ shardCount: normalizedShardCount,
549
+ cipher: cipher || 'aes-256-gcm',
550
+ metadata: nextMetadata
551
+ };
552
+
553
+ if (!existing?.userId) {
554
+ update.userId = req.userId;
555
+ }
556
+
557
+ if (typeof encryptedAESKey === 'string' && encryptedAESKey.trim()) {
558
+ update.encryptedAESKey = encryptedAESKey.trim();
559
+ }
560
+
524
561
  const file = await FileModel.findOneAndUpdate(
525
562
  { fileId: actualFileId },
526
563
  {
527
- $set: {
528
- fileId: actualFileId,
529
- originalName,
530
- sizeBytes: Number(sizeBytes),
531
- shardCount: normalizedShardCount,
532
- cipher: cipher || 'aes-256-gcm',
533
- metadata: metadata || {}
534
- }
564
+ $set: update
535
565
  },
536
566
  { upsert: true, new: true, setDefaultsOnInsert: true }
537
567
  );
@@ -542,7 +572,7 @@ function buildRoutes(config) {
542
572
  }
543
573
  });
544
574
 
545
- router.post('/files/:fileId/filecoin', async (req, res, next) => {
575
+ router.post('/files/:fileId/filecoin', verifyToken, async (req, res, next) => {
546
576
  try {
547
577
  const { fileId } = req.params;
548
578
  const { filecoinCid, filecoinBackedUp } = req.body;
@@ -554,7 +584,15 @@ function buildRoutes(config) {
554
584
  return res.status(400).json({ error: 'filecoinCid is required' });
555
585
  }
556
586
 
557
- const file = await FileModel.findOneAndUpdate(
587
+ const file = await FileModel.findOne({ fileId });
588
+ if (!file) {
589
+ return res.status(404).json({ error: 'file not found' });
590
+ }
591
+ if (file.userId && file.userId !== req.userId) {
592
+ return res.status(403).json({ error: 'Forbidden' });
593
+ }
594
+
595
+ const updated = await FileModel.findOneAndUpdate(
558
596
  { fileId },
559
597
  {
560
598
  $set: {
@@ -565,17 +603,13 @@ function buildRoutes(config) {
565
603
  { new: true }
566
604
  );
567
605
 
568
- if (!file) {
569
- return res.status(404).json({ error: 'file not found' });
570
- }
571
-
572
- res.json({ ok: true, fileId: file.fileId, filecoinCid: file.filecoinCid });
606
+ res.json({ ok: true, fileId: updated.fileId, filecoinCid: updated.filecoinCid });
573
607
  } catch (error) {
574
608
  next(error);
575
609
  }
576
610
  });
577
611
 
578
- router.post('/files/:fileId/filecoin/upload', async (req, res, next) => {
612
+ router.post('/files/:fileId/filecoin/upload', verifyToken, async (req, res, next) => {
579
613
  try {
580
614
  const { fileId } = req.params;
581
615
  const { dataBase64 } = req.body;
@@ -597,7 +631,15 @@ function buildRoutes(config) {
597
631
  return res.status(502).json({ error: 'filecoin upload failed' });
598
632
  }
599
633
 
600
- const file = await FileModel.findOneAndUpdate(
634
+ const file = await FileModel.findOne({ fileId });
635
+ if (!file) {
636
+ return res.status(404).json({ error: 'file not found' });
637
+ }
638
+ if (file.userId && file.userId !== req.userId) {
639
+ return res.status(403).json({ error: 'Forbidden' });
640
+ }
641
+
642
+ const updated = await FileModel.findOneAndUpdate(
601
643
  { fileId },
602
644
  {
603
645
  $set: {
@@ -608,11 +650,7 @@ function buildRoutes(config) {
608
650
  { new: true }
609
651
  );
610
652
 
611
- if (!file) {
612
- return res.status(404).json({ error: 'file not found' });
613
- }
614
-
615
- res.json({ ok: true, fileId: file.fileId, filecoinCid: cid });
653
+ res.json({ ok: true, fileId: updated.fileId, filecoinCid: cid });
616
654
  } catch (error) {
617
655
  next(error);
618
656
  }
@@ -696,13 +734,16 @@ function buildRoutes(config) {
696
734
  }
697
735
  });
698
736
 
699
- router.delete('/files/:fileId', async (req, res, next) => {
737
+ router.delete('/files/:fileId', verifyToken, async (req, res, next) => {
700
738
  try {
701
739
  const { fileId } = req.params;
702
740
  const file = await FileModel.findOne({ fileId }).lean();
703
741
  if (!file) {
704
742
  return res.status(404).json({ error: 'file not found' });
705
743
  }
744
+ if (file.userId && file.userId !== req.userId) {
745
+ return res.status(403).json({ error: 'Forbidden' });
746
+ }
706
747
 
707
748
  const shards = await ShardModel.find({ fileId }).select('shardId').lean();
708
749
  const shardIds = shards.map((shard) => shard.shardId);
@@ -771,13 +812,16 @@ function buildRoutes(config) {
771
812
  }
772
813
  });
773
814
 
774
- router.get('/files/:fileId/manifest', async (req, res, next) => {
815
+ router.get('/files/:fileId/manifest', verifyToken, async (req, res, next) => {
775
816
  try {
776
817
  const { fileId } = req.params;
777
818
  const file = await FileModel.findOne({ fileId }).lean();
778
819
  if (!file) {
779
820
  return res.status(404).json({ error: 'file not found' });
780
821
  }
822
+ if (file.userId && file.userId !== req.userId) {
823
+ return res.status(403).json({ error: 'Forbidden' });
824
+ }
781
825
 
782
826
  const shards = await ShardModel.find({ fileId }).sort({ order: 1 }).lean();
783
827
  const placements = await ShardPlacementModel.find({ fileId, status: 'available' }).lean();
@@ -813,6 +857,8 @@ function buildRoutes(config) {
813
857
  sizeBytes: file.sizeBytes,
814
858
  shardCount: file.shardCount,
815
859
  cipher: file.cipher,
860
+ userId: file.userId || null,
861
+ encryptedAESKey: file.encryptedAESKey || null,
816
862
  metadata: file.metadata,
817
863
  filecoinCid: file.filecoinCid || null,
818
864
  filecoinBackedUp: Boolean(file.filecoinBackedUp)
@@ -2,10 +2,156 @@ 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
+ }
131
+
132
+ async function listFiles(options) {
133
+ const serverUrl = options.server;
134
+ const session = await requireSession();
135
+ const api = createApiClient(serverUrl);
136
+ attachSessionToken(api, session);
137
+ const response = await api.get('/files');
138
+ const files = response.data?.files || [];
139
+
140
+ if (files.length === 0) {
141
+ console.log('No files found for this account.');
142
+ return;
143
+ }
144
+
145
+ for (const file of files) {
146
+ console.log(`fileId: ${file.fileId}`);
147
+ console.log(`name: ${file.originalName}`);
148
+ console.log(`sizeBytes: ${file.sizeBytes}`);
149
+ console.log(`createdAt: ${file.createdAt}`);
150
+ console.log(`filecoinBackedUp: ${Boolean(file.filecoinBackedUp)}`);
151
+ console.log(`filecoinCid: ${file.filecoinCid || ''}`);
152
+ console.log('---');
153
+ }
154
+ }
9
155
 
10
156
  function splitBuffer(buffer, shardSizeBytes) {
11
157
  if (shardSizeBytes <= 0) {
@@ -261,6 +407,9 @@ async function uploadFile(options) {
261
407
  const directTimeoutMs = Number(options.directTimeoutMs || 1200);
262
408
  const relayFirst = Boolean(options.relayFirst);
263
409
 
410
+ const session = await requireSession();
411
+ const masterKey = decryptMasterKey(session.encryptedMasterKey, session.apiToken);
412
+
264
413
  if (!Number.isFinite(shardSizeMb) || shardSizeMb <= 0) {
265
414
  throw new Error('shard-size-mb must be greater than 0');
266
415
  }
@@ -277,8 +426,11 @@ async function uploadFile(options) {
277
426
 
278
427
  const keyBuffer = options.keyBase64 ? parseAesKey(options.keyBase64) : crypto.randomBytes(32);
279
428
  const keyBase64 = keyBuffer.toString('base64');
429
+ const encryptedKeyPayload = encryptAes256Gcm(keyBuffer, masterKey);
430
+ const encryptedAESKey = packEncryptedKey(encryptedKeyPayload);
280
431
 
281
432
  const api = createApiClient(serverUrl);
433
+ attachSessionToken(api, session);
282
434
  const fileId = options.fileId || uuidv4();
283
435
  const originalName = path.basename(filePath);
284
436
 
@@ -288,6 +440,7 @@ async function uploadFile(options) {
288
440
  sizeBytes: plainBuffer.length,
289
441
  shardCount: chunks.length,
290
442
  cipher: 'aes-256-gcm',
443
+ encryptedAESKey,
291
444
  metadata: {
292
445
  encryption: {
293
446
  algorithm: 'aes-256-gcm',
@@ -415,6 +568,7 @@ async function uploadFile(options) {
415
568
  sizeBytes: plainBuffer.length,
416
569
  shardCount: chunks.length,
417
570
  cipher: 'aes-256-gcm',
571
+ encryptedAESKey,
418
572
  metadata: {
419
573
  encryption: {
420
574
  algorithm: 'aes-256-gcm',
@@ -443,20 +597,43 @@ async function downloadFile(options) {
443
597
  const fileId = options.fileId;
444
598
  const serverUrl = options.server;
445
599
  const output = options.output ? path.resolve(options.output) : path.resolve(`./${fileId}.downloaded`);
446
- const keyBuffer = parseAesKey(options.keyBase64);
447
600
  const directTimeoutMs = Number(options.directTimeoutMs || 1200);
448
601
  const relayFirst = Boolean(options.relayFirst);
449
602
 
603
+ const session = await requireSession();
604
+ const masterKey = decryptMasterKey(session.encryptedMasterKey, session.apiToken);
605
+
450
606
  if (!Number.isFinite(directTimeoutMs) || directTimeoutMs <= 0) {
451
607
  throw new Error('direct-timeout-ms must be greater than 0');
452
608
  }
453
609
 
454
610
  const api = createApiClient(serverUrl);
611
+ attachSessionToken(api, session);
455
612
  const manifestResponse = await api.get(`/files/${fileId}/manifest`);
456
613
  const manifest = manifestResponse.data;
457
614
  const metadata = manifest.file?.metadata || {};
458
615
  const encryption = metadata.encryption;
459
616
 
617
+ let keyBuffer = null;
618
+ if (options.keyBase64) {
619
+ keyBuffer = parseAesKey(options.keyBase64);
620
+ } else if (manifest.file?.encryptedAESKey) {
621
+ const encryptedKeyPayload = unpackEncryptedKey(manifest.file.encryptedAESKey);
622
+ keyBuffer = decryptAes256Gcm(
623
+ encryptedKeyPayload.cipherText,
624
+ masterKey,
625
+ encryptedKeyPayload.iv,
626
+ encryptedKeyPayload.authTag
627
+ );
628
+ if (!Buffer.isBuffer(keyBuffer) || keyBuffer.length !== 32) {
629
+ throw new Error('invalid decrypted AES key length');
630
+ }
631
+ }
632
+
633
+ if (!keyBuffer) {
634
+ throw new Error('missing encryption key; provide --key-base64 or login to access encrypted keys');
635
+ }
636
+
460
637
  if (!encryption || encryption.algorithm !== 'aes-256-gcm') {
461
638
  throw new Error('missing or unsupported encryption metadata');
462
639
  }
@@ -566,6 +743,8 @@ async function deleteFile(options) {
566
743
  const serverUrl = options.server;
567
744
 
568
745
  const api = createApiClient(serverUrl);
746
+ const session = await requireSession();
747
+ attachSessionToken(api, session);
569
748
  const response = await api.delete(`/files/${fileId}`);
570
749
  const payload = response.data || {};
571
750
 
@@ -582,5 +761,10 @@ async function deleteFile(options) {
582
761
  module.exports = {
583
762
  uploadFile,
584
763
  downloadFile,
585
- deleteFile
764
+ deleteFile,
765
+ login,
766
+ register,
767
+ logout,
768
+ whoami,
769
+ listFiles
586
770
  };
package/src/user/index.js CHANGED
@@ -1,6 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  const { Command } = require('commander');
3
- const { uploadFile, downloadFile, deleteFile } = require('./commands');
3
+ const {
4
+ uploadFile,
5
+ downloadFile,
6
+ deleteFile,
7
+ login,
8
+ logout,
9
+ register,
10
+ whoami,
11
+ listFiles
12
+ } = require('./commands');
4
13
 
5
14
  const program = new Command();
6
15
 
@@ -25,7 +34,7 @@ program
25
34
  .command('download')
26
35
  .description('Download, verify, decrypt, and reconstruct a file')
27
36
  .requiredOption('--file-id <id>', 'file ID to download')
28
- .requiredOption('--key-base64 <key>', '32-byte AES key in base64 from upload output')
37
+ .option('--key-base64 <key>', '32-byte AES key in base64 from upload output')
29
38
  .option('--server <url>', 'central server URL', 'https://api.nodio.me')
30
39
  .option('--output <path>', 'output file path')
31
40
  .option('--direct-timeout-ms <ms>', 'timeout for each direct donor attempt before relay fallback', '1200')
@@ -43,6 +52,45 @@ program
43
52
  await deleteFile(options);
44
53
  });
45
54
 
55
+ program
56
+ .command('login')
57
+ .description('Login and persist a session locally')
58
+ .option('--server <url>', 'central server URL', 'https://api.nodio.me')
59
+ .action(async (options) => {
60
+ await login(options);
61
+ });
62
+
63
+ program
64
+ .command('logout')
65
+ .description('Clear local session data')
66
+ .action(async () => {
67
+ await logout();
68
+ });
69
+
70
+ program
71
+ .command('register')
72
+ .description('Create an account and persist a session locally')
73
+ .option('--server <url>', 'central server URL', 'https://api.nodio.me')
74
+ .action(async (options) => {
75
+ await register(options);
76
+ });
77
+
78
+ program
79
+ .command('whoami')
80
+ .description('Show the current authenticated user')
81
+ .option('--server <url>', 'central server URL', 'https://api.nodio.me')
82
+ .action(async (options) => {
83
+ await whoami(options);
84
+ });
85
+
86
+ program
87
+ .command('files')
88
+ .description('List files uploaded by the authenticated user')
89
+ .option('--server <url>', 'central server URL', 'https://api.nodio.me')
90
+ .action(async (options) => {
91
+ await listFiles(options);
92
+ });
93
+
46
94
  program.parseAsync(process.argv).catch((error) => {
47
95
  const apiErrorMessage = error.response?.data?.error;
48
96
  if (apiErrorMessage) {