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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodio-cli",
3
- "version": "1.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
+ };
@@ -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
+ };
@@ -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,
@@ -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.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(
558
583
  { fileId },
559
584
  {
560
585
  $set: {
@@ -565,17 +590,13 @@ function buildRoutes(config) {
565
590
  { new: true }
566
591
  );
567
592
 
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 });
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.findOneAndUpdate(
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
- if (!file) {
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)
@@ -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
- .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) {