Epic: Phase 2 — Client-Server Mode with Password Auth + Signed Grants#89
Epic: Phase 2 — Client-Server Mode with Password Auth + Signed Grants#89NoahCardoza merged 27 commits intomainfrom
Conversation
48e96bc to
5b5ce28
Compare
There was a problem hiding this comment.
Pull request overview
This PR implements Phase 2 of the 2keychains system: client-server mode with password authentication and cryptographically signed grants. The implementation adds JWT-based session authentication, Ed25519-signed grant tokens, command binding for additional security, and server-side approval workflow orchestration.
Changes:
- Server API routes for secrets, requests, grants, and injection with JWT session authentication
- Signed grant tokens using Ed25519 keypairs, with command hash binding for verification
- Client-server split: server orchestrates approvals, clients verify signed grants locally before injection
- Request/grant persistence to survive server restarts
Reviewed changes
Copilot reviewed 37 out of 38 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
| package.json | Add jose library dependency for JWT/JWS operations |
| src/server/routes.ts | New API routes for secrets, requests, grants, and injection |
| src/server/auth.ts | Session JWT authentication with login endpoint and bearer token validation |
| src/server/app.ts | Server initialization with session secret generation and service integration |
| src/server/index.ts | Wire service resolution into server startup |
| src/core/signed-grant.ts | JWS signing/verification using jose library (appears unused) |
| src/core/grant.ts | Grant signing implementation using raw Node.js crypto |
| src/core/grant-verifier.ts | Client-side JWS verification with public key fetching |
| src/core/key-manager.ts | Ed25519 keypair generation and persistence |
| src/core/service.ts | Service layer changes for async workflow, command binding, and grant status |
| src/core/request.ts | Request persistence with file-based storage |
| src/core/remote-service.ts | Client HTTP implementation with session auth and local injection |
| src/core/config.ts | New config options for bindCommand and sessionTtlMs |
| src/core/command-hash.ts | Command normalization and SHA-256 hashing for binding |
| src/core/workflow.ts | Forward command/commandHash to notification channels |
| src/cli/secrets.ts, src/cli/request.ts, src/cli/config.ts | CLI updates for async service resolution |
| src/channels/discord.ts | Display bound command in Discord approval requests |
| src/tests/* | Comprehensive test coverage for new functionality |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| requests: Service['requests'] = { | ||
| create: (secretUuids: string[], reason: string, taskRef: string, duration?: number) => | ||
| this.request<AccessRequest>('POST', '/api/requests', { | ||
| secretUuids, | ||
| reason, | ||
| taskRef, | ||
| duration, | ||
| }), | ||
| } |
There was a problem hiding this comment.
The RemoteService.requests.create() method does not accept or forward the 'command' parameter (line 157), even though the Service interface expects it as the 5th parameter. This breaks the entire command binding feature for client mode - clients can never send the command to the server for hashing and binding.
| if (status < 500) { | ||
| error.message = mapStatusToMessage(status) | ||
| } |
There was a problem hiding this comment.
The handleError function overwrites the original error message with a generic message for all 4xx errors (line 27). This loses important diagnostic information. For example, "Command does not match the approved command hash" becomes "Access denied", making debugging impossible. Consider preserving the original error message or only genericizing it for certain error types.
| // sessionSecret is regenerated per server instance — sessions intentionally | ||
| // invalidate on server restart (ephemeral by design). | ||
| const sessionSecret = randomBytes(32) |
There was a problem hiding this comment.
The sessionSecret is regenerated on every server restart (line 17), which invalidates all active session tokens. While the comment says this is intentional, it creates a poor user experience. Consider persisting the session secret to a file (with proper permissions) so sessions survive restarts, or document this behavior clearly for operators.
| this.runWorkflow(request).catch((err: unknown) => { | ||
| console.error('runWorkflow failed unexpectedly:', err) | ||
| request.status = 'denied' | ||
| this.deps.requestLog.save() | ||
| }) |
There was a problem hiding this comment.
The error handler in the fire-and-forget workflow (lines 137-141) sets request.status = 'denied' on any error, but this is misleading. If the error occurs after approval (e.g., in createGrant), the status should be 'error' not 'denied'. The catch block should check if the error happened before or after approval to set the correct status.
| if (!raw.grantId || !raw.requestId || !raw.secretUuids) { | ||
| throw new Error('Grant is missing required fields') | ||
| } | ||
|
|
||
| if (!raw.expiresAt) { | ||
| throw new Error('Grant is missing expiry claim') | ||
| } | ||
|
|
||
| const payload = raw as unknown as GrantJWSPayload | ||
|
|
There was a problem hiding this comment.
The GrantVerifier expects the JWS payload to have fields 'grantId', 'requestId', 'secretUuids', and 'expiresAt' (lines 71-77), but the server's signGrant function in grant.ts signs a completely different structure (the raw grant object with fields like 'id', 'used', 'revokedAt', etc.). The client will always fail to verify grants because it's looking for fields that don't exist in the signed payload.
| if (!raw.grantId || !raw.requestId || !raw.secretUuids) { | |
| throw new Error('Grant is missing required fields') | |
| } | |
| if (!raw.expiresAt) { | |
| throw new Error('Grant is missing expiry claim') | |
| } | |
| const payload = raw as unknown as GrantJWSPayload | |
| // Accept both "grantId" (new schema) and "id" (raw grant object) as the identifier. | |
| const grantId = | |
| typeof raw.grantId === 'string' | |
| ? raw.grantId | |
| : typeof raw.id === 'string' | |
| ? (raw.id as string) | |
| : undefined | |
| if (!grantId) { | |
| throw new Error('Grant is missing required field: grantId') | |
| } | |
| const requestId = typeof raw.requestId === 'string' ? (raw.requestId as string) : undefined | |
| if (!requestId) { | |
| throw new Error('Grant is missing required field: requestId') | |
| } | |
| const secretUuidsRaw = raw.secretUuids | |
| const secretUuids = | |
| Array.isArray(secretUuidsRaw) && secretUuidsRaw.every((v) => typeof v === 'string') | |
| ? (secretUuidsRaw as string[]) | |
| : undefined | |
| if (!secretUuids) { | |
| throw new Error('Grant is missing required field: secretUuids') | |
| } | |
| const expiresAt = | |
| typeof raw.expiresAt === 'string' ? (raw.expiresAt as string) : undefined | |
| if (!expiresAt) { | |
| throw new Error('Grant is missing expiry claim') | |
| } | |
| const commandHash = | |
| typeof raw.commandHash === 'string' ? (raw.commandHash as string) : undefined | |
| const payload: GrantJWSPayload = { | |
| grantId, | |
| requestId, | |
| secretUuids, | |
| expiresAt, | |
| ...(commandHash !== undefined ? { commandHash } : {}), | |
| } |
| response = await fetch(`${this.baseUrl}/api/keys/public`, { | ||
| headers: { Authorization: `Bearer ${this.authToken}` }, | ||
| signal: AbortSignal.timeout(30_000), | ||
| }) |
There was a problem hiding this comment.
The GrantVerifier tries to fetch the server's public key from /api/keys/public (line 29), but this route does not exist in the server implementation. Add the missing route to expose the server's public key so clients can verify grant signatures.
There was a problem hiding this comment.
@copilot open a new pull request to apply changes based on this feedback
src/core/command-hash.ts
Outdated
| import { createHash } from 'node:crypto' | ||
|
|
||
| export function normalizeCommand(cmd: string): string { | ||
| const normalized = cmd.trim().replace(/\s+/g, ' ').toLowerCase() |
There was a problem hiding this comment.
The normalizeCommand function converts commands to lowercase (line 4), which could break case-sensitive commands or file paths. For example, "cat MyFile.txt" becomes "cat myfile.txt", which would fail but still match the hash. Consider removing the lowercase transformation or documenting this limitation clearly.
| const normalized = cmd.trim().replace(/\s+/g, ' ').toLowerCase() | |
| const normalized = cmd.trim().replace(/\s+/g, ' ') |
There was a problem hiding this comment.
@copilot code review[agent] Is there any reason that we would want to normalize this or should we just remove normalization altogether? This is probably more likely used in the audit logs. We aren't executing the lowercase command, but maybe there's some sort of vulnerability if we allow them to run the lowercase command and then make an uppercase to access something else?
src/core/grant.ts
Outdated
| function signGrant(grant: AccessGrant, privateKey: KeyObject): string { | ||
| const header = Buffer.from(JSON.stringify({ alg: 'EdDSA' })).toString('base64url') | ||
| // Omit the jws field so the signature doesn't cover itself | ||
| // eslint-disable-next-line @typescript-eslint/no-unused-vars | ||
| const { jws: _, ...grantWithoutJws } = grant | ||
| const payload = Buffer.from(JSON.stringify(grantWithoutJws)).toString('base64url') | ||
| const signingInput = `${header}.${payload}` | ||
| const sigBytes = sign(null, Buffer.from(signingInput), privateKey) | ||
| return `${signingInput}.${sigBytes.toString('base64url')}` | ||
| } |
There was a problem hiding this comment.
The signGrant function in grant.ts signs the entire grant object (including fields like used, revokedAt, grantedAt, expiresAt, secretUuids, id, requestId), but the client-side GrantVerifier expects a JWS with standard JWT claims (jti, sub, iat, exp). The payload structures are incompatible: the server signs a different structure than what the client expects to verify. This will cause verification failures.
| async (request, reply) => { | ||
| const { secretUuids, reason, taskRef, duration } = request.body | ||
| const result = await service.requests | ||
| .create(secretUuids, reason, taskRef, duration) | ||
| .catch(handleError) |
There was a problem hiding this comment.
The POST /api/requests route does not accept or forward a 'command' field from the request body, but the service.requests.create() method expects it as the 5th parameter. When bindCommand is enabled on the server, command binding will never work because the command is never passed from the API route to the service layer.
iloom Session SummaryKey Themes:
Session Details (click to expand)Key Insights
Decisions Made
Challenges Resolved
Lessons Learned
|
|
@NoahCardoza I've opened a new pull request, #90, to work on those changes. Once the pull request is ready, I'll request review from you. |
|
@NoahCardoza I've opened a new pull request, #91, to work on those changes. Once the pull request is ready, I'll request review from you. |
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…d bypass Co-authored-by: NoahCardoza <10343470+NoahCardoza@users.noreply.github.com>
…g key Co-authored-by: NoahCardoza <10343470+NoahCardoza@users.noreply.github.com>
Use single backslashes instead of triple backslashes for escaping backticks and dollar signs in the node -e inline script. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
218993f to
2a4a179
Compare
Remove case normalization from `normalizeCommand` to close command-bypass vulnerability
Add GET /api/keys/public route for grant signature verification
Fixes #49
Epic: Phase 2 — Client-Server Mode with Password Auth + Signed Grants
Overview
Implement client-server mode as specified in
docs/encrypted-storage-spec.md(Section 4). The server centralizes policy, approval orchestration, and audit. The client runs commands locally and verifies cryptographically signed grants issued by the server.Depends on: Phase 1 (Epic #48) — encrypted store + local unlock + approval workflow
Goals
RemoteServiceHTTP client to the new server APIKey Design Decisions
joselibrary for JWS (Ed25519 or HMAC). Server signs, client verifies.server.authToken→ receives session JWT with TTL → uses session token for subsequent requests~/.2kc/server-grants.json(or SQLite in future)Out of Scope
Spec Reference
docs/encrypted-storage-spec.md— Sections 4 (Variant B: Client-Server Mode), 5 (Notifications)This PR was created automatically by iloom.