nodio-cli 1.1.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodio-cli",
3
- "version": "1.1.0",
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
+ };
@@ -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
- app.use(express.json({ limit: '10mb' }));
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
+ };
@@ -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,8 @@ const {
9
9
  ReplicationTaskModel,
10
10
  RelayTaskModel
11
11
  } = require('./models');
12
+ const verifyToken = require('./middleware/verifyToken');
13
+ const { uploadToFilecoin } = require('../../services/filecoin');
12
14
  const {
13
15
  chooseDistinctOnlineNodes,
14
16
  ensureEmergencyReplicasForShard
@@ -510,9 +512,9 @@ function buildRoutes(config) {
510
512
  }
511
513
  });
512
514
 
513
- router.post('/files/register', async (req, res, next) => {
515
+ router.post('/files/register', verifyToken, async (req, res, next) => {
514
516
  try {
515
- const { fileId, originalName, sizeBytes, shardCount, cipher, metadata } = req.body;
517
+ const { fileId, originalName, sizeBytes, shardCount, cipher, metadata, encryptedAESKey } = req.body;
516
518
  if (!originalName || !Number.isFinite(Number(sizeBytes)) || Number(sizeBytes) < 0) {
517
519
  return res.status(400).json({ error: 'originalName and sizeBytes are required' });
518
520
  }
@@ -520,17 +522,33 @@ function buildRoutes(config) {
520
522
  const normalizedShardCount = parsePositiveInt(shardCount, 1);
521
523
  const actualFileId = fileId || uuidv4();
522
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
+
523
548
  const file = await FileModel.findOneAndUpdate(
524
549
  { fileId: actualFileId },
525
550
  {
526
- $set: {
527
- fileId: actualFileId,
528
- originalName,
529
- sizeBytes: Number(sizeBytes),
530
- shardCount: normalizedShardCount,
531
- cipher: cipher || 'aes-256-gcm',
532
- metadata: metadata || {}
533
- }
551
+ $set: update
534
552
  },
535
553
  { upsert: true, new: true, setDefaultsOnInsert: true }
536
554
  );
@@ -541,7 +559,7 @@ function buildRoutes(config) {
541
559
  }
542
560
  });
543
561
 
544
- router.post('/files/:fileId/filecoin', async (req, res, next) => {
562
+ router.post('/files/:fileId/filecoin', verifyToken, async (req, res, next) => {
545
563
  try {
546
564
  const { fileId } = req.params;
547
565
  const { filecoinCid, filecoinBackedUp } = req.body;
@@ -553,7 +571,15 @@ function buildRoutes(config) {
553
571
  return res.status(400).json({ error: 'filecoinCid is required' });
554
572
  }
555
573
 
556
- const file = await FileModel.findOneAndUpdate(
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(
557
583
  { fileId },
558
584
  {
559
585
  $set: {
@@ -564,11 +590,54 @@ function buildRoutes(config) {
564
590
  { new: true }
565
591
  );
566
592
 
593
+ res.json({ ok: true, fileId: updated.fileId, filecoinCid: updated.filecoinCid });
594
+ } catch (error) {
595
+ next(error);
596
+ }
597
+ });
598
+
599
+ router.post('/files/:fileId/filecoin/upload', verifyToken, async (req, res, next) => {
600
+ try {
601
+ const { fileId } = req.params;
602
+ const { dataBase64 } = req.body;
603
+
604
+ if (!fileId) {
605
+ return res.status(400).json({ error: 'fileId is required' });
606
+ }
607
+ if (!dataBase64 || typeof dataBase64 !== 'string') {
608
+ return res.status(400).json({ error: 'dataBase64 is required' });
609
+ }
610
+
611
+ const payload = Buffer.from(dataBase64, 'base64');
612
+ if (!payload || payload.length === 0) {
613
+ return res.status(400).json({ error: 'dataBase64 decoded to empty payload' });
614
+ }
615
+
616
+ const cid = await uploadToFilecoin(payload, fileId);
617
+ if (!cid) {
618
+ return res.status(502).json({ error: 'filecoin upload failed' });
619
+ }
620
+
621
+ const file = await FileModel.findOne({ fileId });
567
622
  if (!file) {
568
623
  return res.status(404).json({ error: 'file not found' });
569
624
  }
625
+ if (file.userId && file.userId !== req.userId) {
626
+ return res.status(403).json({ error: 'Forbidden' });
627
+ }
570
628
 
571
- res.json({ ok: true, fileId: file.fileId, filecoinCid: file.filecoinCid });
629
+ const updated = await FileModel.findOneAndUpdate(
630
+ { fileId },
631
+ {
632
+ $set: {
633
+ filecoinCid: cid,
634
+ filecoinBackedUp: true
635
+ }
636
+ },
637
+ { new: true }
638
+ );
639
+
640
+ res.json({ ok: true, fileId: updated.fileId, filecoinCid: cid });
572
641
  } catch (error) {
573
642
  next(error);
574
643
  }
@@ -652,13 +721,16 @@ function buildRoutes(config) {
652
721
  }
653
722
  });
654
723
 
655
- router.delete('/files/:fileId', async (req, res, next) => {
724
+ router.delete('/files/:fileId', verifyToken, async (req, res, next) => {
656
725
  try {
657
726
  const { fileId } = req.params;
658
727
  const file = await FileModel.findOne({ fileId }).lean();
659
728
  if (!file) {
660
729
  return res.status(404).json({ error: 'file not found' });
661
730
  }
731
+ if (file.userId && file.userId !== req.userId) {
732
+ return res.status(403).json({ error: 'Forbidden' });
733
+ }
662
734
 
663
735
  const shards = await ShardModel.find({ fileId }).select('shardId').lean();
664
736
  const shardIds = shards.map((shard) => shard.shardId);
@@ -727,13 +799,16 @@ function buildRoutes(config) {
727
799
  }
728
800
  });
729
801
 
730
- router.get('/files/:fileId/manifest', async (req, res, next) => {
802
+ router.get('/files/:fileId/manifest', verifyToken, async (req, res, next) => {
731
803
  try {
732
804
  const { fileId } = req.params;
733
805
  const file = await FileModel.findOne({ fileId }).lean();
734
806
  if (!file) {
735
807
  return res.status(404).json({ error: 'file not found' });
736
808
  }
809
+ if (file.userId && file.userId !== req.userId) {
810
+ return res.status(403).json({ error: 'Forbidden' });
811
+ }
737
812
 
738
813
  const shards = await ShardModel.find({ fileId }).sort({ order: 1 }).lean();
739
814
  const placements = await ShardPlacementModel.find({ fileId, status: 'available' }).lean();
@@ -769,6 +844,8 @@ function buildRoutes(config) {
769
844
  sizeBytes: file.sizeBytes,
770
845
  shardCount: file.shardCount,
771
846
  cipher: file.cipher,
847
+ userId: file.userId || null,
848
+ encryptedAESKey: file.encryptedAESKey || null,
772
849
  metadata: file.metadata,
773
850
  filecoinCid: file.filecoinCid || null,
774
851
  filecoinBackedUp: Boolean(file.filecoinBackedUp)
@@ -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
- const { uploadToFilecoin, retrieveFromFilecoin } = require('../../services/filecoin');
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) {
@@ -88,27 +210,25 @@ function decryptShardsFromBuffers(orderedShards, shardMetaMap, encryptedShardBuf
88
210
  return Buffer.concat(plainParts);
89
211
  }
90
212
 
91
- function fireAndForgetFilecoinUpload(api, fileBuffer, fileId) {
213
+ async function requestFilecoinBackup(api, fileBuffer, fileId) {
92
214
  if (!fileBuffer || fileBuffer.length === 0) {
93
215
  return;
94
216
  }
95
217
 
96
- void (async () => {
97
- const cid = await uploadToFilecoin(fileBuffer, fileId);
98
- if (!cid) {
99
- return;
100
- }
101
-
102
- try {
103
- await api.post(`/files/${fileId}/filecoin`, {
104
- filecoinCid: cid,
105
- filecoinBackedUp: true
106
- });
107
- } catch (error) {
108
- const message = error.response?.data?.error || error.message;
109
- console.warn(`[filecoin] failed to persist cid for ${fileId}: ${message}`);
110
- }
111
- })();
218
+ try {
219
+ await api.post(
220
+ `/files/${fileId}/filecoin/upload`,
221
+ {
222
+ dataBase64: fileBuffer.toString('base64')
223
+ },
224
+ {
225
+ timeout: 180000
226
+ }
227
+ );
228
+ } catch (error) {
229
+ const message = error.response?.data?.error || error.message;
230
+ console.warn(`[filecoin] server upload failed for ${fileId}: ${message}`);
231
+ }
112
232
  }
113
233
 
114
234
  function fireAndForgetLayer1Reseed(api, fileId, orderedShards, encryptedShardBuffers, directTimeoutMs) {
@@ -263,6 +383,9 @@ async function uploadFile(options) {
263
383
  const directTimeoutMs = Number(options.directTimeoutMs || 1200);
264
384
  const relayFirst = Boolean(options.relayFirst);
265
385
 
386
+ const session = await requireSession();
387
+ const masterKey = decryptMasterKey(session.encryptedMasterKey, session.apiToken);
388
+
266
389
  if (!Number.isFinite(shardSizeMb) || shardSizeMb <= 0) {
267
390
  throw new Error('shard-size-mb must be greater than 0');
268
391
  }
@@ -279,8 +402,11 @@ async function uploadFile(options) {
279
402
 
280
403
  const keyBuffer = options.keyBase64 ? parseAesKey(options.keyBase64) : crypto.randomBytes(32);
281
404
  const keyBase64 = keyBuffer.toString('base64');
405
+ const encryptedKeyPayload = encryptAes256Gcm(keyBuffer, masterKey);
406
+ const encryptedAESKey = packEncryptedKey(encryptedKeyPayload);
282
407
 
283
408
  const api = createApiClient(serverUrl);
409
+ attachSessionToken(api, session);
284
410
  const fileId = options.fileId || uuidv4();
285
411
  const originalName = path.basename(filePath);
286
412
 
@@ -290,6 +416,7 @@ async function uploadFile(options) {
290
416
  sizeBytes: plainBuffer.length,
291
417
  shardCount: chunks.length,
292
418
  cipher: 'aes-256-gcm',
419
+ encryptedAESKey,
293
420
  metadata: {
294
421
  encryption: {
295
422
  algorithm: 'aes-256-gcm',
@@ -417,6 +544,7 @@ async function uploadFile(options) {
417
544
  sizeBytes: plainBuffer.length,
418
545
  shardCount: chunks.length,
419
546
  cipher: 'aes-256-gcm',
547
+ encryptedAESKey,
420
548
  metadata: {
421
549
  encryption: {
422
550
  algorithm: 'aes-256-gcm',
@@ -427,24 +555,10 @@ async function uploadFile(options) {
427
555
  }
428
556
  });
429
557
 
430
- // Upload to Filecoin as default (synchronous with retries)
558
+ // Upload to Filecoin via the central server (uses server-side wallet key)
431
559
  const encryptedFileBuffer = Buffer.concat(encryptedShardBuffers);
432
- console.log(`[filecoin] uploading to Filecoin (this may take a while)...`);
433
- const filecoinCid = await uploadToFilecoin(encryptedFileBuffer, fileId);
434
- if (filecoinCid) {
435
- try {
436
- await api.post(`/files/${fileId}/filecoin`, {
437
- filecoinCid,
438
- filecoinBackedUp: true
439
- });
440
- console.log(`[filecoin] backup complete, cid: ${filecoinCid}`);
441
- } catch (error) {
442
- const message = error.response?.data?.error || error.message;
443
- console.warn(`[filecoin] warning: failed to persist cid: ${message}`);
444
- }
445
- } else {
446
- console.warn(`[filecoin] warning: upload to Filecoin failed, file backed up to Layer 1 only`);
447
- }
560
+ console.log(`[filecoin] uploading to Filecoin via central server (this may take a while)...`);
561
+ await requestFilecoinBackup(api, encryptedFileBuffer, fileId);
448
562
 
449
563
  console.log(`Upload complete`);
450
564
  console.log(`fileId: ${fileId}`);
@@ -459,20 +573,43 @@ async function downloadFile(options) {
459
573
  const fileId = options.fileId;
460
574
  const serverUrl = options.server;
461
575
  const output = options.output ? path.resolve(options.output) : path.resolve(`./${fileId}.downloaded`);
462
- const keyBuffer = parseAesKey(options.keyBase64);
463
576
  const directTimeoutMs = Number(options.directTimeoutMs || 1200);
464
577
  const relayFirst = Boolean(options.relayFirst);
465
578
 
579
+ const session = await requireSession();
580
+ const masterKey = decryptMasterKey(session.encryptedMasterKey, session.apiToken);
581
+
466
582
  if (!Number.isFinite(directTimeoutMs) || directTimeoutMs <= 0) {
467
583
  throw new Error('direct-timeout-ms must be greater than 0');
468
584
  }
469
585
 
470
586
  const api = createApiClient(serverUrl);
587
+ attachSessionToken(api, session);
471
588
  const manifestResponse = await api.get(`/files/${fileId}/manifest`);
472
589
  const manifest = manifestResponse.data;
473
590
  const metadata = manifest.file?.metadata || {};
474
591
  const encryption = metadata.encryption;
475
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
+
476
613
  if (!encryption || encryption.algorithm !== 'aes-256-gcm') {
477
614
  throw new Error('missing or unsupported encryption metadata');
478
615
  }
@@ -582,6 +719,8 @@ async function deleteFile(options) {
582
719
  const serverUrl = options.server;
583
720
 
584
721
  const api = createApiClient(serverUrl);
722
+ const session = await requireSession();
723
+ attachSessionToken(api, session);
585
724
  const response = await api.delete(`/files/${fileId}`);
586
725
  const payload = response.data || {};
587
726
 
@@ -598,5 +737,9 @@ async function deleteFile(options) {
598
737
  module.exports = {
599
738
  uploadFile,
600
739
  downloadFile,
601
- deleteFile
740
+ deleteFile,
741
+ login,
742
+ register,
743
+ logout,
744
+ whoami
602
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
- .requiredOption('--key-base64 <key>', '32-byte AES key in base64 from upload output')
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) {