teleportation-cli 1.0.0
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/.claude/hooks/config-loader.mjs +93 -0
- package/.claude/hooks/heartbeat.mjs +331 -0
- package/.claude/hooks/notification.mjs +35 -0
- package/.claude/hooks/permission_request.mjs +307 -0
- package/.claude/hooks/post_tool_use.mjs +137 -0
- package/.claude/hooks/pre_tool_use.mjs +451 -0
- package/.claude/hooks/session-register.mjs +274 -0
- package/.claude/hooks/session_end.mjs +256 -0
- package/.claude/hooks/session_start.mjs +308 -0
- package/.claude/hooks/stop.mjs +277 -0
- package/.claude/hooks/user_prompt_submit.mjs +91 -0
- package/LICENSE +21 -0
- package/README.md +243 -0
- package/lib/auth/api-key.js +110 -0
- package/lib/auth/credentials.js +341 -0
- package/lib/backup/manager.js +461 -0
- package/lib/cli/daemon-commands.js +299 -0
- package/lib/cli/index.js +303 -0
- package/lib/cli/session-commands.js +294 -0
- package/lib/cli/snapshot-commands.js +223 -0
- package/lib/cli/worktree-commands.js +291 -0
- package/lib/config/manager.js +306 -0
- package/lib/daemon/lifecycle.js +336 -0
- package/lib/daemon/pid-manager.js +160 -0
- package/lib/daemon/teleportation-daemon.js +2009 -0
- package/lib/handoff/config.js +102 -0
- package/lib/handoff/example.js +152 -0
- package/lib/handoff/git-handoff.js +351 -0
- package/lib/handoff/handoff.js +277 -0
- package/lib/handoff/index.js +25 -0
- package/lib/handoff/session-state.js +238 -0
- package/lib/install/installer.js +555 -0
- package/lib/machine-coders/claude-code-adapter.js +329 -0
- package/lib/machine-coders/example.js +239 -0
- package/lib/machine-coders/gemini-cli-adapter.js +406 -0
- package/lib/machine-coders/index.js +103 -0
- package/lib/machine-coders/interface.js +168 -0
- package/lib/router/classifier.js +251 -0
- package/lib/router/example.js +92 -0
- package/lib/router/index.js +69 -0
- package/lib/router/mech-llms-client.js +277 -0
- package/lib/router/models.js +188 -0
- package/lib/router/router.js +382 -0
- package/lib/session/cleanup.js +100 -0
- package/lib/session/metadata.js +258 -0
- package/lib/session/mute-checker.js +114 -0
- package/lib/session-registry/manager.js +302 -0
- package/lib/snapshot/manager.js +390 -0
- package/lib/utils/errors.js +166 -0
- package/lib/utils/logger.js +148 -0
- package/lib/utils/retry.js +155 -0
- package/lib/worktree/manager.js +301 -0
- package/package.json +66 -0
- package/teleportation-cli.cjs +2987 -0
package/README.md
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# Teleportation
|
|
2
|
+
|
|
3
|
+
**Remote approval system for Claude Code**
|
|
4
|
+
|
|
5
|
+
Approve AI coding changes from your phone—work continues while you're away.
|
|
6
|
+
|
|
7
|
+
## Overview
|
|
8
|
+
|
|
9
|
+
Teleportation enables developers to approve Claude Code actions remotely from any device. The system intercepts Claude Code's tool requests via hooks, routes them to a relay API, and displays them in a mobile-friendly UI for instant approval.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
### Method 1: Quick Install (curl)
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
curl -fsSL https://raw.githubusercontent.com/dundas/teleportation-private/main/scripts/install.sh | bash
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### Method 2: From Source (Recommended)
|
|
20
|
+
|
|
21
|
+
**Requirements:** Bun >=1.3.0
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# Install Bun if you haven't already
|
|
25
|
+
curl -fsSL https://bun.sh/install | bash
|
|
26
|
+
|
|
27
|
+
# Clone and install
|
|
28
|
+
git clone https://github.com/dundas/teleportation-private.git
|
|
29
|
+
cd teleportation-private
|
|
30
|
+
bun install
|
|
31
|
+
bun link # Makes 'teleportation' command available globally
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Method 3: GitHub Releases
|
|
35
|
+
|
|
36
|
+
Download the latest release from [GitHub Releases](https://github.com/dundas/teleportation-private/releases/latest) and extract to `~/.teleportation/`.
|
|
37
|
+
|
|
38
|
+
## Requirements
|
|
39
|
+
|
|
40
|
+
- **Bun >=1.3.0** (JavaScript runtime)
|
|
41
|
+
- **Claude Code CLI** installed
|
|
42
|
+
|
|
43
|
+
### Installing Bun
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
# macOS, Linux, WSL
|
|
47
|
+
curl -fsSL https://bun.sh/install | bash
|
|
48
|
+
|
|
49
|
+
# Windows (PowerShell)
|
|
50
|
+
powershell -c "irm bun.sh/install.ps1 | iex"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Migrating from Node.js Version
|
|
54
|
+
|
|
55
|
+
If you're upgrading from a previous Node.js version (v0.x):
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
# 1. Uninstall old version (if installed globally)
|
|
59
|
+
npm uninstall -g @teleportation/cli
|
|
60
|
+
|
|
61
|
+
# 2. Install Bun
|
|
62
|
+
curl -fsSL https://bun.sh/install | bash
|
|
63
|
+
|
|
64
|
+
# 3. Remove old dependencies
|
|
65
|
+
cd /path/to/teleportation
|
|
66
|
+
rm -rf node_modules package-lock.json
|
|
67
|
+
|
|
68
|
+
# 4. Install with Bun
|
|
69
|
+
bun install
|
|
70
|
+
|
|
71
|
+
# 5. Verify installation
|
|
72
|
+
./teleportation-cli.cjs --version
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**Note:** Your hooks and credentials will be preserved during the upgrade.
|
|
76
|
+
|
|
77
|
+
## Quick Start
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
# 1. Enable hooks
|
|
81
|
+
teleportation on
|
|
82
|
+
|
|
83
|
+
# 2. Authenticate with your account
|
|
84
|
+
teleportation login
|
|
85
|
+
|
|
86
|
+
# 3. Check status
|
|
87
|
+
teleportation status
|
|
88
|
+
|
|
89
|
+
# 4. Start Claude Code - approvals will be routed to your phone!
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Usage
|
|
93
|
+
|
|
94
|
+
### Basic Commands
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
teleportation on # Enable remote approval hooks
|
|
98
|
+
teleportation off # Disable hooks (local mode)
|
|
99
|
+
teleportation status # Show current configuration
|
|
100
|
+
teleportation login # Authenticate with relay server
|
|
101
|
+
teleportation logout # Clear credentials
|
|
102
|
+
teleportation help # Show all commands
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Session Management
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
teleportation session list # List active sessions
|
|
109
|
+
teleportation session info # Show current session details
|
|
110
|
+
teleportation session pause # Pause current session
|
|
111
|
+
teleportation session resume # Resume paused session
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Worktree Commands (Multi-session)
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
teleportation worktree create <name> # Create isolated worktree
|
|
118
|
+
teleportation worktree list # List all worktrees
|
|
119
|
+
teleportation worktree use <name> # Switch to worktree
|
|
120
|
+
teleportation worktree merge <name> # Merge worktree changes
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Snapshot Commands
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
teleportation snapshot create <name> # Create code snapshot
|
|
127
|
+
teleportation snapshot list # List snapshots
|
|
128
|
+
teleportation snapshot restore <name> # Restore to snapshot
|
|
129
|
+
teleportation snapshot diff <name> # Compare with snapshot
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## How It Works
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
|
136
|
+
│ Claude Code │────▶│ Relay API │────▶│ Mobile UI │
|
|
137
|
+
│ (Your Mac) │ │ (Cloud) │ │ (Your Phone) │
|
|
138
|
+
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
|
139
|
+
│ │ │
|
|
140
|
+
│ 1. Tool request │ 2. Push to queue │
|
|
141
|
+
│ intercepted │ │
|
|
142
|
+
│ │ 3. Display for │
|
|
143
|
+
│ │ approval │
|
|
144
|
+
│ │ │
|
|
145
|
+
│ 5. Continue or │ 4. User approves/ │
|
|
146
|
+
│ block action │◀───denies │
|
|
147
|
+
│ │ │
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
1. **Intercept**: Claude Code hooks capture tool requests
|
|
151
|
+
2. **Route**: Requests are sent to the relay API
|
|
152
|
+
3. **Display**: Mobile UI shows pending approvals
|
|
153
|
+
4. **Decide**: You approve or deny from your phone
|
|
154
|
+
5. **Execute**: Claude Code continues or blocks the action
|
|
155
|
+
|
|
156
|
+
## Project Structure
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
teleportation/
|
|
160
|
+
├── lib/
|
|
161
|
+
│ ├── auth/ # Credential encryption & management
|
|
162
|
+
│ ├── cli/ # CLI command implementations
|
|
163
|
+
│ ├── config/ # Configuration management
|
|
164
|
+
│ ├── install/ # Installation logic
|
|
165
|
+
│ └── session/ # Session metadata extraction
|
|
166
|
+
├── relay/ # Relay API server
|
|
167
|
+
├── mobile-ui/ # Mobile-friendly web UI
|
|
168
|
+
├── .claude/
|
|
169
|
+
│ └── hooks/ # Claude Code hooks
|
|
170
|
+
├── scripts/
|
|
171
|
+
│ └── install.sh # Installation script
|
|
172
|
+
├── teleportation-cli.cjs # Main CLI tool
|
|
173
|
+
└── tests/ # Test suites
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Configuration
|
|
177
|
+
|
|
178
|
+
Configuration is stored in `~/.teleportation/`:
|
|
179
|
+
|
|
180
|
+
```
|
|
181
|
+
~/.teleportation/
|
|
182
|
+
├── config.json # User preferences
|
|
183
|
+
├── credentials.enc # Encrypted credentials
|
|
184
|
+
└── bin/
|
|
185
|
+
└── teleportation # CLI symlink
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Environment variables:
|
|
189
|
+
- `TELEPORTATION_RELAY_URL` - Custom relay server URL
|
|
190
|
+
- `TELEPORTATION_API_KEY` - API key for authentication
|
|
191
|
+
|
|
192
|
+
## Self-Hosting
|
|
193
|
+
|
|
194
|
+
Want to run your own relay server?
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
cd relay
|
|
198
|
+
cp .env.example .env
|
|
199
|
+
# Edit .env with your configuration
|
|
200
|
+
bun install
|
|
201
|
+
bun start
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
See [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md) for full deployment instructions.
|
|
205
|
+
|
|
206
|
+
## Development
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
# Install dependencies
|
|
210
|
+
bun install
|
|
211
|
+
|
|
212
|
+
# Run tests
|
|
213
|
+
bun test
|
|
214
|
+
|
|
215
|
+
# Run relay server locally
|
|
216
|
+
bun run dev:relay
|
|
217
|
+
|
|
218
|
+
# Run mobile UI locally
|
|
219
|
+
bun run dev:mobile
|
|
220
|
+
|
|
221
|
+
# Run everything
|
|
222
|
+
bun run dev:all
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
## Security
|
|
226
|
+
|
|
227
|
+
- **End-to-end encryption**: Credentials encrypted with AES-256
|
|
228
|
+
- **OAuth authentication**: Secure login via Google/GitHub
|
|
229
|
+
- **Multi-tenant isolation**: Your data is isolated from other users
|
|
230
|
+
- **Privacy-preserving**: Session existence not revealed to unauthorized users
|
|
231
|
+
|
|
232
|
+
## License
|
|
233
|
+
|
|
234
|
+
MIT
|
|
235
|
+
|
|
236
|
+
## Contributing
|
|
237
|
+
|
|
238
|
+
See [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines.
|
|
239
|
+
|
|
240
|
+
## Support
|
|
241
|
+
|
|
242
|
+
- [GitHub Issues](https://github.com/dundas/teleportation-private/issues)
|
|
243
|
+
- [Documentation](https://github.com/dundas/teleportation-private/wiki)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* API Key authentication utilities
|
|
4
|
+
* Validates API keys and tests them against the relay API
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { retryFetch } from '../utils/retry.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Validate API key format
|
|
11
|
+
* Basic validation - checks for reasonable length and format
|
|
12
|
+
*/
|
|
13
|
+
export function validateApiKeyFormat(apiKey) {
|
|
14
|
+
if (!apiKey || typeof apiKey !== 'string') {
|
|
15
|
+
return { valid: false, error: 'API key must be a non-empty string' };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (apiKey.length < 10) {
|
|
19
|
+
return { valid: false, error: 'API key is too short (minimum 10 characters)' };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (apiKey.length > 256) {
|
|
23
|
+
return { valid: false, error: 'API key is too long (maximum 256 characters)' };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Basic format check - should contain alphanumeric and some special chars
|
|
27
|
+
if (!/^[a-zA-Z0-9\-_\.]+$/.test(apiKey)) {
|
|
28
|
+
return { valid: false, error: 'API key contains invalid characters' };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return { valid: true };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Test API key against relay API with retry logic
|
|
36
|
+
*/
|
|
37
|
+
export async function testApiKey(apiKey, relayApiUrl, retryOptions = {}) {
|
|
38
|
+
if (!relayApiUrl) {
|
|
39
|
+
return { valid: false, error: 'Relay API URL is required' };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const response = await retryFetch(
|
|
44
|
+
`${relayApiUrl}/api/version`,
|
|
45
|
+
{
|
|
46
|
+
method: 'GET',
|
|
47
|
+
headers: {
|
|
48
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
49
|
+
'Content-Type': 'application/json'
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
maxRetries: 2,
|
|
54
|
+
initialDelay: 1000,
|
|
55
|
+
timeout: 10000,
|
|
56
|
+
shouldRetry: (error) => {
|
|
57
|
+
// Don't retry on 401 (authentication failures)
|
|
58
|
+
if (error.status === 401) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
// Retry on network errors and 5xx server errors
|
|
62
|
+
return true;
|
|
63
|
+
},
|
|
64
|
+
...retryOptions
|
|
65
|
+
}
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
if (response.ok) {
|
|
69
|
+
const data = await response.json();
|
|
70
|
+
return { valid: true, data };
|
|
71
|
+
} else if (response.status === 401) {
|
|
72
|
+
return { valid: false, error: 'Invalid API key - authentication failed. Please check your API key and try again.' };
|
|
73
|
+
} else if (response.status >= 500) {
|
|
74
|
+
return { valid: false, error: `Relay API server error (${response.status}). The server may be temporarily unavailable. Please try again later.` };
|
|
75
|
+
} else {
|
|
76
|
+
return { valid: false, error: `API returned unexpected status ${response.status}. Please check your relay API configuration.` };
|
|
77
|
+
}
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (error.name === 'AbortError' || error.message?.includes('timeout')) {
|
|
80
|
+
return { valid: false, error: `Connection timeout: Could not reach relay API at ${relayApiUrl}. Please check your network connection and try again.` };
|
|
81
|
+
}
|
|
82
|
+
if (error.code === 'ENOTFOUND') {
|
|
83
|
+
return { valid: false, error: `Cannot resolve relay API hostname. Please verify the URL is correct: ${relayApiUrl}` };
|
|
84
|
+
}
|
|
85
|
+
if (error.code === 'ECONNREFUSED') {
|
|
86
|
+
return { valid: false, error: `Connection refused: Relay API is not running at ${relayApiUrl}. Start it with 'teleportation start' or check your configuration.` };
|
|
87
|
+
}
|
|
88
|
+
return { valid: false, error: `Failed to test API key: ${error.message}. Please check your network connection and relay API status.` };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Validate and test API key
|
|
94
|
+
*/
|
|
95
|
+
export async function validateApiKey(apiKey, relayApiUrl) {
|
|
96
|
+
// First validate format
|
|
97
|
+
const formatCheck = validateApiKeyFormat(apiKey);
|
|
98
|
+
if (!formatCheck.valid) {
|
|
99
|
+
return formatCheck;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Then test against API if URL provided
|
|
103
|
+
if (relayApiUrl) {
|
|
104
|
+
return await testApiKey(apiKey, relayApiUrl);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Format is valid but can't test without URL
|
|
108
|
+
return { valid: true, warning: 'API key format is valid but not tested (no relay URL)' };
|
|
109
|
+
}
|
|
110
|
+
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Credential encryption and storage management
|
|
4
|
+
* Uses AES-256 encryption with system keychain for key management
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createCipheriv, createDecipheriv, randomBytes, scrypt } from 'crypto';
|
|
8
|
+
import { promisify } from 'util';
|
|
9
|
+
import { readFile, writeFile, unlink, stat, mkdir } from 'fs/promises';
|
|
10
|
+
import { join, dirname } from 'path';
|
|
11
|
+
import { homedir } from 'os';
|
|
12
|
+
|
|
13
|
+
const scryptAsync = promisify(scrypt);
|
|
14
|
+
const KEY_LENGTH = 32; // 256 bits for AES-256
|
|
15
|
+
const IV_LENGTH = 16; // 128 bits for AES IV
|
|
16
|
+
const SALT_LENGTH = 32;
|
|
17
|
+
const DEFAULT_CREDENTIALS_PATH = join(homedir(), '.teleportation', 'credentials');
|
|
18
|
+
const DEFAULT_KEY_PATH = join(homedir(), '.teleportation', '.key');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Get encryption key from system keychain or fallback to file-based key
|
|
22
|
+
*/
|
|
23
|
+
async function getEncryptionKey(keyPath = DEFAULT_KEY_PATH) {
|
|
24
|
+
// Try to use system keychain first (platform-specific)
|
|
25
|
+
// For macOS: use security command
|
|
26
|
+
// For Linux: use secret-tool or fallback to file
|
|
27
|
+
// For Windows: use Credential Manager or fallback to file
|
|
28
|
+
|
|
29
|
+
const platform = process.platform;
|
|
30
|
+
|
|
31
|
+
if (platform === 'darwin') {
|
|
32
|
+
// macOS - try to use keychain
|
|
33
|
+
try {
|
|
34
|
+
const { execSync } = await import('child_process');
|
|
35
|
+
try {
|
|
36
|
+
const key = execSync(
|
|
37
|
+
'security find-generic-password -a teleportation -s encryption-key -w 2>/dev/null',
|
|
38
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
|
|
39
|
+
).trim();
|
|
40
|
+
if (key && key.length >= 32) {
|
|
41
|
+
return Buffer.from(key.slice(0, 64), 'hex'); // Use first 32 bytes
|
|
42
|
+
}
|
|
43
|
+
} catch (e) {
|
|
44
|
+
// Keychain entry doesn't exist, fall through to file-based
|
|
45
|
+
}
|
|
46
|
+
} catch (e) {
|
|
47
|
+
// security command not available, fall through
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Fallback: use file-based key with scrypt derivation
|
|
52
|
+
try {
|
|
53
|
+
const keyData = await readFile(keyPath, 'utf8');
|
|
54
|
+
const parsed = JSON.parse(keyData);
|
|
55
|
+
|
|
56
|
+
// If we have a stored derived key, use it directly
|
|
57
|
+
if (parsed.derivedKey) {
|
|
58
|
+
return Buffer.from(parsed.derivedKey, 'hex');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Legacy format: derive from salt and master key
|
|
62
|
+
if (parsed.salt && parsed.masterKey) {
|
|
63
|
+
const key = await scryptAsync(Buffer.from(parsed.masterKey, 'hex'), Buffer.from(parsed.salt, 'hex'), KEY_LENGTH);
|
|
64
|
+
// Update to new format
|
|
65
|
+
await writeFile(
|
|
66
|
+
keyPath,
|
|
67
|
+
JSON.stringify({
|
|
68
|
+
derivedKey: Buffer.from(key).toString('hex')
|
|
69
|
+
}),
|
|
70
|
+
{ mode: 0o600 }
|
|
71
|
+
);
|
|
72
|
+
return Buffer.from(key);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
throw new Error('Invalid key file format');
|
|
76
|
+
} catch (e) {
|
|
77
|
+
if (e.code === 'ENOENT' || e.message === 'Invalid key file format') {
|
|
78
|
+
// Key file doesn't exist or is invalid, create a new one
|
|
79
|
+
const masterKey = randomBytes(KEY_LENGTH);
|
|
80
|
+
const salt = randomBytes(SALT_LENGTH);
|
|
81
|
+
const derivedKey = await scryptAsync(masterKey, salt, KEY_LENGTH);
|
|
82
|
+
|
|
83
|
+
// Ensure directory exists
|
|
84
|
+
await mkdir(dirname(keyPath), { recursive: true });
|
|
85
|
+
|
|
86
|
+
// Save derived key directly (32 bytes for AES-256)
|
|
87
|
+
await writeFile(
|
|
88
|
+
keyPath,
|
|
89
|
+
JSON.stringify({
|
|
90
|
+
derivedKey: Buffer.from(derivedKey).toString('hex')
|
|
91
|
+
}),
|
|
92
|
+
{ mode: 0o600 } // Owner read/write only
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
return Buffer.from(derivedKey);
|
|
96
|
+
}
|
|
97
|
+
throw e;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Encrypt data using AES-256-GCM
|
|
103
|
+
*/
|
|
104
|
+
function encrypt(data, key) {
|
|
105
|
+
const iv = randomBytes(IV_LENGTH);
|
|
106
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
107
|
+
|
|
108
|
+
let encrypted = cipher.update(JSON.stringify(data), 'utf8', 'hex');
|
|
109
|
+
encrypted += cipher.final('hex');
|
|
110
|
+
|
|
111
|
+
const authTag = cipher.getAuthTag();
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
data: encrypted,
|
|
115
|
+
iv: iv.toString('hex'),
|
|
116
|
+
authTag: authTag.toString('hex')
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Decrypt data using AES-256-GCM
|
|
122
|
+
*/
|
|
123
|
+
function decrypt(encryptedData, key) {
|
|
124
|
+
const { data, iv, authTag } = encryptedData;
|
|
125
|
+
|
|
126
|
+
if (!data || !iv || !authTag) {
|
|
127
|
+
throw new Error('Invalid encrypted data structure');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'hex'));
|
|
131
|
+
decipher.setAuthTag(Buffer.from(authTag, 'hex'));
|
|
132
|
+
|
|
133
|
+
let decrypted = decipher.update(data, 'hex', 'utf8');
|
|
134
|
+
decrypted += decipher.final('utf8');
|
|
135
|
+
|
|
136
|
+
return JSON.parse(decrypted);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* CredentialManager - handles encryption, storage, and retrieval of credentials
|
|
141
|
+
*/
|
|
142
|
+
export class CredentialManager {
|
|
143
|
+
constructor(credentialsPath = DEFAULT_CREDENTIALS_PATH, keyPath = DEFAULT_KEY_PATH) {
|
|
144
|
+
this.credentialsPath = credentialsPath;
|
|
145
|
+
this.keyPath = keyPath;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Save credentials to encrypted file
|
|
150
|
+
*/
|
|
151
|
+
async save(credentials) {
|
|
152
|
+
if (!credentials || typeof credentials !== 'object') {
|
|
153
|
+
throw new Error('Credentials must be a non-null object');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Ensure directory exists
|
|
157
|
+
await mkdir(dirname(this.credentialsPath), { recursive: true });
|
|
158
|
+
|
|
159
|
+
// Get encryption key
|
|
160
|
+
const key = await getEncryptionKey(this.keyPath);
|
|
161
|
+
|
|
162
|
+
// Encrypt credentials
|
|
163
|
+
const encrypted = encrypt(credentials, key);
|
|
164
|
+
|
|
165
|
+
// Add metadata
|
|
166
|
+
const fileData = {
|
|
167
|
+
version: '1.0',
|
|
168
|
+
createdAt: Date.now(),
|
|
169
|
+
...encrypted
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// Write encrypted file with 600 permissions
|
|
173
|
+
await writeFile(
|
|
174
|
+
this.credentialsPath,
|
|
175
|
+
JSON.stringify(fileData, null, 2),
|
|
176
|
+
{ mode: 0o600 }
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Load and decrypt credentials from file
|
|
182
|
+
*/
|
|
183
|
+
async load() {
|
|
184
|
+
try {
|
|
185
|
+
const fileContent = await readFile(this.credentialsPath, 'utf8');
|
|
186
|
+
const fileData = JSON.parse(fileContent);
|
|
187
|
+
|
|
188
|
+
if (!fileData.data || !fileData.iv || !fileData.authTag) {
|
|
189
|
+
throw new Error('Invalid credential file format');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Get encryption key
|
|
193
|
+
const key = await getEncryptionKey(this.keyPath);
|
|
194
|
+
|
|
195
|
+
// Decrypt credentials
|
|
196
|
+
const credentials = decrypt(
|
|
197
|
+
{
|
|
198
|
+
data: fileData.data,
|
|
199
|
+
iv: fileData.iv,
|
|
200
|
+
authTag: fileData.authTag
|
|
201
|
+
},
|
|
202
|
+
key
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
return credentials;
|
|
206
|
+
} catch (e) {
|
|
207
|
+
if (e.code === 'ENOENT') {
|
|
208
|
+
// File doesn't exist, return null
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
if (e.message === 'Invalid credential file format') {
|
|
212
|
+
throw new Error('Credential file is corrupted or invalid. Please re-authenticate by running: teleportation login\n\nThe existing credential file will be replaced.');
|
|
213
|
+
}
|
|
214
|
+
if (e.message.includes('decrypt') || e.message.includes('Unsupported state') || e.message.includes('bad decrypt')) {
|
|
215
|
+
throw new Error('Failed to decrypt credentials. This usually means:\n - The encryption key is missing or has been changed\n - The credential file was corrupted\n\nPlease re-authenticate by running: teleportation login');
|
|
216
|
+
}
|
|
217
|
+
if (e.message.includes('Invalid key file format')) {
|
|
218
|
+
throw new Error('Encryption key file is corrupted. Please re-authenticate by running: teleportation login\n\nThis will regenerate the encryption key.');
|
|
219
|
+
}
|
|
220
|
+
// Re-throw other errors with helpful message
|
|
221
|
+
throw new Error(`Failed to load credentials: ${e.message}\n\nIf this problem persists, try running: teleportation logout && teleportation login`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Check if credentials are expired
|
|
227
|
+
*/
|
|
228
|
+
async isExpired() {
|
|
229
|
+
const credentials = await this.load();
|
|
230
|
+
if (!credentials || !credentials.expiresAt) {
|
|
231
|
+
return false; // No expiry set, consider valid
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return Date.now() >= credentials.expiresAt;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Check if credentials need rotation (within 7 days of expiry or expired)
|
|
239
|
+
*/
|
|
240
|
+
async needsRotation(warningDays = 7) {
|
|
241
|
+
const credentials = await this.load();
|
|
242
|
+
if (!credentials || !credentials.expiresAt) {
|
|
243
|
+
return false; // No expiry set, consider valid
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const now = Date.now();
|
|
247
|
+
const expiresAt = credentials.expiresAt;
|
|
248
|
+
const warningThreshold = expiresAt - (warningDays * 24 * 60 * 60 * 1000);
|
|
249
|
+
|
|
250
|
+
return now >= warningThreshold;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Get days until expiry (negative if expired)
|
|
255
|
+
*/
|
|
256
|
+
async daysUntilExpiry() {
|
|
257
|
+
const credentials = await this.load();
|
|
258
|
+
if (!credentials || !credentials.expiresAt) {
|
|
259
|
+
return null; // No expiry set
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const now = Date.now();
|
|
263
|
+
const expiresAt = credentials.expiresAt;
|
|
264
|
+
const diffMs = expiresAt - now;
|
|
265
|
+
return Math.floor(diffMs / (24 * 60 * 60 * 1000));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Update credentials (for token refresh or re-authentication)
|
|
270
|
+
*/
|
|
271
|
+
async update(updates) {
|
|
272
|
+
const current = await this.load();
|
|
273
|
+
if (!current) {
|
|
274
|
+
throw new Error('No existing credentials to update');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const updated = {
|
|
278
|
+
...current,
|
|
279
|
+
...updates,
|
|
280
|
+
updatedAt: Date.now()
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
await this.save(updated);
|
|
284
|
+
return updated;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Refresh access token if refresh token is available
|
|
289
|
+
* This is a placeholder - actual implementation depends on OAuth provider
|
|
290
|
+
*/
|
|
291
|
+
async refreshToken() {
|
|
292
|
+
const credentials = await this.load();
|
|
293
|
+
if (!credentials || !credentials.refreshToken) {
|
|
294
|
+
throw new Error('No refresh token available');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// TODO: Implement actual token refresh with OAuth provider
|
|
298
|
+
// For now, this is a placeholder that would be implemented in oauth-client.js
|
|
299
|
+
throw new Error('Token refresh not yet implemented. Please run "teleportation login" to re-authenticate.');
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Delete credentials file
|
|
304
|
+
*/
|
|
305
|
+
async delete() {
|
|
306
|
+
try {
|
|
307
|
+
await unlink(this.credentialsPath);
|
|
308
|
+
} catch (e) {
|
|
309
|
+
if (e.code !== 'ENOENT') {
|
|
310
|
+
throw e;
|
|
311
|
+
}
|
|
312
|
+
// File doesn't exist, that's fine
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Verify file permissions are correct (600)
|
|
318
|
+
*/
|
|
319
|
+
async verifyPermissions() {
|
|
320
|
+
try {
|
|
321
|
+
const stats = await stat(this.credentialsPath);
|
|
322
|
+
const mode = stats.mode & parseInt('777', 8);
|
|
323
|
+
return mode === parseInt('600', 8);
|
|
324
|
+
} catch (e) {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Check if credentials exist
|
|
331
|
+
*/
|
|
332
|
+
async exists() {
|
|
333
|
+
try {
|
|
334
|
+
await stat(this.credentialsPath);
|
|
335
|
+
return true;
|
|
336
|
+
} catch (e) {
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|