codepiper 0.1.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/.env.example +28 -0
- package/CHANGELOG.md +10 -0
- package/LEGAL_NOTICE.md +39 -0
- package/LICENSE +21 -0
- package/README.md +524 -0
- package/package.json +90 -0
- package/packages/cli/package.json +13 -0
- package/packages/cli/src/commands/analytics.ts +157 -0
- package/packages/cli/src/commands/attach.ts +299 -0
- package/packages/cli/src/commands/audit.ts +50 -0
- package/packages/cli/src/commands/auth.ts +261 -0
- package/packages/cli/src/commands/daemon.ts +162 -0
- package/packages/cli/src/commands/doctor.ts +303 -0
- package/packages/cli/src/commands/env-set.ts +162 -0
- package/packages/cli/src/commands/hook-forward.ts +268 -0
- package/packages/cli/src/commands/keys.ts +77 -0
- package/packages/cli/src/commands/kill.ts +19 -0
- package/packages/cli/src/commands/logs.ts +419 -0
- package/packages/cli/src/commands/model.ts +172 -0
- package/packages/cli/src/commands/policy-set.ts +185 -0
- package/packages/cli/src/commands/policy.ts +227 -0
- package/packages/cli/src/commands/providers.ts +114 -0
- package/packages/cli/src/commands/resize.ts +34 -0
- package/packages/cli/src/commands/send.ts +184 -0
- package/packages/cli/src/commands/sessions.ts +202 -0
- package/packages/cli/src/commands/slash.ts +92 -0
- package/packages/cli/src/commands/start.ts +243 -0
- package/packages/cli/src/commands/stop.ts +19 -0
- package/packages/cli/src/commands/tail.ts +137 -0
- package/packages/cli/src/commands/workflow.ts +786 -0
- package/packages/cli/src/commands/workspace.ts +127 -0
- package/packages/cli/src/lib/api.ts +78 -0
- package/packages/cli/src/lib/args.ts +72 -0
- package/packages/cli/src/lib/format.ts +93 -0
- package/packages/cli/src/main.ts +563 -0
- package/packages/core/package.json +7 -0
- package/packages/core/src/config.ts +30 -0
- package/packages/core/src/errors.ts +38 -0
- package/packages/core/src/eventBus.ts +56 -0
- package/packages/core/src/eventBusAdapter.ts +143 -0
- package/packages/core/src/index.ts +10 -0
- package/packages/core/src/sqliteEventBus.ts +336 -0
- package/packages/core/src/types.ts +63 -0
- package/packages/daemon/package.json +11 -0
- package/packages/daemon/src/api/analyticsRoutes.ts +343 -0
- package/packages/daemon/src/api/authRoutes.ts +344 -0
- package/packages/daemon/src/api/bodyLimit.ts +133 -0
- package/packages/daemon/src/api/envSetRoutes.ts +170 -0
- package/packages/daemon/src/api/gitRoutes.ts +409 -0
- package/packages/daemon/src/api/hooks.ts +588 -0
- package/packages/daemon/src/api/inputPolicy.ts +249 -0
- package/packages/daemon/src/api/notificationRoutes.ts +532 -0
- package/packages/daemon/src/api/policyRoutes.ts +234 -0
- package/packages/daemon/src/api/policySetRoutes.ts +445 -0
- package/packages/daemon/src/api/routeUtils.ts +28 -0
- package/packages/daemon/src/api/routes.ts +1004 -0
- package/packages/daemon/src/api/server.ts +1388 -0
- package/packages/daemon/src/api/settingsRoutes.ts +367 -0
- package/packages/daemon/src/api/sqliteErrors.ts +47 -0
- package/packages/daemon/src/api/stt.ts +143 -0
- package/packages/daemon/src/api/terminalRoutes.ts +200 -0
- package/packages/daemon/src/api/validation.ts +287 -0
- package/packages/daemon/src/api/validationRoutes.ts +174 -0
- package/packages/daemon/src/api/workflowRoutes.ts +567 -0
- package/packages/daemon/src/api/workspaceRoutes.ts +151 -0
- package/packages/daemon/src/api/ws.ts +1588 -0
- package/packages/daemon/src/auth/apiRateLimiter.ts +73 -0
- package/packages/daemon/src/auth/authMiddleware.ts +305 -0
- package/packages/daemon/src/auth/authService.ts +496 -0
- package/packages/daemon/src/auth/rateLimiter.ts +137 -0
- package/packages/daemon/src/config/pricing.ts +79 -0
- package/packages/daemon/src/crypto/encryption.ts +196 -0
- package/packages/daemon/src/db/db.ts +2745 -0
- package/packages/daemon/src/db/index.ts +16 -0
- package/packages/daemon/src/db/migrations.ts +182 -0
- package/packages/daemon/src/db/policyDb.ts +349 -0
- package/packages/daemon/src/db/schema.sql +408 -0
- package/packages/daemon/src/db/workflowDb.ts +464 -0
- package/packages/daemon/src/git/gitUtils.ts +544 -0
- package/packages/daemon/src/index.ts +6 -0
- package/packages/daemon/src/main.ts +525 -0
- package/packages/daemon/src/notifications/pushNotifier.ts +369 -0
- package/packages/daemon/src/providers/codexAppServerScaffold.ts +49 -0
- package/packages/daemon/src/providers/registry.ts +111 -0
- package/packages/daemon/src/providers/types.ts +82 -0
- package/packages/daemon/src/sessions/auditLogger.ts +103 -0
- package/packages/daemon/src/sessions/policyEngine.ts +165 -0
- package/packages/daemon/src/sessions/policyMatcher.ts +114 -0
- package/packages/daemon/src/sessions/policyTypes.ts +94 -0
- package/packages/daemon/src/sessions/ptyProcess.ts +141 -0
- package/packages/daemon/src/sessions/sessionManager.ts +1770 -0
- package/packages/daemon/src/sessions/tmuxSession.ts +1073 -0
- package/packages/daemon/src/sessions/transcriptManager.ts +110 -0
- package/packages/daemon/src/sessions/transcriptParser.ts +149 -0
- package/packages/daemon/src/sessions/transcriptTailer.ts +214 -0
- package/packages/daemon/src/tracking/tokenTracker.ts +168 -0
- package/packages/daemon/src/workflows/contextManager.ts +83 -0
- package/packages/daemon/src/workflows/index.ts +31 -0
- package/packages/daemon/src/workflows/resultExtractor.ts +118 -0
- package/packages/daemon/src/workflows/waitConditionPoller.ts +131 -0
- package/packages/daemon/src/workflows/workflowParser.ts +217 -0
- package/packages/daemon/src/workflows/workflowRunner.ts +969 -0
- package/packages/daemon/src/workflows/workflowTypes.ts +188 -0
- package/packages/daemon/src/workflows/workflowValidator.ts +533 -0
- package/packages/providers/claude-code/package.json +11 -0
- package/packages/providers/claude-code/src/index.ts +7 -0
- package/packages/providers/claude-code/src/overlaySettings.ts +198 -0
- package/packages/providers/claude-code/src/provider.ts +311 -0
- package/packages/web/dist/android-chrome-192x192.png +0 -0
- package/packages/web/dist/android-chrome-512x512.png +0 -0
- package/packages/web/dist/apple-touch-icon.png +0 -0
- package/packages/web/dist/assets/AnalyticsPage-BIopKWRf.js +17 -0
- package/packages/web/dist/assets/PoliciesPage-CjdLN3dl.js +11 -0
- package/packages/web/dist/assets/SessionDetailPage-BtSA0V0M.js +179 -0
- package/packages/web/dist/assets/SettingsPage-Dbbz4Ca5.js +37 -0
- package/packages/web/dist/assets/WorkflowsPage-Dv6f3GgU.js +1 -0
- package/packages/web/dist/assets/chart-vendor-DlOHLaCG.js +49 -0
- package/packages/web/dist/assets/codicon-ngg6Pgfi.ttf +0 -0
- package/packages/web/dist/assets/css.worker-BvV5MPou.js +93 -0
- package/packages/web/dist/assets/editor.worker-CKy7Pnvo.js +26 -0
- package/packages/web/dist/assets/html.worker-BLJhxQJQ.js +470 -0
- package/packages/web/dist/assets/index-BbdhRfr2.css +1 -0
- package/packages/web/dist/assets/index-hgphORiw.js +204 -0
- package/packages/web/dist/assets/json.worker-usMZ-FED.js +58 -0
- package/packages/web/dist/assets/monaco-core-B_19GPAS.css +1 -0
- package/packages/web/dist/assets/monaco-core-DQ5Mk8AK.js +1234 -0
- package/packages/web/dist/assets/monaco-react-DfZNWvtW.js +11 -0
- package/packages/web/dist/assets/monacoSetup-DvBj52bT.js +1 -0
- package/packages/web/dist/assets/pencil-Dbczxz59.js +11 -0
- package/packages/web/dist/assets/react-vendor-B5MgMUHH.js +136 -0
- package/packages/web/dist/assets/refresh-cw-B0MGsYPL.js +6 -0
- package/packages/web/dist/assets/tabs-C8LsWiR5.js +1 -0
- package/packages/web/dist/assets/terminal-vendor-Cs8KPbV3.js +9 -0
- package/packages/web/dist/assets/terminal-vendor-LcAfv9l9.css +32 -0
- package/packages/web/dist/assets/trash-2-Btlg0d4l.js +6 -0
- package/packages/web/dist/assets/ts.worker-DGHjMaqB.js +67731 -0
- package/packages/web/dist/favicon.ico +0 -0
- package/packages/web/dist/icon.svg +1 -0
- package/packages/web/dist/index.html +29 -0
- package/packages/web/dist/manifest.json +29 -0
- package/packages/web/dist/og-image.png +0 -0
- package/packages/web/dist/originals/android-chrome-192x192.png +0 -0
- package/packages/web/dist/originals/android-chrome-512x512.png +0 -0
- package/packages/web/dist/originals/apple-touch-icon.png +0 -0
- package/packages/web/dist/originals/favicon.ico +0 -0
- package/packages/web/dist/piper.svg +1 -0
- package/packages/web/dist/sounds/codepiper-soft-chime.wav +0 -0
- package/packages/web/dist/sw.js +257 -0
- package/scripts/postinstall-link-workspaces.mjs +58 -0
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-user authentication service.
|
|
3
|
+
*
|
|
4
|
+
* Handles password hashing (Bun.password / Argon2id), session tokens,
|
|
5
|
+
* TOTP MFA with QR code generation, and recovery codes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as crypto from "node:crypto";
|
|
9
|
+
import * as OTPAuth from "otpauth";
|
|
10
|
+
import QRCode from "qrcode";
|
|
11
|
+
import { decrypt, type EncryptedBlob, encrypt } from "../crypto/encryption";
|
|
12
|
+
import type { AuthSessionRecord, Database } from "../db/db";
|
|
13
|
+
|
|
14
|
+
const SESSION_TOKEN_BYTES = 32;
|
|
15
|
+
const SESSION_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
16
|
+
const ONBOARDING_TOKEN_BYTES = 32;
|
|
17
|
+
const ONBOARDING_TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
18
|
+
const TOTP_WINDOW = 1; // ±1 step (90 seconds total)
|
|
19
|
+
const RECOVERY_CODE_COUNT = 8;
|
|
20
|
+
const MIN_PASSWORD_LENGTH = 8;
|
|
21
|
+
|
|
22
|
+
export interface LoginResult {
|
|
23
|
+
token: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface MfaRequiredResult {
|
|
27
|
+
mfaRequired: true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface MfaSetupRequiredResult {
|
|
31
|
+
mfaSetupRequired: true;
|
|
32
|
+
onboardingToken: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface TotpSetupResult {
|
|
36
|
+
secret: string;
|
|
37
|
+
otpauthUri: string;
|
|
38
|
+
qrDataUrl: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface TotpVerifyResult {
|
|
42
|
+
recoveryCodes: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface OnboardingTotpVerifyResult extends TotpVerifyResult {
|
|
46
|
+
token: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class AuthService {
|
|
50
|
+
private pendingTotpSecret: string | null = null;
|
|
51
|
+
|
|
52
|
+
constructor(
|
|
53
|
+
private db: Database,
|
|
54
|
+
private encryptionKey: Buffer
|
|
55
|
+
) {}
|
|
56
|
+
|
|
57
|
+
// ─── Setup ────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
isSetupRequired(): boolean {
|
|
60
|
+
return !this.db.hasAuthConfig();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
isMfaSetupPending(): boolean {
|
|
64
|
+
const config = this.db.getAuthConfig();
|
|
65
|
+
if (!config) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
return config.mfaSetupPending;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async setupPassword(
|
|
72
|
+
password: string,
|
|
73
|
+
_ip: string | null,
|
|
74
|
+
_userAgent: string | null
|
|
75
|
+
): Promise<MfaSetupRequiredResult> {
|
|
76
|
+
if (!this.isSetupRequired()) {
|
|
77
|
+
throw new Error("Password is already configured");
|
|
78
|
+
}
|
|
79
|
+
this.validatePassword(password);
|
|
80
|
+
|
|
81
|
+
const hash = await Bun.password.hash(password, {
|
|
82
|
+
algorithm: "argon2id",
|
|
83
|
+
memoryCost: 65536,
|
|
84
|
+
timeCost: 3,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const onboarding = this.generateOnboardingToken();
|
|
88
|
+
this.db.createAuthConfig(hash, {
|
|
89
|
+
mfaSetupPending: true,
|
|
90
|
+
onboardingTokenHash: onboarding.tokenHash,
|
|
91
|
+
onboardingTokenExpiresAt: onboarding.expiresAt,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
mfaSetupRequired: true,
|
|
96
|
+
onboardingToken: onboarding.rawToken,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── Login ────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
async login(
|
|
103
|
+
password: string,
|
|
104
|
+
totpCode: string | undefined,
|
|
105
|
+
ip: string | null,
|
|
106
|
+
userAgent: string | null
|
|
107
|
+
): Promise<LoginResult | MfaRequiredResult | MfaSetupRequiredResult> {
|
|
108
|
+
const config = this.db.getAuthConfig();
|
|
109
|
+
if (!config) {
|
|
110
|
+
throw new Error("Authentication not configured");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const validPassword = await Bun.password.verify(password, config.passwordHash);
|
|
114
|
+
if (!validPassword) {
|
|
115
|
+
throw new AuthError("Invalid password", "INVALID_CREDENTIALS");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Setup hardening: first-run is incomplete until MFA setup is verified.
|
|
119
|
+
if (config.mfaSetupPending) {
|
|
120
|
+
const onboarding = this.generateOnboardingToken();
|
|
121
|
+
this.db.updateAuthOnboardingState(true, onboarding.tokenHash, onboarding.expiresAt);
|
|
122
|
+
return {
|
|
123
|
+
mfaSetupRequired: true,
|
|
124
|
+
onboardingToken: onboarding.rawToken,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Check MFA
|
|
129
|
+
if (config.totpEnabled) {
|
|
130
|
+
if (!totpCode) {
|
|
131
|
+
return { mfaRequired: true };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const totpValid = this.verifyTotpCode(config, totpCode);
|
|
135
|
+
if (!totpValid) {
|
|
136
|
+
// Try recovery code
|
|
137
|
+
const recoveryValid = this.tryRecoveryCode(config, totpCode);
|
|
138
|
+
if (!recoveryValid) {
|
|
139
|
+
throw new AuthError("Invalid TOTP code", "INVALID_TOTP");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return this.createSession(ip, userAgent);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
validateOnboardingToken(tokenHash: string): boolean {
|
|
148
|
+
const config = this.db.getAuthConfig();
|
|
149
|
+
if (!config) {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
if (!config.mfaSetupPending) {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
if (!(config.onboardingTokenHash && config.onboardingTokenExpiresAt)) {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
if (Date.now() > config.onboardingTokenExpiresAt) {
|
|
159
|
+
this.db.updateAuthOnboardingState(true, null, null);
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
if (tokenHash.length !== config.onboardingTokenHash.length) {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
return crypto.timingSafeEqual(
|
|
168
|
+
Buffer.from(tokenHash, "hex"),
|
|
169
|
+
Buffer.from(config.onboardingTokenHash, "hex")
|
|
170
|
+
);
|
|
171
|
+
} catch {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ─── Session Management ───────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
validateSession(tokenHash: string): boolean {
|
|
179
|
+
const session = this.db.getAuthSession(tokenHash);
|
|
180
|
+
if (!session) return false;
|
|
181
|
+
if (Date.now() > session.expiresAt) {
|
|
182
|
+
this.db.deleteAuthSession(tokenHash);
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
touchSession(tokenHash: string): void {
|
|
189
|
+
const newExpiry = Date.now() + SESSION_EXPIRY_MS;
|
|
190
|
+
this.db.touchAuthSession(tokenHash, newExpiry);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
revokeSession(tokenHash: string): void {
|
|
194
|
+
this.db.deleteAuthSession(tokenHash);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
revokeAllSessions(exceptTokenHash?: string): void {
|
|
198
|
+
if (exceptTokenHash) {
|
|
199
|
+
const sessions = this.db.listAuthSessions();
|
|
200
|
+
for (const session of sessions) {
|
|
201
|
+
if (session.tokenHash !== exceptTokenHash) {
|
|
202
|
+
this.db.deleteAuthSession(session.tokenHash);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
} else {
|
|
206
|
+
this.db.deleteAllAuthSessions();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
listSessions(): AuthSessionRecord[] {
|
|
211
|
+
return this.db.listAuthSessions();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
cleanupExpiredSessions(): number {
|
|
215
|
+
return this.db.cleanupExpiredAuthSessions();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ─── Password ─────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
async changePassword(currentPassword: string, newPassword: string): Promise<void> {
|
|
221
|
+
const config = this.db.getAuthConfig();
|
|
222
|
+
if (!config) throw new Error("Authentication not configured");
|
|
223
|
+
|
|
224
|
+
const valid = await Bun.password.verify(currentPassword, config.passwordHash);
|
|
225
|
+
if (!valid) {
|
|
226
|
+
throw new AuthError("Current password is incorrect", "INVALID_CREDENTIALS");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
this.validatePassword(newPassword);
|
|
230
|
+
const hash = await Bun.password.hash(newPassword, {
|
|
231
|
+
algorithm: "argon2id",
|
|
232
|
+
memoryCost: 65536,
|
|
233
|
+
timeCost: 3,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
this.db.updateAuthPassword(hash);
|
|
237
|
+
// Revoke all sessions to force re-login
|
|
238
|
+
this.db.deleteAllAuthSessions();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async resetPassword(newPassword: string): Promise<void> {
|
|
242
|
+
this.validatePassword(newPassword);
|
|
243
|
+
const hash = await Bun.password.hash(newPassword, {
|
|
244
|
+
algorithm: "argon2id",
|
|
245
|
+
memoryCost: 65536,
|
|
246
|
+
timeCost: 3,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
if (this.isSetupRequired()) {
|
|
250
|
+
this.db.createAuthConfig(hash);
|
|
251
|
+
} else {
|
|
252
|
+
this.db.updateAuthPassword(hash);
|
|
253
|
+
}
|
|
254
|
+
this.db.updateAuthOnboardingState(false, null, null);
|
|
255
|
+
this.db.deleteAllAuthSessions();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ─── MFA ──────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
async generateTotpSetup(): Promise<TotpSetupResult> {
|
|
261
|
+
const secret = new OTPAuth.Secret();
|
|
262
|
+
const totp = new OTPAuth.TOTP({
|
|
263
|
+
issuer: "CodePiper",
|
|
264
|
+
label: "admin",
|
|
265
|
+
algorithm: "SHA1",
|
|
266
|
+
digits: 6,
|
|
267
|
+
period: 30,
|
|
268
|
+
secret,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const otpauthUri = totp.toString();
|
|
272
|
+
const qrDataUrl = await QRCode.toDataURL(otpauthUri);
|
|
273
|
+
|
|
274
|
+
// Store pending secret (not persisted until verified)
|
|
275
|
+
this.pendingTotpSecret = secret.base32;
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
secret: secret.base32,
|
|
279
|
+
otpauthUri,
|
|
280
|
+
qrDataUrl,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async verifyAndEnableTotp(totpCode: string): Promise<TotpVerifyResult> {
|
|
285
|
+
if (!this.pendingTotpSecret) {
|
|
286
|
+
throw new AuthError(
|
|
287
|
+
"No pending TOTP setup. Please start MFA setup again.",
|
|
288
|
+
"NO_PENDING_TOTP"
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const totp = new OTPAuth.TOTP({
|
|
293
|
+
issuer: "CodePiper",
|
|
294
|
+
label: "admin",
|
|
295
|
+
algorithm: "SHA1",
|
|
296
|
+
digits: 6,
|
|
297
|
+
period: 30,
|
|
298
|
+
secret: OTPAuth.Secret.fromBase32(this.pendingTotpSecret),
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const delta = totp.validate({ token: totpCode, window: TOTP_WINDOW });
|
|
302
|
+
if (delta === null) {
|
|
303
|
+
throw new AuthError("Invalid TOTP code", "INVALID_TOTP");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Encrypt and persist
|
|
307
|
+
const blob = encrypt(this.pendingTotpSecret, this.encryptionKey);
|
|
308
|
+
const encryptedSecret = JSON.stringify(blob);
|
|
309
|
+
|
|
310
|
+
// Generate recovery codes
|
|
311
|
+
const recoveryCodes = this.generateRecoveryCodes();
|
|
312
|
+
const hashedCodes = recoveryCodes.map((code) =>
|
|
313
|
+
this.hashRecoveryCode(code.replace(/-/g, "").toLowerCase())
|
|
314
|
+
);
|
|
315
|
+
const recoveryBlob = encrypt(JSON.stringify(hashedCodes), this.encryptionKey);
|
|
316
|
+
const encryptedRecovery = JSON.stringify(recoveryBlob);
|
|
317
|
+
|
|
318
|
+
this.db.updateAuthTotp(encryptedSecret, true, encryptedRecovery);
|
|
319
|
+
this.db.updateAuthOnboardingState(false, null, null);
|
|
320
|
+
this.pendingTotpSecret = null;
|
|
321
|
+
|
|
322
|
+
return { recoveryCodes };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async completeOnboardingMfa(
|
|
326
|
+
totpCode: string,
|
|
327
|
+
ip: string | null,
|
|
328
|
+
userAgent: string | null
|
|
329
|
+
): Promise<OnboardingTotpVerifyResult> {
|
|
330
|
+
const config = this.db.getAuthConfig();
|
|
331
|
+
if (!config?.mfaSetupPending) {
|
|
332
|
+
throw new AuthError("MFA onboarding is not pending", "MFA_SETUP_NOT_PENDING");
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const result = await this.verifyAndEnableTotp(totpCode);
|
|
336
|
+
const session = this.createSession(ip, userAgent);
|
|
337
|
+
return {
|
|
338
|
+
recoveryCodes: result.recoveryCodes,
|
|
339
|
+
token: session.token,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
resetMfa(): void {
|
|
344
|
+
this.db.updateAuthTotp(null, false, null);
|
|
345
|
+
this.db.updateAuthOnboardingState(false, null, null);
|
|
346
|
+
this.db.deleteAllAuthSessions();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ─── Private Helpers ──────────────────────────────────────────────
|
|
350
|
+
|
|
351
|
+
private createSession(ip: string | null, userAgent: string | null): LoginResult {
|
|
352
|
+
const rawToken = crypto.randomBytes(SESSION_TOKEN_BYTES).toString("hex");
|
|
353
|
+
const tokenHash = hashToken(rawToken);
|
|
354
|
+
const expiresAt = Date.now() + SESSION_EXPIRY_MS;
|
|
355
|
+
|
|
356
|
+
this.db.createAuthSession(tokenHash, ip, userAgent, expiresAt);
|
|
357
|
+
|
|
358
|
+
return { token: rawToken };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private validatePassword(password: string): void {
|
|
362
|
+
if (!password || password.length < MIN_PASSWORD_LENGTH) {
|
|
363
|
+
throw new AuthError(
|
|
364
|
+
`Password must be at least ${MIN_PASSWORD_LENGTH} characters`,
|
|
365
|
+
"WEAK_PASSWORD"
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private verifyTotpCode(
|
|
371
|
+
config: { totpSecretEncrypted: string | null; totpEnabled: boolean },
|
|
372
|
+
code: string
|
|
373
|
+
): boolean {
|
|
374
|
+
if (!config.totpSecretEncrypted) return false;
|
|
375
|
+
|
|
376
|
+
let secret: string;
|
|
377
|
+
try {
|
|
378
|
+
const blob: EncryptedBlob = JSON.parse(config.totpSecretEncrypted);
|
|
379
|
+
secret = decrypt(blob, this.encryptionKey);
|
|
380
|
+
} catch (err) {
|
|
381
|
+
console.error("[auth] Failed to decrypt TOTP secret — encryption key may have changed:", err);
|
|
382
|
+
throw new AuthError(
|
|
383
|
+
"Unable to verify TOTP: encrypted data is corrupt or the encryption key has changed. Use CLI 'codepiper auth reset-mfa' to reset MFA.",
|
|
384
|
+
"TOTP_DECRYPT_FAILED"
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const totp = new OTPAuth.TOTP({
|
|
389
|
+
issuer: "CodePiper",
|
|
390
|
+
label: "admin",
|
|
391
|
+
algorithm: "SHA1",
|
|
392
|
+
digits: 6,
|
|
393
|
+
period: 30,
|
|
394
|
+
secret: OTPAuth.Secret.fromBase32(secret),
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
const delta = totp.validate({ token: code, window: TOTP_WINDOW });
|
|
398
|
+
return delta !== null;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private tryRecoveryCode(
|
|
402
|
+
config: { recoveryCodesEncrypted: string | null },
|
|
403
|
+
code: string
|
|
404
|
+
): boolean {
|
|
405
|
+
if (!config.recoveryCodesEncrypted) return false;
|
|
406
|
+
|
|
407
|
+
let hashedCodes: string[];
|
|
408
|
+
try {
|
|
409
|
+
const blob: EncryptedBlob = JSON.parse(config.recoveryCodesEncrypted);
|
|
410
|
+
const hashedCodesJson = decrypt(blob, this.encryptionKey);
|
|
411
|
+
hashedCodes = JSON.parse(hashedCodesJson);
|
|
412
|
+
} catch (err) {
|
|
413
|
+
console.error("[auth] Failed to decrypt recovery codes:", err);
|
|
414
|
+
throw new AuthError(
|
|
415
|
+
"Unable to verify recovery code: encrypted data is corrupt. Use CLI 'codepiper auth reset-mfa' to reset MFA.",
|
|
416
|
+
"RECOVERY_DECRYPT_FAILED"
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const inputHash = this.hashRecoveryCode(code.replace(/-/g, "").toLowerCase());
|
|
421
|
+
|
|
422
|
+
// Constant-time comparison over fixed iteration count to prevent timing leaks
|
|
423
|
+
let matchIndex = -1;
|
|
424
|
+
for (let i = 0; i < RECOVERY_CODE_COUNT; i++) {
|
|
425
|
+
const storedHash = hashedCodes[i];
|
|
426
|
+
if (storedHash !== undefined) {
|
|
427
|
+
if (crypto.timingSafeEqual(Buffer.from(storedHash, "hex"), Buffer.from(inputHash, "hex"))) {
|
|
428
|
+
matchIndex = i;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (matchIndex === -1) return false;
|
|
434
|
+
|
|
435
|
+
// Remove used recovery code and log usage
|
|
436
|
+
console.warn(`[auth] Recovery code used (${hashedCodes.length - 1} remaining)`);
|
|
437
|
+
hashedCodes.splice(matchIndex, 1);
|
|
438
|
+
const newBlob = encrypt(JSON.stringify(hashedCodes), this.encryptionKey);
|
|
439
|
+
const config2 = this.db.getAuthConfig();
|
|
440
|
+
if (config2) {
|
|
441
|
+
this.db.updateAuthTotp(
|
|
442
|
+
config2.totpSecretEncrypted,
|
|
443
|
+
config2.totpEnabled,
|
|
444
|
+
JSON.stringify(newBlob)
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return true;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
private generateRecoveryCodes(): string[] {
|
|
452
|
+
const codes: string[] = [];
|
|
453
|
+
for (let i = 0; i < RECOVERY_CODE_COUNT; i++) {
|
|
454
|
+
const raw = crypto.randomBytes(4).toString("hex"); // 8 hex chars
|
|
455
|
+
codes.push(`${raw.slice(0, 4)}-${raw.slice(4, 8)}`);
|
|
456
|
+
}
|
|
457
|
+
return codes;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
private hashRecoveryCode(code: string): string {
|
|
461
|
+
return crypto.createHash("sha256").update(code).digest("hex");
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
private generateOnboardingToken(): {
|
|
465
|
+
rawToken: string;
|
|
466
|
+
tokenHash: string;
|
|
467
|
+
expiresAt: number;
|
|
468
|
+
} {
|
|
469
|
+
const rawToken = crypto.randomBytes(ONBOARDING_TOKEN_BYTES).toString("hex");
|
|
470
|
+
return {
|
|
471
|
+
rawToken,
|
|
472
|
+
tokenHash: hashToken(rawToken),
|
|
473
|
+
expiresAt: Date.now() + ONBOARDING_TOKEN_EXPIRY_MS,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Hash a raw session token for storage.
|
|
480
|
+
*/
|
|
481
|
+
export function hashToken(rawToken: string): string {
|
|
482
|
+
return crypto.createHash("sha256").update(rawToken).digest("hex");
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Auth-specific error with error code.
|
|
487
|
+
*/
|
|
488
|
+
export class AuthError extends Error {
|
|
489
|
+
constructor(
|
|
490
|
+
message: string,
|
|
491
|
+
public code: string
|
|
492
|
+
) {
|
|
493
|
+
super(message);
|
|
494
|
+
this.name = "AuthError";
|
|
495
|
+
}
|
|
496
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory sliding window rate limiter with escalating lockout.
|
|
3
|
+
*
|
|
4
|
+
* Lockout tiers:
|
|
5
|
+
* - 5 failures in 15 min → locked 15 min
|
|
6
|
+
* - 10 failures in 1 hour → locked 1 hour
|
|
7
|
+
* - 20+ failures in 24 h → locked 24 hours
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
interface AttemptRecord {
|
|
11
|
+
count: number;
|
|
12
|
+
firstAt: number;
|
|
13
|
+
lockedUntil: number | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RateLimitResult {
|
|
17
|
+
allowed: boolean;
|
|
18
|
+
retryAfterMs?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const TIERS = [
|
|
22
|
+
{ maxAttempts: 5, windowMs: 15 * 60 * 1000, lockMs: 15 * 60 * 1000 },
|
|
23
|
+
{ maxAttempts: 10, windowMs: 60 * 60 * 1000, lockMs: 60 * 60 * 1000 },
|
|
24
|
+
{ maxAttempts: 20, windowMs: 24 * 60 * 60 * 1000, lockMs: 24 * 60 * 60 * 1000 },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const CLEANUP_INTERVAL_MS = 15 * 60 * 1000;
|
|
28
|
+
const SMALLEST_TIER = TIERS[0];
|
|
29
|
+
const LARGEST_TIER = TIERS[TIERS.length - 1];
|
|
30
|
+
|
|
31
|
+
export class RateLimiter {
|
|
32
|
+
private attempts = new Map<string, AttemptRecord>();
|
|
33
|
+
private cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
|
34
|
+
|
|
35
|
+
constructor() {
|
|
36
|
+
this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL_MS);
|
|
37
|
+
// Don't keep the process alive just for cleanup
|
|
38
|
+
if (this.cleanupTimer.unref) {
|
|
39
|
+
this.cleanupTimer.unref();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
check(key: string): RateLimitResult {
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
const record = this.attempts.get(key);
|
|
46
|
+
|
|
47
|
+
if (!record) {
|
|
48
|
+
return { allowed: true };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// If currently locked, check if lock has expired
|
|
52
|
+
if (record.lockedUntil !== null) {
|
|
53
|
+
if (now < record.lockedUntil) {
|
|
54
|
+
return { allowed: false, retryAfterMs: record.lockedUntil - now };
|
|
55
|
+
}
|
|
56
|
+
// Lock expired — reset record
|
|
57
|
+
this.attempts.delete(key);
|
|
58
|
+
return { allowed: true };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Check each tier (highest first — most restrictive)
|
|
62
|
+
for (let i = TIERS.length - 1; i >= 0; i--) {
|
|
63
|
+
const tier = TIERS[i];
|
|
64
|
+
if (!tier) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (now - record.firstAt <= tier.windowMs && record.count >= tier.maxAttempts) {
|
|
68
|
+
// Lock the account
|
|
69
|
+
record.lockedUntil = now + tier.lockMs;
|
|
70
|
+
return { allowed: false, retryAfterMs: tier.lockMs };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// If the smallest window has expired, reset the counter
|
|
75
|
+
if (!SMALLEST_TIER || now - record.firstAt > SMALLEST_TIER.windowMs) {
|
|
76
|
+
this.attempts.delete(key);
|
|
77
|
+
return { allowed: true };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { allowed: true };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
recordFailure(key: string): void {
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
const record = this.attempts.get(key);
|
|
86
|
+
|
|
87
|
+
if (!record) {
|
|
88
|
+
this.attempts.set(key, { count: 1, firstAt: now, lockedUntil: null });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// If outside the largest window, start fresh
|
|
93
|
+
if (!LARGEST_TIER) {
|
|
94
|
+
this.attempts.set(key, { count: 1, firstAt: now, lockedUntil: null });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const largestWindow = LARGEST_TIER.windowMs;
|
|
98
|
+
if (now - record.firstAt > largestWindow) {
|
|
99
|
+
this.attempts.set(key, { count: 1, firstAt: now, lockedUntil: null });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
record.count++;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
recordSuccess(key: string): void {
|
|
107
|
+
this.attempts.delete(key);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private cleanup(): void {
|
|
111
|
+
const now = Date.now();
|
|
112
|
+
const largestWindow = LARGEST_TIER?.windowMs;
|
|
113
|
+
if (largestWindow === undefined) {
|
|
114
|
+
this.attempts.clear();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const [key, record] of this.attempts) {
|
|
119
|
+
// Remove entries whose largest window has expired and are not locked
|
|
120
|
+
if (now - record.firstAt > largestWindow && record.lockedUntil === null) {
|
|
121
|
+
this.attempts.delete(key);
|
|
122
|
+
}
|
|
123
|
+
// Remove entries whose lock has expired
|
|
124
|
+
if (record.lockedUntil !== null && now > record.lockedUntil) {
|
|
125
|
+
this.attempts.delete(key);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
destroy(): void {
|
|
131
|
+
if (this.cleanupTimer) {
|
|
132
|
+
clearInterval(this.cleanupTimer);
|
|
133
|
+
this.cleanupTimer = null;
|
|
134
|
+
}
|
|
135
|
+
this.attempts.clear();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic API pricing configuration.
|
|
3
|
+
*
|
|
4
|
+
* Prices are in USD per million tokens.
|
|
5
|
+
* Source: https://platform.claude.com/docs/en/about-claude/pricing
|
|
6
|
+
*
|
|
7
|
+
* Update this file when Anthropic changes pricing.
|
|
8
|
+
* Last updated: 2026-02-15
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface ModelPricing {
|
|
12
|
+
input: number;
|
|
13
|
+
output: number;
|
|
14
|
+
cacheWrite: number;
|
|
15
|
+
cacheRead: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Pricing table keyed by short model family name.
|
|
20
|
+
* Model IDs like "claude-opus-4-6" are matched via getPricingForModel().
|
|
21
|
+
*/
|
|
22
|
+
export const MODEL_PRICING: Record<string, ModelPricing> = {
|
|
23
|
+
"opus-4.6": { input: 5, output: 25, cacheWrite: 6.25, cacheRead: 0.5 },
|
|
24
|
+
"opus-4.5": { input: 5, output: 25, cacheWrite: 6.25, cacheRead: 0.5 },
|
|
25
|
+
"opus-4.1": { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.5 },
|
|
26
|
+
"opus-4": { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.5 },
|
|
27
|
+
"sonnet-4.5": { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 },
|
|
28
|
+
"sonnet-4": { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 },
|
|
29
|
+
"haiku-4.5": { input: 1, output: 5, cacheWrite: 1.25, cacheRead: 0.1 },
|
|
30
|
+
"haiku-3.5": { input: 0.8, output: 4, cacheWrite: 1, cacheRead: 0.08 },
|
|
31
|
+
"haiku-3": { input: 0.25, output: 1.25, cacheWrite: 0.3, cacheRead: 0.03 },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function lookupPricing(key: string): ModelPricing {
|
|
35
|
+
const pricing = MODEL_PRICING[key];
|
|
36
|
+
if (!pricing) {
|
|
37
|
+
throw new Error(`Missing pricing entry for model key: ${key}`);
|
|
38
|
+
}
|
|
39
|
+
return pricing;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Default pricing for unknown models (uses Sonnet 4.5 rates). */
|
|
43
|
+
const DEFAULT_PRICING = lookupPricing("sonnet-4.5");
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Match a full model ID (e.g. "claude-opus-4-6", "claude-sonnet-4-5-20250929")
|
|
47
|
+
* to its pricing entry.
|
|
48
|
+
*/
|
|
49
|
+
export function getPricingForModel(model: string): ModelPricing {
|
|
50
|
+
if (model.includes("opus-4-6") || model.includes("opus-4.6")) return lookupPricing("opus-4.6");
|
|
51
|
+
if (model.includes("opus-4-5") || model.includes("opus-4.5")) return lookupPricing("opus-4.5");
|
|
52
|
+
if (model.includes("opus-4-1") || model.includes("opus-4.1")) return lookupPricing("opus-4.1");
|
|
53
|
+
if (model.includes("opus")) return lookupPricing("opus-4");
|
|
54
|
+
if (model.includes("sonnet-4-5") || model.includes("sonnet-4.5"))
|
|
55
|
+
return lookupPricing("sonnet-4.5");
|
|
56
|
+
if (model.includes("sonnet")) return lookupPricing("sonnet-4");
|
|
57
|
+
if (model.includes("haiku-4-5") || model.includes("haiku-4.5")) return lookupPricing("haiku-4.5");
|
|
58
|
+
if (model.includes("haiku-3-5") || model.includes("haiku-3.5")) return lookupPricing("haiku-3.5");
|
|
59
|
+
if (model.includes("haiku")) return lookupPricing("haiku-3");
|
|
60
|
+
return DEFAULT_PRICING;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Calculate estimated equivalent API cost from token counts.
|
|
65
|
+
*/
|
|
66
|
+
export function calculateCost(
|
|
67
|
+
promptTokens: number,
|
|
68
|
+
completionTokens: number,
|
|
69
|
+
cacheCreation: number,
|
|
70
|
+
cacheRead: number,
|
|
71
|
+
pricing: ModelPricing
|
|
72
|
+
): number {
|
|
73
|
+
return (
|
|
74
|
+
(promptTokens / 1_000_000) * pricing.input +
|
|
75
|
+
(completionTokens / 1_000_000) * pricing.output +
|
|
76
|
+
(cacheCreation / 1_000_000) * pricing.cacheWrite +
|
|
77
|
+
(cacheRead / 1_000_000) * pricing.cacheRead
|
|
78
|
+
);
|
|
79
|
+
}
|